@syncular/client 0.20.0 → 0.20.1
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 +18 -1
- package/dist/apply.d.ts +14 -7
- package/dist/apply.js +75 -53
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +4 -4
- package/dist/client.js +64 -34
- package/dist/database.d.ts +3 -2
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- package/dist/wasm-database.js +3 -3
- package/package.json +3 -3
- package/src/apply.ts +86 -60
- package/src/bun-database.ts +6 -2
- package/src/client.ts +81 -37
- package/src/database.ts +7 -2
- package/src/node-database.ts +6 -2
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/wasm-database.ts +7 -3
package/dist/reactive-store.js
CHANGED
|
@@ -244,7 +244,8 @@ class QueryEntry {
|
|
|
244
244
|
#running = false;
|
|
245
245
|
#requested = false;
|
|
246
246
|
#desiredRevision = 0n;
|
|
247
|
-
#
|
|
247
|
+
#claimError;
|
|
248
|
+
#claimPending = false;
|
|
248
249
|
#offStatus;
|
|
249
250
|
constructor(store, spec, key, cache) {
|
|
250
251
|
this.store = store;
|
|
@@ -270,10 +271,22 @@ class QueryEntry {
|
|
|
270
271
|
for (const coverage of this.spec.coverage ?? []) {
|
|
271
272
|
claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
|
|
272
273
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
const generation = this.#generation;
|
|
275
|
+
this.#claimPending = claims.length > 0;
|
|
276
|
+
if (claims.length > 0)
|
|
277
|
+
void Promise.all(claims).then(() => {
|
|
278
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
279
|
+
return;
|
|
280
|
+
this.#claimPending = false;
|
|
281
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
282
|
+
this.#requestRead();
|
|
283
|
+
}, (error) => {
|
|
284
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
285
|
+
return;
|
|
286
|
+
this.#claimPending = false;
|
|
287
|
+
this.#claimError = errorOf(error);
|
|
288
|
+
this.#requestRead();
|
|
289
|
+
});
|
|
277
290
|
}
|
|
278
291
|
this.#requestRead();
|
|
279
292
|
}
|
|
@@ -300,7 +313,8 @@ class QueryEntry {
|
|
|
300
313
|
this.#running = false;
|
|
301
314
|
this.#requested = false;
|
|
302
315
|
this.#desiredRevision = 0n;
|
|
303
|
-
this.#
|
|
316
|
+
this.#claimError = undefined;
|
|
317
|
+
this.#claimPending = false;
|
|
304
318
|
this.#state = {
|
|
305
319
|
rows: [],
|
|
306
320
|
phase: 'loading',
|
|
@@ -393,9 +407,11 @@ class QueryEntry {
|
|
|
393
407
|
this.#requested = false;
|
|
394
408
|
if (this.store.availabilitySnapshot().state === 'blocked')
|
|
395
409
|
break;
|
|
396
|
-
await this.#claimReady;
|
|
397
410
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
398
411
|
return;
|
|
412
|
+
if (this.#claimError !== undefined)
|
|
413
|
+
throw this.#claimError;
|
|
414
|
+
const claimWasPending = this.#claimPending;
|
|
399
415
|
const snapshot = await this.store.client.querySnapshot({
|
|
400
416
|
sql: this.spec.sql,
|
|
401
417
|
...(this.spec.params !== undefined
|
|
@@ -407,6 +423,8 @@ class QueryEntry {
|
|
|
407
423
|
});
|
|
408
424
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
409
425
|
return;
|
|
426
|
+
if (this.#claimError !== undefined)
|
|
427
|
+
throw this.#claimError;
|
|
410
428
|
const availability = this.store.availabilitySnapshot();
|
|
411
429
|
if (availability.state === 'blocked') {
|
|
412
430
|
this.#publish({
|
|
@@ -421,6 +439,10 @@ class QueryEntry {
|
|
|
421
439
|
this.#requested = true;
|
|
422
440
|
continue;
|
|
423
441
|
}
|
|
442
|
+
if (!snapshot.coverage.complete &&
|
|
443
|
+
claimWasPending &&
|
|
444
|
+
!this.#claimPending)
|
|
445
|
+
this.#requested = true;
|
|
424
446
|
const mappedRows = this.spec.mapRow === undefined
|
|
425
447
|
? snapshot.rows
|
|
426
448
|
: snapshot.rows.map(this.spec.mapRow);
|
|
@@ -736,6 +758,7 @@ export class ReactiveClientStore {
|
|
|
736
758
|
base,
|
|
737
759
|
claims: new Map(),
|
|
738
760
|
appliedKey: '',
|
|
761
|
+
appliedUnits: new Set(),
|
|
739
762
|
scheduled: false,
|
|
740
763
|
running: false,
|
|
741
764
|
requested: false,
|
|
@@ -744,6 +767,13 @@ export class ReactiveClientStore {
|
|
|
744
767
|
this.#windowClaims.set(key, group);
|
|
745
768
|
}
|
|
746
769
|
group.claims.set(owner, new Set(units));
|
|
770
|
+
const alreadyHeld = units.length > 0 &&
|
|
771
|
+
units.every((unit) => group.appliedUnits.has(unit) &&
|
|
772
|
+
(group.inFlightUnits === undefined || group.inFlightUnits.has(unit)));
|
|
773
|
+
if (alreadyHeld) {
|
|
774
|
+
this.#scheduleWindow(group);
|
|
775
|
+
return Promise.resolve();
|
|
776
|
+
}
|
|
747
777
|
const result = new Promise((resolve, reject) => {
|
|
748
778
|
group?.waiters.push({ resolve, reject });
|
|
749
779
|
});
|
|
@@ -779,16 +809,27 @@ export class ReactiveClientStore {
|
|
|
779
809
|
].sort();
|
|
780
810
|
const key = canonicalValue(units);
|
|
781
811
|
if (key !== group.appliedKey) {
|
|
782
|
-
|
|
812
|
+
group.inFlightUnits = new Set(units);
|
|
813
|
+
try {
|
|
814
|
+
await this.client.setWindow(group.base, units);
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
group.inFlightUnits = undefined;
|
|
818
|
+
}
|
|
783
819
|
if (this.#windowClaims.get(baseKey) !== group)
|
|
784
820
|
return;
|
|
785
821
|
group.appliedKey = key;
|
|
822
|
+
group.appliedUnits = new Set(units);
|
|
786
823
|
}
|
|
787
824
|
}
|
|
788
825
|
for (const waiter of group.waiters.splice(0))
|
|
789
826
|
waiter.resolve();
|
|
790
827
|
}
|
|
791
828
|
catch (error) {
|
|
829
|
+
// A failed window edit may already have committed its first eviction chunk.
|
|
830
|
+
// Reconcile through the core before acknowledging any later claim.
|
|
831
|
+
group.appliedKey = '';
|
|
832
|
+
group.appliedUnits = new Set();
|
|
792
833
|
for (const waiter of group.waiters.splice(0))
|
|
793
834
|
waiter.reject(error);
|
|
794
835
|
}
|
package/dist/schema.js
CHANGED
|
@@ -234,6 +234,11 @@ function createFtsProjection(db, table, index) {
|
|
|
234
234
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
235
235
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
236
236
|
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`);
|
|
237
|
+
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
238
|
+
const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
239
|
+
`_syncular_fts_${index.name}`,
|
|
240
|
+
]).length > 0;
|
|
241
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
|
|
237
242
|
const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
238
243
|
const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
239
244
|
const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
@@ -245,8 +250,9 @@ function createFtsProjection(db, table, index) {
|
|
|
245
250
|
newSourceId,
|
|
246
251
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
247
252
|
].join(', ');
|
|
248
|
-
const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
249
|
-
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
253
|
+
const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
|
|
254
|
+
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
|
|
255
|
+
const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
|
|
250
256
|
// A clean insert cannot already have a projection row because the source
|
|
251
257
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
252
258
|
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
@@ -294,9 +300,9 @@ function createFtsProjection(db, table, index) {
|
|
|
294
300
|
...displacedByUnique.map(deleteDisplaced),
|
|
295
301
|
].join('; ');
|
|
296
302
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
|
|
297
|
-
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN
|
|
303
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`);
|
|
298
304
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
|
|
299
|
-
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)};
|
|
305
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`);
|
|
300
306
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
301
307
|
// different-PK row about to be displaced through a secondary unique index by
|
|
302
308
|
// the new values. Only meaningful when the table has a unique index; with
|
|
@@ -311,6 +317,10 @@ function createFtsProjection(db, table, index) {
|
|
|
311
317
|
if (!existed) {
|
|
312
318
|
db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
|
|
313
319
|
}
|
|
320
|
+
if (!mapped || !existed) {
|
|
321
|
+
db.exec(`DELETE FROM ${mapping}`);
|
|
322
|
+
db.exec(`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
|
|
323
|
+
}
|
|
314
324
|
}
|
|
315
325
|
/**
|
|
316
326
|
* Create only the application-owned synced tables, indexes, and FTS
|
|
@@ -424,6 +434,7 @@ export function dropAndRecreateSyncedTables(db, schema) {
|
|
|
424
434
|
AND sql LIKE 'CREATE VIRTUAL TABLE%'
|
|
425
435
|
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
|
|
426
436
|
for (const row of virtualTables) {
|
|
437
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
|
|
427
438
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
428
439
|
}
|
|
429
440
|
const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
|
package/dist/wasm-database.js
CHANGED
|
@@ -65,9 +65,9 @@ class WasmClientDatabase {
|
|
|
65
65
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
66
66
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
67
67
|
* documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
|
|
68
|
-
* row shuttling before the
|
|
68
|
+
* row shuttling before the chunked INSERT…SELECT statements the caller runs).
|
|
69
69
|
*/
|
|
70
|
-
withSqliteImage(bytes, alias, fn) {
|
|
70
|
+
async withSqliteImage(bytes, alias, fn) {
|
|
71
71
|
assertImageAlias(alias);
|
|
72
72
|
const pointer = this.#db.pointer;
|
|
73
73
|
if (pointer === undefined) {
|
|
@@ -84,7 +84,7 @@ class WasmClientDatabase {
|
|
|
84
84
|
if (rc !== 0) {
|
|
85
85
|
throw new ClientSyncError('sync.invalid_request', `sqlite3_deserialize failed with code ${rc} (§5.3)`);
|
|
86
86
|
}
|
|
87
|
-
return fn();
|
|
87
|
+
return await fn();
|
|
88
88
|
}
|
|
89
89
|
finally {
|
|
90
90
|
this.exec(`DETACH ${alias}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.20.
|
|
92
|
+
"@syncular/core": "0.20.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.20.
|
|
95
|
+
"@syncular/server": "0.20.1",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -215,8 +215,8 @@ export function deleteScopedRows(
|
|
|
215
215
|
* scopes (same local-scope-column rule and fail-closed clause as
|
|
216
216
|
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
217
217
|
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* whether unpinned work remains and whether any scoped rows remain. Each
|
|
219
|
+
* call removes at most 1,024 rows; the caller commits before yielding. Also removes the
|
|
220
220
|
* evicted rows' `server_version` with them (E2 — no residual version
|
|
221
221
|
* cache), which is automatic since the version column is per-row.
|
|
222
222
|
*/
|
|
@@ -225,9 +225,9 @@ export function evictScopedRows(
|
|
|
225
225
|
table: CompiledClientTable,
|
|
226
226
|
effective: ScopeMap,
|
|
227
227
|
pinnedRowIds: ReadonlySet<string>,
|
|
228
|
-
): boolean {
|
|
228
|
+
): { remaining: boolean; deferred: boolean } {
|
|
229
229
|
const entries = Object.entries(effective);
|
|
230
|
-
if (entries.length === 0) return false;
|
|
230
|
+
if (entries.length === 0) return { remaining: false, deferred: false };
|
|
231
231
|
const clauses: string[] = [];
|
|
232
232
|
const params: string[] = [];
|
|
233
233
|
for (const [variable, values] of entries) {
|
|
@@ -238,7 +238,7 @@ export function evictScopedRows(
|
|
|
238
238
|
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
|
-
if (values.length === 0) return false;
|
|
241
|
+
if (values.length === 0) return { remaining: false, deferred: false };
|
|
242
242
|
clauses.push(
|
|
243
243
|
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
244
244
|
);
|
|
@@ -251,21 +251,25 @@ export function evictScopedRows(
|
|
|
251
251
|
pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
|
|
252
252
|
params.push(...ids);
|
|
253
253
|
}
|
|
254
|
+
const target = quoteIdent(table.name);
|
|
254
255
|
db.exec(
|
|
255
|
-
`DELETE FROM ${
|
|
256
|
+
`DELETE FROM ${target} WHERE ${pk} IN (
|
|
257
|
+
SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
|
|
258
|
+
)`,
|
|
256
259
|
params,
|
|
257
260
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
261
|
+
const remaining =
|
|
262
|
+
db.query(
|
|
263
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`,
|
|
264
|
+
params,
|
|
265
|
+
).length > 0;
|
|
266
|
+
const deferred =
|
|
267
|
+
remaining ||
|
|
268
|
+
db.query(
|
|
269
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
270
|
+
params.slice(0, params.length - pinnedRowIds.size),
|
|
271
|
+
).length > 0;
|
|
272
|
+
return { remaining, deferred };
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
/** Descriptor fields a sqlite image is validated against (§5.3). */
|
|
@@ -286,14 +290,14 @@ function imageInvalid(detail: string): never {
|
|
|
286
290
|
}
|
|
287
291
|
|
|
288
292
|
/**
|
|
289
|
-
* Apply a §5.3 sqlite
|
|
293
|
+
* Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
|
|
290
294
|
* the in-file metadata against the descriptor, validate the data table's
|
|
291
295
|
* column names/order against the generated schema, run the §5.6
|
|
292
|
-
* first-page clear when fresh, then copy
|
|
293
|
-
*
|
|
296
|
+
* first-page clear when fresh, then bulk-copy each chunk through a
|
|
297
|
+
* primary-key upsert — `_syncular_version` lands in
|
|
294
298
|
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
295
299
|
*/
|
|
296
|
-
export function applySqliteSegment(
|
|
300
|
+
export async function applySqliteSegment(
|
|
297
301
|
db: ClientDatabase,
|
|
298
302
|
schema: CompiledClientSchema,
|
|
299
303
|
table: CompiledClientTable,
|
|
@@ -304,8 +308,9 @@ export function applySqliteSegment(
|
|
|
304
308
|
readonly effective: ScopeMap;
|
|
305
309
|
readonly transaction?: ApplyTransaction;
|
|
306
310
|
readonly onProgress?: (rowsProcessed: number) => void;
|
|
311
|
+
readonly isCurrent?: () => boolean;
|
|
307
312
|
},
|
|
308
|
-
): number {
|
|
313
|
+
): Promise<number> {
|
|
309
314
|
const withImage = db.withSqliteImage?.bind(db);
|
|
310
315
|
if (withImage === undefined) {
|
|
311
316
|
throw new ClientSyncError(
|
|
@@ -316,7 +321,7 @@ export function applySqliteSegment(
|
|
|
316
321
|
if (descriptor.table !== table.name) {
|
|
317
322
|
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
318
323
|
}
|
|
319
|
-
return withImage(bytes, IMAGE_ALIAS, () => {
|
|
324
|
+
return withImage(bytes, IMAGE_ALIAS, async () => {
|
|
320
325
|
// 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
|
|
321
326
|
// SQLite database or lacks the metadata table fails right here.
|
|
322
327
|
let meta: ReturnType<ClientDatabase['query']>;
|
|
@@ -371,38 +376,44 @@ export function applySqliteSegment(
|
|
|
371
376
|
);
|
|
372
377
|
}
|
|
373
378
|
|
|
374
|
-
|
|
379
|
+
const primaryKeys = info.filter((column) => Number(column.pk) > 0);
|
|
380
|
+
if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
|
|
381
|
+
imageInvalid('image primary key does not match generated schema');
|
|
382
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
383
|
+
const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
|
|
384
|
+
if (count !== descriptor.rowCount)
|
|
385
|
+
imageInvalid('image row count does not match descriptor');
|
|
375
386
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
376
387
|
const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
|
|
388
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
377
389
|
const updates = insertNames
|
|
378
|
-
.filter((name) => name !==
|
|
390
|
+
.filter((name) => name !== primaryKey)
|
|
379
391
|
.map((name) => `${name}=excluded.${name}`)
|
|
380
392
|
.join(', ');
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
393
|
+
let after: SqlValue | undefined;
|
|
394
|
+
let applied = 0;
|
|
395
|
+
for (;;) {
|
|
396
|
+
if (options.isCurrent?.() === false) return applied;
|
|
397
|
+
const boundary = db.query(
|
|
398
|
+
`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
399
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
400
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
401
|
+
after === undefined ? [] : [after],
|
|
402
|
+
)[0]?.boundary;
|
|
403
|
+
if (boundary === null) imageInvalid('image primary key must not be null');
|
|
404
|
+
const predicates: string[] = [];
|
|
405
|
+
const params: SqlValue[] = [];
|
|
406
|
+
if (after !== undefined) {
|
|
407
|
+
predicates.push(`${primaryKey} > ?`);
|
|
408
|
+
params.push(after);
|
|
384
409
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
393
|
-
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
394
|
-
after === undefined ? [] : [after],
|
|
395
|
-
)[0]?.boundary;
|
|
396
|
-
const predicates: string[] = [];
|
|
397
|
-
const params: SqlValue[] = [];
|
|
398
|
-
if (after !== undefined) {
|
|
399
|
-
predicates.push(`${primaryKey} > ?`);
|
|
400
|
-
params.push(after);
|
|
401
|
-
}
|
|
402
|
-
if (boundary !== undefined) {
|
|
403
|
-
predicates.push(`${primaryKey} <= ?`);
|
|
404
|
-
params.push(boundary);
|
|
405
|
-
}
|
|
410
|
+
if (boundary !== undefined) {
|
|
411
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
412
|
+
params.push(boundary);
|
|
413
|
+
}
|
|
414
|
+
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
415
|
+
if (after === undefined && options.clearFirst)
|
|
416
|
+
deleteScopedRows(db, table, options.effective);
|
|
406
417
|
db.exec(
|
|
407
418
|
`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
408
419
|
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
@@ -411,18 +422,15 @@ export function applySqliteSegment(
|
|
|
411
422
|
params,
|
|
412
423
|
);
|
|
413
424
|
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
414
|
-
|
|
415
|
-
options.onProgress?.(applied);
|
|
416
|
-
after = boundary;
|
|
417
|
-
}
|
|
418
|
-
if (applied !== descriptor.rowCount) {
|
|
419
|
-
imageInvalid(
|
|
420
|
-
`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
|
|
421
|
-
);
|
|
422
|
-
}
|
|
425
|
+
});
|
|
423
426
|
options.onProgress?.(applied);
|
|
424
|
-
|
|
425
|
-
|
|
427
|
+
await yieldToHost();
|
|
428
|
+
if (boundary === undefined || applied === count) break;
|
|
429
|
+
after = boundary;
|
|
430
|
+
}
|
|
431
|
+
if (applied !== count)
|
|
432
|
+
imageInvalid('image applied row count does not match descriptor');
|
|
433
|
+
return applied;
|
|
426
434
|
});
|
|
427
435
|
}
|
|
428
436
|
|
|
@@ -444,6 +452,7 @@ export async function applyRowsSegment(
|
|
|
444
452
|
readonly effective: ScopeMap;
|
|
445
453
|
readonly transaction?: ApplyTransaction;
|
|
446
454
|
readonly onProgress?: (rowsProcessed: number) => void;
|
|
455
|
+
readonly isCurrent?: () => boolean;
|
|
447
456
|
},
|
|
448
457
|
encryption?: EncryptionConfig,
|
|
449
458
|
): Promise<number> {
|
|
@@ -453,6 +462,7 @@ export async function applyRowsSegment(
|
|
|
453
462
|
const blocks: readonly (readonly SegmentRow[])[] =
|
|
454
463
|
segment.blocks.length > 0 ? segment.blocks : [[]];
|
|
455
464
|
for (const block of blocks) {
|
|
465
|
+
if (options.isCurrent?.() === false) break;
|
|
456
466
|
// §5.11: decrypt this block's rows before opening the sync transaction
|
|
457
467
|
// (WebCrypto is async; the SQLite transaction is not). Decrypt failure
|
|
458
468
|
// aborts before any write in this block.
|
|
@@ -467,6 +477,7 @@ export async function applyRowsSegment(
|
|
|
467
477
|
: row.values;
|
|
468
478
|
rows.push({ values, serverVersion: row.serverVersion });
|
|
469
479
|
}
|
|
480
|
+
if (options.isCurrent?.() === false) break;
|
|
470
481
|
const clearThisBlock = first && options.clearFirst;
|
|
471
482
|
first = false;
|
|
472
483
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
@@ -480,7 +491,22 @@ export async function applyRowsSegment(
|
|
|
480
491
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
481
492
|
}
|
|
482
493
|
});
|
|
494
|
+
options.onProgress?.(applied);
|
|
495
|
+
await yieldToHost();
|
|
483
496
|
}
|
|
484
497
|
options.onProgress?.(applied);
|
|
485
498
|
return applied;
|
|
486
499
|
}
|
|
500
|
+
|
|
501
|
+
/** Let worker messages run only after the current SQLite transaction commits. */
|
|
502
|
+
export function yieldToHost(): Promise<void> {
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
const channel = new MessageChannel();
|
|
505
|
+
channel.port1.onmessage = () => {
|
|
506
|
+
channel.port1.close();
|
|
507
|
+
channel.port2.close();
|
|
508
|
+
resolve();
|
|
509
|
+
};
|
|
510
|
+
channel.port2.postMessage(null);
|
|
511
|
+
});
|
|
512
|
+
}
|
package/src/bun-database.ts
CHANGED
|
@@ -69,7 +69,11 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
69
69
|
* §5.3 image import: bun:sqlite attaches files, not buffers, so the
|
|
70
70
|
* image lands in a private temp file for the duration of the ATTACH.
|
|
71
71
|
*/
|
|
72
|
-
withSqliteImage<T>(
|
|
72
|
+
async withSqliteImage<T>(
|
|
73
|
+
bytes: Uint8Array,
|
|
74
|
+
alias: string,
|
|
75
|
+
fn: () => T | Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
73
77
|
assertImageAlias(alias);
|
|
74
78
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
75
79
|
const path = join(dir, 'segment.db');
|
|
@@ -77,7 +81,7 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
77
81
|
writeFileSync(path, bytes);
|
|
78
82
|
this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
|
|
79
83
|
try {
|
|
80
|
-
return fn();
|
|
84
|
+
return await fn();
|
|
81
85
|
} finally {
|
|
82
86
|
this.db.run(`DETACH DATABASE ${alias}`);
|
|
83
87
|
}
|