@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/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,76 @@ 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;
295
+ /**
296
+ * What converge does with declared search indexes on a server without
297
+ * Atlas Search: `'fail'` refuses the run before anything is written,
298
+ * `'skip'` converges everything else and reports them as `skip` rows.
299
+ * Default: 'fail'
300
+ * @experimental New in 2.2
301
+ */
302
+ onSearchUnavailable?: 'fail' | 'skip';
303
+ /**
304
+ * Hold every converge — the after-up one included — until each declared
305
+ * search index is queryable with its declared definition. Search indexes
306
+ * build in the background, so without it a new one is not queryable yet when
307
+ * converge returns. An index that FAILED or went STALE before the run, its
308
+ * definition unchanged, does not hold it — it is warned about instead. The
309
+ * migration lock is released while it waits. Default: false
310
+ * @experimental New in 2.2
311
+ */
312
+ waitForSearchIndexes?: boolean;
313
+ /**
314
+ * How long `waitForSearchIndexes` waits before the converge fails with
315
+ * `phase: 'wait'` (the server goes on building). Default: 600000 (10 minutes)
316
+ * @experimental New in 2.2
317
+ */
318
+ searchIndexWaitTimeoutMs?: number;
258
319
  /** Mongoose instance — required only if your migrations use Mongoose models */
259
320
  mongoose?: MongooseLike;
260
321
  hooks?: MigrationHooks;
261
322
  /** Custom logger — set to null to silence all output (useful in tests) */
262
323
  logger?: MigronautLogger | null;
324
+ /**
325
+ * Your own identifier format (ULID, CUID, UUIDv7, …) for every id migronaut
326
+ * mints: the run id — stamped on changelog records, events and log lines,
327
+ * and stored as the lock's owner token — and, through
328
+ * `@alexify/migronaut/bullmq`, the group id of an enqueue call.
329
+ * Default: `crypto.randomUUID()`.
330
+ *
331
+ * Ids are for correlation. The lock adds a token of its own, so a generator
332
+ * that repeats a value blurs which run wrote what but never lets two runs
333
+ * hold the lock at once.
334
+ */
335
+ generateId?: IdGenerator;
336
+ /**
337
+ * OpenTelemetry, from your own `@opentelemetry/api`: a tracer, a meter, or
338
+ * both. Every run and every migration becomes a span — the migration's span
339
+ * is the active one while its `up`/`down` runs, so an instrumented MongoDB
340
+ * driver nests its command spans under it — and their durations are
341
+ * recorded as histograms. Absent, `null` or empty turns it off.
342
+ */
343
+ telemetry?: MigronautTelemetry | null;
263
344
  }
264
345
 
