@alexify/migronaut 1.0.0 → 2.0.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.
@@ -15,6 +15,7 @@ const {
15
15
  MigrationInvalidNameError,
16
16
  MigronautError,
17
17
  NotAppliedError,
18
+ OutOfOrderMigrationError,
18
19
  RunAbortedError,
19
20
  } = require('../errors/index.js');
20
21
  const { computeChecksum } = require('../utils/checksum.js');
@@ -29,6 +30,7 @@ const {
29
30
  } = require('../utils/template.js');
30
31
  const { safeUsername } = require('../utils/user.js');
31
32
  const { runAudit } = require('./audit.js');
33
+ const { runBaseline } = require('./baseline.js');
32
34
  const { Changelog } = require('./changelog.js');
33
35
  const { isCollectionName, loadConfig } = require('./config.js');
34
36
  const { buildContext } = require('./context.js');
@@ -73,6 +75,12 @@ class MigratorKit extends EventEmitter {
73
75
  #fallbackLogger;
74
76
  /** False when the client was injected by the caller, who keeps ownership of it */
75
77
  #ownsClient = true;
78
+ /**
79
+ * Project root this instance resolves against — config discovery, the .env
80
+ * file and a relative migrationsDir. Defaults to process.cwd(); an explicit
81
+ * value is what lets one process host kits for several projects.
82
+ */
83
+ #cwd;
76
84
  /** filepath → {mtimeMs, size, checksum} — spares repeat status()/audit() calls a full re-hash */
77
85
  #checksumCache = new Map();
78
86
 
@@ -82,6 +90,7 @@ class MigratorKit extends EventEmitter {
82
90
  this.#configPath = options.configPath;
83
91
  this.#progress = options.progress;
84
92
  this.#fallbackLogger = options.fallbackLogger;
93
+ this.#cwd = options.cwd;
85
94
  }
86
95
 
87
96
  /**
@@ -154,6 +163,7 @@ class MigratorKit extends EventEmitter {
154
163
  requireDb,
155
164
  ...(lenient ? { lenient: true } : {}),
156
165
  ...(this.#configPath ? { configPath: this.#configPath } : {}),
166
+ ...(this.#cwd ? { cwd: this.#cwd } : {}),
157
167
  ...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
158
168
  });
159
169
  }
@@ -232,7 +242,7 @@ class MigratorKit extends EventEmitter {
232
242
  // Once per instance, not once per connect: re-issuing createIndexes on
233
243
  // every command is a wasted round trip. `ensureIndexes: false` skips it
234
244
  // entirely, for deployments where the app user cannot create indexes.
235
- if (!this.#indexesEnsured && (config.ensureIndexes ?? true)) {
245
+ if (!this.#indexesEnsured && config.ensureIndexes) {
236
246
  await this.#changelog.ensureIndexes(this.#db);
237
247
  this.#indexesEnsured = true;
238
248
  }
@@ -307,6 +317,15 @@ class MigratorKit extends EventEmitter {
307
317
  return new MigrationLock(this.#requireDb(), config.lockCollection, config.lockTTLSeconds);
308
318
  }
309
319
 
320
+ /**
321
+ * The resolved logger with #fields merged into every line, for handing to
322
+ * lock.js — which stays kit-agnostic and cannot stamp runId itself.
323
+ */
324
+ #lockLogger() {
325
+ const wrap = (method) => (msg, fields) => this.#logger[method](msg, this.#fields(fields ?? {}));
326
+ return { debug: wrap('debug'), info: wrap('info'), warn: wrap('warn'), error: wrap('error') };
327
+ }
328
+
310
329
  /**
311
330
  * Run `fn` under the migration lock. The single place that pairs a lock with
312
331
  * a unit of work, so `redo` can hold one lock across both directions instead
@@ -348,8 +367,11 @@ class MigratorKit extends EventEmitter {
348
367
  result = await runWithLock(
349
368
  this.#buildLock(),
350
369
  {
351
- logger: this.#logger,
352
- onLockLost: this.#config?.onLockLost ?? 'abort',
370
+ // Wrapped so every lock line carries #fields (runId included) — a
371
+ // JSON-sink operator must be able to join a lock-lost alert to the
372
+ // run's migration lines and changelog records without a log parse.
373
+ logger: this.#lockLogger(),
374
+ onLockLost: this.#config.onLockLost,
353
375
  owner: this.#runId,
354
376
  onLockAcquired: (extra) => this.#emit('lock:acquired', { owner: this.#runId, ...extra }),
355
377
  onLockReleased: (extra) => this.#emit('lock:released', { owner: this.#runId, ...extra }),
@@ -383,16 +405,30 @@ class MigratorKit extends EventEmitter {
383
405
  }
384
406
  summary = { applied, reverted, total: rows.length };
385
407
  }
408
+ const durationMs = Date.now() - startedAt;
386
409
  this.#emit('run:end', {
387
410
  ...info,
388
411
  success: failure === undefined,
389
- durationMs: Date.now() - startedAt,
412
+ durationMs,
390
413
  ...summary,
391
414
  // A raw Error here would hand subscribers an unredacted driver message
392
415
  // (which can echo the credentialed URI) — errorText is the same
393
416
  // chokepoint every log line and result row already goes through.
394
417
  ...(failure ? { error: errorText(failure) } : {}),
395
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
+ }
396
432
  this.#abort = undefined;
397
433
  this.#runId = undefined;
398
434
  }
@@ -457,7 +493,9 @@ class MigratorKit extends EventEmitter {
457
493
  }
458
494
 
459
495
  #migrationsPath() {
460
- return path.resolve(this.#config?.migrationsDir ?? './migrations');
496
+ // No value fallback: DEFAULT_CONFIG always supplies migrationsDir, and a
497
+ // silent './migrations' here would mask a config-resolution regression.
498
+ return path.resolve(this.#cwd ?? process.cwd(), this.#config.migrationsDir);
461
499
  }
462
500
 
463
501
  /**
@@ -511,7 +549,8 @@ class MigratorKit extends EventEmitter {
511
549
  /** List migration files on disk, sorted ascending */
