@syncular/typegen 0.15.24 → 0.15.25
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/README.md +20 -5
- package/dist/cli.js +20 -4
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +22 -5
- package/dist/migration-lock.d.ts +21 -7
- package/dist/migration-lock.js +106 -22
- package/package.json +3 -3
- package/src/cli.ts +24 -3
- package/src/generate.ts +33 -5
- package/src/migration-lock.ts +160 -28
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ overrides it.
|
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
syncular generate [--manifest-dir <dir>] [--check] [--watch]
|
|
12
|
-
syncular migrations baseline|check [--manifest-dir <dir>]
|
|
12
|
+
syncular migrations baseline|check|upgrade-lock [--manifest-dir <dir>]
|
|
13
13
|
syncular init [--manifest-dir <dir>]
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -26,7 +26,8 @@ are mutually exclusive.
|
|
|
26
26
|
exists. `migrations check` is the fast, CI-friendly history check without
|
|
27
27
|
query analysis or output generation. New migrations are appended to the lock
|
|
28
28
|
by ordinary `generate`; locked migrations may never be edited, removed,
|
|
29
|
-
renamed, or reordered.
|
|
29
|
+
renamed, or reordered. Existing format-1 locks remain valid and keep their
|
|
30
|
+
format until `migrations upgrade-lock` explicitly validates and compacts them.
|
|
30
31
|
|
|
31
32
|
`init` scaffolds a starter `syncular.json`, first migration, and migration
|
|
32
33
|
lock into an existing project (the "add syncular to my app" path). It refuses
|
|
@@ -110,9 +111,11 @@ missing or out-of-order migration).
|
|
|
110
111
|
### Immutable migration history
|
|
111
112
|
|
|
112
113
|
`syncular.migrations.lock.json` is the version-controlled deployment baseline.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
It contains no SQL, filesystem path, row data, or runtime database
|
|
114
|
+
Format 2 records every migration name and SHA-256 checksum (line endings
|
|
115
|
+
normalized), plus one canonical head-schema column snapshot used only for
|
|
116
|
+
diagnostics. It contains no SQL, filesystem path, row data, or runtime database
|
|
117
|
+
state, and grows with migration metadata plus the current schema rather than
|
|
118
|
+
repeating the full cumulative schema after every migration.
|
|
116
119
|
|
|
117
120
|
For an existing project adopting this contract, review the current history
|
|
118
121
|
once, then run:
|
|
@@ -123,6 +126,18 @@ git add syncular.migrations.lock.json
|
|
|
123
126
|
syncular migrations check --manifest-dir .
|
|
124
127
|
```
|
|
125
128
|
|
|
129
|
+
Projects with a committed format-1 lock upgrade deliberately, after first
|
|
130
|
+
checking that immutable history is unchanged:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
syncular migrations check --manifest-dir .
|
|
134
|
+
syncular migrations upgrade-lock --manifest-dir .
|
|
135
|
+
git add syncular.migrations.lock.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The upgrade command refuses an already-current lock and ordinary `generate`
|
|
139
|
+
never changes the lock format implicitly.
|
|
140
|
+
|
|
126
141
|
After that, a deployed migration is immutable. Restore an accidentally edited
|
|
127
142
|
migration and express the repair in a new `NNNN_name/up.sql`; do not delete and
|
|
128
143
|
re-baseline the lock. Existing table layouts are append-only and added columns
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
|
|
19
19
|
import { resolve } from 'node:path';
|
|
20
20
|
import { formatSyql } from './fmt.js';
|
|
21
|
-
import { baselineMigrationHistory, checkMigrationHistory, checkOutputs, generate, loadQueries, writeOutputs, } from './generate.js';
|
|
21
|
+
import { baselineMigrationHistory, checkMigrationHistory, checkOutputs, generate, loadQueries, upgradeMigrationHistory, writeOutputs, } from './generate.js';
|
|
22
22
|
import { InitError, initProject } from './init.js';
|
|
23
23
|
import { runLspStdio } from './lsp.js';
|
|
24
24
|
import { MANIFEST_FILENAME, parseManifest } from './manifest.js';
|
|
@@ -28,7 +28,7 @@ const USAGE = `usage: syncular <command> [options]
|
|
|
28
28
|
|
|
29
29
|
commands:
|
|
30
30
|
generate build the IR + typed module from syncular.json + migrations/
|
|
31
|
-
migrations baseline or
|
|
31
|
+
migrations baseline, check, or upgrade-lock immutable migration history
|
|
32
32
|
fmt format .syql query files canonically (one style, no options)
|
|
33
33
|
lsp run the .syql language server over stdio (editor tooling)
|
|
34
34
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
@@ -49,6 +49,7 @@ fmt options:
|
|
|
49
49
|
migrations options:
|
|
50
50
|
baseline create the first immutable history lock; refuses overwrite
|
|
51
51
|
check validate the committed history without generating outputs
|
|
52
|
+
upgrade-lock explicitly compact a validated format-1 lock to format 2
|
|
52
53
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
53
54
|
|
|
54
55
|
init options:
|
|
@@ -312,8 +313,10 @@ function runFmt(argv) {
|
|
|
312
313
|
}
|
|
313
314
|
function runMigrations(argv) {
|
|
314
315
|
const action = argv[0];
|
|
315
|
-
if (action !== 'baseline' &&
|
|
316
|
-
|
|
316
|
+
if (action !== 'baseline' &&
|
|
317
|
+
action !== 'check' &&
|
|
318
|
+
action !== 'upgrade-lock') {
|
|
319
|
+
fail(`migrations requires 'baseline', 'check', or 'upgrade-lock'\n${USAGE}`);
|
|
317
320
|
}
|
|
318
321
|
const manifestDir = parseManifestDir(argv.slice(1));
|
|
319
322
|
if (action === 'baseline') {
|
|
@@ -332,6 +335,19 @@ function runMigrations(argv) {
|
|
|
332
335
|
console.log('commit this migration baseline before deployment');
|
|
333
336
|
return;
|
|
334
337
|
}
|
|
338
|
+
if (action === 'upgrade-lock') {
|
|
339
|
+
let output;
|
|
340
|
+
try {
|
|
341
|
+
output = upgradeMigrationHistory(manifestDir);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
fail(friendlyGenerateError(error));
|
|
345
|
+
}
|
|
346
|
+
writeFileSync(output.path, output.content, 'utf8');
|
|
347
|
+
console.log(`upgraded ${output.path} to compact format 2`);
|
|
348
|
+
console.log('review and commit the migration-lock format upgrade');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
335
351
|
let drift;
|
|
336
352
|
try {
|
|
337
353
|
drift = checkMigrationHistory(manifestDir);
|
package/dist/generate.d.ts
CHANGED
|
@@ -70,6 +70,12 @@ export interface GenerateResult {
|
|
|
70
70
|
* write so the CLI can refuse replacement before touching the filesystem.
|
|
71
71
|
*/
|
|
72
72
|
export declare function baselineMigrationHistory(manifestDir: string): GeneratedOutput;
|
|
73
|
+
/**
|
|
74
|
+
* Validate and explicitly compact a version-1 migration lock to version 2.
|
|
75
|
+
* The caller owns the in-place write so the CLI can make the format transition
|
|
76
|
+
* visible and reviewable.
|
|
77
|
+
*/
|
|
78
|
+
export declare function upgradeMigrationHistory(manifestDir: string): GeneratedOutput;
|
|
73
79
|
/** Fast CI check for migration history without emitting/query analysis. */
|
|
74
80
|
export declare function checkMigrationHistory(manifestDir: string): string[];
|
|
75
81
|
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
package/dist/generate.js
CHANGED
|
@@ -17,7 +17,7 @@ import { emitSwiftModule } from './emit-swift.js';
|
|
|
17
17
|
import { TypegenError } from './errors.js';
|
|
18
18
|
import { canonicalizeExtensions, IR_VERSION, irHash, serializeIr, } from './ir.js';
|
|
19
19
|
import { MANIFEST_FILENAME, parseManifest, } from './manifest.js';
|
|
20
|
-
import { buildMigrationLock, MIGRATION_LOCK_FILENAME, readMigrationLock, serializeMigrationLock,
|
|
20
|
+
import { buildMigrationLock, LEGACY_MIGRATION_LOCK_FORMAT_VERSION, MIGRATION_LOCK_FILENAME, readMigrationLock, serializeMigrationLock, updateMigrationLock, } from './migration-lock.js';
|
|
21
21
|
import { buildNamingMap } from './naming.js';
|
|
22
22
|
import { analyzeQueryFile, synthesizeDdl, } from './query.js';
|
|
23
23
|
import { serializeQueryIr } from './query-ir.js';
|
|
@@ -352,11 +352,29 @@ export function baselineMigrationHistory(manifestDir) {
|
|
|
352
352
|
content: serializeMigrationLock(buildMigrationLock(migrations)),
|
|
353
353
|
};
|
|
354
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Validate and explicitly compact a version-1 migration lock to version 2.
|
|
357
|
+
* The caller owns the in-place write so the CLI can make the format transition
|
|
358
|
+
* visible and reviewable.
|
|
359
|
+
*/
|
|
360
|
+
export function upgradeMigrationHistory(manifestDir) {
|
|
361
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
362
|
+
const locked = readMigrationLock(manifestDir);
|
|
363
|
+
if (locked.formatVersion !== LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
364
|
+
throw new TypegenError(MIGRATION_LOCK_FILENAME, 'already uses the current compact migration-lock format');
|
|
365
|
+
}
|
|
366
|
+
// Validate the immutable prefix before replacing its representation.
|
|
367
|
+
updateMigrationLock(locked, migrations);
|
|
368
|
+
buildIr(manifest, migrations);
|
|
369
|
+
return {
|
|
370
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
371
|
+
content: serializeMigrationLock(buildMigrationLock(migrations)),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
355
374
|
/** Fast CI check for migration history without emitting/query analysis. */
|
|
356
375
|
export function checkMigrationHistory(manifestDir) {
|
|
357
376
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
358
|
-
const current =
|
|
359
|
-
validateMigrationLock(readMigrationLock(manifestDir), current);
|
|
377
|
+
const current = updateMigrationLock(readMigrationLock(manifestDir), migrations);
|
|
360
378
|
buildIr(manifest, migrations);
|
|
361
379
|
const output = {
|
|
362
380
|
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
@@ -372,8 +390,7 @@ export function checkMigrationHistory(manifestDir) {
|
|
|
372
390
|
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
373
391
|
export function generate(manifestDir) {
|
|
374
392
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
375
|
-
const migrationLock =
|
|
376
|
-
validateMigrationLock(readMigrationLock(manifestDir), migrationLock);
|
|
393
|
+
const migrationLock = updateMigrationLock(readMigrationLock(manifestDir), migrations);
|
|
377
394
|
const ir = buildIr(manifest, migrations);
|
|
378
395
|
// §5/§12 naming: the emitter targets this run generates (keyword hazards
|
|
379
396
|
// are only real on targets that exist), and the per-table collision check
|
package/dist/migration-lock.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { MigrationInput } from './generate.js';
|
|
2
2
|
import type { IrColumnType } from './ir.js';
|
|
3
3
|
export declare const MIGRATION_LOCK_FILENAME = "syncular.migrations.lock.json";
|
|
4
|
-
export declare const MIGRATION_LOCK_FORMAT_VERSION =
|
|
4
|
+
export declare const MIGRATION_LOCK_FORMAT_VERSION = 2;
|
|
5
|
+
export declare const LEGACY_MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
5
6
|
export interface MigrationLockColumn {
|
|
6
7
|
readonly name: string;
|
|
7
8
|
readonly type: IrColumnType;
|
|
@@ -16,14 +17,24 @@ export interface MigrationLockTable {
|
|
|
16
17
|
export interface MigrationLockEntry {
|
|
17
18
|
readonly name: string;
|
|
18
19
|
readonly sha256: string;
|
|
20
|
+
}
|
|
21
|
+
export interface LegacyMigrationLockEntry extends MigrationLockEntry {
|
|
19
22
|
readonly tables: readonly MigrationLockTable[];
|
|
20
23
|
}
|
|
21
|
-
export interface
|
|
24
|
+
export interface LegacyMigrationLock {
|
|
25
|
+
readonly formatVersion: typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION;
|
|
26
|
+
readonly migrations: readonly LegacyMigrationLockEntry[];
|
|
27
|
+
}
|
|
28
|
+
export interface MigrationLockV2 {
|
|
22
29
|
readonly formatVersion: typeof MIGRATION_LOCK_FORMAT_VERSION;
|
|
23
30
|
readonly migrations: readonly MigrationLockEntry[];
|
|
31
|
+
readonly head: {
|
|
32
|
+
readonly tables: readonly MigrationLockTable[];
|
|
33
|
+
};
|
|
24
34
|
}
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
export type MigrationLock = LegacyMigrationLock | MigrationLockV2;
|
|
36
|
+
/** Build a deterministic lock document for a complete migration sequence. */
|
|
37
|
+
export declare function buildMigrationLock(migrations: readonly MigrationInput[], formatVersion?: typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION | typeof MIGRATION_LOCK_FORMAT_VERSION): MigrationLock;
|
|
27
38
|
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
28
39
|
export declare function serializeMigrationLock(lock: MigrationLock): string;
|
|
29
40
|
/** Parse and structurally validate a committed lock document. */
|
|
@@ -31,7 +42,10 @@ export declare function parseMigrationLock(source: string): MigrationLock;
|
|
|
31
42
|
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
32
43
|
export declare function readMigrationLock(manifestDir: string): MigrationLock;
|
|
33
44
|
/**
|
|
34
|
-
* Validate
|
|
35
|
-
*
|
|
45
|
+
* Validate a committed lock against migration inputs and build its exact next
|
|
46
|
+
* document. Ordinary generation preserves the committed format; upgrading a
|
|
47
|
+
* version-1 lock is always an explicit command.
|
|
36
48
|
*/
|
|
37
|
-
export declare function
|
|
49
|
+
export declare function updateMigrationLock(locked: MigrationLock, migrations: readonly MigrationInput[]): MigrationLock;
|
|
50
|
+
/** Validate without changing the committed lock format. */
|
|
51
|
+
export declare function validateMigrationLock(locked: MigrationLock, migrations: readonly MigrationInput[]): void;
|
package/dist/migration-lock.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `syncular.migrations.lock.json` is a version-controlled baseline of every
|
|
5
5
|
* migration that has been accepted so far. Existing entries are immutable;
|
|
6
|
-
* generation may only append newly named migrations.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* generation may only append newly named migrations. Format 2 stores one
|
|
7
|
+
* canonical head-schema snapshot for actionable diagnostics instead of a
|
|
8
|
+
* cumulative schema copy after every migration. Format 1 remains readable and
|
|
9
|
+
* writable until an explicit `migrations upgrade-lock` command replaces it.
|
|
9
10
|
*/
|
|
10
11
|
import { createHash } from 'node:crypto';
|
|
11
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -13,7 +14,8 @@ import { resolve } from 'node:path';
|
|
|
13
14
|
import { TypegenError } from './errors.js';
|
|
14
15
|
import { applyMigrationSql } from './sql.js';
|
|
15
16
|
export const MIGRATION_LOCK_FILENAME = 'syncular.migrations.lock.json';
|
|
16
|
-
export const MIGRATION_LOCK_FORMAT_VERSION =
|
|
17
|
+
export const MIGRATION_LOCK_FORMAT_VERSION = 2;
|
|
18
|
+
export const LEGACY_MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
17
19
|
function normalizedSql(sql) {
|
|
18
20
|
return sql.replace(/\r\n?/g, '\n');
|
|
19
21
|
}
|
|
@@ -37,8 +39,8 @@ function snapshotTables(tables) {
|
|
|
37
39
|
columns: table.columns.map(snapshotColumn),
|
|
38
40
|
}));
|
|
39
41
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
+
/** Replay a complete migration sequence with ephemeral diagnostic snapshots. */
|
|
43
|
+
function buildMigrationHistory(migrations) {
|
|
42
44
|
const tables = new Map();
|
|
43
45
|
const droppedTables = new Set();
|
|
44
46
|
const entries = [];
|
|
@@ -50,7 +52,31 @@ export function buildMigrationLock(migrations) {
|
|
|
50
52
|
tables: snapshotTables(tables),
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
|
-
return {
|
|
55
|
+
return { migrations: entries };
|
|
56
|
+
}
|
|
57
|
+
function lockFromHistory(history, formatVersion) {
|
|
58
|
+
if (history.migrations.length === 0) {
|
|
59
|
+
failLock('cannot lock an empty migration history');
|
|
60
|
+
}
|
|
61
|
+
if (formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
62
|
+
return {
|
|
63
|
+
formatVersion: LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
64
|
+
migrations: history.migrations,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const head = history.migrations.at(-1);
|
|
68
|
+
return {
|
|
69
|
+
formatVersion: MIGRATION_LOCK_FORMAT_VERSION,
|
|
70
|
+
migrations: history.migrations.map(({ name, sha256 }) => ({
|
|
71
|
+
name,
|
|
72
|
+
sha256,
|
|
73
|
+
})),
|
|
74
|
+
head: { tables: head.tables },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Build a deterministic lock document for a complete migration sequence. */
|
|
78
|
+
export function buildMigrationLock(migrations, formatVersion = MIGRATION_LOCK_FORMAT_VERSION) {
|
|
79
|
+
return lockFromHistory(buildMigrationHistory(migrations), formatVersion);
|
|
54
80
|
}
|
|
55
81
|
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
56
82
|
export function serializeMigrationLock(lock) {
|
|
@@ -118,21 +144,46 @@ function parseEntry(value, index) {
|
|
|
118
144
|
const context = `migrations[${index}]`;
|
|
119
145
|
if (!isRecord(value))
|
|
120
146
|
failLock(`${context} must be an object`);
|
|
121
|
-
const { name, sha256
|
|
147
|
+
const { name, sha256 } = value;
|
|
122
148
|
if (typeof name !== 'string' || name.length === 0) {
|
|
123
149
|
failLock(`${context}.name must be a non-empty string`);
|
|
124
150
|
}
|
|
125
151
|
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
126
152
|
failLock(`${context}.sha256 must be a lowercase SHA-256 digest`);
|
|
127
153
|
}
|
|
154
|
+
return { name, sha256 };
|
|
155
|
+
}
|
|
156
|
+
function parseLegacyEntry(value, index) {
|
|
157
|
+
const context = `migrations[${index}]`;
|
|
158
|
+
const entry = parseEntry(value, index);
|
|
159
|
+
if (!isRecord(value))
|
|
160
|
+
failLock(`${context} must be an object`);
|
|
161
|
+
const { tables } = value;
|
|
128
162
|
if (!Array.isArray(tables))
|
|
129
163
|
failLock(`${context}.tables must be an array`);
|
|
130
164
|
return {
|
|
131
|
-
|
|
132
|
-
sha256,
|
|
165
|
+
...entry,
|
|
133
166
|
tables: tables.map((table, tableIndex) => parseTable(table, `${context}.tables[${tableIndex}]`)),
|
|
134
167
|
};
|
|
135
168
|
}
|
|
169
|
+
function parseHead(value) {
|
|
170
|
+
if (!isRecord(value))
|
|
171
|
+
failLock('head must be an object');
|
|
172
|
+
if (!Array.isArray(value.tables))
|
|
173
|
+
failLock('head.tables must be an array');
|
|
174
|
+
return {
|
|
175
|
+
tables: value.tables.map((table, index) => parseTable(table, `head.tables[${index}]`)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function assertUniqueMigrationNames(migrations) {
|
|
179
|
+
const names = new Set();
|
|
180
|
+
for (const migration of migrations) {
|
|
181
|
+
if (names.has(migration.name)) {
|
|
182
|
+
failLock(`migration ${JSON.stringify(migration.name)} appears twice`);
|
|
183
|
+
}
|
|
184
|
+
names.add(migration.name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
136
187
|
/** Parse and structurally validate a committed lock document. */
|
|
137
188
|
export function parseMigrationLock(source) {
|
|
138
189
|
let raw;
|
|
@@ -144,21 +195,28 @@ export function parseMigrationLock(source) {
|
|
|
144
195
|
}
|
|
145
196
|
if (!isRecord(raw))
|
|
146
197
|
failLock('root must be an object');
|
|
147
|
-
if (raw.formatVersion !==
|
|
148
|
-
|
|
198
|
+
if (raw.formatVersion !== LEGACY_MIGRATION_LOCK_FORMAT_VERSION &&
|
|
199
|
+
raw.formatVersion !== MIGRATION_LOCK_FORMAT_VERSION) {
|
|
200
|
+
failLock(`formatVersion must be ${LEGACY_MIGRATION_LOCK_FORMAT_VERSION} or ${MIGRATION_LOCK_FORMAT_VERSION}, got ${JSON.stringify(raw.formatVersion)}`);
|
|
149
201
|
}
|
|
150
202
|
if (!Array.isArray(raw.migrations) || raw.migrations.length === 0) {
|
|
151
203
|
failLock('migrations must be a non-empty array');
|
|
152
204
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
205
|
+
if (raw.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
206
|
+
const migrations = raw.migrations.map(parseLegacyEntry);
|
|
207
|
+
assertUniqueMigrationNames(migrations);
|
|
208
|
+
return {
|
|
209
|
+
formatVersion: LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
210
|
+
migrations,
|
|
211
|
+
};
|
|
160
212
|
}
|
|
161
|
-
|
|
213
|
+
const migrations = raw.migrations.map(parseEntry);
|
|
214
|
+
assertUniqueMigrationNames(migrations);
|
|
215
|
+
return {
|
|
216
|
+
formatVersion: MIGRATION_LOCK_FORMAT_VERSION,
|
|
217
|
+
migrations,
|
|
218
|
+
head: parseHead(raw.head),
|
|
219
|
+
};
|
|
162
220
|
}
|
|
163
221
|
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
164
222
|
export function readMigrationLock(manifestDir) {
|
|
@@ -238,7 +296,7 @@ const REPAIR_HINT = 'deployed migrations are immutable; restore the locked migra
|
|
|
238
296
|
* Validate the committed history as an exact prefix of the current sequence.
|
|
239
297
|
* New migrations may be appended; existing names and bytes may never change.
|
|
240
298
|
*/
|
|
241
|
-
|
|
299
|
+
function validateMigrationHistory(locked, current) {
|
|
242
300
|
for (let index = 0; index < locked.migrations.length; index++) {
|
|
243
301
|
const before = locked.migrations[index];
|
|
244
302
|
const after = current.migrations[index];
|
|
@@ -249,7 +307,33 @@ export function validateMigrationLock(locked, current) {
|
|
|
249
307
|
failLock(`history drift at position ${index + 1}: locked ${JSON.stringify(before.name)} but found ${JSON.stringify(after.name)}; migrations cannot be removed, renamed, or reordered; ${REPAIR_HINT}`);
|
|
250
308
|
}
|
|
251
309
|
if (before.sha256 !== after.sha256) {
|
|
252
|
-
|
|
310
|
+
const diagnosticBefore = locked.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
311
|
+
? locked.migrations[index]
|
|
312
|
+
: {
|
|
313
|
+
...before,
|
|
314
|
+
tables: locked.head.tables,
|
|
315
|
+
};
|
|
316
|
+
// Format 2 keeps one schema snapshot after the complete locked prefix.
|
|
317
|
+
// Compare it with the current schema at that same boundary, excluding
|
|
318
|
+
// valid newly appended migrations from the diagnostic.
|
|
319
|
+
const diagnosticAfter = locked.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
320
|
+
? after
|
|
321
|
+
: (current.migrations[locked.migrations.length - 1] ?? after);
|
|
322
|
+
failLock(`history drift in migration ${JSON.stringify(before.name)}: checksum changed; first schema difference: ${firstSchemaDifference(diagnosticBefore, diagnosticAfter)}; ${REPAIR_HINT}`);
|
|
253
323
|
}
|
|
254
324
|
}
|
|
255
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Validate a committed lock against migration inputs and build its exact next
|
|
328
|
+
* document. Ordinary generation preserves the committed format; upgrading a
|
|
329
|
+
* version-1 lock is always an explicit command.
|
|
330
|
+
*/
|
|
331
|
+
export function updateMigrationLock(locked, migrations) {
|
|
332
|
+
const current = buildMigrationHistory(migrations);
|
|
333
|
+
validateMigrationHistory(locked, current);
|
|
334
|
+
return lockFromHistory(current, locked.formatVersion);
|
|
335
|
+
}
|
|
336
|
+
/** Validate without changing the committed lock format. */
|
|
337
|
+
export function validateMigrationLock(locked, migrations) {
|
|
338
|
+
validateMigrationHistory(locked, buildMigrationHistory(migrations));
|
|
339
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.25",
|
|
4
4
|
"description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"!dist/**/*.test.d.ts"
|
|
49
49
|
],
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@syncular/core": "0.15.
|
|
52
|
-
"@syncular/server": "0.15.
|
|
51
|
+
"@syncular/core": "0.15.25",
|
|
52
|
+
"@syncular/server": "0.15.25"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
checkOutputs,
|
|
25
25
|
generate,
|
|
26
26
|
loadQueries,
|
|
27
|
+
upgradeMigrationHistory,
|
|
27
28
|
writeOutputs,
|
|
28
29
|
} from './generate';
|
|
29
30
|
import { InitError, initProject } from './init';
|
|
@@ -38,7 +39,7 @@ const USAGE = `usage: syncular <command> [options]
|
|
|
38
39
|
|
|
39
40
|
commands:
|
|
40
41
|
generate build the IR + typed module from syncular.json + migrations/
|
|
41
|
-
migrations baseline or
|
|
42
|
+
migrations baseline, check, or upgrade-lock immutable migration history
|
|
42
43
|
fmt format .syql query files canonically (one style, no options)
|
|
43
44
|
lsp run the .syql language server over stdio (editor tooling)
|
|
44
45
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
@@ -59,6 +60,7 @@ fmt options:
|
|
|
59
60
|
migrations options:
|
|
60
61
|
baseline create the first immutable history lock; refuses overwrite
|
|
61
62
|
check validate the committed history without generating outputs
|
|
63
|
+
upgrade-lock explicitly compact a validated format-1 lock to format 2
|
|
62
64
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
63
65
|
|
|
64
66
|
init options:
|
|
@@ -332,8 +334,14 @@ function runFmt(argv: readonly string[]): void {
|
|
|
332
334
|
|
|
333
335
|
function runMigrations(argv: readonly string[]): void {
|
|
334
336
|
const action = argv[0];
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
+
if (
|
|
338
|
+
action !== 'baseline' &&
|
|
339
|
+
action !== 'check' &&
|
|
340
|
+
action !== 'upgrade-lock'
|
|
341
|
+
) {
|
|
342
|
+
fail(
|
|
343
|
+
`migrations requires 'baseline', 'check', or 'upgrade-lock'\n${USAGE}`,
|
|
344
|
+
);
|
|
337
345
|
}
|
|
338
346
|
const manifestDir = parseManifestDir(argv.slice(1));
|
|
339
347
|
if (action === 'baseline') {
|
|
@@ -354,6 +362,19 @@ function runMigrations(argv: readonly string[]): void {
|
|
|
354
362
|
return;
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
if (action === 'upgrade-lock') {
|
|
366
|
+
let output: ReturnType<typeof upgradeMigrationHistory>;
|
|
367
|
+
try {
|
|
368
|
+
output = upgradeMigrationHistory(manifestDir);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
fail(friendlyGenerateError(error));
|
|
371
|
+
}
|
|
372
|
+
writeFileSync(output.path, output.content, 'utf8');
|
|
373
|
+
console.log(`upgraded ${output.path} to compact format 2`);
|
|
374
|
+
console.log('review and commit the migration-lock format upgrade');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
357
378
|
let drift: string[];
|
|
358
379
|
try {
|
|
359
380
|
drift = checkMigrationHistory(manifestDir);
|
package/src/generate.ts
CHANGED
|
@@ -43,10 +43,11 @@ import {
|
|
|
43
43
|
} from './manifest';
|
|
44
44
|
import {
|
|
45
45
|
buildMigrationLock,
|
|
46
|
+
LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
46
47
|
MIGRATION_LOCK_FILENAME,
|
|
47
48
|
readMigrationLock,
|
|
48
49
|
serializeMigrationLock,
|
|
49
|
-
|
|
50
|
+
updateMigrationLock,
|
|
50
51
|
} from './migration-lock';
|
|
51
52
|
import { buildNamingMap, type NamingTarget } from './naming';
|
|
52
53
|
import {
|
|
@@ -578,11 +579,36 @@ export function baselineMigrationHistory(manifestDir: string): GeneratedOutput {
|
|
|
578
579
|
};
|
|
579
580
|
}
|
|
580
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Validate and explicitly compact a version-1 migration lock to version 2.
|
|
584
|
+
* The caller owns the in-place write so the CLI can make the format transition
|
|
585
|
+
* visible and reviewable.
|
|
586
|
+
*/
|
|
587
|
+
export function upgradeMigrationHistory(manifestDir: string): GeneratedOutput {
|
|
588
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
589
|
+
const locked = readMigrationLock(manifestDir);
|
|
590
|
+
if (locked.formatVersion !== LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
591
|
+
throw new TypegenError(
|
|
592
|
+
MIGRATION_LOCK_FILENAME,
|
|
593
|
+
'already uses the current compact migration-lock format',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
// Validate the immutable prefix before replacing its representation.
|
|
597
|
+
updateMigrationLock(locked, migrations);
|
|
598
|
+
buildIr(manifest, migrations);
|
|
599
|
+
return {
|
|
600
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
601
|
+
content: serializeMigrationLock(buildMigrationLock(migrations)),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
581
605
|
/** Fast CI check for migration history without emitting/query analysis. */
|
|
582
606
|
export function checkMigrationHistory(manifestDir: string): string[] {
|
|
583
607
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
584
|
-
const current =
|
|
585
|
-
|
|
608
|
+
const current = updateMigrationLock(
|
|
609
|
+
readMigrationLock(manifestDir),
|
|
610
|
+
migrations,
|
|
611
|
+
);
|
|
586
612
|
buildIr(manifest, migrations);
|
|
587
613
|
const output = {
|
|
588
614
|
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
@@ -599,8 +625,10 @@ export function checkMigrationHistory(manifestDir: string): string[] {
|
|
|
599
625
|
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
600
626
|
export function generate(manifestDir: string): GenerateResult {
|
|
601
627
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
602
|
-
const migrationLock =
|
|
603
|
-
|
|
628
|
+
const migrationLock = updateMigrationLock(
|
|
629
|
+
readMigrationLock(manifestDir),
|
|
630
|
+
migrations,
|
|
631
|
+
);
|
|
604
632
|
const ir = buildIr(manifest, migrations);
|
|
605
633
|
|
|
606
634
|
// §5/§12 naming: the emitter targets this run generates (keyword hazards
|
package/src/migration-lock.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `syncular.migrations.lock.json` is a version-controlled baseline of every
|
|
5
5
|
* migration that has been accepted so far. Existing entries are immutable;
|
|
6
|
-
* generation may only append newly named migrations.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* generation may only append newly named migrations. Format 2 stores one
|
|
7
|
+
* canonical head-schema snapshot for actionable diagnostics instead of a
|
|
8
|
+
* cumulative schema copy after every migration. Format 1 remains readable and
|
|
9
|
+
* writable until an explicit `migrations upgrade-lock` command replaces it.
|
|
9
10
|
*/
|
|
10
11
|
import { createHash } from 'node:crypto';
|
|
11
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -16,7 +17,8 @@ import type { IrColumn, IrColumnType } from './ir';
|
|
|
16
17
|
import { applyMigrationSql, type ParsedTable } from './sql';
|
|
17
18
|
|
|
18
19
|
export const MIGRATION_LOCK_FILENAME = 'syncular.migrations.lock.json';
|
|
19
|
-
export const MIGRATION_LOCK_FORMAT_VERSION =
|
|
20
|
+
export const MIGRATION_LOCK_FORMAT_VERSION = 2;
|
|
21
|
+
export const LEGACY_MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
20
22
|
|
|
21
23
|
export interface MigrationLockColumn {
|
|
22
24
|
readonly name: string;
|
|
@@ -34,12 +36,29 @@ export interface MigrationLockTable {
|
|
|
34
36
|
export interface MigrationLockEntry {
|
|
35
37
|
readonly name: string;
|
|
36
38
|
readonly sha256: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LegacyMigrationLockEntry extends MigrationLockEntry {
|
|
37
42
|
readonly tables: readonly MigrationLockTable[];
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
export interface
|
|
45
|
+
export interface LegacyMigrationLock {
|
|
46
|
+
readonly formatVersion: typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION;
|
|
47
|
+
readonly migrations: readonly LegacyMigrationLockEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MigrationLockV2 {
|
|
41
51
|
readonly formatVersion: typeof MIGRATION_LOCK_FORMAT_VERSION;
|
|
42
52
|
readonly migrations: readonly MigrationLockEntry[];
|
|
53
|
+
readonly head: {
|
|
54
|
+
readonly tables: readonly MigrationLockTable[];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type MigrationLock = LegacyMigrationLock | MigrationLockV2;
|
|
59
|
+
|
|
60
|
+
interface CurrentMigrationHistory {
|
|
61
|
+
readonly migrations: readonly LegacyMigrationLockEntry[];
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
function normalizedSql(sql: string): string {
|
|
@@ -71,13 +90,13 @@ function snapshotTables(
|
|
|
71
90
|
}));
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
93
|
+
/** Replay a complete migration sequence with ephemeral diagnostic snapshots. */
|
|
94
|
+
function buildMigrationHistory(
|
|
76
95
|
migrations: readonly MigrationInput[],
|
|
77
|
-
):
|
|
96
|
+
): CurrentMigrationHistory {
|
|
78
97
|
const tables = new Map<string, ParsedTable>();
|
|
79
98
|
const droppedTables = new Set<string>();
|
|
80
|
-
const entries:
|
|
99
|
+
const entries: LegacyMigrationLockEntry[] = [];
|
|
81
100
|
for (const migration of migrations) {
|
|
82
101
|
applyMigrationSql(
|
|
83
102
|
tables,
|
|
@@ -91,7 +110,43 @@ export function buildMigrationLock(
|
|
|
91
110
|
tables: snapshotTables(tables),
|
|
92
111
|
});
|
|
93
112
|
}
|
|
94
|
-
return {
|
|
113
|
+
return { migrations: entries };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lockFromHistory(
|
|
117
|
+
history: CurrentMigrationHistory,
|
|
118
|
+
formatVersion:
|
|
119
|
+
| typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
120
|
+
| typeof MIGRATION_LOCK_FORMAT_VERSION,
|
|
121
|
+
): MigrationLock {
|
|
122
|
+
if (history.migrations.length === 0) {
|
|
123
|
+
failLock('cannot lock an empty migration history');
|
|
124
|
+
}
|
|
125
|
+
if (formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
126
|
+
return {
|
|
127
|
+
formatVersion: LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
128
|
+
migrations: history.migrations,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const head = history.migrations.at(-1) as LegacyMigrationLockEntry;
|
|
132
|
+
return {
|
|
133
|
+
formatVersion: MIGRATION_LOCK_FORMAT_VERSION,
|
|
134
|
+
migrations: history.migrations.map(({ name, sha256 }) => ({
|
|
135
|
+
name,
|
|
136
|
+
sha256,
|
|
137
|
+
})),
|
|
138
|
+
head: { tables: head.tables },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Build a deterministic lock document for a complete migration sequence. */
|
|
143
|
+
export function buildMigrationLock(
|
|
144
|
+
migrations: readonly MigrationInput[],
|
|
145
|
+
formatVersion:
|
|
146
|
+
| typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
147
|
+
| typeof MIGRATION_LOCK_FORMAT_VERSION = MIGRATION_LOCK_FORMAT_VERSION,
|
|
148
|
+
): MigrationLock {
|
|
149
|
+
return lockFromHistory(buildMigrationHistory(migrations), formatVersion);
|
|
95
150
|
}
|
|
96
151
|
|
|
97
152
|
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
@@ -165,23 +220,55 @@ function parseTable(value: unknown, context: string): MigrationLockTable {
|
|
|
165
220
|
function parseEntry(value: unknown, index: number): MigrationLockEntry {
|
|
166
221
|
const context = `migrations[${index}]`;
|
|
167
222
|
if (!isRecord(value)) failLock(`${context} must be an object`);
|
|
168
|
-
const { name, sha256
|
|
223
|
+
const { name, sha256 } = value;
|
|
169
224
|
if (typeof name !== 'string' || name.length === 0) {
|
|
170
225
|
failLock(`${context}.name must be a non-empty string`);
|
|
171
226
|
}
|
|
172
227
|
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
173
228
|
failLock(`${context}.sha256 must be a lowercase SHA-256 digest`);
|
|
174
229
|
}
|
|
230
|
+
return { name, sha256 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseLegacyEntry(
|
|
234
|
+
value: unknown,
|
|
235
|
+
index: number,
|
|
236
|
+
): LegacyMigrationLockEntry {
|
|
237
|
+
const context = `migrations[${index}]`;
|
|
238
|
+
const entry = parseEntry(value, index);
|
|
239
|
+
if (!isRecord(value)) failLock(`${context} must be an object`);
|
|
240
|
+
const { tables } = value;
|
|
175
241
|
if (!Array.isArray(tables)) failLock(`${context}.tables must be an array`);
|
|
176
242
|
return {
|
|
177
|
-
|
|
178
|
-
sha256,
|
|
243
|
+
...entry,
|
|
179
244
|
tables: tables.map((table, tableIndex) =>
|
|
180
245
|
parseTable(table, `${context}.tables[${tableIndex}]`),
|
|
181
246
|
),
|
|
182
247
|
};
|
|
183
248
|
}
|
|
184
249
|
|
|
250
|
+
function parseHead(value: unknown): MigrationLockV2['head'] {
|
|
251
|
+
if (!isRecord(value)) failLock('head must be an object');
|
|
252
|
+
if (!Array.isArray(value.tables)) failLock('head.tables must be an array');
|
|
253
|
+
return {
|
|
254
|
+
tables: value.tables.map((table, index) =>
|
|
255
|
+
parseTable(table, `head.tables[${index}]`),
|
|
256
|
+
),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function assertUniqueMigrationNames(
|
|
261
|
+
migrations: readonly MigrationLockEntry[],
|
|
262
|
+
): void {
|
|
263
|
+
const names = new Set<string>();
|
|
264
|
+
for (const migration of migrations) {
|
|
265
|
+
if (names.has(migration.name)) {
|
|
266
|
+
failLock(`migration ${JSON.stringify(migration.name)} appears twice`);
|
|
267
|
+
}
|
|
268
|
+
names.add(migration.name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
185
272
|
/** Parse and structurally validate a committed lock document. */
|
|
186
273
|
export function parseMigrationLock(source: string): MigrationLock {
|
|
187
274
|
let raw: unknown;
|
|
@@ -191,23 +278,32 @@ export function parseMigrationLock(source: string): MigrationLock {
|
|
|
191
278
|
failLock('invalid JSON');
|
|
192
279
|
}
|
|
193
280
|
if (!isRecord(raw)) failLock('root must be an object');
|
|
194
|
-
if (
|
|
281
|
+
if (
|
|
282
|
+
raw.formatVersion !== LEGACY_MIGRATION_LOCK_FORMAT_VERSION &&
|
|
283
|
+
raw.formatVersion !== MIGRATION_LOCK_FORMAT_VERSION
|
|
284
|
+
) {
|
|
195
285
|
failLock(
|
|
196
|
-
`formatVersion must be ${MIGRATION_LOCK_FORMAT_VERSION}, got ${JSON.stringify(raw.formatVersion)}`,
|
|
286
|
+
`formatVersion must be ${LEGACY_MIGRATION_LOCK_FORMAT_VERSION} or ${MIGRATION_LOCK_FORMAT_VERSION}, got ${JSON.stringify(raw.formatVersion)}`,
|
|
197
287
|
);
|
|
198
288
|
}
|
|
199
289
|
if (!Array.isArray(raw.migrations) || raw.migrations.length === 0) {
|
|
200
290
|
failLock('migrations must be a non-empty array');
|
|
201
291
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
292
|
+
if (raw.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION) {
|
|
293
|
+
const migrations = raw.migrations.map(parseLegacyEntry);
|
|
294
|
+
assertUniqueMigrationNames(migrations);
|
|
295
|
+
return {
|
|
296
|
+
formatVersion: LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
297
|
+
migrations,
|
|
298
|
+
};
|
|
209
299
|
}
|
|
210
|
-
|
|
300
|
+
const migrations = raw.migrations.map(parseEntry);
|
|
301
|
+
assertUniqueMigrationNames(migrations);
|
|
302
|
+
return {
|
|
303
|
+
formatVersion: MIGRATION_LOCK_FORMAT_VERSION,
|
|
304
|
+
migrations,
|
|
305
|
+
head: parseHead(raw.head),
|
|
306
|
+
};
|
|
211
307
|
}
|
|
212
308
|
|
|
213
309
|
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
@@ -266,8 +362,8 @@ function describeColumnDifference(
|
|
|
266
362
|
}
|
|
267
363
|
|
|
268
364
|
function firstSchemaDifference(
|
|
269
|
-
locked:
|
|
270
|
-
current:
|
|
365
|
+
locked: LegacyMigrationLockEntry,
|
|
366
|
+
current: LegacyMigrationLockEntry,
|
|
271
367
|
): string {
|
|
272
368
|
const length = Math.max(locked.tables.length, current.tables.length);
|
|
273
369
|
for (let index = 0; index < length; index++) {
|
|
@@ -303,9 +399,9 @@ const REPAIR_HINT =
|
|
|
303
399
|
* Validate the committed history as an exact prefix of the current sequence.
|
|
304
400
|
* New migrations may be appended; existing names and bytes may never change.
|
|
305
401
|
*/
|
|
306
|
-
|
|
402
|
+
function validateMigrationHistory(
|
|
307
403
|
locked: MigrationLock,
|
|
308
|
-
current:
|
|
404
|
+
current: CurrentMigrationHistory,
|
|
309
405
|
): void {
|
|
310
406
|
for (let index = 0; index < locked.migrations.length; index++) {
|
|
311
407
|
const before = locked.migrations[index] as MigrationLockEntry;
|
|
@@ -321,9 +417,45 @@ export function validateMigrationLock(
|
|
|
321
417
|
);
|
|
322
418
|
}
|
|
323
419
|
if (before.sha256 !== after.sha256) {
|
|
420
|
+
const diagnosticBefore: LegacyMigrationLockEntry =
|
|
421
|
+
locked.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
422
|
+
? (locked.migrations[index] as LegacyMigrationLockEntry)
|
|
423
|
+
: {
|
|
424
|
+
...before,
|
|
425
|
+
tables: locked.head.tables,
|
|
426
|
+
};
|
|
427
|
+
// Format 2 keeps one schema snapshot after the complete locked prefix.
|
|
428
|
+
// Compare it with the current schema at that same boundary, excluding
|
|
429
|
+
// valid newly appended migrations from the diagnostic.
|
|
430
|
+
const diagnosticAfter =
|
|
431
|
+
locked.formatVersion === LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
432
|
+
? after
|
|
433
|
+
: (current.migrations[locked.migrations.length - 1] ?? after);
|
|
324
434
|
failLock(
|
|
325
|
-
`history drift in migration ${JSON.stringify(before.name)}: checksum changed; first schema difference: ${firstSchemaDifference(
|
|
435
|
+
`history drift in migration ${JSON.stringify(before.name)}: checksum changed; first schema difference: ${firstSchemaDifference(diagnosticBefore, diagnosticAfter)}; ${REPAIR_HINT}`,
|
|
326
436
|
);
|
|
327
437
|
}
|
|
328
438
|
}
|
|
329
439
|
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Validate a committed lock against migration inputs and build its exact next
|
|
443
|
+
* document. Ordinary generation preserves the committed format; upgrading a
|
|
444
|
+
* version-1 lock is always an explicit command.
|
|
445
|
+
*/
|
|
446
|
+
export function updateMigrationLock(
|
|
447
|
+
locked: MigrationLock,
|
|
448
|
+
migrations: readonly MigrationInput[],
|
|
449
|
+
): MigrationLock {
|
|
450
|
+
const current = buildMigrationHistory(migrations);
|
|
451
|
+
validateMigrationHistory(locked, current);
|
|
452
|
+
return lockFromHistory(current, locked.formatVersion);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Validate without changing the committed lock format. */
|
|
456
|
+
export function validateMigrationLock(
|
|
457
|
+
locked: MigrationLock,
|
|
458
|
+
migrations: readonly MigrationInput[],
|
|
459
|
+
): void {
|
|
460
|
+
validateMigrationHistory(locked, buildMigrationHistory(migrations));
|
|
461
|
+
}
|