265
346
  /**
@@ -275,6 +356,419 @@ export type MigronautConfigInput =
275
356
  | Partial<MigronautConfig>
276
357
  | (() => Partial<MigronautConfig> | Promise<Partial<MigronautConfig>>);
277
358
 
359
+ // ─── Collections (converge) ───────────────────────────────────────────────────
360
+
361
+ /** An index key direction: ascending, descending, or a special index type */
362
+ export type IndexKeyDirection = 1 | -1 | 'text' | 'hashed' | '2d' | '2dsphere';
363
+
364
+ /** Collation of an index — `locale` required, the rest as MongoDB defines them */
365
+ export interface IndexCollation {
366
+ locale: string;
367
+ caseLevel?: boolean;
368
+ caseFirst?: 'upper' | 'lower' | 'off';
369
+ strength?: 1 | 2 | 3 | 4 | 5;
370
+ numericOrdering?: boolean;
371
+ alternate?: 'non-ignorable' | 'shifted';
372
+ maxVariable?: 'punct' | 'space';
373
+ backwards?: boolean;
374
+ normalization?: boolean;
375
+ }
376
+
377
+ /**
378
+ * One declared index, in the driver's own flat `createIndexes` shape. Every
379
+ * option is checked: an unknown one is a {@link ConfigInvalidError} rather
380
+ * than dropped, because the driver drops it silently and the index would be
381
+ * built without it.
382
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
383
+ */
384
+ export interface IndexDefinition {
385
+ /**
386
+ * Field → direction, in index order. A compound key with an integer-like
387
+ * field name must be a `Map` (a plain object reorders such names), with that
388
+ * field first — the live key is read back as a plain object.
389
+ */
390
+ key: Record<string, IndexKeyDirection> | Map<string, IndexKeyDirection>;
391
+ /** Defaults to the name MongoDB generates: `email_1`, `a_1_b_-1` */
392
+ name?: string;
393
+ unique?: boolean;
394
+ sparse?: boolean;
395
+ /** Changed in place (`collMod`) — no rebuild */
396
+ hidden?: boolean;
397
+ /** TTL in seconds. Changed in place when the live index already has one */
398
+ expireAfterSeconds?: number;
399
+ partialFilterExpression?: Record<string, unknown>;
400
+ collation?: IndexCollation;
401
+ /** For a wildcard (`$**`) index */
402
+ wildcardProjection?: Record<string, 0 | 1 | boolean>;
403
+ /** Text index field weights (default 1) */
404
+ weights?: Record<string, number>;
405
+ default_language?: string;
406
+ language_override?: string;
407
+ textIndexVersion?: number;
408
+ '2dsphereIndexVersion'?: number;
409
+ bits?: number;
410
+ min?: number;
411
+ max?: number;
412
+ storageEngine?: Record<string, unknown>;
413
+ /** Accepted and ignored — a no-op since MongoDB 4.2 */
414
+ background?: boolean;
415
+ }
416
+
417
+ export type ValidationLevel = 'off' | 'strict' | 'moderate';
418
+ export type ValidationAction = 'error' | 'warn' | 'errorAndLog';
419
+
420
+ /** The two kinds of Atlas search index */
421
+ export type SearchIndexType = 'search' | 'vectorSearch';
422
+
423
+ /** `mappings` of an Atlas Search definition */
424
+ export interface SearchIndexMappings {
425
+ /** Default: false */
426
+ dynamic?: boolean | { typeSet: string };
427
+ fields?: Record<string, unknown>;
428
+ }
429
+
430
+ /**
431
+ * An Atlas Search index definition, as Atlas defines it. Compared whole, with
432
+ * the documented defaults filled in; anything Atlas adds can be declared too.
433
+ */
434
+ export interface SearchDefinition {
435
+ mappings: SearchIndexMappings;
436
+ /** Default: 'lucene.standard' */
437
+ analyzer?: string;
438
+ /** Default: the analyzer */
439
+ searchAnalyzer?: string;
440
+ analyzers?: Array<Record<string, unknown>>;
441
+ synonyms?: Array<Record<string, unknown>>;
442
+ /** Default: false */
443
+ storedSource?: boolean | { include?: string[]; exclude?: string[] };
444
+ /** Default: 1 */
445
+ numPartitions?: number;
446
+ [option: string]: unknown;
447
+ }
448
+
449
+ /** One field of a Vector Search definition */
450
+ export interface VectorSearchField {
451
+ /** `'autoEmbed'` is Atlas's automated embedding (in preview); one index holds vector or autoEmbed fields, not both */
452
+ type: 'vector' | 'filter' | 'autoEmbed' | (string & {});
453
+ path: string;
454
+ numDimensions?: number;
455
+ similarity?: 'euclidean' | 'cosine' | 'dotProduct';
456
+ /** Default: 'none' ('scalar' for autoEmbed) */
457
+ quantization?: string;
458
+ /** Default: 'hnsw' */
459
+ indexingMethod?: 'hnsw' | 'flat';
460
+ /** Default: { maxEdges: 16, numEdgeCandidates: 100 } */
461
+ hnswOptions?: { maxEdges?: number; numEdgeCandidates?: number };
462
+ /** autoEmbed: the embedding model */
463
+ model?: string;
464
+ /** autoEmbed: 'text' */
465
+ modality?: string;
466
+ [option: string]: unknown;
467
+ }
468
+
469
+ /** A Vector Search index definition */
470
+ export interface VectorSearchDefinition {
471
+ fields: VectorSearchField[];
472
+ [option: string]: unknown;
473
+ }
474
+
475
+ /**
476
+ * One declared Atlas Search or Vector Search index. The name defaults to
477
+ * `'default'` and the type to `'search'`, as on the server. A change of type
478
+ * — or of an autoEmbed field's path, model, size, quantization or modality —
479
+ * cannot be made in place: converge refuses it, and the way is a new index
480
+ * under a new name (converge, then remove the old declaration and converge
481
+ * with prune).
482
+ * @experimental New in 2.2 — the shape may still change in a minor release (named in the CHANGELOG).
483
+ */
484
+ export type SearchIndexDefinition =
485
+ | { name?: string; type?: 'search'; definition: SearchDefinition }
486
+ | { name?: string; type: 'vectorSearch'; definition: VectorSearchDefinition };
487
+
488
+ /**
489
+ * A declared collection: the end state `converge()` keeps it in. Leave
490
+ * `indexes`, `searchIndexes` or `validator` out to leave that part unmanaged.
491
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
492
+ */
493
+ export interface CollectionDefinition {
494
+ name: string;
495
+ /**
496
+ * Every index besides `_id`. Undeclared live indexes are kept (and reported)
497
+ * unless `prune` is on.
498
+ */
499
+ indexes?: IndexDefinition[];
500
+ /**
501
+ * Atlas Search and Vector Search indexes (Atlas, an Atlas CLI local
502
+ * deployment, or MongoDB 8.3+ with mongot). Undeclared live ones are kept
503
+ * unless `prune` is on; leave the key out and they are not managed at all.
504
+ * @experimental New in 2.2
505
+ */
506
+ searchIndexes?: SearchIndexDefinition[];
507
+ /** A query or `{ $jsonSchema }` document; `null` (or `{}`) for no validator */
508
+ validator?: Record<string, unknown> | null;
509
+ /** Default: 'strict'. Only with a validator */
510
+ validationLevel?: ValidationLevel;
511
+ /** Default: 'error'. Only with a validator */
512
+ validationAction?: ValidationAction;
513
+ /**
514
+ * Drop live indexes (and search indexes, when `searchIndexes` is declared)
515
+ * this definition does not declare. Default: the call's `prune`, else false
516
+ */
517
+ prune?: boolean;
518
+ }
519
+
520
+ /** What a `collectionsDir` file exports: a definition whose name defaults to the file name */
521
+ export type CollectionDefinitionFile = Omit<CollectionDefinition, 'name'> & { name?: string };
522
+
523
+ /**
524
+ * Options for {@link MigratorKit.converge}
525
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
526
+ */
527
+ export interface ConvergeOptions {
528
+ /** Plan without writing: no lock, no events. The result's rows are `'planned'` */
529
+ dryRun?: boolean;
530
+ /** Drop undeclared indexes in collections whose definition does not set `prune` */
531
+ prune?: boolean;
532
+ /** Skip lock acquisition (dev only) */
533
+ noLock?: boolean;
534
+ /**
535
+ * Refuse ({@link MigrationBlockedError}) while any migration is still
536
+ * pending — checked under the lock. How the queue adapter runs a converge
537
+ * as the tail of a deploy.
538
+ */
539
+ ordered?: boolean;
540
+ /**
541
+ * Allow a rebuild that drops a unique index and builds a unique one back.
542
+ * Without it such a rebuild plans as a `conflict`: the constraint is gone
543
+ * until the new index is built, and a duplicate written in between leaves
544
+ * neither index buildable. The after-up hook and queue jobs never set it.
545
+ * CLI: `--rebuild-unique`.
546
+ */
547
+ rebuildUnique?: boolean;
548
+ /**
549
+ * Hold the run until every declared search index serves its
550
+ * declaration — failing on a FAILED build of an index this run created or
551
+ * changed, or after `searchIndexWaitTimeoutMs`. The migration lock is released
552
+ * when the wait starts — it only reads. Overrides the config's `waitForSearchIndexes`;
553
+ * not with `dryRun`. CLI: `--wait-search` / `--no-wait-search`.
554
+ * @experimental New in 2.2
555
+ */
556
+ waitForSearchIndexes?: boolean;
557
+ /** Who asked for this converge — recorded in the converge history */
558
+ requestedBy?: string;
559
+ /** Why — recorded in the converge history */
560
+ reason?: string;
561
+ }
562
+
563
+ export type ConvergeTarget = 'collection' | 'validator' | 'index' | 'searchIndex';
564
+
565
+ /**
566
+ * What converge does to one target. `keep` is an undeclared index left alone
567
+ * (prune off); `conflict` refuses the run — an undeclared index covers the
568
+ * declared one's key under another name, a unique index would be rebuilt
569
+ * without {@link ConvergeOptions.rebuildUnique}, the collection is a view or a
570
+ * time-series collection, a search index would need a change no update can
571
+ * make (its type, an autoEmbed field's model or size), or the server has no
572
+ * Atlas Search; `skip` is a declared search index left alone on a server
573
+ * without Search (`onSearchUnavailable: 'skip'`). A search index is never
574
+ * `recreate`d: `modify` updates it in place.
575
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
576
+ */
577
+ export type ConvergeActionKind =
578
+ | 'create'
579
+ | 'modify'
580
+ | 'recreate'
581
+ | 'drop'
582
+ | 'keep'
583
+ | 'unchanged'
584
+ | 'conflict'
585
+ | 'skip';
586
+
587
+ /**
588
+ * The status `$listSearchIndexes` reports for a search index — `'UNKNOWN'`
589
+ * when the server reports none
590
+ */
591
+ export type SearchIndexStatus =
592
+ | 'PENDING'
593
+ | 'BUILDING'
594
+ | 'READY'
595
+ | 'FAILED'
596
+ | 'STALE'
597
+ | 'DELETING'
598
+ | 'DOES_NOT_EXIST'
599
+ | 'UNKNOWN'
600
+ | (string & {});
601
+
602
+ /**
603
+ * Where the server is with a search index: it builds in the background, so a
604
+ * created or updated one is not queryable (with its new definition) at once
605
+ * @experimental New in 2.2
606
+ */
607
+ export interface SearchIndexBuild {
608
+ status: SearchIndexStatus;
609
+ queryable: boolean;
610
+ /** The server's message — why a build FAILED, typically */
611
+ message?: string;
612
+ /** A newer definition is being built next to the one served */
613
+ updating?: true;
614
+ }
615
+
616
+ /**
617
+ * `planned` in a dry run; otherwise `applied`, `failed`, or `skipped` — no
618
+ * change was needed, or the run stopped before reaching it.
619
+ */
620
+ export type ConvergeActionStatus = 'planned' | 'applied' | 'failed' | 'skipped';
621
+
622
+ /**
623
+ * One row of a converge result
624
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
625
+ */
626
+ export interface ConvergeAction {
627
+ target: ConvergeTarget;
628
+ /** The index name; the collection name for a `collection` or `validator` row */
629
+ name: string;
630
+ action: ConvergeActionKind;
631
+ status: ConvergeActionStatus;
632
+ /** What differs (`'unique, expireAfterSeconds'`), or why a row is what it is */
633
+ reason?: string;
634
+ /** The live index the row refers to when its name differs from the declared one */
635
+ liveName?: string;
636
+ durationMs?: number;
637
+ /**
638
+ * What is there now — the live index (`{ key, name, ...options }`) or
639
+ * validator (`{ validator, validationLevel, validationAction }`) — on rows
640
+ * that change or drop it, and on `keep` rows. Plain JSON.
641
+ */
642
+ from?: Record<string, unknown>;
643
+ /** What the row puts there — the declared index or validator — on rows that create or change it */
644
+ to?: Record<string, unknown>;
645
+ /**
646
+ * A search index row's build state on the server — as read before the run,
647
+ * and after it for a row the run applied
648
+ * @experimental New in 2.2
649
+ */
650
+ build?: SearchIndexBuild;
651
+ /**
652
+ * On a search index row: the options the server reports that the
653
+ * declaration does not set and migronaut knows no default for
654
+ * (`mappings.fields.title.similarity`) — left out of the comparison, so a
655
+ * new server default does not make every converge update the index.
656
+ * Declare one to manage it.
657
+ * @experimental New in 2.2
658
+ */
659
+ ignored?: string[];
660
+ }
661
+
662
+ /**
663
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
664
+ */
665
+ export interface CollectionConvergeResult {
666
+ name: string;
667
+ actions: ConvergeAction[];
668
+ }
669
+
670
+ /**
671
+ * One entry of the converge history (`convergeLogCollection`): a converge that
672
+ * changed something or failed.
673
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
674
+ */
675
+ export interface ConvergeHistoryEntry {
676
+ runId?: string;
677
+ /** `'converge'`, or `'up'` for the converge that ended a bulk `up` */
678
+ trigger: ConvergeTrigger;
679
+ startedAt: Date;
680
+ finishedAt: Date;
681
+ durationMs: number;
682
+ success: boolean;
683
+ /** Redacted failure message (`success: false` only) */
684
+ error?: string;
685
+ executedBy: string;
686
+ host: string;
687
+ environment: string;
688
+ requestedBy?: string;
689
+ reason?: string;
690
+ /** Changes applied */
691
+ changed: number;
692
+ /** The rows that changed, failed or refused the run — each with its collection and `from` / `to` */
693
+ actions: Array<ConvergeAction & { collection: string }>;
694
+ unstable?: ConvergeUnstable[];
695
+ /**
696
+ * What the run saw of Atlas Search, and how a wait for its builds ended —
697
+ * when a definition declares `searchIndexes`
698
+ * @experimental New in 2.2
699
+ */
700
+ search?: ConvergeSearchSummary;
701
+ }
702
+
703
+ /**
704
+ * Something applied that still compares as changed — reported, never rebuilt in a loop
705
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
706
+ */
707
+ export interface ConvergeUnstable {
708
+ collection: string;
709
+ target: ConvergeTarget;
710
+ name: string;
711
+ action: ConvergeActionKind;
712
+ reason?: string;
713
+ }
714
+
715
+ /**
716
+ * A declared search index that exists but does not serve its declaration yet
717
+ * @experimental New in 2.2
718
+ */
719
+ export interface SearchIndexNotReady extends SearchIndexBuild {
720
+ collection: string;
721
+ name: string;
722
+ }
723
+
724
+ /**
725
+ * What a converge saw of Atlas Search — present when a definition declares
726
+ * `searchIndexes`
727
+ * @experimental New in 2.2
728
+ */
729
+ export interface ConvergeSearchSummary {
730
+ /** Whether the server has Atlas Search */
731
+ available: boolean;
732
+ /**
733
+ * How that was told: `'listed'` (the server listed search indexes),
734
+ * `'parameter'` (its search index manager setting), `'error'` (it refused
735
+ * a search command), `'version'` (older than 6.0, not asked) or
736
+ * `'assumed'` (it would not say — a refusal at apply time reports it)
737
+ */
738
+ evidence?: 'listed' | 'parameter' | 'error' | 'version' | 'assumed';
739
+ /**
740
+ * Declared search indexes still building, updating, stale or failed. Does
741
+ * not count against `inSync`: a build is the server's work, not a difference.
742
+ */
743
+ notReady: SearchIndexNotReady[];
744
+ /** How a wait for the builds (`waitForSearchIndexes`) ended, when there was one */
745
+ wait?: { outcome: ConvergeWaitOutcome; waitedMs: number };
746
+ }
747
+
748
+ /** How a wait for search index builds ended */
749
+ export type ConvergeWaitOutcome = 'ready' | 'failed' | 'timeout' | 'unreadable' | 'aborted';
750
+
751
+ /**
752
+ * Outcome of {@link MigratorKit.converge}
753
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
754
+ */
755
+ export interface ConvergeResult {
756
+ dryRun: boolean;
757
+ /** Changes applied — or, in a dry run, changes the run would make */
758
+ changed: number;
759
+ /**
760
+ * True when the database matches the declarations: nothing left to do and
761
+ * no conflict. Undeclared indexes kept with prune off, search indexes
762
+ * skipped on a server without Search, and search index builds still under
763
+ * way do not count against it.
764
+ */
765
+ inSync: boolean;
766
+ collections: CollectionConvergeResult[];
767
+ unstable?: ConvergeUnstable[];
768
+ /** @experimental New in 2.2 */
769
+ search?: ConvergeSearchSummary;
770
+ }
771
+
278
772
  // ─── Logger ───────────────────────────────────────────────────────────────────
