@syncular/client 0.19.0 → 0.20.0
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 +101 -6
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +32 -8
- package/dist/client.d.ts +3 -0
- package/dist/client.js +57 -1
- 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/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +41 -1
- 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 +39 -13
- package/src/client.ts +72 -1
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/progress.ts +62 -0
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +79 -1
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
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;
|
|
@@ -217,6 +243,10 @@ function inWorkerContext(): boolean {
|
|
|
217
243
|
/** One registered VFS per pool directory, reused across opens. */
|
|
218
244
|
const sahPools = new Map<string, Promise<SahPoolUtil>>();
|
|
219
245
|
|
|
246
|
+
// SAH pools share an I/O method table within the WASM runtime. Keep the
|
|
247
|
+
// installed callback alive for that runtime, including across database closes.
|
|
248
|
+
const sahIoMethods = new Map<number, SqliteIoMethods>();
|
|
249
|
+
|
|
220
250
|
function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
|
|
221
251
|
const detail = error instanceof Error ? error.message : String(error);
|
|
222
252
|
const normalized = detail.toLowerCase();
|
|
@@ -306,5 +336,53 @@ export async function openPersistentWasmDatabase(
|
|
|
306
336
|
sahPools.set(directory, pool);
|
|
307
337
|
}
|
|
308
338
|
const util = await pool;
|
|
309
|
-
|
|
339
|
+
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
340
|
+
const { capi, wasm } = sqlite3;
|
|
341
|
+
const stack = wasm.pstack.pointer;
|
|
342
|
+
try {
|
|
343
|
+
// Do this before the first SQL statement, when SQLite checks for a hot
|
|
344
|
+
// journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
|
|
345
|
+
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
346
|
+
// excludes other owners; report no competing writer, as the other OPFS
|
|
347
|
+
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
348
|
+
const output = wasm.pstack.allocPtr();
|
|
349
|
+
if (
|
|
350
|
+
db.pointer === undefined ||
|
|
351
|
+
capi.sqlite3_file_control(
|
|
352
|
+
db.pointer,
|
|
353
|
+
'main',
|
|
354
|
+
capi.SQLITE_FCNTL_FILE_POINTER,
|
|
355
|
+
output,
|
|
356
|
+
) !== 0 ||
|
|
357
|
+
!wasm.peekPtr(output)
|
|
358
|
+
) {
|
|
359
|
+
throw new ClientSyncError(
|
|
360
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
361
|
+
'Could not configure OPFS crash recovery',
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
365
|
+
const methodsPointer = file.$pMethods;
|
|
366
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
367
|
+
if (!methodsPointer) {
|
|
368
|
+
throw new ClientSyncError(
|
|
369
|
+
STORAGE_UNAVAILABLE_CODE,
|
|
370
|
+
'Could not configure OPFS crash recovery',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
374
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
375
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
376
|
+
wasm.poke32(reserved, 0);
|
|
377
|
+
return 0;
|
|
378
|
+
});
|
|
379
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
380
|
+
}
|
|
381
|
+
return new WasmClientDatabase(db, sqlite3);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
db.close();
|
|
384
|
+
throw error;
|
|
385
|
+
} finally {
|
|
386
|
+
wasm.pstack.restore(stack);
|
|
387
|
+
}
|
|
310
388
|
}
|
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;
|