@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/src/client.ts CHANGED
@@ -513,6 +513,8 @@ function emptySummary(pushed: number): MutableSummary {
513
513
  };
514
514
  }
515
515
 
516
+ const LOG_EPOCH_META_KEY = 'logEpoch';
517
+
516
518
  function isFinalPushResult(frame: PushResultFrame): boolean {
517
519
  return (
518
520
  frame.status !== 'rejected' ||
@@ -579,6 +581,10 @@ export class SyncClient {
579
581
  readonly #invalidation = new InvalidationEmitter();
580
582
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
581
583
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
584
+ readonly #syncNeededListeners = new Set<
585
+ (reason: 'startup' | 'hello' | WakeReason) => void
586
+ >();
587
+ readonly #syncIntentListeners = new Set<(intent: SyncIntent) => void>();
582
588
  readonly #diagnostics = new ClientDiagnosticsEmitter();
583
589
  #diagnosticsDeferralDepth = 0;
584
590
  #diagnosticsPending = false;
@@ -705,8 +711,8 @@ export class SyncClient {
705
711
  subscriptions.some((sub) => sub.status === 'active'));
706
712
  if (startupWork && this.#securityLifecycle === 'active') {
707
713
  this.#needsPull = true;
708
- this.#config.onSyncNeeded?.('startup');
709
- this.#config.onSyncIntent?.({ kind: 'interactive' });
714
+ this.#emitSyncNeeded('startup');
715
+ this.#emitSyncIntent({ kind: 'interactive' });
710
716
  }
711
717
  // Console introspection is a no-op outside a dev page.
712
718
  this.#devtoolsUnregister = registerDevtools({
@@ -776,6 +782,29 @@ export class SyncClient {
776
782
  this.#replayOutbox();
777
783
  }
778
784
 
785
+ /** §2.1 reset after the server reports a different log continuity. */
786
+ #runLogEpochReset(logEpoch: string): string[] {
787
+ const subscriptions = loadSubscriptions(this.#db);
788
+ const pending = listOutbox(this.#db);
789
+ this.#setUpgrading(true);
790
+ this.#applyBatch((batch) => {
791
+ this.#db.transaction(() => {
792
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
793
+ resetSubscriptionsForBump(this.#db);
794
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
795
+ for (const commit of pending) {
796
+ this.#applyOperationsLocally(commit.operations, batch);
797
+ }
798
+ });
799
+ for (const table of this.#schema.tables.values()) batch.table(table.name);
800
+ });
801
+ this.#localResetEpoch += 1;
802
+ this.#setSyncNeeded(true);
803
+ this.#emitSyncNeeded('startup');
804
+ this.#emitSyncIntent({ kind: 'interactive' });
805
+ return subscriptions.map((subscription) => subscription.id);
806
+ }
807
+
779
808
  #setUpgrading(upgrading: boolean): void {
780
809
  if (this.#upgrading === upgrading) return;
781
810
  this.#applyBatch((batch) => {
@@ -803,6 +832,7 @@ export class SyncClient {
803
832
  }
804
833
 
805
834
  async close(): Promise<void> {
835
+ this.#emitSyncIntent({ kind: 'none' });
806
836
  this.#devtoolsUnregister?.();
807
837
  this.#devtoolsUnregister = undefined;
808
838
  this.disconnectRealtime();
@@ -810,6 +840,38 @@ export class SyncClient {
810
840
  await this.#lease?.release();
811
841
  this.#lease = undefined;
812
842
  this.#started = false;
843
+ this.#syncNeededListeners.clear();
844
+ this.#syncIntentListeners.clear();
845
+ }
846
+
847
+ #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
848
+ try {
849
+ this.#config.onSyncNeeded?.(reason);
850
+ } catch {
851
+ // An observer cannot alter sync correctness.
852
+ }
853
+ for (const listener of this.#syncNeededListeners) {
854
+ try {
855
+ listener(reason);
856
+ } catch {
857
+ // An observer cannot alter sync correctness.
858
+ }
859
+ }
860
+ }
861
+
862
+ #emitSyncIntent(intent: SyncIntent): void {
863
+ try {
864
+ this.#config.onSyncIntent?.(intent);
865
+ } catch {
866
+ // An observer cannot alter sync correctness.
867
+ }
868
+ for (const listener of this.#syncIntentListeners) {
869
+ try {
870
+ listener(intent);
871
+ } catch {
872
+ // An observer cannot alter sync correctness.
873
+ }
874
+ }
813
875
  }
814
876
 
815
877
  /** Current fail-closed local-replica security state. */
@@ -869,8 +931,8 @@ export class SyncClient {
869
931
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
870
932
  if (startupWork) {
871
933
  this.#setSyncNeeded(true);
872
- this.#config.onSyncNeeded?.('startup');
873
- this.#config.onSyncIntent?.({ kind: 'interactive' });
934
+ this.#emitSyncNeeded('startup');
935
+ this.#emitSyncIntent({ kind: 'interactive' });
874
936
  }
875
937
  this.#emitDiagnostics();
876
938
  }
@@ -982,6 +1044,24 @@ export class SyncClient {
982
1044
  return this.#changes.on(listener);
983
1045
  }
984
1046
 
1047
+ /** Subscribe to host wake signals raised by startup and realtime. */
1048
+ onSyncNeeded(
1049
+ listener: (reason: 'startup' | 'hello' | WakeReason) => void,
1050
+ ): () => void {
1051
+ this.#syncNeededListeners.add(listener);
1052
+ return () => {
1053
+ this.#syncNeededListeners.delete(listener);
1054
+ };
1055
+ }
1056
+
1057
+ /** Subscribe to exact core-owned scheduling instructions. */
1058
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void {
1059
+ this.#syncIntentListeners.add(listener);
1060
+ return () => {
1061
+ this.#syncIntentListeners.delete(listener);
1062
+ };
1063
+ }
1064
+
985
1065
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
986
1066
  onDiagnostics(listener: ClientDiagnosticsListener): () => void {
987
1067
  return this.#diagnostics.on(listener);
@@ -1781,12 +1861,17 @@ export class SyncClient {
1781
1861
  cursor: -1,
1782
1862
  status: 'active',
1783
1863
  });
1864
+ this.#setSyncNeeded(true);
1865
+ this.#emitSyncIntent({ kind: 'interactive' });
1784
1866
  this.#emitDiagnostics();
1785
1867
  }
1786
1868
 
1787
1869
  unsubscribe(id: string): void {
1788
1870
  this.#requireActive();
1871
+ if (getSubscription(this.#db, id) === undefined) return;
1789
1872
  deleteSubscription(this.#db, id);
1873
+ this.#setSyncNeeded(true);
1874
+ this.#emitSyncIntent({ kind: 'interactive' });
1790
1875
  this.#emitDiagnostics();
1791
1876
  }
1792
1877
 
@@ -1851,7 +1936,9 @@ export class SyncClient {
1851
1936
  status: 'active',
1852
1937
  });
1853
1938
  });
1939
+ this.#needsPull = true;
1854
1940
  batch.window(baseKey, base.table, unit);
1941
+ batch.status();
1855
1942
  });
