@syncular/typegen 0.15.19 → 0.15.21
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 +47 -14
- package/dist/cli.js +44 -1
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +44 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +9 -1
- package/dist/migration-lock.d.ts +37 -0
- package/dist/migration-lock.js +255 -0
- package/package.json +3 -3
- package/src/cli.ts +52 -1
- package/src/generate.ts +61 -2
- package/src/index.ts +1 -0
- package/src/init.ts +21 -1
- package/src/migration-lock.ts +329 -0
package/README.md
CHANGED
|
@@ -9,22 +9,30 @@ 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
13
|
syncular init [--manifest-dir <dir>]
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
`generate` reads `<dir>/syncular.json` plus its migrations directory and
|
|
16
|
-
writes the IR JSON and
|
|
17
|
-
memory and exits 1 unless
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
writes the migration lock, IR JSON, and configured generated modules.
|
|
18
|
+
`--check` regenerates in memory and exits 1 unless every output on disk matches
|
|
19
|
+
**byte-exactly**. `--watch` regenerates on any change under the manifest dir
|
|
20
|
+
(Bun's recursive `fs.watch`, debounced; it skips the write when outputs are
|
|
21
|
+
already fresh so it never loops on its own output). `--check` and `--watch`
|
|
22
|
+
are mutually exclusive.
|
|
23
|
+
|
|
24
|
+
`migrations baseline` creates the first
|
|
25
|
+
`syncular.migrations.lock.json` and refuses to replace one that already
|
|
26
|
+
exists. `migrations check` is the fast, CI-friendly history check without
|
|
27
|
+
query analysis or output generation. New migrations are appended to the lock
|
|
28
|
+
by ordinary `generate`; locked migrations may never be edited, removed,
|
|
29
|
+
renamed, or reordered.
|
|
30
|
+
|
|
31
|
+
`init` scaffolds a starter `syncular.json`, first migration, and migration
|
|
32
|
+
lock into an existing project (the "add syncular to my app" path). It refuses
|
|
33
|
+
to overwrite any of them. New projects usually start from the scaffolder
|
|
34
|
+
instead (`bun create syncular-app my-app`), which emits the same shape plus a
|
|
35
|
+
server + client.
|
|
28
36
|
|
|
29
37
|
When `generate` cannot find the manifest or migrations, the error points at the
|
|
30
38
|
schema guide and suggests `syncular init`.
|
|
@@ -99,6 +107,31 @@ final entry's `through` must be the final migration — a migration not
|
|
|
99
107
|
covered by any version is a hard error (so is a `through` naming a
|
|
100
108
|
missing or out-of-order migration).
|
|
101
109
|
|
|
110
|
+
### Immutable migration history
|
|
111
|
+
|
|
112
|
+
`syncular.migrations.lock.json` is the version-controlled deployment baseline.
|
|
113
|
+
Each entry records the migration name, a SHA-256 checksum of its SQL (line
|
|
114
|
+
endings normalized), and a column-layout snapshot used only for diagnostics.
|
|
115
|
+
It contains no SQL, filesystem path, row data, or runtime database state.
|
|
116
|
+
|
|
117
|
+
For an existing project adopting this contract, review the current history
|
|
118
|
+
once, then run:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
syncular migrations baseline --manifest-dir .
|
|
122
|
+
git add syncular.migrations.lock.json
|
|
123
|
+
syncular migrations check --manifest-dir .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
After that, a deployed migration is immutable. Restore an accidentally edited
|
|
127
|
+
migration and express the repair in a new `NNNN_name/up.sql`; do not delete and
|
|
128
|
+
re-baseline the lock. Existing table layouts are append-only and added columns
|
|
129
|
+
must be nullable so stored payloads and every server backend can upgrade
|
|
130
|
+
safely. `generate` appends a valid new migration to the lock, while
|
|
131
|
+
`generate --check` fails until that updated lock and generated outputs are
|
|
132
|
+
committed. Drift diagnostics name the locked migration and the first affected
|
|
133
|
+
table/column without printing SQL or local paths.
|
|
134
|
+
|
|
102
135
|
**Every-migrated-table rule.** Every table created by the migrations must
|
|
103
136
|
appear in `tables`. A migrated-but-unlisted table is a hard error, not a
|
|
104
137
|
silent skip; internal/unsynced tables will be a future manifest flag.
|
|
@@ -297,8 +330,8 @@ The emitted `*.generated.ts` module:
|
|
|
297
330
|
|
|
298
331
|
**Lint/freshness split**: `*.generated.ts` is excluded from biome (see
|
|
299
332
|
`biome.json`) — hand-format rules on machine output only create churn.
|
|
300
|
-
Freshness and integrity are enforced instead by `syncular
|
|
301
|
-
--check`, which is byte-exact and
|
|
333
|
+
Freshness and migration-history integrity are enforced instead by `syncular
|
|
334
|
+
generate --check`, which is byte-exact and refuses locked-history drift; the
|
|
302
335
|
generated fixture is still typechecked by `tsc` and exercised by tests.
|
|
303
336
|
|
|
304
337
|
## 5. Native emitter contracts (Swift / Kotlin / Dart)
|
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 { checkOutputs, generate, loadQueries, writeOutputs } from './generate.js';
|
|
21
|
+
import { baselineMigrationHistory, checkMigrationHistory, checkOutputs, generate, loadQueries, 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,6 +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 check immutable deployed migration history
|
|
31
32
|
fmt format .syql query files canonically (one style, no options)
|
|
32
33
|
lsp run the .syql language server over stdio (editor tooling)
|
|
33
34
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
@@ -45,6 +46,11 @@ fmt options:
|
|
|
45
46
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
46
47
|
--check fail unless every file is already canonical
|
|
47
48
|
|
|
49
|
+
migrations options:
|
|
50
|
+
baseline create the first immutable history lock; refuses overwrite
|
|
51
|
+
check validate the committed history without generating outputs
|
|
52
|
+
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
53
|
+
|
|
48
54
|
init options:
|
|
49
55
|
--manifest-dir <dir> directory to scaffold into (default: .)`;
|
|
50
56
|
function fail(message) {
|
|
@@ -304,6 +310,39 @@ function runFmt(argv) {
|
|
|
304
310
|
if (drifted === 0)
|
|
305
311
|
console.log('all .syql files canonical');
|
|
306
312
|
}
|
|
313
|
+
function runMigrations(argv) {
|
|
314
|
+
const action = argv[0];
|
|
315
|
+
if (action !== 'baseline' && action !== 'check') {
|
|
316
|
+
fail(`migrations requires 'baseline' or 'check'\n${USAGE}`);
|
|
317
|
+
}
|
|
318
|
+
const manifestDir = parseManifestDir(argv.slice(1));
|
|
319
|
+
if (action === 'baseline') {
|
|
320
|
+
let output;
|
|
321
|
+
try {
|
|
322
|
+
output = baselineMigrationHistory(manifestDir);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
fail(friendlyGenerateError(error));
|
|
326
|
+
}
|
|
327
|
+
if (existsSync(output.path)) {
|
|
328
|
+
fail(`${output.path} already exists — refusing to replace immutable migration history`);
|
|
329
|
+
}
|
|
330
|
+
writeFileSync(output.path, output.content, 'utf8');
|
|
331
|
+
console.log(`wrote ${output.path}`);
|
|
332
|
+
console.log('commit this migration baseline before deployment');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
let drift;
|
|
336
|
+
try {
|
|
337
|
+
drift = checkMigrationHistory(manifestDir);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
fail(friendlyGenerateError(error));
|
|
341
|
+
}
|
|
342
|
+
if (drift.length > 0)
|
|
343
|
+
fail(drift.join('\n'));
|
|
344
|
+
console.log('migration history is locked and unchanged');
|
|
345
|
+
}
|
|
307
346
|
export function runCli(argv) {
|
|
308
347
|
const command = argv[0];
|
|
309
348
|
if (command === 'generate') {
|
|
@@ -323,6 +362,10 @@ export function runCli(argv) {
|
|
|
323
362
|
runFmt(argv.slice(1));
|
|
324
363
|
return;
|
|
325
364
|
}
|
|
365
|
+
if (command === 'migrations') {
|
|
366
|
+
runMigrations(argv.slice(1));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
326
369
|
if (command === 'lsp') {
|
|
327
370
|
void runLspStdio();
|
|
328
371
|
return;
|
package/dist/generate.d.ts
CHANGED
|
@@ -54,6 +54,9 @@ export interface GenerateResult {
|
|
|
54
54
|
readonly queryHash: string;
|
|
55
55
|
/** Generated TS module source (the exact bytes of the `.ts` output). */
|
|
56
56
|
readonly module: string;
|
|
57
|
+
/** Immutable application-migration history baseline. */
|
|
58
|
+
readonly migrationLockJson: string;
|
|
59
|
+
readonly migrationLockPath: string;
|
|
57
60
|
readonly irPath: string;
|
|
58
61
|
readonly modulePath: string;
|
|
59
62
|
/** Analyzed named queries (empty when no `queries/` files / no query output). */
|
|
@@ -62,6 +65,13 @@ export interface GenerateResult {
|
|
|
62
65
|
* stable order — the single list `writeOutputs`/`checkOutputs` iterate. */
|
|
63
66
|
readonly outputs: readonly GeneratedOutput[];
|
|
64
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Build a first immutable migration baseline. The caller owns the one-time
|
|
70
|
+
* write so the CLI can refuse replacement before touching the filesystem.
|
|
71
|
+
*/
|
|
72
|
+
export declare function baselineMigrationHistory(manifestDir: string): GeneratedOutput;
|
|
73
|
+
/** Fast CI check for migration history without emitting/query analysis. */
|
|
74
|
+
export declare function checkMigrationHistory(manifestDir: string): string[];
|
|
65
75
|
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
66
76
|
export declare function generate(manifestDir: string): GenerateResult;
|
|
67
77
|
export declare function writeOutputs(result: GenerateResult): void;
|
package/dist/generate.js
CHANGED
|
@@ -17,6 +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, validateMigrationLock, } from './migration-lock.js';
|
|
20
21
|
import { buildNamingMap } from './naming.js';
|
|
21
22
|
import { analyzeQueryFile, synthesizeDdl, } from './query.js';
|
|
22
23
|
import { serializeQueryIr } from './query-ir.js';
|
|
@@ -322,8 +323,7 @@ export function analyzeQueries(ir, queries, naming, queriesRoot = resolve('/__sy
|
|
|
322
323
|
close();
|
|
323
324
|
}
|
|
324
325
|
}
|
|
325
|
-
|
|
326
|
-
export function generate(manifestDir) {
|
|
326
|
+
function loadProjectInputs(manifestDir) {
|
|
327
327
|
const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
|
|
328
328
|
if (!existsSync(manifestPath)) {
|
|
329
329
|
throw new TypegenError(manifestPath, 'manifest not found');
|
|
@@ -337,6 +337,43 @@ export function generate(manifestDir) {
|
|
|
337
337
|
}
|
|
338
338
|
const manifest = parseManifest(raw);
|
|
339
339
|
const migrations = loadMigrations(resolve(manifestDir, manifest.migrations));
|
|
340
|
+
return { manifest, migrations };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Build a first immutable migration baseline. The caller owns the one-time
|
|
344
|
+
* write so the CLI can refuse replacement before touching the filesystem.
|
|
345
|
+
*/
|
|
346
|
+
export function baselineMigrationHistory(manifestDir) {
|
|
347
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
348
|
+
// Baseline only a schema that is valid as a complete generated project.
|
|
349
|
+
buildIr(manifest, migrations);
|
|
350
|
+
return {
|
|
351
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
352
|
+
content: serializeMigrationLock(buildMigrationLock(migrations)),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Fast CI check for migration history without emitting/query analysis. */
|
|
356
|
+
export function checkMigrationHistory(manifestDir) {
|
|
357
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
358
|
+
const current = buildMigrationLock(migrations);
|
|
359
|
+
validateMigrationLock(readMigrationLock(manifestDir), current);
|
|
360
|
+
buildIr(manifest, migrations);
|
|
361
|
+
const output = {
|
|
362
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
363
|
+
content: serializeMigrationLock(current),
|
|
364
|
+
};
|
|
365
|
+
if (readFileSync(output.path, 'utf8') !== output.content) {
|
|
366
|
+
return [
|
|
367
|
+
`${MIGRATION_LOCK_FILENAME}: new migrations are not locked — run generate and commit the updated lock`,
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
373
|
+
export function generate(manifestDir) {
|
|
374
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
375
|
+
const migrationLock = buildMigrationLock(migrations);
|
|
376
|
+
validateMigrationLock(readMigrationLock(manifestDir), migrationLock);
|
|
340
377
|
const ir = buildIr(manifest, migrations);
|
|
341
378
|
// §5/§12 naming: the emitter targets this run generates (keyword hazards
|
|
342
379
|
// are only real on targets that exist), and the per-table collision check
|
|
@@ -361,9 +398,12 @@ export function generate(manifestDir) {
|
|
|
361
398
|
const irJson = serializeIr(ir);
|
|
362
399
|
const hash = irHash(irJson);
|
|
363
400
|
const module = emitModule(ir, hash, manifest.naming);
|
|
401
|
+
const migrationLockJson = serializeMigrationLock(migrationLock);
|
|
402
|
+
const migrationLockPath = resolve(manifestDir, MIGRATION_LOCK_FILENAME);
|
|
364
403
|
const irPath = resolve(manifestDir, manifest.output.ir);
|
|
365
404
|
const modulePath = resolve(manifestDir, manifest.output.module);
|
|
366
405
|
const outputs = [
|
|
406
|
+
{ path: migrationLockPath, content: migrationLockJson },
|
|
367
407
|
{ path: irPath, content: irJson },
|
|
368
408
|
{ path: modulePath, content: module },
|
|
369
409
|
];
|
|
@@ -447,6 +487,8 @@ export function generate(manifestDir) {
|
|
|
447
487
|
queryIrJson,
|
|
448
488
|
queryHash,
|
|
449
489
|
module,
|
|
490
|
+
migrationLockJson,
|
|
491
|
+
migrationLockPath,
|
|
450
492
|
irPath,
|
|
451
493
|
modulePath,
|
|
452
494
|
queries: analyzedQueries,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/init.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { MANIFEST_FILENAME } from './manifest.js';
|
|
12
|
+
import { buildMigrationLock, MIGRATION_LOCK_FILENAME, serializeMigrationLock, } from './migration-lock.js';
|
|
12
13
|
const STARTER_MANIFEST = `{
|
|
13
14
|
"manifestVersion": 1,
|
|
14
15
|
"migrations": "./migrations",
|
|
@@ -64,6 +65,7 @@ export function initProject(dir) {
|
|
|
64
65
|
const manifestPath = join(root, MANIFEST_FILENAME);
|
|
65
66
|
const migrationDir = join(root, 'migrations', '0001_initial');
|
|
66
67
|
const migrationPath = join(migrationDir, 'up.sql');
|
|
68
|
+
const migrationLockPath = join(root, MIGRATION_LOCK_FILENAME);
|
|
67
69
|
const queriesDir = join(root, 'queries');
|
|
68
70
|
const queryPath = join(queriesDir, 'notes-in-list.sql');
|
|
69
71
|
if (existsSync(manifestPath)) {
|
|
@@ -73,6 +75,9 @@ export function initProject(dir) {
|
|
|
73
75
|
if (existsSync(migrationPath)) {
|
|
74
76
|
throw new InitError(`${migrationPath} already exists — refusing to overwrite.`);
|
|
75
77
|
}
|
|
78
|
+
if (existsSync(migrationLockPath)) {
|
|
79
|
+
throw new InitError(`${migrationLockPath} already exists — refusing to overwrite immutable migration history.`);
|
|
80
|
+
}
|
|
76
81
|
if (existsSync(queryPath)) {
|
|
77
82
|
throw new InitError(`${queryPath} already exists — refusing to overwrite.`);
|
|
78
83
|
}
|
|
@@ -80,6 +85,9 @@ export function initProject(dir) {
|
|
|
80
85
|
mkdirSync(queriesDir, { recursive: true });
|
|
81
86
|
writeFileSync(manifestPath, STARTER_MANIFEST, 'utf8');
|
|
82
87
|
writeFileSync(migrationPath, STARTER_MIGRATION, 'utf8');
|
|
88
|
+
writeFileSync(migrationLockPath, serializeMigrationLock(buildMigrationLock([{ name: '0001_initial', sql: STARTER_MIGRATION }])), 'utf8');
|
|
83
89
|
writeFileSync(queryPath, STARTER_QUERY, 'utf8');
|
|
84
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
written: [manifestPath, migrationPath, migrationLockPath, queryPath],
|
|
92
|
+
};
|
|
85
93
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MigrationInput } from './generate.js';
|
|
2
|
+
import type { IrColumnType } from './ir.js';
|
|
3
|
+
export declare const MIGRATION_LOCK_FILENAME = "syncular.migrations.lock.json";
|
|
4
|
+
export declare const MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
5
|
+
export interface MigrationLockColumn {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly type: IrColumnType;
|
|
8
|
+
readonly nullable: boolean;
|
|
9
|
+
readonly crdtType?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MigrationLockTable {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly primaryKey: string;
|
|
14
|
+
readonly columns: readonly MigrationLockColumn[];
|
|
15
|
+
}
|
|
16
|
+
export interface MigrationLockEntry {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly sha256: string;
|
|
19
|
+
readonly tables: readonly MigrationLockTable[];
|
|
20
|
+
}
|
|
21
|
+
export interface MigrationLock {
|
|
22
|
+
readonly formatVersion: typeof MIGRATION_LOCK_FORMAT_VERSION;
|
|
23
|
+
readonly migrations: readonly MigrationLockEntry[];
|
|
24
|
+
}
|
|
25
|
+
/** Build the exact lock document for a complete migration sequence. */
|
|
26
|
+
export declare function buildMigrationLock(migrations: readonly MigrationInput[]): MigrationLock;
|
|
27
|
+
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
28
|
+
export declare function serializeMigrationLock(lock: MigrationLock): string;
|
|
29
|
+
/** Parse and structurally validate a committed lock document. */
|
|
30
|
+
export declare function parseMigrationLock(source: string): MigrationLock;
|
|
31
|
+
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
32
|
+
export declare function readMigrationLock(manifestDir: string): MigrationLock;
|
|
33
|
+
/**
|
|
34
|
+
* Validate the committed history as an exact prefix of the current sequence.
|
|
35
|
+
* New migrations may be appended; existing names and bytes may never change.
|
|
36
|
+
*/
|
|
37
|
+
export declare function validateMigrationLock(locked: MigrationLock, current: MigrationLock): void;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable application-migration history.
|
|
3
|
+
*
|
|
4
|
+
* `syncular.migrations.lock.json` is a version-controlled baseline of every
|
|
5
|
+
* migration that has been accepted so far. Existing entries are immutable;
|
|
6
|
+
* generation may only append newly named migrations. The per-migration schema
|
|
7
|
+
* snapshot exists solely to make checksum drift actionable without retaining
|
|
8
|
+
* SQL text, row data, or filesystem paths in diagnostics.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { TypegenError } from './errors.js';
|
|
14
|
+
import { applyMigrationSql } from './sql.js';
|
|
15
|
+
export const MIGRATION_LOCK_FILENAME = 'syncular.migrations.lock.json';
|
|
16
|
+
export const MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
17
|
+
function normalizedSql(sql) {
|
|
18
|
+
return sql.replace(/\r\n?/g, '\n');
|
|
19
|
+
}
|
|
20
|
+
function checksum(sql) {
|
|
21
|
+
return createHash('sha256').update(normalizedSql(sql), 'utf8').digest('hex');
|
|
22
|
+
}
|
|
23
|
+
function snapshotColumn(column) {
|
|
24
|
+
return {
|
|
25
|
+
name: column.name,
|
|
26
|
+
type: column.type,
|
|
27
|
+
nullable: column.nullable,
|
|
28
|
+
...(column.crdtType === undefined ? {} : { crdtType: column.crdtType }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function snapshotTables(tables) {
|
|
32
|
+
return [...tables.values()]
|
|
33
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
34
|
+
.map((table) => ({
|
|
35
|
+
name: table.name,
|
|
36
|
+
primaryKey: table.primaryKey,
|
|
37
|
+
columns: table.columns.map(snapshotColumn),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Build the exact lock document for a complete migration sequence. */
|
|
41
|
+
export function buildMigrationLock(migrations) {
|
|
42
|
+
const tables = new Map();
|
|
43
|
+
const droppedTables = new Set();
|
|
44
|
+
const entries = [];
|
|
45
|
+
for (const migration of migrations) {
|
|
46
|
+
applyMigrationSql(tables, migration.sql, `${migration.name}/up.sql`, droppedTables);
|
|
47
|
+
entries.push({
|
|
48
|
+
name: migration.name,
|
|
49
|
+
sha256: checksum(migration.sql),
|
|
50
|
+
tables: snapshotTables(tables),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { formatVersion: MIGRATION_LOCK_FORMAT_VERSION, migrations: entries };
|
|
54
|
+
}
|
|
55
|
+
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
56
|
+
export function serializeMigrationLock(lock) {
|
|
57
|
+
return `${JSON.stringify(lock, null, 2)}\n`;
|
|
58
|
+
}
|
|
59
|
+
function isRecord(value) {
|
|
60
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
function failLock(message) {
|
|
63
|
+
throw new TypegenError(MIGRATION_LOCK_FILENAME, message);
|
|
64
|
+
}
|
|
65
|
+
const COLUMN_TYPES = new Set([
|
|
66
|
+
'string',
|
|
67
|
+
'integer',
|
|
68
|
+
'float',
|
|
69
|
+
'boolean',
|
|
70
|
+
'json',
|
|
71
|
+
'bytes',
|
|
72
|
+
'blob_ref',
|
|
73
|
+
'crdt',
|
|
74
|
+
]);
|
|
75
|
+
function parseColumn(value, context) {
|
|
76
|
+
if (!isRecord(value))
|
|
77
|
+
failLock(`${context} must be an object`);
|
|
78
|
+
const { name, type, nullable, crdtType } = value;
|
|
79
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
80
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof type !== 'string' || !COLUMN_TYPES.has(type)) {
|
|
83
|
+
failLock(`${context}.type is not a supported Syncular column type`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof nullable !== 'boolean') {
|
|
86
|
+
failLock(`${context}.nullable must be boolean`);
|
|
87
|
+
}
|
|
88
|
+
if (crdtType !== undefined && typeof crdtType !== 'string') {
|
|
89
|
+
failLock(`${context}.crdtType must be a string when present`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
type: type,
|
|
94
|
+
nullable,
|
|
95
|
+
...(crdtType === undefined ? {} : { crdtType }),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function parseTable(value, context) {
|
|
99
|
+
if (!isRecord(value))
|
|
100
|
+
failLock(`${context} must be an object`);
|
|
101
|
+
const { name, primaryKey, columns } = value;
|
|
102
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
103
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
104
|
+
}
|
|
105
|
+
if (typeof primaryKey !== 'string' || primaryKey.length === 0) {
|
|
106
|
+
failLock(`${context}.primaryKey must be a non-empty string`);
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
109
|
+
failLock(`${context}.columns must be a non-empty array`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
primaryKey,
|
|
114
|
+
columns: columns.map((column, index) => parseColumn(column, `${context}.columns[${index}]`)),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function parseEntry(value, index) {
|
|
118
|
+
const context = `migrations[${index}]`;
|
|
119
|
+
if (!isRecord(value))
|
|
120
|
+
failLock(`${context} must be an object`);
|
|
121
|
+
const { name, sha256, tables } = value;
|
|
122
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
123
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
126
|
+
failLock(`${context}.sha256 must be a lowercase SHA-256 digest`);
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(tables))
|
|
129
|
+
failLock(`${context}.tables must be an array`);
|
|
130
|
+
return {
|
|
131
|
+
name,
|
|
132
|
+
sha256,
|
|
133
|
+
tables: tables.map((table, tableIndex) => parseTable(table, `${context}.tables[${tableIndex}]`)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Parse and structurally validate a committed lock document. */
|
|
137
|
+
export function parseMigrationLock(source) {
|
|
138
|
+
let raw;
|
|
139
|
+
try {
|
|
140
|
+
raw = JSON.parse(source);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
failLock('invalid JSON');
|
|
144
|
+
}
|
|
145
|
+
if (!isRecord(raw))
|
|
146
|
+
failLock('root must be an object');
|
|
147
|
+
if (raw.formatVersion !== MIGRATION_LOCK_FORMAT_VERSION) {
|
|
148
|
+
failLock(`formatVersion must be ${MIGRATION_LOCK_FORMAT_VERSION}, got ${JSON.stringify(raw.formatVersion)}`);
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(raw.migrations) || raw.migrations.length === 0) {
|
|
151
|
+
failLock('migrations must be a non-empty array');
|
|
152
|
+
}
|
|
153
|
+
const migrations = raw.migrations.map(parseEntry);
|
|
154
|
+
const names = new Set();
|
|
155
|
+
for (const migration of migrations) {
|
|
156
|
+
if (names.has(migration.name)) {
|
|
157
|
+
failLock(`migration ${JSON.stringify(migration.name)} appears twice`);
|
|
158
|
+
}
|
|
159
|
+
names.add(migration.name);
|
|
160
|
+
}
|
|
161
|
+
return { formatVersion: MIGRATION_LOCK_FORMAT_VERSION, migrations };
|
|
162
|
+
}
|
|
163
|
+
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
164
|
+
export function readMigrationLock(manifestDir) {
|
|
165
|
+
const path = resolve(manifestDir, MIGRATION_LOCK_FILENAME);
|
|
166
|
+
if (!existsSync(path)) {
|
|
167
|
+
failLock('missing — run `syncular migrations baseline --manifest-dir .` once before deployment, then commit the file');
|
|
168
|
+
}
|
|
169
|
+
return parseMigrationLock(readFileSync(path, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
function describeColumnDifference(tableName, locked, current) {
|
|
172
|
+
const length = Math.max(locked.length, current.length);
|
|
173
|
+
for (let index = 0; index < length; index++) {
|
|
174
|
+
const before = locked[index];
|
|
175
|
+
const after = current[index];
|
|
176
|
+
const position = index + 1;
|
|
177
|
+
if (before === undefined && after !== undefined) {
|
|
178
|
+
return `table ${JSON.stringify(tableName)}, column ${position} ${JSON.stringify(after.name)} was added (${after.type}, ${after.nullable ? 'nullable' : 'required'})`;
|
|
179
|
+
}
|
|
180
|
+
if (before !== undefined && after === undefined) {
|
|
181
|
+
return `table ${JSON.stringify(tableName)}, column ${position} ${JSON.stringify(before.name)} was removed`;
|
|
182
|
+
}
|
|
183
|
+
if (before === undefined || after === undefined)
|
|
184
|
+
continue;
|
|
185
|
+
if (before.name !== after.name) {
|
|
186
|
+
const oldStillPresent = current
|
|
187
|
+
.slice(index + 1)
|
|
188
|
+
.some((column) => column.name === before.name);
|
|
189
|
+
const newWasPresent = locked
|
|
190
|
+
.slice(index + 1)
|
|
191
|
+
.some((column) => column.name === after.name);
|
|
192
|
+
const action = oldStillPresent
|
|
193
|
+
? `${JSON.stringify(after.name)} was inserted before locked ${JSON.stringify(before.name)}`
|
|
194
|
+
: newWasPresent
|
|
195
|
+
? `locked ${JSON.stringify(before.name)} was removed or reordered before ${JSON.stringify(after.name)}`
|
|
196
|
+
: `locked ${JSON.stringify(before.name)} became ${JSON.stringify(after.name)}`;
|
|
197
|
+
return `table ${JSON.stringify(tableName)}, column ${position}: ${action}`;
|
|
198
|
+
}
|
|
199
|
+
if (before.type !== after.type) {
|
|
200
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed type from ${before.type} to ${after.type}`;
|
|
201
|
+
}
|
|
202
|
+
if (before.nullable !== after.nullable) {
|
|
203
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed nullability from ${before.nullable ? 'nullable' : 'required'} to ${after.nullable ? 'nullable' : 'required'}`;
|
|
204
|
+
}
|
|
205
|
+
if (before.crdtType !== after.crdtType) {
|
|
206
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed CRDT type`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
function firstSchemaDifference(locked, current) {
|
|
212
|
+
const length = Math.max(locked.tables.length, current.tables.length);
|
|
213
|
+
for (let index = 0; index < length; index++) {
|
|
214
|
+
const before = locked.tables[index];
|
|
215
|
+
const after = current.tables[index];
|
|
216
|
+
if (before === undefined && after !== undefined) {
|
|
217
|
+
return `table ${JSON.stringify(after.name)} was added`;
|
|
218
|
+
}
|
|
219
|
+
if (before !== undefined && after === undefined) {
|
|
220
|
+
return `table ${JSON.stringify(before.name)} was removed`;
|
|
221
|
+
}
|
|
222
|
+
if (before === undefined || after === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
if (before.name !== after.name) {
|
|
225
|
+
return `locked table ${JSON.stringify(before.name)} became or moved behind ${JSON.stringify(after.name)}`;
|
|
226
|
+
}
|
|
227
|
+
if (before.primaryKey !== after.primaryKey) {
|
|
228
|
+
return `table ${JSON.stringify(before.name)} changed primary key from ${JSON.stringify(before.primaryKey)} to ${JSON.stringify(after.primaryKey)}`;
|
|
229
|
+
}
|
|
230
|
+
const column = describeColumnDifference(before.name, before.columns, after.columns);
|
|
231
|
+
if (column !== undefined)
|
|
232
|
+
return column;
|
|
233
|
+
}
|
|
234
|
+
return 'schema shape is unchanged; SQL text, defaults, comments, indexes, or another non-column statement changed';
|
|
235
|
+
}
|
|
236
|
+
const REPAIR_HINT = 'deployed migrations are immutable; restore the locked migration and append a new migration (added columns must be nullable)';
|
|
237
|
+
/**
|
|
238
|
+
* Validate the committed history as an exact prefix of the current sequence.
|
|
239
|
+
* New migrations may be appended; existing names and bytes may never change.
|
|
240
|
+
*/
|
|
241
|
+
export function validateMigrationLock(locked, current) {
|
|
242
|
+
for (let index = 0; index < locked.migrations.length; index++) {
|
|
243
|
+
const before = locked.migrations[index];
|
|
244
|
+
const after = current.migrations[index];
|
|
245
|
+
if (after === undefined) {
|
|
246
|
+
failLock(`history drift at migration ${JSON.stringify(before.name)}: the locked migration is missing; ${REPAIR_HINT}`);
|
|
247
|
+
}
|
|
248
|
+
if (before.name !== after.name) {
|
|
249
|
+
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
|
+
}
|
|
251
|
+
if (before.sha256 !== after.sha256) {
|
|
252
|
+
failLock(`history drift in migration ${JSON.stringify(before.name)}: checksum changed; first schema difference: ${firstSchemaDifference(before, after)}; ${REPAIR_HINT}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.21",
|
|
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.21",
|
|
52
|
+
"@syncular/server": "0.15.21"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/cli.ts
CHANGED
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
|
|
19
19
|
import { resolve } from 'node:path';
|
|
20
20
|
import { formatSyql } from './fmt';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
baselineMigrationHistory,
|
|
23
|
+
checkMigrationHistory,
|
|
24
|
+
checkOutputs,
|
|
25
|
+
generate,
|
|
26
|
+
loadQueries,
|
|
27
|
+
writeOutputs,
|
|
28
|
+
} from './generate';
|
|
22
29
|
import { InitError, initProject } from './init';
|
|
23
30
|
import { runLspStdio } from './lsp';
|
|
24
31
|
import { MANIFEST_FILENAME, parseManifest } from './manifest';
|
|
@@ -31,6 +38,7 @@ const USAGE = `usage: syncular <command> [options]
|
|
|
31
38
|
|
|
32
39
|
commands:
|
|
33
40
|
generate build the IR + typed module from syncular.json + migrations/
|
|
41
|
+
migrations baseline or check immutable deployed migration history
|
|
34
42
|
fmt format .syql query files canonically (one style, no options)
|
|
35
43
|
lsp run the .syql language server over stdio (editor tooling)
|
|
36
44
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
@@ -48,6 +56,11 @@ fmt options:
|
|
|
48
56
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
49
57
|
--check fail unless every file is already canonical
|
|
50
58
|
|
|
59
|
+
migrations options:
|
|
60
|
+
baseline create the first immutable history lock; refuses overwrite
|
|
61
|
+
check validate the committed history without generating outputs
|
|
62
|
+
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
63
|
+
|
|
51
64
|
init options:
|
|
52
65
|
--manifest-dir <dir> directory to scaffold into (default: .)`;
|
|
53
66
|
|
|
@@ -317,6 +330,40 @@ function runFmt(argv: readonly string[]): void {
|
|
|
317
330
|
if (drifted === 0) console.log('all .syql files canonical');
|
|
318
331
|
}
|
|
319
332
|
|
|
333
|
+
function runMigrations(argv: readonly string[]): void {
|
|
334
|
+
const action = argv[0];
|
|
335
|
+
if (action !== 'baseline' && action !== 'check') {
|
|
336
|
+
fail(`migrations requires 'baseline' or 'check'\n${USAGE}`);
|
|
337
|
+
}
|
|
338
|
+
const manifestDir = parseManifestDir(argv.slice(1));
|
|
339
|
+
if (action === 'baseline') {
|
|
340
|
+
let output: ReturnType<typeof baselineMigrationHistory>;
|
|
341
|
+
try {
|
|
342
|
+
output = baselineMigrationHistory(manifestDir);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
fail(friendlyGenerateError(error));
|
|
345
|
+
}
|
|
346
|
+
if (existsSync(output.path)) {
|
|
347
|
+
fail(
|
|
348
|
+
`${output.path} already exists — refusing to replace immutable migration history`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
writeFileSync(output.path, output.content, 'utf8');
|
|
352
|
+
console.log(`wrote ${output.path}`);
|
|
353
|
+
console.log('commit this migration baseline before deployment');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let drift: string[];
|
|
358
|
+
try {
|
|
359
|
+
drift = checkMigrationHistory(manifestDir);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
fail(friendlyGenerateError(error));
|
|
362
|
+
}
|
|
363
|
+
if (drift.length > 0) fail(drift.join('\n'));
|
|
364
|
+
console.log('migration history is locked and unchanged');
|
|
365
|
+
}
|
|
366
|
+
|
|
320
367
|
export function runCli(argv: readonly string[]): void {
|
|
321
368
|
const command = argv[0];
|
|
322
369
|
if (command === 'generate') {
|
|
@@ -336,6 +383,10 @@ export function runCli(argv: readonly string[]): void {
|
|
|
336
383
|
runFmt(argv.slice(1));
|
|
337
384
|
return;
|
|
338
385
|
}
|
|
386
|
+
if (command === 'migrations') {
|
|
387
|
+
runMigrations(argv.slice(1));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
339
390
|
if (command === 'lsp') {
|
|
340
391
|
void runLspStdio();
|
|
341
392
|
return;
|
package/src/generate.ts
CHANGED
|
@@ -41,6 +41,13 @@ import {
|
|
|
41
41
|
type ManifestScopeSpec,
|
|
42
42
|
parseManifest,
|
|
43
43
|
} from './manifest';
|
|
44
|
+
import {
|
|
45
|
+
buildMigrationLock,
|
|
46
|
+
MIGRATION_LOCK_FILENAME,
|
|
47
|
+
readMigrationLock,
|
|
48
|
+
serializeMigrationLock,
|
|
49
|
+
validateMigrationLock,
|
|
50
|
+
} from './migration-lock';
|
|
44
51
|
import { buildNamingMap, type NamingTarget } from './naming';
|
|
45
52
|
import {
|
|
46
53
|
type AnalyzedQuery,
|
|
@@ -521,6 +528,9 @@ export interface GenerateResult {
|
|
|
521
528
|
readonly queryHash: string;
|
|
522
529
|
/** Generated TS module source (the exact bytes of the `.ts` output). */
|
|
523
530
|
readonly module: string;
|
|
531
|
+
/** Immutable application-migration history baseline. */
|
|
532
|
+
readonly migrationLockJson: string;
|
|
533
|
+
readonly migrationLockPath: string;
|
|
524
534
|
readonly irPath: string;
|
|
525
535
|
readonly modulePath: string;
|
|
526
536
|
/** Analyzed named queries (empty when no `queries/` files / no query output). */
|
|
@@ -530,8 +540,12 @@ export interface GenerateResult {
|
|
|
530
540
|
readonly outputs: readonly GeneratedOutput[];
|
|
531
541
|
}
|
|
532
542
|
|
|
533
|
-
|
|
534
|
-
|
|
543
|
+
interface ProjectInputs {
|
|
544
|
+
readonly manifest: Manifest;
|
|
545
|
+
readonly migrations: readonly MigrationInput[];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function loadProjectInputs(manifestDir: string): ProjectInputs {
|
|
535
549
|
const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
|
|
536
550
|
if (!existsSync(manifestPath)) {
|
|
537
551
|
throw new TypegenError(manifestPath, 'manifest not found');
|
|
@@ -547,6 +561,46 @@ export function generate(manifestDir: string): GenerateResult {
|
|
|
547
561
|
}
|
|
548
562
|
const manifest = parseManifest(raw);
|
|
549
563
|
const migrations = loadMigrations(resolve(manifestDir, manifest.migrations));
|
|
564
|
+
return { manifest, migrations };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Build a first immutable migration baseline. The caller owns the one-time
|
|
569
|
+
* write so the CLI can refuse replacement before touching the filesystem.
|
|
570
|
+
*/
|
|
571
|
+
export function baselineMigrationHistory(manifestDir: string): GeneratedOutput {
|
|
572
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
573
|
+
// Baseline only a schema that is valid as a complete generated project.
|
|
574
|
+
buildIr(manifest, migrations);
|
|
575
|
+
return {
|
|
576
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
577
|
+
content: serializeMigrationLock(buildMigrationLock(migrations)),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Fast CI check for migration history without emitting/query analysis. */
|
|
582
|
+
export function checkMigrationHistory(manifestDir: string): string[] {
|
|
583
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
584
|
+
const current = buildMigrationLock(migrations);
|
|
585
|
+
validateMigrationLock(readMigrationLock(manifestDir), current);
|
|
586
|
+
buildIr(manifest, migrations);
|
|
587
|
+
const output = {
|
|
588
|
+
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
589
|
+
content: serializeMigrationLock(current),
|
|
590
|
+
};
|
|
591
|
+
if (readFileSync(output.path, 'utf8') !== output.content) {
|
|
592
|
+
return [
|
|
593
|
+
`${MIGRATION_LOCK_FILENAME}: new migrations are not locked — run generate and commit the updated lock`,
|
|
594
|
+
];
|
|
595
|
+
}
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
600
|
+
export function generate(manifestDir: string): GenerateResult {
|
|
601
|
+
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
602
|
+
const migrationLock = buildMigrationLock(migrations);
|
|
603
|
+
validateMigrationLock(readMigrationLock(manifestDir), migrationLock);
|
|
550
604
|
const ir = buildIr(manifest, migrations);
|
|
551
605
|
|
|
552
606
|
// §5/§12 naming: the emitter targets this run generates (keyword hazards
|
|
@@ -575,9 +629,12 @@ export function generate(manifestDir: string): GenerateResult {
|
|
|
575
629
|
const irJson = serializeIr(ir);
|
|
576
630
|
const hash = irHash(irJson);
|
|
577
631
|
const module = emitModule(ir, hash, manifest.naming);
|
|
632
|
+
const migrationLockJson = serializeMigrationLock(migrationLock);
|
|
633
|
+
const migrationLockPath = resolve(manifestDir, MIGRATION_LOCK_FILENAME);
|
|
578
634
|
const irPath = resolve(manifestDir, manifest.output.ir);
|
|
579
635
|
const modulePath = resolve(manifestDir, manifest.output.module);
|
|
580
636
|
const outputs: GeneratedOutput[] = [
|
|
637
|
+
{ path: migrationLockPath, content: migrationLockJson },
|
|
581
638
|
{ path: irPath, content: irJson },
|
|
582
639
|
{ path: modulePath, content: module },
|
|
583
640
|
];
|
|
@@ -700,6 +757,8 @@ export function generate(manifestDir: string): GenerateResult {
|
|
|
700
757
|
queryIrJson,
|
|
701
758
|
queryHash,
|
|
702
759
|
module,
|
|
760
|
+
migrationLockJson,
|
|
761
|
+
migrationLockPath,
|
|
703
762
|
irPath,
|
|
704
763
|
modulePath,
|
|
705
764
|
queries: analyzedQueries,
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { MANIFEST_FILENAME } from './manifest';
|
|
12
|
+
import {
|
|
13
|
+
buildMigrationLock,
|
|
14
|
+
MIGRATION_LOCK_FILENAME,
|
|
15
|
+
serializeMigrationLock,
|
|
16
|
+
} from './migration-lock';
|
|
12
17
|
|
|
13
18
|
const STARTER_MANIFEST = `{
|
|
14
19
|
"manifestVersion": 1,
|
|
@@ -73,6 +78,7 @@ export function initProject(dir: string): InitResult {
|
|
|
73
78
|
const manifestPath = join(root, MANIFEST_FILENAME);
|
|
74
79
|
const migrationDir = join(root, 'migrations', '0001_initial');
|
|
75
80
|
const migrationPath = join(migrationDir, 'up.sql');
|
|
81
|
+
const migrationLockPath = join(root, MIGRATION_LOCK_FILENAME);
|
|
76
82
|
const queriesDir = join(root, 'queries');
|
|
77
83
|
const queryPath = join(queriesDir, 'notes-in-list.sql');
|
|
78
84
|
|
|
@@ -87,6 +93,11 @@ export function initProject(dir: string): InitResult {
|
|
|
87
93
|
`${migrationPath} already exists — refusing to overwrite.`,
|
|
88
94
|
);
|
|
89
95
|
}
|
|
96
|
+
if (existsSync(migrationLockPath)) {
|
|
97
|
+
throw new InitError(
|
|
98
|
+
`${migrationLockPath} already exists — refusing to overwrite immutable migration history.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
90
101
|
if (existsSync(queryPath)) {
|
|
91
102
|
throw new InitError(`${queryPath} already exists — refusing to overwrite.`);
|
|
92
103
|
}
|
|
@@ -95,6 +106,15 @@ export function initProject(dir: string): InitResult {
|
|
|
95
106
|
mkdirSync(queriesDir, { recursive: true });
|
|
96
107
|
writeFileSync(manifestPath, STARTER_MANIFEST, 'utf8');
|
|
97
108
|
writeFileSync(migrationPath, STARTER_MIGRATION, 'utf8');
|
|
109
|
+
writeFileSync(
|
|
110
|
+
migrationLockPath,
|
|
111
|
+
serializeMigrationLock(
|
|
112
|
+
buildMigrationLock([{ name: '0001_initial', sql: STARTER_MIGRATION }]),
|
|
113
|
+
),
|
|
114
|
+
'utf8',
|
|
115
|
+
);
|
|
98
116
|
writeFileSync(queryPath, STARTER_QUERY, 'utf8');
|
|
99
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
written: [manifestPath, migrationPath, migrationLockPath, queryPath],
|
|
119
|
+
};
|
|
100
120
|
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable application-migration history.
|
|
3
|
+
*
|
|
4
|
+
* `syncular.migrations.lock.json` is a version-controlled baseline of every
|
|
5
|
+
* migration that has been accepted so far. Existing entries are immutable;
|
|
6
|
+
* generation may only append newly named migrations. The per-migration schema
|
|
7
|
+
* snapshot exists solely to make checksum drift actionable without retaining
|
|
8
|
+
* SQL text, row data, or filesystem paths in diagnostics.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { TypegenError } from './errors';
|
|
14
|
+
import type { MigrationInput } from './generate';
|
|
15
|
+
import type { IrColumn, IrColumnType } from './ir';
|
|
16
|
+
import { applyMigrationSql, type ParsedTable } from './sql';
|
|
17
|
+
|
|
18
|
+
export const MIGRATION_LOCK_FILENAME = 'syncular.migrations.lock.json';
|
|
19
|
+
export const MIGRATION_LOCK_FORMAT_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export interface MigrationLockColumn {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly type: IrColumnType;
|
|
24
|
+
readonly nullable: boolean;
|
|
25
|
+
readonly crdtType?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MigrationLockTable {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly primaryKey: string;
|
|
31
|
+
readonly columns: readonly MigrationLockColumn[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MigrationLockEntry {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly sha256: string;
|
|
37
|
+
readonly tables: readonly MigrationLockTable[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MigrationLock {
|
|
41
|
+
readonly formatVersion: typeof MIGRATION_LOCK_FORMAT_VERSION;
|
|
42
|
+
readonly migrations: readonly MigrationLockEntry[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizedSql(sql: string): string {
|
|
46
|
+
return sql.replace(/\r\n?/g, '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checksum(sql: string): string {
|
|
50
|
+
return createHash('sha256').update(normalizedSql(sql), 'utf8').digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function snapshotColumn(column: IrColumn): MigrationLockColumn {
|
|
54
|
+
return {
|
|
55
|
+
name: column.name,
|
|
56
|
+
type: column.type,
|
|
57
|
+
nullable: column.nullable,
|
|
58
|
+
...(column.crdtType === undefined ? {} : { crdtType: column.crdtType }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function snapshotTables(
|
|
63
|
+
tables: ReadonlyMap<string, ParsedTable>,
|
|
64
|
+
): MigrationLockTable[] {
|
|
65
|
+
return [...tables.values()]
|
|
66
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
67
|
+
.map((table) => ({
|
|
68
|
+
name: table.name,
|
|
69
|
+
primaryKey: table.primaryKey,
|
|
70
|
+
columns: table.columns.map(snapshotColumn),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the exact lock document for a complete migration sequence. */
|
|
75
|
+
export function buildMigrationLock(
|
|
76
|
+
migrations: readonly MigrationInput[],
|
|
77
|
+
): MigrationLock {
|
|
78
|
+
const tables = new Map<string, ParsedTable>();
|
|
79
|
+
const droppedTables = new Set<string>();
|
|
80
|
+
const entries: MigrationLockEntry[] = [];
|
|
81
|
+
for (const migration of migrations) {
|
|
82
|
+
applyMigrationSql(
|
|
83
|
+
tables,
|
|
84
|
+
migration.sql,
|
|
85
|
+
`${migration.name}/up.sql`,
|
|
86
|
+
droppedTables,
|
|
87
|
+
);
|
|
88
|
+
entries.push({
|
|
89
|
+
name: migration.name,
|
|
90
|
+
sha256: checksum(migration.sql),
|
|
91
|
+
tables: snapshotTables(tables),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { formatVersion: MIGRATION_LOCK_FORMAT_VERSION, migrations: entries };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
98
|
+
export function serializeMigrationLock(lock: MigrationLock): string {
|
|
99
|
+
return `${JSON.stringify(lock, null, 2)}\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function failLock(message: string): never {
|
|
107
|
+
throw new TypegenError(MIGRATION_LOCK_FILENAME, message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const COLUMN_TYPES = new Set<IrColumnType>([
|
|
111
|
+
'string',
|
|
112
|
+
'integer',
|
|
113
|
+
'float',
|
|
114
|
+
'boolean',
|
|
115
|
+
'json',
|
|
116
|
+
'bytes',
|
|
117
|
+
'blob_ref',
|
|
118
|
+
'crdt',
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
function parseColumn(value: unknown, context: string): MigrationLockColumn {
|
|
122
|
+
if (!isRecord(value)) failLock(`${context} must be an object`);
|
|
123
|
+
const { name, type, nullable, crdtType } = value;
|
|
124
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
125
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
126
|
+
}
|
|
127
|
+
if (typeof type !== 'string' || !COLUMN_TYPES.has(type as IrColumnType)) {
|
|
128
|
+
failLock(`${context}.type is not a supported Syncular column type`);
|
|
129
|
+
}
|
|
130
|
+
if (typeof nullable !== 'boolean') {
|
|
131
|
+
failLock(`${context}.nullable must be boolean`);
|
|
132
|
+
}
|
|
133
|
+
if (crdtType !== undefined && typeof crdtType !== 'string') {
|
|
134
|
+
failLock(`${context}.crdtType must be a string when present`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
name,
|
|
138
|
+
type: type as IrColumnType,
|
|
139
|
+
nullable,
|
|
140
|
+
...(crdtType === undefined ? {} : { crdtType }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseTable(value: unknown, context: string): MigrationLockTable {
|
|
145
|
+
if (!isRecord(value)) failLock(`${context} must be an object`);
|
|
146
|
+
const { name, primaryKey, columns } = value;
|
|
147
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
148
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof primaryKey !== 'string' || primaryKey.length === 0) {
|
|
151
|
+
failLock(`${context}.primaryKey must be a non-empty string`);
|
|
152
|
+
}
|
|
153
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
154
|
+
failLock(`${context}.columns must be a non-empty array`);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
primaryKey,
|
|
159
|
+
columns: columns.map((column, index) =>
|
|
160
|
+
parseColumn(column, `${context}.columns[${index}]`),
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseEntry(value: unknown, index: number): MigrationLockEntry {
|
|
166
|
+
const context = `migrations[${index}]`;
|
|
167
|
+
if (!isRecord(value)) failLock(`${context} must be an object`);
|
|
168
|
+
const { name, sha256, tables } = value;
|
|
169
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
170
|
+
failLock(`${context}.name must be a non-empty string`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
173
|
+
failLock(`${context}.sha256 must be a lowercase SHA-256 digest`);
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(tables)) failLock(`${context}.tables must be an array`);
|
|
176
|
+
return {
|
|
177
|
+
name,
|
|
178
|
+
sha256,
|
|
179
|
+
tables: tables.map((table, tableIndex) =>
|
|
180
|
+
parseTable(table, `${context}.tables[${tableIndex}]`),
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Parse and structurally validate a committed lock document. */
|
|
186
|
+
export function parseMigrationLock(source: string): MigrationLock {
|
|
187
|
+
let raw: unknown;
|
|
188
|
+
try {
|
|
189
|
+
raw = JSON.parse(source);
|
|
190
|
+
} catch {
|
|
191
|
+
failLock('invalid JSON');
|
|
192
|
+
}
|
|
193
|
+
if (!isRecord(raw)) failLock('root must be an object');
|
|
194
|
+
if (raw.formatVersion !== MIGRATION_LOCK_FORMAT_VERSION) {
|
|
195
|
+
failLock(
|
|
196
|
+
`formatVersion must be ${MIGRATION_LOCK_FORMAT_VERSION}, got ${JSON.stringify(raw.formatVersion)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (!Array.isArray(raw.migrations) || raw.migrations.length === 0) {
|
|
200
|
+
failLock('migrations must be a non-empty array');
|
|
201
|
+
}
|
|
202
|
+
const migrations = raw.migrations.map(parseEntry);
|
|
203
|
+
const names = new Set<string>();
|
|
204
|
+
for (const migration of migrations) {
|
|
205
|
+
if (names.has(migration.name)) {
|
|
206
|
+
failLock(`migration ${JSON.stringify(migration.name)} appears twice`);
|
|
207
|
+
}
|
|
208
|
+
names.add(migration.name);
|
|
209
|
+
}
|
|
210
|
+
return { formatVersion: MIGRATION_LOCK_FORMAT_VERSION, migrations };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Read the version-controlled baseline without exposing its absolute path. */
|
|
214
|
+
export function readMigrationLock(manifestDir: string): MigrationLock {
|
|
215
|
+
const path = resolve(manifestDir, MIGRATION_LOCK_FILENAME);
|
|
216
|
+
if (!existsSync(path)) {
|
|
217
|
+
failLock(
|
|
218
|
+
'missing — run `syncular migrations baseline --manifest-dir .` once before deployment, then commit the file',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return parseMigrationLock(readFileSync(path, 'utf8'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function describeColumnDifference(
|
|
225
|
+
tableName: string,
|
|
226
|
+
locked: readonly MigrationLockColumn[],
|
|
227
|
+
current: readonly MigrationLockColumn[],
|
|
228
|
+
): string | undefined {
|
|
229
|
+
const length = Math.max(locked.length, current.length);
|
|
230
|
+
for (let index = 0; index < length; index++) {
|
|
231
|
+
const before = locked[index];
|
|
232
|
+
const after = current[index];
|
|
233
|
+
const position = index + 1;
|
|
234
|
+
if (before === undefined && after !== undefined) {
|
|
235
|
+
return `table ${JSON.stringify(tableName)}, column ${position} ${JSON.stringify(after.name)} was added (${after.type}, ${after.nullable ? 'nullable' : 'required'})`;
|
|
236
|
+
}
|
|
237
|
+
if (before !== undefined && after === undefined) {
|
|
238
|
+
return `table ${JSON.stringify(tableName)}, column ${position} ${JSON.stringify(before.name)} was removed`;
|
|
239
|
+
}
|
|
240
|
+
if (before === undefined || after === undefined) continue;
|
|
241
|
+
if (before.name !== after.name) {
|
|
242
|
+
const oldStillPresent = current
|
|
243
|
+
.slice(index + 1)
|
|
244
|
+
.some((column) => column.name === before.name);
|
|
245
|
+
const newWasPresent = locked
|
|
246
|
+
.slice(index + 1)
|
|
247
|
+
.some((column) => column.name === after.name);
|
|
248
|
+
const action = oldStillPresent
|
|
249
|
+
? `${JSON.stringify(after.name)} was inserted before locked ${JSON.stringify(before.name)}`
|
|
250
|
+
: newWasPresent
|
|
251
|
+
? `locked ${JSON.stringify(before.name)} was removed or reordered before ${JSON.stringify(after.name)}`
|
|
252
|
+
: `locked ${JSON.stringify(before.name)} became ${JSON.stringify(after.name)}`;
|
|
253
|
+
return `table ${JSON.stringify(tableName)}, column ${position}: ${action}`;
|
|
254
|
+
}
|
|
255
|
+
if (before.type !== after.type) {
|
|
256
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed type from ${before.type} to ${after.type}`;
|
|
257
|
+
}
|
|
258
|
+
if (before.nullable !== after.nullable) {
|
|
259
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed nullability from ${before.nullable ? 'nullable' : 'required'} to ${after.nullable ? 'nullable' : 'required'}`;
|
|
260
|
+
}
|
|
261
|
+
if (before.crdtType !== after.crdtType) {
|
|
262
|
+
return `table ${JSON.stringify(tableName)}, column ${JSON.stringify(before.name)} changed CRDT type`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function firstSchemaDifference(
|
|
269
|
+
locked: MigrationLockEntry,
|
|
270
|
+
current: MigrationLockEntry,
|
|
271
|
+
): string {
|
|
272
|
+
const length = Math.max(locked.tables.length, current.tables.length);
|
|
273
|
+
for (let index = 0; index < length; index++) {
|
|
274
|
+
const before = locked.tables[index];
|
|
275
|
+
const after = current.tables[index];
|
|
276
|
+
if (before === undefined && after !== undefined) {
|
|
277
|
+
return `table ${JSON.stringify(after.name)} was added`;
|
|
278
|
+
}
|
|
279
|
+
if (before !== undefined && after === undefined) {
|
|
280
|
+
return `table ${JSON.stringify(before.name)} was removed`;
|
|
281
|
+
}
|
|
282
|
+
if (before === undefined || after === undefined) continue;
|
|
283
|
+
if (before.name !== after.name) {
|
|
284
|
+
return `locked table ${JSON.stringify(before.name)} became or moved behind ${JSON.stringify(after.name)}`;
|
|
285
|
+
}
|
|
286
|
+
if (before.primaryKey !== after.primaryKey) {
|
|
287
|
+
return `table ${JSON.stringify(before.name)} changed primary key from ${JSON.stringify(before.primaryKey)} to ${JSON.stringify(after.primaryKey)}`;
|
|
288
|
+
}
|
|
289
|
+
const column = describeColumnDifference(
|
|
290
|
+
before.name,
|
|
291
|
+
before.columns,
|
|
292
|
+
after.columns,
|
|
293
|
+
);
|
|
294
|
+
if (column !== undefined) return column;
|
|
295
|
+
}
|
|
296
|
+
return 'schema shape is unchanged; SQL text, defaults, comments, indexes, or another non-column statement changed';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const REPAIR_HINT =
|
|
300
|
+
'deployed migrations are immutable; restore the locked migration and append a new migration (added columns must be nullable)';
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Validate the committed history as an exact prefix of the current sequence.
|
|
304
|
+
* New migrations may be appended; existing names and bytes may never change.
|
|
305
|
+
*/
|
|
306
|
+
export function validateMigrationLock(
|
|
307
|
+
locked: MigrationLock,
|
|
308
|
+
current: MigrationLock,
|
|
309
|
+
): void {
|
|
310
|
+
for (let index = 0; index < locked.migrations.length; index++) {
|
|
311
|
+
const before = locked.migrations[index] as MigrationLockEntry;
|
|
312
|
+
const after = current.migrations[index];
|
|
313
|
+
if (after === undefined) {
|
|
314
|
+
failLock(
|
|
315
|
+
`history drift at migration ${JSON.stringify(before.name)}: the locked migration is missing; ${REPAIR_HINT}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (before.name !== after.name) {
|
|
319
|
+
failLock(
|
|
320
|
+
`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}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (before.sha256 !== after.sha256) {
|
|
324
|
+
failLock(
|
|
325
|
+
`history drift in migration ${JSON.stringify(before.name)}: checksum changed; first schema difference: ${firstSchemaDifference(before, after)}; ${REPAIR_HINT}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|