@alexify/migronaut 1.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 (59) hide show
  1. package/CHANGELOG.md +409 -1
  2. package/README.md +248 -24
  3. package/bin/migronaut.js +11 -3
  4. package/bullmq.d.ts +845 -0
  5. package/bullmq.js +1 -0
  6. package/index.d.ts +757 -29
  7. package/migronaut.schema.json +191 -1
  8. package/package.json +27 -6
  9. package/src/bullmq/index.js +55 -0
  10. package/src/bullmq/jobs.js +454 -0
  11. package/src/bullmq/processor.js +608 -0
  12. package/src/bullmq/producer.js +424 -0
  13. package/src/bullmq/service.js +653 -0
  14. package/src/bullmq/wait.js +124 -0
  15. package/src/cli/args.js +12 -2
  16. package/src/cli/commands/baseline.js +45 -0
  17. package/src/cli/commands/converge.js +160 -0
  18. package/src/cli/commands/down.js +2 -0
  19. package/src/cli/commands/lock.js +2 -1
  20. package/src/cli/commands/redo.js +8 -1
  21. package/src/cli/commands/unlock.js +12 -2
  22. package/src/cli/commands/up.js +14 -1
  23. package/src/cli/exit-codes.js +10 -2
  24. package/src/cli/index.js +4 -0
  25. package/src/cli/shared.js +29 -7
  26. package/src/cli/table.js +105 -0
  27. package/src/core/audit.js +17 -3
  28. package/src/core/baseline.js +80 -0
  29. package/src/core/changelog.js +140 -24
  30. package/src/core/collections.js +372 -0
  31. package/src/core/config.js +125 -27
  32. package/src/core/converge-log.js +47 -0
  33. package/src/core/converge-plan.js +483 -0
  34. package/src/core/converge.js +867 -0
  35. package/src/core/import-runner.js +34 -6
  36. package/src/core/import.js +14 -7
  37. package/src/core/index-spec.js +496 -0
  38. package/src/core/lock-wait.js +260 -0
  39. package/src/core/lock.js +71 -20
  40. package/src/core/migrator.js +805 -304
  41. package/src/core/options.js +251 -0
  42. package/src/core/run-recorder.js +157 -0
  43. package/src/core/run.js +71 -71
  44. package/src/core/runner.js +70 -20
  45. package/src/core/sequence.js +134 -0
  46. package/src/errors/index.js +71 -1
  47. package/src/index.js +16 -0
  48. package/src/utils/actor.js +48 -0
  49. package/src/utils/canonical.js +179 -0
  50. package/src/utils/collection-name.js +21 -0
  51. package/src/utils/error.js +18 -1
  52. package/src/utils/id.js +77 -0
  53. package/src/utils/loader.js +39 -21
  54. package/src/utils/logger.js +30 -12
  55. package/src/utils/migration-name.js +32 -0
  56. package/src/utils/redact.js +57 -4
  57. package/src/utils/sanitize.js +8 -3
  58. package/src/utils/telemetry.js +393 -0
  59. package/src/utils/template.js +60 -12
@@ -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,17 +11,23 @@ const {
11
11
  ConnectionFailedError,
12
12
  HookFailedError,
13
13
  IrreversibleMigrationError,
14
+ LockAlreadyHeldError,
14
15
  MigrationFileNotFoundError,
15
16
  MigrationInvalidNameError,
16
17
  MigronautError,
17
18
  NotAppliedError,
19
+ OutOfOrderMigrationError,
18
20
  RunAbortedError,
19
21
  } = require('../errors/index.js');
22
+ const { actorFields, pickActor } = require('../utils/actor.js');
20
23
  const { computeChecksum } = require('../utils/checksum.js');
21
24
  const { mapLimit } = require('../utils/concurrency.js');
22
- const { errorText } = require('../utils/error.js');
25
+ const { errorText, errorWithCause } = require('../utils/error.js');
26
+ const { createIdGenerator } = require('../utils/id.js');
23
27
  const { loadMigrationFile } = require('../utils/loader.js');
24
28
  const { resolveLogger } = require('../utils/logger.js');
