@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,479 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const { pathToFileURL } = require('node:url');
4
+ const { ConfigInvalidError } = require('../errors/index.js');
5
+ const { applyEnvFile } = require('../utils/env.js');
6
+ const { errorText } = require('../utils/error.js');
7
+ const { resolveLogger } = require('../utils/logger.js');
8
+ const { redactDeep } = require('../utils/redact.js');
9
+
10
+ /** Default values applied when no flag, env var, or config-file value is present */
11
+ const DEFAULT_CONFIG = {
12
+ migrationsDir: './migrations',
13
+ migrationsCollection: '_migronaut_migrations',
14
+ lockCollection: '_migronaut_locks',
15
+ lockTTLSeconds: 60,
16
+ strict: false,
17
+ useTransaction: false,
18
+ fileExtensions: ['.ts', '.js'],
19
+ createExtension: 'js',
20
+ sequential: false,
21
+ };
22
+
23
+ /** Candidate config file names, checked in priority order within the cwd */
24
+ const CONFIG_FILE_NAMES = ['migronaut.config.ts', 'migronaut.config.js', 'migronaut.config.json'];
25
+
26
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
27
+ const isBoolean = (value) => typeof value === 'boolean';
28
+ const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
29
+ const isExtension = (value) => value === 'ts' || value === 'js';
30
+
31
+ /**
32
+ * Collection names we accept for the changelog/lock collections and
33
+ * `import --from/--to`: non-empty, no `$` or NUL (invalid server-side), and
34
+ * outside the reserved `system.` namespace — so a flag can never point a
35
+ * read or write at a system collection.
36
+ */
37
+ function isCollectionName(value) {
38
+ return (
39
+ isNonEmptyString(value) &&
40
+ !value.includes('$') &&
41
+ !value.includes('\0') &&
42
+ !value.startsWith('system.')
43
+ );
44
+ }
45
+ function isStringList(value) {
46
+ if (!Array.isArray(value) || value.length === 0) return false;
47
+ for (const item of value) {
48
+ if (!isNonEmptyString(item)) return false;
49
+ }
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Validation spec for every checked config key: predicate + failure message.
55
+ * `mongoose`, `hooks`, `logger` and `client` are deliberately unchecked —
56
+ * they hold live instances the validator has nothing to say about. Unknown
57
+ * keys are allowed, matching the previous zod (non-strict object) behavior.
58
+ */
59
+ const CONFIG_KEYS = [
60
+ { path: 'uri', check: isNonEmptyString, message: 'uri is required' },
61
+ { path: 'dbName', check: isNonEmptyString, message: 'dbName is required' },
62
+ { path: 'migrationsDir', check: isNonEmptyString, message: 'must be a non-empty string' },
63
+ {
64
+ path: 'migrationsCollection',
65
+ check: isCollectionName,
66
+ message: "must be a valid collection name (no '$'/NUL, not system.*)",
67
+ },
68
+ {
69
+ path: 'lockCollection',
70
+ check: isCollectionName,
71
+ message: "must be a valid collection name (no '$'/NUL, not system.*)",
72
+ },
73
+ { path: 'lockTTLSeconds', check: isPositiveInteger, message: 'must be a positive integer' },
74
+ { path: 'strict', check: isBoolean, message: 'must be a boolean' },
75
+ { path: 'useTransaction', check: isBoolean, message: 'must be a boolean' },
76
+ {
77
+ path: 'fileExtensions',
78
+ check: isStringList,
79
+ message: 'must be a non-empty array of non-empty strings',
80
+ },
81
+ { path: 'createExtension', check: isExtension, message: "must be 'ts' or 'js'" },
82
+ { path: 'sequential', check: isBoolean, message: 'must be a boolean' },
83
+ {
84
+ path: 'templatePath',
85
+ check: isNonEmptyString,
86
+ message: 'must be a non-empty string',
87
+ optional: true,
88
+ },
89
+ {
90
+ path: 'environment',
91
+ check: isNonEmptyString,
92
+ message: 'must be a non-empty string',
93
+ optional: true,
94
+ },
95
+ {
96
+ path: 'onLockLost',
97
+ check: (value) => value === 'abort' || value === 'warn',
98
+ message: "must be 'abort' or 'warn'",
99
+ optional: true,
100
+ },
101
+ {
102
+ path: 'envFile',
103
+ check: (value) => value === false || isNonEmptyString(value),
104
+ message: 'must be a non-empty string or false',
105
+ optional: true,
106
+ },
107
+ { path: 'ensureIndexes', check: isBoolean, message: 'must be a boolean', optional: true },
108
+ {
109
+ path: 'clientOptions',
110
+ check: (value) => typeof value === 'object' && value !== null && !Array.isArray(value),
111
+ message: 'must be an object',
112
+ optional: true,
113
+ },
114
+ {
115
+ path: 'timeoutMs',
116
+ check: isPositiveInteger,
117
+ message: 'must be a positive integer',
118
+ optional: true,
119
+ },
120
+ { path: 'reloadMigrations', check: isBoolean, message: 'must be a boolean', optional: true },
121
+ ];
122
+
123
+ /**
124
+ * Every key the merged config legitimately carries: the validated ones plus
125
+ * the deliberately-unchecked live instances. Used only to *mention* typos
126
+ * (`migrationsDirectory`, `useTransactions`) at debug level — unknown keys
127
+ * stay allowed, matching the documented non-strict contract.
128
+ */
129
+ const KNOWN_CONFIG_KEYS = new Set(['logger', 'hooks', 'mongoose', 'client']);
130
+ for (const spec of CONFIG_KEYS) KNOWN_CONFIG_KEYS.add(spec.path);
131
+
132
+ /**
133
+ * Validate the merged config, returning a list of `{ path, message }` issues
134
+ * (empty when valid). With `requireDb: false`, empty `uri`/`dbName` strings
135
+ * are allowed — for commands that never touch the database (`init`, `create`).
136
+ */
137
+ function validateConfig(config, options = {}) {
138
+ const requireDb = options.requireDb ?? true;
139
+ // An injected client already carries the connection, so a `uri` would be
140
+ // ignored — requiring one would only be busywork.
141
+ const hasClient = typeof config.client === 'object' && config.client !== null;
142
+ const issues = [];
143
+ for (const spec of CONFIG_KEYS) {
144
+ const value = config[spec.path];
145
+ if (spec.optional && value === undefined) continue;
146
+ if (hasClient && spec.path === 'uri') continue;
147
+ if (!requireDb && (spec.path === 'uri' || spec.path === 'dbName')) {
148
+ if (typeof value !== 'string') issues.push({ path: spec.path, message: 'must be a string' });
149
+ continue;
150
+ }
151
+ if (!spec.check(value)) issues.push({ path: spec.path, message: spec.message });
152
+ }
153
+ return issues;
154
+ }
155
+
156
+ const TRUE_BOOLEAN_STRINGS = new Set(['true', '1', 'yes']);
157
+ const FALSE_BOOLEAN_STRINGS = new Set(['false', '0', 'no']);
158
+
159
+ /**
160
+ * Parse a string into a boolean. Unrecognized values throw instead of silently
161
+ * becoming false — a typo like MIGRONAUT_STRICT=on must never disable a safety
162
+ * setting (fail closed).
163
+ */
164
+ function parseBoolean(value, name) {
165
+ const normalized = value.trim().toLowerCase();
166
+ if (TRUE_BOOLEAN_STRINGS.has(normalized)) return true;
167
+ if (FALSE_BOOLEAN_STRINGS.has(normalized)) return false;
168
+ throw new ConfigInvalidError(`${name} must be 'true'/'1'/'yes' or 'false'/'0'/'no'`, {
169
+ name,
170
+ value,
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Keys never copied from parsed input: JSON.parse creates `__proto__` as an own
176
+ * enumerable property, so merging it would poison the prototype of the merged
177
+ * config and let a JSON config file override values it never declared.
178
+ */
179
+ const UNSAFE_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
180
+
181
+ /** Copy only the defined keys from `source` onto `target`, mutating and returning target */
182
+ function mergeDefined(target, source) {
183
+ for (const key of Object.keys(source)) {
184
+ if (UNSAFE_MERGE_KEYS.has(key)) continue;
185
+ const value = source[key];
186
+ if (value !== undefined) {
187
+ target[key] = value;
188
+ }
189
+ }
190
+ return target;
191
+ }
192
+
193
+ /**
194
+ * Parse a string into a positive integer, failing closed like `parseBoolean`.
195
+ * A bare `Number()` turns a typo into `NaN`, which surfaces as a generic
196
+ * validateConfig issue that never names the variable the user actually set.
197
+ */
198
+ function parsePositiveInteger(value, name) {
199
+ const parsed = Number(value.trim());
200
+ if (!isPositiveInteger(parsed)) {
201
+ throw new ConfigInvalidError(`${name} must be a positive integer`, { name, value });
202
+ }
203
+ return parsed;
204
+ }
205
+
206
+ /** Parse one of a fixed set of string values, failing closed on anything else */
207
+ function parseEnum(allowed) {
208
+ const values = new Set(allowed);
209
+ const list = allowed.map((item) => `'${item}'`).join(' or ');
210
+ return (value, name) => {
211
+ const normalized = value.trim();
212
+ if (!values.has(normalized)) {
213
+ throw new ConfigInvalidError(`${name} must be ${list}`, { name, value });
214
+ }
215
+ return normalized;
216
+ };
217
+ }
218
+
219
+ const parseString = (value) => value;
220
+
221
+ /**
222
+ * Every environment variable that maps onto a config key: the variable name,
223
+ * the config path it feeds, and how its string is parsed. Table-driven for the
224
+ * same reason CONFIG_KEYS is — a hand-written `if` chain silently drifts from
225
+ * the key list it is supposed to mirror (MIGRONAUT_ENVIRONMENT was missing from
226
+ * the tests' save/restore list for exactly that reason).
227
+ *
228
+ * Every *scalar* config option has an entry here, which is what makes the
229
+ * documented "a config file is never required" promise literally true. Options
230
+ * holding non-scalars — `fileExtensions`, `clientOptions`, `client`, `mongoose`,
231
+ * `hooks`, `logger` — are config-file/API only; an env var cannot express them.
232
+ *
233
+ * MIGRONAUT_ENV_FILE is deliberately absent: it selects which .env file to load,
234
+ * so it has to be read before this table can run (see loadConfig).
235
+ */
236
+ const ENV_KEYS = [
237
+ { env: 'MIGRONAUT_URI', path: 'uri', parse: parseString },
238
+ { env: 'MIGRONAUT_DB', path: 'dbName', parse: parseString },
239
+ { env: 'MIGRONAUT_MIGRATIONS_DIR', path: 'migrationsDir', parse: parseString },
240
+ { env: 'MIGRONAUT_COLLECTION', path: 'migrationsCollection', parse: parseString },
241
+ { env: 'MIGRONAUT_LOCK_COLLECTION', path: 'lockCollection', parse: parseString },
242
+ { env: 'MIGRONAUT_LOCK_TTL', path: 'lockTTLSeconds', parse: parsePositiveInteger },
243
+ { env: 'MIGRONAUT_STRICT', path: 'strict', parse: parseBoolean },
244
+ { env: 'MIGRONAUT_USE_TRANSACTION', path: 'useTransaction', parse: parseBoolean },
245
+ { env: 'MIGRONAUT_SEQUENTIAL', path: 'sequential', parse: parseBoolean },
246
+ { env: 'MIGRONAUT_CREATE_EXTENSION', path: 'createExtension', parse: parseEnum(['ts', 'js']) },
247
+ { env: 'MIGRONAUT_ENVIRONMENT', path: 'environment', parse: parseString },
248
+ { env: 'MIGRONAUT_TEMPLATE_PATH', path: 'templatePath', parse: parseString },
249
+ { env: 'MIGRONAUT_TIMEOUT_MS', path: 'timeoutMs', parse: parsePositiveInteger },
250
+ { env: 'MIGRONAUT_ON_LOCK_LOST', path: 'onLockLost', parse: parseEnum(['abort', 'warn']) },
251
+ { env: 'MIGRONAUT_ENSURE_INDEXES', path: 'ensureIndexes', parse: parseBoolean },
252
+ { env: 'MIGRONAUT_RELOAD_MIGRATIONS', path: 'reloadMigrations', parse: parseBoolean },
253
+ ];
254
+
255
+ /** Build a partial config from the MIGRONAUT_* environment variables */
256
+ function readEnvConfig(env = process.env) {
257
+ const result = {};
258
+ for (const spec of ENV_KEYS) {
259
+ const value = env[spec.env];
260
+ if (value === undefined) continue;
261
+ result[spec.path] = spec.parse(value, spec.env);
262
+ }
263
+ return result;
264
+ }
265
+
266
+ /** Returns true when `filepath` exists on disk */
267
+ async function pathExists(filepath) {
268
+ try {
269
+ await fs.access(filepath);
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Locate a config file in `cwd`, returning its absolute path or null.
278
+ * Candidates are checked concurrently, then the first one that exists (in
279
+ * `CONFIG_FILE_NAMES` priority order) wins — preserves the original
280
+ * short-circuit priority without serializing the stat calls.
281
+ */
282
+ async function discoverConfigFile(cwd) {
283
+ const candidates = [];
284
+ for (const name of CONFIG_FILE_NAMES) candidates.push(path.join(cwd, name));
285
+ const checks = [];
286
+ for (const candidate of candidates) checks.push(pathExists(candidate));
287
+ const found = await Promise.all(checks);
288
+ for (let i = 0; i < candidates.length; i++) {
289
+ if (found[i]) return candidates[i];
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * Load and return the config object exported by a config file.
296
+ *
297
+ * A `.ts`/`.js` config may export either a plain object or a (sync or async)
298
+ * factory function — the factory is invoked and awaited here, which is what
299
+ * lets users fetch values from a secret manager. A factory that throws is
300
+ * surfaced as a ConfigInvalidError. JSON configs are always objects.
301
+ */
302
+ async function loadConfigFile(filepath, lenient = false) {
303
+ if (filepath.endsWith('.json')) {
304
+ const raw = await fs.readFile(filepath, 'utf8');
305
+ try {
306
+ return JSON.parse(raw);
307
+ } catch (error) {
308
+ // A bare SyntaxError has no typed code, which breaks the CLI's exit-code
309
+ // mapping and `--json` error shape.
310
+ throw new ConfigInvalidError(
311
+ 'Config file is not valid JSON',
312
+ { path: filepath, cause: errorText(error) },
313
+ { cause: error },
314
+ );
315
+ }
316
+ }
317
+ let mod;
318
+ try {
319
+ mod = await import(pathToFileURL(filepath).href);
320
+ } catch (error) {
321
+ // Module-evaluation failures (syntax error, a throwing top-level statement)
322
+ // get the same treatment as a throwing factory below — including the
323
+ // lenient degradation for commands that never touch the database.
324
+ if (lenient) return null;
325
+ throw new ConfigInvalidError(
326
+ 'Config file failed to load',
327
+ { path: filepath, cause: errorText(error) },
328
+ { cause: error },
329
+ );
330
+ }
331
+ const exported = mod.default ?? mod;
332
+
333
+ if (typeof exported === 'function') {
334
+ try {
335
+ return await exported();
336
+ } catch (error) {
337
+ if (lenient) {
338
+ // The caller does not need the connection (e.g. `create`), so a failing
339
+ // secret-manager round trip degrades to "no values from this file"
340
+ // rather than stopping an otherwise offline command.
341
+ return null;
342
+ }
343
+ throw new ConfigInvalidError(
344
+ 'Config factory function failed to resolve',
345
+ { path: filepath, cause: errorText(error) },
346
+ { cause: error },
347
+ );
348
+ }
349
+ }
350
+ return exported;
351
+ }
352
+
353
+ /**
354
+ * Resolve the final MigronautConfig by merging, in priority order:
355
+ * CLI flags > environment variables > config file > defaults.
356
+ *
357
+ * With `lenient: true`, a config file whose exported factory throws is skipped
358
+ * with a warning instead of aborting — for commands that never open a
359
+ * connection (`create`), which otherwise fail because a secret manager is
360
+ * unreachable.
361
+ *
362
+ * Throws ConfigInvalidError when the merged result fails validation.
363
+ */
364
+ async function loadConfig(options = {}) {
365
+ const cwd = options.cwd ?? process.cwd();
366
+ // The CLI's console logger, used only when neither flags nor the config file
367
+ // supply one — so this module's own lines honor `--verbose`/`--quiet` while
368
+ // a user's configured logger still wins.
369
+ const effectiveLogger = (value) =>
370
+ resolveLogger(value !== undefined ? value : options.fallbackLogger);
371
+
372
+ // Resolved before the merge, because loading the .env file is what supplies
373
+ // the MIGRONAUT_* variables the merge then reads. `false` opts out entirely —
374
+ // a stray .env silently outranking a committed config is a nasty surprise.
375
+ // This is why MIGRONAUT_ENV_FILE is read here rather than from ENV_KEYS: it
376
+ // has to resolve before the table that reads everything else can run.
377
+ const envFile = options.flags?.envFile ?? process.env.MIGRONAUT_ENV_FILE ?? '.env';
378
+ if (envFile !== false) {
379
+ // Checked here, before validateConfig runs, because path.resolve on a
380
+ // non-string throws a raw TypeError instead of the ConfigInvalidError the
381
+ // config contract promises.
382
+ if (!isNonEmptyString(envFile)) {
383
+ throw new ConfigInvalidError('Invalid configuration', {
384
+ issues: [{ path: 'envFile', message: 'must be a non-empty string or false' }],
385
+ });
386
+ }
387
+ await applyEnvFile(path.resolve(cwd, envFile));
388
+ }
389
+
390
+ const merged = { ...DEFAULT_CONFIG };
391
+
392
+ const configFilePath = options.configPath
393
+ ? path.resolve(cwd, options.configPath)
394
+ : await discoverConfigFile(cwd);
395
+
396
+ if (configFilePath) {
397
+ if (!(await pathExists(configFilePath))) {
398
+ throw new ConfigInvalidError('Config file not found', { path: configFilePath });
399
+ }
400
+ const fileConfig = await loadConfigFile(configFilePath, options.lenient ?? false);
401
+ // null means a lenient run whose factory failed — nothing usable from the
402
+ // file, so fall through to env vars, flags and defaults.
403
+ if (fileConfig === null) {
404
+ effectiveLogger(options.flags?.logger).warn(
405
+ `⚠ Could not resolve ${path.basename(configFilePath)} — continuing without it`,
406
+ );
407
+ } else {
408
+ mergeDefined(merged, fileConfig);
409
+ }
410
+ }
411
+
412
+ mergeDefined(merged, readEnvConfig());
413
+
414
+ if (options.flags) {
415
+ mergeDefined(merged, options.flags);
416
+ }
417
+
418
+ const requireDb = options.requireDb ?? true;
419
+ if (!requireDb) {
420
+ if (merged.uri === undefined) merged.uri = '';
421
+ if (merged.dbName === undefined) merged.dbName = '';
422
+ }
423
+
424
+ const issues = validateConfig(merged, { requireDb });
425
+ if (issues.length > 0) {
426
+ throw new ConfigInvalidError('Invalid configuration', { issues });
427
+ }
428
+
429
+ const config = merged;
430
+
431
+ // A typo'd key (`migrationsDirectory`, `useTransactions`) validates fine and
432
+ // then silently does nothing; a debug mention is the cheapest way to notice.
433
+ // The array is only allocated when a stray key actually exists.
434
+ let unknown;
435
+ for (const key in config) {
436
+ if (!KNOWN_CONFIG_KEYS.has(key)) (unknown ??= []).push(key);
437
+ }
438
+ if (unknown) {
439
+ effectiveLogger(config.logger).debug(
440
+ `Unrecognized config key(s), ignored: ${unknown.join(', ')}`,
441
+ );
442
+ }
443
+
444
+ if (config.logger && configFilePath) {
445
+ // resolveLogger, not a direct call — config.logger may be a pino instance.
446
+ effectiveLogger(config.logger).debug(`Loaded config from ${path.basename(configFilePath)}`);
447
+ }
448
+
449
+ // "Which config did it actually pick up?" — the merged result, once, at
450
+ // debug level. Live instances (client, mongoose, hooks, logger) are elided:
451
+ // they are not serializable and redactDeep rightly refuses to clone them.
452
+ {
453
+ const { client, mongoose, hooks, logger, ...rest } = config;
454
+ effectiveLogger(config.logger).debug(
455
+ `Resolved config (source: ${configFilePath ? path.basename(configFilePath) : 'env/flags/defaults'})`,
456
+ redactDeep({
457
+ ...rest,
458
+ ...(client ? { client: '[injected]' } : {}),
459
+ ...(mongoose ? { mongoose: '[injected]' } : {}),
460
+ ...(hooks ? { hooks: Object.keys(hooks) } : {}),
461
+ ...(logger !== undefined ? { logger: logger === null ? null : '[injected]' } : {}),
462
+ }),
463
+ );
464
+ }
465
+
466
+ return config;
467
+ }
468
+
469
+ // CONFIG_KEYS and ENV_KEYS are exported for the unit test that pins the two
470
+ // tables against each other — internal to the config layer, not part of the
471
+ // package's public surface (index.d.ts describes neither).
472
+ module.exports = {
473
+ CONFIG_KEYS,
474
+ DEFAULT_CONFIG,
475
+ ENV_KEYS,
476
+ isCollectionName,
477
+ loadConfig,
478
+ validateConfig,
479
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Build the MigrationContext passed into every migration function.
3
+ *
4
+ * `signal` is the run's abort signal: it fires when the lock is lost, when
5
+ * `stop()` is called, or on SIGINT/SIGTERM. A long migration can watch it to
6
+ * bail out early — migronaut cannot interrupt a running function on its own.
7
+ * The runner adds `session` on top of this when the migration is transactional.
8
+ */
9
+ function buildContext(client, db, mongoose, signal) {
10
+ const context = { client, db };
11
+ if (mongoose) context.mongoose = mongoose;
12
+ if (signal) context.signal = signal;
13
+ return context;
14
+ }
15
+
16
+ module.exports = { buildContext };
@@ -0,0 +1,164 @@
1
+ const { ImportTargetNotEmptyError } = require('../errors/index.js');
2
+ const { computeChecksum } = require('../utils/checksum.js');
3
+ const { Changelog } = require('./changelog.js');
4
+ const { isMigrateMongoDoc, mapMigrateMongoDocs } = require('./import.js');
5
+
6
+ /** Default source collection name used by migrate-mongo */
7
+ const MIGRATE_MONGO_COLLECTION = 'changelog';
8
+
9
+ /** Records per bulkWrite — bounds a single request while keeping round trips rare */
10
+ const IMPORT_CHUNK_SIZE = 1000;
11
+
12
+ /**
13
+ * Adopt an existing migrate-mongo `changelog` collection by mapping its records
14
+ * into the migronaut schema and writing them to the target collection. The
15
+ * source collection is never modified. Forward-only: it records applied history
16
+ * so `up` skips it correctly — it does not adapt legacy migration file
17
+ * signatures, so `down`/`redo` on imported files is unsupported.
18
+ *
19
+ * Pure orchestration over capabilities the MigratorKit injects (`deps`):
20
+ * `{config, db, changelog, logger, fields, filepath, assertNotAborted}`.
21
+ */
22
+ async function runImport(deps, options, signal) {
23
+ const { config, db, changelog, logger } = deps;
24
+
25
+ const source = options.from ?? MIGRATE_MONGO_COLLECTION;
26
+ const target = options.to ?? config.migrationsCollection;
27
+ const dryRun = options.dryRun ?? false;
28
+
29
+ // Records are written to `target`; reuse the connected changelog when it
30
+ // already points there, otherwise bind a fresh one (and ensure its index).
31
+ const targetChangelog =
32
+ target === config.migrationsCollection ? changelog : new Changelog(target);
33
+ // ensureIndexes (target) and getForeignDocs (source) touch unrelated
34
+ // collections — independent, safe to run concurrently.
35
+ const ensureIndexesPromise =
36
+ targetChangelog !== changelog && !dryRun
37
+ ? targetChangelog.ensureIndexes(db)
38
+ : Promise.resolve();
39
+ const [, rawDocs] = await Promise.all([
40
+ ensureIndexesPromise,
41
+ changelog.getForeignDocs(db, source),
42
+ ]);
43
+ if (rawDocs.length === 0) {
44
+ logger.info(`Nothing to import from "${source}"`, deps.fields({ source, target }));
45
+ return { source, target, imported: 0, skipped: 0, dryRun, rows: [] };
46
+ }
47
+
48
+ const valid = [];
49
+ let skipped = 0;
50
+ for (const doc of rawDocs) {
51
+ if (isMigrateMongoDoc(doc)) {
52
+ valid.push(doc);
53
+ } else {
54
+ skipped += 1;
55
+ logger.warn('⚠ Skipping source doc without a usable fileName');
56
+ }
57
+ }
58
+
59
+ // `{name, batch}` projections only — numbering new batches and detecting a
60
+ // non-empty target needs neither full documents nor the whole history twice.
61
+ const existing = await targetChangelog.getNamesAndBatches(db);
62
+ if (!options.force && !dryRun && existing.length > 0) {
63
+ throw new ImportTargetNotEmptyError(
64
+ `Target collection "${target}" already has ${existing.length} record(s) — re-run with force to proceed`,
65
+ { target, existing: existing.length },
66
+ );
67
+ }
68
+
69
+ // Continue batch numbering after the batches already in the target so imported
70
+ // records never collide with existing ones. Records this import will overwrite
71
+ // (same name) are excluded, keeping a forced re-import's batch numbers stable.
72
+ const incomingNames = new Set();
73
+ for (const doc of valid) incomingNames.add(doc.fileName);
74
+ let batchOffset = 0;
75
+ for (const record of existing) {
76
+ if (!incomingNames.has(record.name) && record.batch > batchOffset) {
77
+ batchOffset = record.batch;
78
+ }
79
+ }
80
+
81
+ const rowSources = new Map();
82
+ const records = await mapMigrateMongoDocs(valid, {
83
+ environment: 'imported',
84
+ executedBy: 'migronaut-import',
85
+ batchOffset,
86
+ resolveChecksum: async (fileName, fileHash) => {
87
+ const resolved = await resolveImportChecksum(
88
+ deps.filepath(fileName),
89
+ fileHash,
90
+ options.trustHash ?? false,
91
+ );
92
+ rowSources.set(fileName, resolved.source);
93
+ if (resolved.source === 'missing') {
94
+ logger.warn(`⚠ File not found on disk: ${fileName} — checksum unverifiable`);
95
+ }
96
+ return resolved;
97
+ },
98
+ });
99
+
100
+ const rows = [];
101
+ for (const record of records) {
102
+ rows.push({
103
+ file: record.name,
104
+ batch: record.batch,
105
+ appliedAt: record.appliedAt,
106
+ checksum: record.checksum,
107
+ checksumSource: rowSources.get(record.name) ?? 'missing',
108
+ });
109
+ }
110
+
111
+ if (dryRun) {
112
+ logger.info(
113
+ `◎ Dry-run Would import ${rows.length} record(s) from "${source}" → "${target}"`,
114
+ deps.fields({ source, target, count: rows.length, dryRun: true }),
115
+ );
116
+ return { source, target, imported: 0, skipped, dryRun, rows };
117
+ }
118
+
119
+ // One unordered bulkWrite per chunk instead of one updateOne per record —
120
+ // adopting a 5,000-record changelog is 5 round trips, not 5,000, all while
121
+ // holding the migration lock. The abort check between chunks lets stop() /
122
+ // a lost lock halt a long import instead of running it to completion.
123
+ for (let start = 0; start < records.length; start += IMPORT_CHUNK_SIZE) {
124
+ deps.assertNotAborted(signal);
125
+ await targetChangelog.markAppliedBulk(db, records.slice(start, start + IMPORT_CHUNK_SIZE));
126
+ }
127
+
128
+ logger.info(
129
+ `✔ Imported ${records.length} record(s) from "${source}" → "${target}"`,
130
+ deps.fields({ source, target, imported: records.length, skipped }),
131
+ );
132
+ return { source, target, imported: records.length, skipped, dryRun, rows };
133
+ }
134
+
135
+ /**
136
+ * Decide the checksum to store for an imported migration. Order: when
137
+ * `trustHash`, reuse the source `fileHash` if present; otherwise reuse it only
138
+ * when it matches a freshly computed hash (algorithms align), else recompute
139
+ * from disk; when the file is missing, fall back to the source hash or empty.
140
+ */
141
+ async function resolveImportChecksum(filepath, fileHash, trustHash) {
142
+ if (trustHash && fileHash) {
143
+ return { checksum: fileHash, source: 'reused' };
144
+ }
145
+ // computeChecksum directly, treating ENOENT as "missing" — an access()
146
+ // probe first would double the syscalls and open a TOCTOU window in which
147
+ // the answer can change anyway.
148
+ let recomputed;
149
+ try {
150
+ recomputed = await computeChecksum(filepath);
151
+ } catch (error) {
152
+ if (error.code !== 'ENOENT') throw error;
153
+ if (fileHash) {
154
+ return { checksum: fileHash, source: 'reused' };
155
+ }
156
+ return { checksum: '', source: 'missing' };
157
+ }
158
+ if (fileHash && fileHash === recomputed) {
159
+ return { checksum: fileHash, source: 'reused' };
160
+ }
161
+ return { checksum: recomputed, source: 'recomputed' };
162
+ }
163
+
164
+ module.exports = { runImport };