@syncular/client 0.30.10 → 0.30.12

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/src/state.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * exactly for that purpose.
6
6
  */
7
7
  import type { ScopeMap } from '@syncular/core';
8
+ import type { CompiledClientSchema } from './schema';
8
9
  import type { ClientDatabase } from './database';
9
10
  import type { LocalRevision } from './invalidation';
10
11
 
@@ -65,7 +66,7 @@ export function getSubscription(
65
66
  db: ClientDatabase,
66
67
  id: string,
67
68
  ): SubscriptionRecord | undefined {
68
- const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id = ?', [
69
+ const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
69
70
  id,
70
71
  ])[0];
71
72
  return row === undefined ? undefined : rowToRecord(row);
@@ -76,10 +77,7 @@ export function saveSubscription(
76
77
  record: SubscriptionRecord,
77
78
  ): void {
78
79
  db.exec(
79
- `INSERT OR REPLACE INTO _syncular_subscriptions(
80
- id, tbl, requested_scopes, params, cursor, bootstrap_state,
81
- effective_scopes, status, reason_code)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
80
+ 'INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)',
83
81
  [
84
82
  record.id,
85
83
  record.table,
@@ -97,11 +95,11 @@ export function saveSubscription(
97
95
  }
98
96
 
99
97
  export function deleteSubscription(db: ClientDatabase, id: string): void {
100
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
98
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
101
99
  }
102
100
 
103
101
  /**
104
- * §7.4.3 reset: keep every subscription REGISTRATION (id, table,
102
+ * §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
105
103
  * requested scopes, params — the app's declared intent) but discard all
106
104
  * synced state (cursor → -1, no resume token, no effective-scope map,
107
105
  * status → active), so the next round fresh-bootstraps exactly the
@@ -109,56 +107,115 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
109
107
  */
110
108
  export function resetSubscriptionsForBump(db: ClientDatabase): void {
111
109
  db.exec(
112
- `UPDATE _syncular_subscriptions
113
- SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
114
- status = 'active', reason_code = NULL`,
110
+ "UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL",
115
111
  );
116
112
  }
117
113
 
118
- /**
119
- * Remove registrations whose table no longer exists in the running schema.
120
- * Keeping one would make every subsequent pull fail with
121
- * `sync.unknown_table`. Window bookkeeping belongs to the registration and
122
- * is removed with it. The caller supplies its startup subscription snapshot
123
- * so pruning and startup-work detection stay a single read.
124
- */
114
+ type ScopeDeclarations = Record<
115
+ string,
116
+ Record<string, readonly [string, string]>
117
+ >;
118
+
119
+ /** Persist declarations separately from the previous-version row descriptor. */
120
+ export function saveSubscriptionScopeSchema(
121
+ db: ClientDatabase,
122
+ schema: CompiledClientSchema,
123
+ ): void {
124
+ const declarations: ScopeDeclarations = {};
125
+ for (const table of schema.tables.values()) {
126
+ declarations[table.name] = Object.fromEntries(
127
+ [...table.scopeColumnByVariable].map(([variable, column]) => [
128
+ variable,
129
+ [table.scopePrefixByVariable.get(variable)!, column],
130
+ ]),
131
+ );
132
+ }
133
+ setMeta(db, 'subscriptionScopeSchema', JSON.stringify(declarations));
134
+ }
135
+
136
+ /** Drop incompatible registrations and their windows; never translate scopes. */
125
137
  export function pruneUnknownSubscriptions(
126
138
  db: ClientDatabase,
127
139
  subscriptions: readonly SubscriptionRecord[],
128
- tableNames: ReadonlySet<string>,
140
+ schema: CompiledClientSchema,
141
+ bump = false,
129
142
  ): SubscriptionRecord[] {
130
- const retained = subscriptions.filter((record) =>
131
- tableNames.has(record.table),
143
+ const stored = bump ? getMeta(db, 'subscriptionScopeSchema') : undefined;
144
+ const previous =
145
+ stored === undefined
146
+ ? undefined
147
+ : (JSON.parse(stored) as ScopeDeclarations);
148
+ const compatible = (
149
+ record: Pick<SubscriptionRecord, 'table' | 'scopes'>,
150
+ ): boolean => {
151
+ const table = schema.tables.get(record.table);
152
+ if (table === undefined) return false;
153
+ const variables = Object.keys(record.scopes);
154
+ if (
155
+ !variables.every((variable) => table.scopeColumnByVariable.has(variable))
156
+ )
157
+ return false;
158
+ if (!bump) return true;
159
+ // An old binary stored no declaration evidence. Re-registration is required
160
+ // at a bump: matching names cannot prove unchanged column/prefix meaning.
161
+ const old = previous?.[record.table];
162
+ if (old === undefined) return false;
163
+ const compared =
164
+ variables.length === 0
165
+ ? [
166
+ ...new Set([
167
+ ...Object.keys(old),
168
+ ...table.scopeColumnByVariable.keys(),
169
+ ]),
170
+ ]
171
+ : variables;
172
+ return compared.every(
173
+ (variable) =>
174
+ old[variable]?.[0] === table.scopePrefixByVariable.get(variable) &&
175
+ old[variable]?.[1] === table.scopeColumnByVariable.get(variable),
176
+ );
177
+ };
178
+ const retained = subscriptions.filter(compatible);
179
+ const staleIds = new Set(
180
+ subscriptions
181
+ .filter((record) => !compatible(record))
182
+ .map((record) => record.id),
132
183
  );
133
- const staleIds = subscriptions
134
- .filter((record) => !tableNames.has(record.table))
135
- .map((record) => record.id);
136
- // A redundant subscription scan plus an empty write transaction cut the
137
- // fresh-client bootstrap lane by more than half on bun:sqlite. The supplied
138
- // snapshot and this branch are synchronous, so no application work can
139
- // interleave before a real pruning transaction begins.
140
- if (staleIds.length === 0) return retained;
184
+ // Shrinking a pinned window already removed its registration. Its deferred
185
+ // eviction still carries the old scope declaration and needs the same fence.
186
+ for (const row of db.query(
187
+ 'SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict',
188
+ )) {
189
+ if (
190
+ !compatible({
191
+ table: String(row.tbl),
192
+ scopes: JSON.parse(String(row.effective_scopes)) as ScopeMap,
193
+ })
194
+ )
195
+ staleIds.add(String(row.sub_id));
196
+ }
197
+ if (staleIds.size === 0) return retained;
141
198
  db.transaction(() => {
142
199
  for (const id of staleIds) {
143
- db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
144
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
200
+ db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
201
+ db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
145
202
  id,
146
203
  ]);
147
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
204
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
148
205
  }
149
206
  });
150
207
  return retained;
151
208
  }
152
209
 
153
210
  export function getMeta(db: ClientDatabase, key: string): string | undefined {
154
- const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
211
+ const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
155
212
  key,
156
213
  ])[0];
157
214
  return row === undefined ? undefined : (row.value as string);
158
215
  }
159
216
 
160
217
  export function setMeta(db: ClientDatabase, key: string, value: string): void {
161
- db.exec('INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?, ?)', [
218
+ db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
162
219
  key,
163
220
  value,
164
221
  ]);
@@ -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
  }