29
+ const { assertMigrationName } = require('../utils/migration-name.js');
30
+ const { ATTRIBUTES, SPANS, createTelemetry } = require('../utils/telemetry.js');
25
31
  const {
26
32
  createConfigFile,
27
33
  createMigrationFile,
@@ -29,12 +35,37 @@ const {
29
35
  } = require('../utils/template.js');
30
36
  const { safeUsername } = require('../utils/user.js');
31
37
  const { runAudit } = require('./audit.js');
38
+ const { runBaseline } = require('./baseline.js');
32
39
  const { Changelog } = require('./changelog.js');
33
- 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');
34
43
  const { buildContext } = require('./context.js');
44
+ const { runConverge } = require('./converge.js');
35
45
  const { runImport } = require('./import-runner.js');
36
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');
37
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');
38
69
 
39
70
  /** Simultaneous file reads — keeps a large migrations dir clear of EMFILE */
40
71
  const FS_CONCURRENCY = 16;
@@ -49,6 +80,13 @@ const FS_CONCURRENCY = 16;
49
80
  * logic in the migration's flow; listeners attach from outside, may be several,
50
81
  * and a listener that throws is contained rather than failing the run.
51
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
+
52
90
  class MigratorKit extends EventEmitter {
53
91
  #partialConfig;
54
92
  #configPath;
@@ -65,6 +103,10 @@ class MigratorKit extends EventEmitter {
65
103
  #runSetupDepth = 0;
66
104
  /** Correlation id for the run in flight — ties logs, lock and changelog together */
67
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;
68
110
  /** Whether changelog indexes have already been ensured on this instance */
69
111
  #indexesEnsured = false;
70
112
  /** Memoized resolved logger — resolveLogger allocates on every call otherwise */
@@ -73,8 +115,31 @@ class MigratorKit extends EventEmitter {
73
115
  #fallbackLogger;
74
116
  /** False when the client was injected by the caller, who keeps ownership of it */
75
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;
125
+ /**
126
+ * Project root this instance resolves against — config discovery, the .env
127
+ * file and a relative migrationsDir. Defaults to process.cwd(); an explicit
128
+ * value is what lets one process host kits for several projects.
129
+ */
130
+ #cwd;
76
131
  /** filepath → {mtimeMs, size, checksum} — spares repeat status()/audit() calls a full re-hash */
77
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;
78
143
 
79
144
  constructor(config = {}, options = {}) {
80
145
  super();
@@ -82,6 +147,7 @@ class MigratorKit extends EventEmitter {
82
147
  this.#configPath = options.configPath;
83
148
  this.#progress = options.progress;
84
149
  this.#fallbackLogger = options.fallbackLogger;
150
+ this.#cwd = options.cwd;
85
151
  }
86
152
 
87
153
  /**
@@ -146,18 +212,28 @@ class MigratorKit extends EventEmitter {
146
212
  }
147
213
  }
148
214
 
149
- /** Resolve and cache the full configuration */
215
+ /** Resolve and cache the full configuration — once, however many callers ask at once */
150
216
  async #ensureConfig(requireDb = true, lenient = false) {
151
- if (!this.#config) {
152
- this.#config = await loadConfig({
153
- flags: this.#partialConfig,
154
- requireDb,
155
- ...(lenient ? { lenient: true } : {}),
156
- ...(this.#configPath ? { configPath: this.#configPath } : {}),
157
- ...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
158
- });
159
- }
160
- 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;
161
237
  }
162
238
 
163
239
  get #logger() {
@@ -205,12 +281,27 @@ class MigratorKit extends EventEmitter {
205
281
  return this.#runId ? { runId: this.#runId, ...extra } : { ...extra };
206
282
  }
207
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
+
208
289
  /** Connect to MongoDB and ensure changelog indexes exist */
209
290
  async connect() {
210
291
  const config = await this.#ensureConfig();
211
292
  if (this.#client && this.#db) {
212
293
  return;
213
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) {
214
305
  const startedAt = Date.now();
215
306
  try {
216
307
  if (config.client) {
@@ -232,7 +323,7 @@ class MigratorKit extends EventEmitter {
232
323
  // Once per instance, not once per connect: re-issuing createIndexes on
233
324
  // every command is a wasted round trip. `ensureIndexes: false` skips it
234
325
  // entirely, for deployments where the app user cannot create indexes.
235
- if (!this.#indexesEnsured && (config.ensureIndexes ?? true)) {
326
+ if (!this.#indexesEnsured && config.ensureIndexes) {
236
327
  await this.#changelog.ensureIndexes(this.#db);
237
328
  this.#indexesEnsured = true;
238
329
  }
@@ -307,6 +398,15 @@ class MigratorKit extends EventEmitter {
307
398
  return new MigrationLock(this.#requireDb(), config.lockCollection, config.lockTTLSeconds);
308
399
  }
309
400
 
401
+ /**
402
+ * The resolved logger with #fields merged into every line, for handing to
403
+ * lock.js — which stays kit-agnostic and cannot stamp runId itself.
404
+ */
405
+ #lockLogger() {
406
+ const wrap = (method) => (msg, fields) => this.#logger[method](msg, this.#fields(fields ?? {}));
407
+ return { debug: wrap('debug'), info: wrap('info'), warn: wrap('warn'), error: wrap('error') };
408
+ }
409
+
310
410
  /**
311
411
  * Run `fn` under the migration lock. The single place that pairs a lock with
312
412
  * a unit of work, so `redo` can hold one lock across both directions instead
@@ -324,8 +424,10 @@ class MigratorKit extends EventEmitter {
324
424
  }
325
425
  // One id per run, reused as the lock's owner token and stamped on every
326
426
  // changelog record and log line, so the three can be correlated after the
327
- // fact ("which run left this lock?", "what did run X apply?").
328
- 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();
329
431
  // A second controller layered over the lock's own signal, so stop() and a
330
432
  // lost lock abort through the same path the run loops already watch.
331
433
  const stopper = new AbortController();
@@ -340,59 +442,49 @@ class MigratorKit extends EventEmitter {
340
442
  this.#stopRequested = undefined;
341
443
  this.#abort(pending);
342
444
  }
343
- const startedAt = Date.now();
344
- 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();
345
454
  let failure;
346
455
  let result;
347
456
  try {
348
457
  result = await runWithLock(
349
458
  this.#buildLock(),
350
459
  {
351
- logger: this.#logger,
352
- onLockLost: this.#config?.onLockLost ?? 'abort',
460
+ // Wrapped so every lock line carries #fields (runId included) — a
461
+ // JSON-sink operator must be able to join a lock-lost alert to the
462
+ // run's migration lines and changelog records without a log parse.
463
+ logger: this.#lockLogger(),
464
+ onLockLost: this.#config.onLockLost,
353
465
  owner: this.#runId,
354
- onLockAcquired: (extra) => this.#emit('lock:acquired', { owner: this.#runId, ...extra }),
355
- onLockReleased: (extra) => this.#emit('lock:released', { owner: this.#runId, ...extra }),
356
- 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),
357
469
  ...(options.noLock ? { noLock: true } : {}),
358
470
  },
359
- (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
+ }),
360
481
  );
361
482
  return result;
362
483
  } catch (error) {
363
484
  failure = error;
364
485
  throw error;
365
486
  } finally {
366
- // Result counts, so a metrics subscriber gets "3 applied in 812ms"
367
- // without reconstructing it from per-migration events. On the failure
368
- // path the partial rows live on the error's context — exactly the case
369
- // where "how far did it get?" is the question, so they count too. One
370
- // pass fills both counters.
371
- const rows = Array.isArray(result)
372
- ? result
373
- : failure instanceof MigronautError && Array.isArray(failure.context?.results)
374
- ? failure.context.results
375
- : null;
376
- let summary = {};
377
- if (rows) {
378
- let applied = 0;
379
- let reverted = 0;
380
- for (const row of rows) {
381
- if (row.status === 'applied') applied += 1;
382
- else if (row.status === 'reverted') reverted += 1;
383
- }
384
- summary = { applied, reverted, total: rows.length };
385
- }
386
- this.#emit('run:end', {
387
- ...info,
388
- success: failure === undefined,
389
- durationMs: Date.now() - startedAt,
390
- ...summary,
391
- // A raw Error here would hand subscribers an unredacted driver message
392
- // (which can echo the credentialed URI) — errorText is the same
393
- // chokepoint every log line and result row already goes through.
394
- ...(failure ? { error: errorText(failure) } : {}),
395
- });
487
+ recorder.finish(result, failure);
396
488
  this.#abort = undefined;
397
489
  this.#runId = undefined;
398
490
  }
@@ -441,6 +533,30 @@ class MigratorKit extends EventEmitter {
441
533
  return toLockInfo(await this.#buildLock().forceRelease());
442
534
  }
443
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
+
444
560
  /** Internal accessors that assume a successful connect() */
445
561
  #requireDb() {
446
562
  if (!this.#db) {
@@ -457,20 +573,9 @@ class MigratorKit extends EventEmitter {
457
573
  }
458
574
 
459
575
  #migrationsPath() {
460
- return path.resolve(this.#config?.migrationsDir ?? './migrations');
461
- }
462
-
463
- /**
464
- * Reject non-string filenames before they reach a changelog query or a path
465
- * join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
466
- * become a query-operator injection in `findOne({ name })`.
467
- */
468
- #assertFilename(filename) {
469
- if (filename !== undefined && typeof filename !== 'string') {
470
- throw new MigrationInvalidNameError('Migration name must be a string', {
471
- name: filename,
472
- });
473
- }
576
+ // No value fallback: DEFAULT_CONFIG always supplies migrationsDir, and a
577
+ // silent './migrations' here would mask a config-resolution regression.
578
+ return path.resolve(this.#cwd ?? process.cwd(), this.#config.migrationsDir);
474
579
  }
475
580
 
476
581
  /**
@@ -484,20 +589,7 @@ class MigratorKit extends EventEmitter {
484
589
  */
485
590
  #filepath(name) {
486
591
  const dir = this.#migrationsPath();
487
- if (
488
- typeof name !== 'string' ||
489
- name.length === 0 ||
490
- name === '.' ||
491
- name === '..' ||
492
- name.includes('/') ||
493
- name.includes('\\') ||
494
- name.includes('\0')
495
- ) {
496
- throw new MigrationInvalidNameError(
497
- 'Invalid migration name — must be a bare filename with no path segments',
498
- { name },
499
- );
500
- }
592
+ assertMigrationName(name);
501
593
  const resolved = path.join(dir, name);
502
594
  const relative = path.relative(dir, resolved);
503
595
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
@@ -510,32 +602,8 @@ class MigratorKit extends EventEmitter {
510
602
 
511
603
  /** List migration files on disk, sorted ascending */
512
604
  async #listMigrationFiles() {
513
- const dir = this.#migrationsPath();
514
- const extensions = this.#config?.fileExtensions ?? ['.ts', '.js'];
515
- let entries;
516
- try {
517
- entries = await fs.readdir(dir, { withFileTypes: true });
518
- } catch (error) {
519
- if (error.code === 'ENOENT') return [];
520
- throw error;
521
- }
522
- const matches = [];
523
- for (const entry of entries) {
524
- // A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
525
- // to the migrations is not a migration — including it would hard-fail
526
- // the whole run with MigrationInvalidExportError.
527
- if (!entry.isFile()) continue;
528
- const file = entry.name;
529
- if (file.startsWith('.')) continue;
530
- if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
531
- for (const ext of extensions) {
532
- if (file.endsWith(ext)) {
533
- matches.push(file);
534
- break;
535
- }
536
- }
537
- }
538
- return matches.sort();
605
+ // No value fallback — DEFAULT_CONFIG always supplies fileExtensions.
606
+ return listMigrationFiles(this.#migrationsPath(), this.#config.fileExtensions);
539
607
  }
540
608
 
541
609
  /** Compute the next batch number (monotonic across the full history) */
@@ -544,78 +612,105 @@ class MigratorKit extends EventEmitter {
544
612
  }
545
613
 
546
614
  /**
547
- * Validate the `--steps` option for `down`/`dry-run down`: a positive integer,
548
- * 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.
549
620
  */
550
- #assertStepsValid(steps, filename, batch) {
551
- if (steps === undefined) {
552
- return;
553
- }
554
- if (filename) {
555
- throw new ConfigInvalidError('Cannot combine a filename with --steps', { filename });
556
- }
557
- if (batch !== undefined) {
558
- throw new ConfigInvalidError('Cannot combine --batch with --steps', { batch, steps });
559
- }
560
- if (!Number.isInteger(steps) || steps < 1) {
561
- throw new ConfigInvalidError('--steps must be a positive integer', { steps });
562
- }
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
+ });
563
631
  }
564
632
 
565
633
  /**
566
- * Keep only the pending migrations up to and including `to`.
567
- *
568
- * `to` must name a migration that exists; it may already be applied (then
569
- * nothing before it is pending either, and the result is empty), which is
570
- * what makes `up --to X` idempotent — running it twice is a no-op rather
571
- * 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.
572
637
  */
573
- #truncateAtTarget(pending, allFiles, to) {
574
- if (!allFiles.includes(to)) {
575
- throw new MigrationFileNotFoundError('Migration file not found', { to });
576
- }
577
- const kept = [];
578
- for (const file of pending) {
579
- if (file > to) break;
580
- kept.push(file);
581
- }
582
- return kept;
638
+ async #failedAmong(names) {
639
+ return this.#requireChangelog().getFailedNames(this.#requireDb(), names);
640
+ }
641
+
642
+ /**
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.
648
+ */
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
+ });
583
661
  }
584
662
 
585
663
  /**
586
- * `--to` names a point in the sequence, so it cannot be combined with the
587
- * other ways of choosing targets.
664
+ * Resolve which files an `up` (or its dry-run) targets: a named file (which
665
+ * must exist on disk), or every pending file, optionally truncated at `--to`.
666
+ * The single source of truth for that selection — mirroring
667
+ * {@link #selectDownTargets} — so the preview can never disagree with the
668
+ * real run about what would be applied.
588
669
  */
589
- #assertToValid(to, filename, options = {}) {
590
- if (to === undefined) return;
591
- this.#assertFilename(to);
670
+ async #selectUpTargets(filename, options, appliedNames) {
592
671
  if (filename) {
593
- throw new ConfigInvalidError('Cannot combine a filename with --to', { filename, to });
594
- }
595
- if (options.steps !== undefined) {
596
- throw new ConfigInvalidError('Cannot combine --steps with --to', {
597
- steps: options.steps,
598
- to,
599
- });
600
- }
601
- if (options.batch !== undefined) {
602
- throw new ConfigInvalidError('Cannot combine --batch with --to', {
603
- batch: options.batch,
604
- to,
605
- });
672
+ const filepath = this.#filepath(filename);
673
+ try {
674
+ await fs.access(filepath);
675
+ } catch {
676
+ throw new MigrationFileNotFoundError('Migration file not found', { filename });
677
+ }
678
+ return [filename];
606
679
  }
680
+ const files = await this.#listMigrationFiles();
681
+ const targets = pendingIn(files, appliedNames);
682
+ return options.to !== undefined ? truncateAtTarget(targets, files, options.to) : targets;
607
683
  }
608
684
 
609
685
  /**
610
- * Validate `--batch`. Without this a typo (`--batch abc` → NaN) matches no
611
- * records, so the run prints "Nothing to rollback" and exits 0 — the worst
612
- * possible answer to a mistyped rollback.
686
+ * Detect out-of-order arrivals: a pending target that sorts before the
687
+ * newest applied name is a migration merged late from a parallel branch — it
688
+ * will run after migrations authored later, so environments migrated at
689
+ * different times end up with different effective orders, silently.
690
+ * `onOutOfOrder` decides the reaction: 'warn' (default) logs and continues,
691
+ * 'error' refuses the run, 'allow' disables the check. Only a bulk `up`
692
+ * carries the full applied set; a single-file `up` (an explicit, deliberate
693
+ * target) is exempt by construction, since its applied set holds at most
694
+ * that file.
613
695
  */
614
- #assertBatchValid(batch) {
615
- if (batch === undefined) return;
616
- if (!Number.isInteger(batch) || batch < 1) {
617
- throw new ConfigInvalidError('--batch must be a positive integer', { batch });
696
+ #assertOrderIntact(targets, appliedNames) {
697
+ const policy = this.#config?.onOutOfOrder ?? 'warn';
698
+ if (policy === 'allow') return;
699
+ const arrivals = lateArrivals(targets, appliedNames);
700
+ if (arrivals === null) return;
701
+ const { late, newestApplied } = arrivals;
702
+ if (policy === 'error') {
703
+ throw new OutOfOrderMigrationError(
704
+ `${late.length} pending migration(s) sort before the newest applied one ` +
705
+ `(${newestApplied}): ${late.join(', ')} — apply deliberately with onOutOfOrder: 'warn' or 'allow'`,
706
+ { names: late, newestApplied },
707
+ );
618
708
  }
709
+ this.#logger.warn(
710
+ `⚠ Out-of-order: ${late.length} pending migration(s) sort before the newest applied one ` +
711
+ `(${newestApplied}): ${late.join(', ')}`,
712
+ this.#fields({ event: 'migrations:out-of-order', names: late, newestApplied }),
713
+ );
619
714
  }
620
715
 
621
716
  /**
@@ -640,6 +735,11 @@ class MigratorKit extends EventEmitter {
640
735
  }
641
736
  } catch (error) {
642
737
  failure = error;
738
+ // Failures before the migration body — beforeEach, a file that fails to
739
+ // load — bypass #executeMigration's catch, so the partial results must be
740
+ // attached here too or a --json consumer loses the applied-so-far list
741
+ // exactly when it matters. Copy-on-write makes a re-attach harmless.
742
+ this.#attachResults(error, results);
643
743
  }
644
744
  const succeeded = failure === undefined;
645
745
  // afterAll runs on the failure path too — which is exactly when a
@@ -663,13 +763,57 @@ class MigratorKit extends EventEmitter {
663
763
  return results;
664
764
  }
665
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
+
666
807
  /**
667
808
  * Execute one migration end to end: beforeEach → load → run (with the
668
809
  * changelog write inside the transaction via `onSuccess`) → events, logs,
669
810
  * result row, afterEach — and the mirrored error path. Shared verbatim by
670
811
  * `up` and `down`, so a fix to one direction cannot silently miss the other.
671
812
  */
672
- 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
+ ) {
673
817
  const config = this.#config;
674
818
  const logger = this.#logger;
675
819
  const batchField = batch !== undefined ? { batch } : {};
@@ -680,9 +824,10 @@ class MigratorKit extends EventEmitter {
680
824
  { direction, index, total },
681
825
  ]);
682
826
  const migration = await loadMigrationFile(this.#filepath(name), {
683
- reload: config.reloadMigrations ?? false,
827
+ reload: config.reloadMigrations,
684
828
  });
