@syncular/client 0.22.0 → 0.23.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.
@@ -17,6 +17,11 @@
17
17
  * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
18
18
  * call the `ClientDatabase` directly and are trusted, so they are never
19
19
  * routed through here.
20
+ *
21
+ * RFC 0005 previously added a third rule here, denying reads of its container
22
+ * table by root page. That table now lives in its own database file and is not
23
+ * on the connection `query()` uses, so there is nothing left to guard and the
24
+ * rule was removed with its EXPLAIN plumbing.
20
25
  */
21
26
 
22
27
  /** Verbs a read-only query may begin with (lowercased). */
package/src/schema.ts CHANGED
@@ -620,8 +620,10 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
620
620
  * generated schema created — discovered from `sqlite_master`, since a
621
621
  * bump may add/remove tables) and recreate the synced tables from the
622
622
  * NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
623
- * subscriptions, blob cache) are preserved. Caller owns the surrounding
624
- * transaction and the subscription-state reset (state.ts).
623
+ * subscriptions, blob cache) are preserved. RFC 0005's container is not
624
+ * reachable from here: it lives in its own database file, so this reset cannot
625
+ * see or destroy it. Caller owns the surrounding transaction and the
626
+ * subscription-state reset (state.ts).
625
627
  */
626
628
  export function dropAndRecreateSyncedTables(
627
629
  db: ClientDatabase,
@@ -27,6 +27,7 @@ import {
27
27
  assertImageAlias,
28
28
  type ClientDatabase,
29
29
  runTransaction,
30
+ type SiblingDatabase,
30
31
  type SqlRow,
31
32
  type SqlValue,
32
33
  } from './database';
@@ -51,6 +52,9 @@ interface Oo1Database {
51
52
 
52
53
  interface SahPoolUtil {
53
54
  OpfsSAHPoolDb: new (filename: string) => Oo1Database;
55
+ /** RFC 0005: the runtime exposes naming and removal on the pool util. */
56
+ getFileNames(): string[];
57
+ unlink(filename: string): boolean;
54
58
  }
55
59
 
56
60
  interface SqliteIoMethods {
@@ -116,11 +120,18 @@ function coerceParams(params: readonly SqlValue[]): unknown[] {
116
120
  class WasmClientDatabase implements ClientDatabase {
117
121
  readonly #db: Oo1Database;
118
122
  readonly #sqlite3: Sqlite3Static;
123
+ /** RFC 0005: present only for a persistent OPFS database. */
124
+ readonly #sah: { readonly util: SahPoolUtil } | undefined;
119
125
  #tx = { depth: 0 };
120
126
 
121
- constructor(db: Oo1Database, sqlite3: Sqlite3Static) {
127
+ constructor(
128
+ db: Oo1Database,
129
+ sqlite3: Sqlite3Static,
130
+ sah?: { readonly util: SahPoolUtil },
131
+ ) {
122
132
  this.#db = db;
123
133
  this.#sqlite3 = sqlite3;
134
+ this.#sah = sah;
124
135
  }
125
136
 
126
137
  exec(sql: string, params: readonly SqlValue[] = []): void {
@@ -147,6 +158,39 @@ class WasmClientDatabase implements ClientDatabase {
147
158
  return runTransaction(this.#tx, (sql) => this.exec(sql), fn);
148
159
  }
149
160
 
161
+ /**
162
+ * RFC 0005: a second named file on the same SAH pool directory. The pool
163
+ * forbids two live instances per directory and charges two slots per
164
+ * writable database (`.db` plus its transient `-journal`), which is why the
165
+ * pool's initial capacity must cover three. Removal is the pool's `unlink`,
166
+ * never OPFS `removeEntry` (the pool stores an opaque entry).
167
+ */
168
+ openSibling(name: string): SiblingDatabase | undefined {
169
+ const sah = this.#sah;
170
+ if (sah === undefined) return undefined;
171
+ const fileName = `/${name}.db`;
172
+ const db = new sah.util.OpfsSAHPoolDb(fileName);
173
+ try {
174
+ configureSahCrashRecovery(db, this.#sqlite3);
175
+ } catch (error) {
176
+ db.close();
177
+ throw error;
178
+ }
179
+ return {
180
+ database: new WasmClientDatabase(db, this.#sqlite3),
181
+ close: () => db.close(),
182
+ removeFile: () => {
183
+ sah.util.unlink(fileName);
184
+ },
185
+ };
186
+ }
187
+
188
+ siblingExists(name: string): boolean {
189
+ const sah = this.#sah;
190
+ if (sah === undefined) return false;
191
+ return sah.util.getFileNames().includes(`/${name}.db`);
192
+ }
193
+
150
194
  /**
151
195
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
152
196
  * schema, then `sqlite3_deserialize` the image bytes into it (the
@@ -274,6 +318,69 @@ function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
274
318
  );
275
319
  }
276
320
 
321
+ /** sqlite-wasm's default SAH pool capacity. */
322
+ const SAH_POOL_DEFAULT_CAPACITY = 6;
323
+ /**
324
+ * RFC 0005: each writable database consumes two slots (`.db` plus its
325
+ * transient `-journal`), so a replica plus one container file needs three.
326
+ * Exhaustion is a loud `SQLITE_CANTOPEN` ("SAH pool is full") that names the
327
+ * missing journal, not corruption — raise the floor rather than discover it.
328
+ */
329
+ const SAH_POOL_MIN_CAPACITY = 3;
330
+
331
+ /**
332
+ * Do this before the first SQL statement, when SQLite checks for a hot
333
+ * journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved lock
334
+ * unconditionally, which suppresses crash rollback. The pool already excludes
335
+ * other owners; report no competing writer, as the other OPFS VFS does. Keep
336
+ * DELETE/FULL and the existing database/journal files. Shared by the replica
337
+ * and every RFC 0005 sibling file.
338
+ */
339
+ function configureSahCrashRecovery(
340
+ db: Oo1Database,
341
+ sqlite3: Sqlite3Static,
342
+ ): void {
343
+ const { capi, wasm } = sqlite3;
344
+ const stack = wasm.pstack.pointer;
345
+ try {
346
+ const output = wasm.pstack.allocPtr();
347
+ if (
348
+ db.pointer === undefined ||
349
+ capi.sqlite3_file_control(
350
+ db.pointer,
351
+ 'main',
352
+ capi.SQLITE_FCNTL_FILE_POINTER,
353
+ output,
354
+ ) !== 0 ||
355
+ !wasm.peekPtr(output)
356
+ ) {
357
+ throw new ClientSyncError(
358
+ STORAGE_UNAVAILABLE_CODE,
359
+ 'Could not configure OPFS crash recovery',
360
+ );
361
+ }
362
+ const file = new capi.sqlite3_file(wasm.peekPtr(output));
363
+ const methodsPointer = file.$pMethods;
364
+ file.dispose(); // Borrowed struct view; SQLite still owns the file.
365
+ if (!methodsPointer) {
366
+ throw new ClientSyncError(
367
+ STORAGE_UNAVAILABLE_CODE,
368
+ 'Could not configure OPFS crash recovery',
369
+ );
370
+ }
371
+ if (!sahIoMethods.has(methodsPointer)) {
372
+ const methods = new capi.sqlite3_io_methods(methodsPointer);
373
+ methods.installMethod('xCheckReservedLock', (_file, reserved) => {
374
+ wasm.poke32(reserved, 0);
375
+ return 0;
376
+ });
377
+ sahIoMethods.set(methodsPointer, methods);
378
+ }
379
+ } finally {
380
+ wasm.pstack.restore(stack);
381
+ }
382
+ }
383
+
277
384
  /**
278
385
  * THE reload-persistent browser mode: a named database on OPFS via the
279
386
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
@@ -329,9 +436,10 @@ export async function openPersistentWasmDatabase(
329
436
  // Syncular removes its own rejected entry below, so allow a later
330
437
  // open in the same worker to make a real attempt as well.
331
438
  forceReinitIfPreviouslyFailed: true,
332
- ...(options?.initialCapacity !== undefined
333
- ? { initialCapacity: options.initialCapacity }
334
- : {}),
439
+ initialCapacity: Math.max(
440
+ options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY,
441
+ SAH_POOL_MIN_CAPACITY,
442
+ ),
335
443
  })
336
444
  .catch((error: unknown) => {
337
445
  sahPools.delete(directory);
@@ -341,52 +449,11 @@ export async function openPersistentWasmDatabase(
341
449
  }
342
450
  const util = await pool;
343
451
  const db = new util.OpfsSAHPoolDb(`/${name}.db`);
344
- const { capi, wasm } = sqlite3;
345
- const stack = wasm.pstack.pointer;
346
452
  try {
347
- // Do this before the first SQL statement, when SQLite checks for a hot
348
- // journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
349
- // lock unconditionally, which suppresses crash rollback. The pool already
350
- // excludes other owners; report no competing writer, as the other OPFS
351
- // VFS does. Keep DELETE/FULL and the existing database/journal files.
352
- const output = wasm.pstack.allocPtr();
353
- if (
354
- db.pointer === undefined ||
355
- capi.sqlite3_file_control(
356
- db.pointer,
357
- 'main',
358
- capi.SQLITE_FCNTL_FILE_POINTER,
359
- output,
360
- ) !== 0 ||
361
- !wasm.peekPtr(output)
362
- ) {
363
- throw new ClientSyncError(
364
- STORAGE_UNAVAILABLE_CODE,
365
- 'Could not configure OPFS crash recovery',
366
- );
367
- }
368
- const file = new capi.sqlite3_file(wasm.peekPtr(output));
369
- const methodsPointer = file.$pMethods;
370
- file.dispose(); // Borrowed struct view; SQLite still owns the file.
371
- if (!methodsPointer) {
372
- throw new ClientSyncError(
373
- STORAGE_UNAVAILABLE_CODE,
374
- 'Could not configure OPFS crash recovery',
375
- );
376
- }
377
- if (!sahIoMethods.has(methodsPointer)) {
378
- const methods = new capi.sqlite3_io_methods(methodsPointer);
379
- methods.installMethod('xCheckReservedLock', (_file, reserved) => {
380
- wasm.poke32(reserved, 0);
381
- return 0;
382
- });
383
- sahIoMethods.set(methodsPointer, methods);
384
- }
385
- return new WasmClientDatabase(db, sqlite3);
453
+ configureSahCrashRecovery(db, sqlite3);
454
+ return new WasmClientDatabase(db, sqlite3, { util });
386
455
  } catch (error) {
387
456
  db.close();
388
457
  throw error;
389
- } finally {
390
- wasm.pstack.restore(stack);
391
458
  }
392
459
  }
@@ -347,6 +347,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
347
347
  ...(config.securityPreflight !== undefined
348
348
  ? { securityPreflight: config.securityPreflight }
349
349
  : {}),
350
+ ...(config.previousVersionContext !== undefined
351
+ ? { previousVersionContext: config.previousVersionContext }
352
+ : {}),
350
353
  onSyncNeeded: (reason) => {
351
354
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
352
355
  consumeSyncIntent({ kind: 'interactive' });
@@ -446,6 +449,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
446
449
  purgeLocalData: (input) => requireClient().purgeLocalData(input),
447
450
  rebootstrapLocalData: (input) =>
448
451
  requireClient().rebootstrapLocalData(input),
452
+ previousVersionSnapshot: (spec) =>
453
+ requireClient().previousVersionSnapshot(spec),
454
+ previousVersionAudit: () => requireClient().previousVersionAudit(),
455
+ previousVersionDiscard: () => requireClient().previousVersionDiscard(),
449
456
  sync: () => {
450
457
  const running = requireClient();
451
458
  return serializedSync(() => running.sync());
@@ -512,13 +519,18 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
512
519
  },
513
520
  };
514
521
 
515
- // Local purge/rebootstrap rewrite the same durable state an in-flight
516
- // sync round captured at send time; running their RPCs on the sync chain
517
- // orders them against RPC- and auto-driven rounds (the client core's
518
- // reset fence covers hosts that call the core directly).
522
+ // Local purge/rebootstrap/discard rewrite the same durable state an
523
+ // in-flight sync round captured at send time, and the RFC 0005 reads observe
524
+ // (and can themselves trigger the discard of) that same previous-version
525
+ // container. Running all of them on the sync chain orders them against RPC-
526
+ // and auto-driven rounds (the client core's reset fence covers hosts that
527
+ // call the core directly).
519
528
  const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
520
529
  'purgeLocalData',
521
530
  'rebootstrapLocalData',
531
+ 'previousVersionSnapshot',
532
+ 'previousVersionAudit',
533
+ 'previousVersionDiscard',
522
534
  ]);
523
535
 
524
536
  async function dispatch(message: WorkerCallMessage): Promise<unknown> {
@@ -38,6 +38,7 @@ import type {
38
38
  RejectionRecord,
39
39
  SecurityLifecycle,
40
40
  SubscribeInput,
41
+ SyncClientConfig,
41
42
  SyncClientLimits,
42
43
  SyncSummary,
43
44
  WindowState,
@@ -89,6 +90,14 @@ import type {
89
90
  CommitOutcomeQuery,
90
91
  ResolveCommitOutcomeInput,
91
92
  } from './outcomes';
93
+ import {
94
+ decodePreviousVersionAuditResult,
95
+ decodePreviousVersionDiscardResult,
96
+ decodePreviousVersionSnapshot,
97
+ type PreviousVersionAudit,
98
+ type PreviousVersionReadSpec,
99
+ type PreviousVersionSnapshot,
100
+ } from './previous-version';
92
101
  import type { ClientSchema } from './schema';
93
102
  import type { SubscriptionRecord } from './state';
94
103
  import type { WindowBase } from './window';
@@ -177,6 +186,11 @@ export interface SyncClientHandleConfig {
177
186
  readonly securityPreflight?: boolean;
178
187
  readonly clientId?: string;
179
188
  readonly limits?: SyncClientLimits;
189
+ /**
190
+ * RFC 0005 previous-version context, forwarded into the worker's
191
+ * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
192
+ */
193
+ readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
180
194
  /** Worker-side host loop (§8.4); default true. */
181
195
  readonly autoSync?: boolean;
182
196
  /** Default: Web Locks when available, else single-owner. */
@@ -546,6 +560,29 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
546
560
  );
547
561
  }
548
562
 
563
+ async previousVersionSnapshot(
564
+ spec: PreviousVersionReadSpec,
565
+ ): Promise<PreviousVersionSnapshot> {
566
+ return decodePreviousVersionSnapshot(
567
+ await this.#call('previousVersionSnapshot', [spec]),
568
+ );
569
+ }
570
+
571
+ async previousVersionAudit(): Promise<PreviousVersionAudit | undefined> {
572
+ return decodePreviousVersionAuditResult(
573
+ await this.#call('previousVersionAudit', []),
574
+ );
575
+ }
576
+
577
+ async previousVersionDiscard(): Promise<{
578
+ present: boolean;
579
+ discarded: boolean;
580
+ }> {
581
+ return decodePreviousVersionDiscardResult(
582
+ await this.#call('previousVersionDiscard', []),
583
+ );
584
+ }
585
+
549
586
  sync(): Promise<SyncSummary> {
550
587
  return this.#call('sync', []);
551
588
  }
@@ -815,6 +852,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
815
852
  : {}),
816
853
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
817
854
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
855
+ ...(config.previousVersionContext !== undefined
856
+ ? { previousVersionContext: config.previousVersionContext }
857
+ : {}),
818
858
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
819
859
  };
820
860
  }
@@ -28,6 +28,7 @@ import type {
28
28
  QuerySnapshot,
29
29
  SecurityLifecycle,
30
30
  SubscribeInput,
31
+ SyncClientConfig,
31
32
  SyncClientLimits,
32
33
  SyncSummary,
33
34
  WindowState,
@@ -42,6 +43,11 @@ import type {
42
43
  LocalDataRebootstrapResult,
43
44
  } from './local-rebootstrap';
44
45
  import type { OutboxCommit } from './outbox';
46
+ import type {
47
+ PreviousVersionAudit,
48
+ PreviousVersionReadSpec,
49
+ PreviousVersionSnapshot,
50
+ } from './previous-version';
45
51
  import type { ClientSchema } from './schema';
46
52
  import type { SubscriptionRecord } from './state';
47
53
  import type { WindowBase } from './window';
@@ -102,6 +108,11 @@ export interface WorkerInitConfig {
102
108
  readonly securityPreflight?: boolean;
103
109
  readonly clientId?: string;
104
110
  readonly limits?: SyncClientLimits;
111
+ /**
112
+ * RFC 0005 previous-version context, forwarded verbatim into the worker's
113
+ * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
114
+ */
115
+ readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
105
116
  /**
106
117
  * Worker-side host loop (§8.4): coalesce interactive work immediately and
107
118
  * honor explicit background retry deadlines
@@ -151,6 +162,14 @@ export interface WorkerApi extends Omit<
151
162
  rebootstrapLocalData(
152
163
  input: LocalDataRebootstrapInput,
153
164
  ): LocalDataRebootstrapResult;
165
+ /** RFC 0005 D7: read the captured previous-schema rows for one table. */
166
+ previousVersionSnapshot(
167
+ spec: PreviousVersionReadSpec,
168
+ ): PreviousVersionSnapshot;
169
+ /** RFC 0005 D6: the advisory pre-reset compatibility audit, if recorded. */
170
+ previousVersionAudit(): PreviousVersionAudit | undefined;
171
+ /** RFC 0005 D9: the executable rollback step — drop the container. */
172
+ previousVersionDiscard(): { present: boolean; discarded: boolean };
154
173
  sync(): Promise<SyncSummary>;
155
174
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
156
175
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];