@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
@@ -1,6 +1,6 @@
1
- const { randomUUID } = require('node:crypto');
2
1
  const { EventEmitter } = require('node:events');
3
2
  const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
4
  const path = require('node:path');
5
5
  // `mongodb` is required lazily inside connect(): loading the driver costs ~60ms
6
6
  // and pulls in ~150 modules, which `--help`, `--version`, `init` and `create`
@@ -11,6 +11,7 @@ const {
11
11
  ConnectionFailedError,
12
12
  HookFailedError,
13
13
  IrreversibleMigrationError,
14
+ LockAlreadyHeldError,
14
15
  MigrationFileNotFoundError,
15
16
  MigrationInvalidNameError,
16
17
  MigronautError,
@@ -18,11 +19,15 @@ const {
18
19
  OutOfOrderMigrationError,
19
20
  RunAbortedError,
20
21
  } = require('../errors/index.js');
22
+ const { actorFields, pickActor } = require('../utils/actor.js');
21
23
  const { computeChecksum } = require('../utils/checksum.js');
22
24
  const { mapLimit } = require('../utils/concurrency.js');
23
- const { errorText } = require('../utils/error.js');
25
+ const { errorText, errorWithCause } = require('../utils/error.js');
26
+ const { createIdGenerator } = require('../utils/id.js');
24
27
  const { loadMigrationFile } = require('../utils/loader.js');
25
28
  const { resolveLogger } = require('../utils/logger.js');
29
+ const { assertMigrationName } = require('../utils/migration-name.js');
30
+ const { ATTRIBUTES, SPANS, createTelemetry } = require('../utils/telemetry.js');
26
31
  const {
27
32
  createConfigFile,
28
33
  createMigrationFile,
@@ -32,11 +37,35 @@ const { safeUsername } = require('../utils/user.js');
32
37
  const { runAudit } = require('./audit.js');
33
38
  const { runBaseline } = require('./baseline.js');
34
39
  const { Changelog } = require('./changelog.js');
35
- const { isCollectionName, loadConfig } = require('./config.js');
40
+ const { ConvergeLog } = require('./converge-log.js');
41
+ const { resolveDefinitions } = require('./collections.js');
42
+ const { loadConfig } = require('./config.js');
36
43
  const { buildContext } = require('./context.js');
44
+ const { runConverge } = require('./converge.js');
37
45
  const { runImport } = require('./import-runner.js');
38
46
  const { MigrationLock, runWithLock, toLockInfo } = require('./lock.js');
47
+ const {
48
+ assertConvergeOptions,
49
+ assertDownOptions,
50
+ assertDryRunOptions,
51
+ assertFilename,
52
+ assertHistoryLimit,
53
+ assertImportOptions,
54
+ assertListOptions,
55
+ assertRedoOptions,
56
+ assertUpOptions,
57
+ } = require('./options.js');
58
+ const { RunRecorder } = require('./run-recorder.js');
39
59
  const { runMigration } = require('./runner.js');
60
+ const {
61
+ blockedError,
62
+ lateArrivals,
63
+ listMigrationFiles,
64
+ newestOf,
65
+ pendingIn,
66
+ revertOrder,
67
+ truncateAtTarget,
68
+ } = require('./sequence.js');
40
69
 
41
70
  /** Simultaneous file reads — keeps a large migrations dir clear of EMFILE */
42
71
  const FS_CONCURRENCY = 16;
@@ -51,6 +80,13 @@ const FS_CONCURRENCY = 16;
51
80
  * logic in the migration's flow; listeners attach from outside, may be several,
52
81
  * and a listener that throws is contained rather than failing the run.
53
82
  */
83
+ /**
84
+ * How a lock-wait loop outside the kit (`runMigrations`, the queue processor)
85
+ * reports a finished wait to the kit's telemetry. A symbol, and not exported
86
+ * from the package: the loop is migronaut's own, and so is this channel.
87
+ */
88
+ const RECORD_LOCK_WAIT = Symbol('migronaut.recordLockWait');
89
+
54
90
  class MigratorKit extends EventEmitter {
55
91
  #partialConfig;
56
92
  #configPath;
@@ -67,6 +103,10 @@ class MigratorKit extends EventEmitter {
67
103
  #runSetupDepth = 0;
68
104
  /** Correlation id for the run in flight — ties logs, lock and changelog together */
69
105
  #runId;
106
+ /** Mints an id in the configured format (`generateId`, else a UUID); set with the config */
107
+ #newId;
108
+ /** Spans and metrics through the injected `telemetry` (a no-op without one); set with the config */
109
+ #telemetry;
70
110
  /** Whether changelog indexes have already been ensured on this instance */
71
111
  #indexesEnsured = false;
72
112
  /** Memoized resolved logger — resolveLogger allocates on every call otherwise */
@@ -75,6 +115,13 @@ class MigratorKit extends EventEmitter {
75
115
  #fallbackLogger;
76
116
  /** False when the client was injected by the caller, who keeps ownership of it */
77
117
  #ownsClient = true;
118
+ /** The connect() in flight, so overlapping callers share one client instead of racing */
119
+ #connecting;
120
+ /**
121
+ * The config load in flight, so overlapping first callers share one — a
122
+ * config factory may fetch secrets, and must not run twice.
123
+ */
124
+ #configLoading;
78
125
  /**
79
126
  * Project root this instance resolves against — config discovery, the .env
80
127
  * file and a relative migrationsDir. Defaults to process.cwd(); an explicit
@@ -83,6 +130,16 @@ class MigratorKit extends EventEmitter {
83
130
  #cwd;
84
131
  /** filepath → {mtimeMs, size, checksum} — spares repeat status()/audit() calls a full re-hash */
85
132
  #checksumCache = new Map();
133
+ /** The converge history store, created on first use */
134
+ #convergeLogStore;
135
+ /**
136
+ * Definitions an after-up converge resolved, kept only while `up()` is being
137
+ * refused for a held lock: a caller polling for the lock retries `up()`
138
+ * every few hundred milliseconds, and each retry would otherwise re-import
139
+ * every definition file — under `reloadMigrations`, a module per file per
140
+ * poll that the module cache never frees. Cleared by any other outcome.
141
+ */
142
+ #upDefinitions;
86
143
 
87
144
  constructor(config = {}, options = {}) {
88
145
  super();
@@ -155,19 +212,28 @@ class MigratorKit extends EventEmitter {
155
212
  }
156
213
  }
157
214
 
158
- /** Resolve and cache the full configuration */
215
+ /** Resolve and cache the full configuration — once, however many callers ask at once */
159
216
  async #ensureConfig(requireDb = true, lenient = false) {
160
- if (!this.#config) {
161
- this.#config = await loadConfig({
162
- flags: this.#partialConfig,
163
- requireDb,
164
- ...(lenient ? { lenient: true } : {}),
165
- ...(this.#configPath ? { configPath: this.#configPath } : {}),
166
- ...(this.#cwd ? { cwd: this.#cwd } : {}),
167
- ...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
168
- });
169
- }
170
- return this.#config;
217
+ if (this.#config) return this.#config;
218
+ this.#configLoading ??= this.#loadConfig(requireDb, lenient).finally(() => {
219
+ this.#configLoading = undefined;
220
+ });
221
+ return this.#configLoading;
222
+ }
223
+
224
+ async #loadConfig(requireDb, lenient) {
225
+ const config = await loadConfig({
226
+ flags: this.#partialConfig,
227
+ requireDb,
228
+ ...(lenient ? { lenient: true } : {}),
229
+ ...(this.#configPath ? { configPath: this.#configPath } : {}),
230
+ ...(this.#cwd ? { cwd: this.#cwd } : {}),
231
+ ...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
232
+ });
233
+ this.#newId = createIdGenerator(config.generateId);
234
+ this.#telemetry = createTelemetry(config.telemetry, { dbName: config.dbName });
235
+ this.#config = config;
236
+ return config;
171
237
  }
172
238
 
173
239
  get #logger() {
@@ -215,12 +281,27 @@ class MigratorKit extends EventEmitter {
215
281
  return this.#runId ? { runId: this.#runId, ...extra } : { ...extra };
216
282
  }
217
283
 
284
+ /** Record a finished wait for the lock — see {@link RECORD_LOCK_WAIT} */
285
+ [RECORD_LOCK_WAIT](wait) {
286
+ this.#telemetry?.lockWaited(wait);
287
+ }
288
+
218
289
  /** Connect to MongoDB and ensure changelog indexes exist */
219
290
  async connect() {
220
291
  const config = await this.#ensureConfig();
221
292
  if (this.#client && this.#db) {
222
293
  return;
223
294
  }
295
+ // A long-lived kit serves overlapping callers (a status probe while a
296
+ // queue job starts). Without this, each would open its own MongoClient
297
+ // and all but the last would leak their pools.
298
+ this.#connecting ??= this.#openConnection(config).finally(() => {
299
+ this.#connecting = undefined;
300
+ });
301
+ return this.#connecting;
302
+ }
303
+
304
+ async #openConnection(config) {
224
305
  const startedAt = Date.now();
225
306
  try {
226
307
  if (config.client) {
@@ -343,8 +424,10 @@ class MigratorKit extends EventEmitter {
343
424
  }
344
425
  // One id per run, reused as the lock's owner token and stamped on every
345
426
  // changelog record and log line, so the three can be correlated after the
346
- // fact ("which run left this lock?", "what did run X apply?").
347
- this.#runId = randomUUID();
427
+ // fact ("which run left this lock?", "what did run X apply?"). Minted
428
+ // before any other run state exists: a `generateId` that throws or returns
429
+ // a non-id rejects here, leaving nothing to unwind and no event emitted.
430
+ this.#runId = this.#newId();
348
431
  // A second controller layered over the lock's own signal, so stop() and a
349
432
  // lost lock abort through the same path the run loops already watch.
350
433
  const stopper = new AbortController();
@@ -359,8 +442,15 @@ class MigratorKit extends EventEmitter {
359
442
  this.#stopRequested = undefined;
360
443
  this.#abort(pending);
361
444
  }
362
- const startedAt = Date.now();
363
- this.#emit('run:start', { ...info });
445
+ const recorder = new RunRecorder({
446
+ info,
447
+ runId: this.#runId,
448
+ telemetry: this.#telemetry,
449
+ emit: (event, payload) => this.#emit(event, payload),
450
+ logger: this.#logger,
451
+ fields: (extra) => this.#fields(extra),
452
+ });
453
+ recorder.start();
364
454
  let failure;
365
455
  let result;
366
456
  try {
@@ -373,62 +463,28 @@ class MigratorKit extends EventEmitter {
373
463
  logger: this.#lockLogger(),
374
464
  onLockLost: this.#config.onLockLost,
375
465
  owner: this.#runId,
376
- onLockAcquired: (extra) => this.#emit('lock:acquired', { owner: this.#runId, ...extra }),
377
- onLockReleased: (extra) => this.#emit('lock:released', { owner: this.#runId, ...extra }),
378
- onLockLostEvent: (reason) => this.#emit('lock:lost', { owner: this.#runId, reason }),
466
+ onLockAcquired: (extra) => recorder.lockAcquired(extra),
467
+ onLockReleased: (extra) => recorder.lockReleased(extra),
468
+ onLockLostEvent: (reason) => recorder.lockLost(reason),
379
469
  ...(options.noLock ? { noLock: true } : {}),
380
470
  },
381
- (lockSignal) => fn(AbortSignal.any([lockSignal, stopper.signal])),
471
+ (lockSignal) =>
472
+ // The run's span exists only once the lock is held: a caller polling
473
+ // for a busy lock retries the whole run every few hundred
474
+ // milliseconds, and a span per refusal would bury the one run that
475
+ // did the work. Active around the unit of work only, and ended by the
476
+ // recorder after the release, so the span's outcome is the run's.
477
+ this.#telemetry.open(SPANS.RUN, recorder.spanAttributes(), (span) => {
478
+ recorder.spanOpened(span);
479
+ return fn(AbortSignal.any([lockSignal, stopper.signal]));
480
+ }),
382
481
  );
383
482
  return result;
384
483
  } catch (error) {
385
484
  failure = error;
386
485
  throw error;
387
486
  } finally {
388
- // Result counts, so a metrics subscriber gets "3 applied in 812ms"
389
- // without reconstructing it from per-migration events. On the failure
390
- // path the partial rows live on the error's context — exactly the case
391
- // where "how far did it get?" is the question, so they count too. One
392
- // pass fills both counters.
393
- const rows = Array.isArray(result)
394
- ? result
395
- : failure instanceof MigronautError && Array.isArray(failure.context?.results)
396
- ? failure.context.results
397
- : null;
398
- let summary = {};
399
- if (rows) {
400
- let applied = 0;
401
- let reverted = 0;
402
- for (const row of rows) {
403
- if (row.status === 'applied') applied += 1;
404
- else if (row.status === 'reverted') reverted += 1;
405
- }
406
- summary = { applied, reverted, total: rows.length };
407
- }
408
- const durationMs = Date.now() - startedAt;
409
- this.#emit('run:end', {
410
- ...info,
411
- success: failure === undefined,
412
- durationMs,
413
- ...summary,
414
- // A raw Error here would hand subscribers an unredacted driver message
415
- // (which can echo the credentialed URI) — errorText is the same
416
- // chokepoint every log line and result row already goes through.
417
- ...(failure ? { error: errorText(failure) } : {}),
418
- });
419
- // One human rollup after the per-migration lines: total wall-clock time
420
- // (lock wait and hooks included) is otherwise unobtainable from the
421
- // output — per-file durations exclude all overhead. Success path only;
422
- // a failure already ends with its own error line.
423
- if (failure === undefined && (summary.applied || summary.reverted)) {
424
- const parts = [];
425
- if (summary.applied) parts.push(`${summary.applied} applied`);
426
- if (summary.reverted) parts.push(`${summary.reverted} reverted`);
427
- this.#logger.info(
428
- `✔ Done ${parts.join(', ')} in ${durationMs}ms`,
429
- this.#fields({ ...info, ...summary, durationMs }),
430
- );
431
- }
487
+ recorder.finish(result, failure);
432
488
  this.#abort = undefined;
