@alexify/migronaut 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/LICENSE +22 -0
  3. package/README.md +773 -0
  4. package/bin/migronaut.js +36 -0
  5. package/index.d.ts +903 -0
  6. package/index.js +1 -0
  7. package/migronaut.schema.json +135 -0
  8. package/package.json +105 -0
  9. package/src/cli/args.js +314 -0
  10. package/src/cli/commands/audit.js +40 -0
  11. package/src/cli/commands/create.js +29 -0
  12. package/src/cli/commands/down.js +30 -0
  13. package/src/cli/commands/dry-run.js +36 -0
  14. package/src/cli/commands/import.js +33 -0
  15. package/src/cli/commands/init.js +64 -0
  16. package/src/cli/commands/list.js +19 -0
  17. package/src/cli/commands/lock.js +35 -0
  18. package/src/cli/commands/redo.js +16 -0
  19. package/src/cli/commands/status.js +52 -0
  20. package/src/cli/commands/unlock.js +43 -0
  21. package/src/cli/commands/up.js +53 -0
  22. package/src/cli/exit-codes.js +38 -0
  23. package/src/cli/index.js +91 -0
  24. package/src/cli/shared.js +343 -0
  25. package/src/cli/spinner.js +59 -0
  26. package/src/cli/table.js +259 -0
  27. package/src/core/audit.js +152 -0
  28. package/src/core/changelog.js +231 -0
  29. package/src/core/config.js +479 -0
  30. package/src/core/context.js +16 -0
  31. package/src/core/import-runner.js +164 -0
  32. package/src/core/import.js +60 -0
  33. package/src/core/lock.js +348 -0
  34. package/src/core/migrator.js +1475 -0
  35. package/src/core/run.js +144 -0
  36. package/src/core/runner.js +150 -0
  37. package/src/errors/index.js +215 -0
  38. package/src/index.js +59 -0
  39. package/src/utils/checksum.js +23 -0
  40. package/src/utils/colors.js +68 -0
  41. package/src/utils/concurrency.js +32 -0
  42. package/src/utils/date.js +28 -0
  43. package/src/utils/env.js +51 -0
  44. package/src/utils/error.js +11 -0
  45. package/src/utils/loader.js +131 -0
  46. package/src/utils/logger.js +109 -0
  47. package/src/utils/redact.js +39 -0
  48. package/src/utils/sanitize.js +43 -0
  49. package/src/utils/template.js +615 -0
  50. package/src/utils/user.js +25 -0
