@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.
- package/CLAUDE.md +2 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +13 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +16 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +4 -0
- package/dist/core/modules/migrate/migration-runner.js +46 -6
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.29.x-to-11.30.x.md +1 -1
- package/migration-guides/11.30.x-to-11.31.x.md +38 -0
- package/migration-guides/11.31.0-to-11.31.1.md +89 -0
- package/package.json +1 -1
- package/src/core/modules/migrate/README.md +56 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +27 -3
- package/src/core/modules/migrate/migration-runner.ts +123 -6
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
288
|
+
const rollbackId = migrationId(lastMigration.title);
|
|
289
|
+
const migrationToRollback = allMigrations.find((m) => migrationId(m.title) === rollbackId);
|
|
185
290
|
|
|
186
291
|
if (!migrationToRollback) {
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|