1856
1943
  changed = true;
1857
1944
  widened = true;
@@ -1867,6 +1954,9 @@ export class SyncClient {
1867
1954
  const effects: CommandEffects = {
1868
1955
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1869
1956
  };
1957
+ if (effects.sync.kind === 'interactive') {
1958
+ this.#emitSyncIntent(effects.sync);
1959
+ }
1870
1960
  return { value: undefined, effects };
1871
1961
  }
1872
1962
 
@@ -1934,6 +2024,8 @@ export class SyncClient {
1934
2024
  });
1935
2025
  batch.scopeMap(table, effective);
1936
2026
  batch.window(baseKey, table.name, unit);
2027
+ this.#needsPull = true;
2028
+ batch.status();
1937
2029
  });
1938
2030
  }
1939
2031
 
@@ -2043,7 +2135,9 @@ export class SyncClient {
2043
2135
  this.#applyOperationsLocally(operations, batch);
2044
2136
  batch.status();
2045
2137
  });
2138
+ this.#needsPull = true;
2046
2139
  });
2140
+ this.#emitSyncIntent({ kind: 'interactive' });
2047
2141
  return clientCommitId;
2048
2142
  }
2049
2143
 
@@ -2320,8 +2414,8 @@ export class SyncClient {
2320
2414
  this.#localResetEpoch += 1;
2321
2415
 
2322
2416
  if (!priorUpgrading) this.#config.onUpgrading?.(true);
2323
- this.#config.onSyncNeeded?.('startup');
2324
- this.#config.onSyncIntent?.({ kind: 'interactive' });
2417
+ this.#emitSyncNeeded('startup');
2418
+ this.#emitSyncIntent({ kind: 'interactive' });
2325
2419
  return {
2326
2420
  alreadyApplied: false,
2327
2421
  retainedCommits: pending.length,
@@ -2585,9 +2679,14 @@ export class SyncClient {
2585
2679
  // survive it — the reference server keeps no replay buffer (§8.2).
2586
2680
  this.#setSyncNeeded(false);
2587
2681
  try {
2682
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2588
2683
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2589
2684
  // them, so the server-side existence check (§6.6) passes.
2590
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
2685
+ if (
2686
+ logEpoch !== undefined &&
2687
+ this.#hasBlobs &&
2688
+ this.#config.blobs !== undefined
2689
+ ) {
2591
2690
  await this.flushBlobUploads();
2592
2691
  }
2593
2692
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -2596,7 +2695,9 @@ export class SyncClient {
2596
2695
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
2597
2696
  // mapping.
2598
2697
  const { pushFrames, outbox, deferred } =
2599
- await this.#encodeOutboxForPush();
2698
+ logEpoch === undefined
2699
+ ? { pushFrames: [], outbox: [], deferred: 0 }
2700
+ : await this.#encodeOutboxForPush();
2600
2701
  // Captured together with the subscription state below: the response
2601
2702
  // apply persists SUB_END cursors only while this epoch is current.
2602
2703
  const resetEpoch = this.#localResetEpoch;
@@ -2609,6 +2710,7 @@ export class SyncClient {
2609
2710
  type: 'REQ_HEADER',
2610
2711
  clientId: this.#clientId,
2611
2712
  schemaVersion: this.#schema.version,
2713
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
2612
2714
  },
2613
2715
  ...pushFrames,
2614
2716
  {
@@ -2686,11 +2788,7 @@ export class SyncClient {
2686
2788
  delayMs: this.#retryDelayMs,
2687
2789
  };
2688
2790
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
2689
- try {
2690
- this.#config.onSyncIntent?.(intent);
2691
- } catch {
2692
- // An observer cannot alter sync correctness.
2693
- }
2791
+ this.#emitSyncIntent(intent);
2694
2792
  }
2695
2793
  throw error;
2696
2794
  } finally {
@@ -2916,14 +3014,14 @@ export class SyncClient {
2916
3014
  if (event.event === 'hello') {
2917
3015
  if (event.data.requiresSync) {
2918
3016
  this.#setSyncNeeded(true);
2919
- this.#config.onSyncNeeded?.('hello');
3017
+ this.#emitSyncNeeded('hello');
2920
3018
  }
2921
3019
  return;
2922
3020
  }
2923
3021
  if (event.event === 'sync') {
2924
3022
  // §8.3: any wake-up means "run a pull soon", never data.
2925
3023
  this.#setSyncNeeded(true);
2926
- this.#config.onSyncNeeded?.(event.data.reason);
3024
+ this.#emitSyncNeeded(event.data.reason);
2927
3025
  return;
2928
3026
  }
2929
3027
  if (event.event === 'presence') {
@@ -2995,7 +3093,7 @@ export class SyncClient {
2995
3093
  } catch {
2996
3094
  // A delta that cannot be applied is recovered by a pull (§8.3).
2997
3095
  this.#setSyncNeeded(true);
2998
- this.#config.onSyncNeeded?.('catchup-required');
3096
+ this.#emitSyncNeeded('catchup-required');
2999
3097
  }
3000
3098
  });
3001
3099
  }
@@ -3057,6 +3155,16 @@ export class SyncClient {
3057
3155
  if (header?.type !== 'RESP_HEADER') {
3058
3156
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
3059
3157
  }
3158
+ if (
3159
+ message.wireVersion < 2 ||
3160
+ header.logEpoch === undefined ||
3161
+ header.resetRequired === undefined
3162
+ ) {
3163
+ throw new ClientSyncError(
3164
+ 'client.invalid_host_response',
3165
+ 'the server response does not carry wire version 2 log-epoch state',
3166
+ );
3167
+ }
3060
3168
  if (header.requiredSchemaVersion !== undefined) {
3061
3169
  // §1.6 schema floor: nothing else was processed — stop syncing and
3062
3170
  // surface the upgrade requirement. A live-round floor always stops:
@@ -3078,6 +3186,26 @@ export class SyncClient {
3078
3186
  schemaFloor,
3079
3187
  };
3080
3188
  }
3189
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
3190
+ if (header.resetRequired) {
3191
+ if (mode !== 'pull' || message.frames.length !== 1) {
3192
+ throw new ClientSyncError(
3193
+ 'client.invalid_host_response',
3194
+ 'a log-epoch reset response must contain only RESP_HEADER',
3195
+ );
3196
+ }
3197
+ return {
3198
+ ...summary,
3199
+ resets: this.#runLogEpochReset(header.logEpoch),
3200
+ bootstrapping: [],
3201
+ };
3202
+ }
3203
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
3204
+ throw new ClientSyncError(
3205
+ 'client.invalid_host_response',
3206
+ 'the server changed logEpoch without requiring a reset',
3207
+ );
3208
+ }
3081
3209
 
3082
3210
  let section: OpenSection | undefined;
3083
3211
  let errorFrame: ClientSyncError | undefined;
package/src/index.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';
35
35
  export * from './schema';
36
36
  export * from './sql-tag';
37
37
  export * from './state';
38
+ export * from './sync-scheduler';
38
39
  export * from './transport';
39
40
  export * from './window';
40
41
  export * from './worker-host';
@@ -1,23 +1,9 @@
1
1
  /**
2
- * `ClientDatabase` on better-sqlite3 the Electron-main / plain-Node
3
- * backend. Semantics mirror `./bun-database`
4
- * exactly (synchronous exec/query/transaction with the shared savepoint
5
- * helper, and the same §5.3 sqlite-image ATTACH path), so the core behaves
6
- * identically whether it runs on bun:sqlite (tests), sqlite-wasm (browser)
7
- * or better-sqlite3 (Node/Electron-main).
8
- *
9
- * better-sqlite3 is an OPTIONAL peer dependency, not a hard one: the package
10
- * installs cleanly without it and this module errors helpfully only when a
11
- * host actually calls `openNodeDatabase()` without having installed the peer.
12
- * Not exported from the package root, so browser/bun entries never resolve
13
- * the native module. Subpath export: `@syncular/client/node`.
14
- *
15
- * bun CANNOT dlopen better-sqlite3 (ERR_DLOPEN_FAILED, oven-sh/bun#4290), so
16
- * this adapter is verified under real Node — see the README "Electron-main /
17
- * plain-Node" section for the one-command recipe and `test/node-database`.
2
+ * `ClientDatabase` on Node's built-in `node:sqlite`. Semantics mirror the Bun
3
+ * adapter: synchronous queries, nested transactions, and SQLite image attach.
18
4
  */
5
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
19
6
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
- import { createRequire } from 'node:module';
21
7
  import { tmpdir } from 'node:os';
22
8
  import { join } from 'node:path';
23
9
  import {
@@ -28,54 +14,19 @@ import {
28
14
  type SqlValue,
29
15
  } from './database';
30
16
 
31
- /**
32
- * Structural view of the tiny better-sqlite3 surface this binding uses. We
33
- * type it locally (rather than importing `better-sqlite3`'s types) so the
34
- * package typechecks without the optional peer installed.
35
- */
36
- interface BetterSqliteStatement {
37
- run(...params: NodeParam[]): unknown;
38
- all(...params: NodeParam[]): unknown[];
39
- }
40
- interface BetterSqliteDatabase {
41
- readonly inTransaction: boolean;
42
- prepare(sql: string): BetterSqliteStatement;
43
- exec(sql: string): unknown;
44
- close(): void;
45
- }
46
- type BetterSqliteConstructor = new (
47
- path: string,
48
- options?: { readonly?: boolean; fileMustExist?: boolean },
49
- ) => BetterSqliteDatabase;
50
-
51
- /**
52
- * better-sqlite3 accepts string / number / bigint / null / Buffer|Uint8Array
53
- * bind values, but NOT booleans (it throws "TypeError: can only bind …"). We
54
- * coerce booleans to 0/1 exactly like the bun adapter so callers see one
55
- * uniform bind contract across every backend.
56
- */
57
- type NodeParam = string | number | bigint | Uint8Array | null;
58
-
59
- function coerceParams(params: readonly SqlValue[]): NodeParam[] {
60
- return params.map((value): NodeParam => {
17
+ function coerceParams(params: readonly SqlValue[]): SQLInputValue[] {
18
+ return params.map((value): SQLInputValue => {
61
19
  if (typeof value === 'boolean') return value ? 1 : 0;
62
20
  return value;
63
21
  });
64
22
  }
65
23
 
66
- /**
67
- * better-sqlite3 returns BLOB columns as Node `Buffer`s. A Buffer IS a
68
- * Uint8Array subclass, but it can be a view onto a shared pool buffer, so we
69
- * normalize to a standalone Uint8Array — matching what bun:sqlite hands back
70
- * and keeping the buffer-ownership assumptions elsewhere (worker transfer,
71
- * structured clone) honest.
72
- */
73
24
  function normalizeRow(row: Record<string, unknown>): SqlRow {
74
25
  const out: SqlRow = {};
75
26
  for (const key in row) {
76
27
  const value = row[key];
77
- if (Buffer.isBuffer(value)) {
78
- out[key] = new Uint8Array(value); // copies out of the pool
28
+ if (value instanceof Uint8Array) {
29
+ out[key] = new Uint8Array(value);
79
30
  } else {
80
31
  out[key] = value as SqlValue;
81
32
  }
@@ -83,55 +34,12 @@ function normalizeRow(row: Record<string, unknown>): SqlRow {
83
34
  return out;
84
35
  }
85
36
 
86
- /**
87
- * Load the optional peer AND open the database in one guarded step, so BOTH
88
- * failure modes are turned into a clear, actionable error rather than a raw
89
- * one:
90
- *
91
- * - `require('better-sqlite3')` throwing MODULE_NOT_FOUND — the peer is not
92
- * installed (the common browser-only-host case), and
93
- * - `new Database()` throwing ERR_DLOPEN_FAILED — the module resolves but the
94
- * native addon cannot load, which is exactly what bun does for
95
- * better-sqlite3 (oven-sh/bun#4290); the addon only dlopens at construction.
96
- */
97
- function openBetterSqlite(path: string): BetterSqliteDatabase {
98
- const require = createRequire(import.meta.url);
99
- try {
100
- const mod = require('better-sqlite3') as
101
- | BetterSqliteConstructor
102
- | { default: BetterSqliteConstructor };
103
- const Database =
104
- (mod as { default?: BetterSqliteConstructor }).default ??
105
- (mod as BetterSqliteConstructor);
106
- return new Database(path);
107
- } catch (error) {
108
- const code = (error as { code?: string })?.code;
109
- if (code === 'ERR_DLOPEN_FAILED') {
110
- throw new Error(
111
- "openNodeDatabase() requires the 'better-sqlite3' native module, but " +
112
- 'it failed to load. This most commonly means you are running under ' +
113
- 'bun, which cannot dlopen better-sqlite3 (oven-sh/bun#4290) — use ' +
114
- "the bun:sqlite backend ('@syncular/client/bun') under bun, " +
115
- "and reserve '@syncular/client/node' for Node/Electron-main. " +
116
- `Underlying error: ${String(error)}`,
117
- );
118
- }
119
- throw new Error(
120
- 'openNodeDatabase() requires the optional peer dependency ' +
121
- "'better-sqlite3', which is not installed. Add it to your app " +
122
- '(`npm install better-sqlite3` / `bun add better-sqlite3`) — it is ' +
123
- 'kept optional so @syncular/client installs without a native ' +
124
- `build for browser-only hosts. Underlying error: ${String(error)}`,
125
- );
126
- }
127
- }
128
-
129
37
  export class NodeClientDatabase implements ClientDatabase {
130
- readonly db: BetterSqliteDatabase;
38
+ readonly db: DatabaseSync;
131
39
  #tx = { depth: 0 };
132
40
 
133
41
  constructor(path = ':memory:') {
134
- this.db = openBetterSqlite(path);
42
+ this.db = new DatabaseSync(path);
135
43
  }
136
44
 
137
45
  exec(sql: string, params: readonly SqlValue[] = []): void {
@@ -140,19 +48,14 @@ export class NodeClientDatabase implements ClientDatabase {
140
48
 
141
49
  query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
142
50
  const rows = this.db.prepare(sql).all(...coerceParams(params));
143
- return (rows as Record<string, unknown>[]).map(normalizeRow);
51
+ return rows.map(normalizeRow);
144
52
  }
145
53
 
146
54
  transaction<T>(fn: () => T): T {
147
55
  return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
148
56
  }
149
57
 
150
- /**
151
- * §5.3 image import: better-sqlite3 (like bun:sqlite) attaches files, not
152
- * buffers, so the image lands in a private temp file for the duration of
153
- * the ATTACH. Must be called outside any open transaction (SQLite cannot
154
- * ATTACH inside one).
155
- */
58
+ /** §5.3 image import through a private file attached for one callback. */
156
59
  withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
157
60
  assertImageAlias(alias);
158
61
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
@@ -10,8 +10,8 @@
10
10
  * bypasses the outbox (SPEC §7.1) and silently diverges from the
11
11
  * server — writes MUST go through `client.mutate([...])`.
12
12
  * 2. ONE STATEMENT. `sqlite-wasm`'s `exec` runs every statement in a
13
- * multi-statement string (`SELECT 1; DROP TABLE t`), while bun:sqlite /
14
- * better-sqlite3 prepare only the first. We unify on the strict
13
+ * multi-statement string (`SELECT 1; DROP TABLE t`), while the native
14
+ * SQLite adapters prepare only the first. We unify on the strict
15
15
  * behaviour: exactly one statement per `query()`.
16
16
  *
17
17
  * The guard only fronts the PUBLIC `client.query()` — engine-internal reads
package/src/remote.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  encodeRemoteOperationRequest,
11
11
  encodeRemoteOperationRealtimeMessage,
12
12
  encodeRow,
13
- PROTOCOL_WIRE_VERSION,
14
13
  type PushOperation,
15
14
  type PushOperationResult,
16
15
  type PushResultDetailsFrame,
@@ -42,6 +41,8 @@ export interface SyncRemoteClientConfig {
42
41
  readonly operations?: RemoteOperationTransport;
43
42
  readonly operationRealtime?: RemoteOperationRealtimeConnector;
44
43
  readonly encryption?: EncryptionConfig;
44
+ /** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
45
+ readonly logEpoch?: string;
45
46
  }
46
47
 
47
48
  export interface RemoteCommitInput {
@@ -203,11 +204,15 @@ export class SyncRemoteClient {
203
204
  }
204
205
  >();
205
206
  readonly #encryption: EncryptionConfig | undefined;
207
+ readonly #logEpoch: string | undefined;
206
208
 
207
209
  constructor(config: SyncRemoteClientConfig) {
208
210
  if (config.clientId.length === 0) {
209
211
  throw invalid('SyncRemoteClient clientId must be non-empty');
210
212
  }
213
+ if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
214
+ throw invalid('SyncRemoteClient logEpoch must be non-empty');
215
+ }
211
216
  this.#schema =
212
217
  config.schema === undefined
213
218
  ? undefined
@@ -217,6 +222,7 @@ export class SyncRemoteClient {
217
222
  this.#operations = config.operations;
218
223
  this.#operationRealtime = config.operationRealtime;
219
224
  this.#encryption = config.encryption;
225
+ this.#logEpoch = config.logEpoch;
220
226
  }
221
227
 
222
228
  async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
@@ -277,13 +283,16 @@ export class SyncRemoteClient {
277
283
  return {
278
284
  requestId: input.requestId,
279
285
  bytes: encodeMessage({
280
- wireVersion: PROTOCOL_WIRE_VERSION,
286
+ wireVersion: this.#logEpoch === undefined ? 1 : 2,
281
287
  msgKind: 'request',
282
288
  frames: [
283
289
  {
284
290
  type: 'REQ_HEADER',
285
291
  clientId: this.#clientId,
286
292
  schemaVersion: schema.version,
293
+ ...(this.#logEpoch !== undefined
294
+ ? { logEpoch: this.#logEpoch }
295
+ : {}),
287
296
  },
288
297
  {
289
298
  type: 'PUSH_COMMIT',
@@ -0,0 +1,6 @@
1
+ import { openBunDatabase } from './bun-database';
2
+ import type { ClientDatabase } from './database';
3
+
4
+ export function openSqliteDatabase(path = ':memory:'): ClientDatabase {
5
+ return openBunDatabase(path);
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { ClientDatabase } from './database';
2
+ import { openNodeDatabase } from './node-database';
3
+
4
+ export function openSqliteDatabase(path = ':memory:'): ClientDatabase {
5
+ return openNodeDatabase(path);
6
+ }