@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.
- package/CHANGELOG.md +89 -1
- package/README.md +42 -20
- package/bin/migronaut.js +11 -3
- package/index.d.ts +126 -14
- package/migronaut.schema.json +9 -0
- package/package.json +7 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/exit-codes.js +1 -0
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +15 -3
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +74 -23
- package/src/core/config.js +25 -2
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/lock.js +28 -6
- package/src/core/migrator.js +296 -75
- package/src/core/run.js +33 -1
- package/src/core/runner.js +70 -20
- package/src/errors/index.js +15 -1
- package/src/index.js +8 -0
- package/src/utils/logger.js +30 -12
- package/src/utils/redact.js +36 -3
- package/src/utils/sanitize.js +8 -3
- package/src/utils/template.js +24 -10
package/src/core/migrator.js
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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', {
|
|
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({
|
|
857
|
+
this.#fields({
|
|
858
|
+
migration: name,
|
|
859
|
+
direction,
|
|
860
|
+
...batchField,
|
|
861
|
+
...durationField,
|
|
862
|
+
error: errorText(error),
|
|
863
|
+
}),
|
|
737
864
|
);
|
|
738
|
-
results.push({
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
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'
|
|
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}
|
|
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)
|
|
1109
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
.
|
|
1198
|
-
.
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|