512
550
  async #listMigrationFiles() {
513
551
  const dir = this.#migrationsPath();
514
- const extensions = this.#config?.fileExtensions ?? ['.ts', '.js'];
552
+ // No value fallback — DEFAULT_CONFIG always supplies fileExtensions.
553
+ const extensions = this.#config.fileExtensions;
515
554
  let entries;
516
555
  try {
517
556
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -618,6 +657,68 @@ class MigratorKit extends EventEmitter {
618
657
  }
619
658
  }
620
659
 
660
+ /**
661
+ * Resolve which files an `up` (or its dry-run) targets: a named file (which
662
+ * must exist on disk), or every pending file, optionally truncated at `--to`.
663
+ * The single source of truth for that selection — mirroring
664
+ * {@link #selectDownTargets} — so the preview can never disagree with the
665
+ * real run about what would be applied.
666
+ */
667
+ async #selectUpTargets(filename, options, appliedNames) {
668
+ if (filename) {
669
+ const filepath = this.#filepath(filename);
670
+ try {
671
+ await fs.access(filepath);
672
+ } catch {
673
+ throw new MigrationFileNotFoundError('Migration file not found', { filename });
674
+ }
675
+ return [filename];
676
+ }
677
+ 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;
683
+ }
684
+
685
+ /**
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.
695
+ */
696
+ #assertOrderIntact(targets, appliedNames) {
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;
708
+ if (policy === 'error') {
709
+ throw new OutOfOrderMigrationError(
710
+ `${late.length} pending migration(s) sort before the newest applied one ` +
711
+ `(${newestApplied}): ${late.join(', ')} — apply deliberately with onOutOfOrder: 'warn' or 'allow'`,
712
+ { names: late, newestApplied },
713
+ );
714
+ }
715
+ this.#logger.warn(
716
+ `⚠ Out-of-order: ${late.length} pending migration(s) sort before the newest applied one ` +
717
+ `(${newestApplied}): ${late.join(', ')}`,
718
+ this.#fields({ event: 'migrations:out-of-order', names: late, newestApplied }),
719
+ );
720
+ }
721
+
621
722
  /**
622
723
  * The shared skeleton of a migration run: beforeAll → per-name loop with an
623
724
  * abort check between migrations → afterAll (also on the failure path, which
@@ -640,6 +741,11 @@ class MigratorKit extends EventEmitter {
640
741
  }
641
742
  } catch (error) {
642
743
  failure = error;
744
+ // Failures before the migration body — beforeEach, a file that fails to
745
+ // load — bypass #executeMigration's catch, so the partial results must be
746
+ // attached here too or a --json consumer loses the applied-so-far list
747
+ // exactly when it matters. Copy-on-write makes a re-attach harmless.
748
+ this.#attachResults(error, results);
643
749
  }
644
750
  const succeeded = failure === undefined;
645
751
  // afterAll runs on the failure path too — which is exactly when a
@@ -680,7 +786,7 @@ class MigratorKit extends EventEmitter {
680
786
  { direction, index, total },
681
787
  ]);
682
788
  const migration = await loadMigrationFile(this.#filepath(name), {
683
- reload: config.reloadMigrations ?? false,
789
+ reload: config.reloadMigrations,
684
790
  });
685
791
  const useTransaction = migration.useTransaction ?? config.useTransaction;
686
792
 
@@ -727,15 +833,68 @@ class MigratorKit extends EventEmitter {
727
833
  return duration;
728
834
  } catch (error) {
729
835
  this.#progress?.onStop('error');
836
+ // The runner measures how long the failing attempt ran and leaves it on
837
+ // the error's context — thread it through, so failures carry timing data
838
+ // the same way successes do (a slow-then-failing migration is exactly
839
+ // what a metrics subscriber alerts on).
840
+ const durationMs =
841
+ error instanceof MigronautError && typeof error.context?.durationMs === 'number'
842
+ ? error.context.durationMs
843
+ : undefined;
844
+ const durationField = durationMs !== undefined ? { durationMs } : {};
730
845
  // errorText, not the raw Error: a driver message can echo the
731
846
  // credentialed URI, and event subscribers (Sentry, JSON logs) would
732
847
  // ship it — the same redaction the log line below already gets.
733
- this.#emit('migration:error', { migration: name, direction, error: errorText(error) });
848
+ this.#emit('migration:error', {
849
+ migration: name,
850
+ direction,
851
+ ...batchField,
852
+ ...durationField,
853
+ error: errorText(error),
854
+ });
734
855
  logger.error(
735
856
  `✖ Error ${name}`,
736
- this.#fields({ migration: name, direction, error: errorText(error) }),
857
+ this.#fields({
858
+ migration: name,
859
+ direction,
860
+ ...batchField,
861
+ ...durationField,
862
+ error: errorText(error),
863
+ }),
737
864
  );
738
- results.push({ file: name, status: 'error', error: errorText(error) });
865
+ results.push({
866
+ file: name,
867
+ status: 'error',
868
+ ...(durationMs !== undefined ? { duration: durationMs } : {}),
869
+ error: errorText(error),
870
+ });
871
+ // Best-effort DB-side trace of the failed attempt (up only — marking a
872
+ // failed `down` would demote a record that is still truthfully applied).
873
+ // Swallowed on its own failure: the changelog may be the thing that is
874
+ // down, and this trace must never mask the migration's real error.
875
+ 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
+ try {
884
+ await this.#requireChangelog().markFailed(this.#requireDb(), {
885
+ name,
886
+ error: cause ? `${errorText(error)} — ${cause}` : errorText(error),
887
+ environment: this.#environment(),
888
+ executedBy: safeUsername(),
889
+ ...batchField,
890
+ ...(durationMs !== undefined ? { duration: durationMs } : {}),
891
+ ...(this.#runId ? { runId: this.#runId } : {}),
892
+ });
893
+ } catch {
894
+ // Duplicate key when an 'applied' record exists (forced re-run), or
895
+ // the database itself is unreachable — the trace is best-effort.
896
+ }
897
+ }
739
898
  // Carry what already succeeded, so `--json` consumers can tell which
740
899
  // migrations landed before the failure instead of losing the list.
741
900
  this.#attachResults(error, results);
@@ -781,29 +940,12 @@ class MigratorKit extends EventEmitter {
781
940
  for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
782
941
  }
783
942
 
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);
798
- }
799
- if (options.to !== undefined) {
800
- targets = this.#truncateAtTarget(targets, files, options.to);
801
- }
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
- }
943
+ const targets = await this.#selectUpTargets(filename, options, appliedNames);
944
+ // Pending files are the only bulk targets, so the per-target checksum
945
+ // check below can never see an applied one. Verify them up front instead,
946
+ // otherwise `up --strict` over a bulk run would police nothing.
947
+ if (!filename && strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
948
+ this.#assertOrderIntact(targets, appliedNames);
807
949
 
808
950
  if (targets.length === 0) {
809
951
  logger.info('Nothing to migrate', this.#fields({ direction: 'up' }));
@@ -874,6 +1016,8 @@ class MigratorKit extends EventEmitter {
874
1016
  total: targets.length,
875
1017
  results,
876
1018
  batch,
1019
+ // No appliedAt: the changelog stamps it in server time, so the
1020
+ // revert-selection sorts are immune to this host's clock skew.
877
1021
  onSuccess: (migration, elapsed, session) =>
878
1022
  changelog.markApplied(
879
1023
  db,
@@ -881,7 +1025,6 @@ class MigratorKit extends EventEmitter {
881
1025
  name,
882
1026
  batch,
883
1027
  status: 'applied',
884
- appliedAt: new Date(),
885
1028
  checksum,
886
1029
  environment: this.#environment(),
887
1030
  executedBy: safeUsername(),
@@ -910,7 +1053,10 @@ class MigratorKit extends EventEmitter {
910
1053
  const filepath = this.#filepath(record.name);
911
1054
  let actual;
912
1055
  try {
913
- actual = await computeChecksum(filepath);
1056
+ // Through the instance cache: a long-lived process running strict ups
1057
+ // repeatedly must not re-hash the whole applied history every time —
1058
+ // the same mtime+size trust status() already applies to this verdict.
1059
+ actual = await this.#cachedChecksum(filepath);
914
1060
  } catch (error) {
915
1061
  // A deleted file has no checksum to compare; status() reports it as
916
1062
  // missing, which is a separate concern from drift. Hashing directly
@@ -1088,29 +1234,32 @@ class MigratorKit extends EventEmitter {
1088
1234
  }
1089
1235
 
1090
1236
  /**
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.
1237
+ * Refuse rollback of any forward-only record — one whose `origin` marks it
1238
+ * as adopted rather than executed by migronaut. Imported records use
1239
+ * migrate-mongo's positional `up(db, client)` signature, which migronaut
1240
+ * cannot invoke safely; baselined records were never executed by migronaut
1241
+ * at all, so their `down()` would revert work the tool has no record of
1242
+ * performing. Throws before any migration runs or the changelog is touched.
1095
1243
  */
