Data migrations (pnpm migrate)
AI-generated content
This document was generated by an AI assistant. Verify accuracy before relying on the details.
pnpm migrate replaces "run this mongosh script by hand on each environment" for the MongoDB changes the code depends on — indexes first. A migration is a file packages/data/migrations/NNNN-kebab-name.mjs with an up and a down; umzug 3.8.3 runs the pending ones in order and records each one in adb-system._migrations with the sha256 of its file, so every environment can say which migrations it has. up and down print a dry-run plan unless --apply; one runner at a time holds a lock; assert exits 1 while anything is pending, and pnpm infra:setup <slug> runs it before applying an environment. The first four migrations apply the indexes the money and sync guards rely on (BUGS.md FL-EA51-1); on staging they were applied and verified on 2026-10-09. Production is an environment of its own since 2026-10-10 (engineer decision): PROD_MONGO_URI only, --i-know-this-is-prod plus the host typed back on a terminal, cutover-only migrations behind --cutover, read-only preflights, and an --apply that must repeat the hash of the plan its dry-run printed — see Production.
At a glance
| Fact | Value |
|---|---|
| Command | pnpm migrate <status|up|down|assert> <local|staging|production> [--to <name> | --step <n> | --only <names>] [--apply] [--staging-env <file>] [--gate]; production adds --i-know-this-is-prod, --cutover, --plan <hash> |
| Migrations | packages/data/migrations/NNNN-kebab-name.mjs; the contract is in support/migration.mjs, the index helpers in support/indexes.mjs, each migration's production safety in support/prod-safety.mjs (0001–0010) or its own prodSafety export (0011 on) |
| Runner | tools/scripts/data-migrations.mjs (wiring) → tools/scripts/lib/data-migrations/ (umzug 3.8.3 + the mongodb driver) |
| Tracking | adb-system._migrations: one document per applied migration — migrationName, checksum (sha256 of the file, CRLF read as LF), appliedAt, appliedBy, record (what up returned); unique on migrationName |
| Lock | adb-system._migrations_lock: one document while a runner applies; taken atomically, server clock, 10-minute TTL renewed every minute, released in a finally |
| Default | dry-run: the plan, nothing written. --apply writes |
local | MONGO_URI, else the local stack from the host, mongodb://localhost:27018/?directConnection=true |
staging | MONGO_URI, else STAGING_MONGO_URI from the gitignored apps/ui/e2e/.auth/staging.env (--staging-env <file> from a worktree) |
production | PROD_MONGO_URI only (never MONGO_URI, never a file), and it must classify as production; every command needs --i-know-this-is-prod and the host typed back on a terminal, before a connection is opened — Production |
Production-looking host for local / staging | refused before a connection is opened |
| Exit codes | 0 done / in sync · 1 a migration failed, the run was refused (drift, lock held; production: a refused selection, preflight or plan) or assert found the database not in sync · 2 the tool could not run (usage, refused target or confirmation, unreachable cluster) |
| Deploy gate | pnpm migrate assert <env>; pnpm infra:setup <slug> runs it first (CLOUDFLARE_ENVIRONMENTS.md § Lifecycle) |
| Tests | pnpm --filter @adb/scripts test and pnpm --filter @adb/data test; the real-mongod run is opt-in: MIGRATE_INT_MONGO_URI=<local URI> npx vitest run --no-file-parallelism test/data-migrations.int.test.mjs in tools/scripts |
The migrations
| Migration | What up does | Guard | down | Production |
|---|---|---|---|---|
0001-user-preferences-unique-index | ensureUserPreferencesIndex() (packages/data/persons/userPreference/indexes.mjs): unique { organisationId: 1, userId: 1 } organisationId_1_userId_1 on adb-persons.user_preferences — the build of adb-infrastructure/mongodb-atlas/data-scripts/user-preferences-index.mjs | a pair held by two documents: lists the pairs, builds nothing | drops the index if this migration created it | online-safe |
0002-accounting-indexes | ensureAccountingIndexes(): accounting_event.unique_1, accounting_sequence.uniq_accounting_sequence_org_year, invoices.unique_1_live | a unique build that fails drops back what the run built | drops only what it created | cutover-only |
0003-sync-indexes | ensureSyncIndexes(): sync_organisationId_updatedAt_id on adb-persons.persons, adb-contracts.contracts, adb-parts.parts | — | drops only what it created | online-safe |
0004-payments-unique-index | ensurePaymentUniqueIndex(): unique { organisationId: 1, unique: 1 } partial uniq_payments_org_unique on adb-accounting.payments | first the duplicate count of apps/ui/docs/parity/gaps.md FL-EA51 (with the _ids): any pair → lists them, builds nothing, not recorded | drops the index if this migration created it | cutover-only |
0005-accounting-entry-transaction-indexes | ensureEntryTransactionIndexes(): { transactionId: 1, transactionLineNumber: 1 } and { organisationId: 1, transactionId: 1 } on adb-accounting.domain_object_accounting_entry, under MongoDB's generated names (transactionId_1_transactionLineNumber_1, organisationId_1_transactionId_1) — the journal list, its count and the read of one transaction's lines scanned the whole collection (Atlas Performance Advisor on adb-stage, 2026-10-09) | — (not unique) | drops only what it created | online-safe |
0006-accounting-entry-latest-call-index | ensureEntryLatestCallIndex(): { "account._id": 1, eventType: 1, postingDate: -1 } on adb-accounting.domain_object_accounting_entry, under MongoDB's generated name (account._id_1_eventType_1_postingDate_-1) — the latest call for rent / charges per lease (tenants list « Loyer appelé » / « Charges », #717) fetched each account's whole history through account._id_1 (engineer decision 2026-10-10) | — (not unique) | drops only what it created | online-safe |
0007-accounting-sequence-voids-index | ensureSequenceVoidUniqueIndex(): unique { organisationId: 1, year: 1, sequence: 1 } uniq_accounting_sequence_voids_org_year_sequence on adb-accounting.accounting_sequence_voids, the FEC #3 void record (one document per JE number a posting drew and never used) | — (a new collection) | drops only what it created | online-safe |
0008-accounting-read-indexes | ensureAccountingReadIndexes(): seven read indexes under fixed read_* names — invoices.read_invoices_parentId and six on domain_object_accounting_entry (account statement, account-short target, CRG owner/RDC, CRG stamps crgDescriptor.upFrom (partial), journal order, account target) — brought in by the resync of claude/adb-ui-react (#782); an index with the same KEY under another name (hand-made on staging) is left alone and never recorded | — (not unique) | drops only what it created | online-safe |
0009-accounting-entry-piece-reference-index | ensureEntryPieceReferenceIndex(): { organisationId: 1, pieceReference: 1 } read_entries_org_pieceReference on adb-accounting.domain_object_accounting_entry — GET /accounting/entries?q={"pieceReference": …} (the SPI import's lookup) scanned the organisation's lines, 3–5 s a call, and saturated the broker on staging (Sentry ADB-MONGO-BROKER-8 / ADB-ACCOUNTING-1, 2026-10-10 17:37–19:48 UTC); Atlas Performance Advisor ranks it second on that collection (2026-10-11). Same rule as 0008 for a hand-made index with the same key | — (not unique) | drops only what it created | online-safe |
0010-fee-invoice-supplier-names | The first DATA migration (engineer decision 2026-10-11, the repair of what #815 stopped writing). (1) Cloudflare-written fee invoices (no _class, origin CALL_FOR_RENT / SETTLEMENT / CONTRACT_SIGNED): each bare person target (supplier, receiver, descriptor.supplier/owner/tenant with a targetId and no properties) gets the writer's personTarget (shared mapping accounting/invoice/personTargets.mjs, persons read with persons.person.findByIdList, ids stored as invoice.save stores them). (2) The exact text « Paiement factures fournisseur [null] » (two spaces) becomes [<NAME>] on payments.description, domain_object_accounting_entry.description (via the payment of the line's transactionId) and invoices.settledAmounts[].description (via the settled payment). $set + updatedAt / _v stamps only, filters re-assert the repaired shape (a re-run writes nothing); unresolved persons, blank names, other texts and _class documents are counted and left alone; accounting_event is reported, never rewritten; FEC exports already produced keep « [null] » | — | irreversible (restore from the Atlas snapshot taken before --apply) | cutover-only |
0011-accounting-journal-indexes | ensureAccountingJournalIndexes(): on adb-accounting.domain_object_accounting_entry, three journal-list indexes PARTIAL on { deletedAt: null } (the tracked layer's live condition, which makes them covering) — read_entries_org_txDesc_live { organisationId: 1, transactionId: -1 }, read_entries_org_postingDate_tx_live { organisationId: 1, postingDate: 1, transactionId: 1 }, read_entries_org_journal_postingDate_tx_live { organisationId: 1, "journal._id": 1, postingDate: 1, transactionId: 1 } — and read_entries_org_flatAccountNumber_postingDate { organisationId: 1, "account.accountNumber": 1, postingDate: -1 } (the flat arm of accountAggregatesForPeriod). The journal page and its count took 23–26 s each on staging's largest organisation (2026-10-12). Same rule as 0008 for a hand-made index with the same key | — (not unique) | drops only what it created | online-safe (declared in its file) |
0012-entry-parent-balances-unset | The second DATA migration (engineer decision 2026-10-12, the clean-up of what #870 stopped writing): $unset of account.parent.balances — the collective's whole balance history copied into every auxiliary line, read by nobody — on the live lines of adb-accounting.domain_object_accounting_entry that carry it (staging 2026-10-12: 29 879 lines, 1.39 GB of 1.68 GB). Chunks of 500 updateOne, stamped (updatedAt, _v), filter re-asserting the field (a re-run writes nothing); nothing else on the line moves. services/event-dispatch must drop that change first (routing.mjs isParentBalancesUnsetOnlyUpdate), or every line queues a balance refresh | — | irreversible (restore from the { _id, account.parent.balances } export or the Atlas snapshot taken before --apply) | cutover-only (declared in its file) |
0013-null-labels | DATA (engineer decision 2026-10-12): the « [null] » labels left after 0010. (1) accounting_event whose description / source.description is « Paiement factures fournisseur [null] » take the text of their payment (source.id, same organisation) once 0010 repaired it (14 events on staging); (2) ledger lines labelled exactly « Extourne : [null] » take « Extourne : [<pieceReference>] » — what services/accounting reverseEntryDescription now writes for a reversed line without description (2 lines on staging). Stamped $set, filters re-asserting the « [null] » text. services/event-dispatch isLabelOnlyUpdate must run first (check the ChangeStreamDO's scriptVersion), or the accounting / contracts event consumers replay | — | irreversible | cutover-only (declared in its file) |
0014-views-parts-person-indexes | ensurePersonsPartsIndexes() (packages/data/views/indexes.mjs): { organisationId: 1, ownerID: 1 } and { organisationId: 1, tenantID: 1 } on adb-views.parts, under MongoDB's generated names (organisationId_1_ownerID_1, organisationId_1_tenantID_1) — the prefixes of the persons lists' read-model reads (owners / tenants columns and filters, ux-segments items 6-8), which organisationId_1 alone serves with a filter over every part of the organisation (staging 2026-10-11: 2 462 parts, 14 organisations, the largest 1 513) | — (not unique) | drops only what it created | online-safe |
The ensure* functions are called as they are: the migration binds coll() to its client and records, for every index the function asks for, whether this run created it or found it already there.
Production says whether up may run on production while the JVM still writes (online-safe) or only at the cutover with the JVM accounting writes quiesced (cutover-only; engineer classification, 2026-10-10). 0002 because its accounting_sequence unique build fails while the legacy organizationId year counters exist — it follows the twin-counter migration; 0004 because, with the index, two JVM payments of one organisation created in the same millisecond share a key and the JVM's saveIfNotExists answers the first one for the second (apps/ui/docs/parity/gaps.md FL-EA51). 0007, merged after that classification, is online-safe because nothing on the JVM side writes accounting_sequence_voids (its only writer was packages/data/accounting/sequence/mutations.mjs, and nothing writes it since the JE number is assigned after the posting committed): a unique build on a new, empty collection — to be confirmed by the engineer. 0008 and 0009, brought in by the merge of main (2026-10-11), are online-safe like 0005 and 0006: non-unique read index builds, no data precondition, build load only (off-peak) — to be confirmed by the engineer too. 0010, brought in by the merge of main on 2026-10-12, is cutover-only: a DATA migration that rewrites labels and person targets on payments, ledger lines and invoices, which the JVM accounting services write too, and whose down is irreversible (the Atlas snapshot taken before --apply) — to be confirmed by the engineer. The ten are declared in support/prod-safety.mjs, not in their files: 0001–0009 are applied on staging (read-only status, 2026-10-11), 0010 may be applied anywhere from its merged file, and editing an applied file moves its checksum (a test pins the ten files). The next migration, 0011 and on, exports prodSafety in its own file (export const prodSafety = "online-safe" | "cutover-only", optionally prodSafetyReason); one without a valid declaration fails packages/data/test/migrations-files.test.mjs, and production refuses it.
Everyday use
pnpm migrate status staging # applied / pending / changed / unknown, and the lock
pnpm migrate up staging # the plan (dry-run): what --apply would build, keep or refuse
pnpm migrate up staging --apply --to 0003 # apply up to and including 0003
pnpm migrate assert staging # exit 1 unless in sync — the pre-deploy gate
pnpm migrate down local # the plan of reverting the last applied migration
pnpm migrate down local --apply --to 0000 # revert every applied migration (0 or 0000 = all)
pnpm migrate up staging --only 0003,0005 # exactly these pending migrations, oldest first
From a worktree, the staging credentials stay in the main checkout: pnpm migrate status staging --staging-env <main checkout>/apps/ui/e2e/.auth/staging.env (not --env-file: node looks at that flag anywhere on its command line). --to takes NNNN, the file name, or the file name without .mjs; up --to runs up to and including it, down --to reverts down to and including it. --to and --step are exclusive; down alone reverts the last applied migration.
How it behaves
- The plan is what
--applyruns. The dry-run asks umzug for the same selection over a storage that records nothing, and runs the migrations through a read-only facade: reads pass,createIndexanddropIndexare recorded instead of sent, anything else throws. It predicts the server's refusals measured on mongod 7.0.37 and 8.2.11: same index name with another spec (86), same index under another name (85), and a unique build over keys several documents already share (11000, found by a read-only$groupover the index's partial filter, a missing field counting as null). - A pre-existing index is never dropped. An index already there with the same spec (built by an Atlas script, by
local-sync-backfill.mjs, by hand) is recordedcreated: false;downkeeps it.downalso refuses to drop an index whose spec changed since the migration created it, and skips one already gone. - A failed migration leaves nothing behind and is not recorded. When an
ensure*fails half-way (its builds run in parallel), the runner waits for every build, drops back the ones this run created, and stops; the migrations before it stay applied, the ones after it do not run. - One runner at a time.
--applytakes the lock with one atomic upsert on{ _id, expired or no expiry }(a held lock makes the insert collide on_id); a second runner is refused with the holder's name and expiry. The clock is the server's (hello.localTime) — mongod refuses$exprin an upsert predicate. A run that dies holds the lock until its expiry; a run that loses its lock stops before the next migration. - Never edit an applied migration.
upanddownrefuse while an applied file's checksum moved; add a new migration instead. To add an index to anensure*set: change the declaration, then add a migration that calls the sameensure*— the indexes already there are recorded as pre-existing, so itsdowndrops only the new one. --onlypicks exactly the migrations named (comma-separatedNNNNor names), each pending forup(oldest first) or applied fordown(newest first); it excludes--toand--step. A migration applied out of order leaves the older pending ones marked(OUT OF ORDER: older than an applied migration)instatus— expected on production after the online-safe step, where 0002 and 0004 wait for the cutover.- Irreversible means a
downthat throws.down = irreversible("why, and what to do instead")(support/migration.mjs). The loader refuses a module withoutdown: umzug would otherwise mark it reverted without doing anything.
When status says changed or unknown
- changed — the file of an applied migration no longer matches the checksum recorded when it was applied. If the edit was a mistake, restore the file. If it was deliberate and the database is already in the state the new file describes, a person (not this tool) updates the
checksumof that document inadb-system._migrations, after reading the diff. - unknown — the database has a migration this checkout does not have: the checkout is behind (pull) or the migration was applied from an unmerged branch.
Both make up, down and assert refuse until resolved.
The pre-deploy gate
pnpm infra:setup <slug> runs pnpm migrate assert <slug> --gate before it applies anything (infra-cf/scripts/data-migrations-gate.sh). Not in sync → the setup is refused with the commands that fix it; --check only reports; --skip-migrations-gate applies anyway. It never fails a run for lack of a URI: --gate skips with a note when no MongoDB is configured for the slug (pr-*), and the gate skips when the pnpm workspace is not installed. production is always skipped, before anything is read — even with PROD_MONGO_URI exported in the shell, the gate never connects to production; check it by hand with pnpm migrate assert production --i-know-this-is-prod.
Production
Run from the repository root, in a terminal, with the connection string exported in that shell only: export PROD_MONGO_URI='…' (never in a file, a ticket or a command argument). Every production command prints the cluster as scheme + hosts, then asks you to type the host back; stdin must be a terminal (a pipe or CI is refused). The tool connects only after that. The order (engineer decision, 2026-10-10):
| Step | When | What |
|---|---|---|
| 1 | now, off-peak, the JVM still writing | 0001, 0003, 0005, 0006, 0007, 0008, 0009, 0011 (online-safe) |
| 2 | at the cutover, JVM accounting writes quiesced | the twin-counter migration |
| 3 | right after 2 | 0002 (cutover-only) |
| 4 | right after 3 | 0004 (cutover-only) |
| 5 | right after 4 | 0010 (cutover-only, classification to be confirmed; take an Atlas snapshot first — its down is irreversible) |
| 6 | right after 5, services/event-dispatch with isParentBalancesUnsetOnlyUpdate and isLabelOnlyUpdate deployed AND its ChangeStreamDO on that version | 0012, then 0013 (cutover-only; Atlas snapshot first — their down is irreversible) |
(a) Read-only: the state, the dry-runs and the preflights. Nothing is written; each dry-run ends with its plan hash (or says why it has none) and the exact command that applies it.
pnpm migrate status production --i-know-this-is-prod
pnpm migrate up production --only 0001,0003,0005,0006,0007,0008,0009,0011 --i-know-this-is-prod
pnpm migrate up production --only 0002 --cutover --i-know-this-is-prod # its preflight refuses until step 2 ran
pnpm migrate up production --only 0004 --cutover --i-know-this-is-prod
pnpm migrate up production --only 0010 --cutover --i-know-this-is-prod # matched / to update / unresolved / skipped per collection
pnpm migrate up production --only 0012 --cutover --i-know-this-is-prod # live lines carrying account.parent.balances
pnpm migrate up production --only 0013 --cutover --i-know-this-is-prod # « [null] » events / reversal lines
MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prod
(b) Step 1, the online-safe apply. Review the dry-run, then apply exactly its hash.
pnpm migrate up production --only 0001,0003,0005,0006,0007,0008,0009,0011 --i-know-this-is-prod
pnpm migrate up production --only 0001,0003,0005,0006,0007,0008,0009,0011 --i-know-this-is-prod --apply --plan <hash>
pnpm migrate status production --i-know-this-is-prod # 0002 and 0004 pending, OUT OF ORDER: expected; 0010, 0012 and 0013 pending
(c) Steps 2-5, the cutover, with the JVM accounting writes quiesced:
MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prod
MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prod --apply
MONGO_URI="$PROD_MONGO_URI" node services/accounting/scripts/migrate-accounting-sequence-org-spelling.mjs --i-know-this-is-prod # renamed: 0 merged: 0
pnpm migrate up production --only 0002 --cutover --i-know-this-is-prod # every starred count 0
pnpm migrate up production --only 0002 --cutover --i-know-this-is-prod --apply --plan <hash>
pnpm migrate up production --only 0004 --cutover --i-know-this-is-prod
pnpm migrate up production --only 0004 --cutover --i-know-this-is-prod --apply --plan <hash>
pnpm migrate up production --only 0010 --cutover --i-know-this-is-prod
pnpm migrate up production --only 0010 --cutover --i-know-this-is-prod --apply --plan <hash> # after the Atlas snapshot
pnpm migrate assert production --i-know-this-is-prod # OK: in sync
What production adds, each rule with its test in tools/scripts/test/data-migrations-production.test.mjs and data-migrations-cli.test.mjs:
prodSafety.
uprefuses a selection holding a cutover-only migration unless--cutover, and any migration that declares no prodSafety, before anything is read; the refusal names the online-safe ones to pass to--only.downhas no prodSafety gate (dropping an index is not what the JVM collides with) but the same confirmation and hash.Preflights (read-only, through the dry-run facade), printed for every selected migration that has one; a starred count other than 0 refuses: the dry-run prints no hash, the apply stops before any write.
Migration Counts (starred = refusing) 0001 user_preferencesdocuments; * duplicate(organisationId, userId)pairs0002 accounting_sequencedocuments; * JE year counters and * pieceReference counters withoutorganisationId; * twin counters; * British/British duplicates; counters of unknown kind — the twin-counter migration's own planner, so all-zero means it has run0004 * duplicate (organisationId, unique)payment pairs (FL-EA51's pipeline)The plan hash (16 hex digits) covers the cluster's hosts, the direction,
--cutover, each selected migration's name, checksum and prodSafety, every operation the dry-run facade predicted for it (sorted: the builds run in parallel), and the refusing preflight counts — not the informational counts nor anything else that moves on its own.--applyneeds--plan <hash>; under the lock it re-runs the preflights and the plan, then refuses — before any write — a refusing count, a plan predicted to fail, or a hash other than the one passed. A hash from an older dry-run stays valid only while nothing it covers has moved.The selection is explicit. Production computes what runs (
--only,--to,--step) and hands umzug that list, so what is gated, checked and hashed is what runs.No new write. Production applies through the same lock, tracking documents and index helpers as staging. The twin-counter migration is not recorded in
adb-system._migrations: a record with no file inpackages/data/migrationsmakes everyup,downandassertrefuse (unknown). The 0002 preflight checks its result from the data instead.
State, measured 2026-10-09 to 11
| Environment | Before | After |
|---|---|---|
staging (adb-stage.cm0co.mongodb.net, MongoDB 8.0.34) | user_preferences _id_ only; accounting_event.unique_1, accounting_sequence.uniq_accounting_sequence_org_year, invoices.unique_1_live, payments.uniq_payments_org_unique present; no sync index; no adb-system | 0001–0004 applied 14:52Z: organisationId_1_userId_1 and the three sync indexes created; the four accounting indexes recorded as pre-existing (down keeps them); 0004's duplicate count 0; assert OK |
| staging, 0005 | domain_object_accounting_entry: _id_, postingDate_1, direction_1, account._id_1, account.parent._id_1 | 0005 applied 2026-10-09T21:26:57Z |
| staging, 0006 | 0001–0005 applied | 0006 applied 2026-10-10T09:21:28Z (status, read-only, 2026-10-10) |
| staging, 0007–0009 | 0001–0006 applied | 0007 applied 2026-10-11T15:50:50Z, 0008 17:04:49Z, 0009 17:05:00Z; 9 applied, 0 pending, 0 changed, 0 unknown (status, read-only, 2026-10-11) |
| staging, production preflights (read-only probe through the dry-run facade, 2026-10-10) | — | 0001: 1 preference document, 0 duplicate pairs; 0002: 158 counters, all organisationId, 0 twins, 0 British/British; 0004: 0 duplicate payment pairs; 0 operations recorded by the facade |
| local stack (prod-restored dump) | accounting_event.unique_1, invoices.unique_1 present | 0001 applied; 0002 refused — accounting_sequence still holds the legacy organizationId counters, which all index as { organisationId: null, year } (2023 ×2, 2024 ×3, 2025 ×3): run the twin-counter migration on that database first |
| production | not measured, nothing applied | — |
Open questions
- Production has not been probed by this tool: the preflight counts it will print (2026-07-28 probe: 78 of 88 chrono and 9 of 13 year counters still
organizationId) are not measured yet. The cutover's "JVM accounting writes quiesced" is a manual procedure this runbook does not describe. - The plan hash binds the plan, not the person: whoever runs
--applywith the printed hash applies it. The review is the operator reading the dry-run. - The real-mongod integration test (
MIGRATE_INT_MONGO_URI) covers local and staging paths only; the production rules are tested on the indexed fake, and the terminal confirmation through a pseudo-terminal against an unresolvable host. - The gate runs in
pnpm infra:setuponly. Workers Builds deploys the services on push without it; a gate there needs a MongoDB URI in the build environment. invoices.unique_1(the full index the live twin replaces, docs/local-sync/WP-D.md) is not managed here: staging has onlyunique_1_live, the local dump onlyunique_1.- A DATA migration plans through
bulkWriteonly: the dry-run facade (tools/scripts/lib/data-migrations/clients.mjsplanningClient) records abulkWriteofupdateOne/updateManywithout upsert and answers{ dryRun: true }; an insert, a replace, a delete or an upsert is refused like any other write. The migration plans from its reads (0010 logs matched / to update / unresolved / skipped per collection, with sample ids).