@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,33 @@
1
+ const { defineCommand } = require('../shared.js');
2
+ const { renderImportTable } = require('../table.js');
3
+
4
+ /** Register the `import` command (adopt a migrate-mongo changelog) */
5
+ function registerImport(program) {
6
+ defineCommand(program, {
7
+ name: 'import',
8
+ description: 'Adopt an existing migrate-mongo changelog into the migronaut changelog',
9
+ options: [
10
+ ['--from <collection>', 'Source collection to read (default: changelog)'],
11
+ ['--to <collection>', 'Target collection to write (default: config migrationsCollection)'],
12
+ ['--dry-run', 'Preview the mapping without writing anything'],
13
+ ['--trust-hash', 'Reuse the source fileHash instead of recomputing from disk'],
14
+ ['--force', 'Proceed even when the target changelog already has records'],
15
+ ],
16
+ lockable: true,
17
+ mutating: true,
18
+ run: (migrator, opts) =>
19
+ migrator.import({
20
+ noLock: opts.noLock,
21
+ ...(opts.from ? { from: opts.from } : {}),
22
+ ...(opts.to ? { to: opts.to } : {}),
23
+ ...(opts.dryRun ? { dryRun: true } : {}),
24
+ ...(opts.trustHash ? { trustHash: true } : {}),
25
+ ...(opts.force ? { force: true } : {}),
26
+ }),
27
+ render: (result, { logger }) => {
28
+ if (result.rows.length > 0) logger.info(renderImportTable(result.rows));
29
+ },
30
+ });
31
+ }
32
+
33
+ module.exports = { registerImport };
@@ -0,0 +1,64 @@
1
+ const { ConfigInvalidError } = require('../../errors/index.js');
2
+ const { defineCommand } = require('../shared.js');
3
+
4
+ const FORMATS = new Set(['js', 'ts', 'json']);
5
+
6
+ /** Register the `init` command */
7
+ function registerInit(program) {
8
+ defineCommand(program, {
9
+ name: 'init',
10
+ description:
11
+ 'Create a migronaut config file in the current directory (migronaut.config.js by default)',
12
+ options: [
13
+ ['--format <type>', "Config file format: 'js' (default), 'ts' or 'json'"],
14
+ ['--ts', 'Shorthand for --format ts'],
15
+ ['--js', 'Shorthand for --format js'],
16
+ [
17
+ '--secret-provider',
18
+ 'Generate a config that loads the connection from a secret manager (js/ts only)',
19
+ ],
20
+ ['--force', 'Overwrite an existing config file'],
21
+ ],
22
+ // init has no machine-readable output; the deliverable is the file itself.
23
+ jsonOutput: false,
24
+ // Writing a file needs no database — no spinner, no pre-connect.
25
+ spinner: false,
26
+ preflight: (opts) => {
27
+ // `--json` used to mean "generate migronaut.config.json" here — the one
28
+ // command where it did not mean JSON output. Refuse loudly rather than
29
+ // silently reinterpreting a global output flag as a file format.
30
+ if (opts.json) {
31
+ throw new ConfigInvalidError(
32
+ 'init has no JSON output — to generate migronaut.config.json, pass --format json',
33
+ );
34
+ }
35
+ if (opts.format !== undefined && !FORMATS.has(opts.format)) {
36
+ throw new ConfigInvalidError("--format must be 'js', 'ts' or 'json'", {
37
+ format: opts.format,
38
+ });
39
+ }
40
+ const format = resolveFormat(opts);
41
+ if (opts.secretProvider && format === 'json') {
42
+ throw new ConfigInvalidError(
43
+ '--secret-provider is only available for js/ts configs (not --format json)',
44
+ );
45
+ }
46
+ },
47
+ run: async (migrator, opts) => {
48
+ await migrator.init({
49
+ format: resolveFormat(opts),
50
+ force: opts.force ?? false,
51
+ ...(opts.secretProvider ? { secretProvider: true } : {}),
52
+ });
53
+ },
54
+ });
55
+ }
56
+
57
+ /** Explicit --format wins; --ts/--js are shorthands; the default is js */
58
+ function resolveFormat(opts) {
59
+ if (opts.format !== undefined) return opts.format;
60
+ if (opts.ts) return 'ts';
61
+ return 'js';
62
+ }
63
+
64
+ module.exports = { registerInit };
@@ -0,0 +1,19 @@
1
+ const { defineCommand } = require('../shared.js');
2
+ const { renderRowsOrEmpty } = require('../table.js');
3
+
4
+ /** Register the `list` command */
5
+ function registerList(program) {
6
+ defineCommand(program, {
7
+ name: 'list',
8
+ description: 'List migrations, optionally filtered by status',
9
+ options: [
10
+ ['--pending', 'Show only pending migrations'],
11
+ ['--applied', 'Show only applied migrations'],
12
+ ],
13
+ run: (migrator, opts) =>
14
+ migrator.list(opts.pending ? 'pending' : opts.applied ? 'applied' : 'all'),
15
+ render: renderRowsOrEmpty,
16
+ });
17
+ }
18
+
19
+ module.exports = { registerList };
@@ -0,0 +1,35 @@
1
+ const { defineCommand } = require('../shared.js');
2
+
3
+ /**
4
+ * `lockedAt` as an ISO string, tolerating a lock document whose field is not a
5
+ * Date (hand-edited, half-written, or from another tool). This command exists
6
+ * to diagnose a stuck lock — crashing on the document it came to inspect
7
+ * would fail at exactly the moment it is most needed.
8
+ */
9
+ function lockedAtText(value) {
10
+ return value instanceof Date ? value.toISOString() : String(value);
11
+ }
12
+
13
+ /** Register the `lock` command (inspect the migration lock without touching it) */
14
+ function registerLock(program) {
15
+ defineCommand(program, {
16
+ name: 'lock',
17
+ description: 'Show who holds the migration lock, if anyone',
18
+ run: async (migrator) => {
19
+ const holder = await migrator.lockInfo();
20
+ return { held: holder !== null, holder };
21
+ },
22
+ render: ({ holder }, { logger }) => {
23
+ if (!holder) {
24
+ logger.info('No migration lock is currently held');
25
+ return;
26
+ }
27
+ logger.info(
28
+ `Lock held by pid ${holder.pid} on ${holder.host} (${holder.executedBy}) ` +
29
+ `since ${lockedAtText(holder.lockedAt)}`,
30
+ );
31
+ },
32
+ });
33
+ }
34
+
35
+ module.exports = { registerLock, lockedAtText };
@@ -0,0 +1,16 @@
1
+ const { defineCommand } = require('../shared.js');
2
+
3
+ /** Register the `redo` command */
4
+ function registerRedo(program) {
5
+ defineCommand(program, {
6
+ name: 'redo',
7
+ description: 'Rollback then re-apply the last applied migration, or a specific file',
8
+ args: [['[file]', 'Specific migration file to redo']],
9
+ lockable: true,
10
+ mutating: true,
11
+ run: (migrator, opts, [file]) => migrator.redo(file, { noLock: opts.noLock }),
12
+ // No render: core logs the ↩/✔ lines itself.
13
+ });
14
+ }
15
+
16
+ module.exports = { registerRedo };
@@ -0,0 +1,52 @@
1
+ const { ConfigInvalidError } = require('../../errors/index.js');
2
+ const { defineCommand, EXIT_CODES } = require('../shared.js');
3
+ const { renderRowsOrEmpty } = require('../table.js');
4
+
5
+ /** Register the `status` command */
6
+ function registerStatus(program) {
7
+ defineCommand(program, {
8
+ name: 'status',
9
+ description: 'Show the full migration status table',
10
+ options: [
11
+ ['--check', 'Exit with code 2 if any migrations are pending (CI gate)'],
12
+ ['--pending', 'Show only pending migrations'],
13
+ ['--limit <n>', 'Show only the last N rows'],
14
+ ],
15
+ preflight: (opts) => {
16
+ if (opts.limit !== undefined) {
17
+ const limit = Number(opts.limit);
18
+ if (!Number.isInteger(limit) || limit < 1) {
19
+ throw new ConfigInvalidError('--limit must be a positive integer', {
20
+ limit: opts.limit,
21
+ });
22
+ }
23
+ // --check answers "is ANY migration pending?" — a limited window could
24
+ // hide pending rows and report a clean gate that is not.
25
+ if (opts.check) {
26
+ throw new ConfigInvalidError('--check cannot be combined with --limit');
27
+ }
28
+ }
29
+ },
30
+ run: async (migrator, opts) => {
31
+ const rows = opts.pending ? await migrator.list('pending') : await migrator.status();
32
+ return opts.limit !== undefined ? rows.slice(-Number(opts.limit)) : rows;
33
+ },
34
+ render: renderRowsOrEmpty,
35
+ after: (rows, { logger, opts }) => {
36
+ if (!opts.check) return;
37
+ let pending = 0;
38
+ for (const row of rows) {
39
+ if (row.status === 'pending') pending += 1;
40
+ }
41
+ if (pending > 0) {
42
+ // .error writes to stderr, so JSON stdout stays a single clean document.
43
+ logger.error(`✖ ${pending} pending migration(s)`);
44
+ // A dedicated code: a CI gate must be able to tell "database is
45
+ // behind" (act: migrate) from "the check itself crashed" (act: page).
46
+ process.exitCode = EXIT_CODES.PENDING_MIGRATIONS;
47
+ }
48
+ },
49
+ });
50
+ }
51
+
52
+ module.exports = { registerStatus };
@@ -0,0 +1,43 @@
1
+ const { confirm, defineCommand, emitJson } = require('../shared.js');
2
+ const { lockedAtText } = require('./lock.js');
3
+
4
+ /** Register the `unlock` command (force-release a stuck migration lock) */
5
+ function registerUnlock(program) {
6
+ defineCommand(program, {
7
+ name: 'unlock',
8
+ description: 'Force-release a stuck migration lock left behind by a crashed run',
9
+ options: [['-y, --yes', 'Skip the confirmation prompt']],
10
+ mutating: true,
11
+ // Owns its output: the confirmation flow branches mid-way, so the shared
12
+ // "emit what run() returned" envelope does not fit.
13
+ run: async (migrator, opts, _positionals, { logger, json }) => {
14
+ const holder = await migrator.lockInfo();
15
+
16
+ if (!holder) {
17
+ if (json) emitJson({ released: false, holder: null });
18
+ else logger.info('No migration lock is currently held');
19
+ return undefined;
20
+ }
21
+
22
+ // Confirm before clearing — unless --yes, or --json (non-interactive).
23
+ if (!json && !opts.yes) {
24
+ const since = lockedAtText(holder.lockedAt);
25
+ logger.warn(
26
+ `⚠ Lock held by pid ${holder.pid} on ${holder.host} (${holder.executedBy}) since ${since}`,
27
+ );
28
+ const proceed = await confirm('Force-release this lock? [y/N] ');
29
+ if (!proceed) {
30
+ logger.info('Aborted');
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ const released = await migrator.forceUnlock();
36
+ if (json) emitJson({ released: released !== null, holder: released });
37
+ else logger.info('✔ Lock released');
38
+ return undefined;
39
+ },
40
+ });
41
+ }
42
+
43
+ module.exports = { registerUnlock };
@@ -0,0 +1,53 @@
1
+ const { ConfigInvalidError } = require('../../errors/index.js');
2
+ const { confirm, defineCommand } = require('../shared.js');
3
+
4
+ /** Register the `up` command */
5
+ function registerUp(program) {
6
+ defineCommand(program, {
7
+ name: 'up',
8
+ description: 'Run all pending migrations, or a single named file',
9
+ args: [['[file]', 'Specific migration file to run']],
10
+ options: [
11
+ ['--to <file>', 'Apply pending migrations up to and including this file'],
12
+ ['--strict', 'Abort on checksum mismatch'],
13
+ ['-f, --force', 'Re-run an already-applied migration (requires a file)'],
14
+ ['-y, --yes', 'Confirm --force non-interactively (required with --json)'],
15
+ ['--step', 'Apply each migration as its own batch (revert individually later)'],
16
+ ],
17
+ lockable: true,
18
+ mutating: true,
19
+ // Runs before any connection: validation and the confirmation prompt must
20
+ // not cost a round trip, and their errors use the same typed envelope as
21
+ // everything else (CONFIG_INVALID, exit 6).
22
+ preflight: async (opts, [file], { logger }) => {
23
+ if (opts.force && !file) {
24
+ throw new ConfigInvalidError('--force requires a specific migration file');
25
+ }
26
+ if (opts.force && file && !opts.yes) {
27
+ // --json is non-interactive: refuse rather than silently re-running or
28
+ // hanging on a prompt that can't be answered. --yes is the explicit opt-in.
29
+ if (opts.json) {
30
+ throw new ConfigInvalidError(
31
+ '--force needs confirmation — pass --yes to confirm in --json mode',
32
+ );
33
+ }
34
+ const proceed = await confirm(`are you sure you want to re-run "${file}"? [y/N] `);
35
+ if (!proceed) {
36
+ logger.info('Aborted');
37
+ return false;
38
+ }
39
+ }
40
+ return undefined;
41
+ },
42
+ run: (migrator, opts, [file]) =>
43
+ migrator.up(file, {
44
+ noLock: opts.noLock,
45
+ ...(opts.force ? { force: true } : {}),
46
+ ...(opts.step ? { step: true } : {}),
47
+ ...(opts.to ? { to: opts.to } : {}),
48
+ }),
49
+ // No render: core logs every ✔ Applied line itself.
50
+ });
51
+ }
52
+
53
+ module.exports = { registerUp };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Exit code per error code, so CI can branch on *why* a run failed instead of
3
+ * only that it did. Anything unmapped exits 1, and success is still 0 — a
4
+ * script testing `!= 0` is unaffected.
5
+ *
6
+ * Every MigronautError code has an entry (pinned by a superset test), plus
7
+ * two CLI-condition codes that have no error class: PENDING_MIGRATIONS
8
+ * (`status --check` found work) and AUDIT_FAILED (an audit check failed).
9
+ *
10
+ * Exported from the package root so a programmatic wrapper can mirror the
11
+ * CLI's exit semantics without hardcoding numbers from the docs table. Kept
12
+ * in its own module so importing the library never loads the CLI machinery.
13
+ */
14
+ const EXIT_CODES = {
15
+ PENDING_MIGRATIONS: 2,
16
+ LOCK_ALREADY_HELD: 3,
17
+ CHECKSUM_MISMATCH: 4,
18
+ CONNECTION_FAILED: 5,
19
+ CONFIG_INVALID: 6,
20
+ MIGRATION_EXECUTION_FAILED: 7,
21
+ MIGRATION_FILE_NOT_FOUND: 8,
22
+ NOT_APPLIED: 9,
23
+ LOCK_LOST: 10,
24
+ RUN_ABORTED: 11,
25
+ HOOK_FAILED: 12,
26
+ MIGRATION_IRREVERSIBLE: 13,
27
+ MIGRATION_TIMEOUT: 14,
28
+ TRANSACTIONS_UNSUPPORTED: 15,
29
+ CONFIG_FILE_EXISTS: 16,
30
+ IMPORT_TARGET_NOT_EMPTY: 17,
31
+ MIGRATION_FILE_EXISTS: 18,
32
+ MIGRATION_INVALID_NAME: 19,
33
+ MIGRATION_INVALID_EXPORT: 20,
34
+ LOCK_RELEASE_FAILED: 21,
35
+ AUDIT_FAILED: 22,
36
+ };
37
+
38
+ module.exports = { EXIT_CODES };
@@ -0,0 +1,91 @@
1
+ const { Command } = require('./args.js');
2
+ const { registerAudit } = require('./commands/audit.js');
3
+ const { registerCreate } = require('./commands/create.js');
4
+ const { registerDown } = require('./commands/down.js');
5
+ const { registerDryRun } = require('./commands/dry-run.js');
6
+ const { registerImport } = require('./commands/import.js');
7
+ const { registerInit } = require('./commands/init.js');
8
+ const { registerList } = require('./commands/list.js');
9
+ const { registerLock } = require('./commands/lock.js');
10
+ const { registerRedo } = require('./commands/redo.js');
11
+ const { registerStatus } = require('./commands/status.js');
12
+ const { registerUnlock } = require('./commands/unlock.js');
13
+ const { registerUp } = require('./commands/up.js');
14
+
15
+ /** Build the root commander program with all commands and global flags */
16
+ function buildProgram() {
17
+ const program = new Command();
18
+
19
+ program
20
+ .name('migronaut')
21
+ .description('Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js')
22
+ .option('--uri <uri>', 'MongoDB connection URI (overrides MIGRONAUT_URI)')
23
+ .option('--db <name>', 'Database name (overrides MIGRONAUT_DB)')
24
+ .option('--dir <path>', 'Migrations directory (overrides MIGRONAUT_MIGRATIONS_DIR)')
25
+ .option('--config <path>', 'Path to a config file (overrides auto-discovery)')
26
+ .option('--env-file <path>', 'Path to the .env file to load (default: ./.env)')
27
+ .option('--no-env', 'Do not load a .env file at all')
28
+ .option('--verbose', 'Show debug output, including error causes')
29
+ .option('--quiet', 'Suppress everything but errors')
30
+ .option('--no-color', 'Disable colored output')
31
+ // Global like --verbose/--quiet: `migronaut --json status` and
32
+ // `migronaut status --json` must both work. `init` has no JSON output and
33
+ // rejects the flag with a pointer to `--format json`.
34
+ .option('--json', 'Output machine-readable JSON instead of human text')
35
+ // Read straight from package.json at runtime — no build-time injection needed.
36
+ .version(require('../../package.json').version);
37
+
38
+ registerInit(program);
39
+ registerImport(program);
40
+ registerUp(program);
41
+ registerDown(program);
42
+ registerRedo(program);
43
+ registerStatus(program);
44
+ registerList(program);
45
+ registerDryRun(program);
46
+ registerCreate(program);
47
+ registerLock(program);
48
+ registerAudit(program);
49
+ registerUnlock(program);
50
+
51
+ return program;
52
+ }
53
+
54
+ /** Parse argv and execute the matching command */
55
+ async function run(argv) {
56
+ // Checked before parsing: color is decided the first time anything renders,
57
+ // which can precede the command action that would otherwise read the flag.
58
+ // Tokens after a `--` terminator are positionals, never flags.
59
+ const boundary = argv.indexOf('--');
60
+ const flagTokens = boundary === -1 ? argv : argv.slice(0, boundary);
61
+ const noColor = flagTokens.includes('--no-color');
62
+ const saved = noColor
63
+ ? {
64
+ MIGRONAUT_FORCE_COLOR: process.env.MIGRONAUT_FORCE_COLOR,
65
+ MIGRONAUT_NO_COLOR: process.env.MIGRONAUT_NO_COLOR,
66
+ }
67
+ : undefined;
68
+ if (noColor) {
69
+ // An explicit flag on this invocation outranks every ambient signal.
70
+ // MIGRONAUT_NO_COLOR is the top of supportsColor()'s precedence chain once
71
+ // MIGRONAUT_FORCE_COLOR is cleared, so setting these two is enough to beat
72
+ // an exported FORCE_COLOR (CI runners and many local shells set it) without
73
+ // touching the shared FORCE_COLOR/NO_COLOR other tools read.
74
+ delete process.env.MIGRONAUT_FORCE_COLOR;
75
+ process.env.MIGRONAUT_NO_COLOR = '1';
76
+ }
77
+ try {
78
+ await buildProgram().parseAsync(argv);
79
+ } finally {
80
+ // A programmatic embedder calling run() twice must not inherit this
81
+ // invocation's flag as ambient state.
82
+ if (saved) {
83
+ for (const key of Object.keys(saved)) {
84
+ if (saved[key] === undefined) delete process.env[key];
85
+ else process.env[key] = saved[key];
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ module.exports = { run };