@alexify/migronaut 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +409 -1
- package/README.md +248 -24
- package/bin/migronaut.js +11 -3
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +757 -29
- package/migronaut.schema.json +191 -1
- package/package.json +27 -6
- 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/baseline.js +45 -0
- 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/unlock.js +12 -2
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +10 -2
- package/src/cli/index.js +4 -0
- package/src/cli/shared.js +29 -7
- package/src/cli/table.js +105 -0
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +140 -24
- package/src/core/collections.js +372 -0
- package/src/core/config.js +125 -27
- 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/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +71 -20
- package/src/core/migrator.js +805 -304
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +71 -71
- package/src/core/runner.js +70 -20
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +71 -1
- package/src/index.js +16 -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/logger.js +30 -12
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +57 -4
- package/src/utils/sanitize.js +8 -3
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +60 -12
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,17 +11,23 @@ const {
|
|
|
11
11
|
ConnectionFailedError,
|
|
12
12
|
HookFailedError,
|
|
13
13
|
IrreversibleMigrationError,
|
|
14
|
+
LockAlreadyHeldError,
|
|
14
15
|
MigrationFileNotFoundError,
|
|
15
16
|
MigrationInvalidNameError,
|
|
16
17
|
MigronautError,
|
|
17
18
|
NotAppliedError,
|
|
19
|
+
OutOfOrderMigrationError,
|
|
18
20
|
RunAbortedError,
|
|
19
21
|
} = require('../errors/index.js');
|
|
22
|
+
const { actorFields, pickActor } = require('../utils/actor.js');
|
|
20
23
|
const { computeChecksum } = require('../utils/checksum.js');
|
|
21
24
|
const { mapLimit } = require('../utils/concurrency.js');
|
|
22
|
-
const { errorText } = require('../utils/error.js');
|
|
25
|
+
const { errorText, errorWithCause } = require('../utils/error.js');
|
|
26
|
+
const { createIdGenerator } = require('../utils/id.js');
|
|
23
27
|
const { loadMigrationFile } = require('../utils/loader.js');
|
|
24
28
|
const { resolveLogger } = require('../utils/logger.js');
|
|
29
|
+
const { assertMigrationName } = require('../utils/migration-name.js');
|
|
30
|
+
const { ATTRIBUTES, SPANS, createTelemetry } = require('../utils/telemetry.js');
|
|
25
31
|
const {
|
|
26
32
|
createConfigFile,
|
|
27
33
|
createMigrationFile,
|
|
@@ -29,12 +35,37 @@ const {
|
|
|
29
35
|
} = require('../utils/template.js');
|
|
30
36
|
const { safeUsername } = require('../utils/user.js');
|
|
31
37
|
const { runAudit } = require('./audit.js');
|
|
38
|
+
const { runBaseline } = require('./baseline.js');
|
|
32
39
|
const { Changelog } = require('./changelog.js');
|
|
33
|
-
const {
|
|
40
|
+
const { ConvergeLog } = require('./converge-log.js');
|
|
41
|
+
const { resolveDefinitions } = require('./collections.js');
|
|
42
|
+
const { loadConfig } = require('./config.js');
|
|
34
43
|
const { buildContext } = require('./context.js');
|
|
44
|
+
const { runConverge } = require('./converge.js');
|
|
35
45
|
const { runImport } = require('./import-runner.js');
|
|
36
46
|
const { MigrationLock, runWithLock, toLockInfo } = require('./lock.js');
|
|
47
|
+
const {
|
|
48
|
+
assertConvergeOptions,
|
|
49
|
+
assertDownOptions,
|
|
50
|
+
assertDryRunOptions,
|
|
51
|
+
assertFilename,
|
|
52
|
+
assertHistoryLimit,
|
|
53
|
+
assertImportOptions,
|
|
54
|
+
assertListOptions,
|
|
55
|
+
assertRedoOptions,
|
|
56
|
+
assertUpOptions,
|
|
57
|
+
} = require('./options.js');
|
|
58
|
+
const { RunRecorder } = require('./run-recorder.js');
|
|
37
59
|
const { runMigration } = require('./runner.js');
|
|
60
|
+
const {
|
|
61
|
+
blockedError,
|
|
62
|
+
lateArrivals,
|
|
63
|
+
listMigrationFiles,
|
|
64
|
+
newestOf,
|
|
65
|
+
pendingIn,
|
|
66
|
+
revertOrder,
|
|
67
|
+
truncateAtTarget,
|
|
68
|
+
} = require('./sequence.js');
|
|
38
69
|
|
|
39
70
|
/** Simultaneous file reads — keeps a large migrations dir clear of EMFILE */
|
|
40
71
|
const FS_CONCURRENCY = 16;
|
|
@@ -49,6 +80,13 @@ const FS_CONCURRENCY = 16;
|
|
|
49
80
|
* logic in the migration's flow; listeners attach from outside, may be several,
|
|
50
81
|
* and a listener that throws is contained rather than failing the run.
|
|
51
82
|
*/
|
|
83
|
+
/**
|
|
84
|
+
* How a lock-wait loop outside the kit (`runMigrations`, the queue processor)
|
|
85
|
+
* reports a finished wait to the kit's telemetry. A symbol, and not exported
|
|
86
|
+
* from the package: the loop is migronaut's own, and so is this channel.
|
|
87
|
+
*/
|
|
88
|
+
const RECORD_LOCK_WAIT = Symbol('migronaut.recordLockWait');
|
|
89
|
+
|
|
52
90
|
class MigratorKit extends EventEmitter {
|
|
53
91
|
#partialConfig;
|
|
54
92
|
#configPath;
|
|
@@ -65,6 +103,10 @@ class MigratorKit extends EventEmitter {
|
|
|
65
103
|
#runSetupDepth = 0;
|
|
66
104
|
/** Correlation id for the run in flight — ties logs, lock and changelog together */
|
|
67
105
|
#runId;
|
|
106
|
+
/** Mints an id in the configured format (`generateId`, else a UUID); set with the config */
|
|
107
|
+
#newId;
|
|
108
|
+
/** Spans and metrics through the injected `telemetry` (a no-op without one); set with the config */
|
|
109
|
+
#telemetry;
|
|
68
110
|
/** Whether changelog indexes have already been ensured on this instance */
|
|
69
111
|
#indexesEnsured = false;
|
|
70
112
|
/** Memoized resolved logger — resolveLogger allocates on every call otherwise */
|
|
@@ -73,8 +115,31 @@ class MigratorKit extends EventEmitter {
|
|
|
73
115
|
#fallbackLogger;
|
|
74
116
|
/** False when the client was injected by the caller, who keeps ownership of it */
|
|
75
117
|
#ownsClient = true;
|
|
118
|
+
/** The connect() in flight, so overlapping callers share one client instead of racing */
|
|
119
|
+
#connecting;
|
|
120
|
+
/**
|
|
121
|
+
* The config load in flight, so overlapping first callers share one — a
|
|
122
|
+
* config factory may fetch secrets, and must not run twice.
|
|
123
|
+
*/
|
|
124
|
+
#configLoading;
|
|
125
|
+
/**
|
|
126
|
+
* Project root this instance resolves against — config discovery, the .env
|
|
127
|
+
* file and a relative migrationsDir. Defaults to process.cwd(); an explicit
|
|
128
|
+
* value is what lets one process host kits for several projects.
|
|
129
|
+
*/
|
|
130
|
+
#cwd;
|
|
76
131
|
/** filepath → {mtimeMs, size, checksum} — spares repeat status()/audit() calls a full re-hash */
|
|
77
132
|
#checksumCache = new Map();
|
|
133
|
+
/** The converge history store, created on first use */
|
|
134
|
+
#convergeLogStore;
|
|
135
|
+
/**
|
|
136
|
+
* Definitions an after-up converge resolved, kept only while `up()` is being
|
|
137
|
+
* refused for a held lock: a caller polling for the lock retries `up()`
|
|
138
|
+
* every few hundred milliseconds, and each retry would otherwise re-import
|
|
139
|
+
* every definition file — under `reloadMigrations`, a module per file per
|
|
140
|
+
* poll that the module cache never frees. Cleared by any other outcome.
|
|
141
|
+
*/
|
|
142
|
+
#upDefinitions;
|
|
78
143
|
|
|
79
144
|
constructor(config = {}, options = {}) {
|
|
80
145
|
super();
|
|
@@ -82,6 +147,7 @@ class MigratorKit extends EventEmitter {
|
|
|
82
147
|
this.#configPath = options.configPath;
|
|
83
148
|
this.#progress = options.progress;
|
|
84
149
|
this.#fallbackLogger = options.fallbackLogger;
|
|
150
|
+
this.#cwd = options.cwd;
|
|
85
151
|
}
|
|
86
152
|
|
|
87
153
|
/**
|
|
@@ -146,18 +212,28 @@ class MigratorKit extends EventEmitter {
|
|
|
146
212
|
}
|
|
147
213
|
}
|
|
148
214
|
|
|
149
|
-
/** Resolve and cache the full configuration */
|
|
215
|
+
/** Resolve and cache the full configuration — once, however many callers ask at once */
|
|
150
216
|
async #ensureConfig(requireDb = true, lenient = false) {
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
217
|
+
if (this.#config) return this.#config;
|
|
218
|
+
this.#configLoading ??= this.#loadConfig(requireDb, lenient).finally(() => {
|
|
219
|
+
this.#configLoading = undefined;
|
|
220
|
+
});
|
|
221
|
+
return this.#configLoading;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #loadConfig(requireDb, lenient) {
|
|
225
|
+
const config = await loadConfig({
|
|
226
|
+
flags: this.#partialConfig,
|
|
227
|
+
requireDb,
|
|
228
|
+
...(lenient ? { lenient: true } : {}),
|
|
229
|
+
...(this.#configPath ? { configPath: this.#configPath } : {}),
|
|
230
|
+
...(this.#cwd ? { cwd: this.#cwd } : {}),
|
|
231
|
+
...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
|
|
232
|
+
});
|
|
233
|
+
this.#newId = createIdGenerator(config.generateId);
|
|
234
|
+
this.#telemetry = createTelemetry(config.telemetry, { dbName: config.dbName });
|
|
235
|
+
this.#config = config;
|
|
236
|
+
return config;
|
|
161
237
|
}
|
|
162
238
|
|
|
163
239
|
get #logger() {
|
|
@@ -205,12 +281,27 @@ class MigratorKit extends EventEmitter {
|
|
|
205
281
|
return this.#runId ? { runId: this.#runId, ...extra } : { ...extra };
|
|
206
282
|
}
|
|
207
283
|
|
|
284
|
+
/** Record a finished wait for the lock — see {@link RECORD_LOCK_WAIT} */
|
|
285
|
+
[RECORD_LOCK_WAIT](wait) {
|
|
286
|
+
this.#telemetry?.lockWaited(wait);
|
|
287
|
+
}
|
|
288
|
+
|
|
208
289
|
/** Connect to MongoDB and ensure changelog indexes exist */
|
|
209
290
|
async connect() {
|
|
210
291
|
const config = await this.#ensureConfig();
|
|
211
292
|
if (this.#client && this.#db) {
|
|
212
293
|
return;
|
|
213
294
|
}
|
|
295
|
+
// A long-lived kit serves overlapping callers (a status probe while a
|
|
296
|
+
// queue job starts). Without this, each would open its own MongoClient
|
|
297
|
+
// and all but the last would leak their pools.
|
|
298
|
+
this.#connecting ??= this.#openConnection(config).finally(() => {
|
|
299
|
+
this.#connecting = undefined;
|
|
300
|
+
});
|
|
301
|
+
return this.#connecting;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async #openConnection(config) {
|
|
214
305
|
const startedAt = Date.now();
|
|
215
306
|
try {
|
|
216
307
|
if (config.client) {
|
|
@@ -232,7 +323,7 @@ class MigratorKit extends EventEmitter {
|
|
|
232
323
|
// Once per instance, not once per connect: re-issuing createIndexes on
|
|
233
324
|
// every command is a wasted round trip. `ensureIndexes: false` skips it
|
|
234
325
|
// entirely, for deployments where the app user cannot create indexes.
|
|
235
|
-
if (!this.#indexesEnsured &&
|
|
326
|
+
if (!this.#indexesEnsured && config.ensureIndexes) {
|
|
236
327
|
await this.#changelog.ensureIndexes(this.#db);
|
|
237
328
|
this.#indexesEnsured = true;
|
|
238
329
|
}
|
|
@@ -307,6 +398,15 @@ class MigratorKit extends EventEmitter {
|
|
|
307
398
|
return new MigrationLock(this.#requireDb(), config.lockCollection, config.lockTTLSeconds);
|
|
308
399
|
}
|
|
309
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The resolved logger with #fields merged into every line, for handing to
|
|
403
|
+
* lock.js — which stays kit-agnostic and cannot stamp runId itself.
|
|
404
|
+
*/
|
|
405
|
+
#lockLogger() {
|
|
406
|
+
const wrap = (method) => (msg, fields) => this.#logger[method](msg, this.#fields(fields ?? {}));
|
|
407
|
+
return { debug: wrap('debug'), info: wrap('info'), warn: wrap('warn'), error: wrap('error') };
|
|
408
|
+
}
|
|
409
|
+
|
|
310
410
|
/**
|
|
311
411
|
* Run `fn` under the migration lock. The single place that pairs a lock with
|
|
312
412
|
* a unit of work, so `redo` can hold one lock across both directions instead
|
|
@@ -324,8 +424,10 @@ class MigratorKit extends EventEmitter {
|
|
|
324
424
|
}
|
|
325
425
|
// One id per run, reused as the lock's owner token and stamped on every
|
|
326
426
|
// changelog record and log line, so the three can be correlated after the
|
|
327
|
-
// fact ("which run left this lock?", "what did run X apply?").
|
|
328
|
-
|
|
427
|
+
// fact ("which run left this lock?", "what did run X apply?"). Minted
|
|
428
|
+
// before any other run state exists: a `generateId` that throws or returns
|
|
429
|
+
// a non-id rejects here, leaving nothing to unwind and no event emitted.
|
|
430
|
+
this.#runId = this.#newId();
|
|
329
431
|
// A second controller layered over the lock's own signal, so stop() and a
|
|
330
432
|
// lost lock abort through the same path the run loops already watch.
|
|
331
433
|
const stopper = new AbortController();
|
|
@@ -340,59 +442,49 @@ class MigratorKit extends EventEmitter {
|
|
|
340
442
|
this.#stopRequested = undefined;
|
|
341
443
|
this.#abort(pending);
|
|
342
444
|
}
|
|
343
|
-
const
|
|
344
|
-
|
|
445
|
+
const recorder = new RunRecorder({
|
|
446
|
+
info,
|
|
447
|
+
runId: this.#runId,
|
|
448
|
+
telemetry: this.#telemetry,
|
|
449
|
+
emit: (event, payload) => this.#emit(event, payload),
|
|
450
|
+
logger: this.#logger,
|
|
451
|
+
fields: (extra) => this.#fields(extra),
|
|
452
|
+
});
|
|
453
|
+
recorder.start();
|
|
345
454
|
let failure;
|
|
346
455
|
let result;
|
|
347
456
|
try {
|
|
348
457
|
result = await runWithLock(
|
|
349
458
|
this.#buildLock(),
|
|
350
459
|
{
|
|
351
|
-
|
|
352
|
-
|
|
460
|
+
// Wrapped so every lock line carries #fields (runId included) — a
|
|
461
|
+
// JSON-sink operator must be able to join a lock-lost alert to the
|
|
462
|
+
// run's migration lines and changelog records without a log parse.
|
|
463
|
+
logger: this.#lockLogger(),
|
|
464
|
+
onLockLost: this.#config.onLockLost,
|
|
353
465
|
owner: this.#runId,
|
|
354
|
-
onLockAcquired: (extra) =>
|
|
355
|
-
onLockReleased: (extra) =>
|
|
356
|
-
onLockLostEvent: (reason) =>
|
|
466
|
+
onLockAcquired: (extra) => recorder.lockAcquired(extra),
|
|
467
|
+
onLockReleased: (extra) => recorder.lockReleased(extra),
|
|
468
|
+
onLockLostEvent: (reason) => recorder.lockLost(reason),
|
|
357
469
|
...(options.noLock ? { noLock: true } : {}),
|
|
358
470
|
},
|
|
359
|
-
(lockSignal) =>
|
|
471
|
+
(lockSignal) =>
|
|
472
|
+
// The run's span exists only once the lock is held: a caller polling
|
|
473
|
+
// for a busy lock retries the whole run every few hundred
|
|
474
|
+
// milliseconds, and a span per refusal would bury the one run that
|
|
475
|
+
// did the work. Active around the unit of work only, and ended by the
|
|
476
|
+
// recorder after the release, so the span's outcome is the run's.
|
|
477
|
+
this.#telemetry.open(SPANS.RUN, recorder.spanAttributes(), (span) => {
|
|
478
|
+
recorder.spanOpened(span);
|
|
479
|
+
return fn(AbortSignal.any([lockSignal, stopper.signal]));
|
|
480
|
+
}),
|
|
360
481
|
);
|
|
361
482
|
return result;
|
|
362
483
|
} catch (error) {
|
|
363
484
|
failure = error;
|
|
364
485
|
throw error;
|
|
365
486
|
} finally {
|
|
366
|
-
|
|
367
|
-
// without reconstructing it from per-migration events. On the failure
|
|
368
|
-
// path the partial rows live on the error's context — exactly the case
|
|
369
|
-
// where "how far did it get?" is the question, so they count too. One
|
|
370
|
-
// pass fills both counters.
|
|
371
|
-
const rows = Array.isArray(result)
|
|
372
|
-
? result
|
|
373
|
-
: failure instanceof MigronautError && Array.isArray(failure.context?.results)
|
|
374
|
-
? failure.context.results
|
|
375
|
-
: null;
|
|
376
|
-
let summary = {};
|
|
377
|
-
if (rows) {
|
|
378
|
-
let applied = 0;
|
|
379
|
-
let reverted = 0;
|
|
380
|
-
for (const row of rows) {
|
|
381
|
-
if (row.status === 'applied') applied += 1;
|
|
382
|
-
else if (row.status === 'reverted') reverted += 1;
|
|
383
|
-
}
|
|
384
|
-
summary = { applied, reverted, total: rows.length };
|
|
385
|
-
}
|
|
386
|
-
this.#emit('run:end', {
|
|
387
|
-
...info,
|
|
388
|
-
success: failure === undefined,
|
|
389
|
-
durationMs: Date.now() - startedAt,
|
|
390
|
-
...summary,
|
|
391
|
-
// A raw Error here would hand subscribers an unredacted driver message
|
|
392
|
-
// (which can echo the credentialed URI) — errorText is the same
|
|
393
|
-
// chokepoint every log line and result row already goes through.
|
|
394
|
-
...(failure ? { error: errorText(failure) } : {}),
|
|
395
|
-
});
|
|
487
|
+
recorder.finish(result, failure);
|
|
396
488
|
this.#abort = undefined;
|
|
397
489
|
this.#runId = undefined;
|
|
398
490
|
}
|
|
@@ -441,6 +533,30 @@ class MigratorKit extends EventEmitter {
|
|
|
441
533
|
return toLockInfo(await this.#buildLock().forceRelease());
|
|
442
534
|
}
|
|
443
535
|
|
|
536
|
+
/**
|
|
537
|
+
* The batch number the next `up` would use — a peek, not a reservation: two
|
|
538
|
+
* callers asking before either applies get the same number. Counts reverted
|
|
539
|
+
* and failed records too, so a rolled-back number is never handed out again.
|
|
540
|
+
* Pair it with `up(name, { batch })` to stamp several single-file runs as one
|
|
541
|
+
* batch.
|
|
542
|
+
*/
|
|
543
|
+
async nextBatch() {
|
|
544
|
+
await this.#ensureConfig();
|
|
545
|
+
await this.connect();
|
|
546
|
+
return this.#nextBatch();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* A new id in the format this kit is configured with — the `generateId`
|
|
551
|
+
* option, else a random UUID. It is what every run id comes from; exposed so
|
|
552
|
+
* a layer above the kit (the queue adapter's group ids) mints its own ids in
|
|
553
|
+
* the same format without being configured a second time. Does not connect.
|
|
554
|
+
*/
|
|
555
|
+
async generateId() {
|
|
556
|
+
await this.#ensureConfig();
|
|
557
|
+
return this.#newId();
|
|
558
|
+
}
|
|
559
|
+
|
|
444
560
|
/** Internal accessors that assume a successful connect() */
|
|
445
561
|
#requireDb() {
|
|
446
562
|
if (!this.#db) {
|
|
@@ -457,20 +573,9 @@ class MigratorKit extends EventEmitter {
|
|
|
457
573
|
}
|
|
458
574
|
|
|
459
575
|
#migrationsPath() {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Reject non-string filenames before they reach a changelog query or a path
|
|
465
|
-
* join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
|
|
466
|
-
* become a query-operator injection in `findOne({ name })`.
|
|
467
|
-
*/
|
|
468
|
-
#assertFilename(filename) {
|
|
469
|
-
if (filename !== undefined && typeof filename !== 'string') {
|
|
470
|
-
throw new MigrationInvalidNameError('Migration name must be a string', {
|
|
471
|
-
name: filename,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
576
|
+
// No value fallback: DEFAULT_CONFIG always supplies migrationsDir, and a
|
|
577
|
+
// silent './migrations' here would mask a config-resolution regression.
|
|
578
|
+
return path.resolve(this.#cwd ?? process.cwd(), this.#config.migrationsDir);
|
|
474
579
|
}
|
|
475
580
|
|
|
476
581
|
/**
|
|
@@ -484,20 +589,7 @@ class MigratorKit extends EventEmitter {
|
|
|
484
589
|
*/
|
|
485
590
|
#filepath(name) {
|
|
486
591
|
const dir = this.#migrationsPath();
|
|
487
|
-
|
|
488
|
-
typeof name !== 'string' ||
|
|
489
|
-
name.length === 0 ||
|
|
490
|
-
name === '.' ||
|
|
491
|
-
name === '..' ||
|
|
492
|
-
name.includes('/') ||
|
|
493
|
-
name.includes('\\') ||
|
|
494
|
-
name.includes('\0')
|
|
495
|
-
) {
|
|
496
|
-
throw new MigrationInvalidNameError(
|
|
497
|
-
'Invalid migration name — must be a bare filename with no path segments',
|
|
498
|
-
{ name },
|
|
499
|
-
);
|
|
500
|
-
}
|
|
592
|
+
assertMigrationName(name);
|
|
501
593
|
const resolved = path.join(dir, name);
|
|
502
594
|
const relative = path.relative(dir, resolved);
|
|
503
595
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
@@ -510,32 +602,8 @@ class MigratorKit extends EventEmitter {
|
|
|
510
602
|
|
|
511
603
|
/** List migration files on disk, sorted ascending */
|
|
512
604
|
async #listMigrationFiles() {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
let entries;
|
|
516
|
-
try {
|
|
517
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
518
|
-
} catch (error) {
|
|
519
|
-
if (error.code === 'ENOENT') return [];
|
|
520
|
-
throw error;
|
|
521
|
-
}
|
|
522
|
-
const matches = [];
|
|
523
|
-
for (const entry of entries) {
|
|
524
|
-
// A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
|
|
525
|
-
// to the migrations is not a migration — including it would hard-fail
|
|
526
|
-
// the whole run with MigrationInvalidExportError.
|
|
527
|
-
if (!entry.isFile()) continue;
|
|
528
|
-
const file = entry.name;
|
|
529
|
-
if (file.startsWith('.')) continue;
|
|
530
|
-
if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
|
|
531
|
-
for (const ext of extensions) {
|
|
532
|
-
if (file.endsWith(ext)) {
|
|
533
|
-
matches.push(file);
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return matches.sort();
|
|
605
|
+
// No value fallback — DEFAULT_CONFIG always supplies fileExtensions.
|
|
606
|
+
return listMigrationFiles(this.#migrationsPath(), this.#config.fileExtensions);
|
|
539
607
|
}
|
|
540
608
|
|
|
541
609
|
/** Compute the next batch number (monotonic across the full history) */
|
|
@@ -544,78 +612,105 @@ class MigratorKit extends EventEmitter {
|
|
|
544
612
|
}
|
|
545
613
|
|
|
546
614
|
/**
|
|
547
|
-
*
|
|
548
|
-
*
|
|
615
|
+
* `ordered` guard for a single-file `up`: refuse while an earlier file on
|
|
616
|
+
* disk is still pending. Pending means "no applied record" — a `'failed'`
|
|
617
|
+
* trace counts, so a migration that failed stops the line exactly like one
|
|
618
|
+
* that never ran. Checked inside the lock and before `beforeAll`, so a
|
|
619
|
+
* blocked run fires no hooks and consumes no batch number.
|
|
549
620
|
*/
|
|
550
|
-
#
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
if (!Number.isInteger(steps) || steps < 1) {
|
|
561
|
-
throw new ConfigInvalidError('--steps must be a positive integer', { steps });
|
|
562
|
-
}
|
|
621
|
+
async #assertUpNotBlocked(name, appliedNames, sequence) {
|
|
622
|
+
const files = sequence ?? (await this.#listMigrationFiles());
|
|
623
|
+
const blockedBy = pendingIn(files, appliedNames, name);
|
|
624
|
+
if (blockedBy.length === 0) return;
|
|
625
|
+
throw blockedError(name, 'earlier migration(s) still pending', {
|
|
626
|
+
name,
|
|
627
|
+
direction: 'up',
|
|
628
|
+
blockedBy,
|
|
629
|
+
failed: await this.#failedAmong(blockedBy),
|
|
630
|
+
});
|
|
563
631
|
}
|
|
564
632
|
|
|
565
633
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* nothing before it is pending either, and the result is empty), which is
|
|
570
|
-
* what makes `up --to X` idempotent — running it twice is a no-op rather
|
|
571
|
-
* than an error.
|
|
634
|
+
* The blockers that failed — a stopped line — as opposed to ones that have
|
|
635
|
+
* simply not run yet: with several queue workers, an earlier job may still be
|
|
636
|
+
* in flight elsewhere, and waiting for it is the right reaction.
|
|
572
637
|
*/
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
638
|
+
async #failedAmong(names) {
|
|
639
|
+
return this.#requireChangelog().getFailedNames(this.#requireDb(), names);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* `ordered` guard for a single-file `down`: refuse while a migration applied
|
|
644
|
+
* *after* this one is still applied. "After" is chronological (`appliedAt`),
|
|
645
|
+
* not alphabetical — the order `down --steps` reverts in, and the only one
|
|
646
|
+
* that undoes effects in reverse of how they were made. Name order would
|
|
647
|
+
* deadlock a batch holding a file merged late from a parallel branch.
|
|
648
|
+
*/
|
|
649
|
+
async #assertDownNotBlocked(record) {
|
|
650
|
+
const newer = await this.#requireChangelog().getAppliedNewerThan(this.#requireDb(), record);
|
|
651
|
+
if (newer.length === 0) return;
|
|
652
|
+
const blockedBy = [];
|
|
653
|
+
for (const later of newer) blockedBy.push(later.name);
|
|
654
|
+
throw blockedError(record.name, 'later migration(s) still applied', {
|
|
655
|
+
name: record.name,
|
|
656
|
+
direction: 'down',
|
|
657
|
+
blockedBy,
|
|
658
|
+
// A rollback has no failed trace to tell a stopped line apart.
|
|
659
|
+
failed: [],
|
|
660
|
+
});
|
|
583
661
|
}
|
|
584
662
|
|
|
585
663
|
/**
|
|
586
|
-
*
|
|
587
|
-
*
|
|
664
|
+
* Resolve which files an `up` (or its dry-run) targets: a named file (which
|
|
665
|
+
* must exist on disk), or every pending file, optionally truncated at `--to`.
|
|
666
|
+
* The single source of truth for that selection — mirroring
|
|
667
|
+
* {@link #selectDownTargets} — so the preview can never disagree with the
|
|
668
|
+
* real run about what would be applied.
|
|
588
669
|
*/
|
|
589
|
-
#
|
|
590
|
-
if (to === undefined) return;
|
|
591
|
-
this.#assertFilename(to);
|
|
670
|
+
async #selectUpTargets(filename, options, appliedNames) {
|
|
592
671
|
if (filename) {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
if (options.batch !== undefined) {
|
|
602
|
-
throw new ConfigInvalidError('Cannot combine --batch with --to', {
|
|
603
|
-
batch: options.batch,
|
|
604
|
-
to,
|
|
605
|
-
});
|
|
672
|
+
const filepath = this.#filepath(filename);
|
|
673
|
+
try {
|
|
674
|
+
await fs.access(filepath);
|
|
675
|
+
} catch {
|
|
676
|
+
throw new MigrationFileNotFoundError('Migration file not found', { filename });
|
|
677
|
+
}
|
|
678
|
+
return [filename];
|
|
606
679
|
}
|
|
680
|
+
const files = await this.#listMigrationFiles();
|
|
681
|
+
const targets = pendingIn(files, appliedNames);
|
|
682
|
+
return options.to !== undefined ? truncateAtTarget(targets, files, options.to) : targets;
|
|
607
683
|
}
|
|
608
684
|
|
|
609
685
|
/**
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
686
|
+
* Detect out-of-order arrivals: a pending target that sorts before the
|
|
687
|
+
* newest applied name is a migration merged late from a parallel branch — it
|
|
688
|
+
* will run after migrations authored later, so environments migrated at
|
|
689
|
+
* different times end up with different effective orders, silently.
|
|
690
|
+
* `onOutOfOrder` decides the reaction: 'warn' (default) logs and continues,
|
|
691
|
+
* 'error' refuses the run, 'allow' disables the check. Only a bulk `up`
|
|
692
|
+
* carries the full applied set; a single-file `up` (an explicit, deliberate
|
|
693
|
+
* target) is exempt by construction, since its applied set holds at most
|
|
694
|
+
* that file.
|
|
613
695
|
*/
|
|
614
|
-
#
|
|
615
|
-
|
|
616
|
-
if (
|
|
617
|
-
|
|
696
|
+
#assertOrderIntact(targets, appliedNames) {
|
|
697
|
+
const policy = this.#config?.onOutOfOrder ?? 'warn';
|
|
698
|
+
if (policy === 'allow') return;
|
|
699
|
+
const arrivals = lateArrivals(targets, appliedNames);
|
|
700
|
+
if (arrivals === null) return;
|
|
701
|
+
const { late, newestApplied } = arrivals;
|
|
702
|
+
if (policy === 'error') {
|
|
703
|
+
throw new OutOfOrderMigrationError(
|
|
704
|
+
`${late.length} pending migration(s) sort before the newest applied one ` +
|
|
705
|
+
`(${newestApplied}): ${late.join(', ')} — apply deliberately with onOutOfOrder: 'warn' or 'allow'`,
|
|
706
|
+
{ names: late, newestApplied },
|
|
707
|
+
);
|
|
618
708
|
}
|
|
709
|
+
this.#logger.warn(
|
|
710
|
+
`⚠ Out-of-order: ${late.length} pending migration(s) sort before the newest applied one ` +
|
|
711
|
+
`(${newestApplied}): ${late.join(', ')}`,
|
|
712
|
+
this.#fields({ event: 'migrations:out-of-order', names: late, newestApplied }),
|
|
713
|
+
);
|
|
619
714
|
}
|
|
620
715
|
|
|
621
716
|
/**
|
|
@@ -640,6 +735,11 @@ class MigratorKit extends EventEmitter {
|
|
|
640
735
|
}
|
|
641
736
|
} catch (error) {
|
|
642
737
|
failure = error;
|
|
738
|
+
// Failures before the migration body — beforeEach, a file that fails to
|
|
739
|
+
// load — bypass #executeMigration's catch, so the partial results must be
|
|
740
|
+
// attached here too or a --json consumer loses the applied-so-far list
|
|
741
|
+
// exactly when it matters. Copy-on-write makes a re-attach harmless.
|
|
742
|
+
this.#attachResults(error, results);
|
|
643
743
|
}
|
|
644
744
|
const succeeded = failure === undefined;
|
|
645
745
|
// afterAll runs on the failure path too — which is exactly when a
|
|
@@ -663,13 +763,57 @@ class MigratorKit extends EventEmitter {
|
|
|
663
763
|
return results;
|
|
664
764
|
}
|
|
665
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Execute one migration under its own span, and time it for the meter.
|
|
768
|
+
*
|
|
769
|
+
* The span is the *active* one for everything the migration does — hooks,
|
|
770
|
+
* the file's own `up`/`down`, the changelog write — which is what lets an
|
|
771
|
+
* instrumented driver hang its command spans under the migration that issued
|
|
772
|
+
* them. A listener on `migration:start` could open a span but never make it
|
|
773
|
+
* active, so this is the one thing telemetry needs from inside the kit.
|
|
774
|
+
*/
|
|
775
|
+
async #executeMigration(step) {
|
|
776
|
+
const { name, direction, index, total, batch } = step;
|
|
777
|
+
const telemetry = this.#telemetry;
|
|
778
|
+
const startedAt = Date.now();
|
|
779
|
+
try {
|
|
780
|
+
const duration = await telemetry.wrap(
|
|
781
|
+
SPANS.MIGRATION,
|
|
782
|
+
{
|
|
783
|
+
[ATTRIBUTES.MIGRATION_NAME]: name,
|
|
784
|
+
[ATTRIBUTES.MIGRATION_DIRECTION]: direction,
|
|
785
|
+
[ATTRIBUTES.MIGRATION_BATCH]: batch,
|
|
786
|
+
[ATTRIBUTES.MIGRATION_INDEX]: index,
|
|
787
|
+
[ATTRIBUTES.MIGRATION_TOTAL]: total,
|
|
788
|
+
[ATTRIBUTES.RUN_ID]: this.#runId,
|
|
789
|
+
},
|
|
790
|
+
(span) => this.#executeMigrationSteps(step, span),
|
|
791
|
+
);
|
|
792
|
+
telemetry.migrationEnded({ direction, durationMs: duration });
|
|
793
|
+
return duration;
|
|
794
|
+
} catch (error) {
|
|
795
|
+
// The runner's own measurement when it got as far as the body; the
|
|
796
|
+
// elapsed time here otherwise (a failing beforeEach, a file that does
|
|
797
|
+
// not load) — a failure deserves a data point as much as a success.
|
|
798
|
+
const durationMs =
|
|
799
|
+
error instanceof MigronautError && typeof error.context?.durationMs === 'number'
|
|
800
|
+
? error.context.durationMs
|
|
801
|
+
: Date.now() - startedAt;
|
|
802
|
+
telemetry.migrationEnded({ direction, durationMs, error });
|
|
803
|
+
throw error;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
666
807
|
/**
|
|
667
808
|
* Execute one migration end to end: beforeEach → load → run (with the
|
|
668
809
|
* changelog write inside the transaction via `onSuccess`) → events, logs,
|
|
669
810
|
* result row, afterEach — and the mirrored error path. Shared verbatim by
|
|
670
811
|
* `up` and `down`, so a fix to one direction cannot silently miss the other.
|
|
671
812
|
*/
|
|
672
|
-
async #
|
|
813
|
+
async #executeMigrationSteps(
|
|
814
|
+
{ name, direction, context, index, total, results, batch, onSuccess, failureFields },
|
|
815
|
+
span,
|
|
816
|
+
) {
|
|
673
817
|
const config = this.#config;
|
|
674
818
|
const logger = this.#logger;
|
|
675
819
|
const batchField = batch !== undefined ? { batch } : {};
|
|
@@ -680,9 +824,10 @@ class MigratorKit extends EventEmitter {
|
|
|
680
824
|
{ direction, index, total },
|
|
681
825
|
]);
|
|
682
826
|
const migration = await loadMigrationFile(this.#filepath(name), {
|
|
683
|
-
reload: config.reloadMigrations
|
|
827
|
+
reload: config.reloadMigrations,
|
|
684
828
|
});
|
|
685
829
|
const useTransaction = migration.useTransaction ?? config.useTransaction;
|
|
830
|
+
span.set({ [ATTRIBUTES.MIGRATION_TRANSACTION]: useTransaction });
|
|
686
831
|
|
|
687
832
|
this.#progress?.onStart(name, direction);
|
|
688
833
|
this.#emit('migration:start', { migration: name, direction, ...batchField });
|
|
@@ -727,15 +872,63 @@ class MigratorKit extends EventEmitter {
|
|
|
727
872
|
return duration;
|
|
728
873
|
} catch (error) {
|
|
729
874
|
this.#progress?.onStop('error');
|
|
875
|
+
// The runner measures how long the failing attempt ran and leaves it on
|
|
876
|
+
// the error's context — thread it through, so failures carry timing data
|
|
877
|
+
// the same way successes do (a slow-then-failing migration is exactly
|
|
878
|
+
// what a metrics subscriber alerts on).
|
|
879
|
+
const durationMs =
|
|
880
|
+
error instanceof MigronautError && typeof error.context?.durationMs === 'number'
|
|
881
|
+
? error.context.durationMs
|
|
882
|
+
: undefined;
|
|
883
|
+
const durationField = durationMs !== undefined ? { durationMs } : {};
|
|
730
884
|
// errorText, not the raw Error: a driver message can echo the
|
|
731
885
|
// credentialed URI, and event subscribers (Sentry, JSON logs) would
|
|
732
886
|
// ship it — the same redaction the log line below already gets.
|
|
733
|
-
this.#emit('migration:error', {
|
|
887
|
+
this.#emit('migration:error', {
|
|
888
|
+
migration: name,
|
|
889
|
+
direction,
|
|
890
|
+
...batchField,
|
|
891
|
+
...durationField,
|
|
892
|
+
error: errorText(error),
|
|
893
|
+
});
|
|
734
894
|
logger.error(
|
|
735
895
|
`✖ Error ${name}`,
|
|
736
|
-
this.#fields({
|
|
896
|
+
this.#fields({
|
|
897
|
+
migration: name,
|
|
898
|
+
direction,
|
|
899
|
+
...batchField,
|
|
900
|
+
...durationField,
|
|
901
|
+
error: errorText(error),
|
|
902
|
+
}),
|
|
737
903
|
);
|
|
738
|
-
results.push({
|
|
904
|
+
results.push({
|
|
905
|
+
file: name,
|
|
906
|
+
status: 'error',
|
|
907
|
+
...(durationMs !== undefined ? { duration: durationMs } : {}),
|
|
908
|
+
error: errorText(error),
|
|
909
|
+
});
|
|
910
|
+
// Best-effort DB-side trace of the failed attempt (up only — marking a
|
|
911
|
+
// failed `down` would demote a record that is still truthfully applied).
|
|
912
|
+
// Swallowed on its own failure: the changelog may be the thing that is
|
|
913
|
+
// down, and this trace must never mask the migration's real error.
|
|
914
|
+
if (direction === 'up') {
|
|
915
|
+
try {
|
|
916
|
+
await this.#requireChangelog().markFailed(this.#requireDb(), {
|
|
917
|
+
name,
|
|
918
|
+
// Which migration failed, and why.
|
|
919
|
+
error: errorWithCause(error),
|
|
920
|
+
environment: this.#environment(),
|
|
921
|
+
executedBy: safeUsername(),
|
|
922
|
+
...batchField,
|
|
923
|
+
...(durationMs !== undefined ? { duration: durationMs } : {}),
|
|
924
|
+
...(this.#runId ? { runId: this.#runId } : {}),
|
|
925
|
+
...failureFields,
|
|
926
|
+
});
|
|
927
|
+
} catch {
|
|
928
|
+
// Duplicate key when an 'applied' record exists (forced re-run), or
|
|
929
|
+
// the database itself is unreachable — the trace is best-effort.
|
|
930
|
+
}
|
|
931
|
+
}
|
|
739
932
|
// Carry what already succeeded, so `--json` consumers can tell which
|
|
740
933
|
// migrations landed before the failure instead of losing the list.
|
|
741
934
|
this.#attachResults(error, results);
|
|
@@ -745,17 +938,65 @@ class MigratorKit extends EventEmitter {
|
|
|
745
938
|
|
|
746
939
|
/** Run all pending migrations, or a specific named file */
|
|
747
940
|
async up(filename, options = {}) {
|
|
748
|
-
|
|
749
|
-
this.#assertToValid(options.to, filename, options);
|
|
941
|
+
assertUpOptions(filename, options);
|
|
750
942
|
return this.#runWindow(async () => {
|
|
751
|
-
await this.#ensureConfig();
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
)
|
|
943
|
+
const config = await this.#ensureConfig();
|
|
944
|
+
// Converge only after a run that brings the database to the head: the
|
|
945
|
+
// declared end state describes the newest schema, and a unique index
|
|
946
|
+
// may well depend on a dedupe migration a `--to` run stops short of.
|
|
947
|
+
// A single-file run is one step of a sequence (a queue job), never its end.
|
|
948
|
+
const converge = filename === undefined && (options.converge ?? config.convergeAfterUp);
|
|
949
|
+
if (converge && options.to !== undefined) {
|
|
950
|
+
this.#logger.info(
|
|
951
|
+
'Converge after up skipped: --to stops short of the newest migration',
|
|
952
|
+
this.#fields({ command: 'up', to: options.to }),
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
// Resolved before the lock: a definition file that does not load fails
|
|
956
|
+
// the run before any migration is applied, not after.
|
|
957
|
+
const definitions =
|
|
958
|
+
converge && options.to === undefined
|
|
959
|
+
? (this.#upDefinitions ??= await this.#resolveCollections())
|
|
960
|
+
: [];
|
|
961
|
+
return this.#keepDefinitionsWhileRefused(async () => {
|
|
962
|
+
await this.connect();
|
|
963
|
+
return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal) => {
|
|
964
|
+
const results = await this.#runUp(filename, options, signal);
|
|
965
|
+
// Even when nothing was pending: a converge that failed last time is
|
|
966
|
+
// retried by the next `up` instead of waiting for the next migration.
|
|
967
|
+
if (definitions.length > 0) {
|
|
968
|
+
this.#assertNotAborted(signal, results);
|
|
969
|
+
try {
|
|
970
|
+
await runConverge(
|
|
971
|
+
this.#convergeDeps(),
|
|
972
|
+
{ definitions, trigger: 'up', ...pickActor(options) },
|
|
973
|
+
signal,
|
|
974
|
+
);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
// The migrations are applied and recorded either way — they must
|
|
977
|
+
// survive into what a `--json` consumer sees about the failure.
|
|
978
|
+
this.#attachResults(error, results);
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return results;
|
|
983
|
+
});
|
|
984
|
+
});
|
|
756
985
|
});
|
|
757
986
|
}
|
|
758
987
|
|
|
988
|
+
/** Run `fn`, keeping the cached after-up definitions only when it is refused for the lock */
|
|
989
|
+
async #keepDefinitionsWhileRefused(fn) {
|
|
990
|
+
try {
|
|
991
|
+
const result = await fn();
|
|
992
|
+
this.#upDefinitions = undefined;
|
|
993
|
+
return result;
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (!(error instanceof LockAlreadyHeldError)) this.#upDefinitions = undefined;
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
759
1000
|
async #runUp(filename, options = {}, signal) {
|
|
760
1001
|
const force = options.force ?? false;
|
|
761
1002
|
const config = this.#config;
|
|
@@ -763,47 +1004,71 @@ class MigratorKit extends EventEmitter {
|
|
|
763
1004
|
const changelog = this.#requireChangelog();
|
|
764
1005
|
const logger = this.#logger;
|
|
765
1006
|
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
1007
|
+
// An `ordered` single-file run is one step of a sequence someone else is
|
|
1008
|
+
// driving (a queue job), so it must uphold what a bulk run would: it reads
|
|
1009
|
+
// the full applied set, and with it the strict drift check and the
|
|
1010
|
+
// out-of-order policy apply — otherwise splitting a run into jobs would
|
|
1011
|
+
// silently drop both.
|
|
1012
|
+
const ordered = filename !== undefined && options.ordered === true;
|
|
1013
|
+
const fullSet = !filename || ordered;
|
|
1014
|
+
// A strict full-set run needs the applied records' checksums anyway, so
|
|
1015
|
+
// fetch full records once and derive the name set from them; a plain
|
|
1016
|
+
// single-file run needs only that file's record, so one getByName is both
|
|
1017
|
+
// the applied-check and the checksum source; otherwise the cheaper covered
|
|
770
1018
|
// name query suffices.
|
|
771
|
-
const strictBulk =
|
|
1019
|
+
const strictBulk = fullSet && config.strict && !force;
|
|
772
1020
|
const appliedRecords = strictBulk ? await changelog.getApplied(db) : undefined;
|
|
773
1021
|
const appliedNames = new Set();
|
|
774
1022
|
let singleRecord = null;
|
|
775
1023
|
if (filename) {
|
|
776
1024
|
singleRecord = await changelog.getByName(db, filename);
|
|
777
1025
|
if (singleRecord?.status === 'applied') appliedNames.add(filename);
|
|
778
|
-
} else if (appliedRecords) {
|
|
779
|
-
for (const record of appliedRecords) appliedNames.add(record.name);
|
|
780
|
-
} else {
|
|
781
|
-
for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
|
|
782
1026
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
await fs.access(filepath);
|
|
789
|
-
} catch {
|
|
790
|
-
throw new MigrationFileNotFoundError('Migration file not found', { filename });
|
|
791
|
-
}
|
|
792
|
-
targets = [filename];
|
|
793
|
-
} else {
|
|
794
|
-
const files = await this.#listMigrationFiles();
|
|
795
|
-
targets = [];
|
|
796
|
-
for (const file of files) {
|
|
797
|
-
if (!appliedNames.has(file)) targets.push(file);
|
|
1027
|
+
if (fullSet) {
|
|
1028
|
+
if (appliedRecords) {
|
|
1029
|
+
for (const record of appliedRecords) appliedNames.add(record.name);
|
|
1030
|
+
} else {
|
|
1031
|
+
for (const name of await changelog.getAppliedNames(db)) appliedNames.add(name);
|
|
798
1032
|
}
|
|
799
|
-
|
|
800
|
-
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const targets = await this.#selectUpTargets(filename, options, appliedNames);
|
|
1036
|
+
// Pending files are the only bulk targets, so the per-target checksum
|
|
1037
|
+
// check below can never see an applied one. Verify them up front instead,
|
|
1038
|
+
// otherwise `up --strict` over a bulk run would police nothing.
|
|
1039
|
+
if (strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
|
|
1040
|
+
// An ordered run is a step of the sequence, so its target must be a file
|
|
1041
|
+
// of the sequence: an existing dotfile, declaration file or helper module
|
|
1042
|
+
// next to the migrations is never one — and importing it would run its
|
|
1043
|
+
// top-level code. The name may come from a queue payload; a plain
|
|
1044
|
+
// single-file `up` keeps accepting any file it is pointed at.
|
|
1045
|
+
const sequence = ordered ? await this.#listMigrationFiles() : undefined;
|
|
1046
|
+
if (ordered && !sequence.includes(filename)) {
|
|
1047
|
+
throw new MigrationFileNotFoundError(
|
|
1048
|
+
'Not a migration of the sequence — a dotfile, a declaration file or an extension ' +
|
|
1049
|
+
'outside fileExtensions',
|
|
1050
|
+
{ filename },
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
// An already-applied target is exempt (unless forced): a duplicate job
|
|
1054
|
+
// for it must report the usual "skipped", not a failure.
|
|
1055
|
+
if (ordered && (force || !appliedNames.has(filename))) {
|
|
1056
|
+
await this.#assertUpNotBlocked(filename, appliedNames, sequence);
|
|
1057
|
+
}
|
|
1058
|
+
// The file the caller planned with (a queue job's checksum) must be the
|
|
1059
|
+
// one on this disk: a worker from an older deploy would otherwise apply an
|
|
1060
|
+
// older version of an edited pending file, and record its checksum.
|
|
1061
|
+
if (options.checksum !== undefined && (force || !appliedNames.has(filename))) {
|
|
1062
|
+
const actual = await computeChecksum(this.#filepath(filename));
|
|
1063
|
+
if (actual !== options.checksum) {
|
|
1064
|
+
throw new ChecksumMismatchError(
|
|
1065
|
+
`${filename} is not the file this run was planned with — this process has another ` +
|
|
1066
|
+
'version of it (roll workers out before the producers that enqueue)',
|
|
1067
|
+
{ name: filename, expected: options.checksum, actual, planned: true },
|
|
1068
|
+
);
|
|
801
1069
|
}
|
|
802
|
-
// Pending files are the only targets here, so the per-target checksum
|
|
803
|
-
// check below can never see an applied one. Verify them up front instead,
|
|
804
|
-
// otherwise `up --strict` over a bulk run would police nothing.
|
|
805
|
-
if (strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
|
|
806
1070
|
}
|
|
1071
|
+
this.#assertOrderIntact(targets, appliedNames);
|
|
807
1072
|
|
|
808
1073
|
if (targets.length === 0) {
|
|
809
1074
|
logger.info('Nothing to migrate', this.#fields({ direction: 'up' }));
|
|
@@ -814,8 +1079,10 @@ class MigratorKit extends EventEmitter {
|
|
|
814
1079
|
// Without --step every file in this run shares one batch. With --step each
|
|
815
1080
|
// applied file gets its own sequential batch (base, base+1, …) so a later
|
|
816
1081
|
// `down` can revert them individually. Only successful applies advance the
|
|
817
|
-
// counter, so --step never leaves gaps.
|
|
818
|
-
|
|
1082
|
+
// counter, so --step never leaves gaps. An explicit `batch` is a label the
|
|
1083
|
+
// caller chose — it may equal one already in use, which is how several
|
|
1084
|
+
// single-file runs end up as one rollback unit.
|
|
1085
|
+
const baseBatch = options.batch ?? (await this.#nextBatch());
|
|
819
1086
|
let appliedCount = 0;
|
|
820
1087
|
|
|
821
1088
|
return this.#runSequence({
|
|
@@ -874,6 +1141,8 @@ class MigratorKit extends EventEmitter {
|
|
|
874
1141
|
total: targets.length,
|
|
875
1142
|
results,
|
|
876
1143
|
batch,
|
|
1144
|
+
// No appliedAt: the changelog stamps it in server time, so the
|
|
1145
|
+
// revert-selection sorts are immune to this host's clock skew.
|
|
877
1146
|
onSuccess: (migration, elapsed, session) =>
|
|
878
1147
|
changelog.markApplied(
|
|
879
1148
|
db,
|
|
@@ -881,16 +1150,20 @@ class MigratorKit extends EventEmitter {
|
|
|
881
1150
|
name,
|
|
882
1151
|
batch,
|
|
883
1152
|
status: 'applied',
|
|
884
|
-
appliedAt: new Date(),
|
|
885
1153
|
checksum,
|
|
886
1154
|
environment: this.#environment(),
|
|
887
1155
|
executedBy: safeUsername(),
|
|
888
1156
|
duration: elapsed,
|
|
889
1157
|
...(this.#runId ? { runId: this.#runId } : {}),
|
|
890
1158
|
...(migration.description ? { description: migration.description } : {}),
|
|
1159
|
+
...actorFields(options),
|
|
891
1160
|
},
|
|
892
1161
|
session,
|
|
893
1162
|
),
|
|
1163
|
+
// What a failed attempt's trace records besides the failure: the
|
|
1164
|
+
// version of the file that failed (a breaker compares it), and who
|
|
1165
|
+
// asked for the run.
|
|
1166
|
+
failureFields: { checksum, ...actorFields(options) },
|
|
894
1167
|
});
|
|
895
1168
|
appliedCount += 1;
|
|
896
1169
|
return 'done';
|
|
@@ -910,7 +1183,10 @@ class MigratorKit extends EventEmitter {
|
|
|
910
1183
|
const filepath = this.#filepath(record.name);
|
|
911
1184
|
let actual;
|
|
912
1185
|
try {
|
|
913
|
-
|
|
1186
|
+
// Through the instance cache: a long-lived process running strict ups
|
|
1187
|
+
// repeatedly must not re-hash the whole applied history every time —
|
|
1188
|
+
// the same mtime+size trust status() already applies to this verdict.
|
|
1189
|
+
actual = await this.#cachedChecksum(filepath);
|
|
914
1190
|
} catch (error) {
|
|
915
1191
|
// A deleted file has no checksum to compare; status() reports it as
|
|
916
1192
|
// missing, which is a separate concern from drift. Hashing directly
|
|
@@ -948,10 +1224,7 @@ class MigratorKit extends EventEmitter {
|
|
|
948
1224
|
|
|
949
1225
|
/** Rollback the last batch, a specific batch, a specific file, or the last N steps */
|
|
950
1226
|
async down(filename, options = {}) {
|
|
951
|
-
|
|
952
|
-
this.#assertStepsValid(options.steps, filename, options.batch);
|
|
953
|
-
this.#assertBatchValid(options.batch);
|
|
954
|
-
this.#assertToValid(options.to, filename, options);
|
|
1227
|
+
assertDownOptions(filename, options);
|
|
955
1228
|
return this.#runWindow(async () => {
|
|
956
1229
|
await this.#ensureConfig();
|
|
957
1230
|
await this.connect();
|
|
@@ -1021,17 +1294,6 @@ class MigratorKit extends EventEmitter {
|
|
|
1021
1294
|
return { records, preserveOrder };
|
|
1022
1295
|
}
|
|
1023
1296
|
|
|
1024
|
-
/** Order the selected records for execution (newest first unless pre-ordered) */
|
|
1025
|
-
#downNames(records, preserveOrder) {
|
|
1026
|
-
const names = [];
|
|
1027
|
-
for (const record of records) names.push(record.name);
|
|
1028
|
-
if (!preserveOrder) {
|
|
1029
|
-
names.sort();
|
|
1030
|
-
names.reverse();
|
|
1031
|
-
}
|
|
1032
|
-
return names;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
1297
|
async #runDown(filename, options = {}, signal) {
|
|
1036
1298
|
const config = this.#config;
|
|
1037
1299
|
const db = this.#requireDb();
|
|
@@ -1044,8 +1306,9 @@ class MigratorKit extends EventEmitter {
|
|
|
1044
1306
|
logger.info('Nothing to rollback', this.#fields({ direction: 'down' }));
|
|
1045
1307
|
return [];
|
|
1046
1308
|
}
|
|
1309
|
+
if (filename && options.ordered === true) await this.#assertDownNotBlocked(toRevert[0]);
|
|
1047
1310
|
|
|
1048
|
-
const names =
|
|
1311
|
+
const names = revertOrder(toRevert, preserveOrder);
|
|
1049
1312
|
|
|
1050
1313
|
// The signal must reach the rollback context too: a long-running down()
|
|
1051
1314
|
// under SIGTERM or a lost lock is exactly the case ctx.signal exists for.
|
|
@@ -1065,7 +1328,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1065
1328
|
total: names.length,
|
|
1066
1329
|
results,
|
|
1067
1330
|
onSuccess: async (_migration, _elapsed, session) => {
|
|
1068
|
-
const result = await changelog.markReverted(db, name, session);
|
|
1331
|
+
const result = await changelog.markReverted(db, name, session, pickActor(options));
|
|
1069
1332
|
// Under --no-lock or onLockLost:'warn' a peer may have flipped the
|
|
1070
1333
|
// record first: the down() body already ran against the data, but
|
|
1071
1334
|
// the changelog still claims the migration is applied. Silence
|
|
@@ -1088,29 +1351,32 @@ class MigratorKit extends EventEmitter {
|
|
|
1088
1351
|
}
|
|
1089
1352
|
|
|
1090
1353
|
/**
|
|
1091
|
-
* Refuse rollback of any
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1354
|
+
* Refuse rollback of any forward-only record — one whose `origin` marks it
|
|
1355
|
+
* as adopted rather than executed by migronaut. Imported records use
|
|
1356
|
+
* migrate-mongo's positional `up(db, client)` signature, which migronaut
|
|
1357
|
+
* cannot invoke safely; baselined records were never executed by migronaut
|
|
1358
|
+
* at all, so their `down()` would revert work the tool has no record of
|
|
1359
|
+
* performing. Throws before any migration runs or the changelog is touched.
|
|
1095
1360
|
*/
|
|
1096
1361
|
#assertReversible(records) {
|
|
1097
1362
|
const names = [];
|
|
1098
1363
|
for (const record of records) {
|
|
1099
|
-
if (record.origin === 'migrate-mongo'
|
|
1364
|
+
if (record.origin === 'migrate-mongo' || record.origin === 'baseline') {
|
|
1365
|
+
names.push(record.name);
|
|
1366
|
+
}
|
|
1100
1367
|
}
|
|
1101
1368
|
if (names.length === 0) {
|
|
1102
1369
|
return;
|
|
1103
1370
|
}
|
|
1104
1371
|
this.#logger.error(
|
|
1105
|
-
`✖ Cannot roll back ${names.length}
|
|
1372
|
+
`✖ Cannot roll back ${names.length} forward-only migration(s): ${names.join(', ')}`,
|
|
1106
1373
|
);
|
|
1107
1374
|
this.#logger.debug(
|
|
1108
|
-
'These were adopted via `migronaut import` (forward-only)
|
|
1109
|
-
'
|
|
1110
|
-
'them in migronaut format.',
|
|
1375
|
+
'These were adopted via `migronaut import` or `migronaut baseline` (forward-only), not ' +
|
|
1376
|
+
'executed by migronaut. Revert them manually, or re-apply and revert them natively.',
|
|
1111
1377
|
);
|
|
1112
1378
|
throw new IrreversibleMigrationError(
|
|
1113
|
-
`Cannot roll back
|
|
1379
|
+
`Cannot roll back forward-only migration(s): ${names.join(', ')}`,
|
|
1114
1380
|
{ names },
|
|
1115
1381
|
);
|
|
1116
1382
|
}
|
|
@@ -1124,7 +1390,8 @@ class MigratorKit extends EventEmitter {
|
|
|
1124
1390
|
* show for it.
|
|
1125
1391
|
*/
|
|
1126
1392
|
async redo(filename, options = {}) {
|
|
1127
|
-
|
|
1393
|
+
assertRedoOptions(filename, options);
|
|
1394
|
+
const actor = pickActor(options);
|
|
1128
1395
|
return this.#runWindow(async () => {
|
|
1129
1396
|
await this.#ensureConfig();
|
|
1130
1397
|
await this.connect();
|
|
@@ -1146,10 +1413,10 @@ class MigratorKit extends EventEmitter {
|
|
|
1146
1413
|
target = newest.name;
|
|
1147
1414
|
}
|
|
1148
1415
|
|
|
1149
|
-
const downResults = await this.#runDown(target,
|
|
1416
|
+
const downResults = await this.#runDown(target, actor, signal);
|
|
1150
1417
|
let upResults;
|
|
1151
1418
|
try {
|
|
1152
|
-
upResults = await this.#runUp(target,
|
|
1419
|
+
upResults = await this.#runUp(target, actor, signal);
|
|
1153
1420
|
} catch (error) {
|
|
1154
1421
|
// The revert already happened — after a failed re-apply that is the
|
|
1155
1422
|
// single most important fact, so the down rows must survive into the
|
|
@@ -1167,12 +1434,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1167
1434
|
|
|
1168
1435
|
/** Preview what would run — never writes to the database */
|
|
1169
1436
|
async dryRun(direction, filename, options = {}) {
|
|
1170
|
-
|
|
1171
|
-
// `batch`/`to` must be passed too, or a conflict that `down` rejects would
|
|
1172
|
-
// be silently allowed in its own preview.
|
|
1173
|
-
this.#assertStepsValid(options.steps, filename, options.batch);
|
|
1174
|
-
this.#assertBatchValid(options.batch);
|
|
1175
|
-
this.#assertToValid(options.to, filename, options);
|
|
1437
|
+
assertDryRunOptions(filename, options);
|
|
1176
1438
|
await this.#ensureConfig();
|
|
1177
1439
|
await this.connect();
|
|
1178
1440
|
const db = this.#requireDb();
|
|
@@ -1182,36 +1444,28 @@ class MigratorKit extends EventEmitter {
|
|
|
1182
1444
|
let names;
|
|
1183
1445
|
const recordByName = new Map();
|
|
1184
1446
|
if (direction === 'up') {
|
|
1185
|
-
|
|
1186
|
-
// reverted history is dead weight here.
|
|
1187
|
-
const records = await changelog.getApplied(db);
|
|
1188
|
-
for (const record of records) {
|
|
1189
|
-
recordByName.set(record.name, record);
|
|
1190
|
-
}
|
|
1191
|
-
const applied = new Set(recordByName.keys());
|
|
1447
|
+
const applied = new Set();
|
|
1192
1448
|
if (filename) {
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
.
|
|
1198
|
-
.
|
|
1199
|
-
.catch(() => false);
|
|
1200
|
-
if (!exists) {
|
|
1201
|
-
throw new MigrationFileNotFoundError('Migration file not found', { filename });
|
|
1449
|
+
// Only the named file's record can matter for the preview row — an
|
|
1450
|
+
// applied one renders as applied instead of pending.
|
|
1451
|
+
const record = await changelog.getByName(db, filename);
|
|
1452
|
+
if (record?.status === 'applied') {
|
|
1453
|
+
recordByName.set(filename, record);
|
|
1454
|
+
applied.add(filename);
|
|
1202
1455
|
}
|
|
1203
|
-
names = [filename];
|
|
1204
1456
|
} else {
|
|
1205
|
-
|
|
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
|
-
}
|
|
1457
|
+
// Names only: a bulk preview's rows are pending files, which have no
|
|
1458
|
+
// record to render — fetching the full applied documents would move
|
|
1459
|
+
// the whole history over the wire just to derive this Set.
|
|
1460
|
+
for (const name of await changelog.getAppliedNames(db)) applied.add(name);
|
|
1214
1461
|
}
|
|
1462
|
+
// The same selection (and preflight) the real `up` executes, so a
|
|
1463
|
+
// preview never invents a pending row for a file that does not exist.
|
|
1464
|
+
names = await this.#selectUpTargets(filename, options, applied);
|
|
1465
|
+
// …and the same order policy: under onOutOfOrder: 'error' the real run
|
|
1466
|
+
// refuses, so the preview must too instead of listing rows it would
|
|
1467
|
+
// never apply. A single file is exempt, exactly as in `up`.
|
|
1468
|
+
if (!filename) this.#assertOrderIntact(names, applied);
|
|
1215
1469
|
} else {
|
|
1216
1470
|
// The same selection the real `down` executes — including the
|
|
1217
1471
|
// irreversible-import refusal, so a preview can never show a rollback
|
|
@@ -1220,21 +1474,41 @@ class MigratorKit extends EventEmitter {
|
|
|
1220
1474
|
for (const record of records) {
|
|
1221
1475
|
recordByName.set(record.name, record);
|
|
1222
1476
|
}
|
|
1223
|
-
names =
|
|
1477
|
+
names = revertOrder(records, preserveOrder);
|
|
1224
1478
|
}
|
|
1225
1479
|
|
|
1226
|
-
const rows = await mapLimit(names, FS_CONCURRENCY, (name) =>
|
|
1227
|
-
this.#buildStatusRow(name, recordByName.get(name))
|
|
1228
|
-
|
|
1480
|
+
const rows = await mapLimit(names, FS_CONCURRENCY, async (name) => {
|
|
1481
|
+
const row = await this.#buildStatusRow(name, recordByName.get(name));
|
|
1482
|
+
// What an `up` would apply, by content: a caller that applies the rows
|
|
1483
|
+
// later (a queue job) can insist on exactly this version of the file.
|
|
1484
|
+
if (direction === 'up' && !row.invalid) {
|
|
1485
|
+
row.checksum = await this.#cachedChecksum(this.#filepath(name));
|
|
1486
|
+
}
|
|
1487
|
+
return row;
|
|
1488
|
+
});
|
|
1229
1489
|
logger.info(
|
|
1230
1490
|
`◎ Dry-run Would ${direction === 'up' ? 'apply' : 'revert'}: ${rows.length}`,
|
|
1231
1491
|
this.#fields({ direction, count: rows.length, dryRun: true }),
|
|
1232
1492
|
);
|
|
1493
|
+
// A converge plan is only meaningful against the database the migrations
|
|
1494
|
+
// leave behind — previewing it now would compare with the wrong state.
|
|
1495
|
+
if (
|
|
1496
|
+
direction === 'up' &&
|
|
1497
|
+
!filename &&
|
|
1498
|
+
options.to === undefined &&
|
|
1499
|
+
(await this.convergesAfterUp())
|
|
1500
|
+
) {
|
|
1501
|
+
logger.info(
|
|
1502
|
+
'◎ Dry-run Converge after up is not previewed — it is planned against the database ' +
|
|
1503
|
+
'the migrations leave behind (`converge --dry-run` once they are applied)',
|
|
1504
|
+
this.#fields({ direction, dryRun: true }),
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1233
1507
|
return rows;
|
|
1234
1508
|
}
|
|
1235
1509
|
|
|
1236
1510
|
/** Full migration status for all known files and records */
|
|
1237
|
-
async status() {
|
|
1511
|
+
async status(options = {}) {
|
|
1238
1512
|
await this.#ensureConfig();
|
|
1239
1513
|
await this.connect();
|
|
1240
1514
|
const records = await this.#requireChangelog().getAll(this.#requireDb());
|
|
@@ -1246,9 +1520,23 @@ class MigratorKit extends EventEmitter {
|
|
|
1246
1520
|
const sortedNames = [...names].sort();
|
|
1247
1521
|
// Each row may read and hash a file; unbounded fan-out over thousands of
|
|
1248
1522
|
// migrations exhausts the descriptor limit.
|
|
1249
|
-
|
|
1250
|
-
this.#buildStatusRow(name, recordByName.get(name)),
|
|
1523
|
+
const rows = await mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
|
|
1524
|
+
this.#buildStatusRow(name, recordByName.get(name), options),
|
|
1251
1525
|
);
|
|
1526
|
+
// Mark late arrivals: a not-yet-applied row sorting before the newest
|
|
1527
|
+
// applied name will run after migrations authored later — the same signal
|
|
1528
|
+
// #assertOrderIntact acts on, surfaced here as data.
|
|
1529
|
+
const applied = [];
|
|
1530
|
+
for (const row of rows) {
|
|
1531
|
+
if (row.status === 'applied') applied.push(row.file);
|
|
1532
|
+
}
|
|
1533
|
+
const newestApplied = newestOf(applied);
|
|
1534
|
+
if (newestApplied !== '') {
|
|
1535
|
+
for (const row of rows) {
|
|
1536
|
+
if (row.status !== 'applied' && row.file < newestApplied) row.outOfOrder = true;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return rows;
|
|
1252
1540
|
}
|
|
1253
1541
|
|
|
1254
1542
|
/**
|
|
@@ -1265,17 +1553,17 @@ class MigratorKit extends EventEmitter {
|
|
|
1265
1553
|
});
|
|
1266
1554
|
}
|
|
1267
1555
|
|
|
1268
|
-
/**
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1556
|
+
/**
|
|
1557
|
+
* Filtered list of migrations. `checksums: false` skips hashing the applied
|
|
1558
|
+
* files (`checksumOk` stays null) — for a caller that only needs names and
|
|
1559
|
+
* dates, where a full re-hash of the history would be the whole cost.
|
|
1560
|
+
*/
|
|
1561
|
+
async list(filter = 'all', options = {}) {
|
|
1562
|
+
assertListOptions(filter, options);
|
|
1275
1563
|
if (filter === 'pending') {
|
|
1276
1564
|
return this.#listPending();
|
|
1277
1565
|
}
|
|
1278
|
-
const rows = await this.status();
|
|
1566
|
+
const rows = await this.status(options.checksums === false ? { checksums: false } : {});
|
|
1279
1567
|
if (filter === 'all') {
|
|
1280
1568
|
return rows;
|
|
1281
1569
|
}
|
|
@@ -1300,8 +1588,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1300
1588
|
await this.connect();
|
|
1301
1589
|
const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
|
|
1302
1590
|
const rows = [];
|
|
1303
|
-
for (const file of await this.#listMigrationFiles()) {
|
|
1304
|
-
if (applied.has(file)) continue;
|
|
1591
|
+
for (const file of pendingIn(await this.#listMigrationFiles(), applied)) {
|
|
1305
1592
|
rows.push({
|
|
1306
1593
|
file,
|
|
1307
1594
|
status: 'pending',
|
|
@@ -1332,9 +1619,47 @@ class MigratorKit extends EventEmitter {
|
|
|
1332
1619
|
return checksum;
|
|
1333
1620
|
}
|
|
1334
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* The audit-trail fields a StatusRow surfaces from its record. The changelog
|
|
1624
|
+
* deliberately preserves these (who ran it, from which run, was it ever
|
|
1625
|
+
* reverted) — discarding them here made the questions the append-mostly
|
|
1626
|
+
* design exists to answer unanswerable from any read surface.
|
|
1627
|
+
*/
|
|
1628
|
+
#auditFields(record) {
|
|
1629
|
+
if (!record) return {};
|
|
1630
|
+
return {
|
|
1631
|
+
...(record.executedBy ? { executedBy: record.executedBy } : {}),
|
|
1632
|
+
...(record.environment ? { environment: record.environment } : {}),
|
|
1633
|
+
...(record.runId ? { runId: record.runId } : {}),
|
|
1634
|
+
...(record.revertedAt ? { revertedAt: record.revertedAt } : {}),
|
|
1635
|
+
...(record.origin ? { origin: record.origin } : {}),
|
|
1636
|
+
...(record.status === 'failed' && record.error ? { error: record.error } : {}),
|
|
1637
|
+
...(record.status === 'failed' && record.failedAt ? { failedAt: record.failedAt } : {}),
|
|
1638
|
+
// The version of the file that failed — what tells "failed and unchanged since" apart.
|
|
1639
|
+
...(record.status === 'failed' && record.checksum ? { failedChecksum: record.checksum } : {}),
|
|
1640
|
+
...(record.requestedBy ? { requestedBy: record.requestedBy } : {}),
|
|
1641
|
+
...(record.reason ? { reason: record.reason } : {}),
|
|
1642
|
+
...(record.revertRequestedBy ? { revertRequestedBy: record.revertRequestedBy } : {}),
|
|
1643
|
+
...(record.revertReason ? { revertReason: record.revertReason } : {}),
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* A row's rendered status: 'applied', 'failed' (a recorded failed attempt —
|
|
1649
|
+
* the file still counts as pending for every run path, but the operator
|
|
1650
|
+
* deserves to see the failure), or 'pending' (including reverted history —
|
|
1651
|
+
* the `revertedAt` field carries that story).
|
|
1652
|
+
*/
|
|
1653
|
+
static #rowStatus(record) {
|
|
1654
|
+
if (record?.status === 'applied') return 'applied';
|
|
1655
|
+
if (record?.status === 'failed') return 'failed';
|
|
1656
|
+
return 'pending';
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1335
1659
|
/** Build a StatusRow for a migration, verifying checksum when possible */
|
|
1336
|
-
async #buildStatusRow(name, record) {
|
|
1660
|
+
async #buildStatusRow(name, record, { checksums = true } = {}) {
|
|
1337
1661
|
const isApplied = record?.status === 'applied';
|
|
1662
|
+
const status = MigratorKit.#rowStatus(record);
|
|
1338
1663
|
let filepath;
|
|
1339
1664
|
try {
|
|
1340
1665
|
filepath = this.#filepath(name);
|
|
@@ -1344,19 +1669,20 @@ class MigratorKit extends EventEmitter {
|
|
|
1344
1669
|
// invalid and keep going.
|
|
1345
1670
|
return {
|
|
1346
1671
|
file: String(name),
|
|
1347
|
-
status
|
|
1672
|
+
status,
|
|
1348
1673
|
batch: isApplied && record ? record.batch : null,
|
|
1349
1674
|
appliedAt: isApplied && record ? record.appliedAt : null,
|
|
1350
1675
|
duration: isApplied && record ? record.duration : null,
|
|
1351
1676
|
checksumOk: isApplied ? false : null,
|
|
1352
1677
|
invalid: true,
|
|
1678
|
+
...this.#auditFields(record),
|
|
1353
1679
|
};
|
|
1354
1680
|
}
|
|
1355
1681
|
// Hash (via the cache) and treat ENOENT as "missing" — the old
|
|
1356
1682
|
// access()-first probe cost an extra syscall per row, for pending rows
|
|
1357
1683
|
// whose result was never even used.
|
|
1358
1684
|
let checksumOk = null;
|
|
1359
|
-
if (isApplied && record) {
|
|
1685
|
+
if (isApplied && record && checksums) {
|
|
1360
1686
|
try {
|
|
1361
1687
|
checksumOk = (await this.#cachedChecksum(filepath)) === record.checksum;
|
|
1362
1688
|
} catch (error) {
|
|
@@ -1367,12 +1693,13 @@ class MigratorKit extends EventEmitter {
|
|
|
1367
1693
|
|
|
1368
1694
|
return {
|
|
1369
1695
|
file: name,
|
|
1370
|
-
status
|
|
1696
|
+
status,
|
|
1371
1697
|
batch: isApplied && record ? record.batch : null,
|
|
1372
1698
|
appliedAt: isApplied && record ? record.appliedAt : null,
|
|
1373
1699
|
duration: isApplied && record ? record.duration : null,
|
|
1374
1700
|
checksumOk,
|
|
1375
1701
|
...(record?.description ? { description: record.description } : {}),
|
|
1702
|
+
...this.#auditFields(record),
|
|
1376
1703
|
};
|
|
1377
1704
|
}
|
|
1378
1705
|
|
|
@@ -1419,7 +1746,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1419
1746
|
}
|
|
1420
1747
|
|
|
1421
1748
|
const filepath = await createConfigFile({
|
|
1422
|
-
dir: process.cwd(),
|
|
1749
|
+
dir: this.#cwd ?? process.cwd(),
|
|
1423
1750
|
format: options.format ?? 'js',
|
|
1424
1751
|
force: options.force ?? false,
|
|
1425
1752
|
values,
|
|
@@ -1432,6 +1759,43 @@ class MigratorKit extends EventEmitter {
|
|
|
1432
1759
|
return filepath;
|
|
1433
1760
|
}
|
|
1434
1761
|
|
|
1762
|
+
/**
|
|
1763
|
+
* Adopt an existing database with no prior migration tool: mark migration
|
|
1764
|
+
* files on disk as applied (checksum from disk, one shared batch,
|
|
1765
|
+
* `origin: 'baseline'`) without executing anything — see
|
|
1766
|
+
* {@link runBaseline} in baseline.js for the mechanics. Forward-only, like
|
|
1767
|
+
* import: `down`/`redo` refuse baselined records. Runs under the migration
|
|
1768
|
+
* lock — it writes the changelog, and two concurrent baselines (or a
|
|
1769
|
+
* baseline racing an `up`) must serialize like any other mutation.
|
|
1770
|
+
*/
|
|
1771
|
+
async baseline(options = {}) {
|
|
1772
|
+
assertFilename(options.to);
|
|
1773
|
+
return this.#runWindow(async () => {
|
|
1774
|
+
await this.#ensureConfig();
|
|
1775
|
+
await this.connect();
|
|
1776
|
+
return this.#withLock(options, { command: 'baseline' }, (signal) =>
|
|
1777
|
+
runBaseline(
|
|
1778
|
+
{
|
|
1779
|
+
db: this.#requireDb(),
|
|
1780
|
+
changelog: this.#requireChangelog(),
|
|
1781
|
+
logger: this.#logger,
|
|
1782
|
+
fields: (extra) => this.#fields(extra),
|
|
1783
|
+
filepath: (name) => this.#filepath(name),
|
|
1784
|
+
listMigrationFiles: () => this.#listMigrationFiles(),
|
|
1785
|
+
nextBatch: () => this.#nextBatch(),
|
|
1786
|
+
truncateAtTarget,
|
|
1787
|
+
environment: () => this.#environment(),
|
|
1788
|
+
executedBy: () => safeUsername(),
|
|
1789
|
+
runId: () => this.#runId,
|
|
1790
|
+
assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
|
|
1791
|
+
},
|
|
1792
|
+
options,
|
|
1793
|
+
signal,
|
|
1794
|
+
),
|
|
1795
|
+
);
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1435
1799
|
/**
|
|
1436
1800
|
* Adopt an existing migrate-mongo `changelog` collection by mapping its
|
|
1437
1801
|
* records into our schema and writing them to `migrationsCollection`. The
|
|
@@ -1440,14 +1804,7 @@ class MigratorKit extends EventEmitter {
|
|
|
1440
1804
|
* file signatures, so `down`/`redo` on imported files is unsupported.
|
|
1441
1805
|
*/
|
|
1442
1806
|
async import(options = {}) {
|
|
1443
|
-
|
|
1444
|
-
// or take the lock. Defaults come from the already-validated config.
|
|
1445
|
-
if (options.from !== undefined && !isCollectionName(options.from)) {
|
|
1446
|
-
throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
|
|
1447
|
-
}
|
|
1448
|
-
if (options.to !== undefined && !isCollectionName(options.to)) {
|
|
1449
|
-
throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
|
|
1450
|
-
}
|
|
1807
|
+
assertImportOptions(options);
|
|
1451
1808
|
return this.#runWindow(async () => {
|
|
1452
1809
|
await this.#ensureConfig();
|
|
1453
1810
|
await this.connect();
|
|
@@ -1470,6 +1827,150 @@ class MigratorKit extends EventEmitter {
|
|
|
1470
1827
|
);
|
|
1471
1828
|
});
|
|
1472
1829
|
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Bring the declared collections (`collections`, `collectionsDir`) to their
|
|
1833
|
+
* declared indexes and validators — see {@link runConverge} in converge.js
|
|
1834
|
+
* for the mechanics. Stateless: the live database is read and compared on
|
|
1835
|
+
* every call. What a run changed is appended to the converge history
|
|
1836
|
+
* (`convergeHistory()`), which no run reads back.
|
|
1837
|
+
*
|
|
1838
|
+
* `dryRun` plans without writing — no lock, no events. A real run holds the
|
|
1839
|
+
* migration lock, like every other mutation. `prune` drops undeclared
|
|
1840
|
+
* indexes in collections whose definition does not decide for itself.
|
|
1841
|
+
* `ordered` refuses while any migration is still pending — checked under
|
|
1842
|
+
* the lock, which is what lets a queue run it as the tail of a deploy.
|
|
1843
|
+
* `rebuildUnique` lets a rebuild drop a unique index it builds back; without
|
|
1844
|
+
* it such a rebuild is a conflict (the constraint would be gone until the
|
|
1845
|
+
* build ends), which is why the after-up hook and a queue job never pass it.
|
|
1846
|
+
*/
|
|
1847
|
+
async converge(options = {}) {
|
|
1848
|
+
assertConvergeOptions(options);
|
|
1849
|
+
const actor = pickActor(options);
|
|
1850
|
+
const empty = (dryRun) => ({ dryRun, changed: 0, inSync: true, collections: [] });
|
|
1851
|
+
if (options.dryRun) {
|
|
1852
|
+
await this.#ensureConfig();
|
|
1853
|
+
const definitions = await this.#resolveCollections();
|
|
1854
|
+
if (definitions.length === 0) return empty(true);
|
|
1855
|
+
await this.connect();
|
|
1856
|
+
return runConverge(
|
|
1857
|
+
this.#convergeDeps(),
|
|
1858
|
+
{ definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, dryRun: true },
|
|
1859
|
+
undefined,
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
return this.#runWindow(async () => {
|
|
1863
|
+
await this.#ensureConfig();
|
|
1864
|
+
// Before connecting or locking: a broken definition file must not cost
|
|
1865
|
+
// a round trip, and must not hold the lock while it is reported.
|
|
1866
|
+
const definitions = await this.#resolveCollections();
|
|
1867
|
+
if (definitions.length === 0) {
|
|
1868
|
+
this.#logger.info(
|
|
1869
|
+
'No collections declared — set collections or collectionsDir',
|
|
1870
|
+
this.#fields({ command: 'converge' }),
|
|
1871
|
+
);
|
|
1872
|
+
return empty(false);
|
|
1873
|
+
}
|
|
1874
|
+
await this.connect();
|
|
1875
|
+
return this.#withLock(options, { command: 'converge' }, async (signal) => {
|
|
1876
|
+
if (options.ordered) await this.#assertNothingPending();
|
|
1877
|
+
return runConverge(
|
|
1878
|
+
this.#convergeDeps(),
|
|
1879
|
+
{ definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, ...actor },
|
|
1880
|
+
signal,
|
|
1881
|
+
);
|
|
1882
|
+
});
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Whether a bulk `up` on this kit ends by converging: `convergeAfterUp` is
|
|
1888
|
+
* on and there is something declared to converge. Resolves the config; does
|
|
1889
|
+
* not connect, and does not load definition files. A layer above the kit
|
|
1890
|
+
* (the queue adapter) uses it to mirror that behaviour across single-file
|
|
1891
|
+
* jobs, where the kit's own after-up hook never fires.
|
|
1892
|
+
*/
|
|
1893
|
+
async convergesAfterUp() {
|
|
1894
|
+
const config = await this.#ensureConfig();
|
|
1895
|
+
return (
|
|
1896
|
+
config.convergeAfterUp === true &&
|
|
1897
|
+
((config.collections?.length ?? 0) > 0 || config.collectionsDir !== undefined)
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** Every declared collection, normalized — the config key first, then `collectionsDir` */
|
|
1902
|
+
async #resolveCollections() {
|
|
1903
|
+
const config = this.#config;
|
|
1904
|
+
return resolveDefinitions({
|
|
1905
|
+
inline: config.collections,
|
|
1906
|
+
...(config.collectionsDir !== undefined
|
|
1907
|
+
? { dir: path.resolve(this.#cwd ?? process.cwd(), config.collectionsDir) }
|
|
1908
|
+
: {}),
|
|
1909
|
+
extensions: config.fileExtensions,
|
|
1910
|
+
reload: config.reloadMigrations,
|
|
1911
|
+
reserved: [config.migrationsCollection, config.lockCollection, config.convergeLogCollection],
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
#convergeDeps() {
|
|
1916
|
+
const db = this.#requireDb();
|
|
1917
|
+
return {
|
|
1918
|
+
db,
|
|
1919
|
+
logger: this.#logger,
|
|
1920
|
+
fields: (extra) => this.#fields(extra),
|
|
1921
|
+
emit: (event, payload) => this.#emit(event, payload),
|
|
1922
|
+
assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
|
|
1923
|
+
// The history entry's who-and-where, like a changelog record's.
|
|
1924
|
+
audit: () => ({
|
|
1925
|
+
...(this.#runId ? { runId: this.#runId } : {}),
|
|
1926
|
+
executedBy: safeUsername(),
|
|
1927
|
+
host: os.hostname(),
|
|
1928
|
+
environment: this.#environment(),
|
|
1929
|
+
}),
|
|
1930
|
+
record: (entry) => this.#convergeLog().append(db, entry),
|
|
1931
|
+
// Behind a mongos only: the shard key, so prune never tries to drop its index.
|
|
1932
|
+
shardKeyOf: async (name) =>
|
|
1933
|
+
(
|
|
1934
|
+
await this.#client
|
|
1935
|
+
.db('config')
|
|
1936
|
+
.collection('collections')
|
|
1937
|
+
.findOne({ _id: `${db.databaseName}.${name}` }, { projection: { key: 1 } })
|
|
1938
|
+
)?.key,
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
#convergeLog() {
|
|
1943
|
+
this.#convergeLogStore ??= new ConvergeLog(this.#config.convergeLogCollection);
|
|
1944
|
+
return this.#convergeLogStore;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* The converge history, newest first: one entry per converge that changed
|
|
1949
|
+
* something or failed — when, triggered how, by whom and why, and every
|
|
1950
|
+
* index or validator it touched, with its before and after. Read-only.
|
|
1951
|
+
*/
|
|
1952
|
+
async convergeHistory(options = {}) {
|
|
1953
|
+
const { limit = 20 } = options;
|
|
1954
|
+
assertHistoryLimit(limit);
|
|
1955
|
+
await this.#ensureConfig();
|
|
1956
|
+
await this.connect();
|
|
1957
|
+
return this.#convergeLog().list(this.#requireDb(), limit);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* `converge({ ordered })`: refuse while a migration on disk has no applied
|
|
1962
|
+
* record — a failed one included, exactly as for an ordered `up` job.
|
|
1963
|
+
*/
|
|
1964
|
+
async #assertNothingPending() {
|
|
1965
|
+
const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
|
|
1966
|
+
const blockedBy = pendingIn(await this.#listMigrationFiles(), applied);
|
|
1967
|
+
if (blockedBy.length === 0) return;
|
|
1968
|
+
throw blockedError('converge', 'migration(s) still pending', {
|
|
1969
|
+
command: 'converge',
|
|
1970
|
+
blockedBy,
|
|
1971
|
+
failed: await this.#failedAmong(blockedBy),
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1473
1974
|
}
|
|
1474
1975
|
|
|
1475
|
-
module.exports = { MigratorKit };
|
|
1976
|
+
module.exports = { MigratorKit, RECORD_LOCK_WAIT };
|