@syncular/client 0.30.10 → 0.30.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/src/previous-version.ts
CHANGED
|
@@ -453,12 +453,12 @@ export function readPreviousVersionContainer(
|
|
|
453
453
|
db: ClientDatabase,
|
|
454
454
|
): PreviousVersionRecord | undefined {
|
|
455
455
|
const meta = db.query(
|
|
456
|
-
"SELECT 1 AS present FROM sqlite_master WHERE type
|
|
456
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
|
457
457
|
[CONTAINER_META_TABLE],
|
|
458
458
|
);
|
|
459
459
|
if (meta.length === 0) return undefined;
|
|
460
460
|
const raw = db.query(
|
|
461
|
-
`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id
|
|
461
|
+
`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id=1`,
|
|
462
462
|
)[0]?.record;
|
|
463
463
|
if (raw === undefined) return undefined;
|
|
464
464
|
try {
|
|
@@ -504,13 +504,11 @@ function decodeRecord(value: string): PreviousVersionRecord {
|
|
|
504
504
|
|
|
505
505
|
function writeRecord(db: ClientDatabase, record: PreviousVersionRecord): void {
|
|
506
506
|
db.exec(
|
|
507
|
-
`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
|
|
508
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
509
|
-
record TEXT NOT NULL)`,
|
|
507
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)}(id INTEGER PRIMARY KEY CHECK(id=1),record TEXT NOT NULL)`,
|
|
510
508
|
);
|
|
511
509
|
db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
|
|
512
510
|
db.exec(
|
|
513
|
-
`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,
|
|
511
|
+
`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,record)VALUES(1,?)`,
|
|
514
512
|
[JSON.stringify(record)],
|
|
515
513
|
);
|
|
516
514
|
}
|
|
@@ -541,9 +539,7 @@ export function capturePreviousVersion(
|
|
|
541
539
|
const discovered = new Set(
|
|
542
540
|
replica
|
|
543
541
|
.query(
|
|
544
|
-
|
|
545
|
-
AND name NOT LIKE '_syncular_%'
|
|
546
|
-
AND name NOT LIKE 'sqlite_%'`,
|
|
542
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
|
|
547
543
|
)
|
|
548
544
|
.map((row) => String(row.name)),
|
|
549
545
|
);
|
|
@@ -567,7 +563,7 @@ export function capturePreviousVersion(
|
|
|
567
563
|
for (const table of tables) {
|
|
568
564
|
const probeRows = Number(
|
|
569
565
|
replica.query(
|
|
570
|
-
`SELECT COUNT(*)
|
|
566
|
+
`SELECT COUNT(*)AS count FROM(SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`,
|
|
571
567
|
[config.maxRows + 1],
|
|
572
568
|
)[0]?.count ?? 0,
|
|
573
569
|
);
|
|
@@ -584,15 +580,12 @@ export function capturePreviousVersion(
|
|
|
584
580
|
.join(' + ');
|
|
585
581
|
if (table.columns.length === 0) continue;
|
|
586
582
|
const oversized = replica.query(
|
|
587
|
-
`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
|
|
588
|
-
WHERE (${byteSum}) > ? LIMIT 1`,
|
|
583
|
+
`SELECT 1 AS hit FROM ${quoteIdent(table.name)} WHERE(${byteSum})>? LIMIT 1`,
|
|
589
584
|
[config.maxRowBytes],
|
|
590
585
|
);
|
|
591
586
|
if (oversized.length > 0) return over(measuredRows, measuredBytes);
|
|
592
587
|
const total = replica.query(
|
|
593
|
-
`SELECT COALESCE(SUM(bytes),
|
|
594
|
-
SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
|
|
595
|
-
)`,
|
|
588
|
+
`SELECT COALESCE(SUM(bytes),0)AS total FROM(SELECT(${byteSum})AS bytes FROM ${quoteIdent(table.name)} LIMIT ?)`,
|
|
596
589
|
[perTableRows[index] ?? 0],
|
|
597
590
|
)[0]?.total;
|
|
598
591
|
measuredBytes += Number(total ?? 0);
|
|
@@ -604,11 +597,7 @@ export function capturePreviousVersion(
|
|
|
604
597
|
container.transaction(() => {
|
|
605
598
|
dropPreviousVersionContainer(container);
|
|
606
599
|
container.exec(
|
|
607
|
-
`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
|
|
608
|
-
tbl TEXT NOT NULL,
|
|
609
|
-
row_id TEXT NOT NULL,
|
|
610
|
-
payload TEXT NOT NULL,
|
|
611
|
-
PRIMARY KEY (tbl, row_id))`,
|
|
600
|
+
`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)}(tbl TEXT NOT NULL,row_id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(tbl,row_id))`,
|
|
612
601
|
);
|
|
613
602
|
for (const table of tables) {
|
|
614
603
|
const selectColumns = table.columns
|
|
@@ -617,8 +606,7 @@ export function capturePreviousVersion(
|
|
|
617
606
|
let lastRowId = -1;
|
|
618
607
|
for (;;) {
|
|
619
608
|
const rows = replica.query(
|
|
620
|
-
`SELECT rowid AS _rid
|
|
621
|
-
WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
|
|
609
|
+
`SELECT rowid AS _rid,${selectColumns} FROM ${quoteIdent(table.name)} WHERE rowid>? ORDER BY rowid ASC LIMIT ?`,
|
|
622
610
|
[lastRowId, COPY_BATCH_ROWS],
|
|
623
611
|
);
|
|
624
612
|
if (rows.length === 0) break;
|
|
@@ -632,8 +620,7 @@ export function capturePreviousVersion(
|
|
|
632
620
|
payload[column.name] = rowValueToJson(value);
|
|
633
621
|
}
|
|
634
622
|
container.exec(
|
|
635
|
-
`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,
|
|
636
|
-
VALUES (?, ?, ?)`,
|
|
623
|
+
`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,row_id,payload)VALUES(?,?,?)`,
|
|
637
624
|
[table.name, rowIdText(table, row), JSON.stringify(payload)],
|
|
638
625
|
);
|
|
639
626
|
}
|
|
@@ -703,8 +690,7 @@ export function readPreviousVersionRows(
|
|
|
703
690
|
? ''
|
|
704
691
|
: ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
|
|
705
692
|
const rows = db.query(
|
|
706
|
-
`SELECT row_id,
|
|
707
|
-
WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`,
|
|
693
|
+
`SELECT row_id,payload FROM ${quoteIdent(CONTAINER_TABLE)} WHERE tbl=?${where} ORDER BY row_id ASC LIMIT ?`,
|
|
708
694
|
[table.name, ...rowIds, limit + 1],
|
|
709
695
|
);
|
|
710
696
|
const truncated = rows.length > limit;
|
|
@@ -738,7 +724,7 @@ export function writePreviousVersionRefusal(
|
|
|
738
724
|
|
|
739
725
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
740
726
|
export function clearPreviousVersionRefusal(db: ClientDatabase): void {
|
|
741
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
727
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
742
728
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
743
729
|
]);
|
|
744
730
|
}
|
|
@@ -906,7 +892,7 @@ export function writePreviousVersionAudit(
|
|
|
906
892
|
|
|
907
893
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
908
894
|
export function clearPreviousVersionAudit(db: ClientDatabase): void {
|
|
909
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
895
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
910
896
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
911
897
|
]);
|
|
912
898
|
}
|
package/src/schema.ts
CHANGED
|
@@ -92,17 +92,22 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
|
92
92
|
'trigram',
|
|
93
93
|
]);
|
|
94
94
|
|
|
95
|
+
function invalidSchema(message: string): never {
|
|
96
|
+
throw new Error(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
95
99
|
export function compileClientSchema(
|
|
96
100
|
schema: ClientSchema,
|
|
97
101
|
): CompiledClientSchema {
|
|
98
102
|
const tables = new Map<string, CompiledClientTable>();
|
|
99
103
|
const schemaObjectNames = new Set<string>();
|
|
100
104
|
for (const table of schema.tables) {
|
|
105
|
+
const tableLabel = `table ${table.name}`;
|
|
101
106
|
if (tables.has(table.name)) {
|
|
102
|
-
|
|
107
|
+
invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
|
|
103
108
|
}
|
|
104
109
|
if (schemaObjectNames.has(table.name)) {
|
|
105
|
-
|
|
110
|
+
invalidSchema(
|
|
106
111
|
`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`,
|
|
107
112
|
);
|
|
108
113
|
}
|
|
@@ -110,36 +115,32 @@ export function compileClientSchema(
|
|
|
110
115
|
const columnIndex = new Map<string, number>();
|
|
111
116
|
table.columns.forEach((column, index) => {
|
|
112
117
|
if (columnIndex.has(column.name)) {
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
invalidSchema(
|
|
119
|
+
`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`,
|
|
115
120
|
);
|
|
116
121
|
}
|
|
117
122
|
columnIndex.set(column.name, index);
|
|
118
123
|
});
|
|
119
124
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
120
125
|
if (primaryKeyIndex === undefined) {
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
invalidSchema(
|
|
127
|
+
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
123
128
|
);
|
|
124
129
|
}
|
|
125
130
|
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
126
131
|
// string every implementation produces identically and local storage
|
|
127
132
|
// resolves without deferring to a SQLite build.
|
|
128
|
-
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
133
|
+
const primaryKeyColumn = table.columns[primaryKeyIndex] as RowColumn;
|
|
129
134
|
if (
|
|
130
|
-
|
|
131
|
-
primaryKeyColumn.type !== 'string' &&
|
|
132
|
-
primaryKeyColumn.type !== 'integer' &&
|
|
133
|
-
primaryKeyColumn.type !== 'boolean' &&
|
|
134
|
-
primaryKeyColumn.type !== 'json'
|
|
135
|
+
!['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)
|
|
135
136
|
) {
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
invalidSchema(
|
|
138
|
+
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`,
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
if (table.scopes.length === 0) {
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
invalidSchema(
|
|
143
|
+
`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`,
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
const scopeColumnByVariable = new Map<string, string>();
|
|
@@ -148,22 +149,22 @@ export function compileClientSchema(
|
|
|
148
149
|
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
149
150
|
const match = PATTERN_RE.exec(pattern);
|
|
150
151
|
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
invalidSchema(
|
|
153
|
+
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
const prefix = match[1];
|
|
156
157
|
const variable = match[2];
|
|
157
158
|
const column = typeof spec === 'string' ? variable : spec.column;
|
|
158
159
|
if (!columnIndex.has(column)) {
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
invalidSchema(
|
|
161
|
+
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
|
|
161
162
|
);
|
|
162
163
|
}
|
|
163
164
|
const existing = scopeColumnByVariable.get(variable);
|
|
164
165
|
if (existing !== undefined && existing !== column) {
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
invalidSchema(
|
|
167
|
+
`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
|
|
167
168
|
);
|
|
168
169
|
}
|
|
169
170
|
scopeColumnByVariable.set(variable, column);
|
|
@@ -183,61 +184,34 @@ export function compileClientSchema(
|
|
|
183
184
|
});
|
|
184
185
|
for (const alias of ambiguous) columnIndexByCamel.delete(alias);
|
|
185
186
|
const indexes = table.indexes ?? [];
|
|
186
|
-
for (const index of indexes) {
|
|
187
|
-
if (schemaObjectNames.has(index.name)) {
|
|
188
|
-
throw new Error(
|
|
189
|
-
`table ${table.name}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
schemaObjectNames.add(index.name);
|
|
193
|
-
for (const column of index.columns) {
|
|
194
|
-
if (!columnIndex.has(column)) {
|
|
195
|
-
throw new Error(
|
|
196
|
-
`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
187
|
const ftsIndexes = table.ftsIndexes ?? [];
|
|
202
|
-
for (const index of ftsIndexes) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
207
|
-
}
|
|
188
|
+
for (const index of [...indexes, ...ftsIndexes]) {
|
|
189
|
+
const fts = 'tokenize' in index;
|
|
190
|
+
const label = `${tableLabel}: ${fts ? 'FTS projection' : 'index'} ${JSON.stringify(index.name)}`;
|
|
191
|
+
if (schemaObjectNames.has(index.name))
|
|
192
|
+
invalidSchema(`${label} conflicts with another schema object`);
|
|
208
193
|
schemaObjectNames.add(index.name);
|
|
209
|
-
if (index.columns.length
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
seenColumns.add(columnName);
|
|
222
|
-
const column = table.columns.find(
|
|
223
|
-
(candidate) => candidate.name === columnName,
|
|
224
|
-
);
|
|
225
|
-
if (column === undefined) {
|
|
226
|
-
throw new Error(
|
|
227
|
-
`table ${table.name}: FTS projection ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(columnName)}`,
|
|
194
|
+
if (fts && (!index.columns.length || index.columns.length > 32))
|
|
195
|
+
invalidSchema(`${label} needs between 1 and 32 columns`);
|
|
196
|
+
const seen = new Set<string>();
|
|
197
|
+
for (const name of index.columns) {
|
|
198
|
+
if (fts && seen.has(name))
|
|
199
|
+
invalidSchema(`${label} repeats column ${JSON.stringify(name)}`);
|
|
200
|
+
seen.add(name);
|
|
201
|
+
const column = table.columns[columnIndex.get(name) ?? -1];
|
|
202
|
+
if (!column)
|
|
203
|
+
invalidSchema(
|
|
204
|
+
`${label} names unknown column ${JSON.stringify(name)}`,
|
|
228
205
|
);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
`table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`,
|
|
206
|
+
if (fts && localColumnType(column) !== 'string')
|
|
207
|
+
invalidSchema(
|
|
208
|
+
`${label} column ${JSON.stringify(name)} must have string type`,
|
|
233
209
|
);
|
|
234
|
-
}
|
|
235
210
|
}
|
|
236
|
-
if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
237
|
-
|
|
238
|
-
|
|
211
|
+
if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
212
|
+
invalidSchema(
|
|
213
|
+
`${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
|
|
239
214
|
);
|
|
240
|
-
}
|
|
241
215
|
}
|
|
242
216
|
tables.set(table.name, {
|
|
243
217
|
name: table.name,
|
|
@@ -291,6 +265,21 @@ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
|
|
|
291
265
|
});
|
|
292
266
|
}
|
|
293
267
|
|
|
268
|
+
export function matchesLocalScopes(
|
|
269
|
+
table: CompiledClientTable,
|
|
270
|
+
scopes: Readonly<Record<string, readonly string[]>>,
|
|
271
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
272
|
+
): boolean {
|
|
273
|
+
const entries = Object.entries(scopes);
|
|
274
|
+
return (
|
|
275
|
+
entries.length > 0 &&
|
|
276
|
+
entries.every(([variable, allowed]) => {
|
|
277
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
278
|
+
return column !== undefined && allowed.includes(String(values[column]));
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
294
283
|
export function quoteIdent(name: string): string {
|
|
295
284
|
return `"${name.replaceAll('"', '""')}"`;
|
|
296
285
|
}
|
|
@@ -344,7 +333,7 @@ function createSyncedTable(
|
|
|
344
333
|
});
|
|
345
334
|
columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
|
|
346
335
|
db.exec(
|
|
347
|
-
`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}
|
|
336
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`,
|
|
348
337
|
);
|
|
349
338
|
// Local secondary indexes (CREATE INDEX subset). Created here so both the
|
|
350
339
|
// initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
|
|
@@ -354,7 +343,7 @@ function createSyncedTable(
|
|
|
354
343
|
const unique = index.unique ? 'UNIQUE ' : '';
|
|
355
344
|
const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
|
|
356
345
|
db.exec(
|
|
357
|
-
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}
|
|
346
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`,
|
|
358
347
|
);
|
|
359
348
|
}
|
|
360
349
|
}
|
|
@@ -382,6 +371,9 @@ function createFtsProjection(
|
|
|
382
371
|
table: CompiledClientTable,
|
|
383
372
|
index: ClientFtsIndexSpec,
|
|
384
373
|
): void {
|
|
374
|
+
const projection = quoteIdent(index.name);
|
|
375
|
+
const sourceTable = quoteIdent(table.name);
|
|
376
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
385
377
|
const existed =
|
|
386
378
|
db.query(
|
|
387
379
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
|
@@ -390,7 +382,7 @@ function createFtsProjection(
|
|
|
390
382
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
391
383
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
392
384
|
db.exec(
|
|
393
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS ${
|
|
385
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`,
|
|
394
386
|
);
|
|
395
387
|
|
|
396
388
|
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
@@ -399,11 +391,11 @@ function createFtsProjection(
|
|
|
399
391
|
`_syncular_fts_${index.name}`,
|
|
400
392
|
]).length > 0;
|
|
401
393
|
db.exec(
|
|
402
|
-
`CREATE TABLE IF NOT EXISTS ${mapping}
|
|
394
|
+
`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`,
|
|
403
395
|
);
|
|
404
|
-
const sourceId = `CAST(${
|
|
405
|
-
const newSourceId = `CAST(new.${
|
|
406
|
-
const oldSourceId = `CAST(old.${
|
|
396
|
+
const sourceId = `CAST(${primaryKey} AS TEXT)`;
|
|
397
|
+
const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
|
|
398
|
+
const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
|
|
407
399
|
const projectionColumns = [
|
|
408
400
|
quoteIdent(FTS_SOURCE_ID_COLUMN),
|
|
409
401
|
...indexedColumns,
|
|
@@ -413,10 +405,10 @@ function createFtsProjection(
|
|
|
413
405
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
414
406
|
].join(', ');
|
|
415
407
|
const deleteFor = (value: string) =>
|
|
416
|
-
`DELETE FROM ${
|
|
408
|
+
`DELETE FROM ${projection} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
|
|
417
409
|
const deleteDisplaced = (select: string) =>
|
|
418
|
-
`DELETE FROM ${
|
|
419
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)
|
|
410
|
+
`DELETE FROM ${projection} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));DELETE FROM ${mapping} WHERE source_id IN (${select})`;
|
|
411
|
+
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${projection}(rowid,${projectionColumns})VALUES((SELECT id FROM ${mapping} WHERE source_id=${newSourceId}),${newValues})`;
|
|
420
412
|
|
|
421
413
|
// A clean insert cannot already have a projection row because the source
|
|
422
414
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
@@ -445,7 +437,7 @@ function createFtsProjection(
|
|
|
445
437
|
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
446
438
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
447
439
|
}
|
|
448
|
-
const replacementExists = `EXISTS (SELECT 1 FROM ${
|
|
440
|
+
const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
|
|
449
441
|
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
450
442
|
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
451
443
|
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
@@ -454,7 +446,7 @@ function createFtsProjection(
|
|
|
454
446
|
const match = spec.columns
|
|
455
447
|
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
456
448
|
.join(' AND ');
|
|
457
|
-
return `SELECT ${sourceId} FROM ${
|
|
449
|
+
return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
|
|
458
450
|
});
|
|
459
451
|
const insertGuardCondition = [
|
|
460
452
|
replacementExists,
|
|
@@ -465,17 +457,22 @@ function createFtsProjection(
|
|
|
465
457
|
...displacedByUnique.map(deleteDisplaced),
|
|
466
458
|
].join('; ');
|
|
467
459
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
460
|
+
const createTrigger = (
|
|
461
|
+
suffix: string,
|
|
462
|
+
timing: string,
|
|
463
|
+
body: string,
|
|
464
|
+
when = '',
|
|
465
|
+
) =>
|
|
466
|
+
db.exec(
|
|
467
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`,
|
|
468
|
+
);
|
|
469
|
+
createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
|
|
470
|
+
createTrigger('ai', 'AFTER INSERT', insertNew);
|
|
471
|
+
createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
|
|
472
|
+
createTrigger(
|
|
473
|
+
'au',
|
|
474
|
+
'AFTER UPDATE',
|
|
475
|
+
`${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`,
|
|
479
476
|
);
|
|
480
477
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
481
478
|
// different-PK row about to be displaced through a secondary unique index by
|
|
@@ -486,20 +483,18 @@ function createFtsProjection(
|
|
|
486
483
|
.map((select) => `EXISTS (${select})`)
|
|
487
484
|
.join(' OR ');
|
|
488
485
|
const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
|
|
489
|
-
|
|
490
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
|
|
491
|
-
);
|
|
486
|
+
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
492
487
|
}
|
|
493
488
|
|
|
494
489
|
if (!existed) {
|
|
495
490
|
db.exec(
|
|
496
|
-
`INSERT INTO ${
|
|
491
|
+
`INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`,
|
|
497
492
|
);
|
|
498
493
|
}
|
|
499
494
|
if (!mapped || !existed) {
|
|
500
495
|
db.exec(`DELETE FROM ${mapping}`);
|
|
501
496
|
db.exec(
|
|
502
|
-
`INSERT INTO ${mapping}(id,
|
|
497
|
+
`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`,
|
|
503
498
|
);
|
|
504
499
|
}
|
|
505
500
|
}
|
|
@@ -534,39 +529,39 @@ export function ensureLocalSyncedSchema(
|
|
|
534
529
|
export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
535
530
|
db.transaction(() => {
|
|
536
531
|
ensureFailedOverlaySchema(db);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
532
|
+
const createTable = (name: string, columns: string) =>
|
|
533
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
|
|
534
|
+
createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
|
|
535
|
+
createTable(
|
|
536
|
+
'_syncular_blob_commit_refs',
|
|
537
|
+
'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
|
|
538
|
+
);
|
|
541
539
|
db.exec(
|
|
542
540
|
'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
|
|
543
541
|
);
|
|
544
542
|
db.exec(
|
|
545
|
-
|
|
543
|
+
"INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')",
|
|
544
|
+
);
|
|
545
|
+
createTable(
|
|
546
|
+
'_syncular_outbox',
|
|
547
|
+
'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
|
|
548
|
+
);
|
|
549
|
+
createTable(
|
|
550
|
+
'_syncular_outbox_before_images',
|
|
551
|
+
'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)',
|
|
552
|
+
);
|
|
553
|
+
if (
|
|
554
|
+
!db
|
|
555
|
+
.query('PRAGMA table_info(_syncular_outbox_before_images)')
|
|
556
|
+
.some((row) => row.name === 'delivery_seq')
|
|
557
|
+
)
|
|
558
|
+
db.exec(
|
|
559
|
+
'ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER',
|
|
560
|
+
);
|
|
561
|
+
createTable(
|
|
562
|
+
'_syncular_commit_outcomes',
|
|
563
|
+
"seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT",
|
|
546
564
|
);
|
|
547
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
548
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
549
|
-
client_commit_id TEXT NOT NULL UNIQUE,
|
|
550
|
-
created_at_ms INTEGER NOT NULL,
|
|
551
|
-
operations TEXT NOT NULL)`);
|
|
552
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
|
|
553
|
-
client_commit_id TEXT NOT NULL,
|
|
554
|
-
op_index INTEGER NOT NULL,
|
|
555
|
-
existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
|
|
556
|
-
sync_version INTEGER,
|
|
557
|
-
values_json TEXT,
|
|
558
|
-
PRIMARY KEY(client_commit_id, op_index))`);
|
|
559
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
|
|
560
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
561
|
-
client_commit_id TEXT NOT NULL UNIQUE,
|
|
562
|
-
status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
|
|
563
|
-
recorded_at_ms INTEGER NOT NULL,
|
|
564
|
-
results TEXT NOT NULL,
|
|
565
|
-
operations TEXT,
|
|
566
|
-
resolution TEXT NOT NULL DEFAULT 'active'
|
|
567
|
-
CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
|
|
568
|
-
resolved_at_ms INTEGER,
|
|
569
|
-
replacement_client_commit_id TEXT)`);
|
|
570
565
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
571
566
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
572
567
|
try {
|
|
@@ -576,32 +571,26 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
|
576
571
|
} catch {
|
|
577
572
|
// column already exists — the CREATE above included it
|
|
578
573
|
}
|
|
579
|
-
db.exec(
|
|
580
|
-
ON _syncular_commit_outcomes(resolution,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
requested_scopes TEXT NOT NULL,
|
|
585
|
-
|
|
586
|
-
cursor INTEGER NOT NULL DEFAULT -1,
|
|
587
|
-
bootstrap_state TEXT,
|
|
588
|
-
effective_scopes TEXT,
|
|
589
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
590
|
-
reason_code TEXT)`);
|
|
574
|
+
db.exec(
|
|
575
|
+
'CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)',
|
|
576
|
+
);
|
|
577
|
+
createTable(
|
|
578
|
+
'_syncular_subscriptions',
|
|
579
|
+
"id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT",
|
|
580
|
+
);
|
|
591
581
|
// §4.8 window registry: which units (scope values) of a window base are
|
|
592
582
|
// live locally — the completeness oracle (I3) and the shrink driver
|
|
593
583
|
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
unit TEXT NOT NULL,
|
|
597
|
-
|
|
598
|
-
PRIMARY KEY (base, unit))`);
|
|
584
|
+
createTable(
|
|
585
|
+
'_syncular_windows',
|
|
586
|
+
'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)',
|
|
587
|
+
);
|
|
599
588
|
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
600
589
|
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
tbl TEXT NOT NULL,
|
|
604
|
-
|
|
590
|
+
createTable(
|
|
591
|
+
'_syncular_window_pending_evict',
|
|
592
|
+
'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL',
|
|
593
|
+
);
|
|
605
594
|
});
|
|
606
595
|
}
|
|
607
596
|
|
|
@@ -634,9 +623,7 @@ export function dropAndRecreateSyncedTables(
|
|
|
634
623
|
// Drop virtual tables first. SQLite then removes their shadow tables as one
|
|
635
624
|
// unit, so the generic discovery below never tears an FTS5 projection apart.
|
|
636
625
|
const virtualTables = db.query(
|
|
637
|
-
`SELECT name FROM sqlite_master WHERE type
|
|
638
|
-
AND sql LIKE 'CREATE VIRTUAL TABLE%'
|
|
639
|
-
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
626
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
640
627
|
);
|
|
641
628
|
for (const row of virtualTables) {
|
|
642
629
|
db.exec(
|
|
@@ -645,9 +632,7 @@ export function dropAndRecreateSyncedTables(
|
|
|
645
632
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
646
633
|
}
|
|
647
634
|
const existing = db.query(
|
|
648
|
-
`SELECT name FROM sqlite_master WHERE type
|
|
649
|
-
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
|
|
650
|
-
AND name NOT LIKE 'sqlite_%'`,
|
|
635
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%' AND name NOT LIKE 'sqlite_%'`,
|
|
651
636
|
);
|
|
652
637
|
for (const row of existing) {
|
|
653
638
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
@@ -680,15 +665,8 @@ export function fromSqlValue(column: RowColumn, value: SqlValue): RowValue {
|
|
|
680
665
|
return value !== 0 && value !== false;
|
|
681
666
|
case 'integer':
|
|
682
667
|
return typeof value === 'bigint' ? Number(value) : (value as number);
|
|
683
|
-
|
|
684
|
-
return value as
|
|
685
|
-
case 'bytes':
|
|
686
|
-
case 'crdt':
|
|
687
|
-
return value as Uint8Array;
|
|
688
|
-
case 'string':
|
|
689
|
-
case 'json':
|
|
690
|
-
case 'blob_ref':
|
|
691
|
-
return value as string;
|
|
668
|
+
default:
|
|
669
|
+
return value as RowValue;
|
|
692
670
|
}
|
|
693
671
|
}
|
|
694
672
|
|
|
@@ -710,17 +688,17 @@ export function normalizeRecordKeys(
|
|
|
710
688
|
if (index === undefined) {
|
|
711
689
|
if (key.startsWith('_sync_')) {
|
|
712
690
|
throw invalidRequest(
|
|
713
|
-
`table ${table.name}: ${JSON.stringify(key)} is an internal sync column
|
|
691
|
+
`table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`,
|
|
714
692
|
);
|
|
715
693
|
}
|
|
716
694
|
throw invalidRequest(
|
|
717
|
-
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values
|
|
695
|
+
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`,
|
|
718
696
|
);
|
|
719
697
|
}
|
|
720
698
|
const sqlName = (table.columns[index] as RowColumn).name;
|
|
721
699
|
if (normalized.has(sqlName)) {
|
|
722
700
|
throw invalidRequest(
|
|
723
|
-
`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values
|
|
701
|
+
`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`,
|
|
724
702
|
);
|
|
725
703
|
}
|
|
726
704
|
normalized.set(sqlName, value);
|
|
@@ -810,3 +788,13 @@ export function jsonToRowValue(value: JsonRowValue): RowValue {
|
|
|
810
788
|
}
|
|
811
789
|
return value;
|
|
812
790
|
}
|
|
791
|
+
|
|
792
|
+
/** Convert each column value without changing the row's presence set. */
|
|
793
|
+
export function mapRowValues<T, U>(
|
|
794
|
+
values: Readonly<Record<string, T>>,
|
|
795
|
+
convert: (value: T) => U,
|
|
796
|
+
): Record<string, U> {
|
|
797
|
+
return Object.fromEntries(
|
|
798
|
+
Object.entries(values).map(([key, value]) => [key, convert(value)]),
|
|
799
|
+
);
|
|
800
|
+
}
|