Chapter 26 · Testing, Emulator Suite, CI/CD, Index/Rules Deployment, and Schema Migration
Backward-Compatible Document Migrations, Dual Readers / Writers, Backfills, and Feature Flags
Evolve Firestore documents safely with expand–migrate–contract, dual readers/writers, resumable backfills, feature flags, mismatch metrics, and rollback.
1. AtlasMart needs a new product price model without breaking old clients
Schema v1 stores priceCents. Schema v2 needs a
structured price map with amount/currency so
AtlasMart can support multi-currency display. An irreversible
“rename every field, then deploy the app” migration creates a
compatibility cliff: old clients cannot read v2 and new clients
cannot read unbackfilled v1.
Firestore does not run a central schema migration transaction over the whole database. Safe evolution is an application protocol: expand → dual-read/write → backfill → verify → switch → contract.
- Design backward-compatible document versions and tolerate mixed-version collections.
- Use dual readers/writers only for the bounded compatibility window they solve.
- Build resumable, idempotent, throttled backfills with checkpoints and failure injection.
- Measure migration-version distribution and dual-read mismatch rate.
- Use feature flags and rollback criteria that do not require undoing every migrated document.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart keeps project ID
demo-atlasmart-firestore, Standard-edition Native
mode, database (default), Node.js 22+, Firebase
CLI course baseline 15.30.0, Firebase JavaScript
SDK 12.19.0, Firebase Admin Node SDK
14.4.0,
@firebase/rules-unit-testing 5.0.2, Firestore
emulator 127.0.0.1:8080, Auth emulator
127.0.0.1:9099, and Emulator UI
127.0.0.1:4000. Mandatory work uses a
demo- project and local emulators only. Cloud
deployment, IAM, App Check enforcement, billing, production
indexes, quotas, contention, Query Insights/Key Visualizer,
backup/PITR, and Enterprise/MongoDB-compatibility canaries are
explicitly optional managed checks, never silently inferred
from emulator success.
2. Versioned document contract
| Version | Fields | Reader behavior | Writer behavior |
|---|---|---|---|
| v1 |
priceCents, schemaVersion:1
|
Old client reads priceCents. |
Old client writes only v1 fields. |
| v2 compatibility window |
Both priceCents and
price.amountCents/currency,
schemaVersion:2
|
New reader prefers v2, validates against v1 when both exist. | New writer dual-writes both forms. |
| v2 contracted | Structured price only after old-client traffic is below policy threshold and Rules allow contraction. | v2-only reader. | v2-only writer. |
3. Dual reader with observable mismatch
export function readPrice(doc, metrics) { const v1 = Number.isInteger(doc.priceCents) ? doc.priceCents : null; const v2 = Number.isInteger(doc.price?.amountCents) ? doc.price.amountCents : null; if (v1 != null && v2 != null && v1 !== v2) { metrics.increment("price_dual_read_mismatch"); } if (v2 != null) return { amountCents: v2, currency: doc.price.currency ?? "USD", source: "v2" }; if (v1 != null) return { amountCents: v1, currency: "USD", source: "v1-fallback" }; throw new Error("product has no readable price");}
The mismatch counter is the migration correctness signal. A 100% v2 population is not enough if v1/v2 values disagree.
4. Dual writer: keep invariant in one trusted function
export function encodePrice({ amountCents, currency }) { if (!Number.isInteger(amountCents) || amountCents < 0) throw new Error("invalid amount"); return { priceCents: amountCents, // compatibility field price: { amountCents, currency }, // target model schemaVersion: 2 };}
For untrusted mobile/web writes, Rules must validate both representations during the window. For trusted Admin/server writes, application validation is mandatory because Admin bypasses Rules.
5. Backfill is a long-running distributed job, not a script with hope
| Property | Required mechanism |
|---|---|
| Idempotent | Re-running the same document produces the same v2 value. |
| Resumable | Checkpoint stores last processed document/cursor and migration version. |
| Throttled | Concurrency/rate is configurable; production rate is measured and bounded. |
| Observable | Counts: scanned, already-v2, migrated, failed, mismatch, retries. |
| Failure-tolerant | Per-document failures go to a durable error set; job can resume. |
| Rollback-aware | Application can keep reading v1/v2; backfill need not be reversed to disable the feature. |
6. Resumable local backfill with injected failure
import fs from "node:fs";import { db } from "./admin-emulator.mjs";const CHECKPOINT = "./tmp/price-v2-checkpoint.json";const state = fs.existsSync(CHECKPOINT) ? JSON.parse(fs.readFileSync(CHECKPOINT)) : { after: null, migrated: 0 };const failOn = process.env.FAIL_ON_ID ?? "";let q = db.collection("products").orderBy("__name__").limit(50);if (state.after) q = q.startAfter(state.after);for (;;) { const snap = await q.get(); if (snap.empty) break; for (const doc of snap.docs) { const d = doc.data(); if (doc.id === failOn) throw new Error(`injected failure at ${doc.id}`); if (d.schemaVersion !== 2) { await doc.ref.set({ price: { amountCents: d.priceCents, currency: "USD" }, schemaVersion: 2 }, { merge: true }); state.migrated++; } state.after = doc.id; fs.mkdirSync("./tmp", { recursive: true }); fs.writeFileSync(CHECKPOINT, JSON.stringify(state)); } q = db.collection("products").orderBy("__name__").startAfter(state.after).limit(50);}console.log(state);
Production should use an appropriate bulk mechanism and pacing policy, not this tiny per-document example. The mechanism—idempotency, checkpointing, observability, bounded rate—remains the same.
7. Migration dashboard: percentage is not enough
const snap = await db.collection("products").get();const out = { total:0, v1:0, v2:0, mismatches:0, unreadable:0 };for (const doc of snap.docs) { out.total++; const d = doc.data(); if (d.schemaVersion === 2) out.v2++; else out.v1++; const a = d.priceCents; const b = d.price?.amountCents; if (Number.isInteger(a) && Number.isInteger(b) && a !== b) out.mismatches++; if (!Number.isInteger(a) && !Number.isInteger(b)) out.unreadable++;}console.log(out);
Also track backfill error IDs and old-client traffic. The “contract” step is allowed only when mismatches/unreadable documents are zero under your data-quality policy and legacy-reader usage is below the explicitly accepted threshold.
8. Feature flag controls behavior, not data truth
export function priceMode(flags) { if (flags.forceV1) return "v1"; if (flags.v2CanaryPercent === 0) return "dual-read"; return "v2-preferred";}
If canary metrics degrade, switch readers back to v1/dual-read. This rollback does not delete v2 fields. Keeping additive data is safer than attempting a mass destructive reverse migration during an incident.
9. Controlled failure: irreversible rename
Wrong: backfill deletes
priceCents as soon as it writes price.
A deployment rollback then restores old code that cannot read
migrated products.
Repair: additive expansion, dual compatibility, observed backfill, traffic cutover, then a separately approved contract phase after the rollback window closes.
10. Mandatory lab
- Seed 12 v1 AtlasMart products.
- Deploy local dual-compatible Rules/readers/writers.
- Enable v2-preferred for a deterministic 25% canary cohort.
-
Run the backfill with
FAIL_ON_ID=t-red__p-007; assert a nonzero failure/checkpoint. - Remove injection and resume; assert no duplicate business effect.
-
Run audit and require
mismatches=0,unreadable=0. -
Exercise rollback by setting
forceV1=true; all documents must remain readable without reversing v2 fields.
Backfill throughput, contention, index amplification, billing, real canary p95/p99 and old-client population require managed telemetry. Emulator timing is not a production rate recommendation.
Production judgment and bridge
Schema migration is complete only when the compatibility window has an exit criterion and rollback has been rehearsed. Lesson 5 turns all previous chapters into one production deployment checklist with explicit evidence owners.
Knowledge check
- Why is additive migration safer than rename-in-place?
- What makes a backfill resumable?
- Why track dual-read mismatches?
- Why should rollback usually keep newly added v2 fields?
- What determines when the contract phase may begin?
Review the answers
1. Old and new clients can coexist while data migrates, preserving rollback.
2. A durable checkpoint/cursor plus idempotent per-document transformation.
3. Population percentage can look perfect even when the two representations disagree.
4. Destructive reverse migration adds risk; readers can fall back while additive fields remain harmless.
5. Verified data quality, backfill completion, acceptable old-client share, index/rule readiness, and an explicit closed rollback window.
Summary and next step
This lesson established the working contract for Backward-Compatible Document Migrations, Dual Readers/Writers, Backfills, and Feature Flags. Keep its edition/mode assumptions, trust boundary, verification evidence, and operational constraints explicit when reusing the pattern.
Next, continue to Create a Production Deployment Checklist Covering Rules, Indexes, Billing, Backups, Observability, and Rollback.
Authoritative references
- Firebase: Connect your app to the Cloud Firestore Emulator — demo projects, edition selection, Admin SDK environment variables, reset/import/export, Rules reports, and production differences.
-
Firebase: Install, configure and integrate Local Emulator
Suite
—
emulators:exec, import/export and CI workflow. - Firebase: Test Cloud Firestore Security Rules — emulator rules testing and CI execution.
-
Firebase: Build Security Rules unit tests
—
@firebase/rules-unit-testing, mocked auth, clearing state and disabled-rules fixture setup. - Firebase: Manage and deploy Security Rules — source control, local testing and selective deployment.
- Firebase: Manage indexes in Cloud Firestore — source-controlled index definitions and CLI deployment.
- Firebase: Cloud Firestore index definition reference — composite, field override and vector index JSON shapes.
- Firebase CLI reference — configured multi-database rules/indexes and deployment selectors.