Chapter 19 · Firestore with MongoDB Compatibility: Drivers, MQL/BSON, Tools, and Serverless Differences
Port a Small MongoDB Application and Build Compatibility Tests Instead of Assuming Drop-In Equivalence
Port a small AtlasMart MongoDB repository layer behind a compatibility test suite that records supported behavior, intentional differences, performance evidence, rollback assumptions, and blockers for Chapter 20 migration planning.
1. AtlasMart capstone: port the repository layer, not the assumptions
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.
The team now takes a small source-MongoDB repository used by AtlasMart products and orders and points it at Firestore with MongoDB compatibility. The goal is not to change one URI and celebrate. The goal is to preserve a versioned contract: data types, query results, updates, transactions, indexes, errors, tools, authorization, observability, performance, and rollback. Every failed case becomes either a refactor task or a migration blocker.
AtlasMart keeps the course-wide project identity
demo-atlasmart-firestore. The mandatory
compatibility harness is local/no-cost and targets Node.js
22.23.2 LTS with mongodb@6.21.0, a
current Firestore-supported 6.x Node driver. Tool examples pin
mongosh 2.10.0, MongoDB Compass
1.50.0, Mongoose 8.24.4, Google
Cloud CLI 585.0.0, and retain Firebase CLI
15.30.0 for course continuity. The optional
managed database is atlasmart-mongo-lab,
Enterprise edition with MongoDB-compatible data access in an
explicitly chosen supported location such as
us-central1. Re-check location availability
before creation.
Unlike the Native-mode labs, current official documentation does not provide a Local Emulator Suite endpoint that emulates the MongoDB wire/API compatibility surface. Therefore the mandatory lab uses deterministic BSON/query/index/compatibility fixtures and executable contract tests without credentials. A real Firestore with MongoDB compatibility database is an optional verification layer: database creation requires an actual Google Cloud/Firebase project and billing enabled, and connection/authentication uses SCRAM or Google Cloud identity. No lesson fabricates a successful handshake, Query Explain result, billed unit count, p95/p99 latency, or tool connection that was not actually executed.
Learning outcomes
Wrap MongoDB access behind a repository boundary so compatibility differences do not leak across the application.
Build a source-versus-destination contract suite with exact fixtures and expected result IDs/state.
Separate functional parity, semantic parity, performance, security, operations, and cost as independent gates.
Generate a compatibility/blocker report that can drive Chapter 20 migration planning.
Design a rollback/exit path instead of making the first successful query irreversible.
2. Repository boundary: the port should be reversible
AtlasMart's HTTP/controller layer should not know whether a
repository uses source MongoDB or Firestore's MongoDB-compatible
endpoint. It asks for business operations such as
listSellerProducts, reserveStock, or
appendOrderEvent. The adapter encapsulates
driver/session/query details. That structure makes dual-run
testing and rollback possible.
export class ProductRepository { constructor(db) { this.c = db.collection("products"); } listSellerProducts({tenantId, sellerId, limit=20}) { return this.c.find({tenantId, sellerId, published:true}) .sort({price:1, _id:1}).limit(limit).toArray(); } async decrementStock({_id, qty}) { const r = await this.c.findOneAndUpdate( {_id, stock:{$gte:qty}}, {$inc:{stock:-qty}}, {returnDocument:"after"} ); return r; }}
3. Compatibility suite: compare business evidence, not raw implementation
The same test vectors run against two adapters: a known source MongoDB environment and the isolated Firestore MongoDB-compatible destination. The suite compares canonicalized results and invariant outcomes. It intentionally does not require identical explain plans, error text, topology metadata, or operation latency—those belong to separate gates.
[ {"id":"Q01","op":"seller-products","input":{"tenantId":"tenant-a","sellerId":"seller-a"},"expectedIds":["p-1002","p-1006","p-1001"]}, {"id":"Q02","op":"low-stock","input":{"tenantId":"tenant-a","threshold":5},"expectedIds":["p-1002","p-1004"]}, {"id":"W01","op":"decrement-stock","input":{"_id":"p-1001","qty":2},"expectedStock":6}, {"id":"W02","op":"decrement-stock","input":{"_id":"p-1004","qty":2},"expected":"REJECT_NO_STOCK"}, {"id":"N01","op":"mapReduce","expected":"DESTINATION_UNSUPPORTED"}]
4. Canonicalization prevents false mismatches
Drivers can represent BSON types differently in JavaScript objects. Before comparing source and destination, normalize ObjectId/Long/Decimal/Date/Binary into a stable extended representation, sort arrays only when application semantics define them as sets, and ignore server metadata that is not part of the business contract. Do not normalize away real semantic differences.
function canonical(v) { if (v?.constructor?.name === "ObjectId") return {$oid:v.toHexString()}; if (v instanceof Date) return {$date:v.toISOString()}; if (Array.isArray(v)) return v.map(canonical); if (v && typeof v === "object") return Object.fromEntries( Object.keys(v).sort().map(k => [k, canonical(v[k])]) ); return v;}
5. Six independent migration gates
| Gate | What passes it | Example failure |
|---|---|---|
| Functional | Required CRUD/query/aggregation returns correct state | Unsupported operator or wrong result set |
| Semantic | Transactions, ordering, numeric/date behavior, retries match business requirements | Natural-order dependency or snapshot write-skew risk |
| Security | IAM/database identity + application tenant authorization enforce least privilege | Broad backend principal leaks cross-tenant rows |
| Performance | Bounded p50/p95/p99, scans, bytes/read units meet SLO in target region | Unindexed scan passes functionally but costs/latency fail |
| Operational | Monitoring, explain, backup/recovery, on-call procedures exist | Runbook depends on serverStatus/replica-set commands |
| Cost/exit | Unit/storage/network/recovery cost understood; rollback path tested | No source rollback or unexpected scan/index cost |
6. Mandatory no-cost rehearsal: local oracle + compatibility blocker report
[ {"_id":"p-1001","sellerId":"seller-a","tenantId":"tenant-a","name":"Trail Camera","category":"cameras","price":99.0,"stock":8,"rating":4.6,"tags":["outdoor","camera"],"published":true}, {"_id":"p-1002","sellerId":"seller-a","tenantId":"tenant-a","name":"USB-C Hub","category":"accessories","price":49.0,"stock":3,"rating":4.4,"tags":["usb-c","desk"],"published":true}, {"_id":"p-1003","sellerId":"seller-b","tenantId":"tenant-a","name":"Temp Sensor","category":"sensors","price":39.0,"stock":12,"rating":4.7,"tags":["iot","sensor"],"published":true}, {"_id":"p-1004","sellerId":"seller-b","tenantId":"tenant-a","name":"Edge Gateway","category":"gateways","price":149.0,"stock":1,"rating":4.2,"tags":["edge","iot"],"published":true}, {"_id":"p-1005","sellerId":"seller-c","tenantId":"tenant-b","name":"PoE Camera","category":"cameras","price":199.0,"stock":5,"rating":4.8,"tags":["poe","camera"],"published":true}, {"_id":"p-1006","sellerId":"seller-a","tenantId":"tenant-a","name":"Bench PSU","category":"power","price":89.0,"stock":9,"rating":4.5,"tags":["bench","power"],"published":true}]
{ "target": "firestore-enterprise-mongodb-compat-2026-09-17", "driver": "mongodb@6.21.0", "checks": { "find": "supported", "insert": "supported", "update": "supported", "delete": "supported", "aggregate": "supported", "transactions": "supported-with-different-defaults", "retryableWrites": "unsupported-disable-in-uri", "mapReduce": "unsupported", "gridFS": "unsupported", "wildcardIndexes": "unsupported", "vectorIndexMongoAPI": "unsupported", "textSearch": "preview", "geoNear": "preview", "changeStreams": "preview" }}
import assert from "node:assert/strict";import fs from "node:fs";const products = JSON.parse(fs.readFileSync("atlasmart-products.json", "utf8"));const matrix = JSON.parse(fs.readFileSync("compatibility-matrix.json", "utf8"));assert.equal(products.length, 6);assert.equal(new Set(products.map(p => p._id)).size, 6);assert.deepEqual( products.filter(p => p.tenantId === "tenant-a" && p.stock < 5).map(p => p._id).sort(), ["p-1002", "p-1004"]);assert.equal(products.filter(p => p.category === "cameras").length, 2);assert.equal(matrix.checks.retryableWrites, "unsupported-disable-in-uri");assert.equal(matrix.checks.changeStreams, "preview");console.log(JSON.stringify({fixture:"PASS", products:6, lowStock:["p-1002","p-1004"]}));
node compatibility-local.mjs
import fs from "node:fs";const matrix = JSON.parse(fs.readFileSync("compatibility-matrix.json","utf8"));const blockers = [];for (const [feature,state] of Object.entries(matrix.checks)) { if (state === "unsupported") blockers.push({feature, severity:"BLOCKER_UNTIL_REFACTORED"}); if (state === "preview") blockers.push({feature, severity:"REQUIRES_PRE_GA_RISK_DECISION"});}console.log(JSON.stringify({target:matrix.target, blockers}, null, 2));
The local report intentionally labels unsupported and Preview features. A migration plan that hides those rows is not ready.
7. Optional managed compatibility runner
import { MongoClient } from "mongodb";import assert from "node:assert/strict";const client = new MongoClient(process.env.ATLASMART_MONGO_URI);await client.connect();const c = client.db("atlasmart-mongo-lab").collection("compat_products");const ids = (await c.find({tenantId:"tenant-a", sellerId:"seller-a", published:true}) .sort({price:1, _id:1}).limit(20).toArray()).map(x=>x._id);assert.deepEqual(ids,["p-1002","p-1006","p-1001"]);const low = (await c.find({tenantId:"tenant-a", stock:{$lt:5}}) .sort({_id:1}).toArray()).map(x=>x._id);assert.deepEqual(low,["p-1002","p-1004"]);console.log("managed functional contract PASS");await client.close();
Run this only after seeding the exact six-document fixture into an isolated database. Record database ID/UID, location, driver/tool versions, dataset hash, index list, and test timestamp. Never treat a result from a different fixture as comparable evidence.
8. Performance test: measure distributions, not averages
After functional parity, run a bounded test with declared concurrency, warmup, operation mix, region, index state, returned document sizes, and request count. Record p50/p95/p99, errors, Query Explain bytes/read units for representative queries, and network/client timing separately. Do not load-test a production project or infer production capacity from the local harness.
{ "environment": "OPTIONAL_MANAGED_ISOLATED", "databaseId": "atlasmart-mongo-lab", "location": "RECORD_ACTUAL", "driver": "mongodb@6.21.0", "fixtureHash": "RECORD_ACTUAL", "indexes": ["RECORD_ACTUAL"], "concurrency": "RECORD_ACTUAL", "warmupRequests": "RECORD_ACTUAL", "measured": {"p50Ms":null,"p95Ms":null,"p99Ms":null,"errors":null,"readUnits":null}}
Nulls are intentional until the test runs. A course should never invent “representative” percentile numbers.
9. Security regression: two tenants, one backend principal
Because the service principal may have broad database access, the repository must bind a trusted tenant ID derived from the authenticated application caller, not from an arbitrary request field. The test creates tenant-a and tenant-b documents, asks a tenant-a caller for tenant-b, and expects application authorization to reject before the query is sent.
const caller = {uid:"user-a", tenantId:"tenant-a", sellerIds:["seller-a"]};assert.throws( () => authorizeSeller(caller, "seller-c"), /FORBIDDEN/);// Also assert repository methods never accept raw client tenantId without authorization binding.
10. Rollback and exit path
A good compatibility test suite is also an exit tool. Keep source schemas and query contracts documented, avoid destination-only behavior in the first cutover unless intentionally accepted, preserve export/replay procedures, and define the point after which source writes stop. Chapter 20 will add Datastream/Dataflow migration, freeze/cutover, lag/error monitoring, and rollback orchestration.
status: NOT_YET_CUTOVERfunctional_contract: PASS_OR_FAILsemantic_contract: PASS_OR_FAILsecurity_contract: PASS_OR_FAILperformance_contract: PASS_OR_FAILoperational_contract: PASS_OR_FAILcost_model_reviewed: falseunsupported_features: []preview_dependencies: []rollback_owner: "SET_OWNER"source_write_freeze_plan: "Chapter20"
11. Deliberately wrong port: URI swap with no compatibility suite
The anti-pattern changes MONGODB_URI, sees the
homepage load, then declares migration complete. It misses rare
BSON values, background jobs, admin scripts, transaction
anomalies, unindexed scans, unsupported plugins, and recovery
procedures. The repair is staged gates with exact evidence and a
blocker ledger.
12. Final Chapter 19 verification checklist
- Connection options and supported driver major are pinned.
- BSON/_id/schema constraints are tested against real source data samples.
- All required CRUD/query/aggregation/update operators have executable contracts.
- Tools/ODMs have their own compatibility ledger.
- Transactions/read concerns/write concerns and retry behavior match business invariants.
- Indexes are derived from query inventory and Explain evidence.
- Preview features have an explicit risk/fallback decision.
- Security tests cover cross-tenant attempts.
- Performance evidence reports distributions and target environment.
- Migration blockers and rollback/exit path are documented.
13. Bridge to Chapter 20
Chapter 19 answered “Can AtlasMart's workload semantics run here?” Chapter 20 answers “How do we move the real data and traffic safely?” The compatibility suite becomes the acceptance harness for inventory, transformation, Datastream/Dataflow migration, cutover, validation, and rollback.
Knowledge check
- Why hide the driver behind a repository boundary?
- Should source and destination Explain plans be identical?
- Why are functional and semantic gates separate?
- What should happen to unsupported and Preview dependencies?
- What does Chapter 20 add?
Review the answers
1. It localizes destination-specific behavior and makes source/destination dual-run and rollback practical.
2. No. Compare business results and separately evaluate target performance/cost; the execution engines differ.
3. A query can return the expected row in a simple case while transaction, ordering, retry, or type semantics still violate business requirements.
4. They must appear explicitly in the blocker/risk report with an owner and mitigation/decision.
5. Actual migration planning/execution: source inventory, data movement, change capture, cutover, validation, lag/error monitoring, and rollback.
Summary and next step
This lesson established the working contract for Port a Small MongoDB Application and Build Compatibility Tests Instead of Assuming Drop-In Equivalence. Keep its edition/mode assumptions, trust boundary, verification evidence, and operational constraints explicit when reusing the pattern.
Next, continue to Inventory MongoDB Features, Drivers, Data Types, Indexes, Aggregations, Transactions, and Operational Dependencies.
Authoritative references
- Google Cloud · Firestore with MongoDB compatibility overview
- Google Cloud · Authenticate and connect to a database
- Google Cloud · Supported BSON types, drivers, and third-party tools
- Google Cloud · Behavior differences from MongoDB
- Google Cloud · Supported MongoDB 8.0 feature matrix
- Google Cloud · MongoDB-compatible indexing overview
- Google Cloud · Query Explain for MongoDB-compatible operations
- Google Cloud · Quotas and limits
- Google Cloud · MongoDB compatibility release notes
- Google Cloud · Text search (Preview)
- Google Cloud · Geospatial search (Preview)
- MongoDB · mongosh release notes