433
489
  this.#runId = undefined;
434
490
  }
@@ -477,6 +533,30 @@ class MigratorKit extends EventEmitter {
477
533
  return toLockInfo(await this.#buildLock().forceRelease());
478
534
  }
479
535
 
536
+ /**
537
+ * The batch number the next `up` would use — a peek, not a reservation: two
538
+ * callers asking before either applies get the same number. Counts reverted
539
+ * and failed records too, so a rolled-back number is never handed out again.
540
+ * Pair it with `up(name, { batch })` to stamp several single-file runs as one
541
+ * batch.
542
+ */
543
+ async nextBatch() {
544
+ await this.#ensureConfig();
545
+ await this.connect();
546
+ return this.#nextBatch();
547
+ }
548
+
549
+ /**
550
+ * A new id in the format this kit is configured with — the `generateId`
551
+ * option, else a random UUID. It is what every run id comes from; exposed so
552
+ * a layer above the kit (the queue adapter's group ids) mints its own ids in
553
+ * the same format without being configured a second time. Does not connect.
554
+ */
555
+ async generateId() {
556
+ await this.#ensureConfig();
557
+ return this.#newId();
558
+ }
559
+
480
560
  /** Internal accessors that assume a successful connect() */
481
561
  #requireDb() {
482
562
  if (!this.#db) {
@@ -498,19 +578,6 @@ class MigratorKit extends EventEmitter {
498
578
  return path.resolve(this.#cwd ?? process.cwd(), this.#config.migrationsDir);
499
579
  }
500
580
 
501
- /**
502
- * Reject non-string filenames before they reach a changelog query or a path
503
- * join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
504
- * become a query-operator injection in `findOne({ name })`.
505
- */
506
- #assertFilename(filename) {
507
- if (filename !== undefined && typeof filename !== 'string') {
508
- throw new MigrationInvalidNameError('Migration name must be a string', {
509
- name: filename,
510
- });
511
- }
512
- }
513
-
514
581
  /**
515
582
  * Resolve a migration name to an absolute path inside the migrations dir.
516
583
  *
@@ -522,20 +589,7 @@ class MigratorKit extends EventEmitter {
522
589
  */
523
590
  #filepath(name) {
524
591
  const dir = this.#migrationsPath();
525
- if (
526
- typeof name !== 'string' ||
527
- name.length === 0 ||
528
- name === '.' ||
529
- name === '..' ||
530
- name.includes('/') ||
531
- name.includes('\\') ||
532
- name.includes('\0')
533
- ) {
534
- throw new MigrationInvalidNameError(
535
- 'Invalid migration name — must be a bare filename with no path segments',
536
- { name },
537
- );
538
- }
592
+ assertMigrationName(name);
539
593
  const resolved = path.join(dir, name);
540
594
  const relative = path.relative(dir, resolved);
541
595
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
@@ -548,33 +602,8 @@ class MigratorKit extends EventEmitter {
548
602
 
549
603
  /** List migration files on disk, sorted ascending */
550
604
  async #listMigrationFiles() {
551
- const dir = this.#migrationsPath();
552
605
  // No value fallback — DEFAULT_CONFIG always supplies fileExtensions.
553
- const extensions = this.#config.fileExtensions;
554
- let entries;
555
- try {
556
- entries = await fs.readdir(dir, { withFileTypes: true });
557
- } catch (error) {
558
- if (error.code === 'ENOENT') return [];
559
- throw error;
560
- }
561
- const matches = [];
562
- for (const entry of entries) {
563
- // A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
564
- // to the migrations is not a migration — including it would hard-fail
565
- // the whole run with MigrationInvalidExportError.
566
- if (!entry.isFile()) continue;
567
- const file = entry.name;
568
- if (file.startsWith('.')) continue;
569
- if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
570
- for (const ext of extensions) {
571
- if (file.endsWith(ext)) {
572
- matches.push(file);
573
- break;
574
- }
575
- }
576
- }
577
- return matches.sort();
606
+ return listMigrationFiles(this.#migrationsPath(), this.#config.fileExtensions);
578
607
  }
579
608
 
580
609
  /** Compute the next batch number (monotonic across the full history) */
@@ -583,78 +612,52 @@ class MigratorKit extends EventEmitter {
583
612
  }
584
613
 
585
614
  /**
586
- * Validate the `--steps` option for `down`/`dry-run down`: a positive integer,
587
- * mutually exclusive with a filename and `--batch`. No-op when steps is unset.
615
+ * `ordered` guard for a single-file `up`: refuse while an earlier file on
616
+ * disk is still pending. Pending means "no applied record" — a `'failed'`
617
+ * trace counts, so a migration that failed stops the line exactly like one
618
+ * that never ran. Checked inside the lock and before `beforeAll`, so a
619
+ * blocked run fires no hooks and consumes no batch number.
588
620
  */
589
- #assertStepsValid(steps, filename, batch) {
590
- if (steps === undefined) {
591
- return;
592
- }
593
- if (filename) {
594
- throw new ConfigInvalidError('Cannot combine a filename with --steps', { filename });
595
- }
596
- if (batch !== undefined) {
597
- throw new ConfigInvalidError('Cannot combine --batch with --steps', { batch, steps });
598
- }
599
- if (!Number.isInteger(steps) || steps < 1) {
600
- throw new ConfigInvalidError('--steps must be a positive integer', { steps });
601
- }
621
+ async #assertUpNotBlocked(name, appliedNames, sequence) {
622
+ const files = sequence ?? (await this.#listMigrationFiles());
623
+ const blockedBy = pendingIn(files, appliedNames, name);
624
+ if (blockedBy.length === 0) return;
625
+ throw blockedError(name, 'earlier migration(s) still pending', {
626
+ name,
627
+ direction: 'up',
628
+ blockedBy,
629
+ failed: await this.#failedAmong(blockedBy),
630
+ });
602
631
  }
603
632
 
604
633
  /**
605
- * Keep only the pending migrations up to and including `to`.
606
- *
607
- * `to` must name a migration that exists; it may already be applied (then
608
- * nothing before it is pending either, and the result is empty), which is
609
- * what makes `up --to X` idempotent — running it twice is a no-op rather
610
- * than an error.
634
+ * The blockers that failed — a stopped line — as opposed to ones that have
635
+ * simply not run yet: with several queue workers, an earlier job may still be
636
+ * in flight elsewhere, and waiting for it is the right reaction.
611
637
  */
612
- #truncateAtTarget(pending, allFiles, to) {
613
- if (!allFiles.includes(to)) {
614
- throw new MigrationFileNotFoundError('Migration file not found', { to });
615
- }
616
- const kept = [];
617
- for (const file of pending) {
618
- if (file > to) break;
619
- kept.push(file);
620
- }
621
- return kept;
638
+ async #failedAmong(names) {
639
+ return this.#requireChangelog().getFailedNames(this.#requireDb(), names);
622
640
  }
623
641
 
624
642
  /**
625
- * `--to` names a point in the sequence, so it cannot be combined with the
626
- * other ways of choosing targets.
643
+ * `ordered` guard for a single-file `down`: refuse while a migration applied
644
+ * *after* this one is still applied. "After" is chronological (`appliedAt`),
645
+ * not alphabetical — the order `down --steps` reverts in, and the only one
646
+ * that undoes effects in reverse of how they were made. Name order would
647
+ * deadlock a batch holding a file merged late from a parallel branch.
627
648
  */
628
- #assertToValid(to, filename, options = {}) {
629
- if (to === undefined) return;
630
- this.#assertFilename(to);
631
- if (filename) {
632
- throw new ConfigInvalidError('Cannot combine a filename with --to', { filename, to });
633
- }
634
- if (options.steps !== undefined) {
635
- throw new ConfigInvalidError('Cannot combine --steps with --to', {
636
- steps: options.steps,
637
- to,
638
- });
639
- }
640
- if (options.batch !== undefined) {
641
- throw new ConfigInvalidError('Cannot combine --batch with --to', {
642
- batch: options.batch,
643
- to,
644
- });
645
- }
646
- }
647
-
648
- /**
649
- * Validate `--batch`. Without this a typo (`--batch abc` → NaN) matches no
650
- * records, so the run prints "Nothing to rollback" and exits 0 — the worst
651
- * possible answer to a mistyped rollback.
652
- */
653
- #assertBatchValid(batch) {
654
- if (batch === undefined) return;
655
- if (!Number.isInteger(batch) || batch < 1) {
656
- throw new ConfigInvalidError('--batch must be a positive integer', { batch });
657
- }
649
+ async #assertDownNotBlocked(record) {
650
+ const newer = await this.#requireChangelog().getAppliedNewerThan(this.#requireDb(), record);
651
+ if (newer.length === 0) return;
652
+ const blockedBy = [];
653
+ for (const later of newer) blockedBy.push(later.name);
654
+ throw blockedError(record.name, 'later migration(s) still applied', {
655
+ name: record.name,
656
+ direction: 'down',
657
+ blockedBy,
658
+ // A rollback has no failed trace to tell a stopped line apart.
659
+ failed: [],
660
+ });
658
661
  }
