@lenne.tech/nest-server 11.30.0 → 11.31.1

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.
@@ -7,7 +7,7 @@
7
7
  | **Breaking Changes** | None in nest-server's own API. Two transitive changes may affect projects: `@nestjs/websockets` removed from dependencies; `@nestjs/swagger` 11.4.6 blocks deep imports |
8
8
  | **New Features** | None (dependency maintenance release) |
9
9
  | **Bugfixes** | Dependency updates incl. security-relevant `ws` 8.21.1 override; 6 obsolete pnpm overrides removed |
10
- | **Migration Effort** | Very Low (~5 minutes) — most projects need no changes |
10
+ | **Migration Effort** | Very Low — most projects need no changes |
11
11
 
12
12
  ---
13
13
 
@@ -0,0 +1,38 @@
1
+ # Migration Guide: 11.30.x → 11.31.x
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `pnpm run check` steps report grouped by project (monorepo / api / app); orchestrator parity across all lt starters |
9
+ | **Bugfixes** | Root-only chain steps now run correctly when workspace members exist |
10
+ | **Migration Effort** | None — no code changes required |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ # Update package
18
+ npm install @lenne.tech/nest-server@11.31.x
19
+
20
+ # Verify build
21
+ npm run build
22
+
23
+ # Run tests
24
+ npm test
25
+ ```
26
+
27
+ No code changes required in any project.
28
+
29
+ ---
30
+
31
+ ## What's New in 11.31.x
32
+
33
+ ### Grouped check report
34
+
35
+ `pnpm run check` now groups its steps report by project (monorepo root, api,
36
+ app), so in a fullstack workspace you immediately see which project a failing
37
+ step belongs to. The check orchestrator also behaves identically across all
38
+ lt starters (same chain semantics, same root-only step handling).
@@ -0,0 +1,89 @@
1
+ # Migration Guide: 11.31.0 → 11.31.1
2
+
3
+ > Note: written on the feature branch (`feature/migrate-safe-ts-js-transition`).
4
+ > If the release lands under a different version number, rename this file accordingly.
5
+
6
+ ## Overview
7
+
8
+ | Category | Details |
9
+ |----------|---------|
10
+ | **Breaking Changes** | None |
11
+ | **New Features** | Extension-agnostic migration identity (safe ts-node → compiled-JS transition); strict integrity mode (`--strict` / `NSC__MIGRATE__STRICT` / `MigrationRunnerOptions.strict`); `status()` reports `missing` files; `migrate list` annotates missing files |
12
+ | **Bugfixes** | Co-present `foo.ts` + `foo.js` no longer execute twice in one `up()` run (deduplicated by identity, `.js` wins) |
13
+ | **Migration Effort** | None — no code changes required. Optional: enable strict mode in production images |
14
+
15
+ ---
16
+
17
+ ## Quick Migration
18
+
19
+ ```bash
20
+ # Update package
21
+ npm install @lenne.tech/nest-server@11.31.1
22
+
23
+ # Verify build
24
+ npm run build
25
+
26
+ # Run tests
27
+ npm test
28
+ ```
29
+
30
+ No code changes required in any project.
31
+
32
+ ---
33
+
34
+ ## What's New in 11.31.1
35
+
36
+ ### Extension-agnostic migration identity (safe ts-node → compiled-JS transition)
37
+
38
+ `migrate up`, `migrate down`, and `migrate list` now compare migrations by their
39
+ timestamped file stem WITHOUT the `.ts`/`.js` extension. A migration recorded as
40
+ `1699-foo.ts` (run via ts-node) matches the compiled `1699-foo.js` in a production
41
+ image — already-applied migrations are no longer treated as "pending" and re-run
42
+ after switching the image to compiled JavaScript. No state rewrite is needed;
43
+ existing databases keep working as-is.
44
+
45
+ If both `foo.ts` and `foo.js` are present in the migrations directory (e.g. an
46
+ overlapping `outDir`), they now count as ONE migration: only the `.js` file is
47
+ loaded and executed, with a warning. Previously both files would have executed.
48
+
49
+ Note: the legacy `synchronizedUp()` helper drives the external `migrate` package's
50
+ own state handling and keeps raw-filename identity — the new semantics apply to the
51
+ `MigrationRunner`/built-in CLI path only.
52
+
53
+ ### Missing migration files tolerated by default (up/list)
54
+
55
+ Old, already-applied migration files can be deleted (they live in git and can be
56
+ restored). `migrate up` no longer needs their files to be present:
57
+
58
+ - Default: `up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots. Previously such entries were silently ignored — log-scraping setups will see a NEW warning.
59
+ - `migrate list` marks affected entries with `(file missing)` and `status()` additionally returns them in a new `missing: string[]` field (additive, backward compatible).
60
+ - `migrate down` ALWAYS fails hard when the rollback file is missing — unchanged from 11.31.0, with a clearer message (`… restore the file from git to roll back.`). Rollback is an explicit operator action, never a boot path.
61
+
62
+ ### Strict integrity mode (opt-in)
63
+
64
+ Turn the tolerated drift into a hard error (`up` throws, `list` exits non-zero):
65
+
66
+ | Activation path | Scope |
67
+ |-----------------|-------|
68
+ | `--strict` CLI flag | single invocation |
69
+ | `NSC__MIGRATE__STRICT=1\|true\|yes` env var (case-insensitive) | CLI **and** programmatic `MigrationRunner` instances |
70
+ | `new MigrationRunner({ strict: true, ... })` | programmatic |
71
+
72
+ **Recommended for production images:** set `NSC__MIGRATE__STRICT=true`. In an
73
+ immutable image, a recorded-but-missing migration file can only mean a broken build
74
+ or a state-store mismatch — refusing to boot is the correct reaction there.
75
+
76
+ ---
77
+
78
+ ## Compatibility Notes
79
+
80
+ - `status()` return type gained a `missing: string[]` field. Code that destructures
81
+ only `completed`/`pending` continues to work unchanged.
82
+ - **Vendor mode:** `src/core/modules/migrate/migration-runner.ts` and
83
+ `src/core/modules/migrate/cli/migrate-cli.ts` are an atomic pair — sync BOTH
84
+ files together (the CLI passes `strict` into the runner's options interface and
85
+ imports `parseStrictEnv` from the runner).
86
+
87
+ ## Module Documentation
88
+
89
+ - [Migrate module README](../src/core/modules/migrate/README.md) — see "Migration Identity & Strict Mode"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.30.0",
3
+ "version": "11.31.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -432,6 +432,62 @@ The `migrate` command comes from `@lenne.tech/nest-server` - no external package
432
432
 