685
829
  const useTransaction = migration.useTransaction ?? config.useTransaction;
830
+ span.set({ [ATTRIBUTES.MIGRATION_TRANSACTION]: useTransaction });
686
831
 
687
832
  this.#progress?.onStart(name, direction);
688
833
  this.#emit('migration:start', { migration: name, direction, ...batchField });
@@ -727,15 +872,63 @@ class MigratorKit extends EventEmitter {
727
872
  return duration;
728
873
  } catch (error) {
729
874
  this.#progress?.onStop('error');
875
+ // The runner measures how long the failing attempt ran and leaves it on
876
+ // the error's context — thread it through, so failures carry timing data
877
+ // the same way successes do (a slow-then-failing migration is exactly
878
+ // what a metrics subscriber alerts on).
879
+ const durationMs =
880
+ error instanceof MigronautError && typeof error.context?.durationMs === 'number'
881
+ ? error.context.durationMs
882
+ : undefined;
883
+ const durationField = durationMs !== undefined ? { durationMs } : {};
730
884
  // errorText, not the raw Error: a driver message can echo the
731
885
  // credentialed URI, and event subscribers (Sentry, JSON logs) would
732
886
  // ship it — the same redaction the log line below already gets.
733
- this.#emit('migration:error', { migration: name, direction, error: errorText(error) });
887
+ this.#emit('migration:error', {
888
+ migration: name,
889
+ direction,
890
+ ...batchField,
891
+ ...durationField,
892
+ error: errorText(error),
893
+ });
734
894
  logger.error(
735
895
  `✖ Error ${name}`,
736
- this.#fields({ migration: name, direction, error: errorText(error) }),
896
+ this.#fields({
897
+ migration: name,
898
+ direction,
899
+ ...batchField,
900
+ ...durationField,
901
+ error: errorText(error),
902
+ }),
737
903
  );
