@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/src/reactive-store.ts
CHANGED
|
@@ -359,7 +359,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
359
359
|
#running = false;
|
|
360
360
|
#requested = false;
|
|
361
361
|
#desiredRevision = 0n;
|
|
362
|
-
#
|
|
362
|
+
#claimError: Error | undefined;
|
|
363
|
+
#claimPending = false;
|
|
363
364
|
#offStatus: (() => void) | undefined;
|
|
364
365
|
|
|
365
366
|
constructor(
|
|
@@ -397,10 +398,25 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
397
398
|
),
|
|
398
399
|
);
|
|
399
400
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
401
|
+
const generation = this.#generation;
|
|
402
|
+
this.#claimPending = claims.length > 0;
|
|
403
|
+
if (claims.length > 0)
|
|
404
|
+
void Promise.all(claims).then(
|
|
405
|
+
() => {
|
|
406
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
407
|
+
return;
|
|
408
|
+
this.#claimPending = false;
|
|
409
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
410
|
+
this.#requestRead();
|
|
411
|
+
},
|
|
412
|
+
(error: unknown) => {
|
|
413
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
414
|
+
return;
|
|
415
|
+
this.#claimPending = false;
|
|
416
|
+
this.#claimError = errorOf(error);
|
|
417
|
+
this.#requestRead();
|
|
418
|
+
},
|
|
419
|
+
);
|
|
404
420
|
}
|
|
405
421
|
this.#requestRead();
|
|
406
422
|
}
|
|
@@ -426,7 +442,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
426
442
|
this.#running = false;
|
|
427
443
|
this.#requested = false;
|
|
428
444
|
this.#desiredRevision = 0n;
|
|
429
|
-
this.#
|
|
445
|
+
this.#claimError = undefined;
|
|
446
|
+
this.#claimPending = false;
|
|
430
447
|
this.#state = {
|
|
431
448
|
rows: [],
|
|
432
449
|
phase: 'loading',
|
|
@@ -520,9 +537,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
520
537
|
do {
|
|
521
538
|
this.#requested = false;
|
|
522
539
|
if (this.store.availabilitySnapshot().state === 'blocked') break;
|
|
523
|
-
|
|
540
|
+
|
|
524
541
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
525
542
|
return;
|
|
543
|
+
if (this.#claimError !== undefined) throw this.#claimError;
|
|
544
|
+
const claimWasPending = this.#claimPending;
|
|
526
545
|
const snapshot = await this.store.client.querySnapshot<
|
|
527
546
|
Record<string, SqlValue>
|
|
528
547
|
>({
|
|
@@ -536,6 +555,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
536
555
|
});
|
|
537
556
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
538
557
|
return;
|
|
558
|
+
if (this.#claimError !== undefined) throw this.#claimError;
|
|
539
559
|
const availability = this.store.availabilitySnapshot();
|
|
540
560
|
if (availability.state === 'blocked') {
|
|
541
561
|
this.#publish({
|
|
@@ -550,6 +570,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
550
570
|
this.#requested = true;
|
|
551
571
|
continue;
|
|
552
572
|
}
|
|
573
|
+
if (
|
|
574
|
+
!snapshot.coverage.complete &&
|
|
575
|
+
claimWasPending &&
|
|
576
|
+
!this.#claimPending
|
|
577
|
+
)
|
|
578
|
+
this.#requested = true;
|
|
553
579
|
const mappedRows =
|
|
554
580
|
this.spec.mapRow === undefined
|
|
555
581
|
? (snapshot.rows as readonly Row[])
|
|
@@ -625,6 +651,8 @@ interface WindowClaimGroup {
|
|
|
625
651
|
readonly base: WindowBase;
|
|
626
652
|
readonly claims: Map<symbol, ReadonlySet<string>>;
|
|
627
653
|
appliedKey: string;
|
|
654
|
+
appliedUnits: ReadonlySet<string>;
|
|
655
|
+
inFlightUnits?: ReadonlySet<string> | undefined;
|
|
628
656
|
scheduled: boolean;
|
|
629
657
|
running: boolean;
|
|
630
658
|
requested: boolean;
|
|
@@ -889,6 +917,7 @@ export class ReactiveClientStore {
|
|
|
889
917
|
base,
|
|
890
918
|
claims: new Map(),
|
|
891
919
|
appliedKey: '',
|
|
920
|
+
appliedUnits: new Set(),
|
|
892
921
|
scheduled: false,
|
|
893
922
|
running: false,
|
|
894
923
|
requested: false,
|
|
@@ -897,6 +926,17 @@ export class ReactiveClientStore {
|
|
|
897
926
|
this.#windowClaims.set(key, group);
|
|
898
927
|
}
|
|
899
928
|
group.claims.set(owner, new Set(units));
|
|
929
|
+
const alreadyHeld =
|
|
930
|
+
units.length > 0 &&
|
|
931
|
+
units.every(
|
|
932
|
+
(unit) =>
|
|
933
|
+
group.appliedUnits.has(unit) &&
|
|
934
|
+
(group.inFlightUnits === undefined || group.inFlightUnits.has(unit)),
|
|
935
|
+
);
|
|
936
|
+
if (alreadyHeld) {
|
|
937
|
+
this.#scheduleWindow(group);
|
|
938
|
+
return Promise.resolve();
|
|
939
|
+
}
|
|
900
940
|
const result = new Promise<void>((resolve, reject) => {
|
|
901
941
|
group?.waiters.push({ resolve, reject });
|
|
902
942
|
});
|
|
@@ -932,13 +972,23 @@ export class ReactiveClientStore {
|
|
|
932
972
|
].sort();
|
|
933
973
|
const key = canonicalValue(units);
|
|
934
974
|
if (key !== group.appliedKey) {
|
|
935
|
-
|
|
975
|
+
group.inFlightUnits = new Set(units);
|
|
976
|
+
try {
|
|
977
|
+
await this.client.setWindow(group.base, units);
|
|
978
|
+
} finally {
|
|
979
|
+
group.inFlightUnits = undefined;
|
|
980
|
+
}
|
|
936
981
|
if (this.#windowClaims.get(baseKey) !== group) return;
|
|
937
982
|
group.appliedKey = key;
|
|
983
|
+
group.appliedUnits = new Set(units);
|
|
938
984
|
}
|
|
939
985
|
}
|
|
940
986
|
for (const waiter of group.waiters.splice(0)) waiter.resolve();
|
|
941
987
|
} catch (error) {
|
|
988
|
+
// A failed window edit may already have committed its first eviction chunk.
|
|
989
|
+
// Reconcile through the core before acknowledging any later claim.
|
|
990
|
+
group.appliedKey = '';
|
|
991
|
+
group.appliedUnits = new Set();
|
|
942
992
|
for (const waiter of group.waiters.splice(0)) waiter.reject(error);
|
|
943
993
|
} finally {
|
|
944
994
|
group.running = false;
|
package/src/schema.ts
CHANGED
|
@@ -374,6 +374,14 @@ function createFtsProjection(
|
|
|
374
374
|
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
|
|
375
375
|
);
|
|
376
376
|
|
|
377
|
+
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
378
|
+
const mapped =
|
|
379
|
+
db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
380
|
+
`_syncular_fts_${index.name}`,
|
|
381
|
+
]).length > 0;
|
|
382
|
+
db.exec(
|
|
383
|
+
`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
|
|
384
|
+
);
|
|
377
385
|
const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
378
386
|
const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
379
387
|
const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
@@ -386,9 +394,10 @@ function createFtsProjection(
|
|
|
386
394
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
387
395
|
].join(', ');
|
|
388
396
|
const deleteFor = (value: string) =>
|
|
389
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
397
|
+
`DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
|
|
390
398
|
const deleteDisplaced = (select: string) =>
|
|
391
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
399
|
+
`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})`;
|
|
400
|
+
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})`;
|
|
392
401
|
|
|
393
402
|
// A clean insert cannot already have a projection row because the source
|
|
394
403
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
@@ -441,13 +450,13 @@ function createFtsProjection(
|
|
|
441
450
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
|
|
442
451
|
);
|
|
443
452
|
db.exec(
|
|
444
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN
|
|
453
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
|
|
445
454
|
);
|
|
446
455
|
db.exec(
|
|
447
456
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
|
|
448
457
|
);
|
|
449
458
|
db.exec(
|
|
450
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)};
|
|
459
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
|
|
451
460
|
);
|
|
452
461
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
453
462
|
// different-PK row about to be displaced through a secondary unique index by
|
|
@@ -468,6 +477,12 @@ function createFtsProjection(
|
|
|
468
477
|
`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
|
|
469
478
|
);
|
|
470
479
|
}
|
|
480
|
+
if (!mapped || !existed) {
|
|
481
|
+
db.exec(`DELETE FROM ${mapping}`);
|
|
482
|
+
db.exec(
|
|
483
|
+
`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
471
486
|
}
|
|
472
487
|
|
|
473
488
|
/**
|
|
@@ -602,6 +617,9 @@ export function dropAndRecreateSyncedTables(
|
|
|
602
617
|
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
603
618
|
);
|
|
604
619
|
for (const row of virtualTables) {
|
|
620
|
+
db.exec(
|
|
621
|
+
`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`,
|
|
622
|
+
);
|
|
605
623
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
606
624
|
}
|
|
607
625
|
const existing = db.query(
|
package/src/transport.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface SegmentFetchRequest {
|
|
|
32
32
|
readonly table: string;
|
|
33
33
|
/** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
|
|
34
34
|
readonly requestedScopesJson: string;
|
|
35
|
+
readonly onProgress?: (bytesReceived: number) => void;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -44,7 +45,10 @@ export interface SegmentFetchRequest {
|
|
|
44
45
|
*/
|
|
45
46
|
export interface SegmentDownloader {
|
|
46
47
|
(request: SegmentFetchRequest): Promise<Uint8Array>;
|
|
47
|
-
readonly fetchUrl?: (
|
|
48
|
+
readonly fetchUrl?: (
|
|
49
|
+
url: string,
|
|
50
|
+
onProgress?: (bytesReceived: number) => void,
|
|
51
|
+
) => Promise<Uint8Array>;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export interface RealtimeHandlers {
|
package/src/wasm-database.ts
CHANGED
|
@@ -53,6 +53,13 @@ interface SahPoolUtil {
|
|
|
53
53
|
OpfsSAHPoolDb: new (filename: string) => Oo1Database;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
interface SqliteIoMethods {
|
|
57
|
+
installMethod(
|
|
58
|
+
name: 'xCheckReservedLock',
|
|
59
|
+
callback: (file: number, output: number) => number,
|
|
60
|
+
): unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
interface Sqlite3Static {
|
|
57
64
|
oo1: {
|
|
58
65
|
DB: new (filename: string, flags?: string) => Oo1Database;
|
|
@@ -60,6 +67,18 @@ interface Sqlite3Static {
|
|
|
60
67
|
capi: {
|
|
61
68
|
SQLITE_DESERIALIZE_FREEONCLOSE: number;
|
|
62
69
|
SQLITE_DESERIALIZE_READONLY: number;
|
|
70
|
+
SQLITE_FCNTL_FILE_POINTER: number;
|
|
71
|
+
sqlite3_file_control(
|
|
72
|
+
db: number,
|
|
73
|
+
schema: string,
|
|
74
|
+
operation: number,
|
|
75
|
+
output: number,
|
|
76
|
+
): number;
|
|
77
|
+
sqlite3_file: new (pointer: number) => {
|
|
78
|
+
readonly $pMethods: number;
|
|
79
|
+
dispose(): void;
|
|
80
|
+
};
|
|
81
|
+
sqlite3_io_methods: new (pointer: number) => SqliteIoMethods;
|
|
63
82
|
sqlite3_deserialize(
|
|
64
83
|
db: number,
|
|
65
84
|
schema: string,
|
|
@@ -71,6 +90,13 @@ interface Sqlite3Static {
|
|
|
71
90
|
};
|
|
72
91
|
wasm: {
|
|
73
92
|
allocFromTypedArray(bytes: Uint8Array): number;
|
|
93
|
+
peekPtr(pointer: number): number;
|
|
94
|
+
poke32(pointer: number, value: number): unknown;
|
|
95
|
+
pstack: {
|
|
96
|
+
readonly pointer: number;
|
|
97
|
+
allocPtr(): number;
|
|
98
|
+
restore(pointer: number): void;
|
|
99
|
+
};
|
|
74
100
|
};
|
|
75
101
|
installOpfsSAHPoolVfs(options: {
|
|
76
102
|
name?: string;
|
|
@@ -125,9 +151,13 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
125
151
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
126
152
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
127
153
|
* documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
|
|
128
|
-
* row shuttling before the
|
|
154
|
+
* row shuttling before the chunked INSERT…SELECT statements the caller runs).
|
|
129
155
|
*/
|
|
130
|
-
withSqliteImage<T>(
|
|
156
|
+
async withSqliteImage<T>(
|
|
157
|
+
bytes: Uint8Array,
|
|
158
|
+
alias: string,
|
|
159
|
+
fn: () => T | Promise<T>,
|
|
160
|
+
): Promise<T> {
|
|
131
161
|
assertImageAlias(alias);
|
|
132
162
|
const pointer = this.#db.pointer;
|
|
133
163
|
if (pointer === undefined) {
|
|
@@ -156,7 +186,7 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
156
186
|
`sqlite3_deserialize failed with code ${rc} (§5.3)`,
|
|
157
187
|
);
|
|
158
188
|
}
|
|
159
|
-
return fn();
|
|
189
|
+
return await fn();
|
|
160
190
|
} finally {
|
|
161
191
|
this.exec(`DETACH ${alias}`);
|
|
162
192
|
}
|
|
@@ -217,6 +247,10 @@ function inWorkerContext(): boolean {
|
|
|
217
247
|
/** One registered VFS per pool directory, reused across opens. */
|
|
218
248
|
const sahPools = new Map<string, Promise<SahPoolUtil>>();
|
|
219
249
|
|
|
250
|
+
// SAH pools share an I/O method table within the WASM runtime. Keep the
|
|
251
|
+
// installed callback alive for that runtime, including across database closes.
|
|
252
|
+
const sahIoMethods = new Map<number, SqliteIoMethods>();
|
|
253
|
+
|
|
220
254
|
function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
|
|
221
255
|
const detail = error instanceof Error ? error.message : String(error);
|
|
222
256
|
const normalized = detail.toLowerCase();
|
|
@@ -306,5 +340,53 @@ export async function openPersistentWasmDatabase(
|
|
|
306
340
|
sahPools.set(directory, pool);
|
|
307
341
|
}
|
|
308
342
|
const util = await pool;
|
|
309
|
-
|
|
343
|
+
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
344
|
+
const { capi, wasm } = sqlite3;
|
|
345
|
+
const stack = wasm.pstack.pointer;
|
|
346
|
+
try {
|
|
347
|
+
// Do this before the first SQL statement, when SQLite checks for a hot
|
|
348
|
+
// journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
|
|
349
|
+
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
350
|
+
// excludes other owners; report no competing writer, as the other OPFS
|
|
351
|
+
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
352
|
+
const output = wasm.pstack.allocPtr();
|
|
353
|
+
if (
|
|
354
|
+
db.pointer === undefined ||
|
|
355
|
+
capi.sqlite3_file_control(
|
|
356
|
+
db.pointer,
|
|
357
|
+
'main',
|
|
358
|
+
capi.SQLITE_FCNTL_FILE_POINTER,
|
|
359
|
+
output,
|
|
360
|
+
) !== 0 ||
|
|
361
|
+
!wasm.peekPtr(output)
|
|
362
|
+
) {
|
|
363
|
+
throw new ClientSyncError(
|
|
364
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
365
|
+
'Could not configure OPFS crash recovery',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
369
|
+
const methodsPointer = file.$pMethods;
|
|
370
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
371
|
+
if (!methodsPointer) {
|
|
372
|
+
throw new ClientSyncError(
|
|
373
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
374
|
+
'Could not configure OPFS crash recovery',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
378
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
379
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
380
|
+
wasm.poke32(reserved, 0);
|
|
381
|
+
return 0;
|
|
382
|
+
});
|
|
383
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
384
|
+
}
|
|
385
|
+
return new WasmClientDatabase(db, sqlite3);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
db.close();
|
|
388
|
+
throw error;
|
|
389
|
+
} finally {
|
|
390
|
+
wasm.pstack.restore(stack);
|
|
391
|
+
}
|
|
310
392
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
withClientDiagnosticsHost,
|
|
21
21
|
} from './diagnostics';
|
|
22
22
|
import { encryptionConfigFromKeyring } from './encryption';
|
|
23
|
-
import { ClientSyncError } from './errors';
|
|
23
|
+
import { ClientSyncError, STORAGE_BUSY_CODE } from './errors';
|
|
24
24
|
import {
|
|
25
25
|
httpBlobTransport,
|
|
26
26
|
httpSegmentDownloader,
|
|
@@ -51,6 +51,8 @@ export interface SyncWorkerOverrides {
|
|
|
51
51
|
readonly openDatabase?: (
|
|
52
52
|
config: WorkerInitConfig,
|
|
53
53
|
) => Promise<ClientDatabase> | ClientDatabase;
|
|
54
|
+
/** Retry deadline indirection for deterministic worker-host tests. */
|
|
55
|
+
readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
|
|
54
56
|
readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
|
|
55
57
|
readonly createSegments?: (
|
|
56
58
|
config: WorkerInitConfig,
|
|
@@ -244,9 +246,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
244
246
|
return Object.assign(gateOffline(inner), {
|
|
245
247
|
...(fetchUrl !== undefined
|
|
246
248
|
? {
|
|
247
|
-
fetchUrl: (
|
|
249
|
+
fetchUrl: (
|
|
250
|
+
url: string,
|
|
251
|
+
onProgress?: (bytesReceived: number) => void,
|
|
252
|
+
) => {
|
|
248
253
|
assertOnline();
|
|
249
|
-
return fetchUrl(url);
|
|
254
|
+
return fetchUrl(url, onProgress);
|
|
250
255
|
},
|
|
251
256
|
}
|
|
252
257
|
: {}),
|
|
@@ -262,10 +267,33 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
262
267
|
}
|
|
263
268
|
autoSync = config.autoSync ?? true;
|
|
264
269
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
for (let attempt = 0; ; attempt++) {
|
|
271
|
+
try {
|
|
272
|
+
database =
|
|
273
|
+
overrides.openDatabase !== undefined
|
|
274
|
+
? await overrides.openDatabase(config)
|
|
275
|
+
: await defaultOpenDatabase(config);
|
|
276
|
+
break;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (
|
|
279
|
+
config.database.mode !== 'persistent' ||
|
|
280
|
+
!(error instanceof ClientSyncError) ||
|
|
281
|
+
error.code !== STORAGE_BUSY_CODE ||
|
|
282
|
+
!error.retryable ||
|
|
283
|
+
attempt === 6
|
|
284
|
+
) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
// Retain the leader lease while the previous worker's OPFS handles
|
|
288
|
+
// drain. Only opening is retried; core initialization has not started.
|
|
289
|
+
const delayMs = Math.min(50 * 2 ** attempt, 1000);
|
|
290
|
+
if (overrides.waitForStorageRetry !== undefined) {
|
|
291
|
+
await overrides.waitForStorageRetry(delayMs);
|
|
292
|
+
} else {
|
|
293
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
269
297
|
const transport = gateOffline(
|
|
270
298
|
overrides.createTransport !== undefined
|
|
271
299
|
? overrides.createTransport(config)
|
|
@@ -336,6 +364,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
336
364
|
post({ t: 'event', event: { kind: 'presence', scopeKey } });
|
|
337
365
|
},
|
|
338
366
|
});
|
|
367
|
+
started.onProgress((progress) => {
|
|
368
|
+
if (!closed) post({ t: 'event', event: { kind: 'progress', progress } });
|
|
369
|
+
});
|
|
339
370
|
started.onChange((batch) => {
|
|
340
371
|
if (!closed) post({ t: 'event', event: { kind: 'change', batch } });
|
|
341
372
|
});
|
package/src/worker-host.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProgressEmitter,
|
|
3
|
+
type SyncProgress,
|
|
4
|
+
type SyncProgressListener,
|
|
5
|
+
} from './progress';
|
|
1
6
|
import type { PromiseMethods } from './client';
|
|
2
7
|
/**
|
|
3
8
|
* Main-thread side of the worker mode and the
|
|
@@ -273,6 +278,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
273
278
|
#core: LeaderCore | undefined;
|
|
274
279
|
#follower: FollowerLink | undefined;
|
|
275
280
|
readonly #invalidation: InvalidationEmitter;
|
|
281
|
+
readonly #progress = new ProgressEmitter();
|
|
276
282
|
readonly #changes: ChangeEmitter;
|
|
277
283
|
readonly #presence: Set<(scopeKey: string) => void>;
|
|
278
284
|
readonly #diagnostics: ClientDiagnosticsEmitter;
|
|
@@ -376,7 +382,9 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
376
382
|
|
|
377
383
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
378
384
|
__dispatchEvent(event: SyncWorkerEvent): void {
|
|
379
|
-
if (event.kind === '
|
|
385
|
+
if (event.kind === 'progress') {
|
|
386
|
+
this.#progress.emit(event.progress);
|
|
387
|
+
} else if (event.kind === 'presence') {
|
|
380
388
|
for (const listener of this.#presence) {
|
|
381
389
|
try {
|
|
382
390
|
listener(event.scopeKey);
|
|
@@ -409,6 +417,13 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
409
417
|
return this.#invalidation.on(listener);
|
|
410
418
|
}
|
|
411
419
|
|
|
420
|
+
onProgress(listener: SyncProgressListener): () => void {
|
|
421
|
+
return this.#progress.on(listener);
|
|
422
|
+
}
|
|
423
|
+
progressSnapshot(): SyncProgress | undefined {
|
|
424
|
+
return this.#progress.snapshot();
|
|
425
|
+
}
|
|
426
|
+
|
|
412
427
|
onChange(listener: ClientChangeListener): () => void {
|
|
413
428
|
return this.#changes.on(listener);
|
|
414
429
|
}
|
|
@@ -646,6 +661,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
646
661
|
async close(): Promise<void> {
|
|
647
662
|
if (this.#closed) return;
|
|
648
663
|
this.#closed = true;
|
|
664
|
+
this.#progress.clear();
|
|
649
665
|
this.#devtoolsUnregister();
|
|
650
666
|
if (this.#follower !== undefined) {
|
|
651
667
|
this.#follower.close();
|
package/src/worker-protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SyncProgress } from './progress';
|
|
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
|
|
@@ -206,6 +207,7 @@ export interface WorkerErrorShape {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
export type SyncWorkerEvent =
|
|
210
|
+
| { readonly kind: 'progress'; readonly progress: SyncProgress }
|
|
209
211
|
| {
|
|
210
212
|
readonly kind: 'sync-needed';
|
|
211
213
|
readonly reason: 'startup' | 'hello' | WakeReason;
|