433
433
  See the [Migration Guide](./MIGRATION_FROM_NODEPIT.md) for detailed migration instructions from @nodepit.
434
434
 
435
+ ## Migration Identity & Strict Mode
436
+
437
+ ### Extension-agnostic identity (safe ts-node → compiled-JS transition)
438
+
439
+ A migration's identity is its timestamped file stem **without** the `.ts`/`.js` extension
440
+ (`migrationId('1699-foo.ts') === migrationId('1699-foo.js') === '1699-foo'`). `up`, `down`,
441
+ and `list`/`status` all compare identities, not raw filenames.
442
+
443
+ This makes the recommended production setup — running compiled `.js` migrations in the image
444
+ while the state was recorded under `.ts` names via ts-node — safe: already-applied migrations
445
+ are **not** re-run after the switch, and no state rewrite is needed. If both `foo.ts` and
446
+ `foo.js` are present in the migrations directory (e.g. an overlapping `outDir`), they count
447
+ as ONE migration: only the `.js` file is loaded and executed, and a warning is emitted.
448
+
449
+ Note: identity normalization applies to the `MigrationRunner`/CLI path only. The legacy
450
+ `synchronizedUp()` helper drives the external `migrate` package's own state handling and
451
+ keeps raw-filename identity.
452
+
453
+ ### Missing migration files & strict mode
454
+
455
+ Old, already-applied migration files can be deleted (they live in git and can be restored).
456
+ By default the runner **tolerates** a recorded migration whose file is gone:
457
+
458
+ - `migrate up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots.
459
+ - `migrate list` marks the entry with `(file missing)`.
460
+ - `migrate down` **always fails hard** on a missing rollback file — independent of strict mode. Rollback is an explicit operator action, never a boot path; exiting 0 without rolling anything back would mislead scripts. Restore the file from git, then retry.
461
+
462
+ Strict mode turns the tolerated drift into a hard error (`up` throws, `list` exits non-zero).
463
+ Enable it via any of:
464
+
465
+ | Activation path | Scope |
466
+ | -------------------------------------------- | -------------------------------------------------------------------------------- |
467
+ | `--strict` CLI flag | single invocation |
468
+ | `NSC__MIGRATE__STRICT=1\|true\|yes` env var | CLI **and** programmatic runners (resolved in the `MigrationRunner` constructor) |
469
+ | `new MigrationRunner({ strict: true, ... })` | programmatic |
470
+
471
+ **Recommended for production images:** set `NSC__MIGRATE__STRICT=true` in the container
472
+ environment. In an immutable image, a recorded-but-missing migration file can only mean a
473
+ broken build (empty/miscopied `migrations/` directory) or a state-store mismatch (wrong
474
+ database) — both are conditions where refusing to boot is correct.
475
+
476
+ ### Programmatic usage (MigrationRunner)
477
+
478
+ ```typescript
479
+ import { MigrationRunner, MongoStateStore } from '@lenne.tech/nest-server';
480
+
481
+ const runner = new MigrationRunner({
482
+ migrationsDirectory: './migrations',
483
+ stateStore: new MongoStateStore(process.env.NSC__MONGOOSE__URI),
484
+ strict: true, // optional; defaults to NSC__MIGRATE__STRICT, else false
485
+ });
486
+
487
+ await runner.up();
488
+ const { completed, missing, pending } = await runner.status();
489
+ ```
490
+
435
491
  ## Project Integration
436
492
 
437
493
  The migration utilities are designed to minimize boilerplate in your projects. Instead of copying multiple utility files, you can:
@@ -17,18 +17,21 @@
17
17
  * --store, -s Path to state store module
18
18
  * --compiler, -c Compiler to use (e.g., ts:./path/to/ts-compiler.js)
19
19
  * --template-file, -t Template file for creating migrations
20
+ * --strict Fail if a recorded migration's file is missing (default: off / tolerate).
21
+ * Applies to up/list; down always fails hard on a missing rollback file.
20
22
  */
21
23
 
22
24
  import * as fs from 'fs';
23
25
  import * as path from 'path';
24
26
 
25
- import { MigrationRunner } from '../migration-runner';
27
+ import { MigrationRunner, parseStrictEnv } from '../migration-runner';
26
28
  import { MongoStateStore } from '../mongo-state-store';
27
29
 
28
30
  interface CliOptions {
29
31
  compiler?: string;
30
32
  migrationsDir: string;
31
33
  store?: string;
34
+ strict?: boolean;
32
35
  templateFile?: string;
33
36
  }
34
37
 
@@ -90,9 +93,11 @@ async function listMigrations(options: CliOptions) {
90
93
  const runner = new MigrationRunner({
91
94
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
92
95
  stateStore,
96
+ strict: options.strict,
93
97
  });
94
98
 
95
99
  const status = await runner.status();
100
+ const missing = new Set(status.missing);
96
101
 
97
102
  console.log('\nMigration Status:');
98
103
  console.log('=================\n');
@@ -100,7 +105,7 @@ async function listMigrations(options: CliOptions) {
100
105
  if (status.completed.length > 0) {
101
106
  console.log('Completed:');
102
107
  status.completed.forEach((name) => {
103
- console.log(` ✓ ${name}`);
108
+ console.log(` ✓ ${name}${missing.has(name) ? ' (file missing)' : ''}`);
104
109
  });
105
110
  console.log('');
106
111
  }
@@ -116,6 +121,14 @@ async function listMigrations(options: CliOptions) {
116
121
  if (status.completed.length === 0 && status.pending.length === 0) {
117
122
  console.log('No migrations found\n');
118
123
  }
124
+
125
+ // In strict mode, integrity drift is an error also on the inspection surface —
126
+ // exit non-zero (via main's error handler) instead of hiding it in the listing.
127
+ if (options.strict && status.missing.length > 0) {
128
+ throw new Error(
129
+ `Strict mode: ${status.missing.length} recorded migration file(s) missing: ${status.missing.join(', ')}`,
130
+ );
131
+ }
119
132
  }
120
133
 
121
134
  /**
@@ -198,6 +211,9 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
198
211
  let name: string | undefined;
199
212
  const options: CliOptions = {
200
213
  migrationsDir: './migrations',
214
+ // Off by default: a recorded migration whose file was deleted is tolerated so the
215
+ // server still starts. Enable per-run via `--strict` or globally via NSC__MIGRATE__STRICT.
216
+ strict: parseStrictEnv(),
201
217
  };
202
218
 
203
219
  // Check if second arg is a name (not a flag)
@@ -217,6 +233,8 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
217
233
  options.compiler = args[++i];
218
234
  } else if (arg === '--template-file' || arg === '-t') {
219
235
  options.templateFile = args[++i];
236
+ } else if (arg === '--strict') {
237
+ options.strict = true;
220
238
  }
221
239
  }
222
240
 
@@ -254,6 +272,7 @@ async function runDown(options: CliOptions) {
254
272
  const runner = new MigrationRunner({
255
273
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
256
274
  stateStore,
275
+ strict: options.strict,
257
276
  });
258
277
 
259
278
  await runner.down();
@@ -269,6 +288,7 @@ async function runUp(options: CliOptions) {
269
288
  const runner = new MigrationRunner({
270
289
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
271
290
  stateStore,
291
+ strict: options.strict,
272
292
  });
273
293
 
274
294
  await runner.up();
@@ -292,6 +312,8 @@ Options:
292
312
  --store, -s <path> Path to state store module
293
313
  --compiler, -c <compiler> Compiler to use (e.g., ts:./path/to/ts-compiler.js)
294
314
  --template-file, -t <path> Template file for creating migrations
315
+ --strict Fail if a recorded migration's file is missing (default: off / tolerate).
316
+ Applies to up/list; down always fails hard on a missing rollback file.
295
317
 
296
318
  Examples:
297
319
  migrate create add-user-email
@@ -302,6 +324,7 @@ Examples:
302
324
 
303
325
  Environment Variables:
304
326
  NODE_ENV Set environment (e.g., development, production)
327
+ NSC__MIGRATE__STRICT 1|true|yes → fail on a missing recorded migration file (default: tolerate)
305
328
  `);