659
662
 
660
663
  /**
@@ -675,11 +678,8 @@ class MigratorKit extends EventEmitter {
675
678
  return [filename];
676
679
  }
677
680
  const files = await this.#listMigrationFiles();
678
- const targets = [];
679
- for (const file of files) {
680
- if (!appliedNames.has(file)) targets.push(file);
681
- }
682
- return options.to !== undefined ? this.#truncateAtTarget(targets, files, options.to) : targets;
681
+ const targets = pendingIn(files, appliedNames);
682
+ return options.to !== undefined ? truncateAtTarget(targets, files, options.to) : targets;
683
683
  }
684
684
 
685
685
  /**
@@ -695,16 +695,10 @@ class MigratorKit extends EventEmitter {
695
695
  */
696
696
  #assertOrderIntact(targets, appliedNames) {
697
697
  const policy = this.#config?.onOutOfOrder ?? 'warn';
698
- if (policy === 'allow' || targets.length === 0 || appliedNames.size === 0) return;
699
- let newestApplied = '';
700
- for (const name of appliedNames) {
701
- if (name > newestApplied) newestApplied = name;
702
- }
703
- const late = [];
704
- for (const target of targets) {
705
- if (!appliedNames.has(target) && target < newestApplied) late.push(target);
706
- }
707
- if (late.length === 0) return;
698
+ if (policy === 'allow') return;
699
+ const arrivals = lateArrivals(targets, appliedNames);
700
+ if (arrivals === null) return;
701
+ const { late, newestApplied } = arrivals;
708
702
  if (policy === 'error') {
709
703
  throw new OutOfOrderMigrationError(
710
704
  `${late.length} pending migration(s) sort before the newest applied one ` +
@@ -769,13 +763,57 @@ class MigratorKit extends EventEmitter {
769
763
  return results;
770
764
  }
771
765
 
766
+ /**
767
+ * Execute one migration under its own span, and time it for the meter.
768
+ *
769
+ * The span is the *active* one for everything the migration does — hooks,
770
+ * the file's own `up`/`down`, the changelog write — which is what lets an
771
+ * instrumented driver hang its command spans under the migration that issued
772
+ * them. A listener on `migration:start` could open a span but never make it
773
+ * active, so this is the one thing telemetry needs from inside the kit.
774
+ */
775
+ async #executeMigration(step) {
776
+ const { name, direction, index, total, batch } = step;
777
+ const telemetry = this.#telemetry;
778
+ const startedAt = Date.now();
779
+ try {
780
+ const duration = await telemetry.wrap(
781
+ SPANS.MIGRATION,
782
+ {
783
+ [ATTRIBUTES.MIGRATION_NAME]: name,
784
+ [ATTRIBUTES.MIGRATION_DIRECTION]: direction,
785
+ [ATTRIBUTES.MIGRATION_BATCH]: batch,
786
+ [ATTRIBUTES.MIGRATION_INDEX]: index,
787
+ [ATTRIBUTES.MIGRATION_TOTAL]: total,
788
+ [ATTRIBUTES.RUN_ID]: this.#runId,
789
+ },
790
+ (span) => this.#executeMigrationSteps(step, span),
791
+ );
792
+ telemetry.migrationEnded({ direction, durationMs: duration });
793
+ return duration;
794
+ } catch (error) {
795
+ // The runner's own measurement when it got as far as the body; the
796
+ // elapsed time here otherwise (a failing beforeEach, a file that does
797
+ // not load) — a failure deserves a data point as much as a success.
798
+ const durationMs =
799
+ error instanceof MigronautError && typeof error.context?.durationMs === 'number'
800
+ ? error.context.durationMs
801
+ : Date.now() - startedAt;
802
+ telemetry.migrationEnded({ direction, durationMs, error });
803
+ throw error;
804
+ }
805
+ }
806
+
772
807
  /**
773
808
  * Execute one migration end to end: beforeEach → load → run (with the
774
809
  * changelog write inside the transaction via `onSuccess`) → events, logs,
775
810
  * result row, afterEach — and the mirrored error path. Shared verbatim by
776
811
  * `up` and `down`, so a fix to one direction cannot silently miss the other.
777
812
  */
778
- async #executeMigration({ name, direction, context, index, total, results, batch, onSuccess }) {
813
+ async #executeMigrationSteps(
814
+ { name, direction, context, index, total, results, batch, onSuccess, failureFields },
815
+ span,
816
+ ) {
779
817
  const config = this.#config;
780
818
  const logger = this.#logger;
781
819
  const batchField = batch !== undefined ? { batch } : {};
@@ -789,6 +827,7 @@ class MigratorKit extends EventEmitter {
789
827
  reload: config.reloadMigrations,
790
828
  });
