@syncular/typegen 0.15.40 → 0.15.43

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.
@@ -11,8 +11,10 @@ export interface MigrationInput {
11
11
  /** Contents of its `up.sql`. */
12
12
  readonly sql: string;
13
13
  }
14
- /** Pure IR construction — the testable heart of the generator. */
15
- export declare function buildIr(manifest: Manifest, migrations: readonly MigrationInput[]): IrDocument;
14
+ /** Pure IR construction — the testable heart of the generator. Migrations
15
+ * named in `lockedNames` replay as immutable deployed history: rules that
16
+ * only appended migrations must satisfy are skipped for them. */
17
+ export declare function buildIr(manifest: Manifest, migrations: readonly MigrationInput[], lockedNames?: ReadonlySet<string>): IrDocument;
16
18
  /** Load `NNNN_name/up.sql` migrations, ordered by numeric prefix. */
17
19
  export declare function loadMigrations(migrationsDir: string): MigrationInput[];
18
20
  /** One named-query file (`.sql` — the compatibility floor — or `.syql`, the
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, LEGACY_MIGRATION_LOCK_FORMAT_VERSION, MIGRATION_LOCK_FILENAME, readMigrationLock, serializeMigrationLock, updateMigrationLock, } from './migration-lock.js';
20
+ import { buildMigrationLock, LEGACY_MIGRATION_LOCK_FORMAT_VERSION, lockedMigrationNames, 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';
@@ -149,15 +149,17 @@ function buildSchemaVersions(manifest, migrations) {
149
149
  }
150
150
  return out;
151
151
  }
152
- /** Pure IR construction — the testable heart of the generator. */
153
- export function buildIr(manifest, migrations) {
152
+ /** Pure IR construction — the testable heart of the generator. Migrations
153
+ * named in `lockedNames` replay as immutable deployed history: rules that
154
+ * only appended migrations must satisfy are skipped for them. */
155
+ export function buildIr(manifest, migrations, lockedNames) {
154
156
  if (migrations.length === 0) {
155
157
  throw new TypegenError(MANIFEST_FILENAME, 'no migrations found');
156
158
  }
157
159
  const parsedTables = new Map();
158
160
  const droppedTables = new Set();
159
161
  for (const migration of migrations) {
160
- applyMigrationSql(parsedTables, migration.sql, `${migration.name}/up.sql`, droppedTables);
162
+ applyMigrationSql(parsedTables, migration.sql, `${migration.name}/up.sql`, droppedTables, { lockedHistory: lockedNames?.has(migration.name) === true });
161
163
  }
162
164
  validateFinalSchemaIdentifiers(parsedTables);
163
165
  const schemaVersions = buildSchemaVersions(manifest, migrations);
@@ -366,17 +368,29 @@ export function upgradeMigrationHistory(manifestDir) {
366
368
  }
367
369
  // Validate the immutable prefix before replacing its representation.
368
370
  updateMigrationLock(locked, migrations);
369
- buildIr(manifest, migrations);
371
+ // upgrade-lock converts the committed representation of history that is
372
+ // already locked. Extending the lock over appended migrations is its own
373
+ // reviewable act, so unlocked migrations stop the conversion here.
374
+ if (migrations.length > locked.migrations.length) {
375
+ const appended = migrations
376
+ .slice(locked.migrations.length)
377
+ .map((migration) => migration.name)
378
+ .join(', ');
379
+ throw new TypegenError(MIGRATION_LOCK_FILENAME, `migrations ${appended} are appended beyond the locked history — run generate to extend the lock over them, commit the extended lock, then rerun \`migrations upgrade-lock\``);
380
+ }
381
+ const lockedNames = lockedMigrationNames(locked);
382
+ buildIr(manifest, migrations, lockedNames);
370
383
  return {
371
384
  path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
372
- content: serializeMigrationLock(buildMigrationLock(migrations)),
385
+ content: serializeMigrationLock(buildMigrationLock(migrations, undefined, lockedNames)),
373
386
  };
374
387
  }
375
388
  /** Fast CI check for migration history without emitting/query analysis. */
376
389
  export function checkMigrationHistory(manifestDir) {
377
390
  const { manifest, migrations } = loadProjectInputs(manifestDir);
378
- const current = updateMigrationLock(readMigrationLock(manifestDir), migrations);
379
- buildIr(manifest, migrations);
391
+ const locked = readMigrationLock(manifestDir);
392
+ const current = updateMigrationLock(locked, migrations);
393
+ buildIr(manifest, migrations, lockedMigrationNames(locked));
380
394
  const output = {
381
395
  path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
382
396
  content: serializeMigrationLock(current),
@@ -391,8 +405,9 @@ export function checkMigrationHistory(manifestDir) {
391
405
  /** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
392
406
  export function generate(manifestDir) {
393
407
  const { manifest, migrations } = loadProjectInputs(manifestDir);
394
- const migrationLock = updateMigrationLock(readMigrationLock(manifestDir), migrations);
395
- const ir = buildIr(manifest, migrations);
408
+ const locked = readMigrationLock(manifestDir);
409
+ const migrationLock = updateMigrationLock(locked, migrations);
410
+ const ir = buildIr(manifest, migrations, lockedMigrationNames(locked));
396
411
  // §5/§12 naming: the emitter targets this run generates (keyword hazards
397
412
  // are only real on targets that exist), and the per-table collision check
398
413
  // for the generated row types.
package/dist/lsp.js CHANGED
@@ -6,6 +6,7 @@ import { TypegenError } from './errors.js';
6
6
  import { formatSyql } from './fmt.js';
7
7
  import { buildIr, loadMigrations, loadQueries, makeQueryDb } from './generate.js';
8
8
  import { MANIFEST_FILENAME, parseManifest } from './manifest.js';
9
+ import { lockedMigrationNames, readMigrationLock } from './migration-lock.js';
9
10
  import { SyqlFrontendError } from './syql-lexer.js';
10
11
  import { lowerSyqlQuery } from './syql-lowering.js';
11
12
  import { buildSyqlModuleGraph } from './syql-modules.js';
@@ -177,7 +178,16 @@ export class SyqlLanguageServer {
177
178
  return cached;
178
179
  try {
179
180
  const manifest = parseManifest(JSON.parse(readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8')));
180
- const ir = buildIr(manifest, loadMigrations(resolve(manifestDir, manifest.migrations)));
181
+ // Tolerate locked history exactly like generation does; a project with
182
+ // no readable lock builds strictly.
183
+ let lockedNames;
184
+ try {
185
+ lockedNames = lockedMigrationNames(readMigrationLock(manifestDir));
186
+ }
187
+ catch {
188
+ lockedNames = undefined;
189
+ }
190
+ const ir = buildIr(manifest, loadMigrations(resolve(manifestDir, manifest.migrations)), lockedNames);
181
191
  const { db } = makeQueryDb(ir);
182
192
  const targets = ['ts'];
183
193
  if (manifest.output.swift !== undefined)
@@ -33,8 +33,10 @@ export interface MigrationLockV2 {
33
33
  };
34
34
  }
35
35
  export type MigrationLock = LegacyMigrationLock | MigrationLockV2;
36
+ /** The names of every migration a committed lock has made immutable. */
37
+ export declare function lockedMigrationNames(lock: MigrationLock): ReadonlySet<string>;
36
38
  /** 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;
39
+ export declare function buildMigrationLock(migrations: readonly MigrationInput[], formatVersion?: typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION | typeof MIGRATION_LOCK_FORMAT_VERSION, lockedNames?: ReadonlySet<string>): MigrationLock;
38
40
  /** Fixed-order, byte-deterministic serialization for clean code review. */
39
41
  export declare function serializeMigrationLock(lock: MigrationLock): string;
40
42
  /** Parse and structurally validate a committed lock document. */
@@ -31,21 +31,28 @@ function snapshotColumn(column) {
31
31
  };
32
32
  }
33
33
  function snapshotTables(tables) {
34
- return [...tables.values()]
35
- .sort((a, b) => a.name.localeCompare(b.name))
34
+ return ([...tables.values()]
35
+ // Code-point order keeps the serialization byte-identical across locales.
36
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
36
37
  .map((table) => ({
37
38
  name: table.name,
38
39
  primaryKey: table.primaryKey,
39
40
  columns: table.columns.map(snapshotColumn),
40
- }));
41
+ })));
41
42
  }
