@syncular/client 0.15.3 → 0.15.5

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 CHANGED
@@ -42,10 +42,8 @@ const handle = await createSyncClientHandle({
42
42
  },
43
43
  });
44
44
  if (handle.role === 'follower') {
45
- // Another tab owns the core for this origin. With `multiTab: true`
46
- // (below) this handle transparently proxies to that leader; without it,
47
- // every call rejects with `client.not_leader` (a clear state, not a
48
- // broken client).
45
+ // Another tab owns the core for this origin. The default multi-tab mode
46
+ // transparently proxies this handle to that leader.
49
47
  }
50
48
  await handle.subscribe({ id: 'todos', table: 'todos', scopes: { list_id: ['l1'] } });
51
49
  await handle.syncUntilIdle();
@@ -66,14 +64,13 @@ persists, on purpose, and that is the only main-thread mode.
66
64
 
67
65
  ## Multi-tab followers (TODO 3.2, REVISE B3)
68
66
 
69
- Pass `multiTab: true` and every tab of the same origin shares ONE core:
67
+ By default, every tab of the same origin shares ONE core:
70
68
  one sync loop, one WebSocket, one OPFS database, N tabs.
71
69
 
72
70
  ```ts
73
71
  const handle = await createSyncClientHandle({
74
72
  worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
75
73
  schema, database: { mode: 'persistent', name: 'app' }, endpoints,
76
- multiTab: true,
77
74
  onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
78
75
  });
79
76
  // handle.role is 'leader' or 'follower'; the API is identical either way.
@@ -115,9 +112,8 @@ forwards to the leader's single publisher; there is no per-tab presence
115
112
  peer. This is the honest model — the wire only ever sees one connection per
116
113
  device.
117
114
 
118
- With `multiTab` off (the default) the single-tab contract is unchanged: a
119
- losing tab is an `isLeader === false` handle whose calls reject with
120
- `client.not_leader`.
115
+ Set `multiTab: false` to opt out. A losing tab then becomes an
116
+ `isLeader === false` handle whose calls reject with `client.not_leader`.
121
117
 
122
118
  ## Durable commit outcomes
123
119
 
@@ -169,6 +165,30 @@ successful history may be dismissed. See SPEC §7.2.1.
169
165
  - `openPersistentWasmDatabase` refuses to run on the main thread — not a
170
166
  sahpool limitation, an enforcement of whole-core-in-a-worker.
171
167
 
168
+ ### OPFS ownership and startup recovery
169
+
170
+ An OPFS SAH pool has exactly one live owner per storage directory. Syncular's
171
+ default multi-tab mode prevents ordinary same-origin tabs from opening a second
172
+ pool: followers proxy the one leader over `BroadcastChannel`. A collision can
173
+ still happen during rapid hot-module replacement, or in an embedded/test host
174
+ that shares OPFS data without sharing the same Web Locks and BroadcastChannel
175
+ coordination domain.
176
+
177
+ Pool acquisition failures surface as `ClientSyncError` with code
178
+ `client.storage_busy` and `retryable === true`. Treat that as a startup state:
179
+ close the competing instance or let it finish shutting down, then create the
180
+ handle again. **Do not delete, rename, or silently replace the database with an
181
+ in-memory one**; the local database and pending outbox may be perfectly healthy.
182
+ Missing or obsolete OPFS APIs instead use the non-retryable
183
+ `client.storage_unavailable` code.
184
+
185
+ When using `@syncular/react`, `createSyncClientResource()` exposes `retry()` and
186
+ passes the same action as the second argument to `SyncProvider.renderError`.
187
+ Applications may use a small bounded backoff for errors whose `retryable` flag
188
+ is true, followed by a visible manual retry. Preserve the resource across HMR
189
+ or dispose the previous resource before replacing it so development reloads do
190
+ not manufacture a second owner.
191
+
172
192
  ## Blob attachments (§5.9) — the client storage model
173
193
 
174
194
  File attachments (`blob_ref` columns) ride the `uploadBlob` / `fetchBlob` API
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,11 @@
1
+ /** A persistent local store is temporarily owned by another live engine. */
2
+ export declare const STORAGE_BUSY_CODE = "client.storage_busy";
3
+ /** The browser cannot provide the persistent storage APIs Syncular requires. */
4
+ export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
1
5
  /**
2
- * Client-side errors. Protocol codes come from the SPEC.md §10 catalog;
3
- * the client never invents wire codes, it surfaces them.
6
+ * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
7
+ * unchanged. Host/runtime-only conditions may use the separate `client.*`
8
+ * namespace and never travel on the wire.
4
9
  */
5
10
  export declare class ClientSyncError extends Error {
6
11
  readonly name = "ClientSyncError";
package/dist/errors.js CHANGED
@@ -1,6 +1,11 @@
1
+ /** A persistent local store is temporarily owned by another live engine. */
2
+ export const STORAGE_BUSY_CODE = 'client.storage_busy';
3
+ /** The browser cannot provide the persistent storage APIs Syncular requires. */
4
+ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
1
5
  /**
2
- * Client-side errors. Protocol codes come from the SPEC.md §10 catalog;
3
- * the client never invents wire codes, it surfaces them.
6
+ * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
7
+ * unchanged. Host/runtime-only conditions may use the separate `client.*`
8
+ * namespace and never travel on the wire.
4
9
  */
5
10
  export class ClientSyncError extends Error {
6
11
  name = 'ClientSyncError';
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
23
23
  import { assertImageAlias, runTransaction, } from './database.js';
24
- import { ClientSyncError } from './errors.js';
24
+ import { ClientSyncError, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
25
25
  function coerceParams(params) {
26
26
  return params.map((value) => {
27
27
  if (typeof value === 'boolean')
@@ -119,6 +119,19 @@ function inWorkerContext() {
119
119
  }
120
120
  /** One registered VFS per pool directory, reused across opens. */
121
121
  const sahPools = new Map();
122
+ function opfsSahPoolError(error, directory) {
123
+ const detail = error instanceof Error ? error.message : String(error);
124
+ const normalized = detail.toLowerCase();
125
+ if (normalized.includes('missing required opfs apis') ||
126
+ normalized.includes('opfs api is too old')) {
127
+ return new ClientSyncError(STORAGE_UNAVAILABLE_CODE, `Persistent OPFS storage is unavailable: ${detail}`);
128
+ }
129
+ return new ClientSyncError(STORAGE_BUSY_CODE, 'Could not acquire the persistent OPFS storage directory ' +
130
+ `${JSON.stringify(directory)}. Another live engine may still own its ` +
131
+ 'SAH pool, or the browser may still be releasing it after a reload. ' +
132
+ 'Close the other instance or retry after a short delay; do not delete ' +
133
+ `or rename the directory. Underlying error: ${detail}`, true);
134
+ }
122
135
  /**
123
136
  * THE persistent browser mode: a named database on OPFS via the
124
137
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
@@ -141,7 +154,7 @@ export async function openPersistentWasmDatabase(name, options) {
141
154
  }
142
155
  const storage = globalThis.navigator?.storage;
143
156
  if (typeof storage?.getDirectory !== 'function') {
144
- throw new ClientSyncError('sync.invalid_request', 'OPFS is unavailable in this browser — the syncular support floor ' +
157
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'OPFS is unavailable in this browser — the syncular support floor ' +
145
158
  'requires OPFS (~2023+ browsers). There is no IndexedDB or ' +
146
159
  'in-memory fallback for persistent mode.');
147
160
  }
@@ -154,14 +167,17 @@ export async function openPersistentWasmDatabase(name, options) {
154
167
  // VFS registration names must be unique per directory.
155
168
  name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
156
169
  directory,
170
+ // sqlite-wasm caches a rejected initialization promise by VFS name.
171
+ // Syncular removes its own rejected entry below, so allow a later
172
+ // open in the same worker to make a real attempt as well.
173
+ forceReinitIfPreviouslyFailed: true,
157
174
  ...(options?.initialCapacity !== undefined
158
175
  ? { initialCapacity: options.initialCapacity }
159
176
  : {}),
160
177
  })
161
178
  .catch((error) => {
162
179
  sahPools.delete(directory);
163
- throw new ClientSyncError('sync.invalid_request', 'opfs-sahpool VFS failed to initialize (is OPFS available, and ' +
164
- `is another live instance using directory ${JSON.stringify(directory)}?): ${String(error)}`);
180
+ throw opfsSahPoolError(error, directory);
165
181
  });
166
182
  sahPools.set(directory, pool);
167
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.3"
84
+ "@syncular/core": "0.15.5"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.3",
95
+ "@syncular/server": "0.15.5",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/errors.ts CHANGED
@@ -1,6 +1,13 @@
1
+ /** A persistent local store is temporarily owned by another live engine. */
2
+ export const STORAGE_BUSY_CODE = 'client.storage_busy';
3
+
4
+ /** The browser cannot provide the persistent storage APIs Syncular requires. */
5
+ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
6
+
1
7
  /**
2
- * Client-side errors. Protocol codes come from the SPEC.md §10 catalog;
3
- * the client never invents wire codes, it surfaces them.
8
+ * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
9
+ * unchanged. Host/runtime-only conditions may use the separate `client.*`
10
+ * namespace and never travel on the wire.
4
11
  */
5
12
  export class ClientSyncError extends Error {
6
13
  override readonly name = 'ClientSyncError';
@@ -27,7 +27,11 @@ import {
27
27
  type SqlRow,
28
28
  type SqlValue,
29
29
  } from './database';
30
- import { ClientSyncError } from './errors';
30
+ import {
31
+ ClientSyncError,
32
+ STORAGE_BUSY_CODE,
33
+ STORAGE_UNAVAILABLE_CODE,
34
+ } from './errors';
31
35
 
32
36
  /** Structural view of the sqlite3 oo1 surface this binding uses. */
33
37
  interface Oo1Database {
@@ -69,6 +73,7 @@ interface Sqlite3Static {
69
73
  name?: string;
70
74
  directory?: string;
71
75
  initialCapacity?: number;
76
+ forceReinitIfPreviouslyFailed?: boolean;
72
77
  }): Promise<SahPoolUtil>;
73
78
  }
74
79
 
@@ -209,6 +214,29 @@ function inWorkerContext(): boolean {
209
214
  /** One registered VFS per pool directory, reused across opens. */
210
215
  const sahPools = new Map<string, Promise<SahPoolUtil>>();
211
216
 
217
+ function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
218
+ const detail = error instanceof Error ? error.message : String(error);
219
+ const normalized = detail.toLowerCase();
220
+ if (
221
+ normalized.includes('missing required opfs apis') ||
222
+ normalized.includes('opfs api is too old')
223
+ ) {
224
+ return new ClientSyncError(
225
+ STORAGE_UNAVAILABLE_CODE,
226
+ `Persistent OPFS storage is unavailable: ${detail}`,
227
+ );
228
+ }
229
+ return new ClientSyncError(
230
+ STORAGE_BUSY_CODE,
231
+ 'Could not acquire the persistent OPFS storage directory ' +
232
+ `${JSON.stringify(directory)}. Another live engine may still own its ` +
233
+ 'SAH pool, or the browser may still be releasing it after a reload. ' +
234
+ 'Close the other instance or retry after a short delay; do not delete ' +
235
+ `or rename the directory. Underlying error: ${detail}`,
236
+ true,
237
+ );
238
+ }
239
+
212
240
  /**
213
241
  * THE persistent browser mode: a named database on OPFS via the
214
242
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
@@ -243,7 +271,7 @@ export async function openPersistentWasmDatabase(
243
271
  ).navigator?.storage;
244
272
  if (typeof storage?.getDirectory !== 'function') {
245
273
  throw new ClientSyncError(
246
- 'sync.invalid_request',
274
+ STORAGE_UNAVAILABLE_CODE,
247
275
  'OPFS is unavailable in this browser — the syncular support floor ' +
248
276
  'requires OPFS (~2023+ browsers). There is no IndexedDB or ' +
249
277
  'in-memory fallback for persistent mode.',
@@ -258,19 +286,17 @@ export async function openPersistentWasmDatabase(
258
286
  // VFS registration names must be unique per directory.
259
287
  name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
260
288
  directory,
289
+ // sqlite-wasm caches a rejected initialization promise by VFS name.
290
+ // Syncular removes its own rejected entry below, so allow a later
291
+ // open in the same worker to make a real attempt as well.
292
+ forceReinitIfPreviouslyFailed: true,
261
293
  ...(options?.initialCapacity !== undefined
262
294
  ? { initialCapacity: options.initialCapacity }
263
295
  : {}),
264
296
  })
265
297
  .catch((error: unknown) => {
266
298
  sahPools.delete(directory);
267
- throw new ClientSyncError(
268
- 'sync.invalid_request',
269
- 'opfs-sahpool VFS failed to initialize (is OPFS available, and ' +
270
- `is another live instance using directory ${JSON.stringify(
271
- directory,
272
- )}?): ${String(error)}`,
273
- );
299
+ throw opfsSahPoolError(error, directory);
274
300
  });
275
301
  sahPools.set(directory, pool);
276
302
  }