@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.43",
|
|
4
4
|
"description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"!dist/**/*.test.d.ts"
|
|
57
57
|
],
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@syncular/core": "0.15.
|
|
59
|
+
"@syncular/core": "0.15.43"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@syncular/server": "0.15.
|
|
62
|
+
"@syncular/server": "0.15.43"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/generate.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import {
|
|
45
45
|
buildMigrationLock,
|
|
46
46
|
LEGACY_MIGRATION_LOCK_FORMAT_VERSION,
|
|
47
|
+
lockedMigrationNames,
|
|
47
48
|
MIGRATION_LOCK_FILENAME,
|
|
48
49
|
readMigrationLock,
|
|
49
50
|
serializeMigrationLock,
|
|
@@ -268,10 +269,13 @@ function buildSchemaVersions(
|
|
|
268
269
|
return out;
|
|
269
270
|
}
|
|
270
271
|
|
|
271
|
-
/** Pure IR construction — the testable heart of the generator.
|
|
272
|
+
/** Pure IR construction — the testable heart of the generator. Migrations
|
|
273
|
+
* named in `lockedNames` replay as immutable deployed history: rules that
|
|
274
|
+
* only appended migrations must satisfy are skipped for them. */
|
|
272
275
|
export function buildIr(
|
|
273
276
|
manifest: Manifest,
|
|
274
277
|
migrations: readonly MigrationInput[],
|
|
278
|
+
lockedNames?: ReadonlySet<string>,
|
|
275
279
|
): IrDocument {
|
|
276
280
|
if (migrations.length === 0) {
|
|
277
281
|
throw new TypegenError(MANIFEST_FILENAME, 'no migrations found');
|
|
@@ -284,6 +288,7 @@ export function buildIr(
|
|
|
284
288
|
migration.sql,
|
|
285
289
|
`${migration.name}/up.sql`,
|
|
286
290
|
droppedTables,
|
|
291
|
+
{ lockedHistory: lockedNames?.has(migration.name) === true },
|
|
287
292
|
);
|
|
288
293
|
}
|
|
289
294
|
validateFinalSchemaIdentifiers(parsedTables);
|
|
@@ -600,21 +605,35 @@ export function upgradeMigrationHistory(manifestDir: string): GeneratedOutput {
|
|
|
600
605
|
}
|
|
601
606
|
// Validate the immutable prefix before replacing its representation.
|
|
602
607
|
updateMigrationLock(locked, migrations);
|
|
603
|
-
|
|
608
|
+
// upgrade-lock converts the committed representation of history that is
|
|
609
|
+
// already locked. Extending the lock over appended migrations is its own
|
|
610
|
+
// reviewable act, so unlocked migrations stop the conversion here.
|
|
611
|
+
if (migrations.length > locked.migrations.length) {
|
|
612
|
+
const appended = migrations
|
|
613
|
+
.slice(locked.migrations.length)
|
|
614
|
+
.map((migration) => migration.name)
|
|
615
|
+
.join(', ');
|
|
616
|
+
throw new TypegenError(
|
|
617
|
+
MIGRATION_LOCK_FILENAME,
|
|
618
|
+
`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\``,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const lockedNames = lockedMigrationNames(locked);
|
|
622
|
+
buildIr(manifest, migrations, lockedNames);
|
|
604
623
|
return {
|
|
605
624
|
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
606
|
-
content: serializeMigrationLock(
|
|
625
|
+
content: serializeMigrationLock(
|
|
626
|
+
buildMigrationLock(migrations, undefined, lockedNames),
|
|
627
|
+
),
|
|
607
628
|
};
|
|
608
629
|
}
|
|
609
630
|
|
|
610
631
|
/** Fast CI check for migration history without emitting/query analysis. */
|
|
611
632
|
export function checkMigrationHistory(manifestDir: string): string[] {
|
|
612
633
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
);
|
|
617
|
-
buildIr(manifest, migrations);
|
|
634
|
+
const locked = readMigrationLock(manifestDir);
|
|
635
|
+
const current = updateMigrationLock(locked, migrations);
|
|
636
|
+
buildIr(manifest, migrations, lockedMigrationNames(locked));
|
|
618
637
|
const output = {
|
|
619
638
|
path: resolve(manifestDir, MIGRATION_LOCK_FILENAME),
|
|
620
639
|
content: serializeMigrationLock(current),
|
|
@@ -630,11 +649,9 @@ export function checkMigrationHistory(manifestDir: string): string[] {
|
|
|
630
649
|
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
631
650
|
export function generate(manifestDir: string): GenerateResult {
|
|
632
651
|
const { manifest, migrations } = loadProjectInputs(manifestDir);
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
);
|
|
637
|
-
const ir = buildIr(manifest, migrations);
|
|
652
|
+
const locked = readMigrationLock(manifestDir);
|
|
653
|
+
const migrationLock = updateMigrationLock(locked, migrations);
|
|
654
|
+
const ir = buildIr(manifest, migrations, lockedMigrationNames(locked));
|
|
638
655
|
|
|
639
656
|
// §5/§12 naming: the emitter targets this run generates (keyword hazards
|
|
640
657
|
// are only real on targets that exist), and the per-table collision check
|
package/src/lsp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { formatSyql } from './fmt';
|
|
|
7
7
|
import { buildIr, loadMigrations, loadQueries, makeQueryDb } from './generate';
|
|
8
8
|
import type { IrDocument } from './ir';
|
|
9
9
|
import { MANIFEST_FILENAME, parseManifest } from './manifest';
|
|
10
|
+
import { lockedMigrationNames, readMigrationLock } from './migration-lock';
|
|
10
11
|
import type { QueryDb, QueryNamingOptions } from './query';
|
|
11
12
|
import { SyqlFrontendError, type SyqlSourceSpan } from './syql-lexer';
|
|
12
13
|
import type { SyqlLoweredQuery } from './syql-lowering';
|
|
@@ -260,9 +261,18 @@ export class SyqlLanguageServer {
|
|
|
260
261
|
readFileSync(resolve(manifestDir, MANIFEST_FILENAME), 'utf8'),
|
|
261
262
|
),
|
|
262
263
|
);
|
|
264
|
+
// Tolerate locked history exactly like generation does; a project with
|
|
265
|
+
// no readable lock builds strictly.
|
|
266
|
+
let lockedNames: ReadonlySet<string> | undefined;
|
|
267
|
+
try {
|
|
268
|
+
lockedNames = lockedMigrationNames(readMigrationLock(manifestDir));
|
|
269
|
+
} catch {
|
|
270
|
+
lockedNames = undefined;
|
|
271
|
+
}
|
|
263
272
|
const ir = buildIr(
|
|
264
273
|
manifest,
|
|
265
274
|
loadMigrations(resolve(manifestDir, manifest.migrations)),
|
|
275
|
+
lockedNames,
|
|
266
276
|
);
|
|
267
277
|
const { db } = makeQueryDb(ir);
|
|
268
278
|
const targets: QueryNamingOptions['targets'][number][] = ['ts'];
|
package/src/migration-lock.ts
CHANGED
|
@@ -81,18 +81,29 @@ function snapshotColumn(column: IrColumn): MigrationLockColumn {
|
|
|
81
81
|
function snapshotTables(
|
|
82
82
|
tables: ReadonlyMap<string, ParsedTable>,
|
|
83
83
|
): MigrationLockTable[] {
|
|
84
|
-
return
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
name:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
return (
|
|
85
|
+
[...tables.values()]
|
|
86
|
+
// Code-point order keeps the serialization byte-identical across locales.
|
|
87
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
88
|
+
.map((table) => ({
|
|
89
|
+
name: table.name,
|
|
90
|
+
primaryKey: table.primaryKey,
|
|
91
|
+
columns: table.columns.map(snapshotColumn),
|
|
92
|
+
}))
|
|
93
|
+
);
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
/**
|
|
96
|
+
/** The names of every migration a committed lock has made immutable. */
|
|
97
|
+
export function lockedMigrationNames(lock: MigrationLock): ReadonlySet<string> {
|
|
98
|
+
return new Set(lock.migrations.map((migration) => migration.name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Replay a complete migration sequence with ephemeral diagnostic snapshots.
|
|
102
|
+
* Migrations named in `lockedNames` replay as deployed history; rules that
|
|
103
|
+
* only appended migrations must satisfy are skipped for them. */
|
|
94
104
|
function buildMigrationHistory(
|
|
95
105
|
migrations: readonly MigrationInput[],
|
|
106
|
+
lockedNames?: ReadonlySet<string>,
|
|
96
107
|
): CurrentMigrationHistory {
|
|
97
108
|
const tables = new Map<string, ParsedTable>();
|
|
98
109
|
const droppedTables = new Set<string>();
|
|
@@ -103,6 +114,7 @@ function buildMigrationHistory(
|
|
|
103
114
|
migration.sql,
|
|
104
115
|
`${migration.name}/up.sql`,
|
|
105
116
|
droppedTables,
|
|
117
|
+
{ lockedHistory: lockedNames?.has(migration.name) === true },
|
|
106
118
|
);
|
|
107
119
|
entries.push({
|
|
108
120
|
name: migration.name,
|
|
@@ -145,8 +157,12 @@ export function buildMigrationLock(
|
|
|
145
157
|
formatVersion:
|
|
146
158
|
| typeof LEGACY_MIGRATION_LOCK_FORMAT_VERSION
|
|
147
159
|
| typeof MIGRATION_LOCK_FORMAT_VERSION = MIGRATION_LOCK_FORMAT_VERSION,
|
|
160
|
+
lockedNames?: ReadonlySet<string>,
|
|
148
161
|
): MigrationLock {
|
|
149
|
-
return lockFromHistory(
|
|
162
|
+
return lockFromHistory(
|
|
163
|
+
buildMigrationHistory(migrations, lockedNames),
|
|
164
|
+
formatVersion,
|
|
165
|
+
);
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/** Fixed-order, byte-deterministic serialization for clean code review. */
|
|
@@ -447,7 +463,10 @@ export function updateMigrationLock(
|
|
|
447
463
|
locked: MigrationLock,
|
|
448
464
|
migrations: readonly MigrationInput[],
|
|
449
465
|
): MigrationLock {
|
|
450
|
-
const current = buildMigrationHistory(
|
|
466
|
+
const current = buildMigrationHistory(
|
|
467
|
+
migrations,
|
|
468
|
+
lockedMigrationNames(locked),
|
|
469
|
+
);
|
|
451
470
|
validateMigrationHistory(locked, current);
|
|
452
471
|
return lockFromHistory(current, locked.formatVersion);
|
|
453
472
|
}
|
|
@@ -457,5 +476,8 @@ export function validateMigrationLock(
|
|
|
457
476
|
locked: MigrationLock,
|
|
458
477
|
migrations: readonly MigrationInput[],
|
|
459
478
|
): void {
|
|
460
|
-
validateMigrationHistory(
|
|
479
|
+
validateMigrationHistory(
|
|
480
|
+
locked,
|
|
481
|
+
buildMigrationHistory(migrations, lockedMigrationNames(locked)),
|
|
482
|
+
);
|
|
461
483
|
}
|
package/src/query.ts
CHANGED
|
@@ -767,6 +767,11 @@ function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
|
|
|
767
767
|
]);
|
|
768
768
|
let depth = 0;
|
|
769
769
|
let cursor = 0;
|
|
770
|
+
/** The previous significant token: an uppercased word or a single
|
|
771
|
+
* punctuation character. Distinguishes a table-source group — a `(` after
|
|
772
|
+
* FROM, JOIN, `,` or another `(` — from a function call or parenthesized
|
|
773
|
+
* expression, whose `(` follows an identifier or an operator. */
|
|
774
|
+
let previous: string | undefined;
|
|
770
775
|
const nextIdentifier = (start: number): string | undefined => {
|
|
771
776
|
let index = start;
|
|
772
777
|
while (
|
|
@@ -781,9 +786,19 @@ function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
|
|
|
781
786
|
|
|
782
787
|
while (cursor < cleaned.length) {
|
|
783
788
|
const char = cleaned[cursor] as string;
|
|
789
|
+
if (/\s/.test(char)) {
|
|
790
|
+
cursor += 1;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
784
793
|
if (char === '(') {
|
|
794
|
+
const opensTableSource =
|
|
795
|
+
previous === 'FROM' ||
|
|
796
|
+
previous === 'JOIN' ||
|
|
797
|
+
previous === ',' ||
|
|
798
|
+
previous === '(';
|
|
785
799
|
const next = nextIdentifier(cursor + 1);
|
|
786
800
|
if (
|
|
801
|
+
opensTableSource &&
|
|
787
802
|
activeFromDepths.has(depth) &&
|
|
788
803
|
next !== undefined &&
|
|
789
804
|
known.has(next.toLowerCase())
|
|
@@ -791,18 +806,21 @@ function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
|
|
|
791
806
|
activeFromDepths.add(depth + 1);
|
|
792
807
|
}
|
|
793
808
|
depth += 1;
|
|
809
|
+
previous = '(';
|
|
794
810
|
cursor += 1;
|
|
795
811
|
continue;
|
|
796
812
|
}
|
|
797
813
|
if (char === ')') {
|
|
798
814
|
activeFromDepths.delete(depth);
|
|
799
815
|
depth = Math.max(0, depth - 1);
|
|
816
|
+
previous = ')';
|
|
800
817
|
cursor += 1;
|
|
801
818
|
continue;
|
|
802
819
|
}
|
|
803
820
|
if (char === ',' && activeFromDepths.has(depth)) {
|
|
804
821
|
const next = nextIdentifier(cursor + 1);
|
|
805
822
|
if (next !== undefined && known.has(next.toLowerCase())) return true;
|
|
823
|
+
previous = ',';
|
|
806
824
|
cursor += 1;
|
|
807
825
|
continue;
|
|
808
826
|
}
|
|
@@ -817,9 +835,11 @@ function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
|
|
|
817
835
|
const word = cleaned.slice(cursor, end).toUpperCase();
|
|
818
836
|
if (word === 'FROM') activeFromDepths.add(depth);
|
|
819
837
|
else if (clauseEnders.has(word)) activeFromDepths.delete(depth);
|
|
838
|
+
previous = word;
|
|
820
839
|
cursor = end;
|
|
821
840
|
continue;
|
|
822
841
|
}
|
|
842
|
+
previous = char;
|
|
823
843
|
cursor += 1;
|
|
824
844
|
}
|
|
825
845
|
return false;
|
package/src/sql.ts
CHANGED
|
@@ -80,11 +80,32 @@ const DATA_MUTATION_GUIDANCE =
|
|
|
80
80
|
const tableIdentifierSources = new WeakMap<ParsedTable, string>();
|
|
81
81
|
const columnIdentifierSources = new WeakMap<IrColumn, string>();
|
|
82
82
|
const indexIdentifierSources = new WeakMap<IrIndex, string>();
|
|
83
|
+
const ftsIndexIdentifierSources = new WeakMap<IrFtsIndex, string>();
|
|
84
|
+
|
|
85
|
+
/** Objects created while replaying the locked, immutable history prefix.
|
|
86
|
+
* Rules adopted while that prefix was already deployed (nullable-only ADD
|
|
87
|
+
* COLUMN, ASCII-portable identifiers) replay tolerantly for these objects;
|
|
88
|
+
* every migration beyond the locked prefix enforces them. */
|
|
89
|
+
const lockedHistoryObjects = new WeakSet<object>();
|
|
90
|
+
|
|
91
|
+
/** Options for replaying one migration's SQL. */
|
|
92
|
+
export interface ApplyMigrationSqlOptions {
|
|
93
|
+
/** True while this migration is part of the locked, immutable history
|
|
94
|
+
* prefix (`syncular.migrations.lock.json`). Deployed migrations are
|
|
95
|
+
* immutable, so checks that would require editing them are skipped for the
|
|
96
|
+
* locked prefix and enforced for appended migrations. */
|
|
97
|
+
readonly lockedHistory?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** ASCII identifier contract shared with named-query analysis: query
|
|
101
|
+
* dependency tracking and reactivity resolve identifiers with this shape. */
|
|
102
|
+
const PORTABLE_ASCII_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
83
103
|
|
|
84
104
|
function validateIdentifier(
|
|
85
105
|
source: string,
|
|
86
106
|
kind: string,
|
|
87
107
|
identifier: string,
|
|
108
|
+
target: object,
|
|
88
109
|
): void {
|
|
89
110
|
try {
|
|
90
111
|
validatePortableRelationalIdentifier(kind, identifier);
|
|
@@ -94,25 +115,35 @@ function validateIdentifier(
|
|
|
94
115
|
error instanceof Error ? error.message : 'invalid relational identifier',
|
|
95
116
|
);
|
|
96
117
|
}
|
|
118
|
+
if (lockedHistoryObjects.has(target)) return;
|
|
119
|
+
if (!PORTABLE_ASCII_IDENTIFIER_RE.test(identifier)) {
|
|
120
|
+
throw new TypegenError(
|
|
121
|
+
source,
|
|
122
|
+
`${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`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/**
|
|
100
128
|
* Validate the accumulated head schema after every migration has been
|
|
101
129
|
* applied. This deliberately permits a locked historical migration to create
|
|
102
130
|
* an invalid identifier when a later forward migration drops it; only final
|
|
103
|
-
* generated objects must be portable.
|
|
131
|
+
* generated objects must be portable. Objects created by locked migrations
|
|
132
|
+
* are additionally exempt from the ASCII rule (deployed migrations are
|
|
133
|
+
* immutable, so the name can only be retired by a forward migration).
|
|
104
134
|
*/
|
|
105
135
|
export function validateFinalSchemaIdentifiers(
|
|
106
136
|
tables: ReadonlyMap<string, ParsedTable>,
|
|
107
137
|
): void {
|
|
108
138
|
for (const table of tables.values()) {
|
|
109
139
|
const tableSource = tableIdentifierSources.get(table) ?? 'migrations';
|
|
110
|
-
validateIdentifier(tableSource, 'table', table.name);
|
|
140
|
+
validateIdentifier(tableSource, 'table', table.name, table);
|
|
111
141
|
for (const column of table.columns) {
|
|
112
142
|
validateIdentifier(
|
|
113
143
|
columnIdentifierSources.get(column) ?? tableSource,
|
|
114
144
|
`table ${table.name}: column`,
|
|
115
145
|
column.name,
|
|
146
|
+
column,
|
|
116
147
|
);
|
|
117
148
|
}
|
|
118
149
|
for (const index of table.indexes) {
|
|
@@ -120,6 +151,15 @@ export function validateFinalSchemaIdentifiers(
|
|
|
120
151
|
indexIdentifierSources.get(index) ?? tableSource,
|
|
121
152
|
`table ${table.name}: index`,
|
|
122
153
|
index.name,
|
|
154
|
+
index,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
for (const ftsIndex of table.ftsIndexes) {
|
|
158
|
+
validateIdentifier(
|
|
159
|
+
ftsIndexIdentifierSources.get(ftsIndex) ?? tableSource,
|
|
160
|
+
`table ${table.name}: FTS virtual table`,
|
|
161
|
+
ftsIndex.name,
|
|
162
|
+
ftsIndex,
|
|
123
163
|
);
|
|
124
164
|
}
|
|
125
165
|
}
|
|
@@ -359,22 +399,23 @@ function parseCreate(
|
|
|
359
399
|
tables: Map<string, ParsedTable>,
|
|
360
400
|
droppedTables: ReadonlySet<string>,
|
|
361
401
|
source: string,
|
|
402
|
+
options: ApplyMigrationSqlOptions,
|
|
362
403
|
): void {
|
|
363
404
|
if (cursor.eatWord('VIRTUAL')) {
|
|
364
|
-
parseCreateVirtualTable(cursor, tables);
|
|
405
|
+
parseCreateVirtualTable(cursor, tables, source, options);
|
|
365
406
|
return;
|
|
366
407
|
}
|
|
367
408
|
if (cursor.eatWord('TABLE')) {
|
|
368
|
-
parseCreateTable(cursor, tables, droppedTables, source);
|
|
409
|
+
parseCreateTable(cursor, tables, droppedTables, source, options);
|
|
369
410
|
return;
|
|
370
411
|
}
|
|
371
412
|
if (cursor.eatWord('UNIQUE')) {
|
|
372
413
|
cursor.expectWord('INDEX', 'after CREATE UNIQUE');
|
|
373
|
-
parseCreateIndex(cursor, tables, true, source);
|
|
414
|
+
parseCreateIndex(cursor, tables, true, source, options);
|
|
374
415
|
return;
|
|
375
416
|
}
|
|
376
417
|
if (cursor.eatWord('INDEX')) {
|
|
377
|
-
parseCreateIndex(cursor, tables, false, source);
|
|
418
|
+
parseCreateIndex(cursor, tables, false, source, options);
|
|
378
419
|
return;
|
|
379
420
|
}
|
|
380
421
|
const token = cursor.peek();
|
|
@@ -398,6 +439,8 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
|
398
439
|
function parseCreateVirtualTable(
|
|
399
440
|
cursor: Cursor,
|
|
400
441
|
tables: Map<string, ParsedTable>,
|
|
442
|
+
source: string,
|
|
443
|
+
options: ApplyMigrationSqlOptions,
|
|
401
444
|
): void {
|
|
402
445
|
cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
|
|
403
446
|
if (cursor.eatWord('IF')) {
|
|
@@ -510,7 +553,10 @@ function parseCreateVirtualTable(
|
|
|
510
553
|
);
|
|
511
554
|
}
|
|
512
555
|
}
|
|
513
|
-
|
|
556
|
+
const ftsIndex = { name, columns, tokenize };
|
|
557
|
+
table.ftsIndexes.push(ftsIndex);
|
|
558
|
+
ftsIndexIdentifierSources.set(ftsIndex, source);
|
|
559
|
+
if (options.lockedHistory === true) lockedHistoryObjects.add(ftsIndex);
|
|
514
560
|
}
|
|
515
561
|
|
|
516
562
|
function parseCreateTable(
|
|
@@ -518,6 +564,7 @@ function parseCreateTable(
|
|
|
518
564
|
tables: Map<string, ParsedTable>,
|
|
519
565
|
droppedTables: ReadonlySet<string>,
|
|
520
566
|
source: string,
|
|
567
|
+
options: ApplyMigrationSqlOptions,
|
|
521
568
|
): void {
|
|
522
569
|
if (cursor.eatWord('IF')) {
|
|
523
570
|
cursor.expectWord('NOT', 'after IF');
|
|
@@ -618,8 +665,10 @@ function parseCreateTable(
|
|
|
618
665
|
};
|
|
619
666
|
tables.set(name, table);
|
|
620
667
|
tableIdentifierSources.set(table, source);
|
|
668
|
+
if (options.lockedHistory === true) lockedHistoryObjects.add(table);
|
|
621
669
|
for (const column of finalColumns) {
|
|
622
670
|
columnIdentifierSources.set(column, source);
|
|
671
|
+
if (options.lockedHistory === true) lockedHistoryObjects.add(column);
|
|
623
672
|
}
|
|
624
673
|
}
|
|
625
674
|
|
|
@@ -636,6 +685,7 @@ function parseCreateIndex(
|
|
|
636
685
|
tables: Map<string, ParsedTable>,
|
|
637
686
|
unique: boolean,
|
|
638
687
|
source: string,
|
|
688
|
+
options: ApplyMigrationSqlOptions,
|
|
639
689
|
): void {
|
|
640
690
|
// `INDEX` already matched by the dispatcher.
|
|
641
691
|
if (cursor.eatWord('IF')) {
|
|
@@ -715,12 +765,14 @@ function parseCreateIndex(
|
|
|
715
765
|
const index: IrIndex = { name, columns, unique };
|
|
716
766
|
table.indexes.push(index);
|
|
717
767
|
indexIdentifierSources.set(index, source);
|
|
768
|
+
if (options.lockedHistory === true) lockedHistoryObjects.add(index);
|
|
718
769
|
}
|
|
719
770
|
|
|
720
771
|
function parseAlterTable(
|
|
721
772
|
cursor: Cursor,
|
|
722
773
|
tables: Map<string, ParsedTable>,
|
|
723
774
|
source: string,
|
|
775
|
+
options: ApplyMigrationSqlOptions,
|
|
724
776
|
): void {
|
|
725
777
|
cursor.expectWord('TABLE', 'after ALTER');
|
|
726
778
|
const name = cursor.identifier('a table name');
|
|
@@ -737,7 +789,9 @@ function parseAlterTable(
|
|
|
737
789
|
cursor.eatWord('COLUMN');
|
|
738
790
|
const def = parseColumnDef(cursor, false);
|
|
739
791
|
cursor.expectEnd();
|
|
740
|
-
|
|
792
|
+
// A locked migration replays as deployed; the nullable rule applies to
|
|
793
|
+
// migrations beyond the locked prefix, where the SQL can still change.
|
|
794
|
+
if (!def.column.nullable && options.lockedHistory !== true) {
|
|
741
795
|
cursor.fail(
|
|
742
796
|
`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`,
|
|
743
797
|
);
|
|
@@ -749,6 +803,7 @@ function parseAlterTable(
|
|
|
749
803
|
}
|
|
750
804
|
table.columns.push(def.column);
|
|
751
805
|
columnIdentifierSources.set(def.column, source);
|
|
806
|
+
if (options.lockedHistory === true) lockedHistoryObjects.add(def.column);
|
|
752
807
|
}
|
|
753
808
|
|
|
754
809
|
/** `DROP TABLE [IF EXISTS] name`; table-name reuse remains unsupported. */
|
|
@@ -792,6 +847,17 @@ function parseDropIndex(
|
|
|
792
847
|
table.indexes.splice(index, 1);
|
|
793
848
|
return;
|
|
794
849
|
}
|
|
850
|
+
for (const table of tables.values()) {
|
|
851
|
+
if (table.ftsIndexes.some((candidate) => candidate.name === name)) {
|
|
852
|
+
// `DROP INDEX IF EXISTS <fts>` matches SQLite: an FTS5 virtual table is
|
|
853
|
+
// no regular index, so IF EXISTS resolves to a tolerant no-op (this also
|
|
854
|
+
// keeps a locked migration carrying the statement replayable).
|
|
855
|
+
if (ifExists) return;
|
|
856
|
+
cursor.fail(
|
|
857
|
+
`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})`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
795
861
|
if (!ifExists) {
|
|
796
862
|
cursor.fail(`DROP INDEX ${name}: index does not exist at this point`);
|
|
797
863
|
}
|
|
@@ -825,15 +891,16 @@ export function applyMigrationSql(
|
|
|
825
891
|
sql: string,
|
|
826
892
|
source: string,
|
|
827
893
|
droppedTables: Set<string> = new Set<string>(),
|
|
894
|
+
options: ApplyMigrationSqlOptions = {},
|
|
828
895
|
): void {
|
|
829
896
|
for (const tokens of tokenizeStatements(sql, source)) {
|
|
830
897
|
const cursor = new Cursor(tokens, source);
|
|
831
898
|
const head = cursor.next();
|
|
832
899
|
const word = head.kind === 'word' ? head.text.toUpperCase() : '';
|
|
833
900
|
if (word === 'CREATE') {
|
|
834
|
-
parseCreate(cursor, tables, droppedTables, source);
|
|
901
|
+
parseCreate(cursor, tables, droppedTables, source, options);
|
|
835
902
|
} else if (word === 'ALTER') {
|
|
836
|
-
parseAlterTable(cursor, tables, source);
|
|
903
|
+
parseAlterTable(cursor, tables, source, options);
|
|
837
904
|
} else if (word === 'DROP') {
|
|
838
905
|
parseDrop(cursor, tables, droppedTables);
|
|
839
906
|
} else {
|