42
- /** Replay a complete migration sequence with ephemeral diagnostic snapshots. */
43
- function buildMigrationHistory(migrations) {
43
+ /** The names of every migration a committed lock has made immutable. */
44
+ export function lockedMigrationNames(lock) {
45
+ return new Set(lock.migrations.map((migration) => migration.name));
46
+ }
47
+ /** Replay a complete migration sequence with ephemeral diagnostic snapshots.
48
+ * Migrations named in `lockedNames` replay as deployed history; rules that
49
+ * only appended migrations must satisfy are skipped for them. */
50
+ function buildMigrationHistory(migrations, lockedNames) {
44
51
  const tables = new Map();
45
52
  const droppedTables = new Set();
46
53
  const entries = [];
47
54
  for (const migration of migrations) {
48
- applyMigrationSql(tables, migration.sql, `${migration.name}/up.sql`, droppedTables);
55
+ applyMigrationSql(tables, migration.sql, `${migration.name}/up.sql`, droppedTables, { lockedHistory: lockedNames?.has(migration.name) === true });
49
56
  entries.push({
50
57
  name: migration.name,
51
58
  sha256: checksum(migration.sql),
@@ -75,8 +82,8 @@ function lockFromHistory(history, formatVersion) {
75
82
  };
76
83
  }
77
84
  /** 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);
85
+ export function buildMigrationLock(migrations, formatVersion = MIGRATION_LOCK_FORMAT_VERSION, lockedNames) {
86
+ return lockFromHistory(buildMigrationHistory(migrations, lockedNames), formatVersion);
80
87
  }
81
88
  /** Fixed-order, byte-deterministic serialization for clean code review. */
82
89
  export function serializeMigrationLock(lock) {
@@ -329,11 +336,11 @@ function validateMigrationHistory(locked, current) {
329
336
  * version-1 lock is always an explicit command.
330
337
  */
331
338
  export function updateMigrationLock(locked, migrations) {
332
- const current = buildMigrationHistory(migrations);
339
+ const current = buildMigrationHistory(migrations, lockedMigrationNames(locked));
333
340
  validateMigrationHistory(locked, current);
334
341
  return lockFromHistory(current, locked.formatVersion);
335
342
  }
336
343
  /** Validate without changing the committed lock format. */
337
344
  export function validateMigrationLock(locked, migrations) {
338
- validateMigrationHistory(locked, buildMigrationHistory(migrations));
345
+ validateMigrationHistory(locked, buildMigrationHistory(migrations, lockedMigrationNames(locked)));
339
346
  }
package/dist/query.js CHANGED
@@ -504,6 +504,11 @@ function hasCommaJoinedSchemaTable(sql, ir) {
504
504
  ]);
505
505
  let depth = 0;
506
506
  let cursor = 0;
507
+ /** The previous significant token: an uppercased word or a single
508
+ * punctuation character. Distinguishes a table-source group — a `(` after
509
+ * FROM, JOIN, `,` or another `(` — from a function call or parenthesized
510
+ * expression, whose `(` follows an identifier or an operator. */
511
+ let previous;
507
512
  const nextIdentifier = (start) => {
508
513
  let index = start;
509
514
  while (index < cleaned.length &&
@@ -515,20 +520,31 @@ function hasCommaJoinedSchemaTable(sql, ir) {
515
520
  };
516
521
  while (cursor < cleaned.length) {
517
522
  const char = cleaned[cursor];
523
+ if (/\s/.test(char)) {
524
+ cursor += 1;
525
+ continue;
526
+ }
518
527
  if (char === '(') {
528
+ const opensTableSource = previous === 'FROM' ||
529
+ previous === 'JOIN' ||
530
+ previous === ',' ||
531
+ previous === '(';
519
532
  const next = nextIdentifier(cursor + 1);
520
- if (activeFromDepths.has(depth) &&
533
+ if (opensTableSource &&
534
+ activeFromDepths.has(depth) &&
521
535
  next !== undefined &&
522
536
  known.has(next.toLowerCase())) {
523
537
  activeFromDepths.add(depth + 1);
524
538
  }
525
539
  depth += 1;
540
+ previous = '(';
526
541
  cursor += 1;
527
542
  continue;
528
543
  }
529
544
  if (char === ')') {
530
545
  activeFromDepths.delete(depth);
531
546
  depth = Math.max(0, depth - 1);
547
+ previous = ')';
532
548
  cursor += 1;
533
549
  continue;
534
550
  }
@@ -536,6 +552,7 @@ function hasCommaJoinedSchemaTable(sql, ir) {
536
552
  const next = nextIdentifier(cursor + 1);
537
553
  if (next !== undefined && known.has(next.toLowerCase()))
538
554
  return true;
555
+ previous = ',';
539
556
  cursor += 1;
540
557
  continue;
541
558
  }
@@ -550,9 +567,11 @@ function hasCommaJoinedSchemaTable(sql, ir) {
550
567
  activeFromDepths.add(depth);
551
568
  else if (clauseEnders.has(word))
552
569
  activeFromDepths.delete(depth);
570
+ previous = word;
553
571
  cursor = end;
554
572
  continue;
555
573
  }
574
+ previous = char;
556
575
  cursor += 1;
557
576
  }
558
577
  return false;
package/dist/sql.d.ts CHANGED
@@ -8,15 +8,25 @@ export interface ParsedTable {
8
8
  /** Client-local contentful FTS5 projections. */
9
9
  readonly ftsIndexes: IrFtsIndex[];
10
10
  }
11
+ /** Options for replaying one migration's SQL. */
12
+ export interface ApplyMigrationSqlOptions {
13
+ /** True while this migration is part of the locked, immutable history
14
+ * prefix (`syncular.migrations.lock.json`). Deployed migrations are
15
+ * immutable, so checks that would require editing them are skipped for the
16
+ * locked prefix and enforced for appended migrations. */
17
+ readonly lockedHistory?: boolean;
18
+ }
11
19
  /**
12
20
  * Validate the accumulated head schema after every migration has been
13
21
  * applied. This deliberately permits a locked historical migration to create
14
22
  * an invalid identifier when a later forward migration drops it; only final
15
- * generated objects must be portable.
23
+ * generated objects must be portable. Objects created by locked migrations
24
+ * are additionally exempt from the ASCII rule (deployed migrations are
25
+ * immutable, so the name can only be retired by a forward migration).
16
26
  */
17
27
  export declare function validateFinalSchemaIdentifiers(tables: ReadonlyMap<string, ParsedTable>): void;
18
28
  /**
19
29
  * Apply one migration file's SQL to the accumulated table map. Columns
20
30
  * accumulate in declaration order — the §2.4 row-codec positional order.
21
31
  */
22
- export declare function applyMigrationSql(tables: Map<string, ParsedTable>, sql: string, source: string, droppedTables?: Set<string>): void;
32
+ export declare function applyMigrationSql(tables: Map<string, ParsedTable>, sql: string, source: string, droppedTables?: Set<string>, options?: ApplyMigrationSqlOptions): void;
package/dist/sql.js CHANGED
@@ -59,29 +59,48 @@ const DATA_MUTATION_GUIDANCE = 'Syncular migration SQL is schema-only: retain th
59
59
  const tableIdentifierSources = new WeakMap();
60
60
  const columnIdentifierSources = new WeakMap();
61
61
  const indexIdentifierSources = new WeakMap();
62
- function validateIdentifier(source, kind, identifier) {
62
+ const ftsIndexIdentifierSources = new WeakMap();
63
+ /** Objects created while replaying the locked, immutable history prefix.
64
+ * Rules adopted while that prefix was already deployed (nullable-only ADD
65
+ * COLUMN, ASCII-portable identifiers) replay tolerantly for these objects;
66
+ * every migration beyond the locked prefix enforces them. */
67
+ const lockedHistoryObjects = new WeakSet();
68
+ /** ASCII identifier contract shared with named-query analysis: query
69
+ * dependency tracking and reactivity resolve identifiers with this shape. */
70
+ const PORTABLE_ASCII_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
71
+ function validateIdentifier(source, kind, identifier, target) {
63
72
  try {
64
73
  validatePortableRelationalIdentifier(kind, identifier);
65
74
  }
66
75
  catch (error) {
67
76
  throw new TypegenError(source, error instanceof Error ? error.message : 'invalid relational identifier');
68
77
  }
78
+ if (lockedHistoryObjects.has(target))
79
+ return;
80
+ if (!PORTABLE_ASCII_IDENTIFIER_RE.test(identifier)) {
81
+ throw new TypegenError(source, `${kind} name ${JSON.stringify(identifier)} must be an ASCII identifier matching [A-Za-z_][A-Za-z0-9_]* — named-query analysis resolves ASCII identifiers, so a non-ASCII name silently loses dependency tracking and reactivity`);
82
+ }
69
83
  }
70
84
  /**
71
85
  * Validate the accumulated head schema after every migration has been
72
86
  * applied. This deliberately permits a locked historical migration to create
73
87
  * an invalid identifier when a later forward migration drops it; only final
74
- * generated objects must be portable.
88
+ * generated objects must be portable. Objects created by locked migrations
89
+ * are additionally exempt from the ASCII rule (deployed migrations are
90
+ * immutable, so the name can only be retired by a forward migration).
75
91
  */
76
92
  export function validateFinalSchemaIdentifiers(tables) {
77
93
  for (const table of tables.values()) {
78
94
  const tableSource = tableIdentifierSources.get(table) ?? 'migrations';
79
- validateIdentifier(tableSource, 'table', table.name);
95
+ validateIdentifier(tableSource, 'table', table.name, table);
80
96
  for (const column of table.columns) {
81
- validateIdentifier(columnIdentifierSources.get(column) ?? tableSource, `table ${table.name}: column`, column.name);
97
+ validateIdentifier(columnIdentifierSources.get(column) ?? tableSource, `table ${table.name}: column`, column.name, column);
82
98
  }
83
99
  for (const index of table.indexes) {
84
- validateIdentifier(indexIdentifierSources.get(index) ?? tableSource, `table ${table.name}: index`, index.name);
100
+ validateIdentifier(indexIdentifierSources.get(index) ?? tableSource, `table ${table.name}: index`, index.name, index);
101
+ }
102
+ for (const ftsIndex of table.ftsIndexes) {
103
+ validateIdentifier(ftsIndexIdentifierSources.get(ftsIndex) ?? tableSource, `table ${table.name}: FTS virtual table`, ftsIndex.name, ftsIndex);
85
104
  }
86
105
  }
87
106
  }
@@ -288,22 +307,22 @@ function parseColumnDef(cursor, allowPrimaryKey) {
288
307
  * Dispatch a `CREATE …` statement: `CREATE TABLE`, `CREATE INDEX`, or
289
308
  * `CREATE UNIQUE INDEX`. Anything else is a hard error naming the construct.
290
309
  */
291
- function parseCreate(cursor, tables, droppedTables, source) {
310
+ function parseCreate(cursor, tables, droppedTables, source, options) {
292
311
  if (cursor.eatWord('VIRTUAL')) {
293
- parseCreateVirtualTable(cursor, tables);
312
+ parseCreateVirtualTable(cursor, tables, source, options);
294
313
  return;
295
314
  }
296
315
  if (cursor.eatWord('TABLE')) {
297
- parseCreateTable(cursor, tables, droppedTables, source);
316
+ parseCreateTable(cursor, tables, droppedTables, source, options);
298
317
  return;
299
318
  }
300
319
  if (cursor.eatWord('UNIQUE')) {
301
320
  cursor.expectWord('INDEX', 'after CREATE UNIQUE');
302
- parseCreateIndex(cursor, tables, true, source);
321
+ parseCreateIndex(cursor, tables, true, source, options);
303
322
  return;
304
323
  }
305
324
  if (cursor.eatWord('INDEX')) {
306
- parseCreateIndex(cursor, tables, false, source);
325
+ parseCreateIndex(cursor, tables, false, source, options);
307
326
  return;
308
327
  }
309
328
  const token = cursor.peek();
@@ -320,7 +339,7 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
320
339
  /** Parse the deliberately narrow migration-subset v2 FTS5 form documented in
321
340
  * RFC 0005. The virtual table is attached to its owning synced
322
341
  * table and is not itself a synced table. */
323
- function parseCreateVirtualTable(cursor, tables) {
342
+ function parseCreateVirtualTable(cursor, tables, source, options) {
324
343
  cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
325
344
  if (cursor.eatWord('IF')) {
326
345
  cursor.expectWord('NOT', 'after IF');
@@ -409,9 +428,13 @@ function parseCreateVirtualTable(cursor, tables) {
409
428
  cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(columnName)} must have TEXT/string type`);
410
429
  }
411
430
  }
412
- table.ftsIndexes.push({ name, columns, tokenize });
431
+ const ftsIndex = { name, columns, tokenize };
432
+ table.ftsIndexes.push(ftsIndex);
433
+ ftsIndexIdentifierSources.set(ftsIndex, source);
434
+ if (options.lockedHistory === true)
435
+ lockedHistoryObjects.add(ftsIndex);
413
436
  }
414
- function parseCreateTable(cursor, tables, droppedTables, source) {
437
+ function parseCreateTable(cursor, tables, droppedTables, source, options) {
415
438
  if (cursor.eatWord('IF')) {
416
439
  cursor.expectWord('NOT', 'after IF');
417
440
  cursor.expectWord('EXISTS', 'after IF NOT');
@@ -497,8 +520,12 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
497
520
  };
498
521
  tables.set(name, table);
499
522
  tableIdentifierSources.set(table, source);
523
+ if (options.lockedHistory === true)
524
+ lockedHistoryObjects.add(table);
500
525
  for (const column of finalColumns) {
501
526
  columnIdentifierSources.set(column, source);
527
+ if (options.lockedHistory === true)
528
+ lockedHistoryObjects.add(column);
502
529
  }
503
530
  }
504
531
  /**
@@ -509,7 +536,7 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
509
536
  * (partial index) clause are hard errors naming the construct. Index names
510
537
  * are unique per accumulated schema.
511
538
  */
512
- function parseCreateIndex(cursor, tables, unique, source) {
539
+ function parseCreateIndex(cursor, tables, unique, source, options) {
513
540
  // `INDEX` already matched by the dispatcher.
514
541
  if (cursor.eatWord('IF')) {
515
542
  cursor.expectWord('NOT', 'after IF');
@@ -572,8 +599,10 @@ function parseCreateIndex(cursor, tables, unique, source) {
572
599
  const index = { name, columns, unique };
573
600
  table.indexes.push(index);
574
601
  indexIdentifierSources.set(index, source);
602
+ if (options.lockedHistory === true)
603
+ lockedHistoryObjects.add(index);
575
604
  }
576
- function parseAlterTable(cursor, tables, source) {
605
+ function parseAlterTable(cursor, tables, source, options) {
577
606
  cursor.expectWord('TABLE', 'after ALTER');
578
607
  const name = cursor.identifier('a table name');
579
608
  const table = tables.get(name);
@@ -587,7 +616,9 @@ function parseAlterTable(cursor, tables, source) {
587
616
  cursor.eatWord('COLUMN');
588
617
  const def = parseColumnDef(cursor, false);
589
618
  cursor.expectEnd();
590
- if (!def.column.nullable) {
619
+ // A locked migration replays as deployed; the nullable rule applies to
620
+ // migrations beyond the locked prefix, where the SQL can still change.
621
+ if (!def.column.nullable && options.lockedHistory !== true) {
591
622
  cursor.fail(`ALTER TABLE ${name}: added column ${JSON.stringify(def.column.name)} must be nullable — SQL defaults do not backfill Syncular row payloads; add the column nullable, backfill it through versioned server-authoritative writes, and enforce required values in application validation`);
592
623
  }
593
624
  if (table.columns.some((c) => c.name === def.column.name)) {
@@ -595,6 +626,8 @@ function parseAlterTable(cursor, tables, source) {
595
626
  }
596
627
  table.columns.push(def.column);
597
628
  columnIdentifierSources.set(def.column, source);
629
+ if (options.lockedHistory === true)
630
+ lockedHistoryObjects.add(def.column);
598
631
  }
599
632
  /** `DROP TABLE [IF EXISTS] name`; table-name reuse remains unsupported. */
600
633
  function parseDropTable(cursor, tables, droppedTables) {
@@ -629,6 +662,16 @@ function parseDropIndex(cursor, tables) {
629
662
  table.indexes.splice(index, 1);
630
663
  return;
631
664
  }
665
+ for (const table of tables.values()) {
666
+ if (table.ftsIndexes.some((candidate) => candidate.name === name)) {
667
+ // `DROP INDEX IF EXISTS <fts>` matches SQLite: an FTS5 virtual table is
668
+ // no regular index, so IF EXISTS resolves to a tolerant no-op (this also
669
+ // keeps a locked migration carrying the statement replayable).
670
+ if (ifExists)
671
+ return;
672
+ cursor.fail(`DROP INDEX ${name}: ${name} is an FTS5 virtual table (CREATE VIRTUAL TABLE … USING fts5); the migration subset removes an FTS projection together with its owning content table (DROP TABLE ${table.name})`);
673
+ }
674
+ }
632
675
  if (!ifExists) {
633
676
  cursor.fail(`DROP INDEX ${name}: index does not exist at this point`);
634
677
  }
@@ -649,16 +692,16 @@ function parseDrop(cursor, tables, droppedTables) {
649
692
  * Apply one migration file's SQL to the accumulated table map. Columns
650
693
  * accumulate in declaration order — the §2.4 row-codec positional order.
651
694
  */
652
- export function applyMigrationSql(tables, sql, source, droppedTables = new Set()) {
695
+ export function applyMigrationSql(tables, sql, source, droppedTables = new Set(), options = {}) {
653
696
  for (const tokens of tokenizeStatements(sql, source)) {
654
697
  const cursor = new Cursor(tokens, source);
655
698
  const head = cursor.next();
656
699
  const word = head.kind === 'word' ? head.text.toUpperCase() : '';
657
700
  if (word === 'CREATE') {
658
- parseCreate(cursor, tables, droppedTables, source);
701
+ parseCreate(cursor, tables, droppedTables, source, options);
659
702
  }
660
703
  else if (word === 'ALTER') {
661
- parseAlterTable(cursor, tables, source);
704
+ parseAlterTable(cursor, tables, source, options);
662
705
  }
663
706
  else if (word === 'DROP') {
664
707
  parseDrop(cursor, tables, droppedTables);
@@ -868,19 +868,45 @@ class Validator {
868
868
  }
869
869
  return true;
870
870
  };
871
- const requiredJoinEqualityAt = (index) => {
871
+ const joinEqualityContextAt = (index) => {
872
872
  const prefix = cleaned.slice(0, index);
873
873
  const clauses = [
874
874
  ...prefix.matchAll(/\b(ON|JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/gi),
875
875
  ];
876
876
  const last = clauses.at(-1);
877
877
  if (last?.[1]?.toUpperCase() !== 'ON')
878
- return false;
878
+ return undefined;
879
879
  const start = (last.index ?? 0) + last[0].length;
880
880
  const next = /\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/i.exec(cleaned.slice(index));
881
881
  const end = next === null ? cleaned.length : index + next.index;
882
882
  const clause = cleaned.slice(start, end);
883
- return !/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause);
883
+ if (/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause))
884
+ return undefined;
885
+ const join = clauses.at(-2);
886
+ if (join?.[1]?.toUpperCase() !== 'JOIN')
887
+ return undefined;
888
+ const joinIndex = join.index ?? 0;
889
+ const outerKind = /\b(LEFT|RIGHT|FULL)(?:\s+OUTER)?\s*$/i
890
+ .exec(prefix.slice(0, joinIndex))?.[1]
891
+ ?.toUpperCase();
892
+ if (outerKind === undefined)
893
+ return { kind: 'inner' };
894
+ // A FULL join preserves both operands, so its ON constrains neither.
895
+ if (outerKind === 'FULL')
896
+ return undefined;
897
+ const introducedAlias = (prefix
898
+ .slice(joinIndex + join[0].length, last.index)
899
+ .match(new RegExp(IDENT_SOURCE, 'g')) ?? [])
900
+ .filter((word) => word.toUpperCase() !== 'AS')
901
+ .at(-1)
902
+ ?.toLowerCase();
903
+ if (introducedAlias === undefined)
904
+ return undefined;
905
+ return {
906
+ kind: 'outer',
907
+ introducedAlias,
908
+ nullExtended: outerKind === 'LEFT' ? 'introduced' : 'prior',
909
+ };
884
910
  };
885
911
  const refByAlias = new Map(refs.map((ref) => [ref.alias.toLowerCase(), ref]));
886
912
  const scopeNode = (alias, column) => `${alias.toLowerCase()}\0${column.toLowerCase()}`;
@@ -903,24 +929,93 @@ class Validator {
903
929
  if (leftRoot !== rightRoot)
904
930
  parents.set(rightRoot, leftRoot);
905
931
  };
932
+ /** Directed scope inheritance for outer-join ON equalities: `target`
933
+ * inherits `source`'s proof on every row where target's table actually
934
+ * contributes (matched rows of the null-extended side). The preserved
935
+ * side gains nothing from such an equality. */
936
+ const inheritSources = new Map();
937
+ const addInheritEdge = (target, source) => {
938
+ find(target);
939
+ find(source);
940
+ const sources = inheritSources.get(target) ?? new Set();
941
+ sources.add(source);
942
+ inheritSources.set(target, sources);
943
+ };
944
+ /** Every node whose direct proof also constrains `start`: the union
945
+ * class of `start`, plus everything reachable through directed
946
+ * inheritance edges (each hop stays sound because a satisfied equality
947
+ * needs both operands non-NULL, so the source relation contributed the
948
+ * row). */
949
+ const reachableProofNodes = (start) => {
950
+ const seen = new Set();
951
+ const queue = [start];
952
+ while (queue.length > 0) {
953
+ const node = queue.pop();
954
+ if (seen.has(node))
955
+ continue;
956
+ seen.add(node);
957
+ const root = find(node);
958
+ for (const classmate of [...parents.keys()]) {
959
+ if (!seen.has(classmate) && find(classmate) === root) {
960
+ queue.push(classmate);
961
+ }
962
+ }
963
+ for (const source of inheritSources.get(node) ?? []) {
964
+ if (!seen.has(source))
965
+ queue.push(source);
966
+ }
967
+ }
968
+ return seen;
969
+ };
906
970
  const scopeColumn = (alias, column) => {
907
971
  const ref = refByAlias.get(alias.toLowerCase());
908
972
  const table = this.#ir.tables.find((item) => item.name === ref?.table);
909
973
  return (table?.scopes.some((scope) => scope.column.toLowerCase() === column.toLowerCase()) === true);
910
974
  };
911
- const qualifiedEquality = new RegExp(`(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==|\\bIS\\b)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`, 'gi');
975
+ // Scope inheritance across a join relies on a satisfied predicate proving
976
+ // both operands non-NULL. `IS` is null-safe (`NULL IS NULL` holds), so it
977
+ // would let a null-extended row on an outer join's preserved side carry a
978
+ // scope proof it never satisfies; only `=`/`==` qualify here.
979
+ const qualifiedEquality = new RegExp(`(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`, 'gi');
912
980
  for (const match of cleaned.matchAll(qualifiedEquality)) {
913
981
  const leftAlias = match[1];
914
982
  const leftColumn = match[2];
915
983
  const rightAlias = match[3];
916
984
  const rightColumn = match[4];
917
985
  if (!scopeColumn(leftAlias, leftColumn) ||
918
- !scopeColumn(rightAlias, rightColumn) ||
919
- (!requiredAt(match.index ?? 0) &&
920
- !requiredJoinEqualityAt(match.index ?? 0))) {
986
+ !scopeColumn(rightAlias, rightColumn)) {
987
+ continue;
988
+ }
989
+ const index = match.index ?? 0;
990
+ const leftNode = scopeNode(leftAlias, leftColumn);
991
+ const rightNode = scopeNode(rightAlias, rightColumn);
992
+ if (requiredAt(index)) {
993
+ union(leftNode, rightNode);
994
+ continue;
995
+ }
996
+ const context = joinEqualityContextAt(index);
997
+ if (context === undefined)
921
998
  continue;
999
+ if (context.kind === 'inner') {
1000
+ // An inner join's ON filters every result row, so the equality
1001
+ // constrains both operands symmetrically.
1002
+ union(leftNode, rightNode);
1003
+ continue;
1004
+ }
1005
+ const leftIntroduced = leftAlias.toLowerCase() === context.introducedAlias;
1006
+ const rightIntroduced = rightAlias.toLowerCase() === context.introducedAlias;
1007
+ // An equality with both operands on one side of the outer join proves
1008
+ // nothing across it; preserved-side rows appear regardless of the ON.
1009
+ if (leftIntroduced === rightIntroduced)
1010
+ continue;
1011
+ const introducedNode = leftIntroduced ? leftNode : rightNode;
1012
+ const priorNode = leftIntroduced ? rightNode : leftNode;
1013
+ if (context.nullExtended === 'introduced') {
1014
+ addInheritEdge(introducedNode, priorNode);
1015
+ }
1016
+ else {
1017
+ addInheritEdge(priorNode, introducedNode);
922
1018
  }
923
- union(scopeNode(leftAlias, leftColumn), scopeNode(rightAlias, rightColumn));
924
1019
  }
925
1020
  const required = new Set([...symbols.values()].flatMap((symbol) => symbol.parameter.kind === 'value' &&
926
1021
  !symbol.parameter.optional &&
@@ -1002,9 +1097,9 @@ class Validator {
1002
1097
  const direct = directByVariable.get(scope.variable);
1003
1098
  if (direct !== undefined)
1004
1099
  return [direct];
1005
- const root = find(scopeNode(candidate.ref.alias, scope.column));
1100
+ const reachable = reachableProofNodes(scopeNode(candidate.ref.alias, scope.column));
1006
1101
  const inherited = [...directScopeEvidence.entries()]
1007
- .filter(([node]) => find(node) === root)
1102
+ .filter(([node]) => reachable.has(node))
1008
1103
  .map(([, evidence]) => evidence);
1009
1104
  const params = [
1010
1105
  ...new Set(inherited.flatMap((evidence) => evidence.binding.params)),
@@ -1133,7 +1228,8 @@ class Validator {
1133
1228
  fixedScopes,
1134
1229
  };
1135
1230
  });
1136
- coverage.sort((left, right) => left.table.localeCompare(right.table));
1231
+ // Code-point order keeps emitted metadata byte-identical across locales.
1232
+ coverage.sort((left, right) => left.table < right.table ? -1 : left.table > right.table ? 1 : 0);
1137
1233
  return { dependencies, coverage };
1138
1234
  }
1139
1235
  #validateSort(query, structure, location) {