279
773
 
280
774
  /**
@@ -303,6 +797,92 @@ export interface MigronautLogger {
303
797
  */
304
798
  export type LogMethod = (msg: string, fields?: Record<string, unknown>) => void;
305
799
 
800
+ // ─── Telemetry ────────────────────────────────────────────────────────────────
801
+
802
+ /** A span or metric attribute value — the scalar subset migronaut sets */
803
+ export type MigronautAttributes = Record<string, string | number | boolean>;
804
+
805
+ /**
806
+ * The slice of an OpenTelemetry `Span` migronaut calls. Declared structurally —
807
+ * `@opentelemetry/api` is deliberately not imported, so the package's types
808
+ * resolve for users who never installed it. A real `Span` satisfies it.
809
+ */
810
+ export interface MigronautSpan {
811
+ setAttribute(key: string, value: string | number | boolean): unknown;
812
+ /** `code` is OpenTelemetry's `SpanStatusCode` — migronaut only ever sets ERROR (2) */
813
+ setStatus(status: { code: number; message?: string }): unknown;
814
+ end(): void;
815
+ }
816
+
817
+ /**
818
+ * The slice of an OpenTelemetry `Tracer` migronaut calls — what
819
+ * `trace.getTracer('@alexify/migronaut')` returns. Only `startActiveSpan` is
820
+ * used: it is what makes a span the active context for the migration's own
821
+ * code, and so for any instrumentation running underneath it.
822
+ */
823
+ export interface MigronautTracer {
824
+ startActiveSpan<T>(
825
+ name: string,
826
+ options: { attributes?: MigronautAttributes },
827
+ fn: (span: MigronautSpan) => T,
828
+ ): T;
829
+ }
830
+
831
+ /** An OpenTelemetry `Histogram`, as far as migronaut uses one */
832
+ export interface MigronautHistogram {
833
+ record(value: number, attributes?: MigronautAttributes): void;
834
+ }
835
+
836
+ /** An OpenTelemetry `Counter`, as far as migronaut uses one */
837
+ export interface MigronautCounter {
838
+ add(value: number, attributes?: MigronautAttributes): void;
839
+ }
840
+
841
+ /** Options migronaut passes when it creates an instrument */
842
+ export interface MigronautMetricOptions {
843
+ description?: string;
844
+ unit?: string;
845
+ /** Histogram bucket boundaries, in the instrument's unit (seconds) */
846
+ advice?: { explicitBucketBoundaries?: number[] };
847
+ }
848
+
849
+ /**
850
+ * The slice of an OpenTelemetry `Meter` migronaut calls — what
851
+ * `metrics.getMeter('@alexify/migronaut')` returns.
852
+ */
853
+ export interface MigronautMeter {
854
+ createHistogram(name: string, options?: MigronautMetricOptions): MigronautHistogram;
855
+ createCounter(name: string, options?: MigronautMetricOptions): MigronautCounter;
856
+ }
857
+
858
+ /**
859
+ * The `telemetry` config option. Both parts are optional and independent:
860
+ * a tracer alone gives spans, a meter alone gives metrics.
861
+ *
862
+ * Spans: `migronaut.run` (one per run that held the lock) and
863
+ * `migronaut.migration` (one per migration executed, a child of the run).
864
+ * Metrics: `migronaut.run.duration`, `migronaut.migration.duration` and
865
+ * `migronaut.lock.acquire.duration` (histograms, seconds), plus the counters
866
+ * `migronaut.lock.refused` and `migronaut.lock.lost`. A failure sets the span's
867
+ * status to ERROR with a redacted message, and `error.type` — on the span and
868
+ * the metric point — to the {@link MigronautErrorCode}, or for an error that is
869
+ * not migronaut's to its class name (`_OTHER` when it has none).
870
+ *
871
+ * A tracer or meter that throws never fails a run.
872
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
873
+ */
874
+ export interface MigronautTelemetry {
875
+ tracer?: MigronautTracer | null;
876
+ meter?: MigronautMeter | null;
877
+ /**
878
+ * Static attributes added to every span and every metric point — your own
879
+ * low-cardinality dimensions (`{ tenant: 'acme' }`). At most 20. They cannot
880
+ * replace migronaut's own: `db.namespace` (the database name, always
881
+ * present) and the `migronaut.*` attributes win.
882
+ */
883
+ attributes?: Record<string, string | number | boolean>;
884
+ }
885
+
306
886
  // ─── Progress Reporter ─────────────────────────────────────────────────────────
