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