1096
1244
  #assertReversible(records) {
1097
1245
  const names = [];
1098
1246
  for (const record of records) {
1099
- if (record.origin === 'migrate-mongo') names.push(record.name);
1247
+ if (record.origin === 'migrate-mongo' || record.origin === 'baseline') {
1248
+ names.push(record.name);
1249
+ }
1100
1250
  }
1101
1251
  if (names.length === 0) {
1102
1252
  return;
1103
1253
  }
1104
1254
  this.#logger.error(
1105
- `✖ Cannot roll back ${names.length} migrate-mongo-imported migration(s): ${names.join(', ')}`,
1255
+ `✖ Cannot roll back ${names.length} forward-only migration(s): ${names.join(', ')}`,
1106
1256
  );
1107
1257
  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.',
1258
+ 'These were adopted via `migronaut import` or `migronaut baseline` (forward-only), not ' +
1259
+ 'executed by migronaut. Revert them manually, or re-apply and revert them natively.',
1111
1260
  );
1112
1261
  throw new IrreversibleMigrationError(
1113
- `Cannot roll back migrate-mongo-imported migration(s): ${names.join(', ')}`,
1262
+ `Cannot roll back forward-only migration(s): ${names.join(', ')}`,
1114
1263
  { names },
1115
1264
  );