307
887
 
308
888
  /**
@@ -375,6 +955,20 @@ export interface StatusRow {
375
955
  * the whole status/audit call.
376
956
  */
377
957
  invalid?: true;
958
+ /**
959
+ * The file's current checksum (SHA-256 hex) — on `dryRun('up')` rows, so a
960
+ * caller that applies them later can insist on exactly this version
961
+ * (`up(file, { checksum })`).
962
+ */
963
+ checksum?: string;
964
+ /** Who asked for the apply, and why — when the run said (`requestedBy` / `reason` options) */
965
+ requestedBy?: string;
966
+ reason?: string;
967
+ /** Who asked for the revert, and why — on reverted history rows */
968
+ revertRequestedBy?: string;
969
+ revertReason?: string;
970
+ /** The checksum of the file version that failed (status `'failed'` only) */
971
+ failedChecksum?: string;
378
972
  }
379
973
 
380
974
  // ─── Import (migrate-mongo adoption) ────────────────────────────────────────────
@@ -432,6 +1026,13 @@ export interface LockInfo {
432
1026
  host: string;
433
1027
  /** Username of the holder */
434
1028
  executedBy: string;
1029
+ /**
1030
+ * The holder's run id — the `runId` of its events, log lines and changelog
1031
+ * records. Absent for a document written by hand.
1032
+ */
1033
+ runId?: string;
1034
+ /** The holder's lock TTL (ms), which paces its heartbeat. Absent before 2.1 */
1035
+ ttlMs?: number;
435
1036
  }
