@syncular/client 0.15.46 → 0.15.48

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
@@ -4,8 +4,9 @@ The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its
4
4
  browser platform bindings.
5
5
 
6
6
  The normal `SyncClient` also runs in a CLI or background service.
7
- Use `openBunDatabase(path)` or `openNodeDatabase(path)` for a persistent local
8
- replica. See the [server-side sync client guide](https://syncular.dev/guide-server-clients/).
7
+ Use `openSqliteDatabase(path)` from `@syncular/client/sqlite` for a persistent
8
+ local replica on Node or Bun. See the
9
+ [server-side sync client guide](https://syncular.dev/guide-server-clients/).
9
10
 
10
11
  `SyncRemoteClient` is the database-less server client. It sends ordinary
11
12
  push-only commits through `/sync` and can call registered typed queries,
@@ -13,6 +14,9 @@ server-authoritative commands, and live query watches through the remote
13
14
  operation transport. See [remote server operations](https://syncular.dev/guide-remote-operations/).
14
15
  Its schema and sync transport are optional for query-only or command-only
15
16
  processes.
17
+ Ordinary commits use wire version 1 until the caller supplies an acquired
18
+ partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
19
+ validation.
16
20
 
17
21
  ## Client-local FTS5 projections
18
22
 
@@ -68,6 +72,20 @@ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
68
72
  resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
69
73
  events for rendering.
70
74
 
75
+ A direct `SyncClient` used by a long-running service exposes
76
+ `onSyncNeeded()` and `onSyncIntent()`. Install the shared single-flight loop
77
+ instead of maintaining host timers:
78
+
79
+ ```ts
80
+ const scheduler = installSyncScheduler(client, { onError: reportSyncError });
81
+ // During shutdown:
82
+ scheduler.stop();
83
+ ```
84
+
85
+ Creation-time window helpers produce immutable UTC month scope values.
86
+ `creationTimeBucket(createdAtMs, 'month')` returns `YYYY-MM`, and
87
+ `last(3, 'month')` returns the current and preceding two buckets oldest first.
88
+
71
89
  OPFS is best effort until the browser grants origin persistence. The page owns
72
90
  that decision because `StorageManager.persist()` is a Window API and should be
73
91
  requested from a user action:
@@ -470,10 +488,10 @@ directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
470
488
  that pin them — a refcount adjust and a body insert/delete commit atomically,
471
489
  so a crash never strands a body against a stale count.
472
490
  - **Survives restarts for free.** The client DB already rides OPFS via the
473
- sahpool VFS in the browser (and a plain file under `rusqlite`/better-sqlite3
474
- on native/Node), so there is no second persistence surface and no second
475
- eviction policy to keep coherent. Close the app, reopen it: `fetchBlob` serves
476
- the cached body with no network.
491
+ sahpool VFS in the browser (and a plain file under `rusqlite`, `bun:sqlite`,
492
+ or `node:sqlite` on native runtimes), so there is no second persistence
493
+ surface and no second eviction policy to keep coherent. Close the app, reopen
494
+ it: `fetchBlob` serves the cached body with no network.
477
495
  - **SQLite handles multi-MB images fine.** A page-cached `BLOB` read is a memory
478
496
  copy, well within the image/document envelope this targets.
479
497
 
@@ -507,57 +525,38 @@ straight to a media element instead of pulling bytes through the cache — the
507
525
  image-app default (refcounted `BLOB` cache) and the large-media path (presigned
508
526
  URL, no byte cache) coexist per attachment.
509
527
 
510
- ## Node / Electron-main backend (`./node`)
528
+ ## Node and Bun SQLite backend (`./sqlite`)
511
529
 
512
- Hosts that run outside a browser an **Electron main process**, a plain
513
- **Node** service, a CLI — get a native SQLite backend through
514
- `openNodeDatabase`, a `ClientDatabase` over
515
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3):
530
+ CLIs, background workers, Electron main processes, and services can use one
531
+ runtime-selected import:
516
532
 
517
533
  ```ts
518
- import { openNodeDatabase } from '@syncular/client/node';
534
+ import { openSqliteDatabase } from '@syncular/client/sqlite';
519
535
  import { SyncClient } from '@syncular/client';
520
536
 
521
- const database = openNodeDatabase('app.db'); // or ':memory:' (default)
537
+ const database = openSqliteDatabase('app.db'); // or ':memory:' (default)
522
538
  const client = new SyncClient({ database, schema, /* … */ });
523
539
  ```
524
540
 
525
- It mirrors the bun:sqlite adapter exactly: synchronous `exec` / `query` /
526
- `transaction` (nested calls are savepoints an inner failure rolls back only
527
- the inner scope), the same boolean→0/1 bind coercion, `null` round-trips, and
528
- BLOB columns handed back as plain `Uint8Array`s. The §5.3 `withSqliteImage`
529
- attach path is supported too, so a Node host can accept sqlite-image segments.
541
+ The export selects `bun:sqlite` on Bun and the built-in `node:sqlite` module on
542
+ Node 22.13 or newer. No SQLite package or native addon is required. Both
543
+ adapters support synchronous `exec`, `query`, nested transactions, boolean
544
+ bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
530
545
 
531
- **better-sqlite3 is an OPTIONAL peer dependency, not a hard one.** The package
532
- installs cleanly without it (browser-only apps never pay for a native build);
533
- `openNodeDatabase()` loads it lazily on first call and throws a clear,
534
- actionable error if the peer is missing. Add it in your app:
546
+ Runtime-specific imports remain available:
535
547
 
536
- ```sh
537
- npm install better-sqlite3 # or: bun add better-sqlite3
548
+ ```ts
549
+ import { openBunDatabase } from '@syncular/client/bun';
550
+ import { openNodeDatabase } from '@syncular/client/node';
538
551
  ```
539
552
 
540
- **Verifying the Node adapter and why not under bun.** bun **cannot** dlopen
541
- better-sqlite3 (`ERR_DLOPEN_FAILED`,
542
- [oven-sh/bun#4290](https://github.com/oven-sh/bun/issues/4290)); calling
543
- `openNodeDatabase()` under bun deliberately raises the same helpful error and
544
- points you at `./bun` instead. So the bun test suite
545
- (`test/node-database.test.ts`) proves what it can under bun — type/subpath
546
- conformance, the missing-peer error, and that the shared behavioral contract
547
- (`test/node-database/adapter-contract.ts`) passes on the reference bun:sqlite
548
- backend — while the better-sqlite3 adapter's real behavior is proven under
549
- **Node** against the actual native module by running that same contract:
553
+ The source and packed-package runtime contracts run under actual Node and Bun:
550
554
 
551
555
  ```sh
552
- cd packages/web-client
553
- bun run verify:node
556
+ bun run verify:runtimes
557
+ bun run build:packages && bun run verify:packages
554
558
  ```
555
559
 
556
- That bundles the verifier with bun (transpile + resolve only — bun never
557
- executes the native module) and runs the plain-JS bundle under Node, which
558
- exercises `openNodeDatabase` against real better-sqlite3 and exits non-zero on
559
- any divergence from the contract.
560
-
561
560
  ## RPC protocol (6 message types)
562
561
 
563
562
  `init`, `call`, `ready`, `result`, `error`, `event` — every API method
@@ -573,8 +572,9 @@ they own their buffer.
573
572
  | `.` | protocol core, transports, handle + RPC protocol (browser-safe, no SQLite) |
574
573
  | `./worker` | `startSyncWorker` — worker-side bootstrap (pulls sqlite-wasm) |
575
574
  | `./wasm` | sqlite-wasm bindings: `openPersistentWasmDatabase`, `openWasmDatabase` |
576
- | `./bun` | bun:sqlite binding for tests |
577
- | `./node` | better-sqlite3 binding: `openNodeDatabase` (Electron-main / plain Node) |
575
+ | `./sqlite` | Runtime-selected SQLite: `openSqliteDatabase` on Node or Bun |
576
+ | `./bun` | Explicit `bun:sqlite` binding: `openBunDatabase` |
577
+ | `./node` | Explicit built-in `node:sqlite` binding: `openNodeDatabase` |
578
578
 
579
579
  Tests drive the real worker entry in a bun `Worker` with bun:sqlite
580
580
  injected through the bootstrap's database-factory override
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
7
  import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
8
+ declare module 'bun:sqlite' {
9
+ interface Database {
10
+ clearQueryCache(): void;
11
+ }
12
+ }
8
13
  export declare class BunClientDatabase implements ClientDatabase {
9
14
  #private;
10
15
  readonly db: Database;
@@ -23,6 +23,11 @@ export class BunClientDatabase {
23
23
  }
24
24
  exec(sql, params = []) {
25
25
  this.db.query(sql).run(...coerceParams(params));
26
+ // `Database.query()` caches prepared statements. Clear that cache after
27
+ // schema DDL so a reset does not reprepare every later row upsert.
28
+ if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
29
+ this.db.clearQueryCache();
30
+ }
26
31
  }
27
32
  query(sql, params = []) {
28
33
  return this.db.query(sql).all(...coerceParams(params));
package/dist/client.d.ts CHANGED
@@ -285,6 +285,10 @@ export declare class SyncClient {
285
285
  onInvalidate(listener: InvalidationListener): () => void;
286
286
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
287
287
  onChange(listener: ClientChangeListener): () => void;
288
+ /** Subscribe to host wake signals raised by startup and realtime. */
289
+ onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
290
+ /** Subscribe to exact core-owned scheduling instructions. */
291
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
288
292
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
289
293
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
290
294
  /**
package/dist/client.js CHANGED
@@ -71,6 +71,7 @@ function emptySummary(pushed) {
71
71
  failed: [],
72
72
  };
73
73
  }
74
+ const LOG_EPOCH_META_KEY = 'logEpoch';
74
75
  function isFinalPushResult(frame) {
75
76
  return (frame.status !== 'rejected' ||
76
77
  !frame.results.some((result) => result.status === 'error' &&
@@ -131,6 +132,8 @@ export class SyncClient {
131
132
  #invalidation = new InvalidationEmitter();
132
133
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
133
134
  #presenceListeners = new Set();
135
+ #syncNeededListeners = new Set();
136
+ #syncIntentListeners = new Set();
134
137
  #diagnostics = new ClientDiagnosticsEmitter();
135
138
  #diagnosticsDeferralDepth = 0;
136
139
  #diagnosticsPending = false;
@@ -233,8 +236,8 @@ export class SyncClient {
233
236
  subscriptions.some((sub) => sub.status === 'active'));
234
237
  if (startupWork && this.#securityLifecycle === 'active') {
235
238
  this.#needsPull = true;
236
- this.#config.onSyncNeeded?.('startup');
237
- this.#config.onSyncIntent?.({ kind: 'interactive' });
239
+ this.#emitSyncNeeded('startup');
240
+ this.#emitSyncIntent({ kind: 'interactive' });
238
241
  }
239
242
  // Console introspection is a no-op outside a dev page.
240
243
  this.#devtoolsUnregister = registerDevtools({
@@ -298,6 +301,29 @@ export class SyncClient {
298
301
  this.#setSchemaFloor(undefined);
299
302
  this.#replayOutbox();
300
303
  }
304
+ /** §2.1 reset after the server reports a different log continuity. */
305
+ #runLogEpochReset(logEpoch) {
306
+ const subscriptions = loadSubscriptions(this.#db);
307
+ const pending = listOutbox(this.#db);
308
+ this.#setUpgrading(true);
309
+ this.#applyBatch((batch) => {
310
+ this.#db.transaction(() => {
311
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
312
+ resetSubscriptionsForBump(this.#db);
313
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
314
+ for (const commit of pending) {
315
+ this.#applyOperationsLocally(commit.operations, batch);
316
+ }
317
+ });
318
+ for (const table of this.#schema.tables.values())
319
+ batch.table(table.name);
320
+ });
321
+ this.#localResetEpoch += 1;
322
+ this.#setSyncNeeded(true);
323
+ this.#emitSyncNeeded('startup');
324
+ this.#emitSyncIntent({ kind: 'interactive' });
325
+ return subscriptions.map((subscription) => subscription.id);
326
+ }
301
327
  #setUpgrading(upgrading) {
302
328
  if (this.#upgrading === upgrading)
303
329
  return;
@@ -325,6 +351,7 @@ export class SyncClient {
325
351
  });
326
352
  }
327
353
  async close() {
354
+ this.#emitSyncIntent({ kind: 'none' });
328
355
  this.#devtoolsUnregister?.();
329
356
  this.#devtoolsUnregister = undefined;
330
357
  this.disconnectRealtime();
@@ -332,6 +359,40 @@ export class SyncClient {
332
359
  await this.#lease?.release();
333
360
  this.#lease = undefined;
334
361
  this.#started = false;
362
+ this.#syncNeededListeners.clear();
363
+ this.#syncIntentListeners.clear();
364
+ }
365
+ #emitSyncNeeded(reason) {
366
+ try {
367
+ this.#config.onSyncNeeded?.(reason);
368
+ }
369
+ catch {
370
+ // An observer cannot alter sync correctness.
371
+ }
372
+ for (const listener of this.#syncNeededListeners) {
373
+ try {
374
+ listener(reason);
375
+ }
376
+ catch {
377
+ // An observer cannot alter sync correctness.
378
+ }
379
+ }
380
+ }
381
+ #emitSyncIntent(intent) {
382
+ try {
383
+ this.#config.onSyncIntent?.(intent);
384
+ }
385
+ catch {
386
+ // An observer cannot alter sync correctness.
387
+ }
388
+ for (const listener of this.#syncIntentListeners) {
389
+ try {
390
+ listener(intent);
391
+ }
392
+ catch {
393
+ // An observer cannot alter sync correctness.
394
+ }
395
+ }
335
396
  }
336
397
  /** Current fail-closed local-replica security state. */
337
398
  get securityLifecycle() {
@@ -382,8 +443,8 @@ export class SyncClient {
382
443
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
383
444
  if (startupWork) {
384
445
  this.#setSyncNeeded(true);
385
- this.#config.onSyncNeeded?.('startup');
386
- this.#config.onSyncIntent?.({ kind: 'interactive' });
446
+ this.#emitSyncNeeded('startup');
447
+ this.#emitSyncIntent({ kind: 'interactive' });
387
448
  }
388
449
  this.#emitDiagnostics();
389
450
  }
@@ -479,6 +540,20 @@ export class SyncClient {
479
540
  onChange(listener) {
480
541
  return this.#changes.on(listener);
481
542
  }
543
+ /** Subscribe to host wake signals raised by startup and realtime. */
544
+ onSyncNeeded(listener) {
545
+ this.#syncNeededListeners.add(listener);
546
+ return () => {
547
+ this.#syncNeededListeners.delete(listener);
548
+ };
549
+ }
550
+ /** Subscribe to exact core-owned scheduling instructions. */
551
+ onSyncIntent(listener) {
552
+ this.#syncIntentListeners.add(listener);
553
+ return () => {
554
+ this.#syncIntentListeners.delete(listener);
555
+ };
556
+ }
482
557
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
483
558
  onDiagnostics(listener) {
484
559
  return this.#diagnostics.on(listener);
@@ -1119,11 +1194,17 @@ export class SyncClient {
1119
1194
  cursor: -1,
1120
1195
  status: 'active',
1121
1196
  });
1197
+ this.#setSyncNeeded(true);
1198
+ this.#emitSyncIntent({ kind: 'interactive' });
1122
1199
  this.#emitDiagnostics();
1123
1200
  }
1124
1201
  unsubscribe(id) {
1125
1202
  this.#requireActive();
1203
+ if (getSubscription(this.#db, id) === undefined)
1204
+ return;
1126
1205
  deleteSubscription(this.#db, id);
1206
+ this.#setSyncNeeded(true);
1207
+ this.#emitSyncIntent({ kind: 'interactive' });
1127
1208
  this.#emitDiagnostics();
1128
1209
  }
1129
1210
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -1179,7 +1260,9 @@ export class SyncClient {
1179
1260
  status: 'active',
1180
1261
  });
1181
1262
  });
1263
+ this.#needsPull = true;
1182
1264
  batch.window(baseKey, base.table, unit);
1265
+ batch.status();
1183
1266
  });
1184
1267
  changed = true;
1185
1268
  widened = true;
@@ -1195,6 +1278,9 @@ export class SyncClient {
1195
1278
  const effects = {
1196
1279
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1197
1280
  };
1281
+ if (effects.sync.kind === 'interactive') {
1282
+ this.#emitSyncIntent(effects.sync);
1283
+ }
1198
1284
  return { value: undefined, effects };
1199
1285
  }
1200
1286
  /**
@@ -1254,6 +1340,8 @@ export class SyncClient {
1254
1340
  });
1255
1341
  batch.scopeMap(table, effective);
1256
1342
  batch.window(baseKey, table.name, unit);
1343
+ this.#needsPull = true;
1344
+ batch.status();
1257
1345
  });
1258
1346
  }
1259
1347
  /**
@@ -1349,7 +1437,9 @@ export class SyncClient {
1349
1437
  this.#applyOperationsLocally(operations, batch);
1350
1438
  batch.status();
1351
1439
  });
1440
+ this.#needsPull = true;
1352
1441
  });
1442
+ this.#emitSyncIntent({ kind: 'interactive' });
1353
1443
  return clientCommitId;
1354
1444
  }
1355
1445
  /** Host-facing mutation result with explicit network work intent (§7.5). */
@@ -1577,8 +1667,8 @@ export class SyncClient {
1577
1667
  this.#localResetEpoch += 1;
1578
1668
  if (!priorUpgrading)
1579
1669
  this.#config.onUpgrading?.(true);
1580
- this.#config.onSyncNeeded?.('startup');
1581
- this.#config.onSyncIntent?.({ kind: 'interactive' });
1670
+ this.#emitSyncNeeded('startup');
1671
+ this.#emitSyncIntent({ kind: 'interactive' });
1582
1672
  return {
1583
1673
  alreadyApplied: false,
1584
1674
  retainedCommits: pending.length,
@@ -1798,9 +1888,12 @@ export class SyncClient {
1798
1888
  // survive it — the reference server keeps no replay buffer (§8.2).
1799
1889
  this.#setSyncNeeded(false);
1800
1890
  try {
1891
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
1801
1892
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1802
1893
  // them, so the server-side existence check (§6.6) passes.
1803
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
1894
+ if (logEpoch !== undefined &&
1895
+ this.#hasBlobs &&
1896
+ this.#config.blobs !== undefined) {
1804
1897
  await this.flushBlobUploads();
1805
1898
  }
1806
1899
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -1808,7 +1901,9 @@ export class SyncClient {
1808
1901
  // is removed from the push and surfaced as a rejection, never wedging
1809
1902
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
1810
1903
  // mapping.
1811
- const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
1904
+ const { pushFrames, outbox, deferred } = logEpoch === undefined
1905
+ ? { pushFrames: [], outbox: [], deferred: 0 }
1906
+ : await this.#encodeOutboxForPush();
1812
1907
  // Captured together with the subscription state below: the response
1813
1908
  // apply persists SUB_END cursors only while this epoch is current.
1814
1909
  const resetEpoch = this.#localResetEpoch;
@@ -1819,6 +1914,7 @@ export class SyncClient {
1819
1914
  type: 'REQ_HEADER',
1820
1915
  clientId: this.#clientId,
1821
1916
  schemaVersion: this.#schema.version,
1917
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
1822
1918
  },
1823
1919
  ...pushFrames,
1824
1920
  {
@@ -1885,12 +1981,7 @@ export class SyncClient {
1885
1981
  delayMs: this.#retryDelayMs,
1886
1982
  };
1887
1983
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
1888
- try {
1889
- this.#config.onSyncIntent?.(intent);
1890
- }
1891
- catch {
1892
- // An observer cannot alter sync correctness.
1893
- }
1984
+ this.#emitSyncIntent(intent);
1894
1985
  }
1895
1986
  throw error;
1896
1987
  }
@@ -2078,14 +2169,14 @@ export class SyncClient {
2078
2169
  if (event.event === 'hello') {
2079
2170
  if (event.data.requiresSync) {
2080
2171
  this.#setSyncNeeded(true);
2081
- this.#config.onSyncNeeded?.('hello');
2172
+ this.#emitSyncNeeded('hello');
2082
2173
  }
2083
2174
  return;
2084
2175
  }
2085
2176
  if (event.event === 'sync') {
2086
2177
  // §8.3: any wake-up means "run a pull soon", never data.
2087
2178
  this.#setSyncNeeded(true);
2088
- this.#config.onSyncNeeded?.(event.data.reason);
2179
+ this.#emitSyncNeeded(event.data.reason);
2089
2180
  return;
2090
2181
  }
2091
2182
  if (event.event === 'presence') {
@@ -2155,7 +2246,7 @@ export class SyncClient {
2155
2246
  catch {
2156
2247
  // A delta that cannot be applied is recovered by a pull (§8.3).
2157
2248
  this.#setSyncNeeded(true);
2158
- this.#config.onSyncNeeded?.('catchup-required');
2249
+ this.#emitSyncNeeded('catchup-required');
2159
2250
  }
2160
2251
  });
2161
2252
  }
@@ -2195,6 +2286,11 @@ export class SyncClient {
2195
2286
  if (header?.type !== 'RESP_HEADER') {
2196
2287
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2197
2288
  }
2289
+ if (message.wireVersion < 2 ||
2290
+ header.logEpoch === undefined ||
2291
+ header.resetRequired === undefined) {
2292
+ throw new ClientSyncError('client.invalid_host_response', 'the server response does not carry wire version 2 log-epoch state');
2293
+ }
2198
2294
  if (header.requiredSchemaVersion !== undefined) {
2199
2295
  // §1.6 schema floor: nothing else was processed — stop syncing and
2200
2296
  // surface the upgrade requirement. A live-round floor always stops:
@@ -2216,6 +2312,20 @@ export class SyncClient {
2216
2312
  schemaFloor,
2217
2313
  };
2218
2314
  }
2315
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2316
+ if (header.resetRequired) {
2317
+ if (mode !== 'pull' || message.frames.length !== 1) {
2318
+ throw new ClientSyncError('client.invalid_host_response', 'a log-epoch reset response must contain only RESP_HEADER');
2319
+ }
2320
+ return {
2321
+ ...summary,
2322
+ resets: this.#runLogEpochReset(header.logEpoch),
2323
+ bootstrapping: [],
2324
+ };
2325
+ }
2326
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
2327
+ throw new ClientSyncError('client.invalid_host_response', 'the server changed logEpoch without requiring a reset');
2328
+ }
2219
2329
  let section;
2220
2330
  let errorFrame;
2221
2331
  let deltaCursor = -1;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
@@ -1,41 +1,18 @@
1
- import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
2
1
  /**
3
- * Structural view of the tiny better-sqlite3 surface this binding uses. We
4
- * type it locally (rather than importing `better-sqlite3`'s types) so the
5
- * package typechecks without the optional peer installed.
2
+ * `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
3
+ * adapter: synchronous queries, nested transactions, and SQLite image attach.
6
4
  */
7
- interface BetterSqliteStatement {
8
- run(...params: NodeParam[]): unknown;
9
- all(...params: NodeParam[]): unknown[];
10
- }
11
- interface BetterSqliteDatabase {
12
- readonly inTransaction: boolean;
13
- prepare(sql: string): BetterSqliteStatement;
14
- exec(sql: string): unknown;
15
- close(): void;
16
- }
17
- /**
18
- * better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
19
- * bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
20
- * coerce booleans to 0/1 exactly like the bun adapter so callers see one
21
- * uniform bind contract across every backend.
22
- */
23
- type NodeParam = string | number | bigint | Uint8Array | null;
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
24
7
  export declare class NodeClientDatabase implements ClientDatabase {
25
8
  #private;
26
- readonly db: BetterSqliteDatabase;
9
+ readonly db: DatabaseSync;
27
10
  constructor(path?: string);
28
11
  exec(sql: string, params?: readonly SqlValue[]): void;
29
12
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
30
13
  transaction<T>(fn: () => T): T;
31
- /**
32
- * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
33
- * buffers, so the image lands in a private temp file for the duration of
34
- * the ATTACH. Must be called outside any open transaction (SQLite cannot
35
- * ATTACH inside one).
36
- */
14
+ /** §5.3 image import through a private file attached for one callback. */
37
15
  withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
38
16
  close(): void;
39
17
  }
40
18
  export declare function openNodeDatabase(path?: string): ClientDatabase;
41
- export {};