Chapter 04 · CRUD with Client and Server SDKs: Reads, Writes, Updates, Deletes, Preconditions, and Converters

Data Converters / Typed Models, Serialization, Validation, and Backward-Compatible Schema Evolution

Turn schemaless storage into an explicit application contract with Firestore converters, runtime validation, serialization rules, schemaVersion fields, tolerant readers, and staged backward-compatible evolution.

Beginner → Advanced105–135 minutesAtlasMart emulator-first CRUD labFirebase CLI 15.30.0 · Web SDK 12.19.0 · Admin Node 14.4.0 · Node.js 22+Firestore Standard Native Core semantics unless explicitly labeled EnterpriseLast reviewed: September 2026

Learning outcomes

Firestore is schemaless at the database level, but AtlasMart cannot be schema-less at the application level. Old clients, background jobs, Web SDK code, Admin SDK code, migrations, and tests need a shared interpretation of fields and versions. A TypeScript interface alone is not enough because runtime data can still be malformed or older than the current build. Converters are serialization boundaries; validation and migration policy turn them into a durable contract.

01

Explain what a FirestoreDataConverter does and what it cannot validate automatically.

02

Separate application model types from stored Firestore document shape.

03

Handle optional/missing/null fields and timestamps explicitly across Web and server boundaries.

04

Design tolerant readers and staged writers for backward-compatible schema evolution.

05

Prove a migration with mixed-version fixtures instead of rewriting all documents blindly.

Chapter 04 baseline reviewed 15 September 2026

The lab continues Chapters 01–03 without changing the environment: project demo-atlasmart-firestore; Firestore emulator 127.0.0.1:8080; Auth emulator 127.0.0.1:9099; Emulator UI 127.0.0.1:4000; Firebase CLI 15.30.0; Firebase JavaScript SDK 12.19.0; Firebase Admin Node.js SDK 14.4.0 (which currently carries @google-cloud/firestore 9.1.0); Node.js 22 or newer. The default teaching surface is Firestore Standard edition, Native mode, Core operations, default database (default), emulator-first. Enterprise/Pipeline/MongoDB-compatibility behavior is labeled rather than generalized.

Trust, execution, and evidence note

Browser/mobile SDK requests are untrusted requests evaluated by Firebase Authentication context plus Cloud Firestore Security Rules. Admin/server client libraries are privileged and bypass Firestore Security Rules; production access is governed by IAM/ADC and by authorization logic in your server application. The emulator demonstrates these trust paths and CRUD semantics, but it does not prove production IAM, regional latency, billing, quotas, contention, or service availability. Expected failures are described by invariant/error family rather than fabricated captured output.

1. Converter is a serialization adapter, not a database schema

On the Web SDK, withConverter() attaches a FirestoreDataConverter to a reference/query. toFirestore maps an application object to stored fields; fromFirestore maps a snapshot back to an application model. That improves consistency and TypeScript ergonomics, but a converter does not stop another client, Admin job, import, or older version from writing incompatible data. Runtime validation still matters at every untrusted boundary.

TypeScript · separate app model from stored document
import { FirestoreDataConverter, QueryDocumentSnapshot, SnapshotOptions, Timestamp } from "firebase/firestore";type Product = {  id: string;  name: string;  priceCents: number;  published: boolean;  updatedAt: Date | null;};type ProductDocV2 = {  name: string;  priceCents: number;  status: "draft" | "published";  schemaVersion: 2;  updatedAt?: Timestamp;};const productConverter: FirestoreDataConverter<Product, ProductDocV2> = {  toFirestore(product) {    return {      name: product.name,      priceCents: product.priceCents,      status: product.published ? "published" : "draft",      schemaVersion: 2,      ...(product.updatedAt ? { updatedAt: Timestamp.fromDate(product.updatedAt) } : {})    };  },  fromFirestore(snapshot: QueryDocumentSnapshot<ProductDocV2>, options: SnapshotOptions) {    const raw = snapshot.data(options);    return {      id: snapshot.id,      name: raw.name,      priceCents: raw.priceCents,      published: raw.status === "published",      updatedAt: raw.updatedAt?.toDate() ?? null    };  }};

2. Runtime validation belongs before trusted writes and after untrusted reads

TypeScript types disappear at runtime. A JSON request can contain priceCents: "12990", schemaVersion: 999, a negative price, or a server-managed field. A trusted service that accepts such JSON and passes it to Admin SDK has bypassed both compile-time assumptions and Firestore Rules. Validate the request using an explicit schema/validator and construct the stored object from accepted fields rather than spreading arbitrary input.

