@syncular/typegen 0.15.23 → 0.15.25

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