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.

Advanced · 180–240 minutesschema evolution · dual read/write · backfill · feature flags · rollbackNode 22+ · Firebase CLI course baseline 15.30.0 · JS SDK 12.19.0 · Admin SDK 14.4.0 · rules-unit-testing 5.0.2Mandatory lab demo project + Emulator Suite/no-cost · managed staging/production canaries explicitly optionalLast reviewed: 17 September 2026

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.

Learning outcomes
  • 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.
Execution and safety note

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.

Chapter 26 reproducibility baseline · reviewed 17 September 2026

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

price-reader.mjs
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

price-writer.mjs
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

backfill-price.mjs
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

migration-audit.mjs
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

feature-flag.mjs
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

  1. Seed 12 v1 AtlasMart products.
  2. Deploy local dual-compatible Rules/readers/writers.
  3. Enable v2-preferred for a deterministic 25% canary cohort.
  4. Run the backfill with FAIL_ON_ID=t-red__p-007; assert a nonzero failure/checkpoint.
  5. Remove injection and resume; assert no duplicate business effect.
  6. Run audit and require mismatches=0, unreadable=0.
  7. Exercise rollback by setting forceV1=true; all documents must remain readable without reversing v2 fields.
Production-only checks

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

  1. Why is additive migration safer than rename-in-place?
  2. What makes a backfill resumable?
  3. Why track dual-read mismatches?
  4. Why should rollback usually keep newly added v2 fields?
  5. 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

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.