436
1037
 
437
1038
  // ─── Error Codes ──────────────────────────────────────────────────────────────
@@ -456,7 +1057,11 @@ export type MigronautErrorCode =
456
1057
  | 'NOT_APPLIED'
457
1058
  | 'IMPORT_TARGET_NOT_EMPTY'
458
1059
  | 'MIGRATION_IRREVERSIBLE'
459
- | 'MIGRATION_OUT_OF_ORDER';
1060
+ | 'MIGRATION_OUT_OF_ORDER'
1061
+ | 'MIGRATION_BLOCKED'
1062
+ | 'QUEUE_JOB_INVALID'
1063
+ | 'QUEUE_JOB_FAILED'
1064
+ | 'CONVERGE_FAILED';
460
1065
 
461
1066
  // ─── Config file format ─────────────────────────────────────────────────────────
462
1067
 
@@ -488,6 +1093,44 @@ export interface UpOptions {
488
1093
  * Mutually exclusive with a filename and `steps`.
489
1094
  */
490
1095
  to?: string;
1096
+ /**
1097
+ * Stamp this batch number on what the run applies, instead of the next free
1098
+ * one ({@link MigratorKit.nextBatch}). A label, not a reservation: it may
1099
+ * equal a batch already in use, which is how several single-file runs become
1100
+ * one rollback unit — the queue adapter gives every job of an enqueue group
1101
+ * the same value. Positive integer; mutually exclusive with `step`.
1102
+ */
1103
+ batch?: number;
1104
+ /**
1105
+ * Refuse ({@link MigrationBlockedError}) to apply the named file while an
1106
+ * earlier file on disk is still pending — the invariant a bulk `up` gets by
1107
+ * construction, enforced from the changelog rather than from the caller's
1108
+ * memory. Also makes the single-file run honour `strict` drift checks and
1109
+ * `onOutOfOrder` like a bulk run. Requires a filename.
1110
+ */
1111
+ ordered?: boolean;
1112
+ /**
1113
+ * The SHA-256 (hex) the named file must have — refuse ({@link
1114
+ * ChecksumMismatchError}, `context.planned: true`) to apply any other
1115
+ * version of it. A queue job carries the checksum its plan saw, so a worker
1116
+ * from another deploy never applies a different file under the same name.
1117
+ * Requires a filename; an already-applied file is skipped as usual.
1118
+ */
1119
+ checksum?: string;
1120
+ /**
1121
+ * Converge the declared collections after the migrations, under the same
1122
+ * lock — overrides `convergeAfterUp` for this call. Bulk runs only: refused
1123
+ * with a filename or `to`.
1124
+ */
1125
+ converge?: boolean;
1126
+ /**
1127
+ * Who asked for this run (≤ 128 characters) — stamped on the changelog
1128
+ * records it writes. `executedBy` is the OS user that ran it; on a queue
1129
+ * worker that is the container's, which is why the requester is separate.
1130
+ */
1131
+ requestedBy?: string;
1132
+ /** Why (≤ 512 characters) — a ticket, a sentence; stamped like `requestedBy` */
1133
+ reason?: string;
491
1134
  }
492
1135
 
493
1136
  /** Options for {@link MigratorKit.down} */
