@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 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 the generated TS module. `--check` regenerates in
17
- memory and exits 1 unless both files on disk match **byte-exactly** (this is
18
- the freshness contract; it is unchanged). `--watch` regenerates on any change
19
- under the manifest dir (Bun's recursive `fs.watch`, debounced; it skips the
20
- write when outputs are already fresh so it never loops on its own output).
21
- `--check` and `--watch` are mutually exclusive.
22
-
23
- `init` scaffolds a starter `syncular.json` + `migrations/0001_initial/up.sql`
24
- into an existing project (the "add syncular to my app" path). It refuses to
25
- overwrite an existing manifest or first migration. New projects usually start
26
- from the scaffolder instead (`bun create syncular-app my-app`), which emits the
27
- same shape plus a server + client.
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 generate
301
- --check`, which is byte-exact and therefore strictly stronger; the
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;
@@ -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
- /** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
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
@@ -19,6 +19,7 @@ export * from './ir.js';
19
19
  export * from './lower.js';
20
20
  export * from './lsp.js';
21
21
  export * from './manifest.js';
22
+ export * from './migration-lock.js';
22
23
  export * from './naming.js';
23
24
  export * from './query.js';
24
25
  export * from './query-ir.js';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export * from './ir.js';
19
19
  export * from './lower.js';
20
20
  export * from './lsp.js';
21
21
  export * from './manifest.js';
22
+ export * from './migration-lock.js';
22
23
  export * from './naming.js';
23
24
  export * from './query.js';
24
25
  export * from './query-ir.js';
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 { written: [manifestPath, migrationPath, queryPath] };
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.19",
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.19",
52
- "@syncular/server": "0.15.19"
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 { checkOutputs, generate, loadQueries, writeOutputs } from './generate';
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
- /** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
534
- export function generate(manifestDir: string): GenerateResult {
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
@@ -19,6 +19,7 @@ export * from './ir';
19
19
  export * from './lower';
20
20
  export * from './lsp';
21
21
  export * from './manifest';
22
+ export * from './migration-lock';
22
23
  export * from './naming';
23
24
  export * from './query';
24
25
  export * from './query-ir';
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 { written: [manifestPath, migrationPath, queryPath] };
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
+ }