@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.d.ts ADDED
@@ -0,0 +1,903 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { ClientSession, Db, MongoClient, MongoClientOptions } from 'mongodb';
3
+
4
+ // ─── Migration File Contract ───────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Structural stand-in for a Mongoose instance.
8
+ *
9
+ * Deliberately not `import type { Mongoose } from 'mongoose'`: mongoose is an
10
+ * *optional* peer, and a hard import makes this declaration file fail to
11
+ * resolve for the majority of users who never install it. A real `Mongoose` is
12
+ * assignable to this.
13
+ */
14
+ export interface MongooseLike {
15
+ connection: unknown;
16
+ model: (...args: never[]) => unknown;
17
+ }
18
+
19
+ /** Context object passed into every migration's up() and down() function */
20
+ export interface MigrationContext {
21
+ /** Native MongoDB Db instance */
22
+ db: Db;
23
+ /** Native MongoClient instance — use for sessions/transactions */
24
+ client: MongoClient;
25
+ /** Mongoose instance — only present if passed in config */
26
+ mongoose?: MongooseLike;
27
+ /**
28
+ * Active session, present only when this migration runs inside a transaction.
29
+ * Pass it to your operations (e.g. `{ session }`) so they join the transaction.
30
+ */
31
+ session?: ClientSession;
32
+ /**
33
+ * Aborted when the run is stopping — the lock was lost, `stop()` was called,
34
+ * or a SIGINT/SIGTERM arrived. A long migration can watch this to exit early;
35
+ * migronaut cannot interrupt a running function by itself.
36
+ */
37
+ signal?: AbortSignal;
38
+ }
39
+
40
+ /** Shape of an imported migration file module */
41
+ export interface MigrationModule {
42
+ up: (ctx: MigrationContext) => Promise<void>;
43
+ down: (ctx: MigrationContext) => Promise<void>;
44
+ /** If true, wraps this migration in a MongoDB session + transaction */
45
+ useTransaction?: boolean;
46
+ /** Overrides `MigronautConfig.timeoutMs` for this migration only */
47
+ timeoutMs?: number;
48
+ /** Optional description shown in status table */
49
+ description?: string;
50
+ }
51
+
52
+ // ─── Changelog ────────────────────────────────────────────────────────────────
53
+
54
+ export type MigrationStatus = 'applied' | 'reverted';
55
+
56
+ /**
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.
60
+ */
61
+ export type MigrationOrigin = 'migronaut' | 'migrate-mongo';
62
+
63
+ /** A single record in the _migronaut_migrations changelog collection */
64
+ export interface MigrationRecord {
65
+ /** Migration filename e.g. 20240526143021-add-users-index.ts */
66
+ name: string;
67
+ /** Sequential batch number. All migrations run together share the same batch */
68
+ batch: number;
69
+ status: MigrationStatus;
70
+ appliedAt: Date;
71
+ /** When this migration was applied the *first* time; survives a re-apply */
72
+ firstAppliedAt?: Date;
73
+ revertedAt?: Date;
74
+ /** Execution time in milliseconds */
75
+ duration: number;
76
+ /** SHA-256 hash of the file at time of execution */
77
+ checksum: string;
78
+ /** Resolved environment at time of execution (config → NODE_ENV → 'production') */
79
+ environment: string;
80
+ /** Correlation id of the run that wrote this record; matches the lock's owner token */
81
+ runId?: string;
82
+ /** Who ran it: `MIGRONAUT_USER` if set, otherwise `os.userInfo().username` */
83
+ executedBy: string;
84
+ /** Optional description from migration file */
85
+ description?: string;
86
+ /**
87
+ * Origin of this record. Set to `'migrate-mongo'` for records adopted via
88
+ * `migronaut import` — these are not reversible by migronaut. Absent for native records.
89
+ */
90
+ origin?: MigrationOrigin;
91
+ }
92
+
93
+ // ─── Config ───────────────────────────────────────────────────────────────────
94
+
95
+ /** Which migration in the run a per-migration hook is firing for */
96
+ export interface HookInfo {
97
+ direction: 'up' | 'down';
98
+ /** Zero-based position within this run's targets */
99
+ index: number;
100
+ total: number;
101
+ }
102
+
103
+ /** Outcome of the run, passed to `afterAll` */
104
+ export interface RunSummary {
105
+ /** False when the run ended by throwing — `afterAll` runs either way */
106
+ success: boolean;
107
+ /** How many migrations were applied (or reverted) before the run ended */
108
+ applied: number;
109
+ direction: 'up' | 'down';
110
+ }
111
+
112
+ /**
113
+ * Lifecycle hooks. A hook that throws fails the run with a
114
+ * {@link HookFailedError} — it is never swallowed, and never surfaces as an
115
+ * untyped Error.
116
+ */
117
+ export interface MigrationHooks {
118
+ /** Runs once before any migration in the batch starts */
119
+ beforeAll?: (ctx: MigrationContext) => Promise<void>;
120
+ /**
121
+ * Runs once after the batch ends — including when it failed, so cleanup and
122
+ * notification hooks still fire on the path where they matter most.
123
+ */
124
+ afterAll?: (ctx: MigrationContext, summary: RunSummary) => Promise<void>;
125
+ /** Runs before each individual migration. Not fired for skipped migrations */
126
+ beforeEach?: (name: string, ctx: MigrationContext, info: HookInfo) => Promise<void>;
127
+ /** Runs after each individual migration completes successfully */
128
+ afterEach?: (
129
+ name: string,
130
+ duration: number,
131
+ ctx: MigrationContext,
132
+ info: HookInfo,
133
+ ) => Promise<void>;
134
+ /** Runs when a migration throws — receives the error before it propagates */
135
+ onError?: (name: string, error: Error, ctx: MigrationContext) => Promise<void>;
136
+ }
137
+
138
+ /** File type a created migration is written as */
139
+ export type MigrationExtension = 'ts' | 'js';
140
+
141
+ /**
142
+ * Every **scalar** option below is also settable from the environment as
143
+ * `MIGRONAUT_<SCREAMING_SNAKE>` (`migrationsDir` → `MIGRONAUT_MIGRATIONS_DIR`,
144
+ * with `dbName` → `MIGRONAUT_DB`, `migrationsCollection` → `MIGRONAUT_COLLECTION`
145
+ * and `lockTTLSeconds` → `MIGRONAUT_LOCK_TTL` as the shortened exceptions), which
146
+ * is what makes a config file genuinely optional. Env vars outrank the config
147
+ * file and are outranked by CLI flags. A value that does not parse is rejected
148
+ * with a {@link ConfigInvalidError} naming the variable — never coerced.
149
+ *
150
+ * `fileExtensions`, `clientOptions` and the live handles (`client`, `mongoose`,
151
+ * `hooks`, `logger`) are config-file/API only: a single environment string
152
+ * cannot express them.
153
+ */
154
+ export interface MigronautConfig {
155
+ /** MongoDB connection URI. Not required when `client` is supplied */
156
+ uri: string;
157
+ /**
158
+ * Driver options passed to `new MongoClient(uri, options)` — the escape hatch
159
+ * for everything a connection string cannot express: TLS certificates,
160
+ * AWS IAM / X.509 authentication, proxies, pool sizing, read preferences.
161
+ */
162
+ clientOptions?: MongoClientOptions;
163
+ /**
164
+ * An already-connected client to reuse instead of opening one. Ownership
165
+ * stays with the caller: `disconnect()` leaves it open, so migrations at
166
+ * application startup can share the app's own pool.
167
+ */
168
+ client?: MongoClient;
169
+ /** Database name */
170
+ dbName: string;
171
+ /** Path to migrations directory. Default: './migrations' */
172
+ migrationsDir: string;
173
+ /** Collection name for migration records. Default: '_migronaut_migrations' */
174
+ migrationsCollection: string;
175
+ /** Collection name for distributed lock. Default: '_migronaut_locks' */
176
+ lockCollection: string;
177
+ /** How long (seconds) a lock is considered stale. Default: 60 */
178
+ lockTTLSeconds: number;
179
+ /**
180
+ * If true, abort when a migration file's checksum differs from what was applied.
181
+ * If false, warn but continue. Default: false
182
+ */
183
+ strict: boolean;
184
+ /** Wrap all migrations in transactions globally. Can be overridden per file. Default: false */
185
+ useTransaction: boolean;
186
+ /**
187
+ * Create the changelog indexes on first connect. Default: true. Set false
188
+ * when the application user has no index-creation rights and the indexes are
189
+ * provisioned out of band.
190
+ */
191
+ ensureIndexes?: boolean;
192
+ /** File extensions to scan. Default: ['.ts', '.js'] */
193
+ fileExtensions: string[];
194
+ /**
195
+ * File type `migronaut create` generates by default. Overridden per run by the
196
+ * `--js` / `--ts` flags. Default: 'js'
197
+ */
198
+ createExtension: MigrationExtension;
199
+ /** Use sequential numbering (0001-) instead of timestamps. Default: false */
200
+ sequential: boolean;
201
+ /** Path to a custom migration template file */
202
+ templatePath?: string;
203
+ /**
204
+ * Abort the run when a single migration exceeds this many milliseconds.
205
+ * Best-effort: the migration's own work cannot be cancelled, but the run
206
+ * stops instead of hanging, which also lets the lock's TTL expire so other
207
+ * instances are not blocked forever. A migration can watch `ctx.signal` to
208
+ * bail out itself. Override per file with `export const timeoutMs`.
209
+ */
210
+ timeoutMs?: number;
211
+ /**
212
+ * Bypass Node's ESM module cache when loading migration files. Only useful in
213
+ * a long-lived process that runs migrations more than once (a test runner, a
214
+ * dev server) — each load then leaks a module, which a one-shot CLI never
215
+ * needs to care about. Default: false
216
+ */
217
+ reloadMigrations?: boolean;
218
+ /**
219
+ * `.env` file loaded before the config is resolved (it is what supplies the
220
+ * `MIGRONAUT_*` variables). Relative to the working directory. Default:
221
+ * `'.env'`; set `false` to load nothing, so a stray `.env` cannot silently
222
+ * outrank a committed config.
223
+ */
224
+ envFile?: string | false;
225
+ /**
226
+ * Value stamped onto the `environment` field of changelog records. Falls back
227
+ * to `process.env.NODE_ENV`, then to `'production'` — the safe assumption when
228
+ * nothing says otherwise.
229
+ */
230
+ environment?: string;
231
+ /**
232
+ * What to do when the lock is lost mid-run (another process reclaimed it, or
233
+ * the heartbeat cannot reach the database).
234
+ *
235
+ * - `'abort'` (default) — stop after the migration in flight and throw a
236
+ * {@link LockLostError}, rather than risk two processes migrating at once.
237
+ * - `'warn'` — log and keep going.
238
+ */
239
+ onLockLost?: 'abort' | 'warn';
240
+ /** Mongoose instance — required only if your migrations use Mongoose models */
241
+ mongoose?: MongooseLike;
242
+ hooks?: MigrationHooks;
243
+ /** Custom logger — set to null to silence all output (useful in tests) */
244
+ logger?: MigronautLogger | null;
245
+ }
246
+
247
+ /**
248
+ * What a config file (`migronaut.config.{ts,js}`) may export: either a config object
249
+ * or a (sync or async) factory that returns one. The factory form is resolved
250
+ * at load time, so you can fetch values — e.g. a connection `uri` from AWS
251
+ * Secrets Manager or Google Secret Manager — without ever writing them to disk.
252
+ *
253
+ * The fetched value lives in memory for that command only; the config file
254
+ * itself is never rewritten. JSON config files cannot use the factory form.
255
+ */
256
+ export type MigronautConfigInput =
257
+ | Partial<MigronautConfig>
258
+ | (() => Partial<MigronautConfig> | Promise<Partial<MigronautConfig>>);
259
+
260
+ // ─── Logger ───────────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Pino-compatible logger surface: any object with these four methods works,
264
+ * including a real pino instance (its optional `child` is used to bind a
265
+ * `component` field when present).
266
+ */
267
+ export interface MigronautLogger {
268
+ debug: LogMethod;
269
+ info: LogMethod;
270
+ warn: LogMethod;
271
+ error: LogMethod;
272
+ /**
273
+ * Present on pino-style loggers. When it is, migronaut binds a `component`
274
+ * field once *and* passes structured fields as the first argument, matching
275
+ * pino's own `(obj, msg)` signature.
276
+ */
277
+ child?: (bindings: Record<string, unknown>) => MigronautLogger;
278
+ }
279
+
280
+ /**
281
+ * A log sink. The optional second argument carries structured fields —
282
+ * `{ runId, migration, direction, batch, durationMs }` — so a machine-readable
283
+ * logger does not have to parse the human string. A plain `(msg) => …` logger
284
+ * remains valid: the extra argument is simply ignored.
285
+ */
286
+ export type LogMethod = (msg: string, fields?: Record<string, unknown>) => void;
287
+
288
+ // ─── Progress Reporter ─────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Receives migration lifecycle callbacks so a presentation layer (e.g. an ora
292
+ * spinner) can react. Deliberately separate from {@link MigrationHooks}: hooks
293
+ * run user DB logic inside the migration; this only drives a UI indicator and
294
+ * never touches the database.
295
+ */
296
+ export interface ProgressReporter {
297
+ /** A migration's up()/down() is about to execute */
298
+ onStart: (name: string, direction: 'up' | 'down') => void;
299
+ /**
300
+ * The in-flight migration finished — stop any indicator. `outcome` says
301
+ * whether it succeeded, for reporters that render more than a spinner.
302
+ */
303
+ onStop: (outcome?: 'success' | 'error') => void;
304
+ }
305
+
306
+ // ─── Results ──────────────────────────────────────────────────────────────────
307
+
308
+ export type RunResultStatus = 'applied' | 'reverted' | 'skipped' | 'error';
309
+
310
+ export interface RunResult {
311
+ file: string;
312
+ status: RunResultStatus;
313
+ duration?: number;
314
+ batch?: number;
315
+ reason?: string;
316
+ error?: string;
317
+ }
318
+
319
+ export interface StatusRow {
320
+ file: string;
321
+ status: 'applied' | 'pending';
322
+ batch: number | null;
323
+ appliedAt: Date | null;
324
+ duration: number | null;
325
+ /** null = never applied, true = match, false = mismatch */
326
+ checksumOk: boolean | null;
327
+ description?: string;
328
+ /**
329
+ * Present (true) when the changelog record's name is not a plain filename —
330
+ * a legacy or tampered record. The row is reported as-is instead of failing
331
+ * the whole status/audit call.
332
+ */
333
+ invalid?: true;
334
+ }
335
+
336
+ // ─── Import (migrate-mongo adoption) ────────────────────────────────────────────
337
+
338
+ /**
339
+ * The shape of a record in a migrate-mongo `changelog` collection. Only
340
+ * `fileName` and `appliedAt` are guaranteed; `fileHash` exists only when
341
+ * migrate-mongo ran with `useFileHash`, and `migrationBlock` only on newer
342
+ * versions.
343
+ */
344
+ export interface MigrateMongoDoc {
345
+ fileName: string;
346
+ appliedAt: Date;
347
+ fileHash?: string;
348
+ migrationBlock?: number;
349
+ }
350
+
351
+ /** How an imported record's checksum was resolved */
352
+ export type ImportChecksumSource = 'reused' | 'recomputed' | 'missing';
353
+
354
+ /** One mapped row produced by `migronaut import` */
355
+ export interface ImportRow {
356
+ file: string;
357
+ batch: number;
358
+ appliedAt: Date;
359
+ checksum: string;
360
+ checksumSource: ImportChecksumSource;
361
+ }
362
+
363
+ /** Outcome of an `migronaut import` run */
364
+ export interface ImportResult {
365
+ /** Source collection that was read (e.g. `changelog`) */
366
+ source: string;
367
+ /** Target collection records were written to (e.g. `_migronaut_migrations`) */
368
+ target: string;
369
+ /** Number of records written (0 when `dryRun` is true) */
370
+ imported: number;
371
+ /** Number of source docs skipped as invalid (missing `fileName`) */
372
+ skipped: number;
373
+ /** True when the run previewed only and wrote nothing */
374
+ dryRun: boolean;
375
+ /** The mapped rows, in apply order */
376
+ rows: ImportRow[];
377
+ }
378
+
379
+ // ─── Lock ─────────────────────────────────────────────────────────────────────
380
+
381
+ /** Public view of the current holder of the migration lock */
382
+ export interface LockInfo {
383
+ /** When the lock was acquired (or last renewed) */
384
+ lockedAt: Date;
385
+ /** OS process id of the holder */
386
+ pid: number;
387
+ /** Hostname of the holder */
388
+ host: string;
389
+ /** Username of the holder */
390
+ executedBy: string;
391
+ }
392
+
393
+ // ─── Error Codes ──────────────────────────────────────────────────────────────
394
+
395
+ export type MigronautErrorCode =
396
+ | 'LOCK_ALREADY_HELD'
397
+ | 'LOCK_RELEASE_FAILED'
398
+ | 'LOCK_LOST'
399
+ | 'RUN_ABORTED'
400
+ | 'HOOK_FAILED'
401
+ | 'CHECKSUM_MISMATCH'
402
+ | 'MIGRATION_FILE_NOT_FOUND'
403
+ | 'MIGRATION_FILE_EXISTS'
404
+ | 'MIGRATION_INVALID_NAME'
405
+ | 'MIGRATION_INVALID_EXPORT'
406
+ | 'MIGRATION_EXECUTION_FAILED'
407
+ | 'MIGRATION_TIMEOUT'
408
+ | 'TRANSACTIONS_UNSUPPORTED'
409
+ | 'CONFIG_INVALID'
410
+ | 'CONFIG_FILE_EXISTS'
411
+ | 'CONNECTION_FAILED'
412
+ | 'NOT_APPLIED'
413
+ | 'IMPORT_TARGET_NOT_EMPTY'
414
+ | 'MIGRATION_IRREVERSIBLE';
415
+
416
+ // ─── Config file format ─────────────────────────────────────────────────────────
417
+
418
+ /** File format for a generated config file */
419
+ export type ConfigFormat = 'ts' | 'js' | 'json';
420
+
421
+ // ─── MigratorKit ────────────────────────────────────────────────────────────────
422
+
423
+ /** Options for {@link MigratorKit.up} */
424
+ export interface UpOptions {
425
+ /** Skip lock acquisition (dev only) */
426
+ noLock?: boolean;
427
+ /**
428
+ * Re-run a migration even if it is already applied. Only meaningful together
429
+ * with a specific filename — a standalone `up` only ever targets pending
430
+ * files, so `force` has no applied target to re-run.
431
+ */
432
+ force?: boolean;
433
+ /**
434
+ * Apply each migration in this run as its own batch (sequential, one per file)
435
+ * instead of grouping the whole run into a single shared batch. This lets a
436
+ * later `down` peel migrations off one at a time. Mirrors Laravel's
437
+ * `migrate --step`.
438
+ */
439
+ step?: boolean;
440
+ /**
441
+ * Apply pending migrations up to and including this file, then stop. Useful
442
+ * for staged rollouts and for reproducing a database at a known point.
443
+ * Mutually exclusive with a filename and `steps`.
444
+ */
445
+ to?: string;
446
+ }
447
+
448
+ /** Options for {@link MigratorKit.down} */
449
+ export interface DownOptions {
450
+ /** Skip lock acquisition (dev only) */
451
+ noLock?: boolean;
452
+ /** Revert a specific batch number instead of the last batch */
453
+ batch?: number;
454
+ /**
455
+ * Revert the last N applied migrations (counted as individual files, newest
456
+ * first), regardless of how they were grouped into batches. Mirrors Laravel's
457
+ * `migrate:rollback --step=N`. Mutually exclusive with `batch` and a filename.
458
+ */
459
+ steps?: number;
460
+ /**
461
+ * Revert everything applied *after* this migration; the named one stays
462
+ * applied. Exclusive, so `up --to X` then `down --to X` is a round trip back
463
+ * to the same state. Mutually exclusive with `batch`, `steps` and a filename.
464
+ */
465
+ to?: string;
466
+ }
467
+
468
+ /** Payload common to every lifecycle event */
469
+ export interface MigronautEventBase {
470
+ /** Correlation id of the run, matching the lock owner and changelog records */
471
+ runId?: string;
472
+ }
473
+
474
+ export interface MigrationEvent extends MigronautEventBase {
475
+ migration: string;
476
+ direction: 'up' | 'down';
477
+ batch?: number;
478
+ durationMs?: number;
479
+ /**
480
+ * Human-readable failure message (on `migration:error` only), with URI
481
+ * credentials already redacted — safe to ship to metrics/alerting as-is.
482
+ */
483
+ error?: string;
484
+ /** Why the migration was skipped (on `migration:skipped` only) */
485
+ reason?: string;
486
+ }
487
+
488
+ export interface RunStartEvent extends MigronautEventBase {
489
+ /** Which command started the run: 'up' | 'down' | 'redo' | 'import' */
490
+ command?: string;
491
+ direction?: 'up' | 'down';
492
+ }
493
+
494
+ export interface RunEndEvent extends MigronautEventBase {
495
+ success: boolean;
496
+ /** Same identification as {@link RunStartEvent} */
497
+ command?: string;
498
+ direction?: 'up' | 'down';
499
+ /** Wall-clock duration of the whole run, lock wait included */
500
+ durationMs?: number;
501
+ /**
502
+ * Result counts — present when the run produced a result list, including
503
+ * the failure path (counted from the partial results the error carries)
504
+ */
505
+ applied?: number;
506
+ reverted?: number;
507
+ total?: number;
508
+ /** Redacted failure message — URI credentials are already masked */
509
+ error?: string;
510
+ }
511
+
512
+ export interface LockEvent extends MigronautEventBase {
513
+ owner?: string;
514
+ reason?: string;
515
+ /** True when acquisition was skipped via `noLock` — no real lock existed */
516
+ skipped?: boolean;
517
+ /** Lock TTL in ms (on `lock:acquired`) */
518
+ ttlMs?: number;
519
+ /** How long acquisition took in ms (on `lock:acquired`) */
520
+ acquireMs?: number;
521
+ }
522
+
523
+ /**
524
+ * Lifecycle events emitted by {@link MigratorKit}. Subscribe to feed metrics or
525
+ * alerting without parsing log lines; a listener that throws is contained and
526
+ * never fails the run.
527
+ */
528
+ export interface MigronautEvents {
529
+ 'run:start': (event: RunStartEvent) => void;
530
+ 'run:end': (event: RunEndEvent) => void;
531
+ 'migration:start': (event: MigrationEvent) => void;
532
+ 'migration:success': (event: MigrationEvent) => void;
533
+ 'migration:skipped': (event: MigrationEvent) => void;
534
+ 'migration:error': (event: MigrationEvent) => void;
535
+ 'lock:acquired': (event: LockEvent) => void;
536
+ 'lock:released': (event: LockEvent) => void;
537
+ 'lock:lost': (event: LockEvent) => void;
538
+ }
539
+
540
+ /** One check performed by {@link MigratorKit.audit} */
541
+ export interface AuditCheck {
542
+ /** e.g. 'config', 'connection', 'transactions', 'indexes', 'lock', 'checksums' */
543
+ name: string;
544
+ status: 'pass' | 'warn' | 'fail';
545
+ detail: string;
546
+ }
547
+
548
+ /** Result of {@link MigratorKit.audit} — read-only; nothing is changed */
549
+ export interface AuditReport {
550
+ /** True when no check failed. Warnings do not clear this flag */
551
+ ok: boolean;
552
+ failed: number;
553
+ warnings: number;
554
+ checks: AuditCheck[];
555
+ }
556
+
557
+ /** Options for {@link MigratorKit.redo} */
558
+ export interface RedoOptions {
559
+ /** Skip lock acquisition (dev only) */
560
+ noLock?: boolean;
561
+ }
562
+
563
+ /** Options for {@link MigratorKit.create} */
564
+ export interface CreateOptions {
565
+ /** Path to a custom template file */
566
+ template?: string;
567
+ /**
568
+ * Force a `.js` (`true`) or `.ts` (`false`) file, overriding the config's
569
+ * `createExtension`. Leave unset to let the config decide (default: `'js'`).
570
+ */
571
+ js?: boolean;
572
+ }
573
+
574
+ /** Options for {@link MigratorKit.init} */
575
+ export interface InitOptions {
576
+ /** Config file format. Default: 'js' */
577
+ format?: ConfigFormat;
578
+ /** Overwrite an existing config file */
579
+ force?: boolean;
580
+ /**
581
+ * Generate a runtime secret-loading config (an async factory that fetches the
582
+ * connection from a secret manager) instead of a static object. Only valid
583
+ * for `js`/`ts` formats.
584
+ */
585
+ secretProvider?: boolean;
586
+ }
587
+
588
+ /** Options for {@link MigratorKit.import} */
589
+ export interface ImportOptions {
590
+ /** Source collection to read. Default: `changelog` (migrate-mongo's default) */
591
+ from?: string;
592
+ /** Target collection to write. Default: the config's `migrationsCollection` */
593
+ to?: string;
594
+ /** Preview the mapping without writing anything */
595
+ dryRun?: boolean;
596
+ /** Reuse the source `fileHash` verbatim instead of recomputing from disk */
597
+ trustHash?: boolean;
598
+ /** Proceed even when the target changelog already has records */
599
+ force?: boolean;
600
+ /** Skip lock acquisition (dev only) */
601
+ noLock?: boolean;
602
+ }
603
+
604
+ /** Additional construction options for {@link MigratorKit} */
605
+ export interface MigratorKitOptions {
606
+ /** Explicit config file path — overrides auto-discovery */
607
+ configPath?: string;
608
+ /**
609
+ * 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
611
+ * library — it only calls these callbacks.
612
+ */
613
+ progress?: ProgressReporter;
614
+ /**
615
+ * Logger used only when the resolved config supplies no `logger` of its own.
616
+ * This is how the CLI injects its console logger without clobbering a
617
+ * `logger` (pino, or `null` for silence) declared in the user's config file.
618
+ * Programmatic callers usually set `config.logger` instead.
619
+ */
620
+ fallbackLogger?: MigronautLogger | null;
621
+ }
622
+
623
+ /**
624
+ * The main orchestration class. Every CLI command delegates here. Holds a
625
+ * partial config that is resolved (merged with env/file/defaults) on first use.
626
+ *
627
+ * Extends Node's EventEmitter — see {@link MigronautEvents} for the lifecycle
628
+ * events you can subscribe to.
629
+ */
630
+ export class MigratorKit extends EventEmitter {
631
+ constructor(config?: Partial<MigronautConfig>, options?: MigratorKitOptions);
632
+
633
+ /** Connect to MongoDB and ensure changelog indexes exist */
634
+ connect(): Promise<void>;
635
+ /**
636
+ * The resolved logger — silent when config sets `logger: null`. Meaningful
637
+ * after `connect()` (before it, a config file's logger is not loaded yet).
638
+ */
639
+ readonly logger: MigronautLogger;
640
+ /** Disconnect from MongoDB */
641
+ disconnect(): Promise<void>;
642
+ /**
643
+ * Inspect the current migration lock without modifying it. Returns the holder,
644
+ * or null when no lock is held.
645
+ */
646
+ lockInfo(): Promise<LockInfo | null>;
647
+ /**
648
+ * Force-release the migration lock regardless of who holds it — for clearing a
649
+ * lock left behind by a crashed run (`migronaut unlock`). Returns the holder that was
650
+ * removed, or null if no lock was held.
651
+ */
652
+ forceUnlock(): Promise<LockInfo | null>;
653
+ /** Run all pending migrations, or a specific named file */
654
+ up(filename?: string, options?: UpOptions): Promise<RunResult[]>;
655
+ /** Rollback the last batch, a specific batch, a specific file, or the last N steps */
656
+ down(filename?: string, options?: DownOptions): Promise<RunResult[]>;
657
+ /**
658
+ * Rollback then re-apply: the last applied migration, or a specific file.
659
+ * Both directions run under a single lock, so no other process can slip in
660
+ * while the migration is reverted.
661
+ */
662
+ redo(filename?: string, options?: RedoOptions): Promise<RunResult[]>;
663
+ on<E extends keyof MigronautEvents>(event: E, listener: MigronautEvents[E]): this;
664
+ once<E extends keyof MigronautEvents>(event: E, listener: MigronautEvents[E]): this;
665
+ off<E extends keyof MigronautEvents>(event: E, listener: MigronautEvents[E]): this;
666
+ /**
667
+ * Stop the current or imminently-starting run: the migration currently
668
+ * executing finishes, the remaining ones are skipped, the lock is released,
669
+ * and the in-flight call rejects with a {@link RunAbortedError} whose
670
+ * `context.results` lists what was applied. A stop that arrives while config
671
+ * is loading or the connection is opening is remembered and applied as soon
672
+ * as the run reaches its lock; a pending stop is cleared when a run ends, so
673
+ * it can never abort a later, unrelated run.
674
+ */
675
+ stop(reason?: string): void;
676
+ /** Preview what would run — never writes to the database */
677
+ dryRun(
678
+ direction: 'up' | 'down',
679
+ filename?: string,
680
+ options?: { steps?: number; batch?: number; to?: string },
681
+ ): Promise<StatusRow[]>;
682
+ /** Full migration status for all known files and records */
683
+ status(): Promise<StatusRow[]>;
684
+ /**
685
+ * Read-only health check: configuration, connectivity, transaction support,
686
+ * changelog indexes, lock state, checksum drift and runtime. Reports
687
+ * problems; fixes none of them.
688
+ */
689
+ audit(): Promise<AuditReport>;
690
+ /** Filtered list of migrations. Default: 'all' */
691
+ list(filter?: 'all' | 'pending' | 'applied'): Promise<StatusRow[]>;
692
+ /** Create a new migration file and return its absolute path */
693
+ create(name: string, options?: CreateOptions): Promise<string>;
694
+ /** Create a migronaut config file in the working directory and return its path */
695
+ init(options?: InitOptions): Promise<string>;
696
+ /**
697
+ * Adopt an existing migrate-mongo `changelog` collection by mapping its
698
+ * records into our schema and writing them to `migrationsCollection`.
699
+ */
700
+ import(options?: ImportOptions): Promise<ImportResult>;
701
+ }
702
+
703
+ // ─── Programmatic entry points ─────────────────────────────────────────────────
704
+
705
+ /** What to do when another process already holds the migration lock */
706
+ export type OnLockHeld = 'throw' | 'wait';
707
+
708
+ /** Options for {@link runMigrations} */
709
+ export interface RunMigrationsOptions extends MigratorKitOptions {
710
+ /** Skip lock acquisition (dev only — never in production) */
711
+ noLock?: boolean;
712
+ /**
713
+ * How to react when another process already holds the migration lock — the
714
+ * typical case when several app instances boot at once.
715
+ * - `'throw'` (default): propagate {@link LockAlreadyHeldError}.
716
+ * - `'wait'`: poll until the lock frees, then run.
717
+ */
718
+ onLockHeld?: OnLockHeld;
719
+ /**
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.
723
+ */
724
+ lockWaitTimeoutMs?: number;
725
+ /** Poll interval (ms) while waiting for the lock. Default: 500 */
726
+ lockPollIntervalMs?: number;
727
+ }
728
+
729
+ /** Outcome of a {@link runMigrations} call */
730
+ export interface MigrationSummary {
731
+ /** Migrations applied during this call (empty when nothing was pending) */
732
+ applied: RunResult[];
733
+ /** True when no migrations were pending — the database was already up to date */
734
+ upToDate: boolean;
735
+ /** True when this instance waited for a peer to release the lock before running */
736
+ waited: boolean;
737
+ /** Total time (ms) spent waiting for a peer's lock. 0 when the lock was free */
738
+ waitedMs: number;
739
+ /** Number of `up` attempts made — 1 when the lock was free on the first try */
740
+ attempts: number;
741
+ }
742
+
743
+ /**
744
+ * Run all pending migrations and return a summary — the blessed one-call entry
745
+ * point for application startup, deploy hooks, serverless cold starts, and test
746
+ * setup. Always disconnects in a `finally`, so a failure never leaks a MongoDB
747
+ * connection.
748
+ */
749
+ export function runMigrations(
750
+ config?: Partial<MigronautConfig>,
751
+ options?: RunMigrationsOptions,
752
+ ): Promise<MigrationSummary>;
753
+
754
+ /**
755
+ * Return the migrations that have not yet been applied — a connection-managed
756
+ * readiness probe. Opens its own connection and always disconnects in a `finally`.
757
+ */
758
+ export function pendingMigrations(
759
+ config?: Partial<MigronautConfig>,
760
+ options?: MigratorKitOptions,
761
+ ): Promise<StatusRow[]>;
762
+
763
+ /**
764
+ * The CLI's exit-code map: one entry per {@link MigronautErrorCode}, plus two
765
+ * CLI-condition codes with no error class — `PENDING_MIGRATIONS` (from
766
+ * `status --check`) and `AUDIT_FAILED`. Lets a wrapper script mirror the
767
+ * CLI's exit semantics without hardcoding numbers. Anything unmapped exits 1;
768
+ * success is 0.
769
+ */
770
+ export const EXIT_CODES: Readonly<
771
+ Record<MigronautErrorCode | 'PENDING_MIGRATIONS' | 'AUDIT_FAILED', number>
772
+ >;
773
+
774
+ // ─── Errors ───────────────────────────────────────────────────────────────────
775
+
776
+ /** Construction options shared by every migronaut error — `cause` keeps the wrapped Error */
777
+ export interface MigronautErrorOptions {
778
+ cause?: unknown;
779
+ }
780
+
781
+ /** Base error for all migronaut failures. Carries a typed code and context */
782
+ export class MigronautError extends Error {
783
+ readonly code: MigronautErrorCode;
784
+ readonly context?: Record<string, unknown>;
785
+ constructor(
786
+ code: MigronautErrorCode,
787
+ message: string,
788
+ context?: Record<string, unknown>,
789
+ options?: MigronautErrorOptions,
790
+ );
791
+ }
792
+
793
+ /** Thrown when a lock is already held by another process within its TTL */
794
+ export class LockAlreadyHeldError extends MigronautError {
795
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
796
+ }
797
+
798
+ /** Thrown when releasing a lock fails */
799
+ export class LockReleaseFailedError extends MigronautError {
800
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
801
+ }
802
+
803
+ /** Thrown when a file's checksum differs from the one recorded at apply time */
804
+ export class ChecksumMismatchError extends MigronautError {
805
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
806
+ }
807
+
808
+ /** Thrown when a referenced migration file does not exist on disk */
809
+ export class MigrationFileNotFoundError extends MigronautError {
810
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
811
+ }
812
+
813
+ /**
814
+ * Thrown when a migration name is not a plain filename — e.g. it contains a
815
+ * path separator or `..`, which would let a target escape the migrations
816
+ * directory (path traversal) when joined into a filesystem path.
817
+ */
818
+ export class MigrationInvalidNameError extends MigronautError {
819
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
820
+ }
821
+
822
+ /** Thrown when a migration file does not export valid up()/down() functions */
823
+ export class MigrationInvalidExportError extends MigronautError {
824
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
825
+ }
826
+
827
+ /** Thrown when a migration's up() or down() throws during execution */
828
+ export class MigrationExecutionFailedError extends MigronautError {
829
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
830
+ }
831
+
832
+ /** Thrown when the merged configuration fails validation */
833
+ export class ConfigInvalidError extends MigronautError {
834
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
835
+ }
836
+
837
+ /** Thrown when creating a config file that already exists without `--force` */
838
+ export class ConfigFileExistsError extends MigronautError {
839
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
840
+ }
841
+
842
+ /** Thrown when connecting to MongoDB fails */
843
+ export class ConnectionFailedError extends MigronautError {
844
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
845
+ }
846
+
847
+ /**
848
+ * Thrown when the lock is lost while migrations are still running — another
849
+ * process reclaimed it, or the heartbeat could not reach the database. Set
850
+ * `onLockLost: 'warn'` to downgrade this to a warning.
851
+ */
852
+ export class LockLostError extends MigronautError {
853
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
854
+ }
855
+
856
+ /**
857
+ * Thrown when a run is stopped before finishing — via {@link MigratorKit.stop}
858
+ * or a SIGINT/SIGTERM. `context.results` lists what was applied first.
859
+ */
860
+ export class RunAbortedError extends MigronautError {
861
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
862
+ }
863
+
864
+ /**
865
+ * Thrown when a migration exceeds its `timeoutMs`. Best-effort: the migration's
866
+ * own work keeps running, but the run stops rather than hanging.
867
+ */
868
+ export class MigrationTimeoutError extends MigronautError {
869
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
870
+ }
871
+
872
+ /**
873
+ * Thrown when `useTransaction` is on but the deployment cannot run
874
+ * transactions (a standalone server — they need a replica set or mongos).
875
+ */
876
+ export class TransactionsUnsupportedError extends MigronautError {
877
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
878
+ }
879
+
880
+ /** Thrown when a user-supplied lifecycle hook throws */
881
+ export class HookFailedError extends MigronautError {
882
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
883
+ }
884
+
885
+ /** Thrown when `create` would overwrite an existing migration file */
886
+ export class MigrationFileExistsError extends MigronautError {
887
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
888
+ }
889
+
890
+ /** Thrown when attempting to revert a migration that was never applied */
891
+ export class NotAppliedError extends MigronautError {
892
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
893
+ }
894
+
895
+ /** Thrown when `migronaut import` targets a non-empty changelog without `--force` */
896
+ export class ImportTargetNotEmptyError extends MigronautError {
897
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
898
+ }
899
+
900
+ /** Thrown when attempting to roll back a migrate-mongo-imported (forward-only) migration */
901
+ export class IrreversibleMigrationError extends MigronautError {
902
+ constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
903
+ }