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.
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.
Explain what a FirestoreDataConverter does and what it cannot validate automatically.
Separate application model types from stored Firestore document shape.
Handle optional/missing/null fields and timestamps explicitly across Web and server boundaries.
Design tolerant readers and staged writers for backward-compatible schema evolution.
Prove a migration with mixed-version fixtures instead of rewriting all documents blindly.
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.
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.
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.
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:
- Expand readers: accept both v1 and v2 shapes.
-
Change writers: write v2 and explicit
schemaVersion: 2; optionally dual-write only if the migration plan requires it. - Backfill deterministically: migrate old documents in bounded batches with checkpoints and idempotent transforms.
- Observe: count remaining v1 documents and read-path fallback usage.
- Contract: remove old field handling only after evidence shows old writers/readers are gone and rollback needs are addressed.
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.
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.
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
- What does a Firestore converter provide, and what does it not provide?
- Why is a TypeScript interface insufficient for trusted server input validation?
- How should a reader handle a known older schema during a staged migration?
- Why should server-managed fields be constructed by trusted code instead of spread from request JSON?
- 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
- Add data to Cloud Firestore — Official set, merge, update, nested field, server timestamp, array transform, and increment semantics.
- Get data with Cloud Firestore — Document snapshots, existence checks, and custom-object conversion.
- Delete data from Cloud Firestore — Document deletion, field deletion, collection deletion, and subcollection caveats.
- Cloud Firestore data model — Path hierarchy and the warning that deleting a document does not delete its subcollections.
- Secure data in Cloud Firestore — Mobile/web Security Rules versus server IAM trust paths.
- Security Rules conditions — Request/resource validation and explicit server-library bypass guidance.
- Test Security Rules with Emulator Suite — Deterministic local Rules testing.
- Transactions and batched writes — Retry behavior and when read-dependent writes require transactions.
- Firestore REST Precondition — Conditional existence/update-time semantics.
- Set up the Firebase Admin SDK — Privileged server setup, ADC, and current Node.js runtime requirement.
- Firebase JavaScript SDK release notes — Current Web SDK baseline.
- Firebase Admin Node.js SDK release notes — Current Admin/Firestore dependency baseline.
- Firebase CLI release notes — Current CLI baseline.