@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.
- package/dist/generate.d.ts +4 -2
- package/dist/generate.js +25 -10
- package/dist/lsp.js +11 -1
- package/dist/migration-lock.d.ts +3 -1
- package/dist/migration-lock.js +17 -10
- package/dist/query.js +20 -1
- package/dist/sql.d.ts +12 -2
- package/dist/sql.js +62 -19
- package/dist/syql-validator.js +107 -11
- package/package.json +3 -3
- package/src/generate.ts +30 -13
- package/src/lsp.ts +10 -0
- package/src/migration-lock.ts +33 -11
- package/src/query.ts +20 -0
- package/src/sql.ts +77 -10
- package/src/syql-validator.ts +118 -14
package/dist/generate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
379
|
-
|
|
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
|
|
395
|
-
const
|
|
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
|
-
|
|
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)
|
package/dist/migration-lock.d.ts
CHANGED
|
@@ -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. */
|
package/dist/migration-lock.js
CHANGED
|
@@ -31,21 +31,28 @@ function snapshotColumn(column) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
function snapshotTables(tables) {
|
|
34
|
-
return [...tables.values()]
|
|
35
|
-
|
|
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
|
-
/**
|
|
43
|
-
function
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/syql-validator.js
CHANGED
|
@@ -868,19 +868,45 @@ class Validator {
|
|
|
868
868
|
}
|
|
869
869
|
return true;
|
|
870
870
|
};
|
|
871
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
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
|
|
1100
|
+
const reachable = reachableProofNodes(scopeNode(candidate.ref.alias, scope.column));
|
|
1006
1101
|
const inherited = [...directScopeEvidence.entries()]
|
|
1007
|
-
.filter(([node]) =>
|
|
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
|
-
|
|
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) {
|