@syncular/client 0.30.10 → 0.30.11

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.
@@ -22,6 +22,7 @@
22
22
  * `ClientDatabase`. Browser-only — exercised by the demo, never imported
23
23
  * by bun tests (subpath export `@syncular/client/wasm`).
24
24
  */
25
+ import { webLocksLeaderLock, type LeaderLease } from './leader-lock';
25
26
  import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
26
27
  import {
27
28
  assertImageAlias,
@@ -56,6 +57,8 @@ interface SahPoolUtil {
56
57
  /** RFC 0005: the runtime exposes naming and removal on the pool util. */
57
58
  getFileNames(): string[];
58
59
  unlink(filename: string): boolean;
60
+ pauseVfs(): unknown;
61
+ unpauseVfs(): Promise<unknown>;
59
62
  }
60
63
 
61
64
  interface SqliteIoMethods {
@@ -122,13 +125,16 @@ class WasmClientDatabase implements ClientDatabase {
122
125
  readonly #db: Oo1Database;
123
126
  readonly #sqlite3: Sqlite3Static;
124
127
  /** RFC 0005: present only for a persistent OPFS database. */
125
- readonly #sah: { readonly util: SahPoolUtil } | undefined;
128
+ readonly #sah:
129
+ | { readonly util: SahPoolUtil; readonly lease: LeaderLease }
130
+ | undefined;
131
+ #closed = false;
126
132
  #tx = { depth: 0 };
127
133
 
128
134
  constructor(
129
135
  db: Oo1Database,
130
136
  sqlite3: Sqlite3Static,
131
- sah?: { readonly util: SahPoolUtil },
137
+ sah?: { readonly util: SahPoolUtil; readonly lease: LeaderLease },
132
138
  ) {
133
139
  this.#db = db;
134
140
  this.#sqlite3 = sqlite3;
@@ -236,7 +242,13 @@ class WasmClientDatabase implements ClientDatabase {
236
242
  }
237
243
 
238
244
  close(): void {
245
+ if (this.#closed) return;
239
246
  this.#db.close();
247
+ // DB.close() leaves the SAH pool live. Close siblings first, then pause the
248
+ // VFS without deleting files, before releasing this worker's physical lock.
249
+ this.#sah?.util.pauseVfs();
250
+ this.#closed = true;
251
+ void this.#sah?.lease.release();
240
252
  }
241
253
  }
242
254
 
@@ -420,37 +432,52 @@ export async function openPersistentWasmDatabase(
420
432
  'in-memory fallback for persistent mode.',
421
433
  );
422
434
  }
423
- const sqlite3 = await initSqlite3();
424
- const directory = options?.directory ?? `.syncular/${name}`;
425
- let pool = sahPools.get(directory);
426
- if (pool === undefined) {
427
- pool = sqlite3
428
- .installOpfsSAHPoolVfs({
429
- // VFS registration names must be unique per directory.
430
- name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
431
- directory,
432
- // sqlite-wasm caches a rejected initialization promise by VFS name.
433
- // Syncular removes its own rejected entry below, so allow a later
434
- // open in the same worker to make a real attempt as well.
435
- forceReinitIfPreviouslyFailed: true,
436
- initialCapacity: Math.max(
437
- options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
438
- SAH_POOL_MIN_CAPACITY,
439
- ),
440
- })
441
- .catch((error: unknown) => {
442
- sahPools.delete(directory);
443
- throw opfsSahPoolError(error, directory);
444
- });
445
- sahPools.set(directory, pool);
435
+ if (!navigator.locks) {
436
+ throw new ClientSyncError(
437
+ STORAGE_UNAVAILABLE_CODE,
438
+ 'Persistent OPFS storage requires Web Locks',
439
+ );
446
440
  }
447
- const util = await pool;
448
- const db = new util.OpfsSAHPoolDb(`/${name}.db`);
441
+ const directory = (options?.directory ?? `.syncular/${name}`).replace(
442
+ /^\/+|\/+$/g,
443
+ '',
444
+ );
445
+ // The document's leader lease can end before its worker's SAHs close. This
446
+ // worker-owned lock has exactly the same lifetime as the physical pool.
447
+ const lease = await webLocksLeaderLock().acquire(
448
+ `syncular-opfs/${directory}`,
449
+ );
450
+ let util: SahPoolUtil | undefined;
451
+ let db: Oo1Database | undefined;
449
452
  try {
453
+ const sqlite3 = await initSqlite3();
454
+ let pool = sahPools.get(directory);
455
+ if (pool === undefined) {
456
+ pool = sqlite3
457
+ .installOpfsSAHPoolVfs({
458
+ name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
459
+ directory,
460
+ forceReinitIfPreviouslyFailed: true,
461
+ initialCapacity: Math.max(
462
+ options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
463
+ SAH_POOL_MIN_CAPACITY,
464
+ ),
465
+ })
466
+ .catch((error: unknown) => {
467
+ sahPools.delete(directory);
468
+ throw opfsSahPoolError(error, directory);
469
+ });
470
+ sahPools.set(directory, pool);
471
+ }
472
+ util = await pool;
473
+ await util.unpauseVfs();
474
+ db = new util.OpfsSAHPoolDb(`/${name}.db`);
450
475
  configureSahCrashRecovery(db, sqlite3);
451
- return new WasmClientDatabase(db, sqlite3, { util });
476
+ return new WasmClientDatabase(db, sqlite3, { util, lease });
452
477
  } catch (error) {
453
- db.close();
478
+ db?.close();
479
+ util?.pauseVfs();
480
+ await lease.release();
454
481
  throw error;
455
482
  }
456
483
  }
@@ -804,11 +804,33 @@ async function startWorkerCore(options: {
804
804
  const worker = config.worker();
805
805
  const pending = new Map<number, Pending>();
806
806
  const nextId = { value: 1 };
807
+ let terminated = false;
808
+ let rejectReady: ((error: Error) => void) | undefined;
809
+ const closedError = new ClientSyncError(
810
+ WORKER_FAILED_CODE,
811
+ 'the handle was closed',
812
+ );
813
+ const terminate = () => {
814
+ if (terminated) return;
815
+ terminated = true;
816
+ globalThis.removeEventListener?.('pagehide', terminate);
817
+ bridge?.close();
818
+ worker.terminate();
819
+ rejectReady?.(closedError);
820
+ for (const entry of pending.values()) entry.reject(closedError);
821
+ pending.clear();
822
+ void lease.release();
823
+ };
824
+ // Installed before bootstrap starts: pagehide must also terminate a worker
825
+ // whose first open/import is still pending. Termination closes its SAHs and
826
+ // releases its physical Web Lock; the next document waits for that lock.
827
+ globalThis.addEventListener?.('pagehide', terminate);
807
828
 
808
829
  const invoke = (
809
830
  method: string,
810
831
  args: readonly unknown[],
811
832
  ): Promise<unknown> => {
833
+ if (terminated) return Promise.reject(closedError);
812
834
  const id = nextId.value++;
813
835
  return new Promise<unknown>((resolve, reject) => {
814
836
  pending.set(id, { resolve, reject });
@@ -824,6 +846,7 @@ async function startWorkerCore(options: {
824
846
  let bridge: LeaderBridge | undefined;
825
847
 
826
848
  const ready = new Promise<void>((resolve, reject) => {
849
+ rejectReady = reject;
827
850
  const onMessage = (event: MessageEvent) => {
828
851
  const message = event.data as WorkerToMainMessage;
829
852
  switch (message.t) {
@@ -889,26 +912,25 @@ async function startWorkerCore(options: {
889
912
  invoke,
890
913
  bridge,
891
914
  lease,
892
- close: async (terminate) => {
915
+ close: async (terminateWorker) => {
916
+ if (terminated) return;
893
917
  bridge?.close();
894
918
  try {
895
919
  await invoke('close', []);
896
920
  } catch {
897
- // Closing a wedged worker still terminates it below.
921
+ // A terminated or failed worker has no further close RPC to service.
922
+ }
923
+ if (terminateWorker) terminate();
924
+ else {
925
+ globalThis.removeEventListener?.('pagehide', terminate);
926
+ for (const entry of pending.values()) entry.reject(closedError);
927
+ pending.clear();
928
+ await lease.release();
898
929
  }
899
- if (terminate) worker.terminate();
900
- const closedError = new ClientSyncError(
901
- WORKER_FAILED_CODE,
902
- 'the handle was closed',
903
- );
904
- for (const entry of pending.values()) entry.reject(closedError);
905
- pending.clear();
906
- await lease.release();
907
930
  },
908
931
  };
909
932
  } catch (error) {
910
- worker.terminate();
911
- await lease.release();
933
+ terminate();
912
934
  throw error;
913
935
  }
914
936
  }