738
- results.push({ file: name, status: 'error', error: errorText(error) });
904
+ results.push({
905
+ file: name,
906
+ status: 'error',
907
+ ...(durationMs !== undefined ? { duration: durationMs } : {}),
908
+ error: errorText(error),
909
+ });
910
+ // Best-effort DB-side trace of the failed attempt (up only — marking a
911
+ // failed `down` would demote a record that is still truthfully applied).
912
+ // Swallowed on its own failure: the changelog may be the thing that is
913
+ // down, and this trace must never mask the migration's real error.
914
+ if (direction === 'up') {
915
+ try {
916
+ await this.#requireChangelog().markFailed(this.#requireDb(), {
917
+ name,
918
+ // Which migration failed, and why.
919
+ error: errorWithCause(error),
920
+ environment: this.#environment(),
921
+ executedBy: safeUsername(),
922
+ ...batchField,
923
+ ...(durationMs !== undefined ? { duration: durationMs } : {}),
924
+ ...(this.#runId ? { runId: this.#runId } : {}),
925
+ ...failureFields,
926
+ });
927
+ } catch {
928
+ // Duplicate key when an 'applied' record exists (forced re-run), or
929
+ // the database itself is unreachable — the trace is best-effort.
930
+ }
931
+ }
739
932
  // Carry what already succeeded, so `--json` consumers can tell which
