@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.
- package/CHANGELOG.md +128 -0
- package/LICENSE +22 -0
- package/README.md +773 -0
- package/bin/migronaut.js +36 -0
- package/index.d.ts +903 -0
- package/index.js +1 -0
- package/migronaut.schema.json +135 -0
- package/package.json +105 -0
- package/src/cli/args.js +314 -0
- package/src/cli/commands/audit.js +40 -0
- package/src/cli/commands/create.js +29 -0
- package/src/cli/commands/down.js +30 -0
- package/src/cli/commands/dry-run.js +36 -0
- package/src/cli/commands/import.js +33 -0
- package/src/cli/commands/init.js +64 -0
- package/src/cli/commands/list.js +19 -0
- package/src/cli/commands/lock.js +35 -0
- package/src/cli/commands/redo.js +16 -0
- package/src/cli/commands/status.js +52 -0
- package/src/cli/commands/unlock.js +43 -0
- package/src/cli/commands/up.js +53 -0
- package/src/cli/exit-codes.js +38 -0
- package/src/cli/index.js +91 -0
- package/src/cli/shared.js +343 -0
- package/src/cli/spinner.js +59 -0
- package/src/cli/table.js +259 -0
- package/src/core/audit.js +152 -0
- package/src/core/changelog.js +231 -0
- package/src/core/config.js +479 -0
- package/src/core/context.js +16 -0
- package/src/core/import-runner.js +164 -0
- package/src/core/import.js +60 -0
- package/src/core/lock.js +348 -0
- package/src/core/migrator.js +1475 -0
- package/src/core/run.js +144 -0
- package/src/core/runner.js +150 -0
- package/src/errors/index.js +215 -0
- package/src/index.js +59 -0
- package/src/utils/checksum.js +23 -0
- package/src/utils/colors.js +68 -0
- package/src/utils/concurrency.js +32 -0
- package/src/utils/date.js +28 -0
- package/src/utils/env.js +51 -0
- package/src/utils/error.js +11 -0
- package/src/utils/loader.js +131 -0
- package/src/utils/logger.js +109 -0
- package/src/utils/redact.js +39 -0
- package/src/utils/sanitize.js +43 -0
- package/src/utils/template.js +615 -0
- 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 };
|