@@ -0,0 +1,1475 @@
1
+ const { randomUUID } = require('node:crypto');
2
+ const { EventEmitter } = require('node:events');
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+ // `mongodb` is required lazily inside connect(): loading the driver costs ~60ms
6
+ // and pulls in ~150 modules, which `--help`, `--version`, `init` and `create`
7
+ // have no use for.
8
+ const {
9
+ ChecksumMismatchError,
10
+ ConfigInvalidError,
11
+ ConnectionFailedError,
12
+ HookFailedError,
13
+ IrreversibleMigrationError,
14
+ MigrationFileNotFoundError,
15
+ MigrationInvalidNameError,
16
+ MigronautError,
17
+ NotAppliedError,
18
+ RunAbortedError,
19
+ } = require('../errors/index.js');
20
+ const { computeChecksum } = require('../utils/checksum.js');
21
+ const { mapLimit } = require('../utils/concurrency.js');
22
+ const { errorText } = require('../utils/error.js');
23
+ const { loadMigrationFile } = require('../utils/loader.js');
24
+ const { resolveLogger } = require('../utils/logger.js');
25
+ const {
26
+ createConfigFile,
27
+ createMigrationFile,
28
+ maskUriCredentials,
29
+ } = require('../utils/template.js');
30
+ const { safeUsername } = require('../utils/user.js');
31
+ const { runAudit } = require('./audit.js');
32
+ const { Changelog } = require('./changelog.js');
33
+ const { isCollectionName, loadConfig } = require('./config.js');
34
+ const { buildContext } = require('./context.js');
35
+ const { runImport } = require('./import-runner.js');
36
+ const { MigrationLock, runWithLock, toLockInfo } = require('./lock.js');
37
+ const { runMigration } = require('./runner.js');
38
+
39
+ /** Simultaneous file reads — keeps a large migrations dir clear of EMFILE */
40
+ const FS_CONCURRENCY = 16;
41
+
42
+ /**
43
+ * The main orchestration class. Every CLI command delegates here. Holds a
44
+ * partial config that is resolved (merged with env/file/defaults) on first use.
45
+ *
46
+ * Also an EventEmitter: subscribe with `kit.on('migration:success', …)` to feed
47
+ * metrics or alerting without parsing log lines. Events complement
48
+ * {@link MigrationHooks} — hooks are configured up front and run user database
49
+ * logic in the migration's flow; listeners attach from outside, may be several,
50
+ * and a listener that throws is contained rather than failing the run.
51
+ */
52
+ class MigratorKit extends EventEmitter {
53
+ #partialConfig;
54
+ #configPath;
55
+ #progress;
56
+ #config;
57
+ #client;
58
+ #db;
59
+ #changelog;
60
+ /** Set while a locked run is in flight, so stop() can interrupt it */
61
+ #abort;
62
+ /** A stop requested before the run reached its lock — consumed when it does */
63
+ #stopRequested;
64
+ /** >0 while a run method is setting up or executing — the stop() latch window */
65
+ #runSetupDepth = 0;
66
+ /** Correlation id for the run in flight — ties logs, lock and changelog together */
67
+ #runId;
68
+ /** Whether changelog indexes have already been ensured on this instance */
69
+ #indexesEnsured = false;
70
+ /** Memoized resolved logger — resolveLogger allocates on every call otherwise */
71
+ #resolvedLogger;
72
+ /** Used only when the resolved config supplies no `logger` of its own (CLI injection) */
73
+ #fallbackLogger;
74
+ /** False when the client was injected by the caller, who keeps ownership of it */
75
+ #ownsClient = true;
76
+ /** filepath → {mtimeMs, size, checksum} — spares repeat status()/audit() calls a full re-hash */
77
+ #checksumCache = new Map();
78
+
79
+ constructor(config = {}, options = {}) {
80
+ super();
81
+ this.#partialConfig = config;
82
+ this.#configPath = options.configPath;
83
+ this.#progress = options.progress;
84
+ this.#fallbackLogger = options.fallbackLogger;
85
+ }
86
+
87
+ /**
88
+ * Emit a lifecycle event without letting a listener affect the run.
89
+ *
90
+ * A throwing listener is swallowed (observability must never break a
91
+ * migration), and `error` is deliberately not used as an event name: an
92
+ * EventEmitter with no `error` listener throws, which would turn a reported
93
+ * failure into a second, unrelated one.
94
+ */
95
+ #emit(event, payload) {
96
+ try {
97
+ this.emit(event, { ...(this.#runId ? { runId: this.#runId } : {}), ...payload });
98
+ } catch (error) {
99
+ // A listener's failure is its own problem — but an invisible one is
100
+ // undebuggable, so leave a trace at debug level.
101
+ this.#logger.debug(
102
+ `Event listener for '${event}' threw: ${errorText(error)}`,
103
+ this.#fields({ event, error: errorText(error) }),
104
+ );
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Stop the run in progress: the migration currently executing is allowed to
110
+ * finish (interrupting it mid-write is what leaves a database half-migrated),
111
+ * the remaining ones are skipped, the lock is released, and the call rejects
112
+ * with a RunAbortedError listing what was applied.
113
+ *
114
+ * A stop that arrives before the run reaches its lock — while config is
115
+ * loading or the connection is opening, the exact window a pod eviction hits
116
+ * — is remembered and applied as soon as it does, instead of being lost.
117
+ * With no run in flight or being set up, this is the documented no-op: a
118
+ * latched stop would otherwise silently abort an unrelated run started
119
+ * minutes later.
120
+ */
121
+ stop(reason = 'Run stopped by request') {
122
+ if (this.#abort) {
123
+ this.#abort(reason);
124
+ return;
125
+ }
126
+ if (this.#runSetupDepth > 0) {
127
+ this.#stopRequested = reason;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Mark the window in which a public run method is setting up (config load,
133
+ * connect) or executing, so stop() can tell "run imminent — latch the stop"
134
+ * from "nothing running — no-op". The latch is cleared on the way out so it
135
+ * can never leak into a later, unrelated run.
136
+ */
137
+ async #runWindow(fn) {
138
+ this.#runSetupDepth += 1;
139
+ try {
140
+ return await fn();
141
+ } finally {
142
+ this.#runSetupDepth -= 1;
143
+ if (this.#runSetupDepth === 0) {
144
+ this.#stopRequested = undefined;
145
+ }
146
+ }
147
+ }
148
+
149
+ /** Resolve and cache the full configuration */
150
+ async #ensureConfig(requireDb = true, lenient = false) {
151
+ if (!this.#config) {
152
+ this.#config = await loadConfig({
153
+ flags: this.#partialConfig,
154
+ requireDb,
155
+ ...(lenient ? { lenient: true } : {}),
156
+ ...(this.#configPath ? { configPath: this.#configPath } : {}),
157
+ ...(this.#fallbackLogger !== undefined ? { fallbackLogger: this.#fallbackLogger } : {}),
158
+ });
159
+ }
160
+ return this.#config;
161
+ }
162
+
163
+ get #logger() {
164
+ // Memoized once the config is resolved: with no user logger, resolveLogger
165
+ // builds a fresh logger (and two color palettes) on every read — and this
166
+ // is read per log line. Before resolution the choice is provisional (a
167
+ // config file may still supply a logger), so it is not locked in yet.
168
+ if (this.#resolvedLogger) return this.#resolvedLogger;
169
+ const source = this.#config?.logger !== undefined ? this.#config.logger : this.#fallbackLogger;
170
+ const resolved = resolveLogger(source);
171
+ if (this.#config) this.#resolvedLogger = resolved;
172
+ return resolved;
173
+ }
174
+
175
+ /**
176
+ * The resolved logger — silent when config sets `logger: null`. Meaningful
177
+ * after `connect()` (before it, the config file's logger is not loaded yet);
178
+ * lets wrappers like `runMigrations` log with the run's own verbosity.
179
+ */
180
+ get logger() {
181
+ return this.#logger;
182
+ }
183
+
184
+ /**
185
+ * Environment stamped onto changelog records: an explicit config value wins,
186
+ * then NODE_ENV, then 'production' — the safe assumption when nothing says
187
+ * otherwise (an unset NODE_ENV in a container is far more often production
188
+ * than a developer's laptop).
189
+ *
190
+ * `MIGRONAUT_ENVIRONMENT` is the prefixed override: it feeds `config.environment`
191
+ * through the ENV_KEYS table, so it already outranks NODE_ENV by the time this
192
+ * runs. NODE_ENV stays honored underneath as the ecosystem convention.
193
+ */
194
+ #environment() {
195
+ return this.#config?.environment ?? process.env.NODE_ENV ?? 'production';
196
+ }
197
+
198
+ /**
199
+ * Structured fields for a log line. Passed as the logger's second argument
200
+ * (first, for pino-style loggers) so a machine-readable sink gets
201
+ * `{migration, direction, durationMs, …}` instead of having to parse the
202
+ * emoji-prefixed human string.
203
+ */
204
+ #fields(extra) {
205
+ return this.#runId ? { runId: this.#runId, ...extra } : { ...extra };
206
+ }
207
+
208
+ /** Connect to MongoDB and ensure changelog indexes exist */
209
+ async connect() {
210
+ const config = await this.#ensureConfig();
211
+ if (this.#client && this.#db) {
212
+ return;
213
+ }
214
+ const startedAt = Date.now();
215
+ try {
216
+ if (config.client) {
217
+ // A client the caller owns: reuse its pool (and whatever auth, TLS or
218
+ // proxying it was built with) and never close it — see disconnect().
219
+ this.#client = config.client;
220
+ this.#ownsClient = false;
221
+ } else {
222
+ this.#warnOnWeakTls(config.clientOptions);
223
+ const { MongoClient } = require('mongodb');
224
+ // clientOptions is the escape hatch for everything a URI cannot carry:
225
+ // TLS certificates, AWS IAM / X.509 auth, proxies, pool sizing.
226
+ this.#client = new MongoClient(config.uri, config.clientOptions);
227
+ this.#ownsClient = true;
228
+ await this.#client.connect();
229
+ }
230
+ this.#db = this.#client.db(config.dbName);
231
+ this.#changelog = new Changelog(config.migrationsCollection);
232
+ // Once per instance, not once per connect: re-issuing createIndexes on
233
+ // every command is a wasted round trip. `ensureIndexes: false` skips it
234
+ // entirely, for deployments where the app user cannot create indexes.
235
+ if (!this.#indexesEnsured && (config.ensureIndexes ?? true)) {
236
+ await this.#changelog.ensureIndexes(this.#db);
237
+ this.#indexesEnsured = true;
238
+ }
239
+ // "Which database did it actually write to?" is the most common support
240
+ // question — answerable from --verbose output instead of a separate audit.
241
+ this.#logger.debug(
242
+ `Connected to MongoDB (db: ${config.dbName})`,
243
+ this.#fields({
244
+ dbName: config.dbName,
245
+ durationMs: Date.now() - startedAt,
246
+ injectedClient: !this.#ownsClient,
247
+ }),
248
+ );
249
+ } catch (error) {
250
+ // Close and forget the half-built client. Leaving it assigned would leak
251
+ // its connection pool, since a retry of connect() overwrites the field.
252
+ const dangling = this.#ownsClient ? this.#client : undefined;
253
+ this.#client = undefined;
254
+ this.#db = undefined;
255
+ this.#changelog = undefined;
256
+ await dangling?.close().catch(() => undefined);
257
+ throw new ConnectionFailedError(
258
+ 'Failed to connect to MongoDB',
259
+ { cause: errorText(error) },
260
+ { cause: error },
261
+ );
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Disconnect from MongoDB.
267
+ *
268
+ * An injected `config.client` is left open: the application handed it over to
269
+ * be reused, and closing it here would take down its connection pool for
270
+ * everything else using it.
271
+ */
272
+ async disconnect() {
273
+ if (!this.#client) return;
274
+ const client = this.#client;
275
+ const owned = this.#ownsClient;
276
+ // Restore the full pre-connect invariant: every connection-scoped field is
277
+ // cleared together, so nothing half-connected survives a disconnect.
278
+ this.#client = undefined;
279
+ this.#db = undefined;
280
+ this.#changelog = undefined;
281
+ this.#ownsClient = true;
282
+ if (owned) await client.close();
283
+ }
284
+
285
+ /**
286
+ * A committed config file can weaken TLS verification for everyone who runs
287
+ * migrations against it — a legitimate escape hatch, but never a silent one.
288
+ */
289
+ #warnOnWeakTls(clientOptions) {
290
+ if (!clientOptions) return;
291
+ const weakening = [];
292
+ for (const key of ['tlsInsecure', 'tlsAllowInvalidCertificates', 'tlsAllowInvalidHostnames']) {
293
+ if (clientOptions[key]) weakening.push(key);
294
+ }
295
+ if (weakening.length > 0) {
296
+ this.#logger.warn(
297
+ `⚠ clientOptions disables TLS verification (${weakening.join(', ')}) — ` +
298
+ 'connections are exposed to interception',
299
+ this.#fields({ clientOptions: weakening }),
300
+ );
301
+ }
302
+ }
303
+
304
+ /** Build a lock bound to the configured collection (assumes connected) */
305
+ #buildLock() {
306
+ const config = this.#config;
307
+ return new MigrationLock(this.#requireDb(), config.lockCollection, config.lockTTLSeconds);
308
+ }
309
+
310
+ /**
311
+ * Run `fn` under the migration lock. The single place that pairs a lock with
312
+ * a unit of work, so `redo` can hold one lock across both directions instead
313
+ * of releasing between them. `info` names the run (`{command, direction?}`)
314
+ * for the `run:start`/`run:end` events.
315
+ */
316
+ async #withLock(options, info, fn) {
317
+ // Not reentrant: a second overlapping run on this instance would clobber
318
+ // the first one's runId/abort state in its `finally`. The DB lock already
319
+ // rejects the overlap — this rejects it before any state is disturbed.
320
+ if (this.#runId) {
321
+ throw new ConfigInvalidError('A run is already in flight on this MigratorKit instance', {
322
+ runId: this.#runId,
323
+ });
324
+ }
325
+ // One id per run, reused as the lock's owner token and stamped on every
326
+ // changelog record and log line, so the three can be correlated after the
327
+ // fact ("which run left this lock?", "what did run X apply?").
328
+ this.#runId = randomUUID();
329
+ // A second controller layered over the lock's own signal, so stop() and a
330
+ // lost lock abort through the same path the run loops already watch.
331
+ const stopper = new AbortController();
332
+ this.#abort = (reason) => {
333
+ if (!stopper.signal.aborted) {
334
+ stopper.abort(new RunAbortedError(reason, { reason }));
335
+ }
336
+ };
337
+ // Honor a stop that landed before we got here (config load / connect).
338
+ if (this.#stopRequested !== undefined) {
339
+ const pending = this.#stopRequested;
340
+ this.#stopRequested = undefined;
341
+ this.#abort(pending);
342
+ }
343
+ const startedAt = Date.now();
344
+ this.#emit('run:start', { ...info });
345
+ let failure;
346
+ let result;
347
+ try {
348
+ result = await runWithLock(
349
+ this.#buildLock(),
350
+ {
351
+ logger: this.#logger,
352
+ onLockLost: this.#config?.onLockLost ?? 'abort',
353
+ owner: this.#runId,
354
+ onLockAcquired: (extra) => this.#emit('lock:acquired', { owner: this.#runId, ...extra }),
355
+ onLockReleased: (extra) => this.#emit('lock:released', { owner: this.#runId, ...extra }),
356
+ onLockLostEvent: (reason) => this.#emit('lock:lost', { owner: this.#runId, reason }),
357
+ ...(options.noLock ? { noLock: true } : {}),
358
+ },
359
+ (lockSignal) => fn(AbortSignal.any([lockSignal, stopper.signal])),
360
+ );
361
+ return result;
362
+ } catch (error) {
363
+ failure = error;
364
+ throw error;
365
+ } finally {
366
+ // Result counts, so a metrics subscriber gets "3 applied in 812ms"
367
+ // without reconstructing it from per-migration events. On the failure
368
+ // path the partial rows live on the error's context — exactly the case
369
+ // where "how far did it get?" is the question, so they count too. One
370
+ // pass fills both counters.
371
+ const rows = Array.isArray(result)
372
+ ? result
373
+ : failure instanceof MigronautError && Array.isArray(failure.context?.results)
374
+ ? failure.context.results
375
+ : null;
376
+ let summary = {};
377
+ if (rows) {
378
+ let applied = 0;
379
+ let reverted = 0;
380
+ for (const row of rows) {
381
+ if (row.status === 'applied') applied += 1;
382
+ else if (row.status === 'reverted') reverted += 1;
383
+ }
384
+ summary = { applied, reverted, total: rows.length };
385
+ }
386
+ this.#emit('run:end', {
387
+ ...info,
388
+ success: failure === undefined,
389
+ durationMs: Date.now() - startedAt,
390
+ ...summary,
391
+ // A raw Error here would hand subscribers an unredacted driver message
392
+ // (which can echo the credentialed URI) — errorText is the same
393
+ // chokepoint every log line and result row already goes through.
394
+ ...(failure ? { error: errorText(failure) } : {}),
395
+ });
396
+ this.#abort = undefined;
397
+ this.#runId = undefined;
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Attach partial results to an error's context. Copy-on-write: the error may
403
+ * live on a shared abort signal (see #assertNotAborted) or already carry
404
+ * results from an earlier phase (redo's down half) — mutating its context in
405
+ * place would let a later phase overwrite what an earlier one recorded.
406
+ */
407
+ #attachResults(error, results) {
408
+ if (!(error instanceof MigronautError) || !error.context) return;
409
+ error.context = { ...error.context, results: [...results] };
410
+ }
411
+
412
+ /** Throw whatever aborted the run (LockLostError or RunAbortedError) */
413
+ #assertNotAborted(signal, results) {
414
+ if (!signal?.aborted) return;
415
+ const reason = signal.reason;
416
+ if (reason instanceof MigronautError) {
417
+ if (results) this.#attachResults(reason, results);
418
+ throw reason;
419
+ }
420
+ throw new RunAbortedError('Run aborted', { ...(results ? { results: [...results] } : {}) });
421
+ }
422
+
423
+ /**
424
+ * Inspect the current migration lock without modifying it. Returns the holder,
425
+ * or null when no lock is held.
426
+ */
427
+ async lockInfo() {
428
+ await this.#ensureConfig();
429
+ await this.connect();
430
+ return toLockInfo(await this.#buildLock().inspect());
431
+ }
432
+
433
+ /**
434
+ * Force-release the migration lock regardless of who holds it — for clearing a
435
+ * lock left behind by a crashed run (`migronaut unlock`). Returns the holder that was
436
+ * removed, or null if no lock was held.
437
+ */
438
+ async forceUnlock() {
439
+ await this.#ensureConfig();
440
+ await this.connect();
441
+ return toLockInfo(await this.#buildLock().forceRelease());
442
+ }
443
+
444
+ /** Internal accessors that assume a successful connect() */
445
+ #requireDb() {
446
+ if (!this.#db) {
447
+ throw new ConnectionFailedError('Not connected — call connect() first');
448
+ }
449
+ return this.#db;
450
+ }
451
+
452
+ #requireChangelog() {
453
+ if (!this.#changelog) {
454
+ throw new ConnectionFailedError('Not connected — call connect() first');
455
+ }
456
+ return this.#changelog;
457
+ }
458
+
459
+ #migrationsPath() {
460
+ return path.resolve(this.#config?.migrationsDir ?? './migrations');
461
+ }
462
+
463
+ /**
464
+ * Reject non-string filenames before they reach a changelog query or a path
465
+ * join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
466
+ * become a query-operator injection in `findOne({ name })`.
467
+ */
468
+ #assertFilename(filename) {
469
+ if (filename !== undefined && typeof filename !== 'string') {
470
+ throw new MigrationInvalidNameError('Migration name must be a string', {
471
+ name: filename,
472
+ });
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Resolve a migration name to an absolute path inside the migrations dir.
478
+ *
479
+ * The name must be a bare filename: a name containing a path separator, a
480
+ * NUL byte, or `.`/`..` is rejected with MigrationInvalidNameError.
481
+ * This prevents path traversal — e.g. `migronaut up ../../evil.js` would otherwise
482
+ * resolve (and `loadMigrationFile` execute) a file outside the migrations
483
+ * directory. A final containment check guards against any residual escape.
484
+ */
485
+ #filepath(name) {
486
+ const dir = this.#migrationsPath();
487
+ if (
488
+ typeof name !== 'string' ||
489
+ name.length === 0 ||
490
+ name === '.' ||
491
+ name === '..' ||
492
+ name.includes('/') ||
493
+ name.includes('\\') ||
494
+ name.includes('\0')
495
+ ) {
496
+ throw new MigrationInvalidNameError(
497
+ 'Invalid migration name — must be a bare filename with no path segments',
498
+ { name },
499
+ );
500
+ }
501
+ const resolved = path.join(dir, name);
502
+ const relative = path.relative(dir, resolved);
503
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
504
+ throw new MigrationInvalidNameError('Migration name escapes the migrations directory', {
505
+ name,
506
+ });
507
+ }
508
+ return resolved;
509
+ }
510
+
511
+ /** List migration files on disk, sorted ascending */
512
+ async #listMigrationFiles() {
513
+ const dir = this.#migrationsPath();
514
+ const extensions = this.#config?.fileExtensions ?? ['.ts', '.js'];
515
+ let entries;
516
+ try {
517
+ entries = await fs.readdir(dir, { withFileTypes: true });
518
+ } catch (error) {
519
+ if (error.code === 'ENOENT') return [];
520
+ throw error;
521
+ }
522
+ const matches = [];
523
+ for (const entry of entries) {
524
+ // A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
525
+ // to the migrations is not a migration — including it would hard-fail
526
+ // the whole run with MigrationInvalidExportError.
527
+ if (!entry.isFile()) continue;
528
+ const file = entry.name;
529
+ if (file.startsWith('.')) continue;
530
+ if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
531
+ for (const ext of extensions) {
532
+ if (file.endsWith(ext)) {
533
+ matches.push(file);
534
+ break;
535
+ }
536
+ }
537
+ }
538
+ return matches.sort();
539
+ }
540
+
541
+ /** Compute the next batch number (monotonic across the full history) */
542
+ async #nextBatch() {
543
+ return (await this.#requireChangelog().getMaxBatch(this.#requireDb())) + 1;
544
+ }
545
+
546
+ /**
547
+ * Validate the `--steps` option for `down`/`dry-run down`: a positive integer,
548
+ * mutually exclusive with a filename and `--batch`. No-op when steps is unset.
549
+ */
550
+ #assertStepsValid(steps, filename, batch) {
551
+ if (steps === undefined) {
552
+ return;
553
+ }
554
+ if (filename) {
555
+ throw new ConfigInvalidError('Cannot combine a filename with --steps', { filename });
556
+ }
557
+ if (batch !== undefined) {
558
+ throw new ConfigInvalidError('Cannot combine --batch with --steps', { batch, steps });
559
+ }
560
+ if (!Number.isInteger(steps) || steps < 1) {
561
+ throw new ConfigInvalidError('--steps must be a positive integer', { steps });
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Keep only the pending migrations up to and including `to`.
567
+ *
568
+ * `to` must name a migration that exists; it may already be applied (then
569
+ * nothing before it is pending either, and the result is empty), which is
570
+ * what makes `up --to X` idempotent — running it twice is a no-op rather
571
+ * than an error.
572
+ */
573
+ #truncateAtTarget(pending, allFiles, to) {
574
+ if (!allFiles.includes(to)) {
575
+ throw new MigrationFileNotFoundError('Migration file not found', { to });
576
+ }
577
+ const kept = [];
578
+ for (const file of pending) {
579
+ if (file > to) break;
580
+ kept.push(file);
581
+ }
582
+ return kept;
583
+ }
584
+
585
+ /**
586
+ * `--to` names a point in the sequence, so it cannot be combined with the
587
+ * other ways of choosing targets.
588
+ */
589
+ #assertToValid(to, filename, options = {}) {
590
+ if (to === undefined) return;
591
+ this.#assertFilename(to);
592
+ if (filename) {
593
+ throw new ConfigInvalidError('Cannot combine a filename with --to', { filename, to });
594
+ }
595
+ if (options.steps !== undefined) {
596
+ throw new ConfigInvalidError('Cannot combine --steps with --to', {
597
+ steps: options.steps,
598
+ to,
599
+ });
600
+ }
601
+ if (options.batch !== undefined) {
602
+ throw new ConfigInvalidError('Cannot combine --batch with --to', {
603
+ batch: options.batch,
604
+ to,
605
+ });
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Validate `--batch`. Without this a typo (`--batch abc` → NaN) matches no
611
+ * records, so the run prints "Nothing to rollback" and exits 0 — the worst
612
+ * possible answer to a mistyped rollback.
613
+ */
614
+ #assertBatchValid(batch) {
615
+ if (batch === undefined) return;
616
+ if (!Number.isInteger(batch) || batch < 1) {
617
+ throw new ConfigInvalidError('--batch must be a positive integer', { batch });
618
+ }
619
+ }
620
+
621
+ /**
622
+ * The shared skeleton of a migration run: beforeAll → per-name loop with an
623
+ * abort check between migrations → afterAll (also on the failure path, which
624
+ * is exactly when a cleanup/notification hook matters most). `execute` does
625
+ * the per-migration work and returns `'done'` when it ran (vs. skipped).
626
+ */
627
+ async #runSequence({ direction, names, context, signal, execute }) {
628
+ const config = this.#config;
629
+ const results = [];
630
+ let doneCount = 0;
631
+ let failure;
632
+ try {
633
+ await this.#runHook(config.hooks?.beforeAll, 'beforeAll', [context]);
634
+ for (const [index, name] of names.entries()) {
635
+ // Between migrations is the only safe place to stop: the one in flight
636
+ // has committed, and the next has not started.
637
+ this.#assertNotAborted(signal, results);
638
+ const outcome = await execute(name, index, results);
639
+ if (outcome === 'done') doneCount += 1;
640
+ }
641
+ } catch (error) {
642
+ failure = error;
643
+ }
644
+ const succeeded = failure === undefined;
645
+ // afterAll runs on the failure path too — which is exactly when a
646
+ // cleanup/notification hook matters most. Mirrors runWithLock's release
647
+ // discipline: when the body already failed, a throwing afterAll must not
648
+ // replace that error — the migration failure (and its context.results) is
649
+ // the diagnosis the caller needs, not the notification hook's own trouble.
650
+ try {
651
+ await this.#runHook(config.hooks?.afterAll, 'afterAll', [
652
+ context,
653
+ { success: succeeded, applied: doneCount, direction },
654
+ ]);
655
+ } catch (hookError) {
656
+ if (succeeded) throw hookError;
657
+ this.#logger.warn(
658
+ `⚠ afterAll hook failed after a failed run: ${errorText(hookError)}`,
659
+ this.#fields({ hook: 'afterAll', error: errorText(hookError) }),
660
+ );
661
+ }
662
+ if (failure !== undefined) throw failure;
663
+ return results;
664
+ }
665
+
666
+ /**
667
+ * Execute one migration end to end: beforeEach → load → run (with the
668
+ * changelog write inside the transaction via `onSuccess`) → events, logs,
669
+ * result row, afterEach — and the mirrored error path. Shared verbatim by
670
+ * `up` and `down`, so a fix to one direction cannot silently miss the other.
671
+ */
672
+ async #executeMigration({ name, direction, context, index, total, results, batch, onSuccess }) {
673
+ const config = this.#config;
674
+ const logger = this.#logger;
675
+ const batchField = batch !== undefined ? { batch } : {};
676
+
677
+ await this.#runHook(config.hooks?.beforeEach, 'beforeEach', [
678
+ name,
679
+ context,
680
+ { direction, index, total },
681
+ ]);
682
+ const migration = await loadMigrationFile(this.#filepath(name), {
683
+ reload: config.reloadMigrations ?? false,
684
+ });
685
+ const useTransaction = migration.useTransaction ?? config.useTransaction;
686
+
687
+ this.#progress?.onStart(name, direction);
688
+ this.#emit('migration:start', { migration: name, direction, ...batchField });
689
+ try {
690
+ // The changelog write happens inside runMigration so that, under
691
+ // useTransaction, it commits atomically with the migration itself.
692
+ const { duration } = await runMigration({
693
+ name,
694
+ migration,
695
+ direction,
696
+ context,
697
+ useTransaction,
698
+ logger,
699
+ ...(config.timeoutMs ? { timeoutMs: config.timeoutMs } : {}),
700
+ onSuccess: (elapsed, session) => onSuccess(migration, elapsed, session),
701
+ ...(config.hooks ? { hooks: config.hooks } : {}),
702
+ });
703
+ this.#progress?.onStop('success');
704
+ this.#emit('migration:success', {
705
+ migration: name,
706
+ direction,
707
+ ...batchField,
708
+ durationMs: duration,
709
+ });
710
+ const label = direction === 'up' ? '✔ Applied ' : '↩ Reverted';
711
+ logger.info(
712
+ `${label} ${name} [${duration}ms]`,
713
+ this.#fields({ migration: name, direction, ...batchField, durationMs: duration }),
714
+ );
715
+ results.push({
716
+ file: name,
717
+ status: direction === 'up' ? 'applied' : 'reverted',
718
+ duration,
719
+ ...batchField,
720
+ });
721
+ await this.#runHook(config.hooks?.afterEach, 'afterEach', [
722
+ name,
723
+ duration,
724
+ context,
725
+ { direction, index, total },
726
+ ]);
727
+ return duration;
728
+ } catch (error) {
729
+ this.#progress?.onStop('error');
730
+ // errorText, not the raw Error: a driver message can echo the
731
+ // credentialed URI, and event subscribers (Sentry, JSON logs) would
732
+ // ship it — the same redaction the log line below already gets.
733
+ this.#emit('migration:error', { migration: name, direction, error: errorText(error) });
734
+ logger.error(
735
+ `✖ Error ${name}`,
736
+ this.#fields({ migration: name, direction, error: errorText(error) }),
737
+ );
738
+ results.push({ file: name, status: 'error', error: errorText(error) });
739
+ // Carry what already succeeded, so `--json` consumers can tell which
740
+ // migrations landed before the failure instead of losing the list.
741
+ this.#attachResults(error, results);
742
+ throw error;
743
+ }
744
+ }
745
+
746
+ /** Run all pending migrations, or a specific named file */
747
+ async up(filename, options = {}) {
748
+ this.#assertFilename(filename);
749
+ this.#assertToValid(options.to, filename, options);
750
+ return this.#runWindow(async () => {
751
+ await this.#ensureConfig();
752
+ await this.connect();
753
+ return this.#withLock(options, { command: 'up', direction: 'up' }, (signal) =>
754
+ this.#runUp(filename, options, signal),
755
+ );
756
+ });
757
+ }
758
+
759
+ async #runUp(filename, options = {}, signal) {
760
+ const force = options.force ?? false;
761
+ const config = this.#config;
762
+ const db = this.#requireDb();
763
+ const changelog = this.#requireChangelog();
764
+ const logger = this.#logger;
765
+
766
+ // A strict bulk run needs the applied records' checksums anyway, so fetch
767
+ // full records once and derive the name set from them; a single-file run
768
+ // needs only that file's record, so one getByName is both the
769
+ // applied-check and the checksum source; otherwise the cheaper covered
770
+ // name query suffices.
771
+ const strictBulk = !filename && config.strict && !force;
772
+ const appliedRecords = strictBulk ? await changelog.getApplied(db) : undefined;
773
+ const appliedNames = new Set();
774
+ let singleRecord = null;
775
+ if (filename) {
776
+ singleRecord = await changelog.getByName(db, filename);
777
+ 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
+ }
783
+
784
+ let targets;
785
+ if (filename) {
786
+ const filepath = this.#filepath(filename);
787
+ try {
788
+ await fs.access(filepath);
789
+ } catch {
790
+ throw new MigrationFileNotFoundError('Migration file not found', { filename });
791
+ }
792
+ targets = [filename];
793
+ } else {
794
+ const files = await this.#listMigrationFiles();
795
+ targets = [];
796
+ for (const file of files) {
797
+ if (!appliedNames.has(file)) targets.push(file);
798
+ }
799
+ if (options.to !== undefined) {
800
+ targets = this.#truncateAtTarget(targets, files, options.to);
801
+ }
802
+ // Pending files are the only targets here, so the per-target checksum
803
+ // check below can never see an applied one. Verify them up front instead,
804
+ // otherwise `up --strict` over a bulk run would police nothing.
805
+ if (strictBulk) await this.#assertNoChecksumDrift(appliedRecords);
806
+ }
807
+
808
+ if (targets.length === 0) {
809
+ logger.info('Nothing to migrate', this.#fields({ direction: 'up' }));
810
+ return [];
811
+ }
812
+
813
+ const context = buildContext(this.#client, db, config.mongoose, signal);
814
+ // Without --step every file in this run shares one batch. With --step each
815
+ // applied file gets its own sequential batch (base, base+1, …) so a later
816
+ // `down` can revert them individually. Only successful applies advance the
817
+ // counter, so --step never leaves gaps.
818
+ const baseBatch = await this.#nextBatch();
819
+ let appliedCount = 0;
820
+
821
+ return this.#runSequence({
822
+ direction: 'up',
823
+ names: targets,
824
+ context,
825
+ signal,
826
+ execute: async (name, index, results) => {
827
+ const filepath = this.#filepath(name);
828
+ const checksum = await computeChecksum(filepath);
829
+
830
+ if (appliedNames.has(name)) {
831
+ if (!force) {
832
+ const existing = singleRecord ?? (await changelog.getByName(db, name));
833
+ const mismatch = existing !== null && existing.checksum !== checksum;
834
+ if (mismatch && config.strict) {
835
+ throw new ChecksumMismatchError(`Checksum mismatch for ${name}`, {
836
+ name,
837
+ expected: existing?.checksum,
838
+ actual: checksum,
839
+ });
840
+ }
841
+ if (mismatch) {
842
+ logger.warn(
843
+ `⚠ Warning Checksum mismatch: ${name}`,
844
+ this.#fields({ migration: name, direction: 'up', expected: existing?.checksum }),
845
+ );
846
+ }
847
+ logger.debug(
848
+ `⏭ Skipped ${name}`,
849
+ this.#fields({ migration: name, direction: 'up', reason: 'already-applied' }),
850
+ );
851
+ this.#emit('migration:skipped', {
852
+ migration: name,
853
+ direction: 'up',
854
+ reason: 'already-applied',
855
+ });
856
+ results.push({ file: name, status: 'skipped', reason: 'Already applied' });
857
+ // No beforeEach fired for a skipped migration, so no afterEach owes
858
+ // one either — the hooks stay paired.
859
+ return 'skipped';
860
+ }
861
+ // force: fall through and re-run, ignoring applied state and checksum
862
+ logger.warn(
863
+ `⚠ Forcing re-run of already-applied ${name}`,
864
+ this.#fields({ migration: name, direction: 'up', forced: true }),
865
+ );
866
+ }
867
+
868
+ const batch = options.step ? baseBatch + appliedCount : baseBatch;
869
+ await this.#executeMigration({
870
+ name,
871
+ direction: 'up',
872
+ context,
873
+ index,
874
+ total: targets.length,
875
+ results,
876
+ batch,
877
+ onSuccess: (migration, elapsed, session) =>
878
+ changelog.markApplied(
879
+ db,
880
+ {
881
+ name,
882
+ batch,
883
+ status: 'applied',
884
+ appliedAt: new Date(),
885
+ checksum,
886
+ environment: this.#environment(),
887
+ executedBy: safeUsername(),
888
+ duration: elapsed,
889
+ ...(this.#runId ? { runId: this.#runId } : {}),
890
+ ...(migration.description ? { description: migration.description } : {}),
891
+ },
892
+ session,
893
+ ),
894
+ });
895
+ appliedCount += 1;
896
+ return 'done';
897
+ },
898
+ });
899
+ }
900
+
901
+ /**
902
+ * Verify that every applied migration still matches the file on disk. Used by
903
+ * `up --strict` on a bulk run, where the per-target loop only ever sees
904
+ * pending files and so would never notice drift in the applied ones.
905
+ * Takes the already-fetched applied records — no extra round trips — and
906
+ * hashes with bounded concurrency, since this runs while holding the lock.
907
+ */
908
+ async #assertNoChecksumDrift(records) {
909
+ await mapLimit(records, FS_CONCURRENCY, async (record) => {
910
+ const filepath = this.#filepath(record.name);
911
+ let actual;
912
+ try {
913
+ actual = await computeChecksum(filepath);
914
+ } catch (error) {
915
+ // A deleted file has no checksum to compare; status() reports it as
916
+ // missing, which is a separate concern from drift. Hashing directly
917
+ // and catching ENOENT beats an access() probe: half the syscalls and
918
+ // no TOCTOU window.
919
+ if (error.code === 'ENOENT') return;
920
+ throw error;
921
+ }
922
+ if (record.checksum !== actual) {
923
+ throw new ChecksumMismatchError(`Checksum mismatch for ${record.name}`, {
924
+ name: record.name,
925
+ expected: record.checksum,
926
+ actual,
927
+ });
928
+ }
929
+ });
930
+ }
931
+
932
+ /**
933
+ * Invoke a user lifecycle hook. A throwing hook becomes a HookFailedError so
934
+ * it can never surface as an untyped Error, and the hook that failed is named.
935
+ */
936
+ async #runHook(hook, hookName, args) {
937
+ if (!hook) return;
938
+ try {
939
+ await hook(...args);
940
+ } catch (error) {
941
+ throw new HookFailedError(
942
+ `The ${hookName} hook failed`,
943
+ { hook: hookName, cause: errorText(error) },
944
+ { cause: error },
945
+ );
946
+ }
947
+ }
948
+
949
+ /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
950
+ async down(filename, options = {}) {
951
+ this.#assertFilename(filename);
952
+ this.#assertStepsValid(options.steps, filename, options.batch);
953
+ this.#assertBatchValid(options.batch);
954
+ this.#assertToValid(options.to, filename, options);
955
+ return this.#runWindow(async () => {
956
+ await this.#ensureConfig();
957
+ await this.connect();
958
+ return this.#withLock(options, { command: 'down', direction: 'down' }, (signal) =>
959
+ this.#runDown(filename, options, signal),
960
+ );
961
+ });
962
+ }
963
+
964
+ /**
965
+ * Resolve which applied records a `down` (or its dry-run) targets: a named
966
+ * file, the last N steps, everything after `--to`, or a batch. The single
967
+ * source of truth for that selection — `#runDown` executes it and `dryRun`
968
+ * previews it, so the two can never disagree on what would be reverted.
969
+ *
970
+ * Returns `{ records, preserveOrder }`; when `preserveOrder` is true the
971
+ * records are already in revert order (newest applied first) and must not be
972
+ * re-sorted by filename. Throws IrreversibleMigrationError for
973
+ * migrate-mongo-imported records — in previews as much as in real rollbacks.
974
+ */
975
+ async #selectDownTargets(filename, options = {}) {
976
+ const db = this.#requireDb();
977
+ const changelog = this.#requireChangelog();
978
+
979
+ let records;
980
+ let preserveOrder = false;
981
+ if (filename) {
982
+ const record = await changelog.getByName(db, filename);
983
+ if (!record || record.status !== 'applied') {
984
+ throw new NotAppliedError('Migration is not applied', { filename });
985
+ }
986
+ records = [record];
987
+ } else if (options.steps !== undefined) {
988
+ // Revert the last N applied migrations, newest first, ignoring batches.
989
+ // Server-side sort+limit on the status_appliedAt_name index — never the
990
+ // whole applied history transferred and re-sorted in JS to slice N.
991
+ records = await changelog.getLastAppliedN(db, options.steps);
992
+ preserveOrder = true;
993
+ } else if (options.to !== undefined) {
994
+ // Roll the database back *to* that point: everything applied after the
995
+ // named migration goes, the named one stays. Exclusive, so `up --to X`
996
+ // followed by `down --to X` is a round trip back to the same state.
997
+ // Validate the target first, then fetch only what follows it — both
998
+ // covered by indexes instead of filtering the full history client-side.
999
+ const targetRecord = await changelog.getByName(db, options.to);
1000
+ if (!targetRecord || targetRecord.status !== 'applied') {
1001
+ throw new NotAppliedError('Migration is not applied', { to: options.to });
1002
+ }
1003
+ records = await changelog.getAppliedAfter(db, options.to);
1004
+ } else {
1005
+ const batch = options.batch ?? (await changelog.getLastBatch(db));
1006
+ if (batch === null) {
1007
+ records = [];
1008
+ } else {
1009
+ const byBatch = await changelog.getByBatch(db, batch);
1010
+ records = [];
1011
+ for (const record of byBatch) {
1012
+ if (record.status === 'applied') records.push(record);
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ // Preflight, before running or writing anything: migrate-mongo-imported
1018
+ // records are forward-only. Refuse the whole rollback up front with a clear
1019
+ // reason so the changelog and collection are never left half-reverted.
1020
+ if (records.length > 0) this.#assertReversible(records);
1021
+ return { records, preserveOrder };
1022
+ }
1023
+
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
+ async #runDown(filename, options = {}, signal) {
1036
+ const config = this.#config;
1037
+ const db = this.#requireDb();
1038
+ const changelog = this.#requireChangelog();
1039
+ const logger = this.#logger;
1040
+
1041
+ const { records: toRevert, preserveOrder } = await this.#selectDownTargets(filename, options);
1042
+
1043
+ if (toRevert.length === 0) {
1044
+ logger.info('Nothing to rollback', this.#fields({ direction: 'down' }));
1045
+ return [];
1046
+ }
1047
+
1048
+ const names = this.#downNames(toRevert, preserveOrder);
1049
+
1050
+ // The signal must reach the rollback context too: a long-running down()
1051
+ // under SIGTERM or a lost lock is exactly the case ctx.signal exists for.
1052
+ const context = buildContext(this.#client, db, config.mongoose, signal);
1053
+
1054
+ return this.#runSequence({
1055
+ direction: 'down',
1056
+ names,
1057
+ context,
1058
+ signal,
1059
+ execute: async (name, index, results) => {
1060
+ await this.#executeMigration({
1061
+ name,
1062
+ direction: 'down',
1063
+ context,
1064
+ index,
1065
+ total: names.length,
1066
+ results,
1067
+ onSuccess: async (_migration, _elapsed, session) => {
1068
+ const result = await changelog.markReverted(db, name, session);
1069
+ // Under --no-lock or onLockLost:'warn' a peer may have flipped the
1070
+ // record first: the down() body already ran against the data, but
1071
+ // the changelog still claims the migration is applied. Silence
1072
+ // here would hide exactly that divergence.
1073
+ if (result.matchedCount === 0) {
1074
+ this.#logger.warn(
1075
+ `⚠ Warning Changelog record for ${name} was not 'applied' — revert not recorded`,
1076
+ this.#fields({
1077
+ migration: name,
1078
+ direction: 'down',
1079
+ event: 'changelog:revert-miss',
1080
+ }),
1081
+ );
1082
+ }
1083
+ },
1084
+ });
1085
+ return 'done';
1086
+ },
1087
+ });
1088
+ }
1089
+
1090
+ /**
1091
+ * Refuse rollback of any migrate-mongo-imported record. These are forward-only:
1092
+ * their files use migrate-mongo's positional `up(db, client)`/`down(db, client)`
1093
+ * signature, which migronaut cannot invoke safely, so reverting them could corrupt the
1094
+ * collection. Throws before any migration runs or the changelog is touched.
1095
+ */
1096
+ #assertReversible(records) {
1097
+ const names = [];
1098
+ for (const record of records) {
1099
+ if (record.origin === 'migrate-mongo') names.push(record.name);
1100
+ }
1101
+ if (names.length === 0) {
1102
+ return;
1103
+ }
1104
+ this.#logger.error(
1105
+ `✖ Cannot roll back ${names.length} migrate-mongo-imported migration(s): ${names.join(', ')}`,
1106
+ );
1107
+ this.#logger.debug(
1108
+ 'These were adopted via `migronaut import` (forward-only). Their files use the positional ' +
1109
+ 'migrate-mongo signature, which migronaut cannot run. Revert them manually or re-author ' +
1110
+ 'them in migronaut format.',
1111
+ );
1112
+ throw new IrreversibleMigrationError(
1113
+ `Cannot roll back migrate-mongo-imported migration(s): ${names.join(', ')}`,
1114
+ { names },
1115
+ );
1116
+ }
1117
+
1118
+ /**
1119
+ * Rollback then re-apply: the last applied migration, or a specific file.
1120
+ *
1121
+ * Both directions run under a single lock. Releasing between them would let
1122
+ * another process slip in while the migration is reverted — and a crash in
1123
+ * that gap would leave the database in the rolled-back state with no lock to
1124
+ * show for it.
1125
+ */
1126
+ async redo(filename, options = {}) {
1127
+ this.#assertFilename(filename);
1128
+ return this.#runWindow(async () => {
1129
+ await this.#ensureConfig();
1130
+ await this.connect();
1131
+ const changelog = this.#requireChangelog();
1132
+
1133
+ return this.#withLock(options, { command: 'redo' }, async (signal) => {
1134
+ // Resolved *inside* the lock: picking the newest applied migration before
1135
+ // acquiring it races a peer instance — this redo would then revert and
1136
+ // re-apply a migration that is no longer the newest.
1137
+ let target = filename;
1138
+ if (!target) {
1139
+ // A server-side top-1 sort+limit — not the whole applied history sorted
1140
+ // in memory just to pick its newest element.
1141
+ const newest = await changelog.getNewestApplied(this.#requireDb());
1142
+ if (!newest) {
1143
+ this.#logger.info('Nothing to redo', this.#fields({ command: 'redo' }));
1144
+ return [];
1145
+ }
1146
+ target = newest.name;
1147
+ }
1148
+
1149
+ const downResults = await this.#runDown(target, {}, signal);
1150
+ let upResults;
1151
+ try {
1152
+ upResults = await this.#runUp(target, {}, signal);
1153
+ } catch (error) {
1154
+ // The revert already happened — after a failed re-apply that is the
1155
+ // single most important fact, so the down rows must survive into the
1156
+ // error a `--json` consumer sees.
1157
+ if (error instanceof MigronautError && error.context) {
1158
+ const existing = Array.isArray(error.context.results) ? error.context.results : [];
1159
+ this.#attachResults(error, [...downResults, ...existing]);
1160
+ }
1161
+ throw error;
1162
+ }
1163
+ return [...downResults, ...upResults];
1164
+ });
1165
+ });
1166
+ }
1167
+
1168
+ /** Preview what would run — never writes to the database */
1169
+ async dryRun(direction, filename, options = {}) {
1170
+ this.#assertFilename(filename);
1171
+ // `batch`/`to` must be passed too, or a conflict that `down` rejects would
1172
+ // be silently allowed in its own preview.
1173
+ this.#assertStepsValid(options.steps, filename, options.batch);
1174
+ this.#assertBatchValid(options.batch);
1175
+ this.#assertToValid(options.to, filename, options);
1176
+ await this.#ensureConfig();
1177
+ await this.connect();
1178
+ const db = this.#requireDb();
1179
+ const changelog = this.#requireChangelog();
1180
+ const logger = this.#logger;
1181
+
1182
+ let names;
1183
+ const recordByName = new Map();
1184
+ if (direction === 'up') {
1185
+ // A preview only ever reports pending files or applied records, so the
1186
+ // reverted history is dead weight here.
1187
+ const records = await changelog.getApplied(db);
1188
+ for (const record of records) {
1189
+ recordByName.set(record.name, record);
1190
+ }
1191
+ const applied = new Set(recordByName.keys());
1192
+ if (filename) {
1193
+ // Same preflight as a real `up`, so a preview never invents a pending
1194
+ // row for a file that does not exist.
1195
+ const filepath = this.#filepath(filename);
1196
+ const exists = await fs
1197
+ .access(filepath)
1198
+ .then(() => true)
1199
+ .catch(() => false);
1200
+ if (!exists) {
1201
+ throw new MigrationFileNotFoundError('Migration file not found', { filename });
1202
+ }
1203
+ names = [filename];
1204
+ } else {
1205
+ const files = await this.#listMigrationFiles();
1206
+ names = [];
1207
+ for (const file of files) {
1208
+ if (!applied.has(file)) names.push(file);
1209
+ }
1210
+ // `up --to` gets the same preview surface as the real run.
1211
+ if (options.to !== undefined) {
1212
+ names = this.#truncateAtTarget(names, files, options.to);
1213
+ }
1214
+ }
1215
+ } else {
1216
+ // The same selection the real `down` executes — including the
1217
+ // irreversible-import refusal, so a preview can never show a rollback
1218
+ // the real run would reject.
1219
+ const { records, preserveOrder } = await this.#selectDownTargets(filename, options);
1220
+ for (const record of records) {
1221
+ recordByName.set(record.name, record);
1222
+ }
1223
+ names = this.#downNames(records, preserveOrder);
1224
+ }
1225
+
1226
+ const rows = await mapLimit(names, FS_CONCURRENCY, (name) =>
1227
+ this.#buildStatusRow(name, recordByName.get(name)),
1228
+ );
1229
+ logger.info(
1230
+ `◎ Dry-run Would ${direction === 'up' ? 'apply' : 'revert'}: ${rows.length}`,
1231
+ this.#fields({ direction, count: rows.length, dryRun: true }),
1232
+ );
1233
+ return rows;
1234
+ }
1235
+
1236
+ /** Full migration status for all known files and records */
1237
+ async status() {
1238
+ await this.#ensureConfig();
1239
+ await this.connect();
1240
+ const records = await this.#requireChangelog().getAll(this.#requireDb());
1241
+ const recordByName = new Map();
1242
+ for (const record of records) recordByName.set(record.name, record);
1243
+
1244
+ const names = new Set(recordByName.keys());
1245
+ for (const file of await this.#listMigrationFiles()) names.add(file);
1246
+ const sortedNames = [...names].sort();
1247
+ // Each row may read and hash a file; unbounded fan-out over thousands of
1248
+ // migrations exhausts the descriptor limit.
1249
+ return mapLimit(sortedNames, FS_CONCURRENCY, (name) =>
1250
+ this.#buildStatusRow(name, recordByName.get(name)),
1251
+ );
1252
+ }
1253
+
1254
+ /**
1255
+ * Read-only health check of the setup — see {@link runAudit} in audit.js for
1256
+ * the checks themselves; this only wires in the kit's capabilities.
1257
+ */
1258
+ async audit() {
1259
+ return runAudit({
1260
+ ensureConfig: () => this.#ensureConfig(),
1261
+ connect: () => this.connect(),
1262
+ getDb: () => this.#requireDb(),
1263
+ inspectLock: () => this.#buildLock().inspect(),
1264
+ status: () => this.status(),
1265
+ });
1266
+ }
1267
+
1268
+ /** Filtered list of migrations */
1269
+ async list(filter = 'all') {
1270
+ // An unknown filter silently returning [] reads as "nothing to report" —
1271
+ // the worst possible answer to a typo.
1272
+ if (filter !== 'all' && filter !== 'pending' && filter !== 'applied') {
1273
+ throw new ConfigInvalidError("list filter must be 'all', 'pending' or 'applied'", { filter });
1274
+ }
1275
+ if (filter === 'pending') {
1276
+ return this.#listPending();
1277
+ }
1278
+ const rows = await this.status();
1279
+ if (filter === 'all') {
1280
+ return rows;
1281
+ }
1282
+ const filtered = [];
1283
+ for (const row of rows) {
1284
+ if (row.status === filter) filtered.push(row);
1285
+ }
1286
+ return filtered;
1287
+ }
1288
+
1289
+ /**
1290
+ * Pending migrations, without touching the applied ones.
1291
+ *
1292
+ * Going through `status()` would read and SHA-256 every applied file only to
1293
+ * discard those rows — turning `pendingMigrations()`, which exists to be a
1294
+ * cheap readiness probe, into a full re-hash of the migration history on
1295
+ * every health check. A pending row has no record, so `checksumOk` is always
1296
+ * null and there is nothing to hash.
1297
+ */
1298
+ async #listPending() {
1299
+ await this.#ensureConfig();
1300
+ await this.connect();
1301
+ const applied = new Set(await this.#requireChangelog().getAppliedNames(this.#requireDb()));
1302
+ const rows = [];
1303
+ for (const file of await this.#listMigrationFiles()) {
1304
+ if (applied.has(file)) continue;
1305
+ rows.push({
1306
+ file,
1307
+ status: 'pending',
1308
+ batch: null,
1309
+ appliedAt: null,
1310
+ duration: null,
1311
+ checksumOk: null,
1312
+ });
1313
+ }
1314
+ return rows;
1315
+ }
1316
+
1317
+ /**
1318
+ * Checksum of `filepath`, reusing the cached digest while `{mtimeMs, size}`
1319
+ * are unchanged. status()/audit()/list() re-hash every applied file on every
1320
+ * call otherwise — thousands of reads + SHA-256 per health check in a
1321
+ * long-lived process. Throws ENOENT for a missing file (callers decide what
1322
+ * that means).
1323
+ */
1324
+ async #cachedChecksum(filepath) {
1325
+ const stat = await fs.stat(filepath);
1326
+ const cached = this.#checksumCache.get(filepath);
1327
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1328
+ return cached.checksum;
1329
+ }
1330
+ const checksum = await computeChecksum(filepath);
1331
+ this.#checksumCache.set(filepath, { mtimeMs: stat.mtimeMs, size: stat.size, checksum });
1332
+ return checksum;
1333
+ }
1334
+
1335
+ /** Build a StatusRow for a migration, verifying checksum when possible */
1336
+ async #buildStatusRow(name, record) {
1337
+ const isApplied = record?.status === 'applied';
1338
+ let filepath;
1339
+ try {
1340
+ filepath = this.#filepath(name);
1341
+ } catch {
1342
+ // A legacy or tampered changelog record whose name is not a plain
1343
+ // filename must not take down the whole report — mark the one row
1344
+ // invalid and keep going.
1345
+ return {
1346
+ file: String(name),
1347
+ status: isApplied ? 'applied' : 'pending',
1348
+ batch: isApplied && record ? record.batch : null,
1349
+ appliedAt: isApplied && record ? record.appliedAt : null,
1350
+ duration: isApplied && record ? record.duration : null,
1351
+ checksumOk: isApplied ? false : null,
1352
+ invalid: true,
1353
+ };
1354
+ }
1355
+ // Hash (via the cache) and treat ENOENT as "missing" — the old
1356
+ // access()-first probe cost an extra syscall per row, for pending rows
1357
+ // whose result was never even used.
1358
+ let checksumOk = null;
1359
+ if (isApplied && record) {
1360
+ try {
1361
+ checksumOk = (await this.#cachedChecksum(filepath)) === record.checksum;
1362
+ } catch (error) {
1363
+ // A missing file has nothing to verify — checksumOk stays null.
1364
+ if (error.code !== 'ENOENT') throw error;
1365
+ }
1366
+ }
1367
+
1368
+ return {
1369
+ file: name,
1370
+ status: isApplied ? 'applied' : 'pending',
1371
+ batch: isApplied && record ? record.batch : null,
1372
+ appliedAt: isApplied && record ? record.appliedAt : null,
1373
+ duration: isApplied && record ? record.duration : null,
1374
+ checksumOk,
1375
+ ...(record?.description ? { description: record.description } : {}),
1376
+ };
1377
+ }
1378
+
1379
+ /**
1380
+ * Create a new migration file and return its absolute path.
1381
+ *
1382
+ * Resolved leniently: writing a file needs no database, so a config whose
1383
+ * factory fetches a connection from a secret manager must not make `create`
1384
+ * fail (or reach the network at all) when that manager is unreachable.
1385
+ */
1386
+ async create(name, options = {}) {
1387
+ const config = await this.#ensureConfig(false, true);
1388
+ const dir = this.#migrationsPath();
1389
+ await fs.mkdir(dir, { recursive: true });
1390
+ const templatePath = options.template ?? config.templatePath;
1391
+ const js = options.js ?? config.createExtension === 'js';
1392
+ const filepath = await createMigrationFile({
1393
+ dir,
1394
+ name,
1395
+ sequential: config.sequential,
1396
+ js,
1397
+ fileExtensions: config.fileExtensions,
1398
+ ...(templatePath ? { templatePath } : {}),
1399
+ });
1400
+ this.#logger.info(
1401
+ `✔ Created ${path.basename(filepath)}`,
1402
+ this.#fields({ command: 'create', file: path.basename(filepath) }),
1403
+ );
1404
+ return filepath;
1405
+ }
1406
+
1407
+ /** Create a migronaut config file in the working directory and return its path */
1408
+ async init(options = {}) {
1409
+ const values = {};
1410
+ if (this.#partialConfig.uri) values.uri = this.#partialConfig.uri;
1411
+ if (this.#partialConfig.dbName) values.dbName = this.#partialConfig.dbName;
1412
+ if (this.#partialConfig.migrationsDir) values.migrationsDir = this.#partialConfig.migrationsDir;
1413
+
1414
+ if (values.uri && maskUriCredentials(values.uri).hasCredentials) {
1415
+ this.#logger.warn(
1416
+ '⚠ The URI contains credentials — the password was masked in the generated file. ' +
1417
+ 'Provide the real value via MIGRONAUT_URI, a gitignored .env, or --secret-provider.',
1418
+ );
1419
+ }
1420
+
1421
+ const filepath = await createConfigFile({
1422
+ dir: process.cwd(),
1423
+ format: options.format ?? 'js',
1424
+ force: options.force ?? false,
1425
+ values,
1426
+ ...(options.secretProvider ? { secretProvider: true } : {}),
1427
+ });
1428
+ this.#logger.info(
1429
+ `✔ Created ${path.basename(filepath)}`,
1430
+ this.#fields({ command: 'init', file: path.basename(filepath) }),
1431
+ );
1432
+ return filepath;
1433
+ }
1434
+
1435
+ /**
1436
+ * Adopt an existing migrate-mongo `changelog` collection by mapping its
1437
+ * records into our schema and writing them to `migrationsCollection`. The
1438
+ * source collection is never modified. Forward-only: it records applied
1439
+ * history so `up` skips it correctly — it does not adapt legacy migration
1440
+ * file signatures, so `down`/`redo` on imported files is unsupported.
1441
+ */
1442
+ async import(options = {}) {
1443
+ // Validate before connecting: a bad --from/--to must not cost a round trip
1444
+ // or take the lock. Defaults come from the already-validated config.
1445
+ if (options.from !== undefined && !isCollectionName(options.from)) {
1446
+ throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
1447
+ }
1448
+ if (options.to !== undefined && !isCollectionName(options.to)) {
1449
+ throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
1450
+ }
1451
+ return this.#runWindow(async () => {
1452
+ await this.#ensureConfig();
1453
+ await this.connect();
1454
+ // See runImport in import-runner.js for the mechanics; this wires in the
1455
+ // kit's capabilities, including the abort signal for long imports.
1456
+ return this.#withLock(options, { command: 'import' }, (signal) =>
1457
+ runImport(
1458
+ {
1459
+ config: this.#config,
1460
+ db: this.#requireDb(),
1461
+ changelog: this.#requireChangelog(),
1462
+ logger: this.#logger,
1463
+ fields: (extra) => this.#fields(extra),
1464
+ filepath: (name) => this.#filepath(name),
1465
+ assertNotAborted: (abortSignal) => this.#assertNotAborted(abortSignal),
1466
+ },
1467
+ options,
1468
+ signal,
1469
+ ),
1470
+ );
1471
+ });
1472
+ }
1473
+ }
1474
+
1475
+ module.exports = { MigratorKit };