791
829
  const useTransaction = migration.useTransaction ?? config.useTransaction;
830
+ span.set({ [ATTRIBUTES.MIGRATION_TRANSACTION]: useTransaction });
792
831
 
793
832
  this.#progress?.onStart(name, direction);
794
833
  this.#emit('migration:start', { migration: name, direction, ...batchField });
@@ -873,22 +912,17 @@ class MigratorKit extends EventEmitter {
873
912
  // Swallowed on its own failure: the changelog may be the thing that is
874
913
  // down, and this trace must never mask the migration's real error.
875
914
  if (direction === 'up') {
876
- // The wrapper says WHICH migration failed; the cause says WHY — the
877
- // half forensics actually needs. Redacted like every string that
878
- // leaves the process (the cause is the raw thrown message).
879
- const cause =
880
- error instanceof MigronautError && typeof error.context?.cause === 'string'
881
- ? errorText(error.context.cause)
882
- : undefined;
883
915
  try {
884
916
  await this.#requireChangelog().markFailed(this.#requireDb(), {
885
917
  name,
886
- error: cause ? `${errorText(error)} — ${cause}` : errorText(error),
918
+ // Which migration failed, and why.
919
+ error: errorWithCause(error),
887
920
  environment: this.#environment(),
888
921
  executedBy: safeUsername(),
889
922
  ...batchField,
890
923
  ...(durationMs !== undefined ? { duration: durationMs } : {}),
891
924
  ...(this.#runId ? { runId: this.#runId } : {}),
925
+ ...failureFields,
892
926
  });
893
927
  } catch {
894
928
  // Duplicate key when an 'applied' record exists (forced re-run), or
@@ -904,17 +938,65 @@ class MigratorKit extends EventEmitter {
904
938
 
905
939
  /** Run all pending migrations, or a specific named file */
906
940
  async up(filename, options = {}) {
907
- this.#assertFilename(filename);
908
- this.#assertToValid(options.to, filename, options);
941
+ assertUpOptions(filename, options);
909
942
  return this.#runWindow(async () => {
910
- await this.#ensureConfig();
911
- await this.connect();
912
- return this.#withLock(options, { command: 'up', direction: 'up' }, (signal) =>
913
- this.#runUp(filename, options, signal),
914
- );
943
+ const config = await this.#ensureConfig();
944
+ // Converge only after a run that brings the database to the head: the
945
+ // declared end state describes the newest schema, and a unique index
946
+ // may well depend on a dedupe migration a `--to` run stops short of.
947
+ // A single-file run is one step of a sequence (a queue job), never its end.
948
+ const converge = filename === undefined && (options.converge ?? config.convergeAfterUp);
949
+ if (converge && options.to !== undefined) {
950
+ this.#logger.info(
951
+ 'Converge after up skipped: --to stops short of the newest migration',
952
+ this.#fields({ command: 'up', to: options.to }),
953
+ );
954
+ }
955
+ // Resolved before the lock: a definition file that does not load fails
956
+ // the run before any migration is applied, not after.
957
+ const definitions =
958
+ converge && options.to === undefined
959
+ ? (this.#upDefinitions ??= await this.#resolveCollections())
960
+ : [];
961
+ return this.#keepDefinitionsWhileRefused(async () => {
962
+ await this.connect();
963
+ return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal) => {
964
+ const results = await this.#runUp(filename, options, signal);
965
+ // Even when nothing was pending: a converge that failed last time is
966
+ // retried by the next `up` instead of waiting for the next migration.
967
+ if (definitions.length > 0) {
968
+ this.#assertNotAborted(signal, results);
969
+ try {
970
+ await runConverge(
971
+ this.#convergeDeps(),
972
+ { definitions, trigger: 'up', ...pickActor(options) },
973
+ signal,
974
+ );
975
+ } catch (error) {
976
+ // The migrations are applied and recorded either way — they must
977
+ // survive into what a `--json` consumer sees about the failure.
978
+ this.#attachResults(error, results);
979
+ throw error;
980
+ }
981
+ }
982
+ return results;
983
+ });
984
+ });
915
985
  });
