@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
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./src/index.js');
@@ -0,0 +1,135 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://migronaut.vercel.app/migronaut.schema.json",
4
+ "title": "migronaut configuration",
5
+ "description": "Configuration for migronaut.config.json. Options that hold live instances (hooks, logger, mongoose, client) are only available in a .ts/.js config.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "URL of this schema, for editor completion and validation"
11
+ },
12
+ "uri": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "description": "MongoDB connection URI"
16
+ },
17
+ "dbName": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "description": "Database name"
21
+ },
22
+ "migrationsDir": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "default": "./migrations",
26
+ "description": "Directory holding migration files"
27
+ },
28
+ "migrationsCollection": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "default": "_migronaut_migrations",
32
+ "pattern": "^(?!system\\.)[^$\\u0000]+$",
33
+ "description": "Collection storing the changelog"
34
+ },
35
+ "lockCollection": {
36
+ "type": "string",
37
+ "minLength": 1,
38
+ "default": "_migronaut_locks",
39
+ "pattern": "^(?!system\\.)[^$\\u0000]+$",
40
+ "description": "Collection used for the concurrency lock"
41
+ },
42
+ "lockTTLSeconds": {
43
+ "type": "integer",
44
+ "minimum": 1,
45
+ "default": 60,
46
+ "description": "Seconds before a lock is considered stale and reclaimable"
47
+ },
48
+ "strict": {
49
+ "type": "boolean",
50
+ "default": false,
51
+ "description": "Abort (instead of warn) when an applied file's checksum no longer matches"
52
+ },
53
+ "useTransaction": {
54
+ "type": "boolean",
55
+ "default": false,
56
+ "description": "Wrap every migration in a transaction. Requires a replica set or sharded cluster"
57
+ },
58
+ "fileExtensions": {
59
+ "type": "array",
60
+ "minItems": 1,
61
+ "items": {
62
+ "type": "string",
63
+ "minLength": 1
64
+ },
65
+ "default": [
66
+ ".ts",
67
+ ".js"
68
+ ],
69
+ "description": "Extensions scanned when discovering migrations"
70
+ },
71
+ "createExtension": {
72
+ "enum": [
73
+ "ts",
74
+ "js"
75
+ ],
76
+ "default": "js",
77
+ "description": "File type `migronaut create` generates by default"
78
+ },
79
+ "sequential": {
80
+ "type": "boolean",
81
+ "default": false,
82
+ "description": "Use 0001-style sequential numbering instead of timestamps"
83
+ },
84
+ "templatePath": {
85
+ "type": "string",
86
+ "minLength": 1,
87
+ "description": "Path to a custom migration template file"
88
+ },
89
+ "environment": {
90
+ "type": "string",
91
+ "minLength": 1,
92
+ "description": "Value stamped on changelog records. Defaults to NODE_ENV, then 'production'"
93
+ },
94
+ "onLockLost": {
95
+ "enum": [
96
+ "abort",
97
+ "warn"
98
+ ],
99
+ "default": "abort",
100
+ "description": "What to do when the lock is lost mid-run"
101
+ },
102
+ "envFile": {
103
+ "oneOf": [
104
+ {
105
+ "type": "string",
106
+ "minLength": 1
107
+ },
108
+ {
109
+ "const": false
110
+ }
111
+ ],
112
+ "default": ".env",
113
+ "description": "The .env file to load, or false to load none"
114
+ },
115
+ "ensureIndexes": {
116
+ "type": "boolean",
117
+ "default": true,
118
+ "description": "Create the changelog indexes on first connect"
119
+ },
120
+ "timeoutMs": {
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "description": "Abort the run when a single migration exceeds this many milliseconds"
124
+ },
125
+ "reloadMigrations": {
126
+ "type": "boolean",
127
+ "default": false,
128
+ "description": "Bypass the ESM module cache when loading migrations (long-lived processes)"
129
+ },
130
+ "clientOptions": {
131
+ "type": "object",
132
+ "description": "MongoDB driver options (MongoClientOptions): TLS, auth mechanisms, proxies, pool sizing. Passed to the driver as-is."
133
+ }
134
+ }
135
+ }
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "@alexify/migronaut",
3
+ "version": "1.0.0",
4
+ "description": "Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js",
5
+ "license": "MIT",
6
+ "author": "Alex Dolid <dolid.sasha@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Alexis-Technologies/migronaut.git"
10
+ },
11
+ "homepage": "https://migronaut.vercel.app/",
12
+ "bugs": {
13
+ "url": "https://github.com/Alexis-Technologies/migronaut/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.18.0"
17
+ },
18
+ "main": "index.js",
19
+ "types": "index.d.ts",
20
+ "bin": {
21
+ "migronaut": "./bin/migronaut.js"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./index.d.ts",
26
+ "default": "./index.js"
27
+ }
28
+ },
29
+ "directories": {
30
+ "test": "tests"
31
+ },
32
+ "files": [
33
+ "index.js",
34
+ "index.d.ts",
35
+ "migronaut.schema.json",
36
+ "bin",
37
+ "src",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "tsd": {
45
+ "directory": "tests/types"
46
+ },
47
+ "keywords": [
48
+ "mongodb",
49
+ "mongo",
50
+ "migration",
51
+ "migrations",
52
+ "mongodb-migration",
53
+ "mongodb-migrations",
54
+ "database-migration",
55
+ "schema-migration",
56
+ "migrate-mongo",
57
+ "mongoose",
58
+ "mongoose-migration",
59
+ "rollback",
60
+ "transactions",
61
+ "cli",
62
+ "typescript",
63
+ "nosql"
64
+ ],
65
+ "peerDependencies": {
66
+ "mongodb": ">=5.0.0",
67
+ "mongoose": ">=7.0.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "mongoose": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "devDependencies": {
75
+ "@types/node": "^22.19.19",
76
+ "c8": "^10.1.3",
77
+ "esbuild": "^0.28.1",
78
+ "mongodb": "^6.12.0",
79
+ "mongodb-memory-server": "10.4.3",
80
+ "mongoose": "^8.9.2",
81
+ "oxfmt": "^0.60.0",
82
+ "oxlint": "^1.75.0",
83
+ "pino": "^10.3.1",
84
+ "tsd": "^0.31.2",
85
+ "typescript": "^5.7.2",
86
+ "vitepress": "^1.6.4"
87
+ },
88
+ "scripts": {
89
+ "test": "pnpm run test:unit && pnpm run test:integration",
90
+ "test:unit": "node --test \"tests/unit/**/*.test.js\"",
91
+ "test:integration": "node scripts/node-test.js --test-concurrency=1 \"tests/integration/**/*.test.js\"",
92
+ "test:coverage": "c8 --all --include 'src/**' --check-coverage --lines 90 --branches 90 --functions 90 --reporter text --reporter lcov node scripts/node-test.js --test-concurrency=1 \"tests/unit/**/*.test.js\" \"tests/integration/**/*.test.js\"",
93
+ "test:types": "tsd",
94
+ "check:dts": "tsc --noEmit --strict --skipLibCheck false index.d.ts",
95
+ "lint": "oxlint src bin scripts tests bench",
96
+ "format": "oxfmt src bin scripts tests bench",
97
+ "format:check": "oxfmt --check src bin scripts tests bench",
98
+ "size": "node scripts/size.js",
99
+ "bench": "node bench/bench.js",
100
+ "docs:dev": "vitepress dev docs",
101
+ "docs:build": "vitepress build docs",
102
+ "docs:preview": "vitepress preview docs",
103
+ "release": "pnpm publish"
104
+ }
105
+ }
@@ -0,0 +1,314 @@
1
+ const OPTION_PATTERN = /^(?:-([A-Za-z]), )?--([a-z][a-z0-9-]*)(?: <([^>]+)>)?$/;
2
+
3
+ /** Convert a kebab-case option name to its camelCase opts key */
4
+ const camelize = (name) => name.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
5
+
6
+ /** Pad `label` so descriptions in a help block line up */
7
+ const padLabel = (label, width) => label + ' '.repeat(width - label.length);
8
+
9
+ /** Render one aligned `label description` section of a help screen */
10
+ function renderSection(title, rows) {
11
+ if (rows.length === 0) return [];
12
+ let width = 0;
13
+ for (const row of rows) {
14
+ if (row[0].length > width) width = row[0].length;
15
+ }
16
+ const lines = [];
17
+ for (const row of rows) {
18
+ lines.push(` ${padLabel(row[0], width)} ${row[1]}`);
19
+ }
20
+ return ['', `${title}:`, ...lines];
21
+ }
22
+
23
+ /**
24
+ * Minimal commander-compatible CLI framework covering exactly the surface the
25
+ * migronaut commands use: one level of subcommands, boolean/value/negatable
26
+ * (`--no-x`) options with camelCase keys and optional short aliases, required
27
+ * `<x>` / optional `[x]` positional arguments, `optsWithGlobals()`, and
28
+ * generated `--help` / `--version`. Global (root) options are recognized both
29
+ * before and after the subcommand name. Parse errors are written to stderr
30
+ * and set `process.exitCode = 1` — `process.exit()` is never called.
31
+ *
32
+ * Deliberately unsupported (unused by migronaut): combined short flags
33
+ * (`-fy`), variadic arguments, option defaults other than negatable `true`.
34
+ */
35
+ class Command {
36
+ #name = '';
37
+ #description = '';
38
+ #version = null;
39
+ #options = [];
40
+ #arguments = [];
41
+ #commands = [];
42
+ #actionFn = null;
43
+ #parent = null;
44
+ #values = {};
45
+ /** Lazily-built `--long`/`-s` → option lookup; reset whenever options change */
46
+ #optionIndex = null;
47
+
48
+ name(value) {
49
+ this.#name = value;
50
+ return this;
51
+ }
52
+
53
+ description(value) {
54
+ this.#description = value;
55
+ return this;
56
+ }
57
+
58
+ version(value) {
59
+ this.#version = value;
60
+ return this;
61
+ }
62
+
63
+ option(flags, description = '') {
64
+ const match = OPTION_PATTERN.exec(flags);
65
+ if (!match) throw new TypeError(`Unsupported option flags: '${flags}'`);
66
+ const short = match[1] ?? null;
67
+ const rawName = match[2];
68
+ const negated = rawName.startsWith('no-');
69
+ this.#options.push({
70
+ flags,
71
+ description,
72
+ short,
73
+ long: `--${rawName}`,
74
+ key: camelize(negated ? rawName.slice(3) : rawName),
75
+ negated,
76
+ takesValue: Boolean(match[3]),
77
+ });
78
+ this.#optionIndex = null;
79
+ return this;
80
+ }
81
+
82
+ argument(spec, description = '') {
83
+ this.#arguments.push({
84
+ name: spec.slice(1, -1),
85
+ required: spec.startsWith('<'),
86
+ description,
87
+ });
88
+ return this;
89
+ }
90
+
91
+ command(name) {
92
+ const sub = new Command();
93
+ sub.#name = name;
94
+ sub.#parent = this;
95
+ this.#commands.push(sub);
96
+ return sub;
97
+ }
98
+
99
+ action(fn) {
100
+ this.#actionFn = fn;
101
+ return this;
102
+ }
103
+
104
+ opts() {
105
+ return this.#values;
106
+ }
107
+
108
+ optsWithGlobals() {
109
+ const globals = this.#parent ? this.#parent.#values : {};
110
+ return { ...globals, ...this.#values };
111
+ }
112
+
113
+ /** Parse argv (including the node + script tokens) and run the matching action */
114
+ async parseAsync(argv) {
115
+ const tokens = argv.slice(2);
116
+ this.#values = {};
117
+ this.#seedNegatableDefaults();
118
+
119
+ let command = null;
120
+ const positionals = [];
121
+ // After a bare `--`, everything is a positional — the standard
122
+ // end-of-options terminator.
123
+ let optionsEnded = false;
124
+
125
+ for (let i = 0; i < tokens.length; i++) {
126
+ const token = tokens[i];
127
+ if (optionsEnded) {
128
+ positionals.push(token);
129
+ continue;
130
+ }
131
+ if (token === '--') {
132
+ optionsEnded = true;
133
+ continue;
134
+ }
135
+ if (token === '-h' || token === '--help') {
136
+ // Short-circuit, commander-style: the user asking for help must get
137
+ // it even when a later token would fail the parse — mistyping a flag
138
+ // and appending --help to find out what went wrong is the exact case.
139
+ process.stdout.write(`${this.#helpText(command ?? this)}\n`);
140
+ return;
141
+ }
142
+ if ((token === '-V' || token === '--version') && this.#version !== null) {
143
+ process.stdout.write(`${this.#version}\n`);
144
+ return;
145
+ }
146
+ if (token.startsWith('-') && token !== '-') {
147
+ let name = token;
148
+ let inlineValue;
149
+ const separator = token.indexOf('=');
150
+ if (token.startsWith('--') && separator !== -1) {
151
+ name = token.slice(0, separator);
152
+ inlineValue = token.slice(separator + 1);
153
+ }
154
+ const found = this.#findOption(command, name);
155
+ if (!found) return this.#fail(`unknown option '${name}'`);
156
+ const { owner, option } = found;
157
+ if (option.takesValue) {
158
+ let value = inlineValue;
159
+ if (value === undefined) {
160
+ const next = tokens[i + 1];
161
+ // A leading digit after `-` is a negative number, not a flag —
162
+ // `--steps -1` should reach the core validator (which rejects it
163
+ // with an actionable message), not die as "argument missing".
164
+ const negativeNumber = next !== undefined && /^-\d/.test(next);
165
+ if (next === undefined || (next.startsWith('-') && next !== '-' && !negativeNumber)) {
166
+ return this.#fail(
167
+ `option '${option.flags}' argument missing ` +
168
+ `(use ${option.long}=<value> for values starting with '-')`,
169
+ );
170
+ }
171
+ value = next;
172
+ i++;
173
+ }
174
+ owner.#values[option.key] = value;
175
+ } else {
176
+ owner.#values[option.key] = !option.negated;
177
+ }
178
+ continue;
179
+ }
180
+ if (command === null) {
181
+ let sub = null;
182
+ for (const candidate of this.#commands) {
183
+ if (candidate.#name === token) {
184
+ sub = candidate;
185
+ break;
186
+ }
187
+ }
188
+ if (!sub) return this.#fail(`unknown command '${token}'`);
189
+ command = sub;
190
+ command.#values = {};
191
+ command.#seedNegatableDefaults();
192
+ continue;
193
+ }
194
+ positionals.push(token);
195
+ }
196
+
197
+ if (command === null) {
198
+ process.stderr.write(`${this.#helpText(this)}\n`);
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ const args = [];
204
+ for (let i = 0; i < command.#arguments.length; i++) {
205
+ const spec = command.#arguments[i];
206
+ const value = positionals[i];
207
+ if (value === undefined && spec.required) {
208
+ return this.#fail(`missing required argument '${spec.name}'`);
209
+ }
210
+ args.push(value);
211
+ }
212
+ // Extra positionals are almost always a mistake (`up a.js b.js` would
213
+ // silently run only a.js); commander errors here, and so do we.
214
+ if (positionals.length > command.#arguments.length) {
215
+ const extra = positionals.slice(command.#arguments.length);
216
+ return this.#fail(`too many arguments — unexpected: ${extra.join(', ')}`);
217
+ }
218
+
219
+ if (command.#actionFn) {
220
+ await command.#actionFn(...args, command.#values, command);
221
+ }
222
+ }
223
+
224
+ /** Seed negatable options: declaring `--no-x` makes `x` default to true */
225
+ #seedNegatableDefaults() {
226
+ for (const option of this.#options) {
227
+ if (option.negated) this.#values[option.key] = true;
228
+ }
229
+ }
230
+
231
+ /** The `--long`/`-s` → option map for this command, built once per option set */
232
+ #lookupIndex() {
233
+ if (this.#optionIndex === null) {
234
+ const index = new Map();
235
+ for (const option of this.#options) {
236
+ index.set(option.long, option);
237
+ if (option.short !== null) index.set(`-${option.short}`, option);
238
+ }
239
+ this.#optionIndex = index;
240
+ }
241
+ return this.#optionIndex;
242
+ }
243
+
244
+ /** Resolve an option token against the active command first, then the root */
245
+ #findOption(command, token) {
246
+ const lookup = (owner) => {
247
+ if (owner === null) return null;
248
+ const option = owner.#lookupIndex().get(token);
249
+ return option ? { owner, option } : null;
250
+ };
251
+ return lookup(command) ?? lookup(this);
252
+ }
253
+
254
+ #fail(message) {
255
+ process.stderr.write(`error: ${message}\n`);
256
+ process.exitCode = 1;
257
+ }
258
+
259
+ /** Usage suffix for a command's positional arguments, e.g. ` <direction> [file]` */
260
+ #argsUsage(command) {
261
+ let usage = '';
262
+ for (const spec of command.#arguments) {
263
+ usage += spec.required ? ` <${spec.name}>` : ` [${spec.name}]`;
264
+ }
265
+ return usage;
266
+ }
267
+
268
+ #helpText(target) {
269
+ const isRoot = target === this;
270
+ const usage = isRoot
271
+ ? `Usage: ${this.#name} [options] [command]`
272
+ : `Usage: ${this.#name} ${target.#name} [options]${this.#argsUsage(target)}`;
273
+ const lines = [usage];
274
+ if (target.#description) lines.push('', target.#description);
275
+
276
+ const argumentRows = [];
277
+ for (const spec of target.#arguments) {
278
+ argumentRows.push([spec.name, spec.description]);
279
+ }
280
+ lines.push(...renderSection('Arguments', argumentRows));
281
+
282
+ const optionRows = [];
283
+ for (const option of target.#options) {
284
+ optionRows.push([option.flags, option.description]);
285
+ }
286
+ if (isRoot && this.#version !== null) {
287
+ optionRows.push(['-V, --version', 'output the version number']);
288
+ }
289
+ optionRows.push(['-h, --help', 'display help for command']);
290
+ lines.push(...renderSection('Options', optionRows));
291
+
292
+ // Subcommand help also lists the root's flags: they are accepted after the
293
+ // command name, so hiding them here would misdocument the real surface.
294
+ if (!isRoot) {
295
+ const globalRows = [];
296
+ for (const option of this.#options) {
297
+ globalRows.push([option.flags, option.description]);
298
+ }
299
+ lines.push(...renderSection('Global Options', globalRows));
300
+ }
301
+
302
+ if (isRoot) {
303
+ const commandRows = [];
304
+ for (const sub of this.#commands) {
305
+ const options = sub.#options.length > 0 ? ' [options]' : '';
306
+ commandRows.push([`${sub.#name}${options}${this.#argsUsage(sub)}`, sub.#description]);
307
+ }
308
+ lines.push(...renderSection('Commands', commandRows));
309
+ }
310
+ return lines.join('\n');
311
+ }
312
+ }
313
+
314
+ module.exports = { Command };
@@ -0,0 +1,40 @@
1
+ const { createColors } = require('../../utils/colors.js');
2
+ const { defineCommand, EXIT_CODES } = require('../shared.js');
3
+
4
+ /** Symbol and color for each check outcome */
5
+ function renderStatus(colors, status) {
6
+ if (status === 'pass') return colors.green('✔');
7
+ if (status === 'warn') return colors.yellow('!');
8
+ return colors.red('✖');
9
+ }
10
+
11
+ /** Register the `audit` command (read-only diagnostics) */
12
+ function registerAudit(program) {
13
+ defineCommand(program, {
14
+ name: 'audit',
15
+ description: 'Check the migronaut setup: config, connectivity, transactions, indexes, lock',
16
+ // audit() connects on its own and reports the outcome as a check.
17
+ connect: false,
18
+ run: (migrator) => migrator.audit(),
19
+ render: (report, { logger }) => {
20
+ const colors = createColors(process.stdout);
21
+ for (const check of report.checks) {
22
+ logger.info(
23
+ `${renderStatus(colors, check.status)} ${check.name.padEnd(12)} ${check.detail}`,
24
+ );
25
+ }
26
+ logger.info(
27
+ report.ok
28
+ ? `\nNo problems found (${report.warnings} warning(s))`
29
+ : `\n${report.failed} check(s) failed, ${report.warnings} warning(s)`,
30
+ );
31
+ },
32
+ // A failed check is what CI should act on; warnings are advisory. The
33
+ // dedicated code separates "a check failed" from an unclassified crash.
34
+ after: (report) => {
35
+ if (!report.ok) process.exitCode = EXIT_CODES.AUDIT_FAILED;
36
+ },
37
+ });
38
+ }
39
+
40
+ module.exports = { registerAudit };
@@ -0,0 +1,29 @@
1
+ const { defineCommand } = require('../shared.js');
2
+
3
+ /** Register the `create` command */
4
+ function registerCreate(program) {
5
+ defineCommand(program, {
6
+ name: 'create',
7
+ description: 'Create a new migration file',
8
+ args: [['<name>', 'Migration name (will be slugified)']],
9
+ options: [
10
+ ['--js', 'Force a .js file (overrides config createExtension)'],
11
+ ['--ts', 'Force a .ts file (overrides config createExtension)'],
12
+ ['--template <path>', 'Use a custom template file'],
13
+ ],
14
+ // Writing a file needs no database — no spinner, no pre-connect.
15
+ spinner: false,
16
+ run: async (migrator, opts, [name]) => {
17
+ // Tri-state: explicit flag wins; otherwise leave undefined so config decides.
18
+ const js = opts.ts ? false : opts.js ? true : undefined;
19
+ const path = await migrator.create(name, {
20
+ ...(js !== undefined ? { js } : {}),
21
+ ...(opts.template ? { template: opts.template } : {}),
22
+ });
23
+ return { path };
24
+ },
25
+ // No render: core already logs "✔ Created …" itself.
26
+ });
27
+ }
28
+
29
+ module.exports = { registerCreate };
@@ -0,0 +1,30 @@
1
+ const { defineCommand } = require('../shared.js');
2
+
3
+ /** Register the `down` command */
4
+ function registerDown(program) {
5
+ defineCommand(program, {
6
+ name: 'down',
7
+ description:
8
+ 'Rollback the last batch, a specific batch, the last N steps, or a single named file',
9
+ args: [['[file]', 'Specific migration file to revert']],
10
+ options: [
11
+ ['--batch <n>', 'Revert a specific batch number'],
12
+ ['--steps <n>', 'Revert the last N migrations, regardless of batch'],
13
+ ['--to <file>', 'Revert everything applied after this file (it stays applied)'],
14
+ ],
15
+ lockable: true,
16
+ mutating: true,
17
+ run: (migrator, opts, [file]) =>
18
+ migrator.down(file, {
19
+ noLock: opts.noLock,
20
+ // `!== undefined`, not truthiness: `--batch 0` is a mistake worth
21
+ // reporting, not one to silently drop. Core validates the value.
22
+ ...(opts.batch !== undefined ? { batch: Number(opts.batch) } : {}),
23
+ ...(opts.steps !== undefined ? { steps: Number(opts.steps) } : {}),
24
+ ...(opts.to ? { to: opts.to } : {}),
25
+ }),
26
+ // No render: core logs every ↩ Reverted line itself.
27
+ });
28
+ }
29
+
30
+ module.exports = { registerDown };
@@ -0,0 +1,36 @@
1
+ const { ConfigInvalidError } = require('../../errors/index.js');
2
+ const { defineCommand } = require('../shared.js');
3
+ const { renderStatusTable } = require('../table.js');
4
+
5
+ /** Register the `dry-run` command */
6
+ function registerDryRun(program) {
7
+ defineCommand(program, {
8
+ name: 'dry-run',
9
+ description: 'Preview what an up or down would do, without touching the database',
10
+ args: [
11
+ ['<direction>', "Either 'up' or 'down'"],
12
+ ['[file]', 'Specific migration file'],
13
+ ],
14
+ options: [
15
+ ['--steps <n>', 'Preview reverting the last N migrations (down only)'],
16
+ ['--batch <n>', 'Preview reverting a specific batch (down only)'],
17
+ ['--to <file>', 'Preview migrating to this file (up: inclusive; down: exclusive)'],
18
+ ],
19
+ preflight: (_opts, [direction]) => {
20
+ if (direction !== 'up' && direction !== 'down') {
21
+ throw new ConfigInvalidError("Direction must be 'up' or 'down'", { direction });
22
+ }
23
+ },
24
+ run: (migrator, opts, [direction, file]) =>
25
+ migrator.dryRun(direction, file, {
26
+ ...(opts.steps !== undefined ? { steps: Number(opts.steps) } : {}),
27
+ ...(opts.batch !== undefined ? { batch: Number(opts.batch) } : {}),
28
+ ...(opts.to ? { to: opts.to } : {}),
29
+ }),
30
+ render: (rows, { logger }) => {
31
+ if (rows.length > 0) logger.info(renderStatusTable(rows));
32
+ },
33
+ });
34
+ }
35
+
36
+ module.exports = { registerDryRun };