@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 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
- ## v1.0.0
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
- | SHA-256 checksum / tamper detection | ❌ | ✅ |
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. It has since added transaction access
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`, `create`,
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
- process.stderr.write(`✖ ${errorText(error)}\n`);
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
- process.stderr.write(`✖ ${errorText(error)}\n`);
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
- process.stderr.write(`✖ ${errorText(error)}\n`);
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`; such records are forward-only and cannot be reverted by migronaut.
59
- * Absent (or `'migronaut'`) means a natively-applied, reversible migration.
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. an ora
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
- status: 'applied' | 'pending';
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 ora spinner) can show progress. Core never imports a spinner
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'`. Default: 90000 — sized to
721
- * outlast a peer's typical run plus one lock TTL, so parallel deploys don't
722
- * give up while a healthy peer is still migrating.
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 migrate-mongo-imported (forward-only) migration */
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
+ }
@@ -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": "1.0.0",
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 — unless --yes, or --json (non-interactive).
23
- if (!json && !opts.yes) {
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}`,
@@ -33,6 +33,7 @@ const EXIT_CODES = {
33
33
  MIGRATION_INVALID_EXPORT: 20,
34
34
  LOCK_RELEASE_FAILED: 21,
35
35
  AUDIT_FAILED: 22,
36
+ MIGRATION_OUT_OF_ORDER: 23,
36
37
  };
37
38
 
38
39
  module.exports = { EXIT_CODES };