306
329
  }
307
330
 
@@ -313,4 +336,5 @@ if (require.main === module) {
313
336
  });
314
337
  }
315
338
 
316
- export { main };
339
+ // parseArgs is exported for unit testing only (same pattern as resolveCliPath in bin/migrate.js)
340
+ export { main, parseArgs };
@@ -33,6 +33,59 @@ export interface MigrationFile {
33
33
  */
34
34
  export const DEFAULT_MIGRATION_FILE_PATTERN = /(?:(?<!\.d)\.ts|\.js)$/;
35
35
 
36
+ /**
37
+ * Matches the trailing `.ts`/`.js` extension stripped by {@link migrationId}.
38
+ * Hoisted to module level so `migrationId` allocates no per-call RegExp wrapper.
39
+ */
40
+ const MIGRATION_EXTENSION_PATTERN = /\.(ts|js)$/;
41
+
42
+ /**
43
+ * Migration identity = the timestamped file stem WITHOUT its `.ts`/`.js` extension.
44
+ *
45
+ * A migration keeps its identity when a project switches its production image from
46
+ * ts-node (`1699-foo.ts`) to compiled JavaScript (`1699-foo.js`) — the recommended
47
+ * prod setup, since the image prunes ts-node. Comparing raw filenames would make
48
+ * every compiled `.js` migration look "pending" against a state recorded under `.ts`
49
+ * names and re-run already-applied migrations (data corruption on existing DBs).
50
+ * Normalising both sides makes the transition safe with no state rewrite.
51
+ *
52
+ * @param title Migration title, usually the filename (e.g. `1699000000000-foo.ts`)
53
+ * @returns The extension-agnostic identity (e.g. `1699000000000-foo`)
54
+ * @example
55
+ * migrationId('1699000000000-foo.ts'); // '1699000000000-foo'
56
+ * migrationId('1699000000000-foo.js'); // '1699000000000-foo'
57
+ * migrationId('1699000000000-foo'); // '1699000000000-foo' (already extensionless)
58
+ */
59
+ export function migrationId(title: string): string {
60
+ return title.replace(MIGRATION_EXTENSION_PATTERN, '');
61
+ }
62
+
63
+ /**
64
+ * Parse the `NSC__MIGRATE__STRICT` environment variable into a boolean.
65
+ *
66
+ * Truthy values: `1`, `true`, `yes` (case-insensitive, surrounding whitespace ignored —
67
+ * a real risk with Docker/compose env files). Every other value means `false` (tolerate,
68
+ * the safe default); non-empty unrecognized values additionally emit a warning so a typo
69
+ * like `NSC__MIGRATE__STRICT=on` does not silently disable the control the operator
70
+ * intended to enable.
71
+ *
72
+ * Shared by the CLI (`migrate-cli.ts`) and the {@link MigrationRunner} constructor, so
73
+ * the env var behaves identically for CLI and programmatic runners.
74
+ */
75
+ export function parseStrictEnv(value: string | undefined = process.env.NSC__MIGRATE__STRICT): boolean {
76
+ if (value === undefined) {
77
+ return false;
78
+ }
79
+ const normalized = value.trim().toLowerCase();
80
+ if (['1', 'true', 'yes'].includes(normalized)) {
81
+ return true;
82
+ }
83
+ if (normalized !== '' && !['0', 'false', 'no'].includes(normalized)) {
84
+ console.warn(`[migrate] Unrecognized NSC__MIGRATE__STRICT value "${value}" — treating as false (tolerate).`);
85
+ }
86
+ return false;
87
+ }
88
+
36
89
  /**
37
90
  * Migration runner configuration
38
91
  */
@@ -43,6 +96,23 @@ export interface MigrationRunnerOptions {
43
96
  pattern?: RegExp;
44
97
  /** State store for tracking migrations */
45
98
  stateStore: MongoStateStore;
99
+ /**
100
+ * Fail hard when a migration recorded in the state has no file on disk.
101
+ *
102
+ * Default: resolved from the `NSC__MIGRATE__STRICT` environment variable (see
103
+ * {@link parseStrictEnv}), falling back to `false` (tolerate) — recorded migrations
104
+ * whose files were deleted are ignored: `up()` skips them (with a warning) and the
105
+ * server still starts. Migrations are tracked in git and can be restored, so old
106
+ * migration files can be pruned without blocking boot. Set `true` to enforce
107
+ * state/disk integrity (missing file → error in `up()` and `migrate list`).
108
+ *
109
+ * Applies to `up()` and `status()`/`migrate list` only — `down()` ALWAYS fails hard
110
+ * on a missing rollback file, because rollback is an explicit operator action and
111
+ * never a boot path (an exit 0 with no rollback performed would mislead scripts).
112
+ *
113
+ * @see `--strict` CLI flag and `NSC__MIGRATE__STRICT` env var in `cli/migrate-cli.ts`
114
+ */
115
+ strict?: boolean;
46
116
  }
47
117
 
48
118
  /**
@@ -72,12 +142,21 @@ export class MigrationRunner {
72
142
  private pattern: RegExp;
73
143
 
74
144
  constructor(options: MigrationRunnerOptions) {
75
- this.options = options;
145
+ // Resolve the strict default from the environment so NSC__MIGRATE__STRICT works
146
+ // identically for programmatic runners and the CLI (which parses it itself).
147
+ this.options = { ...options, strict: options.strict ?? parseStrictEnv() };
76
148
  this.pattern = options.pattern || DEFAULT_MIGRATION_FILE_PATTERN;
77
149
  }
78
150
 
79
151
  /**
80
152
  * Load all migration files from the migrations directory
153
+ *
154
+ * Files are deduplicated by {@link migrationId}: when both `foo.ts` and `foo.js`
155
+ * are present (overlapping `outDir`, source + build output copied into one image),
156
+ * they are ONE migration — loading both would execute it twice in a single `up()`
157
+ * run (the exact double-execution the identity concept exists to prevent). The
158
+ * `.js` file wins deterministically (`.js` sorts before `.ts`), matching the
159
+ * compiled-production intent; the duplicate is skipped with a warning.
81
160
  */
82
161
  private async loadMigrationFiles(): Promise<MigrationFile[]> {
83
162
  const files = fs
@@ -86,8 +165,17 @@ export class MigrationRunner {
86
165
  .sort(); // Sort alphabetically (timestamp-based filenames will be in order)
87
166
 
88
167
  const migrations: MigrationFile[] = [];
168
+ const seenIds = new Set<string>();
89
169
 
90
170
  for (const file of files) {
171
+ const id = migrationId(file);
172
+ if (seenIds.has(id)) {
173
+ console.warn(
174
+ `[migrate] duplicate files for migration "${id}" — skipping ${file} (a same-named file was already loaded)`,
175
+ );
176
+ continue;
177
+ }
178
+
91
179
  const filePath = path.join(this.options.migrationsDirectory, file);
92
180
 
93
181
  const module = require(filePath);
@@ -101,6 +189,7 @@ export class MigrationRunner {
101
189
  const timestampMatch = file.match(/^(\d+)-/);
102
190
  const timestamp = timestampMatch ? parseInt(timestampMatch[1], 10) : Date.now();
103
191
 
192
+ seenIds.add(id);
104
193
  migrations.push({
105
194
  down: module.down,
106
195
  filePath,
@@ -121,9 +210,24 @@ export class MigrationRunner {
121
210
 
122
211
  const allMigrations = await this.loadMigrationFiles();
123
212
  const state = await this.options.stateStore.loadAsync();
124
- const completedMigrations = (state.migrations || []).map((m) => m.title);
213
+ const recorded = state.migrations || [];
214
+ const presentIds = new Set(allMigrations.map((m) => migrationId(m.title)));
215
+
216
+ // Recorded migrations whose file is gone. Tolerated by default (git-tracked and
217
+ // restorable); in strict mode this is a hard error so integrity drift is caught.
218
+ const missing = recorded.filter((m) => !presentIds.has(migrationId(m.title)));
219
+ if (missing.length > 0) {
220
+ const list = missing.map((m) => m.title).join(', ');
221
+ if (this.options.strict) {
222
+ throw new Error(`Strict mode: ${missing.length} recorded migration file(s) missing: ${list}`);
223
+ }
224
+ console.warn(
225
+ `[migrate] ${missing.length} recorded migration file(s) missing — tolerated (strict=false): ${list}`,
226
+ );
227
+ }
125
228
 
126
- const pendingMigrations = allMigrations.filter((m) => !completedMigrations.includes(m.title));
229
+ const completedIds = new Set(recorded.map((m) => migrationId(m.title)));
230
+ const pendingMigrations = allMigrations.filter((m) => !completedIds.has(migrationId(m.title)));
127
231
 
128
232
  if (pendingMigrations.length === 0) {
129
233
  console.log('No pending migrations');
@@ -181,10 +285,14 @@ export class MigrationRunner {
181
285
 
182
286
  const lastMigration = completedMigrations[completedMigrations.length - 1];
183
287
  const allMigrations = await this.loadMigrationFiles();
184
- const migrationToRollback = allMigrations.find((m) => m.title === lastMigration.title);
288
+ const rollbackId = migrationId(lastMigration.title);
289
+ const migrationToRollback = allMigrations.find((m) => migrationId(m.title) === rollbackId);
185
290
 
186
291
  if (!migrationToRollback) {
187
- throw new Error(`Migration file not found: ${lastMigration.title}`);
292
+ // Always a hard error, independent of `strict`: down() is an explicit operator
293
+ // action and never a boot path — the boot-tolerance rationale does not apply,
294
+ // and an exit 0 with no rollback performed would mislead scripted rollbacks.
295
+ throw new Error(`Migration file not found: ${lastMigration.title} — restore the file from git to roll back.`);
188
296
  }
189
297
 
190
298
  if (!migrationToRollback.down) {
@@ -216,18 +324,27 @@ export class MigrationRunner {
216
324
 
217
325
  /**
218
326
  * Get migration status
327
+ *
328
+ * `missing` lists recorded migrations whose file is gone from disk (identity-based,
329
+ * so a `.ts`-recorded migration with a compiled `.js` on disk is NOT missing) — the
330
+ * same integrity drift `up()` warns about, surfaced here so `migrate list` can show
331
+ * it before a deploy or file pruning.
219
332
  */
220
333
  async status(): Promise<{
221
334
  completed: string[];
335
+ missing: string[];
222
336
  pending: string[];
223
337
  }> {
224
338
  const allMigrations = await this.loadMigrationFiles();
225
339
  const state = await this.options.stateStore.loadAsync();
226
340
  const completedMigrations = (state.migrations || []).map((m) => m.title);
341
+ const completedIds = new Set(completedMigrations.map(migrationId));
342
+ const presentIds = new Set(allMigrations.map((m) => migrationId(m.title)));
227
343
 
228
344
  return {
229
345
  completed: completedMigrations,
230
- pending: allMigrations.filter((m) => !completedMigrations.includes(m.title)).map((m) => m.title),
346
+ missing: completedMigrations.filter((title) => !presentIds.has(migrationId(title))),
347
+ pending: allMigrations.filter((m) => !completedIds.has(migrationId(m.title))).map((m) => m.title),
231
348
  };
232
349
  }
233
350