@alexify/migronaut 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/README.md +208 -6
  3. package/bullmq.d.ts +845 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +634 -18
  6. package/migronaut.schema.json +182 -1
  7. package/package.json +21 -5
  8. package/src/bullmq/index.js +55 -0
  9. package/src/bullmq/jobs.js +454 -0
  10. package/src/bullmq/processor.js +608 -0
  11. package/src/bullmq/producer.js +424 -0
  12. package/src/bullmq/service.js +653 -0
  13. package/src/bullmq/wait.js +124 -0
  14. package/src/cli/args.js +12 -2
  15. package/src/cli/commands/converge.js +160 -0
  16. package/src/cli/commands/down.js +2 -0
  17. package/src/cli/commands/lock.js +2 -1
  18. package/src/cli/commands/redo.js +8 -1
  19. package/src/cli/commands/up.js +14 -1
  20. package/src/cli/exit-codes.js +9 -2
  21. package/src/cli/index.js +2 -0
  22. package/src/cli/shared.js +14 -4
  23. package/src/cli/table.js +105 -0
  24. package/src/core/changelog.js +71 -6
  25. package/src/core/collections.js +372 -0
  26. package/src/core/config.js +100 -25
  27. package/src/core/converge-log.js +47 -0
  28. package/src/core/converge-plan.js +483 -0
  29. package/src/core/converge.js +867 -0
  30. package/src/core/index-spec.js +496 -0
  31. package/src/core/lock-wait.js +260 -0
  32. package/src/core/lock.js +45 -16
  33. package/src/core/migrator.js +563 -283
  34. package/src/core/options.js +251 -0
  35. package/src/core/run-recorder.js +157 -0
  36. package/src/core/run.js +58 -90
  37. package/src/core/sequence.js +134 -0
  38. package/src/errors/index.js +56 -0
  39. package/src/index.js +8 -0
  40. package/src/utils/actor.js +48 -0
  41. package/src/utils/canonical.js +179 -0
  42. package/src/utils/collection-name.js +21 -0
  43. package/src/utils/error.js +18 -1
  44. package/src/utils/id.js +77 -0
  45. package/src/utils/loader.js +39 -21
  46. package/src/utils/migration-name.js +32 -0
  47. package/src/utils/redact.js +21 -1
  48. package/src/utils/telemetry.js +393 -0
  49. package/src/utils/template.js +36 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,326 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Release headings carry the publish date (`## vX.Y.Z — YYYY-MM-DD`).
5
5
 