@@ -508,6 +1151,20 @@ export interface DownOptions {
508
1151
  * to the same state. Mutually exclusive with `batch`, `steps` and a filename.
509
1152
  */
510
1153
  to?: string;
1154
+ /**
1155
+ * Refuse ({@link MigrationBlockedError}) to revert the named file while a
1156
+ * migration applied *after* it is still applied — reverts must go newest
1157
+ * first (by `appliedAt`, the order `steps` uses). Requires a filename.
1158
+ */
1159
+ ordered?: boolean;
1160
+ /**
1161
+ * Who asked for this run (≤ 128 characters) — stamped on the records it
1162
+ * reverts (`revertRequestedBy`). `executedBy` is the OS user that ran it; on a queue
1163
+ * worker that is the container's, which is why the requester is separate.
1164
+ */
1165
+ requestedBy?: string;
1166
+ /** Why (≤ 512 characters) — a ticket, a sentence; stamped as `revertReason` */
1167
+ reason?: string;
511
1168
  }
512
1169
 
513
1170
  /** Payload common to every lifecycle event */
@@ -531,7 +1188,7 @@ export interface MigrationEvent extends MigronautEventBase {
531
1188
  }
532
1189
 
533
1190
  export interface RunStartEvent extends MigronautEventBase {
534
- /** Which command started the run: 'up' | 'down' | 'redo' | 'import' | 'baseline' */
1191
+ /** Which command started the run: 'up' | 'down' | 'redo' | 'import' | 'baseline' | 'converge' */
535
1192
  command?: string;
536
1193
  direction?: 'up' | 'down';
537
1194
  }
@@ -563,12 +1220,90 @@ export interface LockEvent extends MigronautEventBase {
563
1220
  ttlMs?: number;
564
1221
  /** How long acquisition took in ms (on `lock:acquired`) */
565
1222
  acquireMs?: number;
1223
+ /**
1224
+ * True on a `lock:released` that came before the run ended: a converge gave
1225
+ * the lock up to wait for search index builds, which only reads.
1226
+ * @experimental New in 2.2
1227
+ */
1228
+ early?: true;
1229
+ }
1230
+
1231
+ /** Who started a converge: the `converge` call itself, or a bulk `up` (`convergeAfterUp`) */
1232
+ export type ConvergeTrigger = 'converge' | 'up';
1233
+
1234
+ /**
1235
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1236
+ */
1237
+ export interface ConvergeStartEvent extends MigronautEventBase {
1238
+ trigger: ConvergeTrigger;
1239
+ /** Declared collections being converged */
1240
+ collections: number;
1241
+ }
1242
+
1243
+ /**
1244
+ * One step a converge carried out (or failed)
1245
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1246
+ */
1247
+ export interface ConvergeActionEvent extends MigronautEventBase {
1248
+ collection: string;
1249
+ target: ConvergeTarget;
1250
+ name: string;
1251
+ action: ConvergeActionKind;
1252
+ /**
1253
+ * `'started'` fires before the step runs — an index build can take hours,
1254
+ * and this is how a subscriber sees which one is in progress; `'applied'`
1255
+ * or `'failed'` follows when it ends.
1256
+ */
1257
+ status: 'started' | 'applied' | 'failed';
1258
+ /** `'applied'` only */
1259
+ durationMs?: number;
1260
+ reason?: string;
1261
+ /** Redacted failure message (status `'failed'` only) */
1262
+ error?: string;
1263
+ }
1264
+
1265
+ /**
1266
+ * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1267
+ */
1268
+ /**
1269
+ * A converge's wait for search index builds (`waitForSearchIndexes`):
1270
+ * `started` once, `progress` every 30 seconds, then how it ended — one of
1271
+ * {@link ConvergeWaitOutcome}.
1272
+ * @experimental New in 2.2
1273
+ */
1274
+ export interface ConvergeWaitEvent extends MigronautEventBase {
1275
+ status: 'started' | 'progress' | ConvergeWaitOutcome;
1276
+ /** How many search indexes the wait is for */
1277
+ searchIndexes: number;
1278
+ /** `started` only — whether the migration lock was released for the wait */
1279
+ lockReleased?: boolean;
1280
+ /** `started` only — the budget (`searchIndexWaitTimeoutMs`) */
1281
+ timeoutMs?: number;
1282
+ /** Every status but `started` */
1283
+ waitedMs?: number;
1284
+ /** `failed` and `timeout` — the indexes that did not get there */
1285
+ notReady?: SearchIndexNotReady[];
1286
+ }
1287
+
1288
+ export interface ConvergeEndEvent extends MigronautEventBase {
1289
+ trigger: ConvergeTrigger;
1290
+ success: boolean;
1291
+ durationMs: number;
1292
+ changed: number;
1293
+ inSync: boolean;
1294
+ /** Rows per action kind */
1295
+ counts: Partial<Record<ConvergeActionKind, number>>;
1296
+ /** The full result — partial on the failure path */
1297
+ result: ConvergeResult;
1298
+ /** Redacted failure message */
1299
+ error?: string;
566
1300
  }
567
1301
 
568
1302
  /**
569
1303
  * Lifecycle events emitted by {@link MigratorKit}. Subscribe to feed metrics or
570
1304
  * alerting without parsing log lines; a listener that throws is contained and
571
- * never fails the run.
1305
+ * never fails the run. The `converge:*` events fire for real converge runs
1306
+ * only, not for a dry run.
572
1307
  */
