@alexify/migronaut 2.0.0 → 2.2.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 (54) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/README.md +235 -6
  3. package/bullmq.d.ts +860 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +888 -19
  6. package/migronaut.schema.json +238 -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 +632 -0
  11. package/src/bullmq/producer.js +427 -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 +188 -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 +164 -0
  24. package/src/core/audit.js +88 -3
  25. package/src/core/changelog.js +71 -6
  26. package/src/core/collections.js +396 -0
  27. package/src/core/config.js +130 -25
  28. package/src/core/converge-log.js +47 -0
  29. package/src/core/converge-plan.js +686 -0
  30. package/src/core/converge-search-run.js +440 -0
  31. package/src/core/converge-search.js +404 -0
  32. package/src/core/converge.js +1024 -0
  33. package/src/core/index-spec.js +507 -0
  34. package/src/core/lock-wait.js +260 -0
  35. package/src/core/lock.js +95 -28
  36. package/src/core/migrator.js +600 -287
  37. package/src/core/options.js +266 -0
  38. package/src/core/run-recorder.js +157 -0
  39. package/src/core/run.js +58 -90
  40. package/src/core/search-index-spec.js +758 -0
  41. package/src/core/sequence.js +134 -0
  42. package/src/core/server-info.js +63 -0
  43. package/src/errors/index.js +60 -0
  44. package/src/index.js +8 -0
  45. package/src/utils/actor.js +48 -0
  46. package/src/utils/canonical.js +212 -0
  47. package/src/utils/collection-name.js +21 -0
  48. package/src/utils/error.js +18 -1
  49. package/src/utils/id.js +77 -0
  50. package/src/utils/loader.js +39 -21
  51. package/src/utils/migration-name.js +32 -0
  52. package/src/utils/redact.js +21 -1
  53. package/src/utils/telemetry.js +410 -0
  54. package/src/utils/template.js +43 -2
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,22 @@ 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, search indexes and validators as an end state** — declare them (Atlas Search and
63
+ Vector Search indexes included), and `migronaut converge` brings the database to match; no
64
+ migration file per index change ([details](#declared-collections)).
60
65
  - **TypeScript, ESM & CommonJS** — all run with no `ts-node` plumbing.
61
66
  - **Zero config files required** — drive everything from env vars if you prefer.
62
67
  - **Pino-friendly logging** — the `logger` option is pino-compatible; pass a pino instance directly
63
68
  and migronaut logs through it (with a `component: 'migronaut'` child binding).
69
+ - **Your id format** — run ids and queue group ids are random UUIDs by default; pass
70
+ `generateId: ulid` (or cuid2, nanoid, UUIDv7 — any `() => string`) and every id migronaut mints
71
+ comes from your generator.
72
+ - **OpenTelemetry (optional)** — pass a tracer and a meter from your own `@opentelemetry/api`: a
73
+ span per run and per migration, active while the migration runs, so an instrumented MongoDB
74
+ driver nests its command spans under the migration that issued them — plus duration metrics.
75
+ - **Migrations as a queue (optional)** — `@alexify/migronaut/bullmq` runs each migration as its own
76
+ BullMQ job, in order, so migronaut can be a migration service: trigger it over HTTP, on a
77
+ schedule, or from a deploy hook that waits for the result.
64
78
 
65
79
  ### How it compares to `migrate-mongo`
66
80
 
@@ -76,6 +90,8 @@ change before it touches your database.
76
90
  | Lifecycle hooks | ❌ | ✅ |
77
91
  | First-class TypeScript (built-in) | ❌ | ✅ |
78
92
  | History preserved on rollback (never deleted) | ❌ | ✅ |
93
+ | Declared indexes & validators (`converge`) | ❌ | ✅ |
94
+ | Declared Atlas Search / Vector Search indexes | ❌ | ✅ |
79
95
  | Adopt an existing `migrate-mongo` changelog | — | ✅ `migronaut import` |
80
96
 
81
97
  <sub>Reflects `migrate-mongo`'s documented CLI as of mid-2026 (v14: optional
@@ -101,6 +117,9 @@ via a `client` argument; `migronaut` exposes the same plus a declarative per-fil
101
117
  | Changelog written inside the migration's transaction | ❌ | ✅ |
102
118
  | Credentials masked in errors, logs and `--json` | ❌ | ✅ |
103
119
  | Pino-compatible logger | ❌ | ✅ |
120
+ | BullMQ queue adapter (`/bullmq` entry point) | ❌ | ✅ |
121
+ | Declared indexes & validators (`converge`) | ❌ | ✅ |
122
+ | Declared Atlas Search / Vector Search indexes | ❌ | ✅ |
104
123
  | Node floor | ≥ 18 | ≥ 22.18 |
105
124
 
106
125
  <sub>Compared against `mongo-migrate-kit` 1.2.2 — the version this project forked from. The Node
@@ -161,7 +180,7 @@ Full docs, guides, and the API reference live at
161
180
  - [Core Concepts](https://migronaut.vercel.app/guide/concepts) — migrations, batches, the changelog, locking
162
181
  - [Getting Started](https://migronaut.vercel.app/guide/getting-started) & [Tutorial](https://migronaut.vercel.app/guide/tutorial)
163
182
  - [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)
183
+ - [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
184
  - Reference: [CLI Cheatsheet](https://migronaut.vercel.app/reference/cli) · [Error Codes](https://migronaut.vercel.app/reference/error-codes)
166
185
 
167
186
  ---
@@ -180,6 +199,7 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
180
199
  | `migronaut up [file]` | Run all pending migrations, one named file, or up to `--to <file>` |
181
200
  | `migronaut down [file]` | Roll back the last batch, a chosen batch, the last N steps, one file, or to `--to <file>` |
182
201
  | `migronaut redo [file]` | Roll back then re-apply (the last migration, or one file) |
202
+ | `migronaut converge` | Bring declared collections — indexes, search indexes and validators — to their declared state |
183
203
  | `migronaut status` | Print the full migration status table (`--check` to fail CI on pending) |
184
204
  | `migronaut list` | List migrations, filtered by status |
185
205
  | `migronaut dry-run <up\|down> [file]` | Preview a run without touching the database |
@@ -187,8 +207,8 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
187
207
  | `migronaut lock` | Show who currently holds the migration lock |
188
208
  | `migronaut unlock` | Force-release a stuck lock left behind by a crashed run |
189
209
 
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
210
+ Most data commands (`up`, `down`, `redo`, `converge`, `status`, `list`, `dry-run`, `import`,
211
+ `baseline`, `create`, `audit`, `lock`, `unlock`) accept **`--json`** for machine-readable output — see
192
212
  [CI & automation](#ci--automation).
193
213
 
194
214
  <details>
@@ -236,6 +256,8 @@ migronaut up <file> --force # re-run an ALREADY-applied file (asks for co
236
256
  migronaut up <file> --force --yes # confirm a re-run non-interactively (required with --json)
237
257
  migronaut up --strict # abort on any checksum mismatch
238
258
  migronaut up --no-lock # skip the concurrency lock (local dev only)
259
+ migronaut up --converge # converge the declared collections afterwards (bulk runs only)
260
+ migronaut up --no-converge # don't, even with convergeAfterUp on
239
261
  migronaut up --json # machine-readable output (array of run results)
240
262
 
241
263
  # down — roll back
@@ -253,6 +275,16 @@ migronaut redo <file> # a specific file
253
275
  migronaut redo --no-lock # skip the lock (dev only)
254
276
  migronaut redo --json # machine-readable output (array of run results)
255
277
 
278
+ # converge — declared indexes, search indexes and validators → the database
279
+ migronaut converge # plan, ask before any drop/rebuild, then apply
280
+ migronaut converge --dry-run # show the plan, change nothing
281
+ migronaut converge --check # exit 28 if anything would change (CI gate)
282
+ migronaut converge --prune # also drop indexes a definition does not declare
283
+ migronaut converge --wait-search # wait until every declared search index is queryable
284
+ migronaut converge --yes # no confirmation (required for drops/rebuilds with --json)
285
+ migronaut converge --no-lock # skip the concurrency lock (local dev only)
286
+ migronaut converge --json # machine-readable output (the converge result)
287
+
256
288
  # status — full status table
257
289
  migronaut status # the full status table
258
290
  migronaut status --check # exit 2 if any migration is pending (CI gate)
@@ -601,6 +633,113 @@ All errors extend `MigronautError` and carry a typed `code` (`LOCK_ALREADY_HELD`
601
633
 
602
634
  </details>
603
635
 
636
+ <details id="declared-collections">
637
+ <summary><b>Declared collections</b> — indexes, search indexes and validators as an end state, with no migration file per change</summary>
638
+
639
+ <br>
640
+
641
+ When what matters is *the final shape* of a collection's indexes, Atlas Search indexes and
642
+ validator — not the history of how it got there — declare it and let `migronaut converge` make the
643
+ difference:
644
+
645
+ ```js
646
+ // migronaut.config.js
647
+ export default {
648
+ uri: process.env.MIGRONAUT_URI,
649
+ dbName: 'my_app',
650
+ collections: [
651
+ {
652
+ name: 'users',
653
+ indexes: [
654
+ { key: { email: 1 }, unique: true },
655
+ { key: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
656
+ ],
657
+ validator: { $jsonSchema: { bsonType: 'object', required: ['email'] } },
658
+ },
659
+ {
660
+ name: 'movies',
661
+ searchIndexes: [
662
+ { definition: { mappings: { dynamic: true } } }, // Atlas Search, named "default"
663
+ {
664
+ name: 'plot_vectors',
665
+ type: 'vectorSearch',
666
+ definition: {
667
+ fields: [{ type: 'vector', path: 'embedding', numDimensions: 1536, similarity: 'cosine' }],
668
+ },
669
+ },
670
+ ],
671
+ },
672
+ ],
673
+ collectionsDir: './collections', // …and/or one file per collection
674
+ };
675
+ ```
676
+
677
+ ```bash
678
+ migronaut converge --dry-run # what would change
679
+ migronaut converge # apply — asks before dropping or rebuilding an index
680
+ migronaut converge --check # exit 28 on drift: a CI gate
681
+ ```
682
+
683
+ - **Stateless.** Every run reads `listIndexes` / `listCollections` and compares; nothing is
684
+ recorded. Edit the declaration, converge again.
685
+ - **Safe by default.** Missing indexes are created, a TTL or `hidden` change is applied in place,
686
+ a changed index is rebuilt (and put back if the rebuild fails). An index you did not declare is
687
+ **kept and reported** — dropped only with `prune`. An identical index under another name is
688
+ accepted as is, never silently rebuilt.
689
+ - **Search indexes, too** — on Atlas, an Atlas CLI local deployment or MongoDB 8.3+ with `mongot`.
690
+ A changed definition is updated in place (on Atlas the old one serves until the new one is
691
+ built), never dropped and rebuilt; `--wait-search` waits until the builds are queryable. On a server without
692
+ Search, converge refuses — or skips them with `onSearchUnavailable: 'skip'`.
693
+ - **Locked like a migration**, and refused before the first write when the plan has a conflict.
694
+ - **After every deploy** with `convergeAfterUp: true` — a bulk `up` then ends by converging,
695
+ under the same lock — or as a [queue job](https://migronaut.vercel.app/guide/bullmq#converge-jobs).
696
+
697
+ Experimental since 2.1 (search indexes: 2.2). → **[Declared Collections](https://migronaut.vercel.app/guide/collections)**
698
+
699
+ </details>
700
+
701
+ <details>
702
+ <summary><b>Migrations as a queue</b> — a migration service on BullMQ, one migration per job</summary>
703
+
704
+ <br>
705
+
706
+ `@alexify/migronaut/bullmq` enqueues each pending migration as its own BullMQ job and applies them
707
+ in order with a single-concurrency worker. BullMQ is **injected** — it is your dependency, never
708
+ migronaut's:
709
+
710
+ ```js
711
+ const { Queue, Worker, QueueEvents } = require('bullmq');
712
+ const { createMigrationQueue } = require('@alexify/migronaut/bullmq');
713
+
714
+ const mq = createMigrationQueue({
715
+ config: { uri: process.env.MIGRONAUT_URI, dbName: 'my_app' },
716
+ bullmq: { Queue, Worker, QueueEvents },
717
+ connection: { host: 'redis', port: 6379 },
718
+ });
719
+
720
+ await mq.startWorker(); // the process that applies migrations
721
+
722
+ const group = await mq.enqueueUp(); // one job per pending migration, one shared batch
723
+ const { results } = await group.wait(); // optional: block until they all finished
724
+
725
+ await mq.enqueueDown(); // roll the last batch back, newest first
726
+ await mq.schedule({ every: 300_000 }); // or keep the database migrated on a schedule
727
+ await mq.enqueueConverge(); // declared indexes and validators, as a job
728
+ ```
729
+
730
+ - **Order comes from MongoDB, not from Redis.** Every job is a normal single-file run under the
731
+ usual lock, and refuses while an earlier migration is still pending — so a failed migration
732
+ stops the line (`MIGRATION_BLOCKED`), and a CLI `migronaut up` at the same moment is safe.
733
+ - **One attempt per job, on purpose** — a queue retry would let later migrations overtake the
734
+ failed one. Duplicate enqueues are deduplicated; an already-applied migration completes as
735
+ `skipped`.
736
+ - **Bring your own Worker** with `createMigrationProcessor()` (NestJS, BullMQ Pro).
737
+
738
+ → **[Migrations as a Queue](https://migronaut.vercel.app/guide/bullmq)** ·
739
+ [runnable example service](examples/migration-service)
740
+
741
+ </details>
742
+
604
743
  ---
605
744
 
606
745
  ## Configuration
@@ -633,19 +772,102 @@ export default {
633
772
  // ── Bookkeeping collections ─────────────────────────────────────────────
634
773
  migrationsCollection: '_migronaut_migrations', // the append-only audit trail
635
774
  lockCollection: '_migronaut_locks', // the concurrency lock
775
+ convergeLogCollection: '_migronaut_converge', // what each converge changed
636
776
  lockTTLSeconds: 60, // a lock older than this is reclaimable
637
777
 
638
778
  // ── Safety ──────────────────────────────────────────────────────────────
639
779
  strict: false, // true → abort on a checksum mismatch (instead of warn + skip)
640
780
  useTransaction: false, // true → wrap every migration in a transaction (override per file)
641
781
 
782
+ // ── Declared collections (experimental) — see `migronaut converge` ──────
783
+ // collections: [{ name: 'users', indexes: [{ key: { email: 1 }, unique: true }] }],
784
+ // collectionsDir: './collections', // one definition file per collection
785
+ // convergeAfterUp: false, // true → every bulk `up` ends by converging
786
+ // onSearchUnavailable: 'fail', // 'skip' → converge without search indexes where Search is absent
787
+ // waitForSearchIndexes: false, // true → converge waits until search indexes are queryable
788
+
642
789
  // ── Code-only options (omit in migronaut.config.json) ─────────────────────────
643
790
  // hooks: { beforeAll, afterAll, beforeEach, afterEach, onError },
644
791
  // mongoose: myMongooseInstance, // pass if your migrations use Mongoose models
645
792
  // logger: null, // null silences all output; a pino instance works directly
793
+ // generateId: ulid, // your id format for run ids — any sync `() => string`
794
+ // telemetry: { tracer, meter }, // OpenTelemetry, from your own @opentelemetry/api
646
795
  };
647
796
  ```
648
797
 
798
+ <details>
799
+ <summary><b>Custom id format</b> — ULID, CUID, UUIDv7 or anything else instead of UUIDs</summary>
800
+
801
+ <br>
802
+
803
+ Every run gets a `runId` — stamped on its changelog records, events and log lines, and stored as
804
+ the lock's owner token — and every queue enqueue gets a `groupId`. Both are `crypto.randomUUID()`
805
+ by default. Pass `generateId` to mint them with the generator the rest of your system uses:
806
+
807
+ ```js
808
+ const { ulid } = require('ulid');
809
+ const { runMigrations } = require('@alexify/migronaut');
810
+
811
+ await runMigrations({
812
+ uri: process.env.MIGRONAUT_URI,
813
+ dbName: 'my_app',
814
+ generateId: ulid, // createId (cuid2), nanoid, uuidv7 … pass straight through
815
+ });
816
+ ```
817
+
818
+ It is called with no arguments and must synchronously return a non-empty string of at most 128
819
+ characters; anything else fails the run with `CONFIG_INVALID` before a migration starts. Ids are
820
+ for correlation only — the lock carries a token of its own, so a generator that repeats a value
821
+ can never let two runs hold the lock at once.
822
+
823
+ </details>
824
+
825
+ <details>
826
+ <summary><b>OpenTelemetry</b> — a span per run and per migration, and their durations as metrics</summary>
827
+
828
+ <br>
829
+
830
+ Pass a tracer and/or a meter from your own `@opentelemetry/api` — migronaut never imports it:
831
+
832
+ ```js
833
+ const { metrics, trace } = require('@opentelemetry/api');
834
+ const { runMigrations } = require('@alexify/migronaut');
835
+
836
+ await runMigrations({
837
+ uri: process.env.MIGRONAUT_URI,
838
+ dbName: 'my_app',
839
+ telemetry: {
840
+ tracer: trace.getTracer('@alexify/migronaut'),
841
+ meter: metrics.getMeter('@alexify/migronaut'),
842
+ },
843
+ });
844
+ ```
845
+
846
+ ```
847
+ migronaut.run one per run, once it holds the lock
848
+ └─ migronaut.migration one per migration — the ACTIVE span while it runs
849
+ ├─ insert users ← your instrumented MongoDB driver nests here
850
+ └─ update _migronaut_migrations
851
+ ```
852
+
853
+ That nesting is the point. An instrumented driver only records a command that has a parent span,
854
+ and a migration run at application startup has none — so without this, the driver's spans for your
855
+ migrations are simply missing. A [lifecycle event](#advanced-features) can tell you a migration
856
+ started; only a span opened inside the kit can be the parent of what it does next.
857
+
858
+ The meter gets `migronaut.run.duration`, `migronaut.migration.duration` and
859
+ `migronaut.lock.acquire.duration` (histograms, seconds), plus the counters `migronaut.lock.refused`
860
+ and `migronaut.lock.lost`. A failure sets the span's status to `ERROR` — with the message redacted
861
+ like every log line — and `error.type` to the typed error code. A tracer or meter that throws never
862
+ fails a run.
863
+
864
+ Through the [BullMQ adapter](https://migronaut.vercel.app/guide/bullmq), add
865
+ `bullmq: { Queue, Worker, telemetry: new BullMQOtel({ tracerName }) }` and one trace runs from the
866
+ request that enqueued to the MongoDB commands in the worker. Full guide:
867
+ [OpenTelemetry](https://migronaut.vercel.app/guide/opentelemetry).
868
+
869
+ </details>
870
+
649
871
  <details>
650
872
  <summary><b>Structured logging with pino</b> — the logger option is pino-compatible</summary>
651
873
 
@@ -683,6 +905,7 @@ optional rather than merely discouraged:
683
905
  | `MIGRONAUT_MIGRATIONS_DIR` | `migrationsDir` | `./migrations` |
684
906
  | `MIGRONAUT_COLLECTION` | `migrationsCollection` | `_migronaut_migrations` |
685
907
  | `MIGRONAUT_LOCK_COLLECTION` | `lockCollection` | `_migronaut_locks` |
908
+ | `MIGRONAUT_CONVERGE_LOG_COLLECTION` | `convergeLogCollection` | `_migronaut_converge` |
686
909
  | `MIGRONAUT_LOCK_TTL` | `lockTTLSeconds` | `60` |
687
910
  | `MIGRONAUT_STRICT` | `strict` | `false` |
688
911
  | `MIGRONAUT_USE_TRANSACTION` | `useTransaction` | `false` |
@@ -695,10 +918,16 @@ optional rather than merely discouraged:
695
918
  | `MIGRONAUT_ON_OUT_OF_ORDER` | `onOutOfOrder` | `warn` |
696
919
  | `MIGRONAUT_ENSURE_INDEXES` | `ensureIndexes` | `true` |
697
920
  | `MIGRONAUT_RELOAD_MIGRATIONS` | `reloadMigrations` | `false` |
921
+ | `MIGRONAUT_COLLECTIONS_DIR` | `collectionsDir` | — *(none read)* |
922
+ | `MIGRONAUT_CONVERGE_AFTER_UP` | `convergeAfterUp` | `false` |
923
+ | `MIGRONAUT_ON_SEARCH_UNAVAILABLE` | `onSearchUnavailable` | `fail` |
924
+ | `MIGRONAUT_WAIT_FOR_SEARCH_INDEXES` | `waitForSearchIndexes` | `false` |
925
+ | `MIGRONAUT_SEARCH_INDEX_WAIT_TIMEOUT_MS` | `searchIndexWaitTimeoutMs` | `600000` |
698
926
  | `MIGRONAUT_ENV_FILE` | `envFile` | `.env` |
699
927
 
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.
928
+ `fileExtensions`, `clientOptions`, `collections`, `client`, `mongoose`, `hooks`, `logger`,
929
+ `generateId` and `telemetry` are config-file/API only — they aren't scalars, so no environment
930
+ variable can express them.
702
931
 
703
932
  A value that doesn't parse is **rejected, never coerced**: `MIGRONAUT_STRICT=on` or
704
933
  `MIGRONAUT_LOCK_TTL=abc` fails with an error naming the variable, rather than quietly turning a