6
+ ## v2.1.0 — 2026-10-04
7
+
8
+ Migrations as a queue, ids in your own format, OpenTelemetry, and declared collections. Additive:
9
+ nothing changes for anyone who uses none of them, with the narrow exceptions listed under
10
+ **Changed**.
11
+
12
+ ### Added
13
+
14
+ - **`@alexify/migronaut/bullmq`** — a new entry point that runs migrations as
15
+ [BullMQ](https://docs.bullmq.io/) jobs, **one migration per job**, so migronaut can be a
16
+ migration service: enqueue from an HTTP handler, a schedule or a deploy hook, and let a worker
17
+ apply them in order.
18
+ - `createMigrationQueue(options)` — the facade: `enqueueUp` / `enqueueDown` (returning a group
19
+ handle with `wait()`), `startWorker`, `status` / `pending` / `audit` / `lockInfo`, `getJob`,
20
+ `pause` / `resume`, `schedule` / `unschedule`, `close`.
21
+ - `createMigrationProcessor(options)` — the processor on its own, for a Worker you construct
22
+ (NestJS, BullMQ Pro), plus `enqueueUp` / `enqueueDown` / `planUpJobs` / `planDownJobs` /
23
+ `waitForGroup` for a Queue you own.
24
+ - **BullMQ is injected, never depended on** — `bullmq: { Queue, Worker, QueueEvents }` from
25
+ your own install. The package still has no `dependencies` and gains no peer; `src/` never
26
+ imports `bullmq` (a test enforces it).
27
+ - **Order comes from MongoDB, not from Redis.** Every job is a single-file run under the usual
28
+ lock; it refuses while an earlier migration is still pending, so a failed migration stops the
29
+ line (`MIGRATION_BLOCKED` for the jobs behind it). Jobs get one attempt on purpose — a BullMQ
30
+ retry re-queues behind the waiting jobs — and a held lock is waited out inside the job.
31
+ - **One batch per enqueue**, so `down` still rolls back a whole deploy; duplicate enqueues are
32
+ deduplicated, and a job whose migration is already applied completes as `skipped`.
33
+ - Job payloads are validated as untrusted input; messages, stacks and job logs are redacted. A
34
+ queue job's target must be a file of the migration sequence — a payload can never make the
35
+ worker import a dotfile, a declaration file or a helper module next to the migrations.
36
+ - **Shutdown puts unstarted work back.** A job that a closing worker stops before its migration
37
+ starts (waiting for the lock, or fetched during shutdown) is moved back to the head of the
38
+ queue instead of failing — so a rolling deploy no longer fails the rest of the enqueue it
39
+ interrupts as `MIGRATION_BLOCKED`. A migration already running always finishes.
40
+ - **A versioned, strict job contract.** A worker accepts every job data version from
41
+ `MIN_JOB_DATA_VERSION` (exported) up to its own and refuses newer ones and unknown fields
42
+ rather than ignoring what they mean — roll workers out before producers. Jobs always state
43
+ `ordered`, and may carry the plan-time `checksum` of their file.
44
+ - **Scheduler ticks are bounded**: they carry the queue's `jobOptions`, and keep the last 100
45
+ completed / 500 failed jobs when those set no retention.
46
+ - **Correlation**: a job's `runId` is on its progress (`completed` and `failed`), its failure log
47
+ row and its error's `context` (with `jobId` and `groupId`) — a failed job has no return value;
48
+ the worker's failure log line names the group, migration and run.
49
+ - An injected `Queue` (or `QueueEvents`) on another name or prefix than the facade's is rejected,
50
+ and the facade takes an injected queue's prefix by default; `startWorker()` can be retried
51
+ after a failed start; a closed queue refuses every further call.
52
+ - **A worker decides what a job may ask for** — the `allow` option (`{ down: true, force:
53
+ false, unordered: false }` by default) on `createMigrationProcessor` and
54
+ `createMigrationQueue`: a payload asking to re-run an applied migration or to skip the order
55
+ guard is refused (`QUEUE_JOB_INVALID`, `context.permission`) unless allowed. The facade's
56
+ `enqueue*` calls follow the same policy.
57
+ - **A job runs only the file it was planned with**: an `up` job carries the plan-time checksum,
58
+ and a worker with another version of the file fails it (`CHECKSUM_MISMATCH`,
59
+ `context.planned`) instead of applying it.
60
+ - **Several workers without global concurrency**: a job blocked only by earlier migrations that
61
+ have not failed (one may be in flight on another worker) waits for them within its lock-wait
62
+ budget instead of failing its group; `MigrationBlockedError` carries `context.failed`.
63
+ - `wait()` holds every await to its one `timeoutMs` budget, decides a timeout by the clock, and
64
+ its `QueueJobFailedError` carries the job's own typed `code`; an empty group can be waited for
65
+ without QueueEvents. A long lock wait reports progress every few seconds, not every poll.
66
+ - What the queue stores about a failure — `failedReason`, stack, job logs — masks the values a
67
+ duplicate-key error quotes, on top of credentials. `schedule({ every })` needs at least 1000 ms.
68
+ - Dedup ids encode file names reversibly (two names can no longer share one and absorb each
69
+ other's job), and a forced re-run has a dedup id of its own.
70
+ - **A schedule holds a failed migration**: a `sync` tick whose next migration failed, with its
71
+ file unchanged since, enqueues nothing (`returnvalue.held`, and a warning) instead of re-running
72
+ it every tick; a changed file or an explicit `enqueueUp(name)` resumes it.
73
+ - Jobs carry `requestedBy` / `reason` (`enqueueUp` / `enqueueDown` / `enqueueConverge` options).
74
+ - **`bullmq.d.ts`** — hand-written types for the entry point, with structural `BullMQ*Like`
75
+ interfaces instead of an import of `bullmq`, generic over the classes you inject.
76
+ - **`up(file, { batch })`** — stamp an explicit batch number instead of the next free one, and
77
+ **`MigratorKit.nextBatch()`** to peek at it: together they let several single-file runs form one
78
+ batch.
79
+ - **`up(file, { ordered: true })` / `down(file, { ordered: true })`** — refuse a single-file run
80
+ that would go out of sequence (`MigrationBlockedError`); an ordered `up` also applies the
81
+ `strict` drift check and the `onOutOfOrder` policy a bulk run would, and only targets a file of
82
+ the migration sequence.
83
+ - **`up(file, { checksum })`** — refuse (`CHECKSUM_MISMATCH`, `context.planned`) to apply any other
84
+ version of the file than the one with this SHA-256; `dryRun('up')` rows carry each file's
85
+ `checksum` for it.
86
+ - **`list(filter, { checksums: false })`** / **`status({ checksums: false })`** — skip hashing the
87
+ applied files, for a caller that needs names and dates only.
88
+ - **Who asked, and why** — `up`, `down`, `redo` and `converge` take `requestedBy` (≤ 128
89
+ characters) and `reason` (≤ 512), and `migronaut up` / `down` / `redo` / `converge` take
90
+ `--reason`. They are stamped on the changelog (`requestedBy` / `reason` on an apply,
91
+ `revertRequestedBy` / `revertReason` on a revert; a later apply that says nothing clears the old
92
+ ones) and on the converge history; `status()` rows show them. `executedBy` stays the OS user —
93
+ on a queue worker, the container's — which is why the requester has fields of its own. A failed
94
+ attempt's trace now also records the checksum of the file version that failed
95
+ (`StatusRow.failedChecksum`).
96
+ - **`runMigrations(config, { signal })`** — an `AbortSignal` (wired to SIGTERM) stops a wait for
97
+ the lock between polls, and a run that holds it between migrations.
98
+ - **`LockInfo.runId` and `LockInfo.ttlMs`** — which run holds the lock (also on `migronaut lock`),
99
+ and the holder's TTL, which the lock document now records.
100
+ - **Three error codes**: `MIGRATION_BLOCKED` (exit 24), `QUEUE_JOB_INVALID` (25),
101
+ `QUEUE_JOB_FAILED` (26), with `MigrationBlockedError`, `QueueJobInvalidError` and
102
+ `QueueJobFailedError` exported from the package root.
103
+ - **Runnable example** — `examples/migration-service`: a queue, a worker and a plain `node:http`
104
+ API (not published to npm).
105
+ - **`generateId` config option** — your own identifier format (ULID, CUID, nanoid, UUIDv7, …)
106
+ everywhere migronaut used to call `crypto.randomUUID()`: the `runId` of every run — on changelog
107
+ records, events, log lines and the lock's owner token — and the `groupId` of every queue
108
+ enqueue. One option covers the kit, the CLI (through `migronaut.config.js`/`.ts`),
109
+ `runMigrations` and the queue adapter.
110
+ - **Injected, like the logger** — migronaut ships no generator but the default. It is called
111
+ with no arguments, so third-party functions pass straight through: `generateId: ulid`.
112
+ - **Checked on every call** — it must synchronously return a non-empty string of at most 128
113
+ characters. A throw, a promise or anything else fails the run with `CONFIG_INVALID` before a
114
+ migration starts (and an enqueue before a job is added).
115
+ - Code-only: no environment variable and no place in a JSON config, like `logger` and `hooks`.
116
+ - **`MigratorKit.generateId()`** — a new id in the kit's configured format, for code that wants its
117
+ own ids to match (it is how the queue adapter mints group ids). Resolves the config; does not
118
+ connect.
119
+ - **`IdGenerator` type** — `() => string`, exported from the package root.
120
+ - **`telemetry` config option** — OpenTelemetry traces and metrics through a tracer and/or a meter
121
+ from your own `@opentelemetry/api`: `telemetry: { tracer, meter }`. One option covers the kit,
122
+ the CLI (through `migronaut.config.js`/`.ts`), `runMigrations` and the queue adapter.
123
+ - **Spans**: `migronaut.run` for every run that acquired the lock, and a child
124
+ `migronaut.migration` for every migration executed. The migration's span is the *active* one
125
+ while its hooks, its `up`/`down` and its changelog write run — so an instrumented MongoDB
126
+ driver nests its command spans under the migration that issued them. That is what lifecycle
127
+ events cannot do, and why this lives in the kit: at application startup there is no ambient
128
+ span, and the driver instrumentation records nothing without a parent.
129
+ - **Metrics**: `migronaut.run.duration`, `migronaut.migration.duration`,
130
+ `migronaut.lock.acquire.duration` and `migronaut.lock.wait.duration` (one point per wait for a
131
+ held lock, by `migronaut.lock.wait.outcome`: `acquired`, `timeout`, `aborted`) — histograms,
132
+ in seconds, with boundaries from 10ms to an hour — and the counters `migronaut.lock.refused`
133
+ and `migronaut.lock.lost`.
134
+ - **Dimensions**: every span and metric point carries `db.namespace` (the database name), plus
135
+ the caller's own static attributes from `telemetry.attributes` (at most 20 scalars).
136
+ - **Failures** set the span's status to `ERROR` with a redacted message (credentials and the
137
+ values a duplicate-key error quotes masked, at most 1 KB), and `error.type` — on
138
+ the span and on the metric point — to the typed error code. No exception event is recorded: it
139
+ would carry the unredacted message and stack.
140
+ - **A run that never got the lock emits no span.** A caller polling for a busy lock retries the
141
+ whole run every few hundred milliseconds; the refusals are counted
142
+ (`migronaut.lock.refused`) instead.
143
+ - **Injected, like the logger** — `@opentelemetry/api` is neither a dependency nor a peer, and
144
+ `src/` never imports it (a test enforces it). The types are structural: `MigronautTracer`,
145
+ `MigronautSpan`, `MigronautMeter`, `MigronautHistogram`, `MigronautCounter`,
146
+ `MigronautMetricOptions`, `MigronautAttributes` and `MigronautTelemetry`, exported from the
147
+ package root.
148
+ - **It can never fail a run.** Every call into the tracer, a span, the meter and an instrument
149
+ is guarded — a promise one of them returns included, so a rejecting SDK cannot surface as an
150
+ unhandled rejection — and a tracer that throws before or after running the work, or runs it
151
+ twice, still gets each migration executed exactly once.
152
+ - Code-only, like `logger` and `generateId`. The span, attribute and metric names are new and
153
+ should be treated as experimental.
154
+ - **`bullmq.telemetry`** — `createMigrationQueue({ bullmq: { Queue, Worker, telemetry } })` hands
155
+ BullMQ's own telemetry object (`new BullMQOtel({ tracerName })` from `bullmq-otel`) to the Queue
156
+ and the Worker it constructs. Together with the kit's `telemetry` it gives one trace from the
157
+ request that enqueued, across Redis, to the MongoDB commands in the worker. Until now the facade
158
+ built its Queue without it, so the enqueuing side of that trace could not be joined.
159
+ `workerOptions.telemetry` and `startWorker({ telemetry })` override it for the worker alone.
160
+ - **OpenTelemetry in the example** — `examples/migration-service` gains `tracing.js` and a Jaeger
161
+ in its `docker-compose.yml`: set `OTEL_EXPORTER_OTLP_ENDPOINT` and every enqueue is one trace.
162
+ - **Declared collections** — indexes and validators declared as an end state, and applied by
163
+ `migronaut converge`, with no migration file per change. For what only ever has a current value
164
+ (which indexes a collection has, which validator guards it); migrations stay the tool for
165
+ changes with an order and a history.
166
+ - **`collections` config option** — an array of definitions, `{ name, indexes?, validator?,
167
+ validationLevel?, validationAction?, prune? }`, each index in the driver's own flat
168
+ `createIndexes` shape. Works in `migronaut.config.{ts,js,json}` (and the JSON Schema) and in
169
+ `new MigratorKit({ collections })`.
170
+ - **`collectionsDir` config option** (`MIGRONAUT_COLLECTIONS_DIR`) — one definition file per
171
+ collection (`.ts`/`.js` default export, or `.json`; the name defaults to the file name).
172
+ Opt-in, combined with `collections`, and loaded only when a converge runs — a broken file never
173
+ blocks `status` or an emergency `down`. A collection declared twice is `CONFIG_INVALID`.
174
+ - **Validated strictly**: an unknown definition key or index option is an error with its path
175
+ (`collections[2].indexes[0].uniqe`) — the driver silently drops an option it does not know, so
176
+ a typo would otherwise build the wrong index and then look in sync forever.
177
+ - **`MigratorKit.converge({ dryRun?, prune?, noLock?, ordered? })`** and **`migronaut converge`**
178
+ (`--dry-run`, `--check`, `--prune`, `--yes`). Stateless: every run reads `listCollections` and
179
+ `listIndexes`, plans, and carries the plan out one step at a time under the migration lock —
180
+ create the collection, set the validator, create indexes, `collMod` a TTL or `hidden` in place,
181
+ rebuild what changed otherwise, drop last. Nothing is recorded.
182
+ - **Safe by default.** An index you did not declare is kept and reported (`keep`), and dropped
183
+ only with `prune` (per definition, or for the definitions that do not decide). An identical
184
+ index under another name is accepted as is rather than rebuilt; a different one covering the
185
+ same key is a conflict that refuses the run before the first write. A rebuild whose new index
186
+ fails to build puts the old one back — and says so, with the reason, when it cannot.
187
+ - **A unique index is never rebuilt unasked.** Dropping it opens a window with no constraint,
188
+ and a duplicate written in that window leaves neither index buildable. Such a rebuild is a
189
+ `conflict` unless `converge({ rebuildUnique: true })` / `--rebuild-unique`; a converge after
190
+ `up` and a queue job never pass it.
191
+ - **Comparisons follow what the server stores** — text indexes in their `_fts` form, collations
192
+ field by field against the expanded spec (`strength`, `caseLevel` and `numericOrdering` at
193
+ their universal defaults when left out), the collection's default collation,
194
+ `{ locale: 'simple' }`, a flag stored as `1`. A compound `Map` key may hold an integer-like
195
+ field only first — the driver reads keys back as plain objects.
196
+ Anything applied that still compares as changed is reported under `unstable` instead of being
197
+ rebuilt on every run.
198
+ - **The CLI plans first** and asks before any drop or rebuild, and before changing the validator
199
+ of a collection that holds data; `--json` refuses such a plan without `--yes` (and applies a
200
+ purely additive one); a plan with a conflict is refused without asking. `--check` exits `28` on
201
+ drift — a CI gate; `--ordered` refuses while a migration is pending.
202
+ - **Results say what changed**: every row that changes, drops or keeps something carries
203
+ `from` and `to` — the live and the declared index or validator, as plain JSON.
204
+ - **History** — every converge that changes something or fails appends an entry to
205
+ `_migronaut_converge` (`convergeLogCollection`, `MIGRONAUT_CONVERGE_LOG_COLLECTION`): when,
206
+ the trigger, the run id, who ran it and where, who asked and why, and every row it touched
207
+ with its `from` / `to`. Read it with `MigratorKit.convergeHistory({ limit })` or
208
+ `migronaut converge --history [--limit n] [--json]`. Best-effort, and a database that never
209
+ converges never gets the collection.
210
+ - **In place where the server can**: making an index unique (MongoDB 7.0+, `collMod`
211
+ `prepareUnique` then `unique` — duplicates leave the index as it was) and adding a TTL to a
212
+ single-field index (5.1+) are `modify`, not a rebuild. (6.0 accepts the unique conversion but
213
+ did not enforce it in our tests, so it rebuilds.)
214
+ - **Sharded clusters**: behind a `mongos`, prune never drops the index backing a shard key (read
215
+ from `config.collections`, or kept with a warning when the server refuses the drop).
216
+ - **Re-planned before each collection**: every collection after the first is read and planned
217
+ again right before its turn; one that changed meanwhile into a conflict or a new drop/rebuild
218
+ stops the run (`ConvergeFailedError`, `phase: 'replan'`) before it is touched.
219
+ - **Regular expressions** in a validator or partial filter must use flags the driver stores as
220
+ written (`i`, `m`; a `BSONRegExp` for server options) — `g` would become dotAll and `s`, `u`,
221
+ `y` vanish — and compare in their stored form. A `__proto__` key in a JSON definition stays a
222
+ key.
223
+ - **At scale**: all declared collections are read with one `listCollections` and a bounded
224
+ fan-out of `listIndexes`; the new indexes of a collection are built by one `createIndexes`
225
+ (one pass over the data); a connection that fails mid-rebuild is reported, never "repaired"
226
+ by restoring the old index next to a build the server may still be running.
227
+ - **`convergeAfterUp` config option** (`MIGRONAUT_CONVERGE_AFTER_UP`) — a bulk `up` (no file, no
228
+ `to`; also `runMigrations`) ends by converging under the same lock, even when nothing was
229
+ pending, so a failed converge is retried by the next deploy. `up(undefined, { converge })` and
230
+ `migronaut up --converge` / `--no-converge` decide per run. `up` still returns its migration
231
+ rows; `runMigrations` adds `summary.converge`.
232
+ - **`MigratorKit.convergesAfterUp()`** — whether a bulk `up` on the kit ends by converging.
233
+ - **Events**: `converge:start`, `converge:action` (per step: `'started'` before it runs — an index
234
+ build can take hours — then `'applied'` or `'failed'`) and `converge:end` (with the full
235
+ result), for real runs. An index build also logs which index it is starting on. The run itself is an ordinary `run:start`/`run:end` with
236
+ `command: 'converge'`, and an ordinary `migronaut.run` span.
237
+ - **Types**: `CollectionDefinition`, `CollectionDefinitionFile`, `IndexDefinition`,
238
+ `IndexKeyDirection`, `IndexCollation`, `ValidationLevel`, `ValidationAction`, `ConvergeOptions`,
239
+ `ConvergeResult`, `CollectionConvergeResult`, `ConvergeAction`, `ConvergeActionKind`,
240
+ `ConvergeActionStatus`, `ConvergeTarget`, `ConvergeUnstable`, `ConvergeTrigger` and the three
241
+ event payloads, exported from the package root.
242
+ - The definition shape, the result shape and the queue contract below are new and should be
243
+ treated as experimental.
244
+ - **Converge jobs in the queue adapter** — `JOB_NAMES.CONVERGE` (`'converge'`), `enqueueConverge()`
245
+ and `MigrationQueue.enqueueConverge()`, and `schedule({ job: 'converge' })` with its own default
246
+ id, `DEFAULT_CONVERGE_SCHEDULER_ID` (`'migronaut-converge'`). With `convergeAfterUp`,
247
+ `enqueueUp` ends a group that reaches the newest migration with a converge job (or adds a
248
+ converge-only job when nothing is pending but a dry run finds drift), and an idle `sync` tick does
249
+ the same. A converge job refuses as `MIGRATION_BLOCKED` while a migration is pending, is keyed for
250
+ deduplication on the migration it follows, and carries no `prune` — what may be dropped comes
251
+ from the worker's own definitions. New types: `ConvergeJobData`, `ConvergeJobResult`,
252
+ `ConvergeJobSpec`, `ConvergeHandle`, `EnqueueConvergeOptions`.
253
+ - **Two exit codes**: `CONVERGE_FAILED` (27, with `ConvergeFailedError` exported from the package
254
+ root) and the CLI-only `COLLECTIONS_DRIFT` (28, from `converge --check`).
255
+
256
+ ### Changed
257
+
258
+ - **`MigronautErrorCode` gained four members** (above). TypeScript consumers with an exhaustive
259
+ `switch` over the code union need a `default` branch or the new cases.
260
+ - **`collections`, `collectionsDir` and `convergeAfterUp` config keys are now validated**
261
+ (`CONFIG_INVALID`). They were previously unknown and ignored, like any stray key.
262
+ - **The CLI arg parser makes a `--x` / `--no-x` pair tri-state**, as commander does: when a command
263
+ declares both, neither given leaves the option unset instead of defaulting to `true`. Only
264
+ `up --converge` / `--no-converge` uses this.
265
+ - **`dryRun('up')` now applies the out-of-order policy** of the run it previews: under
266
+ `onOutOfOrder: 'error'` a bulk preview refuses with `MIGRATION_OUT_OF_ORDER` instead of listing
267
+ rows the run would reject; under `'warn'` it logs the warning. A single-file preview is exempt,
268
+ as the single-file run is.
269
+ - **`connect()` is safe to call concurrently** — overlapping first calls on one `MigratorKit`
270
+ share a single connection instead of each opening (and all but one leaking) a client. Matters
271
+ for a long-lived kit serving several callers.
272
+ - The lock-wait loop of `runMigrations` moved to `src/core/lock-wait.js`, shared with the queue
273
+ processor. Two changes to how it waits: **polls back off**, doubling from `lockPollIntervalMs`
274
+ up to 5 s (and at most a quarter of the budget), so a fleet waiting out a long deploy no longer
275
+ hammers the lock document; and **the default `lockWaitTimeoutMs` follows the holder's TTL** —
276
+ `max(90 s, 1.5 × lockTTLSeconds)` — so a holder with a long TTL, whose heartbeat moves the lock
277
+ only every TTL/2, is no longer mistaken for a stalled one. An explicit `lockWaitTimeoutMs` is
278
+ used as given (with a warning when it is shorter than the holder's heartbeat). `waitedMs` is
279
+ now measured by the clock from the first refusal, and a wait that times out rethrows the
280
+ refusal with `context.timedOut`, `attempts` and `waitedMs`.
281
+ - **`runMigrations` validates `onLockHeld`** (`CONFIG_INVALID`): a value other than `'throw'` or
282
+ `'wait'` — `'Wait'`, say — used to behave as `'throw'` without a word. A poll interval above the
283
+ largest timer (2³¹−1 ms, which Node fires after 1 ms) is refused too.
284
+ - **The lock document gained a `nonce` field**, minted by migronaut on every acquire and matched
285
+ alongside `owner` when the lock is confirmed, renewed and released. The owner token is the run
286
+ id, whose format `generateId` now decides; the nonce keeps mutual exclusion independent of it,
287
+ so a generator that repeats an id can blur correlation but never let two runs hold the lock.
288
+ Older releases ignore the field and can share a database with this one. The nonce is never
289
+ exposed; the owner — the holder's run id — now is, as `LockInfo.runId` (`lock`, `lockInfo()`,
290
+ `LockAlreadyHeldError` context), next to the holder's `ttlMs`, also written since this release:
291
+ "which run holds the lock?" is the first question about a stuck one, and the nonce, not the
292
+ owner, is what proves ownership.
293
+ - **A `generateId` config key that is not a function is now rejected** (`CONFIG_INVALID`). The key
294
+ was previously unknown and ignored, like any stray key.
295
+ - **A `telemetry` config key that is not usable is now rejected** (`CONFIG_INVALID`): it must be an
296
+ object whose `tracer` has `startActiveSpan` and whose `meter` has `createHistogram` and
297
+ `createCounter`. Absent, `null` and `{}` all mean "off". The key was previously unknown and
298
+ ignored.
299
+ - **A scheduled `sync` job no longer joins the trace that registered its schedule.** Its template
300
+ now carries `telemetry: { omitContext: true }`: BullMQ builds each scheduler iteration from the
301
+ previous job's options, so with telemetry on, every tick would otherwise have been appended to
302
+ one ever-growing trace. Each tick is a trace of its own; no effect on a queue without telemetry.
303
+
304
+ ### Fixed
305
+
306
+ - `docs/reference/cli.md` lists exit code `23` (`MIGRATION_OUT_OF_ORDER`), missing since v2.0.0.
307
+ - The events table in `docs/guide/hooks.md` had fallen behind the payloads: it now lists
308
+ `migration:skipped`, the `command` / `durationMs` / count fields of `run:start` and `run:end`,
309
+ and `ttlMs` / `acquireMs` on `lock:acquired`.
310
+
311
+ ### Tooling
312
+
313
+ - A unit test pins `src/utils/id.js` as the only module that mints an identifier, so no id can
314
+ bypass `generateId`.
315
+ - A unit test pins that nothing under `src/` or `bin/`, and neither declaration file, imports an
316
+ `@opentelemetry/*` package or `bullmq-otel`; they are devDependencies only. The structural types
317
+ are checked part by part against the real `@opentelemetry/api`, and one integration test runs the
318
+ real `@opentelemetry/instrumentation-mongodb` to prove the driver's spans nest under a migration.
319
+ - Declared collections are pinned by a table-driven planner test, a fixed-point integration matrix
320
+ (every kind of index converges, then plans as unchanged on a real server), and the shared queue
321
+ scenarios on both the fake and the real BullMQ.
322
+ - An in-tree fake BullMQ carries the adapter's unit and integration tests; the same scenarios run
323
+ against the real `bullmq` package when `MIGRONAUT_TEST_REDIS_URL` is set, which CI now does
324
+ (a Redis service on the `test` job). `bullmq` and `ioredis` are devDependencies for that only.
325
+
6
326
  ## v2.0.0 — 2026-08-30
7
327
 
8
328
  A major bump for three narrow contract changes (below); everything else is
package/README.md CHANGED
@@ -43,7 +43,9 @@ change before it touches your database.
43
43
 
44
44
  ## Reasons to choose it
45
45
 
46
- - **Zero dependencies** — no runtime dependencies at all; only the `mongodb` driver as a peer.
46
+ - **Zero dependencies** — no runtime dependencies at all; only the `mongodb` driver as a peer
47
+ (Mongoose, BullMQ and OpenTelemetry are optional integrations you inject — never installed for
48
+ you).
47
49
  Instant installs, nothing extra in your lockfile, no supply-chain surface.
48
50
  - **Run a single migration** — `migronaut up <file>`, not just "all pending".
49
51
  - **Roll back anything** — a batch (`--batch 3`), the last N (`--steps 2`), one file, or `redo`.
@@ -57,10 +59,21 @@ change before it touches your database.
57
59
  (warn by default, `onOutOfOrder: 'error'` to refuse) instead of silently applying.
58
60
  - **Lifecycle hooks** — `beforeAll`, `afterAll`, `beforeEach`, `afterEach`, `onError`.
59
61
  - **Opt-in transactions** — wrap a migration so it fully commits or fully aborts.
62
+ - **Indexes and validators as an end state** — declare them, and `migronaut converge` brings the
63
+ database to match; no migration file per index change ([details](#declared-collections)).
60
64
  - **TypeScript, ESM & CommonJS** — all run with no `ts-node` plumbing.
61
65
  - **Zero config files required** — drive everything from env vars if you prefer.
62
66
  - **Pino-friendly logging** — the `logger` option is pino-compatible; pass a pino instance directly
63
67
  and migronaut logs through it (with a `component: 'migronaut'` child binding).
68
+ - **Your id format** — run ids and queue group ids are random UUIDs by default; pass
69
+ `generateId: ulid` (or cuid2, nanoid, UUIDv7 — any `() => string`) and every id migronaut mints
70
+ comes from your generator.
71
+ - **OpenTelemetry (optional)** — pass a tracer and a meter from your own `@opentelemetry/api`: a
72
+ span per run and per migration, active while the migration runs, so an instrumented MongoDB
73
+ driver nests its command spans under the migration that issued them — plus duration metrics.
74
+ - **Migrations as a queue (optional)** — `@alexify/migronaut/bullmq` runs each migration as its own
75
+ BullMQ job, in order, so migronaut can be a migration service: trigger it over HTTP, on a
76
+ schedule, or from a deploy hook that waits for the result.
64
77
 
65
78
  ### How it compares to `migrate-mongo`
66
79
 
@@ -76,6 +89,7 @@ change before it touches your database.
76
89
  | Lifecycle hooks | ❌ | ✅ |
77
90
  | First-class TypeScript (built-in) | ❌ | ✅ |
78
91
  | History preserved on rollback (never deleted) | ❌ | ✅ |
92
+ | Declared indexes & validators (`converge`) | ❌ | ✅ |
79
93
  | Adopt an existing `migrate-mongo` changelog | — | ✅ `migronaut import` |
80
94
 
81
95
  <sub>Reflects `migrate-mongo`'s documented CLI as of mid-2026 (v14: optional
@@ -101,6 +115,8 @@ via a `client` argument; `migronaut` exposes the same plus a declarative per-fil
101
115
  | Changelog written inside the migration's transaction | ❌ | ✅ |
102
116
  | Credentials masked in errors, logs and `--json` | ❌ | ✅ |
103
117
  | Pino-compatible logger | ❌ | ✅ |
118
+ | BullMQ queue adapter (`/bullmq` entry point) | ❌ | ✅ |
119
+ | Declared indexes & validators (`converge`) | ❌ | ✅ |
104
120
  | Node floor | ≥ 18 | ≥ 22.18 |
105
121
 
106
122
  <sub>Compared against `mongo-migrate-kit` 1.2.2 — the version this project forked from. The Node
@@ -161,7 +177,7 @@ Full docs, guides, and the API reference live at
161
177
  - [Core Concepts](https://migronaut.vercel.app/guide/concepts) — migrations, batches, the changelog, locking
162
178
  - [Getting Started](https://migronaut.vercel.app/guide/getting-started) & [Tutorial](https://migronaut.vercel.app/guide/tutorial)
163
179
  - [Configuration](https://migronaut.vercel.app/guide/configuration) · [Writing Migrations](https://migronaut.vercel.app/guide/writing-migrations) · [Transactions](https://migronaut.vercel.app/guide/transactions) · [Hooks](https://migronaut.vercel.app/guide/hooks)
164
- - [Programmatic API](https://migronaut.vercel.app/guide/api) · [CI/CD](https://migronaut.vercel.app/guide/ci-cd) · [Troubleshooting](https://migronaut.vercel.app/guide/troubleshooting)
180
+ - [Programmatic API](https://migronaut.vercel.app/guide/api) · [Migrations as a Queue (BullMQ)](https://migronaut.vercel.app/guide/bullmq) · [OpenTelemetry](https://migronaut.vercel.app/guide/opentelemetry) · [CI/CD](https://migronaut.vercel.app/guide/ci-cd) · [Troubleshooting](https://migronaut.vercel.app/guide/troubleshooting)
165
181
  - Reference: [CLI Cheatsheet](https://migronaut.vercel.app/reference/cli) · [Error Codes](https://migronaut.vercel.app/reference/error-codes)
166
182
 
167
183
  ---
@@ -180,6 +196,7 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
180
196
  | `migronaut up [file]` | Run all pending migrations, one named file, or up to `--to <file>` |
181
197
  | `migronaut down [file]` | Roll back the last batch, a chosen batch, the last N steps, one file, or to `--to <file>` |
182
198
  | `migronaut redo [file]` | Roll back then re-apply (the last migration, or one file) |
199
+ | `migronaut converge` | Bring declared collections — indexes and validators — to their declared state |
183
200
  | `migronaut status` | Print the full migration status table (`--check` to fail CI on pending) |
184
201
  | `migronaut list` | List migrations, filtered by status |
185
202
  | `migronaut dry-run <up\|down> [file]` | Preview a run without touching the database |
@@ -187,8 +204,8 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
187
204
  | `migronaut lock` | Show who currently holds the migration lock |
188
205
  | `migronaut unlock` | Force-release a stuck lock left behind by a crashed run |
189
206
 
190
- Most data commands (`up`, `down`, `redo`, `status`, `list`, `dry-run`, `import`, `baseline`,
191
- `create`, `audit`, `lock`, `unlock`) accept **`--json`** for machine-readable output — see
207
+ Most data commands (`up`, `down`, `redo`, `converge`, `status`, `list`, `dry-run`, `import`,
208
+ `baseline`, `create`, `audit`, `lock`, `unlock`) accept **`--json`** for machine-readable output — see
192
209
  [CI & automation](#ci--automation).
193
210
 
194
211
  <details>
@@ -236,6 +253,8 @@ migronaut up <file> --force # re-run an ALREADY-applied file (asks for co
236
253
  migronaut up <file> --force --yes # confirm a re-run non-interactively (required with --json)
237
254
  migronaut up --strict # abort on any checksum mismatch
238
255
  migronaut up --no-lock # skip the concurrency lock (local dev only)
256
+ migronaut up --converge # converge the declared collections afterwards (bulk runs only)
257
+ migronaut up --no-converge # don't, even with convergeAfterUp on
239
258
  migronaut up --json # machine-readable output (array of run results)
240
259
 
241
260
  # down — roll back
@@ -253,6 +272,15 @@ migronaut redo <file> # a specific file
253
272
  migronaut redo --no-lock # skip the lock (dev only)
254
273
  migronaut redo --json # machine-readable output (array of run results)
255
274
 
275
+ # converge — declared indexes and validators → the database
276
+ migronaut converge # plan, ask before any drop/rebuild, then apply
277
+ migronaut converge --dry-run # show the plan, change nothing
278
+ migronaut converge --check # exit 28 if anything would change (CI gate)
279
+ migronaut converge --prune # also drop indexes a definition does not declare
280
+ migronaut converge --yes # no confirmation (required for drops/rebuilds with --json)
281
+ migronaut converge --no-lock # skip the concurrency lock (local dev only)
282
+ migronaut converge --json # machine-readable output (the converge result)
283
+
256
284
  # status — full status table
257
285
  migronaut status # the full status table
258
286
  migronaut status --check # exit 2 if any migration is pending (CI gate)
@@ -601,6 +629,95 @@ All errors extend `MigronautError` and carry a typed `code` (`LOCK_ALREADY_HELD`
601
629
 
602
630
  </details>
603
631
 
632
+ <details id="declared-collections">
633
+ <summary><b>Declared collections</b> — indexes and validators as an end state, with no migration file per change</summary>
634
+
635
+ <br>
636
+
637
+ When what matters is *the final shape* of a collection's indexes and validator — not the history
638
+ of how it got there — declare it and let `migronaut converge` make the difference:
639
+
640
+ ```js
641
+ // migronaut.config.js
642
+ export default {
643
+ uri: process.env.MIGRONAUT_URI,
644
+ dbName: 'my_app',
645
+ collections: [
646
+ {
647
+ name: 'users',
648
+ indexes: [
649
+ { key: { email: 1 }, unique: true },
650
+ { key: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
651
+ ],
652
+ validator: { $jsonSchema: { bsonType: 'object', required: ['email'] } },
653
+ },
654
+ ],
655
+ collectionsDir: './collections', // …and/or one file per collection
656
+ };
657
+ ```
658
+
659
+ ```bash
660
+ migronaut converge --dry-run # what would change
661
+ migronaut converge # apply — asks before dropping or rebuilding an index
662
+ migronaut converge --check # exit 28 on drift: a CI gate
663
+ ```
664
+
665
+ - **Stateless.** Every run reads `listIndexes` / `listCollections` and compares; nothing is
666
+ recorded. Edit the declaration, converge again.
667
+ - **Safe by default.** Missing indexes are created, a TTL or `hidden` change is applied in place,
668
+ a changed index is rebuilt (and put back if the rebuild fails). An index you did not declare is
669
+ **kept and reported** — dropped only with `prune`. An identical index under another name is
670
+ accepted as is, never silently rebuilt.
671
+ - **Locked like a migration**, and refused before the first write when the plan has a conflict.
672
+ - **After every deploy** with `convergeAfterUp: true` — a bulk `up` then ends by converging,
673
+ under the same lock — or as a [queue job](https://migronaut.vercel.app/guide/bullmq#converge-jobs).
674
+
675
+ Experimental in 2.1. → **[Declared Collections](https://migronaut.vercel.app/guide/collections)**
676
+
677
+ </details>
678
+
679
+ <details>
680
+ <summary><b>Migrations as a queue</b> — a migration service on BullMQ, one migration per job</summary>
681
+
682
+ <br>
683
+
684
+ `@alexify/migronaut/bullmq` enqueues each pending migration as its own BullMQ job and applies them
685
+ in order with a single-concurrency worker. BullMQ is **injected** — it is your dependency, never
686
+ migronaut's:
687
+
688
+ ```js
689
+ const { Queue, Worker, QueueEvents } = require('bullmq');
690
+ const { createMigrationQueue } = require('@alexify/migronaut/bullmq');
691
+
692
+ const mq = createMigrationQueue({
693
+ config: { uri: process.env.MIGRONAUT_URI, dbName: 'my_app' },
694
+ bullmq: { Queue, Worker, QueueEvents },
695
+ connection: { host: 'redis', port: 6379 },
696
+ });
697
+
698
+ await mq.startWorker(); // the process that applies migrations
699
+
700
+ const group = await mq.enqueueUp(); // one job per pending migration, one shared batch
701
+ const { results } = await group.wait(); // optional: block until they all finished
702
+
703
+ await mq.enqueueDown(); // roll the last batch back, newest first
704
+ await mq.schedule({ every: 300_000 }); // or keep the database migrated on a schedule
705
+ await mq.enqueueConverge(); // declared indexes and validators, as a job
706
+ ```
707
+
708
+ - **Order comes from MongoDB, not from Redis.** Every job is a normal single-file run under the
709
+ usual lock, and refuses while an earlier migration is still pending — so a failed migration
710
+ stops the line (`MIGRATION_BLOCKED`), and a CLI `migronaut up` at the same moment is safe.
711
+ - **One attempt per job, on purpose** — a queue retry would let later migrations overtake the
712
+ failed one. Duplicate enqueues are deduplicated; an already-applied migration completes as
713
+ `skipped`.
714
+ - **Bring your own Worker** with `createMigrationProcessor()` (NestJS, BullMQ Pro).
715
+
716
+ → **[Migrations as a Queue](https://migronaut.vercel.app/guide/bullmq)** ·
717
+ [runnable example service](examples/migration-service)
718
+
719
+ </details>
720
+
604
721
  ---
605
722
 
606
723
  ## Configuration
@@ -633,19 +750,100 @@ export default {
633
750
  // ── Bookkeeping collections ─────────────────────────────────────────────
634
751
  migrationsCollection: '_migronaut_migrations', // the append-only audit trail
635
752
  lockCollection: '_migronaut_locks', // the concurrency lock
753
+ convergeLogCollection: '_migronaut_converge', // what each converge changed
636
754
  lockTTLSeconds: 60, // a lock older than this is reclaimable
637
755
 
638
756
  // ── Safety ──────────────────────────────────────────────────────────────
639
757
  strict: false, // true → abort on a checksum mismatch (instead of warn + skip)
640
758
  useTransaction: false, // true → wrap every migration in a transaction (override per file)
641
759
 
760
+ // ── Declared collections (experimental) — see `migronaut converge` ──────
761
+ // collections: [{ name: 'users', indexes: [{ key: { email: 1 }, unique: true }] }],
762
+ // collectionsDir: './collections', // one definition file per collection
763
+ // convergeAfterUp: false, // true → every bulk `up` ends by converging
764
+
642
765
  // ── Code-only options (omit in migronaut.config.json) ─────────────────────────
643
766
  // hooks: { beforeAll, afterAll, beforeEach, afterEach, onError },
644
767
  // mongoose: myMongooseInstance, // pass if your migrations use Mongoose models
645
768
  // logger: null, // null silences all output; a pino instance works directly
769
+ // generateId: ulid, // your id format for run ids — any sync `() => string`
770
+ // telemetry: { tracer, meter }, // OpenTelemetry, from your own @opentelemetry/api
646
771
  };
647
772
  ```
648
773
 
774
+ <details>
775
+ <summary><b>Custom id format</b> — ULID, CUID, UUIDv7 or anything else instead of UUIDs</summary>
776
+
777
+ <br>
778
+
779
+ Every run gets a `runId` — stamped on its changelog records, events and log lines, and stored as
780
+ the lock's owner token — and every queue enqueue gets a `groupId`. Both are `crypto.randomUUID()`
781
+ by default. Pass `generateId` to mint them with the generator the rest of your system uses:
782
+
783
+ ```js
784
+ const { ulid } = require('ulid');
785
+ const { runMigrations } = require('@alexify/migronaut');
786
+
787
+ await runMigrations({
788
+ uri: process.env.MIGRONAUT_URI,
789
+ dbName: 'my_app',
790
+ generateId: ulid, // createId (cuid2), nanoid, uuidv7 … pass straight through
791
+ });
792
+ ```
793
+
794
+ It is called with no arguments and must synchronously return a non-empty string of at most 128
795
+ characters; anything else fails the run with `CONFIG_INVALID` before a migration starts. Ids are
796
+ for correlation only — the lock carries a token of its own, so a generator that repeats a value
797
+ can never let two runs hold the lock at once.
798
+
799
+ </details>
800
+
801
+ <details>
802
+ <summary><b>OpenTelemetry</b> — a span per run and per migration, and their durations as metrics</summary>
803
+
804
+ <br>
805
+
806
+ Pass a tracer and/or a meter from your own `@opentelemetry/api` — migronaut never imports it:
807
+
808
+ ```js
809
+ const { metrics, trace } = require('@opentelemetry/api');
810
+ const { runMigrations } = require('@alexify/migronaut');
811
+
812
+ await runMigrations({
813
+ uri: process.env.MIGRONAUT_URI,
814
+ dbName: 'my_app',
815
+ telemetry: {
816
+ tracer: trace.getTracer('@alexify/migronaut'),
817
+ meter: metrics.getMeter('@alexify/migronaut'),
818
+ },
819
+ });
820
+ ```
821
+
822
+ ```
823
+ migronaut.run one per run, once it holds the lock
824
+ └─ migronaut.migration one per migration — the ACTIVE span while it runs
825
+ ├─ insert users ← your instrumented MongoDB driver nests here
826
+ └─ update _migronaut_migrations
827
+ ```
828
+
829
+ That nesting is the point. An instrumented driver only records a command that has a parent span,
830
+ and a migration run at application startup has none — so without this, the driver's spans for your
831
+ migrations are simply missing. A [lifecycle event](#advanced-features) can tell you a migration
832
+ started; only a span opened inside the kit can be the parent of what it does next.
833
+
834
+ The meter gets `migronaut.run.duration`, `migronaut.migration.duration` and
835
+ `migronaut.lock.acquire.duration` (histograms, seconds), plus the counters `migronaut.lock.refused`
836
+ and `migronaut.lock.lost`. A failure sets the span's status to `ERROR` — with the message redacted
837
+ like every log line — and `error.type` to the typed error code. A tracer or meter that throws never
838
+ fails a run.
839
+
840
+ Through the [BullMQ adapter](https://migronaut.vercel.app/guide/bullmq), add
841
+ `bullmq: { Queue, Worker, telemetry: new BullMQOtel({ tracerName }) }` and one trace runs from the
842
+ request that enqueued to the MongoDB commands in the worker. Full guide:
843
+ [OpenTelemetry](https://migronaut.vercel.app/guide/opentelemetry).
844
+
845
+ </details>
846
+
649
847
  <details>
650
848
  <summary><b>Structured logging with pino</b> — the logger option is pino-compatible</summary>
651
849
 
@@ -683,6 +881,7 @@ optional rather than merely discouraged:
683
881
  | `MIGRONAUT_MIGRATIONS_DIR` | `migrationsDir` | `./migrations` |
684
882
  | `MIGRONAUT_COLLECTION` | `migrationsCollection` | `_migronaut_migrations` |
685
883
  | `MIGRONAUT_LOCK_COLLECTION` | `lockCollection` | `_migronaut_locks` |
884
+ | `MIGRONAUT_CONVERGE_LOG_COLLECTION` | `convergeLogCollection` | `_migronaut_converge` |
686
885
  | `MIGRONAUT_LOCK_TTL` | `lockTTLSeconds` | `60` |
687
886
  | `MIGRONAUT_STRICT` | `strict` | `false` |
688
887
  | `MIGRONAUT_USE_TRANSACTION` | `useTransaction` | `false` |
@@ -695,10 +894,13 @@ optional rather than merely discouraged:
695
894
  | `MIGRONAUT_ON_OUT_OF_ORDER` | `onOutOfOrder` | `warn` |
696
895
  | `MIGRONAUT_ENSURE_INDEXES` | `ensureIndexes` | `true` |
697
896
  | `MIGRONAUT_RELOAD_MIGRATIONS` | `reloadMigrations` | `false` |
897
+ | `MIGRONAUT_COLLECTIONS_DIR` | `collectionsDir` | — *(none read)* |
898
+ | `MIGRONAUT_CONVERGE_AFTER_UP` | `convergeAfterUp` | `false` |
698
899
  | `MIGRONAUT_ENV_FILE` | `envFile` | `.env` |
699
900
 
700
- `fileExtensions`, `clientOptions`, `client`, `mongoose`, `hooks` and `logger` are config-file/API
701
- only — they aren't scalars, so no environment variable can express them.
901
+ `fileExtensions`, `clientOptions`, `collections`, `client`, `mongoose`, `hooks`, `logger`,
902
+ `generateId` and `telemetry` are config-file/API only — they aren't scalars, so no environment
903
+ variable can express them.
702
904
 
703
905
  A value that doesn't parse is **rejected, never coerced**: `MIGRONAUT_STRICT=on` or
704
906
  `MIGRONAUT_LOCK_TTL=abc` fails with an error naming the variable, rather than quietly turning a