@syncular/client 0.21.1 → 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
  /** Raised when `client.query()` is handed SQL it will not run. */
22
27
  export declare class RawSqlError extends Error {
@@ -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
  /** Verbs a read-only query may begin with (lowercased). */
22
27
  const READ_ONLY_VERBS = new Set([
@@ -82,15 +82,21 @@ function scheduleMicrotask(task) {
82
82
  }
83
83
  function batchMatches(batch, spec) {
84
84
  for (const change of batch.tables) {
85
- const dependency = spec.dependencies.find((candidate) => candidate.table === change.table);
86
- if (dependency === undefined)
87
- continue;
88
- if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
89
- return true;
90
- }
91
- for (const key of dependency.scopeKeys) {
92
- if (change.scopeKeys.has(key))
85
+ // A composed spec carries one dependency per query branch. Every
86
+ // dependency on the changed table is consulted: a scoped dependency
87
+ // that does not cover the change must not mask a sibling dependency
88
+ // on the same table that does.
89
+ for (const dependency of spec.dependencies) {
90
+ if (dependency.table !== change.table)
91
+ continue;
92
+ if (dependency.scopeKeys === undefined ||
93
+ change.scopeKeys === undefined) {
93
94
  return true;
95
+ }
96
+ for (const key of dependency.scopeKeys) {
97
+ if (change.scopeKeys.has(key))
98
+ return true;
99
+ }
94
100
  }
95
101
  }
96
102
  for (const change of batch.windows) {
@@ -268,8 +274,27 @@ class QueryEntry {
268
274
  this.#onAvailabilityChange();
269
275
  if (this.spec.claimCoverage !== false) {
270
276
  const claims = [];
277
+ // A composed spec carries one coverage entry per query branch, and
278
+ // several branches can target the same base. One claim per distinct
279
+ // base covers the union of their units; a later entry for the same
280
+ // base does not replace the earlier one.
281
+ const unitsByBase = new Map();
271
282
  for (const coverage of this.spec.coverage ?? []) {
272
- claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
283
+ const baseKey = windowBaseKey(coverage.base);
284
+ const existing = unitsByBase.get(baseKey);
285
+ if (existing === undefined) {
286
+ unitsByBase.set(baseKey, {
287
+ base: coverage.base,
288
+ units: new Set(coverage.units),
289
+ });
290
+ }
291
+ else {
292
+ for (const unit of coverage.units)
293
+ existing.units.add(unit);
294
+ }
295
+ }
296
+ for (const { base, units } of unitsByBase.values()) {
297
+ claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
273
298
  }
274
299
  const generation = this.#generation;
275
300
  this.#claimPending = claims.length > 0;
package/dist/schema.d.ts CHANGED
@@ -122,8 +122,10 @@ export declare function ensureLocalSchema(db: ClientDatabase, schema: CompiledCl
122
122
  * generated schema created — discovered from `sqlite_master`, since a
123
123
  * bump may add/remove tables) and recreate the synced tables from the
124
124
  * NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
125
- * subscriptions, blob cache) are preserved. Caller owns the surrounding
126
- * transaction and the subscription-state reset (state.ts).
125
+ * subscriptions, blob cache) are preserved. RFC 0005's container is not
126
+ * reachable from here: it lives in its own database file, so this reset cannot
127
+ * see or destroy it. Caller owns the surrounding transaction and the
128
+ * subscription-state reset (state.ts).
127
129
  */
128
130
  export declare function dropAndRecreateSyncedTables(db: ClientDatabase, schema: CompiledClientSchema): void;
129
131
  /** RowValue → SQL bind value for the local mirror tables. */
package/dist/schema.js CHANGED
@@ -31,6 +31,17 @@ export function compileClientSchema(schema) {
31
31
  if (primaryKeyIndex === undefined) {
32
32
  throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
33
33
  }
34
+ // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
35
+ // string every implementation produces identically and local storage
36
+ // resolves without deferring to a SQLite build.
37
+ const primaryKeyColumn = table.columns[primaryKeyIndex];
38
+ if (primaryKeyColumn !== undefined &&
39
+ primaryKeyColumn.type !== 'string' &&
40
+ primaryKeyColumn.type !== 'integer' &&
41
+ primaryKeyColumn.type !== 'boolean' &&
42
+ primaryKeyColumn.type !== 'json') {
43
+ throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`);
44
+ }
34
45
  if (table.scopes.length === 0) {
35
46
  throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
36
47
  }
@@ -427,8 +438,10 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
427
438
  * generated schema created — discovered from `sqlite_master`, since a
428
439
  * bump may add/remove tables) and recreate the synced tables from the
429
440
  * NEW schema. Bookkeeping tables (`_syncular_*`: outbox, meta,
430
- * subscriptions, blob cache) are preserved. Caller owns the surrounding
431
- * transaction and the subscription-state reset (state.ts).
441
+ * subscriptions, blob cache) are preserved. RFC 0005's container is not
442
+ * reachable from here: it lives in its own database file, so this reset cannot
443
+ * see or destroy it. Caller owns the surrounding transaction and the
444
+ * subscription-state reset (state.ts).
432
445
  */
433
446
  export function dropAndRecreateSyncedTables(db, schema) {
434
447
  // Drop virtual tables first. SQLite then removes their shadow tables as one
@@ -35,10 +35,13 @@ function coerceParams(params) {
35
35
  class WasmClientDatabase {
36
36
  #db;
37
37
  #sqlite3;
38
+ /** RFC 0005: present only for a persistent OPFS database. */
39
+ #sah;
38
40
  #tx = { depth: 0 };
39
- constructor(db, sqlite3) {
41
+ constructor(db, sqlite3, sah) {
40
42
  this.#db = db;
41
43
  this.#sqlite3 = sqlite3;
44
+ this.#sah = sah;
42
45
  }
43
46
  exec(sql, params = []) {
44
47
  this.#db.exec({
@@ -61,6 +64,40 @@ class WasmClientDatabase {
61
64
  transaction(fn) {
62
65
  return runTransaction(this.#tx, (sql) => this.exec(sql), fn);
63
66
  }
67
+ /**
68
+ * RFC 0005: a second named file on the same SAH pool directory. The pool
69
+ * forbids two live instances per directory and charges two slots per
70
+ * writable database (`.db` plus its transient `-journal`), which is why the
71
+ * pool's initial capacity must cover three. Removal is the pool's `unlink`,
72
+ * never OPFS `removeEntry` (the pool stores an opaque entry).
73
+ */
74
+ openSibling(name) {
75
+ const sah = this.#sah;
76
+ if (sah === undefined)
77
+ return undefined;
78
+ const fileName = `/${name}.db`;
79
+ const db = new sah.util.OpfsSAHPoolDb(fileName);
80
+ try {
81
+ configureSahCrashRecovery(db, this.#sqlite3);
82
+ }
83
+ catch (error) {
84
+ db.close();
85
+ throw error;
86
+ }
87
+ return {
88
+ database: new WasmClientDatabase(db, this.#sqlite3),
89
+ close: () => db.close(),
90
+ removeFile: () => {
91
+ sah.util.unlink(fileName);
92
+ },
93
+ };
94
+ }
95
+ siblingExists(name) {
96
+ const sah = this.#sah;
97
+ if (sah === undefined)
98
+ return false;
99
+ return sah.util.getFileNames().includes(`/${name}.db`);
100
+ }
64
101
  /**
65
102
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
66
103
  * schema, then `sqlite3_deserialize` the image bytes into it (the
@@ -138,6 +175,52 @@ function opfsSahPoolError(error, directory) {
138
175
  'Close the other instance or retry after a short delay; do not delete ' +
139
176
  `or rename the directory. Underlying error: ${detail}`, true);
140
177
  }
178
+ /** sqlite-wasm's default SAH pool capacity. */
179
+ const SAH_POOL_DEFAULT_CAPACITY = 6;
180
+ /**
181
+ * RFC 0005: each writable database consumes two slots (`.db` plus its
182
+ * transient `-journal`), so a replica plus one container file needs three.
183
+ * Exhaustion is a loud `SQLITE_CANTOPEN` ("SAH pool is full") that names the
184
+ * missing journal, not corruption — raise the floor rather than discover it.
185
+ */
186
+ const SAH_POOL_MIN_CAPACITY = 3;
187
+ /**
188
+ * Do this before the first SQL statement, when SQLite checks for a hot
189
+ * journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved lock
190
+ * unconditionally, which suppresses crash rollback. The pool already excludes
191
+ * other owners; report no competing writer, as the other OPFS VFS does. Keep
192
+ * DELETE/FULL and the existing database/journal files. Shared by the replica
193
+ * and every RFC 0005 sibling file.
194
+ */
195
+ function configureSahCrashRecovery(db, sqlite3) {
196
+ const { capi, wasm } = sqlite3;
197
+ const stack = wasm.pstack.pointer;
198
+ try {
199
+ const output = wasm.pstack.allocPtr();
200
+ if (db.pointer === undefined ||
201
+ capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
202
+ !wasm.peekPtr(output)) {
203
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
204
+ }
205
+ const file = new capi.sqlite3_file(wasm.peekPtr(output));
206
+ const methodsPointer = file.$pMethods;
207
+ file.dispose(); // Borrowed struct view; SQLite still owns the file.
208
+ if (!methodsPointer) {
209
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
210
+ }
211
+ if (!sahIoMethods.has(methodsPointer)) {
212
+ const methods = new capi.sqlite3_io_methods(methodsPointer);
213
+ methods.installMethod('xCheckReservedLock', (_file, reserved) => {
214
+ wasm.poke32(reserved, 0);
215
+ return 0;
216
+ });
217
+ sahIoMethods.set(methodsPointer, methods);
218
+ }
219
+ }
220
+ finally {
221
+ wasm.pstack.restore(stack);
222
+ }
223
+ }
141
224
  /**
142
225
  * THE reload-persistent browser mode: a named database on OPFS via the
143
226
  * `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
@@ -179,9 +262,7 @@ export async function openPersistentWasmDatabase(name, options) {
179
262
  // Syncular removes its own rejected entry below, so allow a later
180
263
  // open in the same worker to make a real attempt as well.
181
264
  forceReinitIfPreviouslyFailed: true,
182
- ...(options?.initialCapacity !== undefined
183
- ? { initialCapacity: options.initialCapacity }
184
- : {}),
265
+ initialCapacity: Math.max(options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY, SAH_POOL_MIN_CAPACITY),
185
266
  })
186
267
  .catch((error) => {
187
268
  sahPools.delete(directory);
@@ -191,41 +272,12 @@ export async function openPersistentWasmDatabase(name, options) {
191
272
  }
192
273
  const util = await pool;
193
274
  const db = new util.OpfsSAHPoolDb(`/${name}.db`);
194
- const { capi, wasm } = sqlite3;
195
- const stack = wasm.pstack.pointer;
196
275
  try {
197
- // Do this before the first SQL statement, when SQLite checks for a hot
198
- // journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
199
- // lock unconditionally, which suppresses crash rollback. The pool already
200
- // excludes other owners; report no competing writer, as the other OPFS
201
- // VFS does. Keep DELETE/FULL and the existing database/journal files.
202
- const output = wasm.pstack.allocPtr();
203
- if (db.pointer === undefined ||
204
- capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
205
- !wasm.peekPtr(output)) {
206
- throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
207
- }
208
- const file = new capi.sqlite3_file(wasm.peekPtr(output));
209
- const methodsPointer = file.$pMethods;
210
- file.dispose(); // Borrowed struct view; SQLite still owns the file.
211
- if (!methodsPointer) {
212
- throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
213
- }
214
- if (!sahIoMethods.has(methodsPointer)) {
215
- const methods = new capi.sqlite3_io_methods(methodsPointer);
216
- methods.installMethod('xCheckReservedLock', (_file, reserved) => {
217
- wasm.poke32(reserved, 0);
218
- return 0;
219
- });
220
- sahIoMethods.set(methodsPointer, methods);
221
- }
222
- return new WasmClientDatabase(db, sqlite3);
276
+ configureSahCrashRecovery(db, sqlite3);
277
+ return new WasmClientDatabase(db, sqlite3, { util });
223
278
  }
224
279
  catch (error) {
225
280
  db.close();
226
281
  throw error;
227
282
  }
228
- finally {
229
- wasm.pstack.restore(stack);
230
- }
231
283
  }
@@ -251,6 +251,9 @@ export function startSyncWorker(overrides = {}) {
251
251
  ...(config.securityPreflight !== undefined
252
252
  ? { securityPreflight: config.securityPreflight }
253
253
  : {}),
254
+ ...(config.previousVersionContext !== undefined
255
+ ? { previousVersionContext: config.previousVersionContext }
256
+ : {}),
254
257
  onSyncNeeded: (reason) => {
255
258
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
256
259
  consumeSyncIntent({ kind: 'interactive' });
@@ -342,6 +345,9 @@ export function startSyncWorker(overrides = {}) {
342
345
  },
343
346
  purgeLocalData: (input) => requireClient().purgeLocalData(input),
344
347
  rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
348
+ previousVersionSnapshot: (spec) => requireClient().previousVersionSnapshot(spec),
349
+ previousVersionAudit: () => requireClient().previousVersionAudit(),
350
+ previousVersionDiscard: () => requireClient().previousVersionDiscard(),
345
351
  sync: () => {
346
352
  const running = requireClient();
347
353
  return serializedSync(() => running.sync());
@@ -407,13 +413,18 @@ export function startSyncWorker(overrides = {}) {
407
413
  database = undefined;
408
414
  },
409
415
  };
410
- // Local purge/rebootstrap rewrite the same durable state an in-flight
411
- // sync round captured at send time; running their RPCs on the sync chain
412
- // orders them against RPC- and auto-driven rounds (the client core's
413
- // reset fence covers hosts that call the core directly).
416
+ // Local purge/rebootstrap/discard rewrite the same durable state an
417
+ // in-flight sync round captured at send time, and the RFC 0005 reads observe
418
+ // (and can themselves trigger the discard of) that same previous-version
419
+ // container. Running all of them on the sync chain orders them against RPC-
420
+ // and auto-driven rounds (the client core's reset fence covers hosts that
421
+ // call the core directly).
414
422
  const syncChainMethods = new Set([
415
423
  'purgeLocalData',
416
424
  'rebootstrapLocalData',
425
+ 'previousVersionSnapshot',
426
+ 'previousVersionAudit',
427
+ 'previousVersionDiscard',
417
428
  ]);
418
429
  async function dispatch(message) {
419
430
  const method = api[message.method];
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
25
25
  */
26
26
  import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
- import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
28
+ import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
30
  import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -37,6 +37,7 @@ import { type LocalDataRebootstrapInput, type LocalDataRebootstrapResult } from
37
37
  import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
38
38
  import type { OutboxCommit } from './outbox.js';
39
39
  import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
40
+ import { type PreviousVersionAudit, type PreviousVersionReadSpec, type PreviousVersionSnapshot } from './previous-version.js';
40
41
  import type { ClientSchema } from './schema.js';
41
42
  import type { SubscriptionRecord } from './state.js';
42
43
  import type { WindowBase } from './window.js';
@@ -80,6 +81,11 @@ export interface SyncClientHandleConfig {
80
81
  readonly securityPreflight?: boolean;
81
82
  readonly clientId?: string;
82
83
  readonly limits?: SyncClientLimits;
84
+ /**
85
+ * RFC 0005 previous-version context, forwarded into the worker's
86
+ * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
87
+ */
88
+ readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
83
89
  /** Worker-side host loop (§8.4); default true. */
84
90
  readonly autoSync?: boolean;
85
91
  /** Default: Web Locks when available, else single-owner. */
@@ -206,6 +212,12 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
206
212
  }): Promise<string>;
207
213
  purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
208
214
  rebootstrapLocalData(input: LocalDataRebootstrapInput): Promise<LocalDataRebootstrapResult>;
215
+ previousVersionSnapshot(spec: PreviousVersionReadSpec): Promise<PreviousVersionSnapshot>;
216
+ previousVersionAudit(): Promise<PreviousVersionAudit | undefined>;
217
+ previousVersionDiscard(): Promise<{
218
+ present: boolean;
219
+ discarded: boolean;
220
+ }>;
209
221
  sync(): Promise<SyncSummary>;
210
222
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
211
223
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
@@ -6,6 +6,7 @@ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './i
6
6
  import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
7
7
  import { decodeLocalDataRebootstrapResult, } from './local-rebootstrap.js';
8
8
  import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
9
+ import { decodePreviousVersionAuditResult, decodePreviousVersionDiscardResult, decodePreviousVersionSnapshot, } from './previous-version.js';
9
10
  import { NOT_LEADER_CODE, WORKER_FAILED_CODE, WORKER_RESTART_REQUIRED_CODE, } from './worker-protocol.js';
10
11
  const WORKER_BUNDLE_LOAD_FAILURE = /(?:failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|failed to load module script)/iu;
11
12
  /** Classify startup without echoing a chunk URL or bundler text to UI/logs. */
@@ -280,6 +281,15 @@ export class SyncClientHandle {
280
281
  async rebootstrapLocalData(input) {
281
282
  return decodeLocalDataRebootstrapResult(await this.#call('rebootstrapLocalData', [input]));
282
283
  }
284
+ async previousVersionSnapshot(spec) {
285
+ return decodePreviousVersionSnapshot(await this.#call('previousVersionSnapshot', [spec]));
286
+ }
287
+ async previousVersionAudit() {
288
+ return decodePreviousVersionAuditResult(await this.#call('previousVersionAudit', []));
289
+ }
290
+ async previousVersionDiscard() {
291
+ return decodePreviousVersionDiscardResult(await this.#call('previousVersionDiscard', []));
292
+ }
283
293
  sync() {
284
294
  return this.#call('sync', []);
285
295
  }
@@ -490,6 +500,9 @@ function buildInitConfig(config) {
490
500
  : {}),
491
501
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
492
502
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
503
+ ...(config.previousVersionContext !== undefined
504
+ ? { previousVersionContext: config.previousVersionContext }
505
+ : {}),
493
506
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
494
507
  };
495
508
  }
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
19
19
  */
20
20
  import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
- import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
+ import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
24
  import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -27,6 +27,7 @@ import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
27
27
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
28
28
  import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
29
29
  import type { OutboxCommit } from './outbox.js';
30
+ import type { PreviousVersionAudit, PreviousVersionReadSpec, PreviousVersionSnapshot } from './previous-version.js';
30
31
  import type { ClientSchema } from './schema.js';
31
32
  import type { SubscriptionRecord } from './state.js';
32
33
  import type { WindowBase } from './window.js';
@@ -73,6 +74,11 @@ export interface WorkerInitConfig {
73
74
  readonly securityPreflight?: boolean;
74
75
  readonly clientId?: string;
75
76
  readonly limits?: SyncClientLimits;
77
+ /**
78
+ * RFC 0005 previous-version context, forwarded verbatim into the worker's
79
+ * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
80
+ */
81
+ readonly previousVersionContext?: SyncClientConfig['previousVersionContext'];
76
82
  /**
77
83
  * Worker-side host loop (§8.4): coalesce interactive work immediately and
78
84
  * honor explicit background retry deadlines
@@ -107,6 +113,15 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
107
113
  purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult;
108
114
  /** Application-authorized, outbox-preserving projection recovery. */
109
115
  rebootstrapLocalData(input: LocalDataRebootstrapInput): LocalDataRebootstrapResult;
116
+ /** RFC 0005 D7: read the captured previous-schema rows for one table. */
117
+ previousVersionSnapshot(spec: PreviousVersionReadSpec): PreviousVersionSnapshot;
118
+ /** RFC 0005 D6: the advisory pre-reset compatibility audit, if recorded. */
119
+ previousVersionAudit(): PreviousVersionAudit | undefined;
120
+ /** RFC 0005 D9: the executable rollback step — drop the container. */
121
+ previousVersionDiscard(): {
122
+ present: boolean;
123
+ discarded: boolean;
124
+ };
110
125
  sync(): Promise<SyncSummary>;
111
126
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
112
127
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
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",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.21.1"
92
+ "@syncular/core": "0.23.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.21.1",
95
+ "@syncular/server": "0.23.0",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
@@ -4,13 +4,14 @@
4
4
  * browser entry never touches `bun:sqlite`.
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import {
11
11
  assertImageAlias,
12
12
  type ClientDatabase,
13
13
  runTransaction,
14
+ type SiblingDatabase,
14
15
  type SqlRow,
15
16
  type SqlValue,
16
17
  } from './database';
@@ -32,9 +33,11 @@ function coerceParams(params: readonly SqlValue[]): BunParam[] {
32
33
 
33
34
  export class BunClientDatabase implements ClientDatabase {
34
35
  readonly db: Database;
36
+ readonly #path: string;
35
37
  #tx = { depth: 0 };
36
38
 
37
39
  constructor(path = ':memory:') {
40
+ this.#path = path;
38
41
  this.db = new Database(path);
39
42
  // Match native Rust persistence: append durable commits to the WAL
40
43
  // instead of creating and syncing a rollback journal per transaction.
@@ -61,6 +64,28 @@ export class BunClientDatabase implements ClientDatabase {
61
64
  return this.db.query(sql).all(...coerceParams(params)) as SqlRow[];
62
65
  }
63
66
 
67
+ /** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
68
+ openSibling(name: string): SiblingDatabase {
69
+ const siblingPath =
70
+ this.#path === ':memory:' ? ':memory:' : `${this.#path}.${name}`;
71
+ const database = new BunClientDatabase(siblingPath);
72
+ return {
73
+ database,
74
+ close: () => database.close(),
75
+ removeFile: () => {
76
+ if (siblingPath === ':memory:') return;
77
+ rmSync(siblingPath, { force: true });
78
+ rmSync(`${siblingPath}-wal`, { force: true });
79
+ rmSync(`${siblingPath}-shm`, { force: true });
80
+ },
81
+ };
82
+ }
83
+
84
+ siblingExists(name: string): boolean {
85
+ if (this.#path === ':memory:') return false;
86
+ return existsSync(`${this.#path}.${name}`);
87
+ }
88
+
64
89
  transaction<T>(fn: () => T): T {
65
90
  return runTransaction(this.#tx, (sql) => this.db.run(sql), fn);
66
91
  }