1116
1265
  }
@@ -1182,36 +1331,24 @@ class MigratorKit extends EventEmitter {
1182
1331
  let names;
1183
1332
  const recordByName = new Map();
1184
1333
  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());
1334
+ const applied = new Set();
1192
1335
  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 });
1336
+ // Only the named file's record can matter for the preview row — an
1337
+ // applied one renders as applied instead of pending.
1338
+ const record = await changelog.getByName(db, filename);
1339
+ if (record?.status === 'applied') {
1340
+ recordByName.set(filename, record);
1341
+ applied.add(filename);
1202
1342
  }
1203
- names = [filename];
1204
1343
  } 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
- }
1344
+ // Names only: a bulk preview's rows are pending files, which have no
1345
+ // record to render — fetching the full applied documents would move
1346
+ // the whole history over the wire just to derive this Set.
1347
+ for (const name of await changelog.getAppliedNames(db)) applied.add(name);
1214
1348
  }
1349
+ // The same selection (and preflight) the real `up` executes, so a
1350
+ // preview never invents a pending row for a file that does not exist.
1351
+ names = await this.#selectUpTargets(filename, options, applied);
1215
1352
  } else {
1216
1353
  // The same selection the real `down` executes — including the
1217
1354
  // irreversible-import refusal, so a preview can never show a rollback
@@ -1246,9 +1383,22 @@ class MigratorKit extends EventEmitter {
1246
1383
  const sortedNames = [...names].sort();
1247
1384
  // Each row may read and hash a file; unbounded fan-out over thousands of
1248
1385
  // migrations exhausts the descriptor limit.
1249
- return mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1386
+ const rows = await mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1250
1387
  this.#buildStatusRow(name, recordByName.get(name)),
1251
1388
  );
1389
+ // Mark late arrivals: a not-yet-applied row sorting before the newest
1390
+ // applied name will run after migrations authored later — the same signal
1391
+ // #assertOrderIntact acts on, surfaced here as data.
1392
+ let newestApplied = '';
1393
+ for (const row of rows) {
1394
+ if (row.status === 'applied' && row.file > newestApplied) newestApplied = row.file;
1395
+ }
1396
+ if (newestApplied !== '') {
1397
+ for (const row of rows) {
1398
+ if (row.status !== 'applied' && row.file < newestApplied) row.outOfOrder = true;
1399
+ }
1400
+ }
1401
+ return rows;
1252
1402
  }