740
933
  // migrations landed before the failure instead of losing the list.
741
934
  this.#attachResults(error, results);
@@ -745,17 +938,65 @@ class MigratorKit extends EventEmitter {
745
938
 
746
939
  /** Run all pending migrations, or a specific named file */
747
940
  async up(filename, options = {}) {
748
- this.#assertFilename(filename);
749
- this.#assertToValid(options.to, filename, options);
941
+ assertUpOptions(filename, options);
750
942
  return this.#runWindow(async () => {
751
- await this.#ensureConfig();
752
- await this.connect();
753
- return this.#withLock(options, { command: 'up', direction: 'up' }, (signal) =>
754
- this.#runUp(filename, options, signal),
755
- );
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
+ });
756
985
  });
757
986
  }
758
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
+
759
1000
  async #runUp(filename, options = {}, signal) {
760
1001
  const force = options.force ?? false;
761
1002
  const config = this.#config;
@@ -763,47 +1004,71 @@ class MigratorKit extends EventEmitter {
763
1004
  const changelog = this.#requireChangelog();
764
1005
  const logger = this.#logger;
765
1006
 
766
- // A strict bulk run needs the applied records' checksums anyway, so fetch
767
- // full records once and derive the name set from them; a single-file run
768
- // needs only that file's record, so one getByName is both the
769
- // 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
770
1018
  // name query suffices.
771
- const strictBulk = !filename && config.strict && !force;
1019
+ const strictBulk = fullSet && config.strict && !force;
772
1020
  const appliedRecords = strictBulk ? await changelog.getApplied(db) : undefined;
773
1021
  const appliedNames = new Set();
774
1022
  let singleRecord = null;
775
1023
  if (filename) {
776
1024
  singleRecord = await changelog.getByName(db, filename);
777
1025
  if (singleRecord?.status === 'applied') appliedNames.add(filename);
778
- } else if (appliedRecords) {
779
- for (const record of appliedRecords) appliedNames.add(record.name);
780
- } else {
781
- for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
782
1026
  }
783
-
784
- let targets;
785
- if (filename) {
786
- const filepath = this.#filepath(filename);
787
- try {
788
- await fs.access(filepath);
789
- } catch {
790
- throw new MigrationFileNotFoundError('Migration file not found', { filename });
791
- }
792
- targets = [filename];
793
- } else {
794
- const files = await this.#listMigrationFiles();
795
- targets = [];
796
- for (const file of files) {
797
- if (!appliedNames.has(file)) targets.push(file);
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);
798
1032
  }
799
- if (options.to !== undefined) {
800
- targets = this.#truncateAtTarget(targets, files, options.to);
1033
+ }
1034
+
1035
+ const targets = await this.#selectUpTargets(filename, options, appliedNames);
1036
+ // Pending files are the only bulk targets, so the per-target checksum
1037
+ // check below can never see an applied one. Verify them up front instead,
1038
+ // otherwise `up --strict` over a bulk run would police nothing.
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
+ );
801
1069
  }
802
- // Pending files are the only targets here, so the per-target checksum
803
- // check below can never see an applied one. Verify them up front instead,
804
- // otherwise `up --strict` over a bulk run would police nothing.
805
- if (strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
806
1070
  }
1071
+ this.#assertOrderIntact(targets, appliedNames);
807
1072
 