Trusted service · narrow runtime validator without leaking arbitrary fields
function parseProductPatch(input) {  if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid-object");  const out = {};  if ("name" in input) {    if (typeof input.name !== "string" || input.name.trim().length < 1 || input.name.length > 120)      throw new Error("invalid-name");    out.name = input.name.trim();  }  if ("priceCents" in input) {    if (!Number.isSafeInteger(input.priceCents) || input.priceCents < 0)      throw new Error("invalid-price");    out.priceCents = input.priceCents;  }  if (Object.keys(out).length === 0) throw new Error("empty-patch");  return out;}

The exact business bounds belong in a shared contract and tests, not copied magic numbers. The important mechanism is allow-list construction: client input cannot silently inject isStaff, sellerId, schemaVersion, or audit fields.

3. Optional, missing, null, undefined, and sentinel values must be policy decisions

A serializer should state what each absence means. undefined is a JavaScript runtime value and is not a portable Firestore domain value. A missing stored field can mean “not applicable yet” or “older schema.” Null can mean explicit no-value. A server timestamp sentinel is not a final timestamp value before commit. A delete sentinel is an instruction, not application data. Converters should not casually blur these categories.

Input/state Recommended contract question
Missing field Is it optional, legacy, or invalid for this schemaVersion?
null Does null have an explicit business meaning?
JavaScript undefined Reject or intentionally omit before Firestore serialization; do not make behavior accidental.
serverTimestamp() Is this a write transform owned by persistence code rather than the domain object?
deleteField() Is removing the field semantically different from storing null?

4. Backward-compatible evolution: expand, read both, migrate, contract

Assume Chapter 03 used published: true/false in some product documents, while the new model uses status: "draft" | "published". A risky migration changes all writers and readers at once, then bulk rewrites production. A safer staged evolution is:

  1. Expand readers: accept both v1 and v2 shapes.
  2. Change writers: write v2 and explicit schemaVersion: 2; optionally dual-write only if the migration plan requires it.
  3. Backfill deterministically: migrate old documents in bounded batches with checkpoints and idempotent transforms.
  4. Observe: count remaining v1 documents and read-path fallback usage.
  5. Contract: remove old field handling only after evidence shows old writers/readers are gone and rollback needs are addressed.
Tolerant reader · v1/v2 product normalization
function normalizeProduct(snapshot) {  const d = snapshot.data();  if (d.schemaVersion === 2) {    if (d.status !== "draft" && d.status !== "published") throw new Error("invalid-v2-status");    return { id: snapshot.id, name: d.name, priceCents: d.priceCents, published: d.status === "published" };  }  if (d.schemaVersion === 1 || d.schemaVersion === undefined) {    if (typeof d.published !== "boolean") throw new Error("invalid-v1-published");    return { id: snapshot.id, name: d.name, priceCents: d.priceCents, published: d.published };  }  throw new Error(`unsupported-schema-version:${d.schemaVersion}`);}

5. Web and Admin SDKs do not erase platform serialization differences

Both SDK families understand Firestore types, but their application objects and trust boundaries differ. Web code may use a converter primarily for typed UI/domain objects. Server code may use Admin Firestore sentinels and timestamps and should validate external payloads independently. If data crosses HTTP/JSON, Firestore Timestamp, DocumentReference, bytes, GeoPoints, and sentinels need an explicit wire representation; JSON.stringify is not a schema protocol.

Portability rule

Store domain facts in Firestore-native types, but define how those facts cross each application boundary. A Firestore reference is not a server-side join, a Timestamp is not automatically an ISO string, and a converter on one client does not force other writers to use the same shape.

6. Hands-on lab: mixed-version fixtures and converter contract tests

Continue the same local AtlasMart workspace. Keep firebase emulators:start --only auth,firestore running. Admin/server examples set FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 and GCLOUD_PROJECT=demo-atlasmart-firestore; browser examples connect the modular Web SDK explicitly to the Firestore/Auth emulators. Use only synthetic Chapter 04 documents such as profiles/u-alice, products/p-1001, crudOperations/<operationId>, and named test users. Cleanup must target only those paths.

fixture matrix · reader/writer compatibility
products/p-v1: {name, priceCents, published, schemaVersion:1}products/p-v1legacy: {name, priceCents, published}  # legacy missing versionproducts/p-v2: {name, priceCents, status:"published", schemaVersion:2}products/p-bad: {name, priceCents:"12990", status:"live", schemaVersion:2}Acceptance:- tolerant reader normalizes valid v1/v2- writer emits only v2- invalid runtime shape is rejected, not silently coerced- server-managed fields are derived by trusted code- backfill can be safely re-run without changing already-v2 documents

7. Deliberately wrong approach: “TypeScript means Firestore has a schema”

A developer asserts snapshot.data() as ProductDocV2 and assumes all documents now satisfy the interface. The cast changes only the compiler's belief. It does not validate stored data, old clients, imports, or Admin writes. The repair is to validate/normalize runtime data, version the stored contract, test mixed versions, and design staged evolution. Converters improve discipline; they do not become a database constraint.

Knowledge check

Check your understanding

  1. What does a Firestore converter provide, and what does it not provide?
  2. Why is a TypeScript interface insufficient for trusted server input validation?
  3. How should a reader handle a known older schema during a staged migration?
  4. Why should server-managed fields be constructed by trusted code instead of spread from request JSON?
  5. What is the expand/migrate/contract idea protecting you from?
Review the answers

1. It centralizes serialization/deserialization and can provide typed references; it does not force every writer or existing document to match the model.

2. Type information is erased at runtime and arbitrary JSON can violate the compile-time shape. The server must validate actual values.

3. Recognize the older version explicitly, validate it, normalize it into the current application model, and measure remaining fallback usage.

4. It prevents privilege/ownership/schema/audit fields from being injected by an untrusted caller and makes the mutation contract auditable.

5. It avoids a flag-day migration in which old readers/writers and new document shapes become incompatible with no safe observation or rollback window.

Summary and next step

Converters, validation, and schema versions turn Firestore's flexible document storage into an explicit application contract. Readers tolerate planned history; writers emit the current shape; trusted services validate runtime input and derive privileged fields. The final lesson combines these rules with retries, conditional writes, idempotency, and observability.

Next: Build an Idempotent CRUD Service and Verify Error Handling, Retry, Preconditions, and Observability.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.