@alexify/migronaut 1.0.0 → 2.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 +89 -1
- package/README.md +42 -20
- package/bin/migronaut.js +11 -3
- package/index.d.ts +126 -14
- package/migronaut.schema.json +9 -0
- package/package.json +7 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/exit-codes.js +1 -0
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +15 -3
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +74 -23
- package/src/core/config.js +25 -2
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/lock.js +28 -6
- package/src/core/migrator.js +296 -75
- package/src/core/run.js +33 -1
- package/src/core/runner.js +70 -20
- package/src/errors/index.js +15 -1
- package/src/index.js +8 -0
- package/src/utils/logger.js +30 -12
- package/src/utils/redact.js +36 -3
- package/src/utils/sanitize.js +8 -3
- package/src/utils/template.js +24 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,96 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
Release headings carry the publish date (`## vX.Y.Z — YYYY-MM-DD`).
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## v2.0.0 — 2026-08-30
|
|
7
|
+
|
|
8
|
+
A major bump for three narrow contract changes (below); everything else is
|
|
9
|
+
additive. Upgrading is a no-op for the most commonly scripted surface:
|
|
10
|
+
`pendingMigrations()` / `list('pending')` still report a file as `pending`
|
|
11
|
+
even when a failed attempt was recorded for it.
|
|
12
|
+
|
|
13
|
+
### Breaking changes
|
|
14
|
+
|
|
15
|
+
- **`unlock --json` now requires `--yes`.** Previously `--json` alone
|
|
16
|
+
force-released the lock; it now refuses with `CONFIG_INVALID` (exit 6), because
|
|
17
|
+
a non-interactive mode must never assume consent to a destructive action —
|
|
18
|
+
force-releasing a live run's lock enables exactly the concurrent migration the
|
|
19
|
+
lock exists to prevent. **Migration:** add `--yes` to any scripted
|
|
20
|
+
`migronaut unlock --json`.
|
|
21
|
+
- **`StatusRow.status` and `MigrationStatus` gained `'failed'`.** A recorded
|
|
22
|
+
failed attempt now renders as `failed` in `status()` / `list('all')` instead of
|
|
23
|
+
`pending`. **Migration:** TypeScript consumers with an exhaustive `switch` over
|
|
24
|
+
the status must handle the new member; treat `'failed'` as outstanding work.
|
|
25
|
+
- **`MigronautErrorCode` gained `'MIGRATION_OUT_OF_ORDER'`** (and `EXIT_CODES`
|
|
26
|
+
the matching key, exit 23). **Migration:** exhaustive `switch` statements over
|
|
27
|
+
the code union must handle it.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`migronaut baseline`** — adopt an existing database with no prior migration tool: mark
|
|
32
|
+
migration files on disk as applied (checksum from disk, one shared batch, `origin: 'baseline'`)
|
|
33
|
+
without executing them. Forward-only, idempotent, confirmation-gated.
|
|
34
|
+
- **Out-of-order detection** — a bulk `up` flags pending migrations that sort before the newest
|
|
35
|
+
applied one (a file merged late from a parallel branch). New scalar config `onOutOfOrder:
|
|
36
|
+
'warn' | 'error' | 'allow'` (default `'warn'`, env `MIGRONAUT_ON_OUT_OF_ORDER`), a new
|
|
37
|
+
`MIGRATION_OUT_OF_ORDER` error code (exit 23), `outOfOrder` on `StatusRow`, and an `ordering`
|
|
38
|
+
check in `audit`.
|
|
39
|
+
- **Failed-attempt traces** — a failing `up` now leaves a best-effort `status: 'failed'` record
|
|
40
|
+
(error text, `failedAt`, `runId`) in the changelog; `status` renders it as `failed` while every
|
|
41
|
+
run path still retries the file; a successful apply clears the trace. A forced re-run's failure
|
|
42
|
+
never demotes an `applied` record.
|
|
43
|
+
- **Audit-trail read surface** — `StatusRow` now carries `executedBy`, `environment`, `runId`,
|
|
44
|
+
`revertedAt` and `origin` from the changelog record, so `status --json` can answer "who ran
|
|
45
|
+
migration X, from which run, and was it ever reverted?".
|
|
46
|
+
- **`onKit` option on `runMigrations`** — receive the internally-constructed `MigratorKit` before
|
|
47
|
+
connect and subscribe to its lifecycle events (metrics without log parsing).
|
|
48
|
+
- **`createLogger` export** — the default console logger (with level control) for programmatic
|
|
49
|
+
callers.
|
|
50
|
+
- **`cwd` option on `MigratorKit`** — scope config discovery, `.env` loading and a relative
|
|
51
|
+
`migrationsDir` to a project root, for processes hosting kits for several projects.
|
|
52
|
+
- **ESM-interop integration test** pinning the "ESM consumers still work" promise.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **Server-time changelog stamps** — `appliedAt`/`revertedAt`/`failedAt` are stamped with the
|
|
57
|
+
server clock (`$currentDate`, matching the lock's `$$NOW` discipline) unless the record carries
|
|
58
|
+
an explicit `appliedAt` (import), so `redo`/`down --steps` ordering is immune to host clock skew.
|
|
59
|
+
- **Non-transactional changelog-write failures are reported distinctly** — when a migration's own
|
|
60
|
+
writes committed but recording them failed, the error now says exactly that (context
|
|
61
|
+
`phase: 'changelog-write'`, `bodySucceeded: true`) instead of the generic "migration failed"
|
|
62
|
+
that invited re-running committed writes.
|
|
63
|
+
- **A timed-out migration is told to stop** — `timeoutMs` now aborts `ctx.signal` (with the
|
|
64
|
+
`MigrationTimeoutError` as reason), so cooperative bodies stop writing instead of racing the
|
|
65
|
+
next lock holder.
|
|
66
|
+
- **`lockWaitTimeoutMs` bounds stall, not total wait** — while a waiting `runMigrations` observes
|
|
67
|
+
the holder's heartbeat advancing, the deadline re-arms; only a stalled holder times peers out.
|
|
68
|
+
- **`baseline` requires `--yes` in `--json` mode** — the same non-interactive
|
|
69
|
+
confirmation policy `up --force --json` and `unlock --json` follow.
|
|
70
|
+
- **Failure telemetry carries timing** — `migration:error` events, error result rows and error
|
|
71
|
+
contexts now include `durationMs`/`batch`; the run ends with a `✔ Done N applied in Xms`
|
|
72
|
+
summary line.
|
|
73
|
+
- Signals during the CLI's pre-connect now abort the run (exit 11) instead of being silently
|
|
74
|
+
dropped; partial-results lists survive hook/load failures and lock-release failures; lock
|
|
75
|
+
warn lines carry `runId`; import interruptions report progress and that a `--force` re-run
|
|
76
|
+
resumes idempotently.
|
|
77
|
+
|
|
78
|
+
### Fixed / hardened
|
|
79
|
+
|
|
80
|
+
- URI redaction now masks query-string secrets (`tlsCertificateKeyFilePassword`, `proxyPassword`,
|
|
81
|
+
`sslKeyPassword`, secret `authMechanismProperties` values) and empty-username passwords —
|
|
82
|
+
in logs, errors, `--json`, events, and `init`-generated config files (which now warn about
|
|
83
|
+
query-string secrets too).
|
|
84
|
+
- Terminal sanitization strips the whole C1 control block (DCS/OSC/PM/APC, not just CSI), and
|
|
85
|
+
`bin/migronaut.js`'s last-resort handlers sanitize their output.
|
|
86
|
+
- `MigrationLock.release()` without a held owner token is a no-op instead of an unscoped delete;
|
|
87
|
+
an uncontended `acquire()` takes one round trip instead of two.
|
|
88
|
+
- Import checksum resolution is concurrency-bounded (no EMFILE on thousands-record changelogs);
|
|
89
|
+
the strict drift check reuses the instance checksum cache; `dry-run up` fetches applied names
|
|
90
|
+
instead of full records; a warn/error-only injected logger keeps its output instead of being
|
|
91
|
+
silenced entirely.
|
|
92
|
+
|
|
93
|
+
## v1.0.0 — 2026-07-28
|
|
6
94
|
|
|
7
95
|
Initial release. Requires **Node.js ≥ 22.18**.
|
|
8
96
|
|
package/README.md
CHANGED
|
@@ -23,6 +23,22 @@ change before it touches your database.
|
|
|
23
23
|
|
|
24
24
|
</div>
|
|
25
25
|
|
|
26
|
+
> [!TIP]
|
|
27
|
+
> ### 🔄 Already using `migrate-mongo`? Switch in under a minute.
|
|
28
|
+
>
|
|
29
|
+
> `migronaut` adopts your existing `changelog` **as-is** — no re-running migrations, no data loss, no rewriting
|
|
30
|
+
> files. Point it at the same database and bring your whole history over in one command:
|
|
31
|
+
>
|
|
32
|
+
> ```bash
|
|
33
|
+
> migronaut import # one-time: adopt your migrate-mongo changelog (it's never modified)
|
|
34
|
+
> migronaut up # applies only what's new — your past migrations are recognized as already applied
|
|
35
|
+
> ```
|
|
36
|
+
>
|
|
37
|
+
> Your applied history is preserved and new migrations run normally. Your `up`/`down`/`create`/`status`
|
|
38
|
+
> mental model carries over 1:1 — you just gain dry-runs, single-file control, real rollbacks, hooks,
|
|
39
|
+
> and locking. No `migrate-mongo` history at all? `migronaut baseline` adopts an existing database
|
|
40
|
+
> with no prior tool. → **[See how it works](#advanced-features)**
|
|
41
|
+
|
|
26
42
|
---
|
|
27
43
|
|
|
28
44
|
## Reasons to choose it
|
|
@@ -35,6 +51,10 @@ change before it touches your database.
|
|
|
35
51
|
- **No race conditions** — an atomic MongoDB lock stops two deploys running migrations at once.
|
|
36
52
|
- **Tamper detection** — SHA-256 checksums catch a migration edited after it was applied.
|
|
37
53
|
- **Audit trail kept** — a rollback updates the record, it never deletes it.
|
|
54
|
+
- **Adopt any existing database** — `migronaut import` for a migrate-mongo history,
|
|
55
|
+
`migronaut baseline` when there was no migration tool at all.
|
|
56
|
+
- **Out-of-order detection** — a migration merged late from a parallel branch is flagged
|
|
57
|
+
(warn by default, `onOutOfOrder: 'error'` to refuse) instead of silently applying.
|
|
38
58
|
- **Lifecycle hooks** — `beforeAll`, `afterAll`, `beforeEach`, `afterEach`, `onError`.
|
|
39
59
|
- **Opt-in transactions** — wrap a migration so it fully commits or fully aborts.
|
|
40
60
|
- **TypeScript, ESM & CommonJS** — all run with no `ts-node` plumbing.
|
|
@@ -51,13 +71,15 @@ change before it touches your database.
|
|
|
51
71
|
| Roll back a specific batch (not just the last) | ❌ | ✅ |
|
|
52
72
|
| Dry-run preview | ❌ | ✅ |
|
|
53
73
|
| `redo` (down + up) | ❌ | ✅ |
|
|
54
|
-
|
|
|
74
|
+
| Concurrency lock | opt-in, TTL-only | ✅ always on: heartbeat, owner token, abort-on-loss, `lock`/`unlock` CLI |
|
|
75
|
+
| Checksum / tamper detection | opt-in `useFileHash` (re-runs changed files) | ✅ enforced drift detection: per-row `checksumOk`, `--strict`, `audit` |
|
|
55
76
|
| Lifecycle hooks | ❌ | ✅ |
|
|
56
77
|
| First-class TypeScript (built-in) | ❌ | ✅ |
|
|
57
78
|
| History preserved on rollback (never deleted) | ❌ | ✅ |
|
|
58
79
|
| Adopt an existing `migrate-mongo` changelog | — | ✅ `migronaut import` |
|
|
59
80
|
|
|
60
|
-
<sub>Reflects `migrate-mongo`'s documented CLI as of mid-2026
|
|
81
|
+
<sub>Reflects `migrate-mongo`'s documented CLI as of mid-2026 (v14: optional
|
|
82
|
+
`lockCollectionName`/`lockTtl` lock, optional `useFileHash`). It has since added transaction access
|
|
61
83
|
via a `client` argument; `migronaut` exposes the same plus a declarative per-file `useTransaction` flag.</sub>
|
|
62
84
|
|
|
63
85
|
### How it compares to `mongo-migrate-kit`
|
|
@@ -87,21 +109,6 @@ with no dependency.</sub>
|
|
|
87
109
|
|
|
88
110
|
→ **[Full comparison, and what stayed the same](https://migronaut.vercel.app/guide/vs-mongo-migrate-kit)**
|
|
89
111
|
|
|
90
|
-
> [!TIP]
|
|
91
|
-
> ### 🔄 Already using `migrate-mongo`? Switch in under a minute.
|
|
92
|
-
>
|
|
93
|
-
> `migronaut` adopts your existing `changelog` **as-is** — no re-running migrations, no data loss, no rewriting
|
|
94
|
-
> files. Point it at the same database and bring your whole history over in one command:
|
|
95
|
-
>
|
|
96
|
-
> ```bash
|
|
97
|
-
> migronaut import # one-time: adopt your migrate-mongo changelog (it's never modified)
|
|
98
|
-
> migronaut up # applies only what's new — your past migrations are recognized as already applied
|
|
99
|
-
> ```
|
|
100
|
-
>
|
|
101
|
-
> Your applied history is preserved and new migrations run normally. Your `up`/`down`/`create`/`status`
|
|
102
|
-
> mental model carries over 1:1 — you just gain dry-runs, single-file control, real rollbacks, hooks,
|
|
103
|
-
> and locking. → **[See how it works](#advanced-features)**
|
|
104
|
-
|
|
105
112
|
---
|
|
106
113
|
|
|
107
114
|
## Quick start
|
|
@@ -168,6 +175,7 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
|
|
|
168
175
|
|---|---|
|
|
169
176
|
| `migronaut init` | Create a documented `migronaut.config.*` in the current directory |
|
|
170
177
|
| `migronaut import` | Adopt an existing `migrate-mongo` changelog (one-time, forward-only) |
|
|
178
|
+
| `migronaut baseline` | Mark existing migration files as applied without running them (adopt an existing DB) |
|
|
171
179
|
| `migronaut create <name>` | Generate a timestamped migration file |
|
|
172
180
|
| `migronaut up [file]` | Run all pending migrations, one named file, or up to `--to <file>` |
|
|
173
181
|
| `migronaut down [file]` | Roll back the last batch, a chosen batch, the last N steps, one file, or to `--to <file>` |
|
|
@@ -179,8 +187,8 @@ Every command accepts the global flags `--uri`, `--db`, `--dir`, `--config`, `--
|
|
|
179
187
|
| `migronaut lock` | Show who currently holds the migration lock |
|
|
180
188
|
| `migronaut unlock` | Force-release a stuck lock left behind by a crashed run |
|
|
181
189
|
|
|
182
|
-
Most data commands (`up`, `down`, `redo`, `status`, `list`, `dry-run`, `import`, `
|
|
183
|
-
`audit`, `lock`, `unlock`) accept **`--json`** for machine-readable output — see
|
|
190
|
+
Most data commands (`up`, `down`, `redo`, `status`, `list`, `dry-run`, `import`, `baseline`,
|
|
191
|
+
`create`, `audit`, `lock`, `unlock`) accept **`--json`** for machine-readable output — see
|
|
184
192
|
[CI & automation](#ci--automation).
|
|
185
193
|
|
|
186
194
|
<details>
|
|
@@ -206,6 +214,12 @@ migronaut import --force # proceed even if the migronaut changelog alr
|
|
|
206
214
|
migronaut import --no-lock # skip the concurrency lock (local dev only)
|
|
207
215
|
migronaut import --json # machine-readable output
|
|
208
216
|
|
|
217
|
+
# baseline — adopt an existing database (no prior migration tool)
|
|
218
|
+
migronaut baseline # mark ALL pending files as applied, without running them
|
|
219
|
+
migronaut baseline --to <file> # only files up to and including <file>
|
|
220
|
+
migronaut baseline --yes # skip the confirmation prompt (required with --json)
|
|
221
|
+
migronaut baseline --json # machine-readable output ({ "baselined": [...], ... })
|
|
222
|
+
|
|
209
223
|
# create — generate a migration file
|
|
210
224
|
migronaut create <name> # file type follows config `createExtension` (default .js)
|
|
211
225
|
migronaut create <name> --ts # force a .ts file
|
|
@@ -580,6 +594,11 @@ await migrator.disconnect();
|
|
|
580
594
|
All errors extend `MigronautError` and carry a typed `code` (`LOCK_ALREADY_HELD`, `CHECKSUM_MISMATCH`,
|
|
581
595
|
`NOT_APPLIED`, …), so `catch` blocks stay type-safe.
|
|
582
596
|
|
|
597
|
+
> [!NOTE]
|
|
598
|
+
> Running several kits against several databases in **one process**? Pass `envFile: false` and
|
|
599
|
+
> explicit `uri`/`dbName` to each — `.env` loading mutates the shared `process.env` (dotenv
|
|
600
|
+
> semantics), so different env files could otherwise leak `MIGRONAUT_*` values between kits.
|
|
601
|
+
|
|
583
602
|
</details>
|
|
584
603
|
|
|
585
604
|
---
|
|
@@ -592,7 +611,9 @@ All errors extend `MigronautError` and carry a typed `code` (`LOCK_ALREADY_HELD`
|
|
|
592
611
|
|
|
593
612
|
A config file is optional and auto-discovered in the working directory as `migronaut.config.ts`,
|
|
594
613
|
`migronaut.config.js`, or `migronaut.config.json`. Run `migronaut init` to generate one — it ships fully commented,
|
|
595
|
-
so every setting lives in one documented place
|
|
614
|
+
so every setting lives in one documented place. A generated `migronaut.config.json` points its
|
|
615
|
+
`$schema` at the hosted copy for editor completion; offline or air-gapped setups can point it at
|
|
616
|
+
the copy every install already ships: `./node_modules/@alexify/migronaut/migronaut.schema.json`.
|
|
596
617
|
|
|
597
618
|
```js
|
|
598
619
|
// migronaut.config.js — generated by `migronaut init`, every option explained
|
|
@@ -671,6 +692,7 @@ optional rather than merely discouraged:
|
|
|
671
692
|
| `MIGRONAUT_TEMPLATE_PATH` | `templatePath` | — *(built-in template)* |
|
|
672
693
|
| `MIGRONAUT_TIMEOUT_MS` | `timeoutMs` | — *(no timeout)* |
|
|
673
694
|
| `MIGRONAUT_ON_LOCK_LOST` | `onLockLost` | `abort` |
|
|
695
|
+
| `MIGRONAUT_ON_OUT_OF_ORDER` | `onOutOfOrder` | `warn` |
|
|
674
696
|
| `MIGRONAUT_ENSURE_INDEXES` | `ensureIndexes` | `true` |
|
|
675
697
|
| `MIGRONAUT_RELOAD_MIGRATIONS` | `reloadMigrations` | `false` |
|
|
676
698
|
| `MIGRONAUT_ENV_FILE` | `envFile` | `.env` |
|
package/bin/migronaut.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { errorText } = require('../src/utils/error.js');
|
|
3
|
+
const { sanitizeTerminal } = require('../src/utils/sanitize.js');
|
|
4
|
+
|
|
5
|
+
// Same hygiene as the default logger: these last-resort handlers are the only
|
|
6
|
+
// paths that write to the TTY directly, and an escaped error message can embed
|
|
7
|
+
// DB-derived text — sanitized by construction, like every other output path.
|
|
8
|
+
const writeError = (error) => {
|
|
9
|
+
process.stderr.write(`✖ ${sanitizeTerminal(errorText(error))}\n`);
|
|
10
|
+
};
|
|
3
11
|
|
|
4
12
|
// `status --json | head` closes the pipe early; the resulting EPIPE arrives as
|
|
5
13
|
// a stream 'error' event, not a promise rejection, and would otherwise crash
|
|
@@ -13,7 +21,7 @@ for (const stream of [process.stdout, process.stderr]) {
|
|
|
13
21
|
// A rejection escaping the CLI's own handling still gets the project's error
|
|
14
22
|
// format instead of Node's default trace.
|
|
15
23
|
process.on('unhandledRejection', (error) => {
|
|
16
|
-
|
|
24
|
+
writeError(error);
|
|
17
25
|
process.exitCode = 1;
|
|
18
26
|
});
|
|
19
27
|
|
|
@@ -22,14 +30,14 @@ process.on('unhandledRejection', (error) => {
|
|
|
22
30
|
// skips errorText's URI redaction. exit(1), not exitCode: after an uncaught
|
|
23
31
|
// throw the process state is not trustworthy enough to keep draining.
|
|
24
32
|
process.on('uncaughtException', (error) => {
|
|
25
|
-
|
|
33
|
+
writeError(error);
|
|
26
34
|
process.exit(1);
|
|
27
35
|
});
|
|
28
36
|
|
|
29
37
|
const { run } = require('../src/cli/index.js');
|
|
30
38
|
|
|
31
39
|
run(process.argv).catch((error) => {
|
|
32
|
-
|
|
40
|
+
writeError(error);
|
|
33
41
|
// exitCode, not exit(): letting the event loop drain flushes buffered
|
|
34
42
|
// stdout/stderr writes that process.exit() would truncate on a pipe.
|
|
35
43
|
process.exitCode = 1;
|
package/index.d.ts
CHANGED
|
@@ -51,14 +51,15 @@ export interface MigrationModule {
|
|
|
51
51
|
|
|
52
52
|
// ─── Changelog ────────────────────────────────────────────────────────────────
|
|
53
53
|
|
|
54
|
-
export type MigrationStatus = 'applied' | 'reverted';
|
|
54
|
+
export type MigrationStatus = 'applied' | 'reverted' | 'failed';
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Where a changelog record originated. `'migrate-mongo'` marks a record adopted
|
|
58
|
-
* via `migronaut import
|
|
59
|
-
* Absent (or `'migronaut'`)
|
|
58
|
+
* via `migronaut import`, `'baseline'` one stamped by `migronaut baseline`; both are
|
|
59
|
+
* forward-only and cannot be reverted by migronaut. Absent (or `'migronaut'`)
|
|
60
|
+
* means a natively-applied, reversible migration.
|
|
60
61
|
*/
|
|
61
|
-
export type MigrationOrigin = 'migronaut' | 'migrate-mongo';
|
|
62
|
+
export type MigrationOrigin = 'migronaut' | 'migrate-mongo' | 'baseline';
|
|
62
63
|
|
|
63
64
|
/** A single record in the _migronaut_migrations changelog collection */
|
|
64
65
|
export interface MigrationRecord {
|
|
@@ -71,6 +72,10 @@ export interface MigrationRecord {
|
|
|
71
72
|
/** When this migration was applied the *first* time; survives a re-apply */
|
|
72
73
|
firstAppliedAt?: Date;
|
|
73
74
|
revertedAt?: Date;
|
|
75
|
+
/** When the last failed attempt was recorded (status `'failed'` only) */
|
|
76
|
+
failedAt?: Date;
|
|
77
|
+
/** Redacted message of the last failed attempt (status `'failed'` only) */
|
|
78
|
+
error?: string;
|
|
74
79
|
/** Execution time in milliseconds */
|
|
75
80
|
duration: number;
|
|
76
81
|
/** SHA-256 hash of the file at time of execution */
|
|
@@ -237,6 +242,19 @@ export interface MigronautConfig {
|
|
|
237
242
|
* - `'warn'` — log and keep going.
|
|
238
243
|
*/
|
|
239
244
|
onLockLost?: 'abort' | 'warn';
|
|
245
|
+
/**
|
|
246
|
+
* What a bulk `up` does when a pending migration sorts before the newest
|
|
247
|
+
* applied one — a file merged late from a parallel branch, which would apply
|
|
248
|
+
* out of authoring order (environments migrated at different times then
|
|
249
|
+
* disagree on the effective order).
|
|
250
|
+
*
|
|
251
|
+
* - `'warn'` (default) — log the late arrivals and apply them.
|
|
252
|
+
* - `'error'` — refuse the run with an {@link OutOfOrderMigrationError}.
|
|
253
|
+
* - `'allow'` — apply silently.
|
|
254
|
+
*
|
|
255
|
+
* A single-file `up` (an explicit, deliberate target) is never checked.
|
|
256
|
+
*/
|
|
257
|
+
onOutOfOrder?: 'warn' | 'error' | 'allow';
|
|
240
258
|
/** Mongoose instance — required only if your migrations use Mongoose models */
|
|
241
259
|
mongoose?: MongooseLike;
|
|
242
260
|
hooks?: MigrationHooks;
|
|
@@ -288,8 +306,8 @@ export type LogMethod = (msg: string, fields?: Record<string, unknown>) => void;
|
|
|
288
306
|
// ─── Progress Reporter ─────────────────────────────────────────────────────────
|
|
289
307
|
|
|
290
308
|
/**
|
|
291
|
-
* Receives migration lifecycle callbacks so a presentation layer (e.g.
|
|
292
|
-
* spinner) can react. Deliberately separate from {@link MigrationHooks}: hooks
|
|
309
|
+
* Receives migration lifecycle callbacks so a presentation layer (e.g. a
|
|
310
|
+
* progress spinner) can react. Deliberately separate from {@link MigrationHooks}: hooks
|
|
293
311
|
* run user DB logic inside the migration; this only drives a UI indicator and
|
|
294
312
|
* never touches the database.
|
|
295
313
|
*/
|
|
@@ -318,13 +336,39 @@ export interface RunResult {
|
|
|
318
336
|
|
|
319
337
|
export interface StatusRow {
|
|
320
338
|
file: string;
|
|
321
|
-
|
|
339
|
+
/**
|
|
340
|
+
* `'failed'` marks a recorded failed attempt — the file still counts as
|
|
341
|
+
* pending for every run path (the next `up` retries it), but the failure is
|
|
342
|
+
* surfaced instead of rendering as a plain pending row. A reverted record
|
|
343
|
+
* reports as `'pending'`, with `revertedAt` carrying its history.
|
|
344
|
+
*/
|
|
345
|
+
status: 'applied' | 'pending' | 'failed';
|
|
322
346
|
batch: number | null;
|
|
323
347
|
appliedAt: Date | null;
|
|
324
348
|
duration: number | null;
|
|
325
349
|
/** null = never applied, true = match, false = mismatch */
|
|
326
350
|
checksumOk: boolean | null;
|
|
327
351
|
description?: string;
|
|
352
|
+
/** Who ran it — from the changelog's audit trail, when recorded */
|
|
353
|
+
executedBy?: string;
|
|
354
|
+
/** Environment stamped at apply time, when recorded */
|
|
355
|
+
environment?: string;
|
|
356
|
+
/** Correlation id of the run that wrote the record, when recorded */
|
|
357
|
+
runId?: string;
|
|
358
|
+
/** When the migration was reverted — present on reverted history rows */
|
|
359
|
+
revertedAt?: Date;
|
|
360
|
+
/** `'migrate-mongo'` / `'baseline'` mark forward-only adopted records */
|
|
361
|
+
origin?: MigrationOrigin;
|
|
362
|
+
/** Redacted message of the last failed attempt (status `'failed'` only) */
|
|
363
|
+
error?: string;
|
|
364
|
+
/** When the last failed attempt was recorded (status `'failed'` only) */
|
|
365
|
+
failedAt?: Date;
|
|
366
|
+
/**
|
|
367
|
+
* Present (true) on a not-yet-applied row that sorts before the newest
|
|
368
|
+
* applied migration — a file merged late from a parallel branch, which will
|
|
369
|
+
* apply out of authoring order. See `MigronautConfig.onOutOfOrder`.
|
|
370
|
+
*/
|
|
371
|
+
outOfOrder?: true;
|
|
328
372
|
/**
|
|
329
373
|
* Present (true) when the changelog record's name is not a plain filename —
|
|
330
374
|
* a legacy or tampered record. The row is reported as-is instead of failing
|
|
@@ -411,7 +455,8 @@ export type MigronautErrorCode =
|
|
|
411
455
|
| 'CONNECTION_FAILED'
|
|
412
456
|
| 'NOT_APPLIED'
|
|
413
457
|
| 'IMPORT_TARGET_NOT_EMPTY'
|
|
414
|
-
| 'MIGRATION_IRREVERSIBLE'
|
|
458
|
+
| 'MIGRATION_IRREVERSIBLE'
|
|
459
|
+
| 'MIGRATION_OUT_OF_ORDER';
|
|
415
460
|
|
|
416
461
|
// ─── Config file format ─────────────────────────────────────────────────────────
|
|
417
462
|
|
|
@@ -486,7 +531,7 @@ export interface MigrationEvent extends MigronautEventBase {
|
|
|
486
531
|
}
|
|
487
532
|
|
|
488
533
|
export interface RunStartEvent extends MigronautEventBase {
|
|
489
|
-
/** Which command started the run: 'up' | 'down' | 'redo' | 'import' */
|
|
534
|
+
/** Which command started the run: 'up' | 'down' | 'redo' | 'import' | 'baseline' */
|
|
490
535
|
command?: string;
|
|
491
536
|
direction?: 'up' | 'down';
|
|
492
537
|
}
|
|
@@ -585,6 +630,24 @@ export interface InitOptions {
|
|
|
585
630
|
secretProvider?: boolean;
|
|
586
631
|
}
|
|
587
632
|
|
|
633
|
+
/** Options for {@link MigratorKit.baseline} */
|
|
634
|
+
export interface BaselineOptions {
|
|
635
|
+
/** Baseline pending files up to and including this one, instead of all */
|
|
636
|
+
to?: string;
|
|
637
|
+
/** Skip lock acquisition (dev only) */
|
|
638
|
+
noLock?: boolean;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Outcome of a {@link MigratorKit.baseline} call */
|
|
642
|
+
export interface BaselineSummary {
|
|
643
|
+
/** Files marked applied by this call, in name order */
|
|
644
|
+
baselined: string[];
|
|
645
|
+
/** Files on disk that were already applied (or beyond `--to`) and untouched */
|
|
646
|
+
skipped: number;
|
|
647
|
+
/** The shared batch number stamped on the baselined records; null when none */
|
|
648
|
+
batch: number | null;
|
|
649
|
+
}
|
|
650
|
+
|
|
588
651
|
/** Options for {@link MigratorKit.import} */
|
|
589
652
|
export interface ImportOptions {
|
|
590
653
|
/** Source collection to read. Default: `changelog` (migrate-mongo's default) */
|
|
@@ -605,9 +668,16 @@ export interface ImportOptions {
|
|
|
605
668
|
export interface MigratorKitOptions {
|
|
606
669
|
/** Explicit config file path — overrides auto-discovery */
|
|
607
670
|
configPath?: string;
|
|
671
|
+
/**
|
|
672
|
+
* Project root this instance resolves against: config-file discovery, the
|
|
673
|
+
* `.env` file and a relative `migrationsDir`. Defaults to `process.cwd()`.
|
|
674
|
+
* Set it when one process hosts kits for several projects, so their
|
|
675
|
+
* relative paths stop sharing one global working directory.
|
|
676
|
+
*/
|
|
677
|
+
cwd?: string;
|
|
608
678
|
/**
|
|
609
679
|
* Optional lifecycle reporter, invoked around each migration's execution so a
|
|
610
|
-
* UI (the CLI's
|
|
680
|
+
* UI (the CLI's spinner) can show progress. Core never imports a spinner
|
|
611
681
|
* library — it only calls these callbacks.
|
|
612
682
|
*/
|
|
613
683
|
progress?: ProgressReporter;
|
|
@@ -698,6 +768,14 @@ export class MigratorKit extends EventEmitter {
|
|
|
698
768
|
* records into our schema and writing them to `migrationsCollection`.
|
|
699
769
|
*/
|
|
700
770
|
import(options?: ImportOptions): Promise<ImportResult>;
|
|
771
|
+
/**
|
|
772
|
+
* Adopt an existing database with no prior migration tool: mark migration
|
|
773
|
+
* files on disk as applied — checksums from disk, one shared batch,
|
|
774
|
+
* `origin: 'baseline'` — without executing anything. Forward-only:
|
|
775
|
+
* `down`/`redo` refuse baselined records. Idempotent: already-applied names
|
|
776
|
+
* are skipped, so a partial baseline can simply be re-run.
|
|
777
|
+
*/
|
|
778
|
+
baseline(options?: BaselineOptions): Promise<BaselineSummary>;
|
|
701
779
|
}
|
|
702
780
|
|
|
703
781
|
// ─── Programmatic entry points ─────────────────────────────────────────────────
|
|
@@ -717,13 +795,23 @@ export interface RunMigrationsOptions extends MigratorKitOptions {
|
|
|
717
795
|
*/
|
|
718
796
|
onLockHeld?: OnLockHeld;
|
|
719
797
|
/**
|
|
720
|
-
* Max time (ms) to wait when `onLockHeld: 'wait'
|
|
721
|
-
*
|
|
722
|
-
*
|
|
798
|
+
* Max time (ms) to wait when `onLockHeld: 'wait'` **without observing holder
|
|
799
|
+
* progress**. While the holder's heartbeat visibly advances its lock, the
|
|
800
|
+
* deadline is re-armed — a healthy peer working through a long backlog never
|
|
801
|
+
* times its waiting peers out; only a stalled holder runs this budget down.
|
|
802
|
+
* Default: 90000.
|
|
723
803
|
*/
|
|
724
804
|
lockWaitTimeoutMs?: number;
|
|
725
805
|
/** Poll interval (ms) while waiting for the lock. Default: 500 */
|
|
726
806
|
lockPollIntervalMs?: number;
|
|
807
|
+
/**
|
|
808
|
+
* Receives the internally-constructed {@link MigratorKit} right after
|
|
809
|
+
* construction (before connect), so an embedding application can subscribe
|
|
810
|
+
* to its lifecycle events — `kit.on('migration:success', …)` for metrics,
|
|
811
|
+
* lock telemetry, runId correlation — while keeping the managed
|
|
812
|
+
* connect/run/disconnect lifecycle.
|
|
813
|
+
*/
|
|
814
|
+
onKit?: (kit: MigratorKit) => void;
|
|
727
815
|
}
|
|
728
816
|
|
|
729
817
|
/** Outcome of a {@link runMigrations} call */
|
|
@@ -771,6 +859,21 @@ export const EXIT_CODES: Readonly<
|
|
|
771
859
|
Record<MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED', number>
|
|
772
860
|
>;
|
|
773
861
|
|
|
862
|
+
// ─── Logger factory ───────────────────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
/** Threshold accepted by {@link createLogger} — drops anything less severe */
|
|
865
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Create the default console logger (pino-compatible surface, terminal-escape
|
|
869
|
+
* sanitization and colors included). `debug`/`info` write to `stream`
|
|
870
|
+
* (stdout by default); `warn`/`error` always write to stderr. For programmatic
|
|
871
|
+
* callers who want migronaut's own output at a chosen verbosity — e.g.
|
|
872
|
+
* `logger: createLogger(process.stdout, 'debug')` — without hand-writing a
|
|
873
|
+
* four-method logger.
|
|
874
|
+
*/
|
|
875
|
+
export function createLogger(stream?: NodeJS.WritableStream, level?: LogLevel): MigronautLogger;
|
|
876
|
+
|
|
774
877
|
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
775
878
|
|
|
776
879
|
/** Construction options shared by every migronaut error — `cause` keeps the wrapped Error */
|
|
@@ -897,7 +1000,16 @@ export class ImportTargetNotEmptyError extends MigronautError {
|
|
|
897
1000
|
constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
|
|
898
1001
|
}
|
|
899
1002
|
|
|
900
|
-
/** Thrown when attempting to roll back a
|
|
1003
|
+
/** Thrown when attempting to roll back a forward-only (imported or baselined) migration */
|
|
901
1004
|
export class IrreversibleMigrationError extends MigronautError {
|
|
902
1005
|
constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
|
|
903
1006
|
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Thrown by a bulk `up` under `onOutOfOrder: 'error'` when a pending migration
|
|
1010
|
+
* sorts before the newest applied one — a file merged late from a parallel
|
|
1011
|
+
* branch. `context.names` lists the late arrivals.
|
|
1012
|
+
*/
|
|
1013
|
+
export class OutOfOrderMigrationError extends MigronautError {
|
|
1014
|
+
constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
|
|
1015
|
+
}
|
package/migronaut.schema.json
CHANGED
|
@@ -99,6 +99,15 @@
|
|
|
99
99
|
"default": "abort",
|
|
100
100
|
"description": "What to do when the lock is lost mid-run"
|
|
101
101
|
},
|
|
102
|
+
"onOutOfOrder": {
|
|
103
|
+
"enum": [
|
|
104
|
+
"warn",
|
|
105
|
+
"error",
|
|
106
|
+
"allow"
|
|
107
|
+
],
|
|
108
|
+
"default": "warn",
|
|
109
|
+
"description": "What a bulk `up` does when a pending migration sorts before the newest applied one (a file merged late from a parallel branch)"
|
|
110
|
+
},
|
|
102
111
|
"envFile": {
|
|
103
112
|
"oneOf": [
|
|
104
113
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexify/migronaut",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js — adopts an existing migrate-mongo changelog in one command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alex Dolid <dolid.sasha@gmail.com>",
|
|
7
7
|
"repository": {
|
|
@@ -51,13 +51,16 @@
|
|
|
51
51
|
"migrations",
|
|
52
52
|
"mongodb-migration",
|
|
53
53
|
"mongodb-migrations",
|
|
54
|
+
"mongodb-migrate",
|
|
54
55
|
"database-migration",
|
|
55
56
|
"schema-migration",
|
|
56
57
|
"migrate-mongo",
|
|
58
|
+
"migrate-mongo-alternative",
|
|
57
59
|
"mongoose",
|
|
58
60
|
"mongoose-migration",
|
|
59
61
|
"rollback",
|
|
60
62
|
"transactions",
|
|
63
|
+
"zero-dependency",
|
|
61
64
|
"cli",
|
|
62
65
|
"typescript",
|
|
63
66
|
"nosql"
|
|
@@ -73,6 +76,8 @@
|
|
|
73
76
|
},
|
|
74
77
|
"devDependencies": {
|
|
75
78
|
"@types/node": "^22.19.19",
|
|
79
|
+
"@vercel/analytics": "^2.0.1",
|
|
80
|
+
"@vercel/speed-insights": "^2.0.0",
|
|
76
81
|
"c8": "^10.1.3",
|
|
77
82
|
"esbuild": "^0.28.1",
|
|
78
83
|
"mongodb": "^6.12.0",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { ConfigInvalidError } = require('../../errors/index.js');
|
|
2
|
+
const { confirm, defineCommand } = require('../shared.js');
|
|
3
|
+
|
|
4
|
+
/** Register the `baseline` command (adopt an existing database, no prior tool) */
|
|
5
|
+
function registerBaseline(program) {
|
|
6
|
+
defineCommand(program, {
|
|
7
|
+
name: 'baseline',
|
|
8
|
+
description: 'Mark existing migration files as applied without running them',
|
|
9
|
+
options: [
|
|
10
|
+
['--to <file>', 'Baseline pending files up to and including this one'],
|
|
11
|
+
['-y, --yes', 'Skip the confirmation prompt (required with --json)'],
|
|
12
|
+
],
|
|
13
|
+
lockable: true,
|
|
14
|
+
mutating: true,
|
|
15
|
+
// Confirmation before any connection: baselining rewrites what the
|
|
16
|
+
// changelog claims is applied, which is exactly as consequential as a
|
|
17
|
+
// forced re-run — and --json is non-interactive, so it needs an explicit
|
|
18
|
+
// --yes rather than a prompt that can never be answered.
|
|
19
|
+
preflight: async (opts, _positionals, { logger }) => {
|
|
20
|
+
if (!opts.yes) {
|
|
21
|
+
if (opts.json) {
|
|
22
|
+
throw new ConfigInvalidError(
|
|
23
|
+
'baseline needs confirmation — pass --yes to confirm in --json mode',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const proceed = await confirm(
|
|
27
|
+
'Mark pending migration files as applied WITHOUT running them? [y/N] ',
|
|
28
|
+
);
|
|
29
|
+
if (!proceed) {
|
|
30
|
+
logger.info('Aborted');
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
},
|
|
36
|
+
run: (migrator, opts) =>
|
|
37
|
+
migrator.baseline({
|
|
38
|
+
noLock: opts.noLock,
|
|
39
|
+
...(opts.to ? { to: opts.to } : {}),
|
|
40
|
+
}),
|
|
41
|
+
// No render: core logs the ✔ Baselined summary itself.
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { registerBaseline };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { ConfigInvalidError } = require('../../errors/index.js');
|
|
1
2
|
const { confirm, defineCommand, emitJson } = require('../shared.js');
|
|
2
3
|
const { lockedAtText } = require('./lock.js');
|
|
3
4
|
|
|
@@ -19,8 +20,17 @@ function registerUnlock(program) {
|
|
|
19
20
|
return undefined;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
// Confirm before clearing
|
|
23
|
-
|
|
23
|
+
// Confirm before clearing. --json is non-interactive, so it requires an
|
|
24
|
+
// explicit --yes instead of silently proceeding — force-releasing a live
|
|
25
|
+
// run's lock enables exactly the concurrent-migration scenario the lock
|
|
26
|
+
// exists to prevent, and the CLI's one destructive-confirmation policy
|
|
27
|
+
// (see `up --force`) is: non-interactive modes never assume consent.
|
|
28
|
+
if (!opts.yes) {
|
|
29
|
+
if (json) {
|
|
30
|
+
throw new ConfigInvalidError(
|
|
31
|
+
'unlock needs confirmation — pass --yes to confirm in --json mode',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
24
34
|
const since = lockedAtText(holder.lockedAt);
|
|
25
35
|
logger.warn(
|
|
26
36
|
`⚠ Lock held by pid ${holder.pid} on ${holder.host} (${holder.executedBy}) since ${since}`,
|