916
986
  }
917
987
 
988
+ /** Run `fn`, keeping the cached after-up definitions only when it is refused for the lock */
989
+ async #keepDefinitionsWhileRefused(fn) {
990
+ try {
991
+ const result = await fn();
992
+ this.#upDefinitions = undefined;
993
+ return result;
994
+ } catch (error) {
995
+ if (!(error instanceof LockAlreadyHeldError)) this.#upDefinitions = undefined;
996
+ throw error;
997
+ }
998
+ }
999
+
918
1000
  async #runUp(filename, options = {}, signal) {
919
1001
  const force = options.force ?? false;
920
1002
  const config = this.#config;
@@ -922,29 +1004,70 @@ class MigratorKit extends EventEmitter {
922
1004
  const changelog = this.#requireChangelog();
923
1005
  const logger = this.#logger;
924
1006
 
925
- // A strict bulk run needs the applied records' checksums anyway, so fetch
926
- // full records once and derive the name set from them; a single-file run
927
- // needs only that file's record, so one getByName is both the
928
- // applied-check and the checksum source; otherwise the cheaper covered
1007
+ // An `ordered` single-file run is one step of a sequence someone else is
1008
+ // driving (a queue job), so it must uphold what a bulk run would: it reads
1009
+ // the full applied set, and with it the strict drift check and the
1010
+ // out-of-order policy apply — otherwise splitting a run into jobs would
1011
+ // silently drop both.
1012
+ const ordered = filename !== undefined && options.ordered === true;
1013
+ const fullSet = !filename || ordered;
1014
+ // A strict full-set run needs the applied records' checksums anyway, so
1015
+ // fetch full records once and derive the name set from them; a plain
1016
+ // single-file run needs only that file's record, so one getByName is both
1017
+ // the applied-check and the checksum source; otherwise the cheaper covered
929
1018
  // name query suffices.
930
- const strictBulk = !filename && config.strict && !force;
1019
+ const strictBulk = fullSet && config.strict && !force;
931
1020
  const appliedRecords = strictBulk ? await changelog.getApplied(db) : undefined;
932
1021
  const appliedNames = new Set();
933
1022
  let singleRecord = null;
934
1023
  if (filename) {
935
1024
  singleRecord = await changelog.getByName(db, filename);
936
1025
  if (singleRecord?.status === 'applied') appliedNames.add(filename);
937
- } else if (appliedRecords) {
938
- for (const record of appliedRecords) appliedNames.add(record.name);
939
- } else {
940
- for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
1026
+ }
1027
+ if (fullSet) {
1028
+ if (appliedRecords) {
1029
+ for (const record of appliedRecords) appliedNames.add(record.name);
1030
+ } else {
1031
+ for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
1032
+ }
941
1033
  }
942
1034
 
943
1035
  const targets = await this.#selectUpTargets(filename, options, appliedNames);
944
1036
  // Pending files are the only bulk targets, so the per-target checksum
945
1037
  // check below can never see an applied one. Verify them up front instead,
946
1038
  // otherwise `up --strict` over a bulk run would police nothing.
947
- if (!filename && strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
1039
+ if (strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
1040
+ // An ordered run is a step of the sequence, so its target must be a file
1041
+ // of the sequence: an existing dotfile, declaration file or helper module
1042
+ // next to the migrations is never one — and importing it would run its
1043
+ // top-level code. The name may come from a queue payload; a plain
1044
+ // single-file `up` keeps accepting any file it is pointed at.
1045
+ const sequence = ordered ? await this.#listMigrationFiles() : undefined;
1046
+ if (ordered && !sequence.includes(filename)) {
1047
+ throw new MigrationFileNotFoundError(
1048
+ 'Not a migration of the sequence — a dotfile, a declaration file or an extension ' +
1049
+ 'outside fileExtensions',
1050
+ { filename },
1051
+ );
1052
+ }
1053
+ // An already-applied target is exempt (unless forced): a duplicate job
1054
+ // for it must report the usual "skipped", not a failure.
1055
+ if (ordered && (force || !appliedNames.has(filename))) {
1056
+ await this.#assertUpNotBlocked(filename, appliedNames, sequence);
1057
+ }
1058
+ // The file the caller planned with (a queue job's checksum) must be the
1059
+ // one on this disk: a worker from an older deploy would otherwise apply an
1060
+ // older version of an edited pending file, and record its checksum.
1061
+ if (options.checksum !== undefined && (force || !appliedNames.has(filename))) {
1062
+ const actual = await computeChecksum(this.#filepath(filename));
1063
+ if (actual !== options.checksum) {
1064
+ throw new ChecksumMismatchError(
1065
+ `${filename} is not the file this run was planned with — this process has another ` +
1066
+ 'version of it (roll workers out before the producers that enqueue)',
1067
+ { name: filename, expected: options.checksum, actual, planned: true },
1068
+ );
1069
+ }
1070
+ }
948
1071
  this.#assertOrderIntact(targets, appliedNames);
949
1072
 
950
1073
  if (targets.length === 0) {
@@ -956,8 +1079,10 @@ class MigratorKit extends EventEmitter {
956
1079
  // Without --step every file in this run shares one batch. With --step each
957
1080
  // applied file gets its own sequential batch (base, base+1, …) so a later
958
1081
  // `down` can revert them individually. Only successful applies advance the
959
- // counter, so --step never leaves gaps.
960
- const baseBatch = await this.#nextBatch();
1082
+ // counter, so --step never leaves gaps. An explicit `batch` is a label the
1083
+ // caller chose — it may equal one already in use, which is how several
1084
+ // single-file runs end up as one rollback unit.
1085
+ const baseBatch = options.batch ?? (await this.#nextBatch());
961
1086
  let appliedCount = 0;
962
1087
 
963
1088
  return this.#runSequence({
@@ -1031,9 +1156,14 @@ class MigratorKit extends EventEmitter {
1031
1156
  duration: elapsed,
1032
1157
  ...(this.#runId ? { runId: this.#runId } : {}),
1033
1158
  ...(migration.description ? { description: migration.description } : {}),
1159
+ ...actorFields(options),
1034
1160
  },
1035
1161
  session,
1036
1162
  ),
1163
+ // What a failed attempt's trace records besides the failure: the
1164
+ // version of the file that failed (a breaker compares it), and who
1165
+ // asked for the run.
1166
+ failureFields: { checksum, ...actorFields(options) },
1037
1167
  });
1038
1168
  appliedCount += 1;
1039
1169
  return 'done';
@@ -1094,10 +1224,7 @@ class MigratorKit extends EventEmitter {
1094
1224
 
1095
1225
  /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
1096
1226
  async down(filename, options = {}) {
1097
- this.#assertFilename(filename);
1098
- this.#assertStepsValid(options.steps, filename, options.batch);
1099
- this.#assertBatchValid(options.batch);
1100
- this.#assertToValid(options.to, filename, options);
1227
+ assertDownOptions(filename, options);
1101
1228
  return this.#runWindow(async () => {
1102
1229
  await this.#ensureConfig();
1103
1230
  await this.connect();
@@ -1167,17 +1294,6 @@ class MigratorKit extends EventEmitter {
1167
1294
  return { records, preserveOrder };
1168
1295
  }
1169
1296
 
1170
- /** Order the selected records for execution (newest first unless pre-ordered) */
1171
- #downNames(records, preserveOrder) {
1172
- const names = [];
1173
- for (const record of records) names.push(record.name);
1174
- if (!preserveOrder) {
1175
- names.sort();
1176
- names.reverse();
1177
- }
1178
- return names;
1179
- }
1180
-
1181
1297
  async #runDown(filename, options = {}, signal) {
1182
1298
  const config = this.#config;
1183
1299
  const db = this.#requireDb();
@@ -1190,8 +1306,9 @@ class MigratorKit extends EventEmitter {
1190
1306
  logger.info('Nothing to rollback', this.#fields({ direction: 'down' }));
1191
1307
  return [];
1192
1308
  }
1309
+ if (filename && options.ordered === true) await this.#assertDownNotBlocked(toRevert[0]);
1193
1310
 
1194
- const names = this.#downNames(toRevert, preserveOrder);
1311
+ const names = revertOrder(toRevert, preserveOrder);
1195
1312
 
1196
1313
  // The signal must reach the rollback context too: a long-running down()
1197
1314
  // under SIGTERM or a lost lock is exactly the case ctx.signal exists for.
@@ -1211,7 +1328,7 @@ class MigratorKit extends EventEmitter {
1211
1328
  total: names.length,
1212
1329
  results,
1213
1330
  onSuccess: async (_migration, _elapsed, session) => {
1214
- const result = await changelog.markReverted(db, name, session);
1331
+ const result = await changelog.markReverted(db, name, session, pickActor(options));
1215
1332
  // Under --no-lock or onLockLost:'warn' a peer may have flipped the
1216
1333
  // record first: the down() body already ran against the data, but
1217
1334
  // the changelog still claims the migration is applied. Silence
@@ -1273,7 +1390,8 @@ class MigratorKit extends EventEmitter {
1273
1390
  * show for it.
1274
1391
  */
1275
1392
  async redo(filename, options = {}) {
1276
- this.#assertFilename(filename);
1393
+ assertRedoOptions(filename, options);
1394
+ const actor = pickActor(options);
1277
1395
  return this.#runWindow(async () => {
1278
1396
  await this.#ensureConfig();
1279
1397
  await this.connect();
@@ -1295,10 +1413,10 @@ class MigratorKit extends EventEmitter {
1295
1413
  target = newest.name;
1296
1414
  }
1297
1415
 
1298
- const downResults = await this.#runDown(target, {}, signal);
1416
+ const downResults = await this.#runDown(target, actor, signal);
1299
1417
  let upResults;
1300
1418
  try {
1301
- upResults = await this.#runUp(target, {}, signal);
1419
+ upResults = await this.#runUp(target, actor, signal);
1302
1420
  } catch (error) {
1303
1421
  // The revert already happened — after a failed re-apply that is the
1304
1422
  // single most important fact, so the down rows must survive into the
@@ -1316,12 +1434,7 @@ class MigratorKit extends EventEmitter {
1316
1434
 
1317
1435
  /** Preview what would run — never writes to the database */
1318
1436
  async dryRun(direction, filename, options = {}) {
1319
- this.#assertFilename(filename);
1320
- // `batch`/`to` must be passed too, or a conflict that `down` rejects would
1321
- // be silently allowed in its own preview.
1322
- this.#assertStepsValid(options.steps, filename, options.batch);
1323
- this.#assertBatchValid(options.batch);
1324
- this.#assertToValid(options.to, filename, options);
1437
+ assertDryRunOptions(filename, options);
1325
1438
  await this.#ensureConfig();
1326
1439
  await this.connect();
1327
1440
  const db = this.#requireDb();
@@ -1349,6 +1462,10 @@ class MigratorKit extends EventEmitter {
1349
1462
  // The same selection (and preflight) the real `up` executes, so a
1350
1463
  // preview never invents a pending row for a file that does not exist.
1351
1464
  names = await this.#selectUpTargets(filename, options, applied);
1465
+ // …and the same order policy: under onOutOfOrder: 'error' the real run
1466
+ // refuses, so the preview must too instead of listing rows it would
1467
+ // never apply. A single file is exempt, exactly as in `up`.
1468
+ if (!filename) this.#assertOrderIntact(names, applied);
1352
1469
  } else {
1353
1470
  // The same selection the real `down` executes — including the
1354
1471
  // irreversible-import refusal, so a preview can never show a rollback
@@ -1357,21 +1474,41 @@ class MigratorKit extends EventEmitter {
1357
1474
  for (const record of records) {
1358
1475
  recordByName.set(record.name, record);
1359
1476
  }
1360
- names = this.#downNames(records, preserveOrder);
1477
+ names = revertOrder(records, preserveOrder);
1361
1478
  }
1362
1479
 
1363
- const rows = await mapLimit(names, FS_CONCURRENCY, (name) =>
1364
- this.#buildStatusRow(name, recordByName.get(name)),
1365
- );
1480
+ const rows = await mapLimit(names, FS_CONCURRENCY, async (name) => {
1481
+ const row = await this.#buildStatusRow(name, recordByName.get(name));
1482
+ // What an `up` would apply, by content: a caller that applies the rows
1483
+ // later (a queue job) can insist on exactly this version of the file.
1484
+ if (direction === 'up' && !row.invalid) {
1485
+ row.checksum = await this.#cachedChecksum(this.#filepath(name));
1486
+ }
1487
+ return row;
1488
+ });
1366
1489
  logger.info(
1367
1490
  `◎ Dry-run Would ${direction === 'up' ? 'apply' : 'revert'}: ${rows.length}`,
1368
1491
  this.#fields({ direction, count: rows.length, dryRun: true }),
1369
1492
  );
1493
+ // A converge plan is only meaningful against the database the migrations
1494
+ // leave behind — previewing it now would compare with the wrong state.
1495
+ if (
1496
+ direction === 'up' &&
1497
+ !filename &&
1498
+ options.to === undefined &&
1499
+ (await this.convergesAfterUp())
1500
+ ) {
1501
+ logger.info(
1502
+ '◎ Dry-run Converge after up is not previewed — it is planned against the database ' +
1503
+ 'the migrations leave behind (`converge --dry-run` once they are applied)',
1504
+ this.#fields({ direction, dryRun: true }),
1505
+ );
1506
+ }
1370
1507
  return rows;
1371
1508
  }
1372
1509
 
1373
1510
  /** Full migration status for all known files and records */
1374
- async status() {
1511
+ async status(options = {}) {
1375
1512
  await this.#ensureConfig();
1376
1513
  await this.connect();
1377
1514
  const records = await this.#requireChangelog().getAll(this.#requireDb());
@@ -1384,15 +1521,16 @@ class MigratorKit extends EventEmitter {
1384
1521
  // Each row may read and hash a file; unbounded fan-out over thousands of
1385
1522
  // migrations exhausts the descriptor limit.
1386
1523
  const rows = await mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1387
- this.#buildStatusRow(name, recordByName.get(name)),
1524
+ this.#buildStatusRow(name, recordByName.get(name), options),
1388
1525
  );
1389
1526
  // Mark late arrivals: a not-yet-applied row sorting before the newest
1390
1527
  // applied name will run after migrations authored later — the same signal
1391
1528
  // #assertOrderIntact acts on, surfaced here as data.
1392
- let newestApplied = '';
1529
+ const applied = [];
1393
1530
  for (const row of rows) {
1394
- if (row.status === 'applied' && row.file > newestApplied) newestApplied = row.file;
1531
+ if (row.status === 'applied') applied.push(row.file);
1395
1532
  }
1533
+ const newestApplied = newestOf(applied);
1396
1534
  if (newestApplied !== '') {
1397
1535
  for (const row of rows) {
1398
1536
  if (row.status !== 'applied' && row.file < newestApplied) row.outOfOrder = true;
@@ -1415,17 +1553,17 @@ class MigratorKit extends EventEmitter {
1415
1553
  });
1416
1554
  }
1417
1555
 
1418
- /** Filtered list of migrations */
1419
- async list(filter = 'all') {
1420
- // An unknown filter silently returning [] reads as "nothing to report" —
1421
- // the worst possible answer to a typo.
1422
- if (filter !== 'all' && filter !== 'pending' && filter !== 'applied') {
1423
- throw new ConfigInvalidError("list filter must be 'all', 'pending' or 'applied'", { filter });
1424
- }
1556
+ /**
1557
+ * Filtered list of migrations. `checksums: false` skips hashing the applied
1558
+ * files (`checksumOk` stays null) — for a caller that only needs names and
1559
+ * dates, where a full re-hash of the history would be the whole cost.
1560
+ */
1561
+ async list(filter = 'all', options = {}) {
1562
+ assertListOptions(filter, options);
1425
1563
  if (filter === 'pending') {
1426
1564
  return this.#listPending();
1427
1565
  }
1428
- const rows = await this.status();
1566
+ const rows = await this.status(options.checksums === false ? { checksums: false } : {});
1429
1567
  if (filter === 'all') {
1430
1568
  return rows;
1431
1569
  }
@@ -1450,8 +1588,7 @@ class MigratorKit extends EventEmitter {
1450
1588
  await this.connect();
1451
1589
  const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
1452
1590
  const rows = [];
1453
- for (const file of await this.#listMigrationFiles()) {
1454
- if (applied.has(file)) continue;
1591
+ for (const file of pendingIn(await this.#listMigrationFiles(), applied)) {
1455
1592
  rows.push({
1456
1593
  file,
1457
1594
  status: 'pending',
@@ -1498,6 +1635,12 @@ class MigratorKit extends EventEmitter {
1498
1635
  ...(record.origin ? { origin: record.origin } : {}),
1499
1636
  ...(record.status === 'failed' && record.error ? { error: record.error } : {}),
1500
1637
  ...(record.status === 'failed' && record.failedAt ? { failedAt: record.failedAt } : {}),
1638
+ // The version of the file that failed — what tells "failed and unchanged since" apart.
1639
+ ...(record.status === 'failed' && record.checksum ? { failedChecksum: record.checksum } : {}),
1640
+ ...(record.requestedBy ? { requestedBy: record.requestedBy } : {}),
1641
+ ...(record.reason ? { reason: record.reason } : {}),
1642
+ ...(record.revertRequestedBy ? { revertRequestedBy: record.revertRequestedBy } : {}),
1643
+ ...(record.revertReason ? { revertReason: record.revertReason } : {}),
1501
1644
  };
1502
1645
  }
1503
1646
 
@@ -1514,7 +1657,7 @@ class MigratorKit extends EventEmitter {
1514
1657
  }
1515
1658
 
1516
1659
  /** Build a StatusRow for a migration, verifying checksum when possible */
1517
- async #buildStatusRow(name, record) {
1660
+ async #buildStatusRow(name, record, { checksums = true } = {}) {
1518
1661
  const isApplied = record?.status === 'applied';
1519
1662
  const status = MigratorKit.#rowStatus(record);
1520
1663
  let filepath;
@@ -1539,7 +1682,7 @@ class MigratorKit extends EventEmitter {
1539
1682
  // access()-first probe cost an extra syscall per row, for pending rows
1540
1683
  // whose result was never even used.
1541
1684
  let checksumOk = null;
1542
- if (isApplied && record) {
1685
+ if (isApplied && record && checksums) {
1543
1686
  try {
1544
1687
  checksumOk = (await this.#cachedChecksum(filepath)) === record.checksum;
1545
1688
  } catch (error) {
@@ -1626,7 +1769,7 @@ class MigratorKit extends EventEmitter {
1626
1769
  * baseline racing an `up`) must serialize like any other mutation.
1627
1770
  */
1628
1771
  async baseline(options = {}) {
1629
- this.#assertFilename(options.to);
1772
+ assertFilename(options.to);
1630
1773
  return this.#runWindow(async () => {
1631
1774
  await this.#ensureConfig();
1632
1775
  await this.connect();
@@ -1640,7 +1783,7 @@ class MigratorKit extends EventEmitter {
1640
1783
  filepath: (name) => this.#filepath(name),
1641
1784
  listMigrationFiles: () => this.#listMigrationFiles(),
1642
1785
  nextBatch: () => this.#nextBatch(),
1643
- truncateAtTarget: (pending, all, to) => this.#truncateAtTarget(pending, all, to),
1786
+ truncateAtTarget,
1644
1787
  environment: () => this.#environment(),
1645
1788
  executedBy: () => safeUsername(),
1646
1789
  runId: () => this.#runId,
@@ -1661,14 +1804,7 @@ class MigratorKit extends EventEmitter {
1661
1804
  * file signatures, so `down`/`redo` on imported files is unsupported.
1662
1805
  */
1663
1806
  async import(options = {}) {
1664
- // Validate before connecting: a bad --from/--to must not cost a round trip
1665
- // or take the lock. Defaults come from the already-validated config.
1666
- if (options.from !== undefined && !isCollectionName(options.from)) {
1667
- throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
1668
- }
1669
- if (options.to !== undefined && !isCollectionName(options.to)) {
1670
- throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
1671
- }
1807
+ assertImportOptions(options);
1672
1808
  return this.#runWindow(async () => {
1673
1809
  await this.#ensureConfig();
1674
1810
  await this.connect();
@@ -1691,6 +1827,150 @@ class MigratorKit extends EventEmitter {
1691
1827
  );
1692
1828
  });
1693
1829
  }
1830
+
1831
+ /**
1832
+ * Bring the declared collections (`collections`, `collectionsDir`) to their
1833
+ * declared indexes and validators — see {@link runConverge} in converge.js
1834
+ * for the mechanics. Stateless: the live database is read and compared on
1835
+ * every call. What a run changed is appended to the converge history
1836
+ * (`convergeHistory()`), which no run reads back.
1837
+ *
1838
+ * `dryRun` plans without writing — no lock, no events. A real run holds the
1839
+ * migration lock, like every other mutation. `prune` drops undeclared
1840
+ * indexes in collections whose definition does not decide for itself.
1841
+ * `ordered` refuses while any migration is still pending — checked under
1842
+ * the lock, which is what lets a queue run it as the tail of a deploy.
1843
+ * `rebuildUnique` lets a rebuild drop a unique index it builds back; without
1844
+ * it such a rebuild is a conflict (the constraint would be gone until the
1845
+ * build ends), which is why the after-up hook and a queue job never pass it.
1846
+ */
1847
+ async converge(options = {}) {
1848
+ assertConvergeOptions(options);
1849
+ const actor = pickActor(options);
1850
+ const empty = (dryRun) => ({ dryRun, changed: 0, inSync: true, collections: [] });
1851
+ if (options.dryRun) {
1852
+ await this.#ensureConfig();
1853
+ const definitions = await this.#resolveCollections();
1854
+ if (definitions.length === 0) return empty(true);
1855
+ await this.connect();
1856
+ return runConverge(
1857
+ this.#convergeDeps(),
1858
+ { definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, dryRun: true },
1859
+ undefined,
1860
+ );
1861
+ }
1862
+ return this.#runWindow(async () => {
1863
+ await this.#ensureConfig();
1864
+ // Before connecting or locking: a broken definition file must not cost
1865
+ // a round trip, and must not hold the lock while it is reported.
1866
+ const definitions = await this.#resolveCollections();
1867
+ if (definitions.length === 0) {
1868
+ this.#logger.info(
1869
+ 'No collections declared — set collections or collectionsDir',
1870
+ this.#fields({ command: 'converge' }),
1871
+ );
1872
+ return empty(false);
1873
+ }
1874
+ await this.connect();
1875
+ return this.#withLock(options, { command: 'converge' }, async (signal) => {
1876
+ if (options.ordered) await this.#assertNothingPending();
1877
+ return runConverge(
1878
+ this.#convergeDeps(),
1879
+ { definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, ...actor },
1880
+ signal,
1881
+ );
1882
+ });
1883
+ });
1884
+ }
1885
+
1886
+ /**
1887
+ * Whether a bulk `up` on this kit ends by converging: `convergeAfterUp` is
1888
+ * on and there is something declared to converge. Resolves the config; does
1889
+ * not connect, and does not load definition files. A layer above the kit
1890
+ * (the queue adapter) uses it to mirror that behaviour across single-file
1891
+ * jobs, where the kit's own after-up hook never fires.
1892
+ */
1893
+ async convergesAfterUp() {
1894
+ const config = await this.#ensureConfig();
1895
+ return (
1896
+ config.convergeAfterUp === true &&
1897
+ ((config.collections?.length ?? 0) > 0 || config.collectionsDir !== undefined)
1898
+ );
1899
+ }
1900
+
1901
+ /** Every declared collection, normalized — the config key first, then `collectionsDir` */
1902
+ async #resolveCollections() {
1903
+ const config = this.#config;
1904
+ return resolveDefinitions({
1905
+ inline: config.collections,
1906
+ ...(config.collectionsDir !== undefined
1907
+ ? { dir: path.resolve(this.#cwd ?? process.cwd(), config.collectionsDir) }
1908
+ : {}),
1909
+ extensions: config.fileExtensions,
1910
+ reload: config.reloadMigrations,
1911
+ reserved: [config.migrationsCollection, config.lockCollection, config.convergeLogCollection],
1912
+ });
1913
+ }
1914
+
1915
+ #convergeDeps() {
1916
+ const db = this.#requireDb();
1917
+ return {
1918
+ db,
1919
+ logger: this.#logger,
1920
+ fields: (extra) => this.#fields(extra),
1921
+ emit: (event, payload) => this.#emit(event, payload),
1922
+ assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
1923
+ // The history entry's who-and-where, like a changelog record's.
1924
+ audit: () => ({
1925
+ ...(this.#runId ? { runId: this.#runId } : {}),
1926
+ executedBy: safeUsername(),
1927
+ host: os.hostname(),
1928
+ environment: this.#environment(),
1929
+ }),
1930
+ record: (entry) => this.#convergeLog().append(db, entry),
1931
+ // Behind a mongos only: the shard key, so prune never tries to drop its index.
1932
+ shardKeyOf: async (name) =>
1933
+ (
1934
+ await this.#client
1935
+ .db('config')
1936
+ .collection('collections')
1937
+ .findOne({ _id: `${db.databaseName}.${name}` }, { projection: { key: 1 } })
1938
+ )?.key,
1939
+ };
1940
+ }
1941
+
1942
+ #convergeLog() {
1943
+ this.#convergeLogStore ??= new ConvergeLog(this.#config.convergeLogCollection);
1944
+ return this.#convergeLogStore;
1945
+ }
1946
+
1947
+ /**
1948
+ * The converge history, newest first: one entry per converge that changed
1949
+ * something or failed — when, triggered how, by whom and why, and every
1950
+ * index or validator it touched, with its before and after. Read-only.
1951
+ */
1952
+ async convergeHistory(options = {}) {
1953
+ const { limit = 20 } = options;
1954
+ assertHistoryLimit(limit);
1955
+ await this.#ensureConfig();
1956
+ await this.connect();
1957
+ return this.#convergeLog().list(this.#requireDb(), limit);
1958
+ }
1959
+
1960
+ /**
1961
+ * `converge({ ordered })`: refuse while a migration on disk has no applied
1962
+ * record — a failed one included, exactly as for an ordered `up` job.
1963
+ */
1964
+ async #assertNothingPending() {
1965
+ const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
1966
+ const blockedBy = pendingIn(await this.#listMigrationFiles(), applied);
1967
+ if (blockedBy.length === 0) return;
1968
+ throw blockedError('converge', 'migration(s) still pending', {
1969
+ command: 'converge',
1970
+ blockedBy,
1971
+ failed: await this.#failedAmong(blockedBy),
1972
+ });
1973
+ }
1694
1974
  }
1695
1975
 
1696
- module.exports = { MigratorKit };
1976
+ module.exports = { MigratorKit, RECORD_LOCK_WAIT };