@syncular/client 0.19.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 +118 -6
- package/dist/apply.d.ts +16 -7
- package/dist/apply.js +83 -37
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +7 -4
- package/dist/client.js +121 -35
- package/dist/database.d.ts +3 -2
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +44 -4
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +99 -47
- package/src/bun-database.ts +6 -2
- package/src/client.ts +153 -38
- package/src/database.ts +7 -2
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/node-database.ts +6 -2
- package/src/progress.ts +62 -0
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +86 -4
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
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/transport.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SegmentFetchRequest {
|
|
|
22
22
|
readonly table: string;
|
|
23
23
|
/** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
|
|
24
24
|
readonly requestedScopesJson: string;
|
|
25
|
+
readonly onProgress?: (bytesReceived: number) => void;
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
28
|
* Fetch segment bytes from the direct endpoint (§5.5). `fetchUrl`, when
|
|
@@ -33,7 +34,7 @@ export interface SegmentFetchRequest {
|
|
|
33
34
|
*/
|
|
34
35
|
export interface SegmentDownloader {
|
|
35
36
|
(request: SegmentFetchRequest): Promise<Uint8Array>;
|
|
36
|
-
readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
|
|
37
|
+
readonly fetchUrl?: (url: string, onProgress?: (bytesReceived: number) => void) => Promise<Uint8Array>;
|
|
37
38
|
}
|
|
38
39
|
export interface RealtimeHandlers {
|
|
39
40
|
/** JSON control frame (§8.1): hello / sync / heartbeat / unknown. */
|
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}`);
|
|
@@ -122,6 +122,9 @@ function inWorkerContext() {
|
|
|
122
122
|
}
|
|
123
123
|
/** One registered VFS per pool directory, reused across opens. */
|
|
124
124
|
const sahPools = new Map();
|
|
125
|
+
// SAH pools share an I/O method table within the WASM runtime. Keep the
|
|
126
|
+
// installed callback alive for that runtime, including across database closes.
|
|
127
|
+
const sahIoMethods = new Map();
|
|
125
128
|
function opfsSahPoolError(error, directory) {
|
|
126
129
|
const detail = error instanceof Error ? error.message : String(error);
|
|
127
130
|
const normalized = detail.toLowerCase();
|
|
@@ -187,5 +190,42 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
187
190
|
sahPools.set(directory, pool);
|
|
188
191
|
}
|
|
189
192
|
const util = await pool;
|
|
190
|
-
|
|
193
|
+
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
194
|
+
const { capi, wasm } = sqlite3;
|
|
195
|
+
const stack = wasm.pstack.pointer;
|
|
196
|
+
try {
|
|
197
|
+
// Do this before the first SQL statement, when SQLite checks for a hot
|
|
198
|
+
// journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
|
|
199
|
+
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
200
|
+
// excludes other owners; report no competing writer, as the other OPFS
|
|
201
|
+
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
202
|
+
const output = wasm.pstack.allocPtr();
|
|
203
|
+
if (db.pointer === undefined ||
|
|
204
|
+
capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
|
|
205
|
+
!wasm.peekPtr(output)) {
|
|
206
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
207
|
+
}
|
|
208
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
209
|
+
const methodsPointer = file.$pMethods;
|
|
210
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
211
|
+
if (!methodsPointer) {
|
|
212
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
213
|
+
}
|
|
214
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
215
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
216
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
217
|
+
wasm.poke32(reserved, 0);
|
|
218
|
+
return 0;
|
|
219
|
+
});
|
|
220
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
221
|
+
}
|
|
222
|
+
return new WasmClientDatabase(db, sqlite3);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
db.close();
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
wasm.pstack.restore(stack);
|
|
230
|
+
}
|
|
191
231
|
}
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type WorkerInitConfig } from './worker-protocol.js';
|
|
|
4
4
|
export interface SyncWorkerOverrides {
|
|
5
5
|
/** Database factory indirection: tests inject bun:sqlite here. */
|
|
6
6
|
readonly openDatabase?: (config: WorkerInitConfig) => Promise<ClientDatabase> | ClientDatabase;
|
|
7
|
+
/** Retry deadline indirection for deterministic worker-host tests. */
|
|
8
|
+
readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
|
|
7
9
|
readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
|
|
8
10
|
readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
|
|
9
11
|
readonly createRealtime?: (config: WorkerInitConfig, clientId: string) => RealtimeConnector | undefined;
|
package/dist/worker-entry.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { SyncClient } from './client.js';
|
|
17
17
|
import { withClientDiagnosticsHost, } from './diagnostics.js';
|
|
18
18
|
import { encryptionConfigFromKeyring } from './encryption.js';
|
|
19
|
-
import { ClientSyncError } from './errors.js';
|
|
19
|
+
import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
|
|
20
20
|
import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
|
|
21
21
|
import { openPersistentWasmDatabase } from './wasm-database.js';
|
|
22
22
|
import { WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
@@ -170,9 +170,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
170
170
|
return Object.assign(gateOffline(inner), {
|
|
171
171
|
...(fetchUrl !== undefined
|
|
172
172
|
? {
|
|
173
|
-
fetchUrl: (url) => {
|
|
173
|
+
fetchUrl: (url, onProgress) => {
|
|
174
174
|
assertOnline();
|
|
175
|
-
return fetchUrl(url);
|
|
175
|
+
return fetchUrl(url, onProgress);
|
|
176
176
|
},
|
|
177
177
|
}
|
|
178
178
|
: {}),
|
|
@@ -183,10 +183,33 @@ export function startSyncWorker(overrides = {}) {
|
|
|
183
183
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
184
184
|
}
|
|
185
185
|
autoSync = config.autoSync ?? true;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
for (let attempt = 0;; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
database =
|
|
189
|
+
overrides.openDatabase !== undefined
|
|
190
|
+
? await overrides.openDatabase(config)
|
|
191
|
+
: await defaultOpenDatabase(config);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (config.database.mode !== 'persistent' ||
|
|
196
|
+
!(error instanceof ClientSyncError) ||
|
|
197
|
+
error.code !== STORAGE_BUSY_CODE ||
|
|
198
|
+
!error.retryable ||
|
|
199
|
+
attempt === 6) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
// Retain the leader lease while the previous worker's OPFS handles
|
|
203
|
+
// drain. Only opening is retried; core initialization has not started.
|
|
204
|
+
const delayMs = Math.min(50 * 2 ** attempt, 1000);
|
|
205
|
+
if (overrides.waitForStorageRetry !== undefined) {
|
|
206
|
+
await overrides.waitForStorageRetry(delayMs);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
190
213
|
const transport = gateOffline(overrides.createTransport !== undefined
|
|
191
214
|
? overrides.createTransport(config)
|
|
192
215
|
: httpSyncTransport(config.endpoints.syncUrl));
|
|
@@ -245,6 +268,10 @@ export function startSyncWorker(overrides = {}) {
|
|
|
245
268
|
post({ t: 'event', event: { kind: 'presence', scopeKey } });
|
|
246
269
|
},
|
|
247
270
|
});
|
|
271
|
+
started.onProgress((progress) => {
|
|
272
|
+
if (!closed)
|
|
273
|
+
post({ t: 'event', event: { kind: 'progress', progress } });
|
|
274
|
+
});
|
|
248
275
|
started.onChange((batch) => {
|
|
249
276
|
if (!closed)
|
|
250
277
|
post({ t: 'event', event: { kind: 'change', batch } });
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
1
2
|
import type { PromiseMethods } from './client.js';
|
|
2
3
|
/**
|
|
3
4
|
* Main-thread side of the worker mode and the
|
|
@@ -179,6 +180,8 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
179
180
|
* unsubscribe function.
|
|
180
181
|
*/
|
|
181
182
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
183
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
184
|
+
progressSnapshot(): SyncProgress | undefined;
|
|
182
185
|
onChange(listener: ClientChangeListener): () => void;
|
|
183
186
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
184
187
|
/**
|
package/dist/worker-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProgressEmitter, } from './progress.js';
|
|
1
2
|
import { registerDevtools } from './devtools.js';
|
|
2
3
|
import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
|
|
3
4
|
import { ClientSyncError } from './errors.js';
|
|
@@ -68,6 +69,7 @@ export class SyncClientHandle {
|
|
|
68
69
|
#core;
|
|
69
70
|
#follower;
|
|
70
71
|
#invalidation;
|
|
72
|
+
#progress = new ProgressEmitter();
|
|
71
73
|
#changes;
|
|
72
74
|
#presence;
|
|
73
75
|
#diagnostics;
|
|
@@ -156,7 +158,10 @@ export class SyncClientHandle {
|
|
|
156
158
|
}
|
|
157
159
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
158
160
|
__dispatchEvent(event) {
|
|
159
|
-
if (event.kind === '
|
|
161
|
+
if (event.kind === 'progress') {
|
|
162
|
+
this.#progress.emit(event.progress);
|
|
163
|
+
}
|
|
164
|
+
else if (event.kind === 'presence') {
|
|
160
165
|
for (const listener of this.#presence) {
|
|
161
166
|
try {
|
|
162
167
|
listener(event.scopeKey);
|
|
@@ -189,6 +194,12 @@ export class SyncClientHandle {
|
|
|
189
194
|
onInvalidate(listener) {
|
|
190
195
|
return this.#invalidation.on(listener);
|
|
191
196
|
}
|
|
197
|
+
onProgress(listener) {
|
|
198
|
+
return this.#progress.on(listener);
|
|
199
|
+
}
|
|
200
|
+
progressSnapshot() {
|
|
201
|
+
return this.#progress.snapshot();
|
|
202
|
+
}
|
|
192
203
|
onChange(listener) {
|
|
193
204
|
return this.#changes.on(listener);
|
|
194
205
|
}
|
|
@@ -349,6 +360,7 @@ export class SyncClientHandle {
|
|
|
349
360
|
if (this.#closed)
|
|
350
361
|
return;
|
|
351
362
|
this.#closed = true;
|
|
363
|
+
this.#progress.clear();
|
|
352
364
|
this.#devtoolsUnregister();
|
|
353
365
|
if (this.#follower !== undefined) {
|
|
354
366
|
this.#follower.close();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SyncProgress } from './progress.js';
|
|
1
2
|
/**
|
|
2
3
|
* The worker RPC protocol: the whole
|
|
3
4
|
* client core runs in a Web Worker; the UI thread talks to it through
|
|
@@ -151,6 +152,9 @@ export interface WorkerErrorShape {
|
|
|
151
152
|
readonly retryable: boolean;
|
|
152
153
|
}
|
|
153
154
|
export type SyncWorkerEvent = {
|
|
155
|
+
readonly kind: 'progress';
|
|
156
|
+
readonly progress: SyncProgress;
|
|
157
|
+
} | {
|
|
154
158
|
readonly kind: 'sync-needed';
|
|
155
159
|
readonly reason: 'startup' | 'hello' | WakeReason;
|
|
156
160
|
} | {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
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,9 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.20.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.20.1",
|
|
96
|
+
"playwright": "1.63.0"
|
|
96
97
|
}
|
|
97
98
|
}
|