@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.
@@ -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
- return new WasmClientDatabase(new util.OpfsSAHPoolDb(`/${name}.db`), sqlite3);
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
  }
@@ -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: (url: string) => {
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
- database =
266
- overrides.openDatabase !== undefined
267
- ? await overrides.openDatabase(config)
268
- : await defaultOpenDatabase(config);
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
  });
@@ -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 === 'presence') {
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();
@@ -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;