1253
1403
 
1254
1404
  /**
@@ -1332,9 +1482,41 @@ class MigratorKit extends EventEmitter {
1332
1482
  return checksum;
1333
1483
  }
1334
1484
 
1485
+ /**
1486
+ * The audit-trail fields a StatusRow surfaces from its record. The changelog
1487
+ * deliberately preserves these (who ran it, from which run, was it ever
1488
+ * reverted) — discarding them here made the questions the append-mostly
1489
+ * design exists to answer unanswerable from any read surface.
1490
+ */
1491
+ #auditFields(record) {
1492
+ if (!record) return {};
1493
+ return {
1494
+ ...(record.executedBy ? { executedBy: record.executedBy } : {}),
1495
+ ...(record.environment ? { environment: record.environment } : {}),
1496
+ ...(record.runId ? { runId: record.runId } : {}),
1497
+ ...(record.revertedAt ? { revertedAt: record.revertedAt } : {}),
1498
+ ...(record.origin ? { origin: record.origin } : {}),
1499
+ ...(record.status === 'failed' && record.error ? { error: record.error } : {}),
1500
+ ...(record.status === 'failed' && record.failedAt ? { failedAt: record.failedAt } : {}),
1501
+ };
1502
+ }
1503
+
1504
+ /**
1505
+ * A row's rendered status: 'applied', 'failed' (a recorded failed attempt —
1506
+ * the file still counts as pending for every run path, but the operator
1507
+ * deserves to see the failure), or 'pending' (including reverted history —
1508
+ * the `revertedAt` field carries that story).
1509
+ */
1510
+ static #rowStatus(record) {
1511
+ if (record?.status === 'applied') return 'applied';
1512
+ if (record?.status === 'failed') return 'failed';
1513
+ return 'pending';
1514
+ }
1515
+
1335
1516
  /** Build a StatusRow for a migration, verifying checksum when possible */
