@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/index.d.ts CHANGED
@@ -143,6 +143,16 @@ export interface MigrationHooks {
143
143
  /** File type a created migration is written as */
144
144
  export type MigrationExtension = 'ts' | 'js';
145
145
 
146
+ /**
147
+ * Mints one identifier. Called with **no arguments** and no `this`, so a
148
+ * third-party generator passes straight through (`generateId: ulid`,
149
+ * `generateId: createId`, `generateId: nanoid`). It must be **synchronous** and
150
+ * return a non-empty string of at most 128 characters, different on every
151
+ * call; anything else — a throw and a returned promise included — fails the
152
+ * run with a {@link ConfigInvalidError}.
153
+ */
154
+ export type IdGenerator = () => string;
155
+
146
156
  /**
147
157
  * Every **scalar** option below is also settable from the environment as
148
158
  * `MIGRONAUT_<SCREAMING_SNAKE>` (`migrationsDir` → `MIGRONAUT_MIGRATIONS_DIR`,
@@ -152,9 +162,9 @@ export type MigrationExtension = 'ts' | 'js';
152
162
  * file and are outranked by CLI flags. A value that does not parse is rejected
153
163
  * with a {@link ConfigInvalidError} naming the variable — never coerced.
154
164
  *
155
- * `fileExtensions`, `clientOptions` and the live handles (`client`, `mongoose`,
156
- * `hooks`, `logger`) are config-file/API only: a single environment string
157
- * cannot express them.
165
+ * `fileExtensions`, `clientOptions`, `collections`, `generateId` and the live
166
+ * handles (`client`, `mongoose`, `hooks`, `logger`, `telemetry`) are
167
+ * config-file/API only: a single environment string cannot express them.
158
168
  */
159
169
  export interface MigronautConfig {
160
170
  /** MongoDB connection URI. Not required when `client` is supplied */
@@ -179,6 +189,12 @@ export interface MigronautConfig {
179
189
  migrationsCollection: string;
180
190
  /** Collection name for distributed lock. Default: '_migronaut_locks' */
181
191
  lockCollection: string;
192
+ /**
193
+ * Collection holding the converge history — one entry per converge that
194
+ * changed something or failed. Created by the first such converge, never
195
+ * before. Default: '_migronaut_converge'
196
+ */
197
+ convergeLogCollection: string;
182
198
  /** How long (seconds) a lock is considered stale. Default: 60 */
183
199
  lockTTLSeconds: number;
184
200
  /**
@@ -255,11 +271,52 @@ export interface MigronautConfig {
255
271
  * A single-file `up` (an explicit, deliberate target) is never checked.
256
272
  */
257
273
  onOutOfOrder?: 'warn' | 'error' | 'allow';
274
+ /**
275
+ * Declared collections: their indexes and validator, as the end state you
276
+ * want. `converge()` (`migronaut converge`) compares them with the live
277
+ * database and makes the difference — no migration file per change, and
278
+ * nothing recorded. Combined with the files in `collectionsDir`; a
279
+ * collection declared twice is a {@link ConfigInvalidError}. Experimental.
280
+ */
281
+ collections?: CollectionDefinition[];
282
+ /**
283
+ * Directory of collection definition files, one collection per file (a
284
+ * `.ts`/`.js` default export or a `.json` document; the collection name
285
+ * defaults to the file name). Opt-in — nothing is read unless this is set.
286
+ * Files are loaded when a converge runs, not at config resolution.
287
+ */
288
+ collectionsDir?: string;
289
+ /**
290
+ * End every bulk `up` — no file, no `to` — by converging the declared
291
+ * collections, under the same lock and even when no migration was pending.
292
+ * Default: false
293
+ */
294
+ convergeAfterUp?: boolean;
258
295
  /** Mongoose instance — required only if your migrations use Mongoose models */
259
296
  mongoose?: MongooseLike;
260
297
  hooks?: MigrationHooks;
261
298
  /** Custom logger — set to null to silence all output (useful in tests) */
262
299
  logger?: MigronautLogger | null;
300
+ /**
301
+ * Your own identifier format (ULID, CUID, UUIDv7, …) for every id migronaut
302
+ * mints: the run id — stamped on changelog records, events and log lines,
303
+ * and stored as the lock's owner token — and, through
304
+ * `@alexify/migronaut/bullmq`, the group id of an enqueue call.
305
+ * Default: `crypto.randomUUID()`.
306
+ *
307
+ * Ids are for correlation. The lock adds a token of its own, so a generator
308
+ * that repeats a value blurs which run wrote what but never lets two runs
309
+ * hold the lock at once.
310
+ */
311
+ generateId?: IdGenerator;
312
+ /**
313
+ * OpenTelemetry, from your own `@opentelemetry/api`: a tracer, a meter, or
314
+ * both. Every run and every migration becomes a span — the migration's span
315
+ * is the active one while its `up`/`down` runs, so an instrumented MongoDB
316
+ * driver nests its command spans under it — and their durations are
317
+ * recorded as histograms. Absent, `null` or empty turns it off.
318
+ */
319
+ telemetry?: MigronautTelemetry | null;
263
320
  }
264
321
 
265
322
  /**
@@ -275,6 +332,237 @@ export type MigronautConfigInput =
275
332
  | Partial<MigronautConfig>
276
333
  | (() => Partial<MigronautConfig> | Promise<Partial<MigronautConfig>>);
277
334
 
335
+ // ─── Collections (converge) ───────────────────────────────────────────────────
336
+
337
+ /** An index key direction: ascending, descending, or a special index type */
338
+ export type IndexKeyDirection = 1 | -1 | 'text' | 'hashed' | '2d' | '2dsphere';
339
+
340
+ /** Collation of an index — `locale` required, the rest as MongoDB defines them */
341
+ export interface IndexCollation {
342
+ locale: string;
343
+ caseLevel?: boolean;
344
+ caseFirst?: 'upper' | 'lower' | 'off';
345
+ strength?: 1 | 2 | 3 | 4 | 5;
346
+ numericOrdering?: boolean;
347
+ alternate?: 'non-ignorable' | 'shifted';
348
+ maxVariable?: 'punct' | 'space';
349
+ backwards?: boolean;
350
+ normalization?: boolean;
351
+ }
352
+
353
+ /**
354
+ * One declared index, in the driver's own flat `createIndexes` shape. Every
355
+ * option is checked: an unknown one is a {@link ConfigInvalidError} rather
356
+ * than dropped, because the driver drops it silently and the index would be
357
+ * built without it.
358
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
359
+ */
360
+ export interface IndexDefinition {
361
+ /**
362
+ * Field → direction, in index order. A compound key with an integer-like
363
+ * field name must be a `Map` (a plain object reorders such names), with that
364
+ * field first — the live key is read back as a plain object.
365
+ */
366
+ key: Record<string, IndexKeyDirection> | Map<string, IndexKeyDirection>;
367
+ /** Defaults to the name MongoDB generates: `email_1`, `a_1_b_-1` */
368
+ name?: string;
369
+ unique?: boolean;
370
+ sparse?: boolean;
371
+ /** Changed in place (`collMod`) — no rebuild */
372
+ hidden?: boolean;
373
+ /** TTL in seconds. Changed in place when the live index already has one */
374
+ expireAfterSeconds?: number;
375
+ partialFilterExpression?: Record<string, unknown>;
376
+ collation?: IndexCollation;
377
+ /** For a wildcard (`$**`) index */
378
+ wildcardProjection?: Record<string, 0 | 1 | boolean>;
379
+ /** Text index field weights (default 1) */
380
+ weights?: Record<string, number>;
381
+ default_language?: string;
382
+ language_override?: string;
383
+ textIndexVersion?: number;
384
+ '2dsphereIndexVersion'?: number;
385
+ bits?: number;
386
+ min?: number;
387
+ max?: number;
388
+ storageEngine?: Record<string, unknown>;
389
+ /** Accepted and ignored — a no-op since MongoDB 4.2 */
390
+ background?: boolean;
391
+ }
392
+
393
+ export type ValidationLevel = 'off' | 'strict' | 'moderate';
394
+ export type ValidationAction = 'error' | 'warn' | 'errorAndLog';
395
+
396
+ /**
397
+ * A declared collection: the end state `converge()` keeps it in. Leave
398
+ * `indexes` or `validator` out to leave that part unmanaged.
399
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
400
+ */
401
+ export interface CollectionDefinition {
402
+ name: string;
403
+ /**
404
+ * Every index besides `_id`. Undeclared live indexes are kept (and reported)
405
+ * unless `prune` is on.
406
+ */
407
+ indexes?: IndexDefinition[];
408
+ /** A query or `{ $jsonSchema }` document; `null` (or `{}`) for no validator */
409
+ validator?: Record<string, unknown> | null;
410
+ /** Default: 'strict'. Only with a validator */
411
+ validationLevel?: ValidationLevel;
412
+ /** Default: 'error'. Only with a validator */
413
+ validationAction?: ValidationAction;
414
+ /** Drop live indexes this definition does not declare. Default: the call's `prune`, else false */
415
+ prune?: boolean;
416
+ }
417
+
418
+ /** What a `collectionsDir` file exports: a definition whose name defaults to the file name */
419
+ export type CollectionDefinitionFile = Omit<CollectionDefinition, 'name'> & { name?: string };
420
+
421
+ /**
422
+ * Options for {@link MigratorKit.converge}
423
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
424
+ */
425
+ export interface ConvergeOptions {
426
+ /** Plan without writing: no lock, no events. The result's rows are `'planned'` */
427
+ dryRun?: boolean;
428
+ /** Drop undeclared indexes in collections whose definition does not set `prune` */
429
+ prune?: boolean;
430
+ /** Skip lock acquisition (dev only) */
431
+ noLock?: boolean;
432
+ /**
433
+ * Refuse ({@link MigrationBlockedError}) while any migration is still
434
+ * pending — checked under the lock. How the queue adapter runs a converge
435
+ * as the tail of a deploy.
436
+ */
437
+ ordered?: boolean;
438
+ /**
439
+ * Allow a rebuild that drops a unique index and builds a unique one back.
440
+ * Without it such a rebuild plans as a `conflict`: the constraint is gone
441
+ * until the new index is built, and a duplicate written in between leaves
442
+ * neither index buildable. The after-up hook and queue jobs never set it.
443
+ * CLI: `--rebuild-unique`.
444
+ */
445
+ rebuildUnique?: boolean;
446
+ /** Who asked for this converge — recorded in the converge history */
447
+ requestedBy?: string;
448
+ /** Why — recorded in the converge history */
449
+ reason?: string;
450
+ }
451
+
452
+ export type ConvergeTarget = 'collection' | 'validator' | 'index';
453
+
454
+ /**
455
+ * What converge does to one target. `keep` is an undeclared index left alone
456
+ * (prune off); `conflict` refuses the run — an undeclared index covers the
457
+ * declared one's key under another name, a unique index would be rebuilt
458
+ * without {@link ConvergeOptions.rebuildUnique}, or the collection is a view
459
+ * or a time-series collection.
460
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
461
+ */
462
+ export type ConvergeActionKind =
463
+ | 'create'
464
+ | 'modify'
465
+ | 'recreate'
466
+ | 'drop'
467
+ | 'keep'
468
+ | 'unchanged'
469
+ | 'conflict';
470
+
471
+ /**
472
+ * `planned` in a dry run; otherwise `applied`, `failed`, or `skipped` — no
473
+ * change was needed, or the run stopped before reaching it.
474
+ */
475
+ export type ConvergeActionStatus = 'planned' | 'applied' | 'failed' | 'skipped';
476
+
477
+ /**
478
+ * One row of a converge result
479
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
480
+ */
481
+ export interface ConvergeAction {
482
+ target: ConvergeTarget;
483
+ /** The index name; the collection name for a `collection` or `validator` row */
484
+ name: string;
485
+ action: ConvergeActionKind;
486
+ status: ConvergeActionStatus;
487
+ /** What differs (`'unique, expireAfterSeconds'`), or why a row is what it is */
488
+ reason?: string;
489
+ /** The live index the row refers to when its name differs from the declared one */
490
+ liveName?: string;
491
+ durationMs?: number;
492
+ /**
493
+ * What is there now — the live index (`{ key, name, ...options }`) or
494
+ * validator (`{ validator, validationLevel, validationAction }`) — on rows
495
+ * that change or drop it, and on `keep` rows. Plain JSON.
496
+ */
497
+ from?: Record<string, unknown>;
498
+ /** What the row puts there — the declared index or validator — on rows that create or change it */
499
+ to?: Record<string, unknown>;
500
+ }
501
+
502
+ /**
503
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
504
+ */
505
+ export interface CollectionConvergeResult {
506
+ name: string;
507
+ actions: ConvergeAction[];
508
+ }
509
+
510
+ /**
511
+ * One entry of the converge history (`convergeLogCollection`): a converge that
512
+ * changed something or failed.
513
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
514
+ */
515
+ export interface ConvergeHistoryEntry {
516
+ runId?: string;
517
+ /** `'converge'`, or `'up'` for the converge that ended a bulk `up` */
518
+ trigger: ConvergeTrigger;
519
+ startedAt: Date;
520
+ finishedAt: Date;
521
+ durationMs: number;
522
+ success: boolean;
523
+ /** Redacted failure message (`success: false` only) */
524
+ error?: string;
525
+ executedBy: string;
526
+ host: string;
527
+ environment: string;
528
+ requestedBy?: string;
529
+ reason?: string;
530
+ /** Changes applied */
531
+ changed: number;
532
+ /** The rows that changed, failed or refused the run — each with its collection and `from` / `to` */
533
+ actions: Array<ConvergeAction & { collection: string }>;
534
+ unstable?: ConvergeUnstable[];
535
+ }
536
+
537
+ /**
538
+ * Something applied that still compares as changed — reported, never rebuilt in a loop
539
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
540
+ */
541
+ export interface ConvergeUnstable {
542
+ collection: string;
543
+ target: ConvergeTarget;
544
+ name: string;
545
+ action: ConvergeActionKind;
546
+ reason?: string;
547
+ }
548
+
549
+ /**
550
+ * Outcome of {@link MigratorKit.converge}
551
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
552
+ */
553
+ export interface ConvergeResult {
554
+ dryRun: boolean;
555
+ /** Changes applied — or, in a dry run, changes the run would make */
556
+ changed: number;
557
+ /**
558
+ * True when the database matches the declarations: nothing left to do and
559
+ * no conflict. Undeclared indexes kept with prune off do not count against it.
560
+ */
561
+ inSync: boolean;
562
+ collections: CollectionConvergeResult[];
563
+ unstable?: ConvergeUnstable[];
564
+ }
565
+
278
566
  // ─── Logger ───────────────────────────────────────────────────────────────────
279
567
 
280
568
  /**
@@ -303,6 +591,92 @@ export interface MigronautLogger {
303
591
  */
304
592
  export type LogMethod = (msg: string, fields?: Record<string, unknown>) => void;
305
593
 
594
+ // ─── Telemetry ────────────────────────────────────────────────────────────────
595
+
596
+ /** A span or metric attribute value — the scalar subset migronaut sets */
597
+ export type MigronautAttributes = Record<string, string | number | boolean>;
598
+
599
+ /**
600
+ * The slice of an OpenTelemetry `Span` migronaut calls. Declared structurally —
601
+ * `@opentelemetry/api` is deliberately not imported, so the package's types
602
+ * resolve for users who never installed it. A real `Span` satisfies it.
603
+ */
604
+ export interface MigronautSpan {
605
+ setAttribute(key: string, value: string | number | boolean): unknown;
606
+ /** `code` is OpenTelemetry's `SpanStatusCode` — migronaut only ever sets ERROR (2) */
607
+ setStatus(status: { code: number; message?: string }): unknown;
608
+ end(): void;
609
+ }
610
+
611
+ /**
612
+ * The slice of an OpenTelemetry `Tracer` migronaut calls — what
613
+ * `trace.getTracer('@alexify/migronaut')` returns. Only `startActiveSpan` is
614
+ * used: it is what makes a span the active context for the migration's own
615
+ * code, and so for any instrumentation running underneath it.
616
+ */
617
+ export interface MigronautTracer {
618
+ startActiveSpan<T>(
619
+ name: string,
620
+ options: { attributes?: MigronautAttributes },
621
+ fn: (span: MigronautSpan) => T,
622
+ ): T;
623
+ }
624
+
625
+ /** An OpenTelemetry `Histogram`, as far as migronaut uses one */
626
+ export interface MigronautHistogram {
627
+ record(value: number, attributes?: MigronautAttributes): void;
628
+ }
629
+
630
+ /** An OpenTelemetry `Counter`, as far as migronaut uses one */
631
+ export interface MigronautCounter {
632
+ add(value: number, attributes?: MigronautAttributes): void;
633
+ }
634
+
635
+ /** Options migronaut passes when it creates an instrument */
636
+ export interface MigronautMetricOptions {
637
+ description?: string;
638
+ unit?: string;
639
+ /** Histogram bucket boundaries, in the instrument's unit (seconds) */
640
+ advice?: { explicitBucketBoundaries?: number[] };
641
+ }
642
+
643
+ /**
644
+ * The slice of an OpenTelemetry `Meter` migronaut calls — what
645
+ * `metrics.getMeter('@alexify/migronaut')` returns.
646
+ */
647
+ export interface MigronautMeter {
648
+ createHistogram(name: string, options?: MigronautMetricOptions): MigronautHistogram;
649
+ createCounter(name: string, options?: MigronautMetricOptions): MigronautCounter;
650
+ }
651
+
652
+ /**
653
+ * The `telemetry` config option. Both parts are optional and independent:
654
+ * a tracer alone gives spans, a meter alone gives metrics.
655
+ *
656
+ * Spans: `migronaut.run` (one per run that held the lock) and
657
+ * `migronaut.migration` (one per migration executed, a child of the run).
658
+ * Metrics: `migronaut.run.duration`, `migronaut.migration.duration` and
659
+ * `migronaut.lock.acquire.duration` (histograms, seconds), plus the counters
660
+ * `migronaut.lock.refused` and `migronaut.lock.lost`. A failure sets the span's
661
+ * status to ERROR with a redacted message, and `error.type` — on the span and
662
+ * the metric point — to the {@link MigronautErrorCode}, or for an error that is
663
+ * not migronaut's to its class name (`_OTHER` when it has none).
664
+ *
665
+ * A tracer or meter that throws never fails a run.
666
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
667
+ */
668
+ export interface MigronautTelemetry {
669
+ tracer?: MigronautTracer | null;
670
+ meter?: MigronautMeter | null;
671
+ /**
672
+ * Static attributes added to every span and every metric point — your own
673
+ * low-cardinality dimensions (`{ tenant: 'acme' }`). At most 20. They cannot
674
+ * replace migronaut's own: `db.namespace` (the database name, always
675
+ * present) and the `migronaut.*` attributes win.
676
+ */
677
+ attributes?: Record<string, string | number | boolean>;
678
+ }
679
+
306
680
  // ─── Progress Reporter ─────────────────────────────────────────────────────────
307
681
 
308
682
  /**
@@ -375,6 +749,20 @@ export interface StatusRow {
375
749
  * the whole status/audit call.
376
750
  */
377
751
  invalid?: true;
752
+ /**
753
+ * The file's current checksum (SHA-256 hex) — on `dryRun('up')` rows, so a
754
+ * caller that applies them later can insist on exactly this version
755
+ * (`up(file, { checksum })`).
756
+ */
757
+ checksum?: string;
758
+ /** Who asked for the apply, and why — when the run said (`requestedBy` / `reason` options) */
759
+ requestedBy?: string;
760
+ reason?: string;
761
+ /** Who asked for the revert, and why — on reverted history rows */
762
+ revertRequestedBy?: string;
763
+ revertReason?: string;
764
+ /** The checksum of the file version that failed (status `'failed'` only) */
765
+ failedChecksum?: string;
378
766
  }
379
767
 
380
768
  // ─── Import (migrate-mongo adoption) ────────────────────────────────────────────
@@ -432,6 +820,13 @@ export interface LockInfo {
432
820
  host: string;
433
821
  /** Username of the holder */
434
822
  executedBy: string;
823
+ /**
824
+ * The holder's run id — the `runId` of its events, log lines and changelog
825
+ * records. Absent for a document written by hand.
826
+ */
827
+ runId?: string;
828
+ /** The holder's lock TTL (ms), which paces its heartbeat. Absent before 2.1 */
829
+ ttlMs?: number;
435
830
  }
436
831
 
437
832
  // ─── Error Codes ──────────────────────────────────────────────────────────────
@@ -456,7 +851,11 @@ export type MigronautErrorCode =
456
851
  | 'NOT_APPLIED'
457
852
  | 'IMPORT_TARGET_NOT_EMPTY'
458
853
  | 'MIGRATION_IRREVERSIBLE'
459
- | 'MIGRATION_OUT_OF_ORDER';
854
+ | 'MIGRATION_OUT_OF_ORDER'
855
+ | 'MIGRATION_BLOCKED'
856
+ | 'QUEUE_JOB_INVALID'
857
+ | 'QUEUE_JOB_FAILED'
858
+ | 'CONVERGE_FAILED';
460
859
 
461
860
  // ─── Config file format ─────────────────────────────────────────────────────────
462
861
 
@@ -488,6 +887,44 @@ export interface UpOptions {
488
887
  * Mutually exclusive with a filename and `steps`.
489
888
  */
490
889
  to?: string;
890
+ /**
891
+ * Stamp this batch number on what the run applies, instead of the next free
892
+ * one ({@link MigratorKit.nextBatch}). A label, not a reservation: it may
893
+ * equal a batch already in use, which is how several single-file runs become
894
+ * one rollback unit — the queue adapter gives every job of an enqueue group
895
+ * the same value. Positive integer; mutually exclusive with `step`.
896
+ */
897
+ batch?: number;
898
+ /**
899
+ * Refuse ({@link MigrationBlockedError}) to apply the named file while an
900
+ * earlier file on disk is still pending — the invariant a bulk `up` gets by
901
+ * construction, enforced from the changelog rather than from the caller's
902
+ * memory. Also makes the single-file run honour `strict` drift checks and
903
+ * `onOutOfOrder` like a bulk run. Requires a filename.
904
+ */
905
+ ordered?: boolean;
906
+ /**
907
+ * The SHA-256 (hex) the named file must have — refuse ({@link
908
+ * ChecksumMismatchError}, `context.planned: true`) to apply any other
909
+ * version of it. A queue job carries the checksum its plan saw, so a worker
910
+ * from another deploy never applies a different file under the same name.
911
+ * Requires a filename; an already-applied file is skipped as usual.
912
+ */
913
+ checksum?: string;
914
+ /**
915
+ * Converge the declared collections after the migrations, under the same
916
+ * lock — overrides `convergeAfterUp` for this call. Bulk runs only: refused
917
+ * with a filename or `to`.
918
+ */
919
+ converge?: boolean;
920
+ /**
921
+ * Who asked for this run (≤ 128 characters) — stamped on the changelog
922
+ * records it writes. `executedBy` is the OS user that ran it; on a queue
923
+ * worker that is the container's, which is why the requester is separate.
924
+ */
925
+ requestedBy?: string;
926
+ /** Why (≤ 512 characters) — a ticket, a sentence; stamped like `requestedBy` */
927
+ reason?: string;
491
928
  }
492
929
 
493
930
  /** Options for {@link MigratorKit.down} */
@@ -508,6 +945,20 @@ export interface DownOptions {
508
945
  * to the same state. Mutually exclusive with `batch`, `steps` and a filename.
509
946
  */
510
947
  to?: string;
948
+ /**
949
+ * Refuse ({@link MigrationBlockedError}) to revert the named file while a
950
+ * migration applied *after* it is still applied — reverts must go newest
951
+ * first (by `appliedAt`, the order `steps` uses). Requires a filename.
952
+ */
953
+ ordered?: boolean;
954
+ /**
955
+ * Who asked for this run (≤ 128 characters) — stamped on the records it
956
+ * reverts (`revertRequestedBy`). `executedBy` is the OS user that ran it; on a queue
957
+ * worker that is the container's, which is why the requester is separate.
958
+ */
959
+ requestedBy?: string;
960
+ /** Why (≤ 512 characters) — a ticket, a sentence; stamped as `revertReason` */
961
+ reason?: string;
511
962
  }
512
963
 
513
964
  /** Payload common to every lifecycle event */
@@ -531,7 +982,7 @@ export interface MigrationEvent extends MigronautEventBase {
531
982
  }
532
983
 
533
984
  export interface RunStartEvent extends MigronautEventBase {
534
- /** Which command started the run: 'up' | 'down' | 'redo' | 'import' | 'baseline' */
985
+ /** Which command started the run: 'up' | 'down' | 'redo' | 'import' | 'baseline' | 'converge' */
535
986
  command?: string;
536
987
  direction?: 'up' | 'down';
537
988
  }
@@ -565,10 +1016,62 @@ export interface LockEvent extends MigronautEventBase {
565
1016
  acquireMs?: number;
566
1017
  }
567
1018
 
1019
+ /** Who started a converge: the `converge` call itself, or a bulk `up` (`convergeAfterUp`) */
1020
+ export type ConvergeTrigger = 'converge' | 'up';
1021
+
1022
+ /**
1023
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1024
+ */
1025
+ export interface ConvergeStartEvent extends MigronautEventBase {
1026
+ trigger: ConvergeTrigger;
1027
+ /** Declared collections being converged */
1028
+ collections: number;
1029
+ }
1030
+
1031
+ /**
1032
+ * One step a converge carried out (or failed)
1033
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1034
+ */
1035
+ export interface ConvergeActionEvent extends MigronautEventBase {
1036
+ collection: string;
1037
+ target: ConvergeTarget;
1038
+ name: string;
1039
+ action: ConvergeActionKind;
1040
+ /**
1041
+ * `'started'` fires before the step runs — an index build can take hours,
1042
+ * and this is how a subscriber sees which one is in progress; `'applied'`
1043
+ * or `'failed'` follows when it ends.
1044
+ */
1045
+ status: 'started' | 'applied' | 'failed';
1046
+ /** `'applied'` only */
1047
+ durationMs?: number;
1048
+ reason?: string;
1049
+ /** Redacted failure message (status `'failed'` only) */
1050
+ error?: string;
1051
+ }
1052
+
1053
+ /**
1054
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1055
+ */
1056
+ export interface ConvergeEndEvent extends MigronautEventBase {
1057
+ trigger: ConvergeTrigger;
1058
+ success: boolean;
1059
+ durationMs: number;
1060
+ changed: number;
1061
+ inSync: boolean;
1062
+ /** Rows per action kind */
1063
+ counts: Partial<Record<ConvergeActionKind, number>>;
1064
+ /** The full result — partial on the failure path */
1065
+ result: ConvergeResult;
1066
+ /** Redacted failure message */
1067
+ error?: string;
1068
+ }
1069
+
568
1070
  /**
569
1071
  * Lifecycle events emitted by {@link MigratorKit}. Subscribe to feed metrics or
570
1072
  * alerting without parsing log lines; a listener that throws is contained and
571
- * never fails the run.
1073
+ * never fails the run. The `converge:*` events fire for real converge runs
1074
+ * only, not for a dry run.
572
1075
  */
573
1076
  export interface MigronautEvents {
574
1077
  'run:start': (event: RunStartEvent) => void;
@@ -580,6 +1083,9 @@ export interface MigronautEvents {
580
1083
  'lock:acquired': (event: LockEvent) => void;
581
1084
  'lock:released': (event: LockEvent) => void;
582
1085
  'lock:lost': (event: LockEvent) => void;
1086
+ 'converge:start': (event: ConvergeStartEvent) => void;
1087
+ 'converge:action': (event: ConvergeActionEvent) => void;
1088
+ 'converge:end': (event: ConvergeEndEvent) => void;
583
1089
  }
584
1090
 
585
1091
  /** One check performed by {@link MigratorKit.audit} */
@@ -599,10 +1105,20 @@ export interface AuditReport {
599
1105
  checks: AuditCheck[];
600
1106
  }
601
1107
 
1108
+ /** Options for {@link MigratorKit.status} and {@link MigratorKit.list} */
1109
+ export interface StatusOptions {
1110
+ /** Hash applied files to fill `checksumOk`. Default true */
1111
+ checksums?: boolean;
1112
+ }
1113
+
602
1114
  /** Options for {@link MigratorKit.redo} */
603
1115
  export interface RedoOptions {
604
1116
  /** Skip lock acquisition (dev only) */
605
1117
  noLock?: boolean;
1118
+ /** Who asked — stamped on the revert and on the re-apply */
1119
+ requestedBy?: string;
1120
+ /** Why — stamped like `requestedBy` */
1121
+ reason?: string;
606
1122
  }
607
1123
 
608
1124
  /** Options for {@link MigratorKit.create} */
@@ -720,6 +1236,19 @@ export class MigratorKit extends EventEmitter {
720
1236
  * removed, or null if no lock was held.
721
1237
  */
722
1238
  forceUnlock(): Promise<LockInfo | null>;
1239
+ /**
1240
+ * The batch number the next `up` would use (highest recorded batch + 1,
1241
+ * reverted and failed records included). A peek, not a reservation — pair it
1242
+ * with `up(name, { batch })` to stamp several single-file runs as one batch.
1243
+ * Connects if needed.
1244
+ */
1245
+ nextBatch(): Promise<number>;
1246
+ /**
1247
+ * A new id in this kit's configured format — the `generateId` option, else a
1248
+ * random UUID. The same source every run id comes from, for code that wants
1249
+ * its own ids to match. Resolves the config; does not connect.
1250
+ */
1251
+ generateId(): Promise<string>;
723
1252
  /** Run all pending migrations, or a specific named file */
724
1253
  up(filename?: string, options?: UpOptions): Promise<RunResult[]>;
725
1254
  /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
@@ -749,16 +1278,22 @@ export class MigratorKit extends EventEmitter {
749
1278
  filename?: string,
750
1279
  options?: { steps?: number; batch?: number; to?: string },
751
1280
  ): Promise<StatusRow[]>;
752
- /** Full migration status for all known files and records */
753
- status(): Promise<StatusRow[]>;
1281
+ /**
1282
+ * Full migration status for all known files and records. `checksums: false`
1283
+ * skips hashing the applied files (`checksumOk` stays null).
1284
+ */
1285
+ status(options?: StatusOptions): Promise<StatusRow[]>;
754
1286
  /**
755
1287
  * Read-only health check: configuration, connectivity, transaction support,
756
1288
  * changelog indexes, lock state, checksum drift and runtime. Reports
757
1289
  * problems; fixes none of them.
758
1290
  */
759
1291
  audit(): Promise<AuditReport>;
760
- /** Filtered list of migrations. Default: 'all' */
761
- list(filter?: 'all' | 'pending' | 'applied'): Promise<StatusRow[]>;
1292
+ /**
1293
+ * Filtered list of migrations. Default: 'all'. `checksums: false` skips
1294
+ * hashing the applied files — for a caller that needs names and dates only.
1295
+ */
1296
+ list(filter?: 'all' | 'pending' | 'applied', options?: StatusOptions): Promise<StatusRow[]>;
762
1297
  /** Create a new migration file and return its absolute path */
763
1298
  create(name: string, options?: CreateOptions): Promise<string>;
764
1299
  /** Create a migronaut config file in the working directory and return its path */
@@ -776,6 +1311,25 @@ export class MigratorKit extends EventEmitter {
776
1311
  * are skipped, so a partial baseline can simply be re-run.
777
1312
  */
778
1313
  baseline(options?: BaselineOptions): Promise<BaselineSummary>;
1314
+ /**
1315
+ * Bring the declared collections (`collections`, `collectionsDir`) to their
1316
+ * declared indexes and validators. Stateless: the live database is read and
1317
+ * compared on every call; what a run changed is appended to the converge
1318
+ * history ({@link MigratorKit.convergeHistory}), which no run reads back. A
1319
+ * real run holds the migration lock; a plan with a conflict is refused before
1320
+ * any write, and a failed step throws {@link ConvergeFailedError}. Experimental.
1321
+ */
1322
+ converge(options?: ConvergeOptions): Promise<ConvergeResult>;
1323
+ /**
1324
+ * Whether a bulk `up` on this kit ends by converging: `convergeAfterUp` is
1325
+ * on and something is declared. Resolves the config; does not connect.
1326
+ */
1327
+ convergesAfterUp(): Promise<boolean>;
1328
+ /**
1329
+ * The converge history, newest first (`limit` 1–1000, default 20): one entry
1330
+ * per converge that changed something or failed. Read-only.
1331
+ */
1332
+ convergeHistory(options?: { limit?: number }): Promise<ConvergeHistoryEntry[]>;
779
1333
  }
780
1334
 
781
1335
  // ─── Programmatic entry points ─────────────────────────────────────────────────
@@ -799,11 +1353,24 @@ export interface RunMigrationsOptions extends MigratorKitOptions {
799
1353
  * progress**. While the holder's heartbeat visibly advances its lock, the
800
1354
  * deadline is re-armed — a healthy peer working through a long backlog never
801
1355
  * times its waiting peers out; only a stalled holder runs this budget down.
802
- * Default: 90000.
1356
+ * Default: 90000, or 1.5× the holder's lock TTL when that is longer — its
1357
+ * heartbeat only moves the lock every TTL/2, and a crashed holder's lock is
1358
+ * reclaimable only after a full TTL. An explicit value is used as given.
803
1359
  */
804
1360
  lockWaitTimeoutMs?: number;
805
- /** Poll interval (ms) while waiting for the lock. Default: 500 */
1361
+ /**
1362
+ * First poll interval (ms) while waiting for the lock. Polls back off from
1363
+ * it, doubling, up to 5 s (and never more than a quarter of the wait budget).
1364
+ * Default: 500
1365
+ */
806
1366
  lockPollIntervalMs?: number;
1367
+ /**
1368
+ * Abort the call: a wait for the lock stops between polls, and a run that
1369
+ * holds it stops between migrations (one already executing finishes), with
1370
+ * a {@link RunAbortedError}. Wire it to SIGTERM so a pod being shut down
1371
+ * does not take the lock just before it is killed.
1372
+ */
1373
+ signal?: AbortSignal;
807
1374
  /**
808
1375
  * Receives the internally-constructed {@link MigratorKit} right after
809
1376
  * construction (before connect), so an embedding application can subscribe
@@ -822,10 +1389,12 @@ export interface MigrationSummary {
822
1389
  upToDate: boolean;
823
1390
  /** True when this instance waited for a peer to release the lock before running */
824
1391
  waited: boolean;
825
- /** Total time (ms) spent waiting for a peer's lock. 0 when the lock was free */
1392
+ /** Time (ms) from the first refusal to the run, by the clock. 0 when the lock was free */
826
1393
  waitedMs: number;
827
1394
  /** Number of `up` attempts made — 1 when the lock was free on the first try */
828
1395
  attempts: number;
1396
+ /** The converge that ended the run — present only when `convergeAfterUp` converged */
1397
+ converge?: ConvergeResult;
829
1398
  }
830
1399
 
831
1400
  /**
@@ -849,14 +1418,17 @@ export function pendingMigrations(
849
1418
  ): Promise<StatusRow[]>;
850
1419
 
851
1420
  /**
852
- * The CLI's exit-code map: one entry per {@link MigronautErrorCode}, plus two
1421
+ * The CLI's exit-code map: one entry per {@link MigronautErrorCode}, plus three
853
1422
  * CLI-condition codes with no error class — `PENDING_MIGRATIONS` (from
854
- * `status --check`) and `AUDIT_FAILED`. Lets a wrapper script mirror the
855
- * CLI's exit semantics without hardcoding numbers. Anything unmapped exits 1;
856
- * success is 0.
1423
+ * `status --check`), `AUDIT_FAILED` and `COLLECTIONS_DRIFT` (from
1424
+ * `converge --check`). Lets a wrapper script mirror the CLI's exit semantics
1425
+ * without hardcoding numbers. Anything unmapped exits 1; success is 0.
857
1426
  */
858
1427
  export const EXIT_CODES: Readonly<
859
- Record<MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED', number>
1428
+ Record<
1429
+ MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED' | 'COLLECTIONS_DRIFT',
1430
+ number
1431
+ >
860
1432
  >;
861
1433
 
862
1434
  // ─── Logger factory ───────────────────────────────────────────────────────────
@@ -1013,3 +1585,47 @@ export class IrreversibleMigrationError extends MigronautError {
1013
1585
  export class OutOfOrderMigrationError extends MigronautError {
1014
1586
  constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1015
1587
  }
1588
+
1589
+ /**
1590
+ * Thrown by an `ordered` single-file `up`/`down` that would run out of
1591
+ * sequence: an earlier migration is still pending (`up`), or one applied later
1592
+ * is still applied (`down`). `context.name`, `context.direction` and
1593
+ * `context.blockedBy` (the migrations that must go first).
1594
+ */
1595
+ export class MigrationBlockedError extends MigronautError {
1596
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1597
+ }
1598
+
1599
+ /**
1600
+ * Thrown by the queue adapter (`@alexify/migronaut/bullmq`) when a job's
1601
+ * payload fails the contract check — an unknown job name or data version, a
1602
+ * migration name that is not a bare filename, malformed group fields. Job data
1603
+ * is untrusted input. `context.jobId`, `context.issue`.
1604
+ */
1605
+ export class QueueJobInvalidError extends MigronautError {
1606
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1607
+ }
1608
+
1609
+ /**
1610
+ * Thrown by a queue group's `wait()` when one of its jobs failed or the wait
1611
+ * timed out (one budget for the whole call). `context.failedReason` is the
1612
+ * worker's (redacted) message, `context.code` the job's own typed error code
1613
+ * when it reported one (`MIGRATION_BLOCKED`, `CHECKSUM_MISMATCH`, …),
1614
+ * `context.results` the jobs that finished before it, plus `groupId`, `jobId`,
1615
+ * `migration`, `direction` and `timedOut`.
1616
+ */
1617
+ export class QueueJobFailedError extends MigronautError {
1618
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1619
+ }
1620
+
1621
+ /**
1622
+ * Thrown by {@link MigratorKit.converge} when the database cannot be brought
1623
+ * to the declared state. `context.phase` is `'plan'` for a refused plan
1624
+ * (`context.conflicts` lists why; nothing was written) or `'apply'` for a
1625
+ * failed step (`collection`, `target`, `name`, `action`, `cause`, and
1626
+ * `mongoCode`, `hint` and — after a failed rebuild — `restored` when they
1627
+ * apply). `context.converge` is the {@link ConvergeResult} so far.
1628
+ */
1629
+ export class ConvergeFailedError extends MigronautError {
1630
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1631
+ }