573
1308
  export interface MigronautEvents {
574
1309
  'run:start': (event: RunStartEvent) => void;
@@ -580,11 +1315,19 @@ export interface MigronautEvents {
580
1315
  'lock:acquired': (event: LockEvent) => void;
581
1316
  'lock:released': (event: LockEvent) => void;
582
1317
  'lock:lost': (event: LockEvent) => void;
1318
+ 'converge:start': (event: ConvergeStartEvent) => void;
1319
+ 'converge:action': (event: ConvergeActionEvent) => void;
1320
+ 'converge:wait': (event: ConvergeWaitEvent) => void;
1321
+ 'converge:end': (event: ConvergeEndEvent) => void;
583
1322
  }
584
1323
 
585
1324
  /** One check performed by {@link MigratorKit.audit} */
586
1325
  export interface AuditCheck {
587
- /** e.g. 'config', 'connection', 'transactions', 'indexes', 'lock', 'checksums' */
1326
+ /**
1327
+ * e.g. 'config', 'connection', 'transactions', 'indexes', 'lock', 'checksums',
1328
+ * 'pending', 'ordering', 'runtime' — and 'search' when declared collections
1329
+ * hold search indexes
1330
+ */
588
1331
  name: string;
589
1332
  status: 'pass' | 'warn' | 'fail';
590
1333
  detail: string;
@@ -599,10 +1342,20 @@ export interface AuditReport {
599
1342
  checks: AuditCheck[];
600
1343
  }
601
1344
 
1345
+ /** Options for {@link MigratorKit.status} and {@link MigratorKit.list} */
1346
+ export interface StatusOptions {
1347
+ /** Hash applied files to fill `checksumOk`. Default true */
1348
+ checksums?: boolean;
1349
+ }
1350
+
602
1351
  /** Options for {@link MigratorKit.redo} */
603
1352
  export interface RedoOptions {
604
1353
  /** Skip lock acquisition (dev only) */
605
1354
  noLock?: boolean;
1355
+ /** Who asked — stamped on the revert and on the re-apply */
1356
+ requestedBy?: string;
1357
+ /** Why — stamped like `requestedBy` */
1358
+ reason?: string;
606
1359
  }
607
1360
 
608
1361
  /** Options for {@link MigratorKit.create} */
@@ -720,6 +1473,19 @@ export class MigratorKit extends EventEmitter {
720
1473
  * removed, or null if no lock was held.
721
1474
  */
722
1475
  forceUnlock(): Promise<LockInfo | null>;
1476
+ /**
1477
+ * The batch number the next `up` would use (highest recorded batch + 1,
1478
+ * reverted and failed records included). A peek, not a reservation — pair it
1479
+ * with `up(name, { batch })` to stamp several single-file runs as one batch.
1480
+ * Connects if needed.
1481
+ */
1482
+ nextBatch(): Promise<number>;
1483
+ /**
1484
+ * A new id in this kit's configured format — the `generateId` option, else a
1485
+ * random UUID. The same source every run id comes from, for code that wants
1486
+ * its own ids to match. Resolves the config; does not connect.
1487
+ */
1488
+ generateId(): Promise<string>;
723
1489
  /** Run all pending migrations, or a specific named file */
724
1490
  up(filename?: string, options?: UpOptions): Promise<RunResult[]>;
725
1491
  /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
@@ -749,16 +1515,22 @@ export class MigratorKit extends EventEmitter {
749
1515
  filename?: string,
750
1516
  options?: { steps?: number; batch?: number; to?: string },
751
1517
  ): Promise<StatusRow[]>;
752
- /** Full migration status for all known files and records */
753
- status(): Promise<StatusRow[]>;
1518
+ /**
1519
+ * Full migration status for all known files and records. `checksums: false`
1520
+ * skips hashing the applied files (`checksumOk` stays null).
1521
+ */
1522
+ status(options?: StatusOptions): Promise<StatusRow[]>;
754
1523
  /**
755
1524
  * Read-only health check: configuration, connectivity, transaction support,
756
1525
  * changelog indexes, lock state, checksum drift and runtime. Reports
757
1526
  * problems; fixes none of them.
758
1527
  */
759
1528
  audit(): Promise<AuditReport>;
760
- /** Filtered list of migrations. Default: 'all' */
761
- list(filter?: 'all' | 'pending' | 'applied'): Promise<StatusRow[]>;
1529
+ /**
1530
+ * Filtered list of migrations. Default: 'all'. `checksums: false` skips
1531
+ * hashing the applied files — for a caller that needs names and dates only.
1532
+ */
1533
+ list(filter?: 'all' | 'pending' | 'applied', options?: StatusOptions): Promise<StatusRow[]>;
762
1534
  /** Create a new migration file and return its absolute path */
763
1535
  create(name: string, options?: CreateOptions): Promise<string>;
764
1536
  /** Create a migronaut config file in the working directory and return its path */
@@ -776,6 +1548,25 @@ export class MigratorKit extends EventEmitter {
776
1548
  * are skipped, so a partial baseline can simply be re-run.
777
1549
  */
778
1550
  baseline(options?: BaselineOptions): Promise<BaselineSummary>;
1551
+ /**
1552
+ * Bring the declared collections (`collections`, `collectionsDir`) to their
1553
+ * declared indexes and validators. Stateless: the live database is read and
1554
+ * compared on every call; what a run changed is appended to the converge
1555
+ * history ({@link MigratorKit.convergeHistory}), which no run reads back. A
1556
+ * real run holds the migration lock; a plan with a conflict is refused before
1557
+ * any write, and a failed step throws {@link ConvergeFailedError}. Experimental.
1558
+ */
1559
+ converge(options?: ConvergeOptions): Promise<ConvergeResult>;
1560
+ /**
1561
+ * Whether a bulk `up` on this kit ends by converging: `convergeAfterUp` is
1562
+ * on and something is declared. Resolves the config; does not connect.
1563
+ */
1564
+ convergesAfterUp(): Promise<boolean>;
1565
+ /**
1566
+ * The converge history, newest first (`limit` 1–1000, default 20): one entry
1567
+ * per converge that changed something or failed. Read-only.
1568
+ */
1569
+ convergeHistory(options?: { limit?: number }): Promise<ConvergeHistoryEntry[]>;
779
1570
  }
780
1571
 
781
1572
  // ─── Programmatic entry points ─────────────────────────────────────────────────
@@ -799,11 +1590,24 @@ export interface RunMigrationsOptions extends MigratorKitOptions {
799
1590
  * progress**. While the holder's heartbeat visibly advances its lock, the
800
1591
  * deadline is re-armed — a healthy peer working through a long backlog never
801
1592
  * times its waiting peers out; only a stalled holder runs this budget down.
802
- * Default: 90000.
1593
+ * Default: 90000, or 1.5× the holder's lock TTL when that is longer — its
1594
+ * heartbeat only moves the lock every TTL/2, and a crashed holder's lock is
1595
+ * reclaimable only after a full TTL. An explicit value is used as given.
803
1596
  */
804
1597
  lockWaitTimeoutMs?: number;
805
- /** Poll interval (ms) while waiting for the lock. Default: 500 */
1598
+ /**
1599
+ * First poll interval (ms) while waiting for the lock. Polls back off from
1600
+ * it, doubling, up to 5 s (and never more than a quarter of the wait budget).
1601
+ * Default: 500
1602
+ */
806
1603
  lockPollIntervalMs?: number;
1604
+ /**
1605
+ * Abort the call: a wait for the lock stops between polls, and a run that
1606
+ * holds it stops between migrations (one already executing finishes), with
1607
+ * a {@link RunAbortedError}. Wire it to SIGTERM so a pod being shut down
1608
+ * does not take the lock just before it is killed.
1609
+ */
1610
+ signal?: AbortSignal;
807
1611
  /**
808
1612
  * Receives the internally-constructed {@link MigratorKit} right after
809
1613
  * construction (before connect), so an embedding application can subscribe
@@ -822,10 +1626,12 @@ export interface MigrationSummary {
822
1626
  upToDate: boolean;
823
1627
  /** True when this instance waited for a peer to release the lock before running */
824
1628
  waited: boolean;
825
- /** Total time (ms) spent waiting for a peer's lock. 0 when the lock was free */
1629
+ /** Time (ms) from the first refusal to the run, by the clock. 0 when the lock was free */
826
1630
  waitedMs: number;
827
1631
  /** Number of `up` attempts made — 1 when the lock was free on the first try */
828
1632
  attempts: number;
1633
+ /** The converge that ended the run — present only when `convergeAfterUp` converged */
1634
+ converge?: ConvergeResult;
829
1635
  }
830
1636
 
831
1637
  /**
@@ -849,14 +1655,17 @@ export function pendingMigrations(
849
1655
  ): Promise<StatusRow[]>;
850
1656
 
851
1657
  /**
852
- * The CLI's exit-code map: one entry per {@link MigronautErrorCode}, plus two
1658
+ * The CLI's exit-code map: one entry per {@link MigronautErrorCode}, plus three
853
1659
  * 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.
1660
+ * `status --check`), `AUDIT_FAILED` and `COLLECTIONS_DRIFT` (from
1661
+ * `converge --check`). Lets a wrapper script mirror the CLI's exit semantics
1662
+ * without hardcoding numbers. Anything unmapped exits 1; success is 0.
857
1663
  */
858
1664
  export const EXIT_CODES: Readonly<
859
- Record<MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED', number>
1665
+ Record<
1666
+ MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED' | 'COLLECTIONS_DRIFT',
1667
+ number
1668
+ >
860
1669
  >;
861
1670
 
862
1671
  // ─── Logger factory ───────────────────────────────────────────────────────────
@@ -1013,3 +1822,63 @@ export class IrreversibleMigrationError extends MigronautError {
1013
1822
  export class OutOfOrderMigrationError extends MigronautError {
1014
1823
  constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1015
1824
  }
1825
+
1826
+ /**
1827
+ * Thrown by an `ordered` single-file `up`/`down` that would run out of
1828
+ * sequence: an earlier migration is still pending (`up`), or one applied later
1829
+ * is still applied (`down`). `context.name`, `context.direction` and
1830
+ * `context.blockedBy` (the migrations that must go first).
1831
+ */
1832
+ export class MigrationBlockedError extends MigronautError {
1833
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1834
+ }
1835
+
1836
+ /**
1837
+ * Thrown by the queue adapter (`@alexify/migronaut/bullmq`) when a job's
1838
+ * payload fails the contract check — an unknown job name or data version, a
1839
+ * migration name that is not a bare filename, malformed group fields. Job data
1840
+ * is untrusted input. `context.jobId`, `context.issue`.
1841
+ */
1842
+ export class QueueJobInvalidError extends MigronautError {
1843
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1844
+ }
1845
+
1846
+ /**
1847
+ * Thrown by a queue group's `wait()` when one of its jobs failed or the wait
1848
+ * timed out (one budget for the whole call). `context.failedReason` is the
1849
+ * worker's (redacted) message, `context.code` the job's own typed error code
1850
+ * when it reported one (`MIGRATION_BLOCKED`, `CHECKSUM_MISMATCH`, …),
1851
+ * `context.results` the jobs that finished before it, plus `groupId`, `jobId`,
1852
+ * `migration`, `direction` and `timedOut`.
1853
+ */
1854
+ export class QueueJobFailedError extends MigronautError {
1855
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1856
+ }
1857
+
1858
+ /**
1859
+ * Thrown by {@link MigratorKit.converge} when the database cannot be brought
1860
+ * to the declared state. `context.phase` is:
1861
+ * - `'plan'` for a refused plan (`context.conflicts` lists why, with a `hint`
1862
+ * when Atlas Search is missing; nothing was written) — or a search index
1863
+ * list that could not be read before the first write (`collection`,
1864
+ * `target: 'searchIndex'`, `cause`, `mongoCode`, `hint`);
1865
+ * - `'replan'` when a collection changed while the run was under way
1866
+ * (`collection`, `introduced`: the new conflicts or drops; nothing of that
1867
+ * collection was written) — or its search index list could not be read
1868
+ * again (as for `'plan'`);
1869
+ * - `'apply'` for a failed step (`collection`, `target`, `name`, `action`,
1870
+ * `cause`, and `mongoCode`, `hint` and — after a failed rebuild — `restored`
1871
+ * when they apply) — or a search index list that could not be read to check
1872
+ * the steps just applied (as for `'plan'`);
1873
+ * - `'wait'` when `waitForSearchIndexes` gave up: `reason` is `'failed'` (the
1874
+ * build of a search index this run created or changed FAILED) or `'timeout'`,
1875
+ * with `notReady` the indexes not serving their declaration, `waitedMs`,
1876
+ * `timeoutMs` — or `'unreadable'`: a search index list that could not be
1877
+ * read (as for `'plan'`), after up to three network or failover blips in a
1878
+ * row. Everything was applied — only the builds were not finished.
1879
+ *
1880
+ * `context.converge` is the {@link ConvergeResult} so far.
1881
+ */
1882
+ export class ConvergeFailedError extends MigronautError {
1883
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
1884
+ }