1336
1517
  async #buildStatusRow(name, record) {
1337
1518
  const isApplied = record?.status === 'applied';
1519
+ const status = MigratorKit.#rowStatus(record);
1338
1520
  let filepath;
1339
1521
  try {
1340
1522
  filepath = this.#filepath(name);
@@ -1344,12 +1526,13 @@ class MigratorKit extends EventEmitter {
1344
1526
  // invalid and keep going.
1345
1527
  return {
1346
1528
  file: String(name),
1347
- status: isApplied ? 'applied' : 'pending',
1529
+ status,
1348
1530
  batch: isApplied && record ? record.batch : null,
1349
1531
  appliedAt: isApplied && record ? record.appliedAt : null,
1350
1532
  duration: isApplied && record ? record.duration : null,
1351
1533
  checksumOk: isApplied ? false : null,
1352
1534
  invalid: true,
1535
+ ...this.#auditFields(record),
1353
1536
  };
1354
1537
  }
1355
1538
  // Hash (via the cache) and treat ENOENT as "missing" — the old
@@ -1367,12 +1550,13 @@ class MigratorKit extends EventEmitter {
1367
1550
 
1368
1551
  return {
1369
1552
  file: name,
1370
- status: isApplied ? 'applied' : 'pending',
1553
+ status,
1371
1554
  batch: isApplied && record ? record.batch : null,
1372
1555
  appliedAt: isApplied && record ? record.appliedAt : null,
1373
1556
  duration: isApplied && record ? record.duration : null,
1374
1557
  checksumOk,
1375
1558
  ...(record?.description ? { description: record.description } : {}),
1559
+ ...this.#auditFields(record),
1376
1560
  };
1377
1561
  }
1378
1562
 
@@ -1419,7 +1603,7 @@ class MigratorKit extends EventEmitter {
1419
1603
  }
1420
1604
 
1421
1605
  const filepath = await createConfigFile({
1422
- dir: process.cwd(),
1606
+ dir: this.#cwd ?? process.cwd(),
1423
1607
  format: options.format ?? 'js',
1424
1608
  force: options.force ?? false,
1425
1609
  values,
@@ -1432,6 +1616,43 @@ class MigratorKit extends EventEmitter {
1432
1616
  return filepath;
1433
1617
  }
1434
1618
 
1619
+ /**
1620
+ * Adopt an existing database with no prior migration tool: mark migration
1621
+ * files on disk as applied (checksum from disk, one shared batch,
1622
+ * `origin: 'baseline'`) without executing anything — see
1623
+ * {@link runBaseline} in baseline.js for the mechanics. Forward-only, like
1624
+ * import: `down`/`redo` refuse baselined records. Runs under the migration
1625
+ * lock — it writes the changelog, and two concurrent baselines (or a
1626
+ * baseline racing an `up`) must serialize like any other mutation.
1627
+ */
1628
+ async baseline(options = {}) {
1629
+ this.#assertFilename(options.to);
1630
+ return this.#runWindow(async () => {
1631
+ await this.#ensureConfig();
1632
+ await this.connect();
1633
+ return this.#withLock(options, { command: 'baseline' }, (signal) =>
1634
+ runBaseline(
1635
+ {
1636
+ db: this.#requireDb(),
1637
+ changelog: this.#requireChangelog(),
1638
+ logger: this.#logger,
1639
+ fields: (extra) => this.#fields(extra),
1640
+ filepath: (name) => this.#filepath(name),
1641
+ listMigrationFiles: () => this.#listMigrationFiles(),
1642
+ nextBatch: () => this.#nextBatch(),
1643
+ truncateAtTarget: (pending, all, to) => this.#truncateAtTarget(pending, all, to),
1644
+ environment: () => this.#environment(),
1645
+ executedBy: () => safeUsername(),
1646
+ runId: () => this.#runId,
1647
+ assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
1648
+ },
1649
+ options,
1650
+ signal,
1651
+ ),
1652
+ );
1653
+ });
1654
+ }
1655
+
1435
1656
  /**
1436
1657
  * Adopt an existing migrate-mongo `changelog` collection by mapping its
1437
1658
  * records into our schema and writing them to `migrationsCollection`. The
package/src/core/run.js CHANGED
@@ -33,6 +33,12 @@ function jitteredDelay(baseMs) {
33
33
  * the race to acquire the lock block until the migrating peer finishes, then
34
34
  * confirm there is nothing left to apply.
35
35
  *
36
+ * Running several kits against several databases in ONE process: pass
37
+ * `envFile: false` and supply `uri`/`dbName` directly. `.env` loading mutates
38
+ * the shared process.env (dotenv semantics, override: false), so two kits
39
+ * with different env files would otherwise leak `MIGRONAUT_*` values into each
40
+ * other's config resolution.
41
+ *
36
42
  * @example
37
43
  * ```js
38
44
  * const { runMigrations } = require('@alexify/migronaut');
@@ -50,9 +56,14 @@ async function runMigrations(config = {}, options = {}) {
50
56
  onLockHeld = 'throw',
51
57
  lockWaitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS,
52
58
  lockPollIntervalMs = DEFAULT_LOCK_POLL_INTERVAL_MS,
59
+ onKit,
53
60
  ...kitOptions
54
61
  } = options;
55
62
 
63
+ if (onKit !== undefined && typeof onKit !== 'function') {
64
+ throw new ConfigInvalidError('onKit must be a function', { onKit: typeof onKit });
65
+ }
66
+
56
67
  // Validated before anything connects. A NaN here (or any non-positive value)
57
68
  // disables every deadline comparison below — `NaN > deadline` is always
58
69
  // false — turning the wait loop into an unbounded retry storm against the
@@ -69,6 +80,10 @@ async function runMigrations(config = {}, options = {}) {
69
80
  }
70
81
 
71
82
  const kit = new MigratorKit(config, kitOptions);
83
+ // Handed out before connect so listeners catch every lifecycle event —
84
+ // this is the metrics/alerting injection point for apps that embed
85
+ // runMigrations and cannot reach the internally-constructed kit otherwise.
86
+ onKit?.(kit);
72
87
  let waited = false;
73
88
  let waitedMs = 0;
74
89
  let attempts = 0;
@@ -82,6 +97,10 @@ async function runMigrations(config = {}, options = {}) {
82
97
  // The clock starts at the first contention, not before the first attempt —
83
98
  // otherwise a slow initial attempt eats the whole waiting budget.
84
99
  let deadline;
100
+ // The holder's lockedAt from the last refusal: its heartbeat advances it
101
+ // every TTL/2, so a change between polls is proof of a live, progressing
102
+ // peer.
103
+ let lastHolderLockedAt;
85
104
 
86
105
  for (;;) {
87
106
  try {
@@ -92,7 +111,20 @@ async function runMigrations(config = {}, options = {}) {
92
111
  if (onLockHeld !== 'wait' || !(error instanceof LockAlreadyHeldError)) {
93
112
  throw error;
94
113
  }
95
- deadline ??= Date.now() + lockWaitTimeoutMs;
114
+ // The timeout bounds *stall* time, not total wait: while the holder's
115
+ // heartbeat visibly advances, it is healthy and working through its
116
+ // backlog — timing out then would crash-loop every waiting instance
117
+ // on exactly the deploys (a large first backlog) that take longest.
118
+ // Only a holder that stops renewing runs the deadline down.
119
+ const holderLockedAt = error.context?.holder?.lockedAt?.getTime?.();
120
+ const holderAdvanced =
121
+ holderLockedAt !== undefined &&
122
+ lastHolderLockedAt !== undefined &&
123
+ holderLockedAt > lastHolderLockedAt;
124
+ if (deadline === undefined || holderAdvanced) {
125
+ deadline = Date.now() + lockWaitTimeoutMs;
126
+ }
127
+ if (holderLockedAt !== undefined) lastHolderLockedAt = holderLockedAt;
96
128
  const nextDelay = jitteredDelay(lockPollIntervalMs);
97
129
  if (Date.now() + nextDelay > deadline) {
98
130
  throw error;