808
1073
  if (targets.length === 0) {
809
1074
  logger.info('Nothing to migrate', this.#fields({ direction: 'up' }));
@@ -814,8 +1079,10 @@ class MigratorKit extends EventEmitter {
814
1079
  // Without --step every file in this run shares one batch. With --step each
815
1080
  // applied file gets its own sequential batch (base, base+1, …) so a later
816
1081
  // `down` can revert them individually. Only successful applies advance the
817
- // counter, so --step never leaves gaps.
818
- 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());
819
1086
  let appliedCount = 0;
820
1087
 
821
1088
  return this.#runSequence({
@@ -874,6 +1141,8 @@ class MigratorKit extends EventEmitter {
874
1141
  total: targets.length,
875
1142
  results,
876
1143
  batch,
1144
+ // No appliedAt: the changelog stamps it in server time, so the
1145
+ // revert-selection sorts are immune to this host's clock skew.
877
1146
  onSuccess: (migration, elapsed, session) =>
878
1147
  changelog.markApplied(
879
1148
  db,
@@ -881,16 +1150,20 @@ class MigratorKit extends EventEmitter {
881
1150
  name,
882
1151
  batch,
883
1152
  status: 'applied',
884
- appliedAt: new Date(),
885
1153
  checksum,
886
1154
  environment: this.#environment(),
887
1155
  executedBy: safeUsername(),
888
1156
  duration: elapsed,
889
1157
  ...(this.#runId ? { runId: this.#runId } : {}),
890
1158
  ...(migration.description ? { description: migration.description } : {}),
1159
+ ...actorFields(options),
891
1160
  },
892
1161
  session,
893
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) },
894
1167
  });
895
1168
  appliedCount += 1;
896
1169
  return 'done';
@@ -910,7 +1183,10 @@ class MigratorKit extends EventEmitter {
910
1183
  const filepath = this.#filepath(record.name);
911
1184
  let actual;
912
1185
  try {
913
- actual = await computeChecksum(filepath);
1186
+ // Through the instance cache: a long-lived process running strict ups
1187
+ // repeatedly must not re-hash the whole applied history every time —
1188
+ // the same mtime+size trust status() already applies to this verdict.
1189
+ actual = await this.#cachedChecksum(filepath);
914
1190
  } catch (error) {
915
1191
  // A deleted file has no checksum to compare; status() reports it as
916
1192
  // missing, which is a separate concern from drift. Hashing directly
@@ -948,10 +1224,7 @@ class MigratorKit extends EventEmitter {
948
1224
 
949
1225
  /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
950
1226
  async down(filename, options = {}) {
951
- this.#assertFilename(filename);
952
- this.#assertStepsValid(options.steps, filename, options.batch);
953
- this.#assertBatchValid(options.batch);
954
- this.#assertToValid(options.to, filename, options);
1227
+ assertDownOptions(filename, options);
955
1228
  return this.#runWindow(async () => {
956
1229
  await this.#ensureConfig();
957
1230
  await this.connect();
@@ -1021,17 +1294,6 @@ class MigratorKit extends EventEmitter {
1021
1294
  return { records, preserveOrder };
1022
1295
  }
1023
1296
 
1024
- /** Order the selected records for execution (newest first unless pre-ordered) */
1025
- #downNames(records, preserveOrder) {
1026
- const names = [];
1027
- for (const record of records) names.push(record.name);
1028
- if (!preserveOrder) {
1029
- names.sort();
1030
- names.reverse();
1031
- }
1032
- return names;
1033
- }
1034
-
1035
1297
  async #runDown(filename, options = {}, signal) {
1036
1298
  const config = this.#config;
1037
1299
  const db = this.#requireDb();
@@ -1044,8 +1306,9 @@ class MigratorKit extends EventEmitter {
1044
1306
  logger.info('Nothing to rollback', this.#fields({ direction: 'down' }));
1045
1307
  return [];
1046
1308
  }
1309
+ if (filename && options.ordered === true) await this.#assertDownNotBlocked(toRevert[0]);
1047
1310
 
1048
- const names = this.#downNames(toRevert, preserveOrder);
1311
+ const names = revertOrder(toRevert, preserveOrder);
1049
1312
 
1050
1313
  // The signal must reach the rollback context too: a long-running down()
1051
1314
  // under SIGTERM or a lost lock is exactly the case ctx.signal exists for.
@@ -1065,7 +1328,7 @@ class MigratorKit extends EventEmitter {
1065
1328
  total: names.length,
1066
1329
  results,
1067
1330
  onSuccess: async (_migration, _elapsed, session) => {
1068
- const result = await changelog.markReverted(db, name, session);
1331
+ const result = await changelog.markReverted(db, name, session, pickActor(options));
1069
1332
  // Under --no-lock or onLockLost:'warn' a peer may have flipped the
1070
1333
  // record first: the down() body already ran against the data, but
1071
1334
  // the changelog still claims the migration is applied. Silence
@@ -1088,29 +1351,32 @@ class MigratorKit extends EventEmitter {
1088
1351
  }
1089
1352
 
1090
1353
  /**
1091
- * Refuse rollback of any migrate-mongo-imported record. These are forward-only:
1092
- * their files use migrate-mongo's positional `up(db, client)`/`down(db, client)`
1093
- * signature, which migronaut cannot invoke safely, so reverting them could corrupt the
1094
- * collection. Throws before any migration runs or the changelog is touched.
1354
+ * Refuse rollback of any forward-only record — one whose `origin` marks it
1355
+ * as adopted rather than executed by migronaut. Imported records use
1356
+ * migrate-mongo's positional `up(db, client)` signature, which migronaut
1357
+ * cannot invoke safely; baselined records were never executed by migronaut
1358
+ * at all, so their `down()` would revert work the tool has no record of
1359
+ * performing. Throws before any migration runs or the changelog is touched.
1095
1360
  */
1096
1361
  #assertReversible(records) {
1097
1362
  const names = [];
1098
1363
  for (const record of records) {
1099
- if (record.origin === 'migrate-mongo') names.push(record.name);
1364
+ if (record.origin === 'migrate-mongo' || record.origin === 'baseline') {
1365
+ names.push(record.name);
1366
+ }
1100
1367
  }
1101
1368
  if (names.length === 0) {
1102
1369
  return;
1103
1370
  }
1104
1371
  this.#logger.error(
1105
- `✖ Cannot roll back ${names.length} migrate-mongo-imported migration(s): ${names.join(', ')}`,
1372
+ `✖ Cannot roll back ${names.length} forward-only migration(s): ${names.join(', ')}`,
1106
1373
  );
1107
1374
  this.#logger.debug(
1108
- 'These were adopted via `migronaut import` (forward-only). Their files use the positional ' +
1109
- 'migrate-mongo signature, which migronaut cannot run. Revert them manually or re-author ' +
1110
- 'them in migronaut format.',
1375
+ 'These were adopted via `migronaut import` or `migronaut baseline` (forward-only), not ' +
1376
+ 'executed by migronaut. Revert them manually, or re-apply and revert them natively.',
1111
1377
  );
1112
1378
  throw new IrreversibleMigrationError(
1113
- `Cannot roll back migrate-mongo-imported migration(s): ${names.join(', ')}`,
1379
+ `Cannot roll back forward-only migration(s): ${names.join(', ')}`,
1114
1380
  { names },
1115
1381
  );
1116
1382
  }
@@ -1124,7 +1390,8 @@ class MigratorKit extends EventEmitter {
1124
1390
  * show for it.
1125
1391
  */
1126
1392
  async redo(filename, options = {}) {
1127
- this.#assertFilename(filename);
1393
+ assertRedoOptions(filename, options);
1394
+ const actor = pickActor(options);
1128
1395
  return this.#runWindow(async () => {
1129
1396
  await this.#ensureConfig();
1130
1397
  await this.connect();
@@ -1146,10 +1413,10 @@ class MigratorKit extends EventEmitter {
1146
1413
  target = newest.name;
1147
1414
  }
1148
1415
 
1149
- const downResults = await this.#runDown(target, {}, signal);
1416
+ const downResults = await this.#runDown(target, actor, signal);
1150
1417
  let upResults;
1151
1418
  try {
1152
- upResults = await this.#runUp(target, {}, signal);
1419
+ upResults = await this.#runUp(target, actor, signal);
1153
1420
  } catch (error) {
1154
1421
  // The revert already happened — after a failed re-apply that is the
1155
1422
  // single most important fact, so the down rows must survive into the
@@ -1167,12 +1434,7 @@ class MigratorKit extends EventEmitter {
1167
1434
 
1168
1435
  /** Preview what would run — never writes to the database */
1169
1436
  async dryRun(direction, filename, options = {}) {
1170
- this.#assertFilename(filename);
1171
- // `batch`/`to` must be passed too, or a conflict that `down` rejects would
1172
- // be silently allowed in its own preview.
1173
- this.#assertStepsValid(options.steps, filename, options.batch);
1174
- this.#assertBatchValid(options.batch);
1175
- this.#assertToValid(options.to, filename, options);
1437
+ assertDryRunOptions(filename, options);
1176
1438
  await this.#ensureConfig();
1177
1439
  await this.connect();
1178
1440
  const db = this.#requireDb();
@@ -1182,36 +1444,28 @@ class MigratorKit extends EventEmitter {
1182
1444
  let names;
1183
1445
  const recordByName = new Map();
1184
1446
  if (direction === 'up') {
1185
- // A preview only ever reports pending files or applied records, so the
1186
- // reverted history is dead weight here.
1187
- const records = await changelog.getApplied(db);
1188
- for (const record of records) {
1189
- recordByName.set(record.name, record);
1190
- }
1191
- const applied = new Set(recordByName.keys());
1447
+ const applied = new Set();
1192
1448
  if (filename) {
1193
- // Same preflight as a real `up`, so a preview never invents a pending
1194
- // row for a file that does not exist.
1195
- const filepath = this.#filepath(filename);
1196
- const exists = await fs
1197
- .access(filepath)
1198
- .then(() => true)
1199
- .catch(() => false);
1200
- if (!exists) {
1201
- throw new MigrationFileNotFoundError('Migration file not found', { filename });
1449
+ // Only the named file's record can matter for the preview row — an
1450
+ // applied one renders as applied instead of pending.
1451
+ const record = await changelog.getByName(db, filename);
1452
+ if (record?.status === 'applied') {
1453
+ recordByName.set(filename, record);
1454
+ applied.add(filename);
1202
1455
  }
1203
- names = [filename];
1204
1456
  } else {
1205
- const files = await this.#listMigrationFiles();
1206
- names = [];
1207
- for (const file of files) {
1208
- if (!applied.has(file)) names.push(file);
1209
- }
1210
- // `up --to` gets the same preview surface as the real run.
1211
- if (options.to !== undefined) {
1212
- names = this.#truncateAtTarget(names, files, options.to);
1213
- }
1457
+ // Names only: a bulk preview's rows are pending files, which have no
1458
+ // record to render — fetching the full applied documents would move
1459
+ // the whole history over the wire just to derive this Set.
1460
+ for (const name of await changelog.getAppliedNames(db)) applied.add(name);
1214
1461
  }
1462
+ // The same selection (and preflight) the real `up` executes, so a
1463
+ // preview never invents a pending row for a file that does not exist.
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);
1215
1469
  } else {
1216
1470
  // The same selection the real `down` executes — including the
1217
1471
  // irreversible-import refusal, so a preview can never show a rollback
@@ -1220,21 +1474,41 @@ class MigratorKit extends EventEmitter {
1220
1474
  for (const record of records) {
1221
1475
  recordByName.set(record.name, record);
1222
1476
  }
1223
- names = this.#downNames(records, preserveOrder);
1477
+ names = revertOrder(records, preserveOrder);
1224
1478
  }
1225
1479
 
1226
- const rows = await mapLimit(names, FS_CONCURRENCY, (name) =>
1227
- this.#buildStatusRow(name, recordByName.get(name)),
1228
- );
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
+ });
1229
1489
  logger.info(
1230
1490
  `◎ Dry-run Would ${direction === 'up' ? 'apply' : 'revert'}: ${rows.length}`,
1231
1491
  this.#fields({ direction, count: rows.length, dryRun: true }),
1232
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
+ }
1233
1507
  return rows;
1234
1508
  }
1235
1509
 
1236
1510
  /** Full migration status for all known files and records */
1237
- async status() {
1511
+ async status(options = {}) {
1238
1512
  await this.#ensureConfig();
1239
1513
  await this.connect();
1240
1514
  const records = await this.#requireChangelog().getAll(this.#requireDb());
@@ -1246,9 +1520,23 @@ class MigratorKit extends EventEmitter {
1246
1520
  const sortedNames = [...names].sort();
1247
1521
  // Each row may read and hash a file; unbounded fan-out over thousands of
1248
1522
  // migrations exhausts the descriptor limit.
1249
- return mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1250
- this.#buildStatusRow(name, recordByName.get(name)),
1523
+ const rows = await mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1524
+ this.#buildStatusRow(name, recordByName.get(name), options),
1251
1525
  );
1526
+ // Mark late arrivals: a not-yet-applied row sorting before the newest
1527
+ // applied name will run after migrations authored later — the same signal
1528
+ // #assertOrderIntact acts on, surfaced here as data.
1529
+ const applied = [];
1530
+ for (const row of rows) {
1531
+ if (row.status === 'applied') applied.push(row.file);
1532
+ }
1533
+ const newestApplied = newestOf(applied);
1534
+ if (newestApplied !== '') {
1535
+ for (const row of rows) {
1536
+ if (row.status !== 'applied' && row.file < newestApplied) row.outOfOrder = true;
1537
+ }
1538
+ }
1539
+ return rows;
1252
1540
  }
1253
1541
 
1254
1542
  /**
@@ -1265,17 +1553,17 @@ class MigratorKit extends EventEmitter {
1265
1553
  });
1266
1554
  }
1267
1555
 
1268
- /** Filtered list of migrations */
1269
- async list(filter = 'all') {
1270
- // An unknown filter silently returning [] reads as "nothing to report" —
1271
- // the worst possible answer to a typo.
1272
- if (filter !== 'all' && filter !== 'pending' && filter !== 'applied') {
1273
- throw new ConfigInvalidError("list filter must be 'all', 'pending' or 'applied'", { filter });
1274
- }
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);
1275
1563
  if (filter === 'pending') {
1276
1564
  return this.#listPending();
1277
1565
  }
1278
- const rows = await this.status();
1566
+ const rows = await this.status(options.checksums === false ? { checksums: false } : {});
1279
1567
  if (filter === 'all') {
1280
1568
  return rows;
1281
1569
  }
@@ -1300,8 +1588,7 @@ class MigratorKit extends EventEmitter {
1300
1588
  await this.connect();
1301
1589
  const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
1302
1590
  const rows = [];
1303
- for (const file of await this.#listMigrationFiles()) {
1304
- if (applied.has(file)) continue;
1591
+ for (const file of pendingIn(await this.#listMigrationFiles(), applied)) {
1305
1592
  rows.push({
1306
1593
  file,
1307
1594
  status: 'pending',
@@ -1332,9 +1619,47 @@ class MigratorKit extends EventEmitter {
1332
1619
  return checksum;
1333
1620
  }
1334
1621
 
1622
+ /**
1623
+ * The audit-trail fields a StatusRow surfaces from its record. The changelog
1624
+ * deliberately preserves these (who ran it, from which run, was it ever
1625
+ * reverted) — discarding them here made the questions the append-mostly
1626
+ * design exists to answer unanswerable from any read surface.
1627
+ */
1628
+ #auditFields(record) {
1629
+ if (!record) return {};
1630
+ return {
1631
+ ...(record.executedBy ? { executedBy: record.executedBy } : {}),
1632
+ ...(record.environment ? { environment: record.environment } : {}),
1633
+ ...(record.runId ? { runId: record.runId } : {}),
1634
+ ...(record.revertedAt ? { revertedAt: record.revertedAt } : {}),
1635
+ ...(record.origin ? { origin: record.origin } : {}),
1636
+ ...(record.status === 'failed' && record.error ? { error: record.error } : {}),
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 } : {}),
1644
+ };
1645
+ }
1646
+
1647
+ /**
1648
+ * A row's rendered status: 'applied', 'failed' (a recorded failed attempt —
1649
+ * the file still counts as pending for every run path, but the operator
1650
+ * deserves to see the failure), or 'pending' (including reverted history —
1651
+ * the `revertedAt` field carries that story).
1652
+ */
1653
+ static #rowStatus(record) {
1654
+ if (record?.status === 'applied') return 'applied';
1655
+ if (record?.status === 'failed') return 'failed';
1656
+ return 'pending';
1657
+ }
1658
+
1335
1659
  /** Build a StatusRow for a migration, verifying checksum when possible */
1336
- async #buildStatusRow(name, record) {
1660
+ async #buildStatusRow(name, record, { checksums = true } = {}) {
1337
1661
  const isApplied = record?.status === 'applied';
1662
+ const status = MigratorKit.#rowStatus(record);
1338
1663
  let filepath;
1339
1664
  try {
1340
1665
  filepath = this.#filepath(name);
@@ -1344,19 +1669,20 @@ class MigratorKit extends EventEmitter {
1344
1669
  // invalid and keep going.
1345
1670
  return {
1346
1671
  file: String(name),
1347
- status: isApplied ? 'applied' : 'pending',
1672
+ status,
1348
1673
  batch: isApplied && record ? record.batch : null,
1349
1674
  appliedAt: isApplied && record ? record.appliedAt : null,
1350
1675
  duration: isApplied && record ? record.duration : null,
1351
1676
  checksumOk: isApplied ? false : null,
1352
1677
  invalid: true,
1678
+ ...this.#auditFields(record),
1353
1679
  };
1354
1680
  }
1355
1681
  // Hash (via the cache) and treat ENOENT as "missing" — the old
1356
1682
  // access()-first probe cost an extra syscall per row, for pending rows
1357
1683
  // whose result was never even used.
1358
1684
  let checksumOk = null;
1359
- if (isApplied && record) {
1685
+ if (isApplied && record && checksums) {
1360
1686
  try {
1361
1687
  checksumOk = (await this.#cachedChecksum(filepath)) === record.checksum;
1362
1688
  } catch (error) {
@@ -1367,12 +1693,13 @@ class MigratorKit extends EventEmitter {
1367
1693
 
1368
1694
  return {
1369
1695
  file: name,
1370
- status: isApplied ? 'applied' : 'pending',
1696
+ status,
1371
1697
  batch: isApplied && record ? record.batch : null,
1372
1698
  appliedAt: isApplied && record ? record.appliedAt : null,
1373
1699
  duration: isApplied && record ? record.duration : null,
1374
1700
  checksumOk,
1375
1701
  ...(record?.description ? { description: record.description } : {}),
1702
+ ...this.#auditFields(record),
1376
1703
  };
1377
1704
  }
1378
1705
 
@@ -1419,7 +1746,7 @@ class MigratorKit extends EventEmitter {
1419
1746
  }
1420
1747
 
1421
1748
  const filepath = await createConfigFile({
1422
- dir: process.cwd(),
1749
+ dir: this.#cwd ?? process.cwd(),
1423
1750
  format: options.format ?? 'js',
1424
1751
  force: options.force ?? false,
1425
1752
  values,
@@ -1432,6 +1759,43 @@ class MigratorKit extends EventEmitter {
1432
1759
  return filepath;
1433
1760
  }
1434
1761
 
1762
+ /**
1763
+ * Adopt an existing database with no prior migration tool: mark migration
1764
+ * files on disk as applied (checksum from disk, one shared batch,
1765
+ * `origin: 'baseline'`) without executing anything — see
1766
+ * {@link runBaseline} in baseline.js for the mechanics. Forward-only, like
1767
+ * import: `down`/`redo` refuse baselined records. Runs under the migration
1768
+ * lock — it writes the changelog, and two concurrent baselines (or a
1769
+ * baseline racing an `up`) must serialize like any other mutation.
1770
+ */
1771
+ async baseline(options = {}) {
1772
+ assertFilename(options.to);
1773
+ return this.#runWindow(async () => {
1774
+ await this.#ensureConfig();
1775
+ await this.connect();
1776
+ return this.#withLock(options, { command: 'baseline' }, (signal) =>
1777
+ runBaseline(
1778
+ {
1779
+ db: this.#requireDb(),
1780
+ changelog: this.#requireChangelog(),
1781
+ logger: this.#logger,
1782
+ fields: (extra) => this.#fields(extra),
1783
+ filepath: (name) => this.#filepath(name),
1784
+ listMigrationFiles: () => this.#listMigrationFiles(),
1785
+ nextBatch: () => this.#nextBatch(),
1786
+ truncateAtTarget,
1787
+ environment: () => this.#environment(),
1788
+ executedBy: () => safeUsername(),
1789
+ runId: () => this.#runId,
1790
+ assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
1791
+ },
1792
+ options,
1793
+ signal,
1794
+ ),
1795
+ );
1796
+ });
1797
+ }
1798
+
1435
1799
  /**
1436
1800
  * Adopt an existing migrate-mongo `changelog` collection by mapping its
1437
1801
  * records into our schema and writing them to `migrationsCollection`. The
@@ -1440,14 +1804,7 @@ class MigratorKit extends EventEmitter {
1440
1804
  * file signatures, so `down`/`redo` on imported files is unsupported.
1441
1805
  */
1442
1806
  async import(options = {}) {
1443
- // Validate before connecting: a bad --from/--to must not cost a round trip
1444
- // or take the lock. Defaults come from the already-validated config.
1445
- if (options.from !== undefined && !isCollectionName(options.from)) {
1446
- throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
1447
- }
1448
- if (options.to !== undefined && !isCollectionName(options.to)) {
1449
- throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
1450
- }
1807
+ assertImportOptions(options);
1451
1808
  return this.#runWindow(async () => {
1452
1809
  await this.#ensureConfig();
1453
1810
  await this.connect();
@@ -1470,6 +1827,150 @@ class MigratorKit extends EventEmitter {
1470
1827
  );
1471
1828
  });
1472
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
+ }
1473
1974
  }
1474
1975
 
1475
- module.exports = { MigratorKit };
1976
+ module.exports = { MigratorKit, RECORD_LOCK_WAIT };