@syncular/client 0.1.3 → 0.2.1

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.
Files changed (233) hide show
  1. package/README.md +227 -709
  2. package/dist/apply.d.ts +80 -0
  3. package/dist/apply.js +301 -0
  4. package/dist/blob.d.ts +127 -0
  5. package/dist/blob.js +201 -0
  6. package/dist/bun-database.d.ts +22 -0
  7. package/dist/bun-database.js +61 -0
  8. package/dist/client.d.ts +320 -69
  9. package/dist/client.js +1627 -163
  10. package/dist/content-type.d.ts +2 -0
  11. package/dist/content-type.js +2 -0
  12. package/dist/database.d.ts +35 -80
  13. package/dist/database.js +32 -947
  14. package/dist/encryption.d.ts +47 -0
  15. package/dist/encryption.js +75 -0
  16. package/dist/errors.d.ts +8 -22
  17. package/dist/errors.js +10 -207
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.js +196 -0
  20. package/dist/index.d.ts +28 -16
  21. package/dist/index.js +25 -12
  22. package/dist/invalidation.d.ts +69 -0
  23. package/dist/invalidation.js +84 -0
  24. package/dist/leader-lock.d.ts +28 -0
  25. package/dist/leader-lock.js +38 -0
  26. package/dist/multi-tab.d.ts +134 -0
  27. package/dist/multi-tab.js +399 -0
  28. package/dist/node-database.d.ts +41 -0
  29. package/dist/node-database.js +133 -0
  30. package/dist/outbox.d.ts +56 -0
  31. package/dist/outbox.js +140 -0
  32. package/dist/schema.d.ts +107 -0
  33. package/dist/schema.js +267 -0
  34. package/dist/state.d.ts +40 -0
  35. package/dist/state.js +77 -0
  36. package/dist/transport.d.ts +44 -0
  37. package/dist/transport.js +7 -0
  38. package/dist/wasm-database.d.ts +30 -0
  39. package/dist/wasm-database.js +170 -0
  40. package/dist/window.d.ts +64 -0
  41. package/dist/window.js +0 -0
  42. package/dist/worker-entry.d.ts +16 -2
  43. package/dist/worker-entry.js +300 -456
  44. package/dist/worker-host.d.ts +174 -0
  45. package/dist/worker-host.js +536 -0
  46. package/dist/worker-protocol.d.ts +173 -61
  47. package/dist/worker-protocol.js +7 -16
  48. package/package.json +45 -112
  49. package/src/apply.ts +435 -0
  50. package/src/blob.ts +333 -0
  51. package/src/bun-database.ts +75 -0
  52. package/src/client.ts +2247 -277
  53. package/src/content-type.ts +2 -0
  54. package/src/database.ts +63 -1389
  55. package/src/encryption.ts +123 -0
  56. package/src/errors.ts +11 -265
  57. package/src/http.ts +254 -0
  58. package/src/index.ts +25 -20
  59. package/src/invalidation.ts +128 -0
  60. package/src/leader-lock.ts +68 -0
  61. package/src/multi-tab.ts +550 -0
  62. package/src/node-database.ts +180 -0
  63. package/src/outbox.ts +206 -0
  64. package/src/schema.ts +397 -0
  65. package/src/state.ts +126 -0
  66. package/src/transport.ts +52 -0
  67. package/src/wasm-database.ts +279 -0
  68. package/src/window.ts +0 -0
  69. package/src/worker-entry.ts +391 -545
  70. package/src/worker-host.ts +800 -0
  71. package/src/worker-protocol.ts +204 -99
  72. package/dist/auth-leases.d.ts +0 -11
  73. package/dist/auth-leases.d.ts.map +0 -1
  74. package/dist/auth-leases.js +0 -140
  75. package/dist/auth-leases.js.map +0 -1
  76. package/dist/blob-limits.d.ts +0 -15
  77. package/dist/blob-limits.d.ts.map +0 -1
  78. package/dist/blob-limits.js +0 -66
  79. package/dist/blob-limits.js.map +0 -1
  80. package/dist/bridge-client.d.ts +0 -69
  81. package/dist/bridge-client.d.ts.map +0 -1
  82. package/dist/bridge-client.js +0 -282
  83. package/dist/bridge-client.js.map +0 -1
  84. package/dist/client-config.d.ts +0 -6
  85. package/dist/client-config.d.ts.map +0 -1
  86. package/dist/client-config.js +0 -18
  87. package/dist/client-config.js.map +0 -1
  88. package/dist/client.d.ts.map +0 -1
  89. package/dist/client.js.map +0 -1
  90. package/dist/command-history.d.ts +0 -35
  91. package/dist/command-history.d.ts.map +0 -1
  92. package/dist/command-history.js +0 -378
  93. package/dist/command-history.js.map +0 -1
  94. package/dist/console-diagnostics.d.ts +0 -33
  95. package/dist/console-diagnostics.d.ts.map +0 -1
  96. package/dist/console-diagnostics.js +0 -496
  97. package/dist/console-diagnostics.js.map +0 -1
  98. package/dist/crdt-yjs/index.d.ts +0 -4
  99. package/dist/crdt-yjs/index.d.ts.map +0 -1
  100. package/dist/crdt-yjs/index.js +0 -4
  101. package/dist/crdt-yjs/index.js.map +0 -1
  102. package/dist/crdt-yjs/webview-host-facade.d.ts +0 -126
  103. package/dist/crdt-yjs/webview-host-facade.d.ts.map +0 -1
  104. package/dist/crdt-yjs/webview-host-facade.js +0 -284
  105. package/dist/crdt-yjs/webview-host-facade.js.map +0 -1
  106. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +0 -153
  107. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +0 -1
  108. package/dist/crdt-yjs/yjs-document-field-adapter.js +0 -406
  109. package/dist/crdt-yjs/yjs-document-field-adapter.js.map +0 -1
  110. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +0 -73
  111. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +0 -1
  112. package/dist/crdt-yjs/yjs-prosemirror-bridge.js +0 -169
  113. package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +0 -1
  114. package/dist/database.d.ts.map +0 -1
  115. package/dist/database.js.map +0 -1
  116. package/dist/diagnostics.d.ts +0 -10
  117. package/dist/diagnostics.d.ts.map +0 -1
  118. package/dist/diagnostics.js +0 -83
  119. package/dist/diagnostics.js.map +0 -1
  120. package/dist/errors.d.ts.map +0 -1
  121. package/dist/errors.js.map +0 -1
  122. package/dist/generated-bridge.d.ts +0 -365
  123. package/dist/generated-bridge.d.ts.map +0 -1
  124. package/dist/generated-bridge.js +0 -250
  125. package/dist/generated-bridge.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/mutations.d.ts +0 -72
  129. package/dist/mutations.d.ts.map +0 -1
  130. package/dist/mutations.js +0 -63
  131. package/dist/mutations.js.map +0 -1
  132. package/dist/network.d.ts +0 -3
  133. package/dist/network.d.ts.map +0 -1
  134. package/dist/network.js +0 -17
  135. package/dist/network.js.map +0 -1
  136. package/dist/react/index.d.ts +0 -169
  137. package/dist/react/index.d.ts.map +0 -1
  138. package/dist/react/index.js +0 -628
  139. package/dist/react/index.js.map +0 -1
  140. package/dist/react-native/index.d.ts +0 -35
  141. package/dist/react-native/index.d.ts.map +0 -1
  142. package/dist/react-native/index.js +0 -49
  143. package/dist/react-native/index.js.map +0 -1
  144. package/dist/runtime-contract.d.ts +0 -13
  145. package/dist/runtime-contract.d.ts.map +0 -1
  146. package/dist/runtime-contract.js +0 -24
  147. package/dist/runtime-contract.js.map +0 -1
  148. package/dist/rust-client.d.ts +0 -106
  149. package/dist/rust-client.d.ts.map +0 -1
  150. package/dist/rust-client.js +0 -938
  151. package/dist/rust-client.js.map +0 -1
  152. package/dist/sentry.d.ts +0 -35
  153. package/dist/sentry.d.ts.map +0 -1
  154. package/dist/sentry.js +0 -155
  155. package/dist/sentry.js.map +0 -1
  156. package/dist/sql-safety.d.ts +0 -3
  157. package/dist/sql-safety.d.ts.map +0 -1
  158. package/dist/sql-safety.js +0 -62
  159. package/dist/sql-safety.js.map +0 -1
  160. package/dist/syncular-runtime-artifacts.json +0 -61
  161. package/dist/tauri/index.d.ts +0 -35
  162. package/dist/tauri/index.d.ts.map +0 -1
  163. package/dist/tauri/index.js +0 -114
  164. package/dist/tauri/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -1096
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -2
  168. package/dist/types.js.map +0 -1
  169. package/dist/wasm/.syncular-wasm-profile +0 -1
  170. package/dist/wasm/syncular-runtime-artifact.json +0 -21
  171. package/dist/wasm/syncular.d.ts +0 -207
  172. package/dist/wasm/syncular.js +0 -2341
  173. package/dist/wasm/syncular_bg.wasm +0 -0
  174. package/dist/wasm/syncular_bg.wasm.d.ts +0 -97
  175. package/dist/wasm-bindings/runtime-contract.d.ts +0 -22
  176. package/dist/wasm-bindings/runtime-contract.d.ts.map +0 -1
  177. package/dist/wasm-bindings/runtime-contract.js +0 -112
  178. package/dist/wasm-bindings/runtime-contract.js.map +0 -1
  179. package/dist/wasm-core/.syncular-wasm-profile +0 -1
  180. package/dist/wasm-core/syncular-runtime-artifact.json +0 -17
  181. package/dist/wasm-core/syncular.d.ts +0 -162
  182. package/dist/wasm-core/syncular.js +0 -1847
  183. package/dist/wasm-core/syncular_bg.wasm +0 -0
  184. package/dist/wasm-core/syncular_bg.wasm.d.ts +0 -77
  185. package/dist/wasm-perf/.syncular-wasm-profile +0 -1
  186. package/dist/wasm-perf/syncular-runtime-artifact.json +0 -21
  187. package/dist/wasm-perf/syncular.d.ts +0 -207
  188. package/dist/wasm-perf/syncular.js +0 -2341
  189. package/dist/wasm-perf/syncular_bg.wasm +0 -0
  190. package/dist/wasm-perf/syncular_bg.wasm.d.ts +0 -97
  191. package/dist/wasm-runtime.d.ts +0 -23
  192. package/dist/wasm-runtime.d.ts.map +0 -1
  193. package/dist/wasm-runtime.js +0 -69
  194. package/dist/wasm-runtime.js.map +0 -1
  195. package/dist/worker-client.d.ts +0 -123
  196. package/dist/worker-client.d.ts.map +0 -1
  197. package/dist/worker-client.js +0 -1735
  198. package/dist/worker-client.js.map +0 -1
  199. package/dist/worker-entry.d.ts.map +0 -1
  200. package/dist/worker-entry.js.map +0 -1
  201. package/dist/worker-protocol.d.ts.map +0 -1
  202. package/dist/worker-protocol.js.map +0 -1
  203. package/dist/worker-realtime.d.ts +0 -39
  204. package/dist/worker-realtime.d.ts.map +0 -1
  205. package/dist/worker-realtime.js +0 -677
  206. package/dist/worker-realtime.js.map +0 -1
  207. package/src/auth-leases.ts +0 -251
  208. package/src/blob-limits.ts +0 -98
  209. package/src/bridge-client.ts +0 -512
  210. package/src/client-config.ts +0 -29
  211. package/src/command-history.ts +0 -623
  212. package/src/console-diagnostics.ts +0 -617
  213. package/src/crdt-yjs/index.ts +0 -3
  214. package/src/crdt-yjs/webview-host-facade.ts +0 -477
  215. package/src/crdt-yjs/yjs-document-field-adapter.ts +0 -733
  216. package/src/crdt-yjs/yjs-prosemirror-bridge.ts +0 -272
  217. package/src/diagnostics.ts +0 -116
  218. package/src/generated-bridge.ts +0 -741
  219. package/src/mutations.ts +0 -168
  220. package/src/network.ts +0 -32
  221. package/src/react/index.ts +0 -1036
  222. package/src/react-native/index.ts +0 -152
  223. package/src/runtime-contract.ts +0 -48
  224. package/src/rust-client.ts +0 -1491
  225. package/src/sentry.ts +0 -215
  226. package/src/sql-safety.ts +0 -61
  227. package/src/tauri/index.ts +0 -211
  228. package/src/types.ts +0 -1397
  229. package/src/wasm-bindings/generated-wasm-bindings.d.ts +0 -70
  230. package/src/wasm-bindings/runtime-contract.ts +0 -158
  231. package/src/wasm-runtime.ts +0 -145
  232. package/src/worker-client.ts +0 -2289
  233. package/src/worker-realtime.ts +0 -843
package/dist/client.js CHANGED
@@ -1,219 +1,1683 @@
1
- import { isSyncularOfflineError } from './errors.js';
2
- import { browserSyncularNetworkStatusSource } from './network.js';
3
- export function getSyncularClientStatus(client) {
4
- const lifecycle = client.lifecycleState();
5
- const connection = client.connectionState();
6
- const outbox = lifecycle.outbox ?? null;
7
- const conflicts = lifecycle.conflicts ?? null;
1
+ /**
2
+ * SyncClient the B3 client protocol core (SPEC.md §§3–8 client side).
3
+ *
4
+ * A plain library running on whatever thread it is constructed on
5
+ * (worker-OPTIONAL, REVISE B3): storage behind `ClientDatabase`, network
6
+ * behind `SyncTransport`/`SegmentDownloader`/`RealtimeConnector`, multi-tab
7
+ * ownership behind `LeaderLock`. One combined push+pull request per
8
+ * `sync()` round (§7.2); local reads go straight to the database.
9
+ */
10
+ import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
11
+ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
12
+ import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
13
+ import { ClientSyncError } from './errors.js';
14
+ import { Invalidation, InvalidationEmitter, } from './invalidation.js';
15
+ import { singleOwnerLock, } from './leader-lock.js';
16
+ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
17
+ import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, } from './schema.js';
18
+ import { deleteSubscription, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
19
+ import { deletePendingEviction, deleteWindowUnit, deriveSubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
20
+ /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
21
+ export function windowComplete(state, unit) {
22
+ return state.units.includes(unit);
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Internals
26
+ // ---------------------------------------------------------------------------
27
+ /** §4.2 accept bits the client cares about. */
28
+ const ACCEPT_ROWS_BASELINE = 0b0011;
29
+ const ACCEPT_SQLITE = 1 << 2;
30
+ const ACCEPT_SIGNED_URLS = 1 << 3;
31
+ /**
32
+ * §7.4.4 client-local code: a pending outbox commit cannot re-encode under
33
+ * the new generated schema after a bump (a referenced column is gone).
34
+ * Never a wire code (§10.3) — surfaced through the rejection channel.
35
+ */
36
+ const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
37
+ /** §6.1 per-request operation cap (matches the server's shipped default —
38
+ * `sync.too_many_operations` above it). The push half sends whole commits in
39
+ * commit order up to this cap and defers the rest to the next round. */
40
+ const MAX_OPS_PER_REQUEST = 500;
41
+ async function sha256Hex(bytes) {
42
+ const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer);
43
+ return [...new Uint8Array(digest)]
44
+ .map((b) => b.toString(16).padStart(2, '0'))
45
+ .join('');
46
+ }
47
+ function emptySummary(pushed) {
8
48
  return {
9
- lifecycle,
10
- connection,
11
- outbox,
12
- conflicts,
13
- isConnected: connection.realtime === 'connected' && !connection.closed,
14
- isSyncing: lifecycle.phase === 'syncing' || lifecycle.phase === 'recovering',
15
- hasPendingMutations: (outbox?.pending ?? 0) + (outbox?.sending ?? 0) > 0,
16
- hasConflicts: (conflicts?.unresolved ?? 0) > 0,
17
- requiresAction: lifecycle.requiresAction,
49
+ pushed,
50
+ applied: [],
51
+ rejected: [],
52
+ retryable: [],
53
+ conflicts: [],
54
+ commitsApplied: 0,
55
+ segmentRowsApplied: 0,
56
+ resets: [],
57
+ revoked: [],
58
+ failed: [],
18
59
  };
19
60
  }
20
- export class SyncularClientLifecycle {
21
- client;
22
- options;
61
+ export class SyncClient {
62
+ #config;
63
+ #db;
64
+ #schema;
65
+ /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
66
+ #encryption;
67
+ #now;
23
68
  #started = false;
24
- #pollTimer;
25
- #unsubscribeDiagnostics;
26
- #unsubscribeNetwork;
27
- #syncInFlight;
28
- #queuedSyncWaiters = [];
29
- #hasConnectedRealtime = false;
30
- #realtimeStarted = false;
31
- #network;
32
- constructor(client, options = {}) {
33
- this.client = client;
34
- this.options = options;
35
- this.#network =
36
- options.network === false
37
- ? undefined
38
- : (options.network ?? browserSyncularNetworkStatusSource());
69
+ #lease;
70
+ #clientId = '';
71
+ #schemaFloor;
72
+ #leaseState;
73
+ /** §7.4.5: true while a schema-bump reset + first bootstrap is in flight. */
74
+ #upgrading = false;
75
+ #conflicts = [];
76
+ #rejections = [];
77
+ #socket;
78
+ #pendingRound;
79
+ #needsPull = false;
80
+ #syncing = false;
81
+ /**
82
+ * True from the synchronous entry of `sync()` until its serialized round
83
+ * settles the "one loop owns the database" guard that rejects a
84
+ * concurrent `sync()` before the op chain would queue it. Distinct from
85
+ * `#syncing`, which is true only while `#runSync`'s body actually runs
86
+ * (the fast-bail the delta path reads).
87
+ */
88
+ #syncOutstanding = false;
89
+ #hasBlobs;
90
+ /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
91
+ #presence = new Map();
92
+ /** TODO 3.1 / I1: the ONE apply-path invalidation listener set. */
93
+ #invalidation = new InvalidationEmitter();
94
+ /** §8.6: subscribable presence-change listeners (twin of onPresence). */
95
+ #presenceListeners = new Set();
96
+ /** The batch accumulator; non-undefined only inside `#applyBatch`. */
97
+ #batch;
98
+ /**
99
+ * Operation-serialization mutex (the core owns one loop). Every
100
+ * transaction-entering ASYNC operation — `sync`, the delta-apply body, and
101
+ * `setWindow` — runs to completion under this chain so no two interleave at
102
+ * an `await` point. This is the single guard that keeps the apply seam
103
+ * atomic: two `#processResponse` runs must never share the `#batch`
104
+ * accumulator or interleave their SQLite transactions (a delta arriving
105
+ * mid-`await this.#downloadSegment` of a pull, or a `setWindow` widen racing
106
+ * a delta). Synchronous ops (`mutate`, schema reset) never join the chain —
107
+ * single-threaded JS cannot interleave them, and because an async op holds
108
+ * no OPEN db transaction (and no installed `#batch`) across its awaits only
109
+ * while it is NOT actively running, a sync op can only land between chained
110
+ * sections, when the seam is quiescent.
111
+ */
112
+ #opChain = Promise.resolve();
113
+ constructor(config) {
114
+ this.#config = config;
115
+ this.#db = config.database;
116
+ this.#schema = compileClientSchema(config.schema);
117
+ this.#encryption = config.encryption;
118
+ this.#now = config.now ?? Date.now;
119
+ this.#hasBlobs = schemaHasBlobs(this.#schema);
39
120
  }
121
+ // -- lifecycle ------------------------------------------------------------
122
+ /** Acquire leadership, create local tables, resolve the clientId. */
40
123
  async start() {
41
124
  if (this.#started)
42
125
  return;
126
+ const lock = this.#config.leaderLock ?? singleOwnerLock();
127
+ this.#lease = await lock.acquire(this.#config.lockName ?? 'syncular-leader');
128
+ ensureLocalSchema(this.#db, this.#schema);
129
+ if (this.#hasBlobs)
130
+ ensureBlobSchema(this.#db);
131
+ const persisted = getMeta(this.#db, 'clientId');
132
+ this.#clientId = this.#config.clientId ?? persisted ?? crypto.randomUUID();
133
+ if (persisted !== this.#clientId) {
134
+ setMeta(this.#db, 'clientId', this.#clientId);
135
+ }
136
+ // §7.3.5: restore the persisted lease so leaseState survives restart.
137
+ const leaseJson = getMeta(this.#db, 'leaseState');
138
+ if (leaseJson !== undefined) {
139
+ this.#leaseState = JSON.parse(leaseJson);
140
+ }
141
+ // §7.4.2 trigger 1: the persisted local schema version differs from the
142
+ // generated version this client ships — run the wipe/re-bootstrap reset
143
+ // before the first sync round. A fresh install (no marker) is treated as
144
+ // already at the generated version.
145
+ this.#detectAndResetSchema();
43
146
  this.#started = true;
44
- this.#hasConnectedRealtime =
45
- this.client.connectionState().realtime === 'connected';
46
- this.#realtimeStarted = this.#hasConnectedRealtime;
47
- this.#unsubscribeDiagnostics = this.client.addDiagnosticListener((event) => this.#handleDiagnostic(event));
48
- this.#unsubscribeNetwork = this.#subscribeNetworkEvents();
147
+ }
148
+ /**
149
+ * §7.4.1/§7.4.2: compare the generated schema version to the persisted
150
+ * marker and run the §7.4.3 reset when they differ. Idempotent by the
151
+ * marker — a mid-reset crash re-runs the reset on the next boot.
152
+ */
153
+ #detectAndResetSchema() {
154
+ const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
155
+ if (markerJson === undefined) {
156
+ // Fresh install: the tables just created match the running code.
157
+ setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
158
+ return;
159
+ }
160
+ const marker = Number(markerJson);
161
+ if (marker === this.#schema.version)
162
+ return;
163
+ this.#runSchemaReset();
164
+ }
165
+ /**
166
+ * §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
167
+ * and leaseState. Drops/recreates every synced table from the new schema,
168
+ * resets subscription sync-state (keeping registrations), clears any
169
+ * schema-floor stop state, rewrites the marker, and raises `upgrading`.
170
+ * The bump is idempotent by the marker (rewritten last).
171
+ */
172
+ #runSchemaReset() {
173
+ this.#setUpgrading(true);
174
+ this.#applyBatch((batch) => {
175
+ this.#db.transaction(() => {
176
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
177
+ resetSubscriptionsForBump(this.#db);
178
+ setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
179
+ });
180
+ // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
181
+ for (const table of this.#schema.tables.values())
182
+ batch.table(table.name);
183
+ });
184
+ // The stop state is over: this client now ships a servable schema. The
185
+ // outbox is re-applied optimistically over the (now empty) tables so
186
+ // pending offline writes stay visible across the bump (§7.4.5).
187
+ this.#schemaFloor = undefined;
188
+ this.#replayOutbox();
189
+ }
190
+ #setUpgrading(upgrading) {
191
+ if (this.#upgrading === upgrading)
192
+ return;
193
+ this.#upgrading = upgrading;
194
+ this.#config.onUpgrading?.(upgrading);
195
+ }
196
+ async close() {
197
+ this.#socket?.close();
198
+ this.#socket = undefined;
199
+ this.#abortPendingRound('client closed mid-round');
200
+ await this.#lease?.release();
201
+ this.#lease = undefined;
202
+ this.#started = false;
203
+ }
204
+ // -- accessors ------------------------------------------------------------
205
+ get clientId() {
206
+ return this.#clientId;
207
+ }
208
+ /** The underlying database — raw SQL is the local query API (B3). */
209
+ get database() {
210
+ return this.#db;
211
+ }
212
+ query(sql, params) {
213
+ return this.#db.query(sql, params);
214
+ }
215
+ // -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
216
+ /**
217
+ * Subscribe to fine-grained invalidation. The callback fires ONCE per
218
+ * apply batch (never per row, I1) with the `{tables, scopeKeys}` touched
219
+ * this batch (§3.1 vocabulary, I2). Returns an unsubscribe function.
220
+ *
221
+ * Every local mutation flows through the same choke point: `COMMIT`
222
+ * apply, segment apply, the optimistic overlay rebuild, the §3.3 purge,
223
+ * the §7.4.3 schema-bump reset, and local `mutate`. `tables` is the
224
+ * reliable floor; `scopeKeys` refines it wherever the source carried
225
+ * per-row scopes (COMMIT changes) or a scope map (segments/purge).
226
+ */
227
+ onInvalidate(listener) {
228
+ return this.#invalidation.on(listener);
229
+ }
230
+ /**
231
+ * Run `fn` as one apply batch: install a fresh accumulator, collect every
232
+ * touched key, then emit exactly one coalesced event if anything changed.
233
+ * Re-entrant calls share the outer batch so a nested apply never
234
+ * double-emits (e.g. purge → blob reconcile → replay inside one round).
235
+ */
236
+ #applyBatch(fn) {
237
+ if (this.#batch !== undefined)
238
+ return fn(this.#batch);
239
+ const batch = new Invalidation();
240
+ this.#batch = batch;
241
+ try {
242
+ return fn(batch);
243
+ }
244
+ finally {
245
+ this.#batch = undefined;
246
+ const event = batch.finish();
247
+ if (event !== undefined)
248
+ this.#invalidation.emit(event);
249
+ }
250
+ }
251
+ /**
252
+ * Run `fn` as the next link in the operation-serialization chain
253
+ * ({@link #opChain}): it starts only after every previously-serialized
254
+ * operation has fully settled, so transaction-entering async operations
255
+ * never interleave at an await point. Both chain branches settle to
256
+ * `undefined` so one operation's rejection never poisons the next
257
+ * (mirrors the worker host's `serializedSync`). NOT re-entrant: a
258
+ * serialized operation must not call another serialized operation, or it
259
+ * would deadlock waiting on itself.
260
+ */
261
+ #serialize(fn) {
262
+ const next = this.#opChain.then(fn, fn);
263
+ this.#opChain = next.then(() => undefined, () => undefined);
264
+ return next;
265
+ }
266
+ /** Async twin of {@link #applyBatch} for the pull/delta apply round. */
267
+ async #applyBatchAsync(fn) {
268
+ if (this.#batch !== undefined)
269
+ return fn(this.#batch);
270
+ const batch = new Invalidation();
271
+ this.#batch = batch;
49
272
  try {
50
- if (this.options.subscriptions) {
51
- await this.client.setSubscriptions(this.options.subscriptions);
273
+ return await fn(batch);
274
+ }
275
+ finally {
276
+ this.#batch = undefined;
277
+ const event = batch.finish();
278
+ if (event !== undefined)
279
+ this.#invalidation.emit(event);
280
+ }
281
+ }
282
+ // -- blobs (§5.9) ---------------------------------------------------------
283
+ /**
284
+ * Stage a blob for attachment (§5.9.7): hash the bytes into the content
285
+ * address, cache them locally, and queue the upload (flushed before the
286
+ * next push — B4). Returns the canonical `BlobRef` **string** to store in
287
+ * a `blob_ref` column of a mutation. The referencing row MUST be written
288
+ * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
289
+ */
290
+ async uploadBlob(bytes, options) {
291
+ if (this.#config.blobs === undefined) {
292
+ throw new ClientSyncError('sync.invalid_request', 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
293
+ }
294
+ const blobId = await computeBlobId(bytes);
295
+ this.#db.transaction(() => {
296
+ putCachedBlob(this.#db, blobId, bytes, this.#now(), options?.mediaType);
297
+ recordPendingUpload(this.#db, blobId, this.#now(), options?.mediaType);
298
+ });
299
+ // §5.9.7 B1: a staged upload is pinned (recordPendingUpload), so the cap
300
+ // trim below will never evict it — but a stage may push other zero-ref
301
+ // bodies over the cap, so run the trim.
302
+ this.#enforceBlobCacheCap();
303
+ return {
304
+ blobId,
305
+ byteLength: bytes.length,
306
+ ...(options?.mediaType !== undefined
307
+ ? { mediaType: options.mediaType }
308
+ : {}),
309
+ ...(options?.name !== undefined ? { name: options.name } : {}),
310
+ };
311
+ }
312
+ /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
313
+ blobRefString(ref) {
314
+ return serializeBlobRef(ref);
315
+ }
316
+ /**
317
+ * Resolve blob bytes for a `blobId` (§5.9.7): a content-addressed cache
318
+ * hit serves without a network fetch (B1); a miss downloads via the blob
319
+ * transport (§5.9.5), verifies the content address, caches, and returns.
320
+ * Accepts a raw `blob_ref` column string or a bare `blobId`.
321
+ */
322
+ async fetchBlob(blobIdOrRef) {
323
+ const blobId = blobIdOrRef.startsWith('sha256:')
324
+ ? blobIdOrRef
325
+ : parseBlobRef(blobIdOrRef).blobId;
326
+ const cached = getCachedBlob(this.#db, blobId, this.#now());
327
+ if (cached !== undefined)
328
+ return cached;
329
+ const transport = this.#config.blobs;
330
+ if (transport === undefined) {
331
+ throw new ClientSyncError('sync.invalid_request', 'fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
332
+ }
333
+ // §5.9.5: the authorized endpoint serves bytes inline OR (always-issue,
334
+ // presign configured) a signed url the client fetches directly. On a url
335
+ // arm the client MUST NOT attach host auth and MUST NOT fall through:
336
+ // failure => re-request, the caller's next fetchBlob mints a fresh url.
337
+ const response = await transport.download(blobId);
338
+ let bytes;
339
+ if (response.kind === 'url') {
340
+ if (transport.fetchUrl === undefined) {
341
+ throw new ClientSyncError('sync.invalid_request', 'blob download returned a url but the transport cannot fetch urls (§5.9.5)');
52
342
  }
53
- if (this.options.initialSync !== false && this.#isOnline()) {
54
- await this.#syncForLifecycle();
343
+ if (response.urlExpiresAtMs !== undefined &&
344
+ response.urlExpiresAtMs <= this.#now()) {
345
+ // §5.9.5: MUST NOT start a fetch at/past expiry — re-request recovers.
346
+ throw new ClientSyncError('sync.segment_expired', `blob url for ${blobId} expired before fetch — re-request mints a fresh url (§5.9.5)`, true);
55
347
  }
56
- if (this.options.realtime !== false && this.#isOnline()) {
57
- await this.#startRealtimeForLifecycle();
348
+ bytes = await transport.fetchUrl(response.url);
349
+ }
350
+ else {
351
+ bytes = response.bytes;
352
+ }
353
+ const computed = await computeBlobId(bytes);
354
+ if (computed !== blobId) {
355
+ // §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
356
+ // On the url path this invalidates the fetch (no fall-through) — the
357
+ // next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
358
+ throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
359
+ }
360
+ putCachedBlob(this.#db, blobId, bytes, this.#now());
361
+ this.#enforceBlobCacheCap();
362
+ const stored = getCachedBlob(this.#db, blobId);
363
+ if (stored === undefined) {
364
+ throw new ClientSyncError('sync.invalid_request', 'blob cache write failed');
365
+ }
366
+ return stored;
367
+ }
368
+ /** §5.9.7 B1: trim the blob cache to the configured cap (no-op if unset). */
369
+ #enforceBlobCacheCap() {
370
+ const cap = this.#config.blobCacheMaxBytes;
371
+ if (cap === undefined)
372
+ return;
373
+ enforceBlobCacheCap(this.#db, cap);
374
+ }
375
+ /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
376
+ async flushBlobUploads() {
377
+ const transport = this.#config.blobs;
378
+ if (transport === undefined || !this.#hasBlobs)
379
+ return;
380
+ for (const pending of listPendingUploads(this.#db)) {
381
+ const cached = getCachedBlob(this.#db, pending.blobId);
382
+ if (cached === undefined) {
383
+ // The bytes are gone (never happens for a well-behaved client);
384
+ // drop the upload so it does not wedge the queue.
385
+ clearPendingUpload(this.#db, pending.blobId);
386
+ continue;
58
387
  }
59
- this.#startPolling();
388
+ await this.#uploadOne(transport, pending.blobId, cached.bytes, pending.mediaType);
389
+ clearPendingUpload(this.#db, pending.blobId);
60
390
  }
61
- catch (error) {
62
- await this.stop().catch(() => undefined);
63
- throw error;
391
+ }
392
+ /**
393
+ * §5.9.3: upload one blob, preferring the presigned direct-to-storage grant
394
+ * when the transport supports it, else streaming through the direct endpoint
395
+ * (capability, not fallback). A `url` grant PUTs direct with no host auth; on
396
+ * a grant PUT failure the client streams through the direct endpoint — a
397
+ * *different, host-authenticated capability*, not a fall-through of the
398
+ * grant's authority (the direct endpoint was always the other path, B4).
399
+ */
400
+ async #uploadOne(transport, blobId, bytes, mediaType) {
401
+ if (transport.uploadGrant !== undefined &&
402
+ transport.uploadToUrl !== undefined) {
403
+ const grant = await transport.uploadGrant(blobId, bytes.length, mediaType);
404
+ if (grant.kind === 'present')
405
+ return; // idempotent §5.9.3 — no PUT needed
406
+ if (grant.kind === 'url') {
407
+ if (grant.urlExpiresAtMs === undefined ||
408
+ grant.urlExpiresAtMs > this.#now()) {
409
+ try {
410
+ await transport.uploadToUrl(grant.url, bytes, mediaType);
411
+ return;
412
+ }
413
+ catch {
414
+ // Grant PUT failed — stream through the direct endpoint below.
415
+ }
416
+ }
417
+ }
418
+ // grant.kind === 'none' (no presign store) or a failed/expired grant:
419
+ // stream through the direct host-authenticated endpoint.
64
420
  }
421
+ await transport.upload(blobId, bytes, mediaType);
422
+ }
423
+ get conflicts() {
424
+ return this.#conflicts;
425
+ }
426
+ get rejections() {
427
+ return this.#rejections;
428
+ }
429
+ /** Non-undefined once the server declared a schema floor (§1.6). */
430
+ get schemaFloor() {
431
+ return this.#schemaFloor;
432
+ }
433
+ /**
434
+ * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
435
+ * flight — the app's "upgrading…" cue. Clears when the first post-reset
436
+ * bootstrap round reaches idle (every subscription past its fresh
437
+ * bootstrap).
438
+ */
439
+ get upgrading() {
440
+ return this.#upgrading;
441
+ }
442
+ /**
443
+ * §7.3.5: the current auth-lease state (opaque). Undefined until a
444
+ * `LEASE` frame arrives. `errorCode` is set when a round was rejected
445
+ * with a request-level lease code — syncing on the lease has stopped.
446
+ */
447
+ get leaseState() {
448
+ return this.#leaseState;
449
+ }
450
+ /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
451
+ * `undefined` if no lease is held. Negative once expired. */
452
+ leaseRemainingMs(now = this.#now()) {
453
+ const expiresAtMs = this.#leaseState?.expiresAtMs;
454
+ return expiresAtMs === undefined ? undefined : expiresAtMs - now;
455
+ }
456
+ /** True when syncing is stopped pending a client upgrade. */
457
+ get stopped() {
458
+ return this.#schemaFloor !== undefined;
459
+ }
460
+ /** §8: a hello/wake-up asked for a pull that has not run yet. */
461
+ get syncNeeded() {
462
+ return this.#needsPull;
463
+ }
464
+ /**
465
+ * §8.6 presence on a scope key: the current peers present there (a map
466
+ * of `actorId clientId` → peer). Empty for a key with no present peers.
467
+ * Ephemeral — reflects only what the socket has delivered.
468
+ */
469
+ presence(scopeKey) {
470
+ const peers = this.#presence.get(scopeKey);
471
+ return peers === undefined ? [] : [...peers.values()];
472
+ }
473
+ /** Every scope key this client currently has presence state for. */
474
+ presenceKeys() {
475
+ return [...this.#presence.keys()];
65
476
  }
66
- async stop() {
67
- if (!this.#started)
477
+ /**
478
+ * §8.6: subscribe to presence changes (join/update/leave on any held key).
479
+ * The subscribable twin of the `onPresence` config callback — React's
480
+ * `usePresence` targets this so many components can watch one client.
481
+ * Returns an unsubscribe function.
482
+ */
483
+ onPresence(listener) {
484
+ this.#presenceListeners.add(listener);
485
+ return () => {
486
+ this.#presenceListeners.delete(listener);
487
+ };
488
+ }
489
+ /**
490
+ * §8.6.2 publish (or clear, `doc: null`) this client's presence document
491
+ * for `scopeKey`. Requires a live socket; the document is ephemeral and
492
+ * lost on disconnect (the server emits leave). Authorization is the
493
+ * connection's registration (§8.6.3) — an unheld key is rejected loudly
494
+ * by the server with `presence.forbidden`.
495
+ */
496
+ setPresence(scopeKey, doc) {
497
+ this.#requireStarted();
498
+ const socket = this.#socket;
499
+ if (socket === undefined) {
500
+ throw new ClientSyncError('sync.invalid_request', 'setPresence requires a connected realtime socket (§8.6)');
501
+ }
502
+ socket.send(encodePresencePublish(scopeKey, doc));
503
+ }
504
+ /**
505
+ * §4.2 accept mask: the configured override, or the rows baseline plus
506
+ * bit 2 when the backend can import sqlite images (§5.3) and a segment
507
+ * downloader exists (sqlite segments are never inline, §5.7), plus
508
+ * bit 3 when the downloader exposes a direct URL fetch (§5.4
509
+ * capability negotiation).
510
+ */
511
+ #acceptMask() {
512
+ const configured = this.#config.limits?.accept;
513
+ if (configured !== undefined)
514
+ return configured;
515
+ const segments = this.#config.segments;
516
+ const sqliteCapable = typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
517
+ const urlCapable = typeof segments?.fetchUrl === 'function';
518
+ return (ACCEPT_ROWS_BASELINE |
519
+ (sqliteCapable ? ACCEPT_SQLITE : 0) |
520
+ (urlCapable ? ACCEPT_SIGNED_URLS : 0));
521
+ }
522
+ subscriptions() {
523
+ this.#requireStarted();
524
+ return loadSubscriptions(this.#db);
525
+ }
526
+ subscription(id) {
527
+ this.#requireStarted();
528
+ return getSubscription(this.#db, id);
529
+ }
530
+ pendingCommits() {
531
+ this.#requireStarted();
532
+ return listOutbox(this.#db);
533
+ }
534
+ // -- subscriptions ----------------------------------------------------------
535
+ subscribe(input) {
536
+ this.#requireStarted();
537
+ if (!this.#schema.tables.has(input.table)) {
538
+ throw new ClientSyncError('sync.unknown_table', `subscribe: unknown local table ${JSON.stringify(input.table)}`);
539
+ }
540
+ const existing = getSubscription(this.#db, input.id);
541
+ if (existing !== undefined) {
542
+ saveSubscription(this.#db, {
543
+ ...existing,
544
+ table: input.table,
545
+ scopes: input.scopes,
546
+ ...(input.params !== undefined ? { params: input.params } : {}),
547
+ });
68
548
  return;
69
- this.#started = false;
70
- this.#stopPolling();
71
- this.#unsubscribeDiagnostics?.();
72
- this.#unsubscribeDiagnostics = undefined;
73
- this.#unsubscribeNetwork?.();
74
- this.#unsubscribeNetwork = undefined;
75
- this.#rejectQueuedSyncWaiters(new Error('Syncular lifecycle stopped before queued sync could run'));
76
- if (this.options.realtime !== false) {
77
- await this.client.stopRealtime();
78
- }
79
- this.#realtimeStarted = false;
80
- }
81
- async sync() {
82
- if (this.#syncInFlight) {
83
- return new Promise((resolve, reject) => {
84
- this.#queuedSyncWaiters.push({ resolve, reject });
549
+ }
550
+ saveSubscription(this.#db, {
551
+ id: input.id,
552
+ table: input.table,
553
+ scopes: input.scopes,
554
+ ...(input.params !== undefined ? { params: input.params } : {}),
555
+ cursor: -1,
556
+ status: 'active',
557
+ });
558
+ }
559
+ unsubscribe(id) {
560
+ this.#requireStarted();
561
+ deleteSubscription(this.#db, id);
562
+ }
563
+ // -- windowed subscriptions (§4.8) ------------------------------------------
564
+ /**
565
+ * Set the live window units for a base (§4.8): a value-sharded family of
566
+ * subscriptions, one per unit. Computes the diff against the registry —
567
+ * added units get fresh subscriptions (image-lane bootstrap on the next
568
+ * sync); removed units are unsubscribed and evicted, fused in one local
569
+ * transaction (E1–E4). Idempotent: calling with the same units is a
570
+ * no-op. Re-entry (a unit removed then re-added) cancels any deferred
571
+ * eviction and fresh-bootstraps.
572
+ *
573
+ * The change takes effect on the next `sync()`/socket round — the pull's
574
+ * subscription list (now with the added unit, without the removed one)
575
+ * re-registers realtime at round end (§8.7). No socket cycle needed.
576
+ */
577
+ async setWindow(base, units) {
578
+ this.#requireStarted();
579
+ const table = this.#table(base.table);
580
+ if (!table.scopeColumnByVariable.has(base.variable)) {
581
+ throw new ClientSyncError('sync.invalid_request', `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
582
+ }
583
+ // Serialize the whole window edit: it spans an `await deriveSubId` between
584
+ // db transactions, so without the chain a delta apply (or a concurrent
585
+ // setWindow) could interleave its transactions and corrupt the registry.
586
+ await this.#serialize(async () => {
587
+ const baseKey = windowBaseKey(base);
588
+ const wanted = new Set(units);
589
+ const live = loadWindowUnits(this.#db, baseKey);
590
+ const liveByUnit = new Map(live.map((u) => [u.unit, u.subId]));
591
+ // Widen: units wanted but not live → fresh subscription + registry row.
592
+ for (const unit of wanted) {
593
+ if (liveByUnit.has(unit))
594
+ continue;
595
+ const subId = await deriveSubId(base, unit);
596
+ this.#db.transaction(() => {
597
+ // Re-entry cancels any deferred eviction for this sub id.
598
+ deletePendingEviction(this.#db, subId);
599
+ insertWindowUnit(this.#db, baseKey, unit, subId);
600
+ saveSubscription(this.#db, {
601
+ id: subId,
602
+ table: base.table,
603
+ scopes: unitScopes(base, unit),
604
+ ...(base.params !== undefined ? { params: base.params } : {}),
605
+ cursor: -1,
606
+ status: 'active',
607
+ });
608
+ });
609
+ }
610
+ // Shrink: units live but not wanted → unsubscribe fused with eviction.
611
+ for (const { unit, subId } of live) {
612
+ if (wanted.has(unit))
613
+ continue;
614
+ this.#evictUnit(baseKey, base, unit, subId);
615
+ }
616
+ });
617
+ }
618
+ /**
619
+ * The completeness oracle (§4.8 I3): which units of a base are windowed-in
620
+ * locally, and a per-unit verdict a live query renders "may be partial"
621
+ * from. A query whose scope footprint includes an un-windowed unit is
622
+ * NOT answerable in full — the host widens or shows partial, never
623
+ * silently-complete.
624
+ */
625
+ windowState(base) {
626
+ this.#requireStarted();
627
+ const baseKey = windowBaseKey(base);
628
+ return { units: loadWindowUnits(this.#db, baseKey).map((u) => u.unit) };
629
+ }
630
+ /**
631
+ * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
632
+ * one transaction. Deletes the unit's rows EXCEPT those pinned by a
633
+ * pending outbox commit (E1); if any pin remains, records a deferred
634
+ * eviction retried on the next outbox drain. Discards the subscription's
635
+ * cursor/resume/effective-echo (E3) and its version state with the rows
636
+ * (E2). Emits the evicted table's invalidation keys (I1). Fail-closed:
637
+ * with no local scope-column mapping, surfaces a configuration error and
638
+ * evicts nothing (§4.8/§3.3).
639
+ */
640
+ #evictUnit(baseKey, base, unit, subId) {
641
+ const table = this.#table(base.table);
642
+ const sub = getSubscription(this.#db, subId);
643
+ // The rows a unit holds live under its LAST effective scopes if it ever
644
+ // synced; before first sync, the requested unit scopes are the match.
645
+ const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
646
+ const pinned = this.#pinnedRowIds(base.table);
647
+ this.#applyBatch((batch) => {
648
+ this.#db.transaction(() => {
649
+ const deferred = evictScopedRows(this.#db, table, effective, pinned);
650
+ deleteWindowUnit(this.#db, baseKey, unit);
651
+ deleteSubscription(this.#db, subId);
652
+ if (deferred) {
653
+ savePendingEviction(this.#db, subId, base.table, effective);
654
+ }
655
+ else {
656
+ deletePendingEviction(this.#db, subId);
657
+ }
658
+ });
659
+ // I1: eviction is a bulk delete — a query over the evicted unit re-runs.
660
+ batch.table(table.name);
661
+ batch.scopeMap(table, effective);
662
+ });
663
+ }
664
+ /**
665
+ * §4.8 E1: retry deferred evictions after the outbox drains. A pinned
666
+ * unit's rows are removed once no pending commit references them; a unit
667
+ * that re-entered the window in the meantime has no pending record left.
668
+ */
669
+ #drainPendingEvictions() {
670
+ const pending = loadPendingEvictions(this.#db);
671
+ if (pending.length === 0)
672
+ return;
673
+ for (const entry of pending) {
674
+ const table = this.#schema.tables.get(entry.table);
675
+ if (table === undefined) {
676
+ deletePendingEviction(this.#db, entry.subId);
677
+ continue;
678
+ }
679
+ const pinned = this.#pinnedRowIds(entry.table);
680
+ this.#applyBatch((batch) => {
681
+ let deferred = false;
682
+ this.#db.transaction(() => {
683
+ deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
684
+ if (!deferred)
685
+ deletePendingEviction(this.#db, entry.subId);
686
+ });
687
+ batch.table(table.name);
688
+ batch.scopeMap(table, entry.effective);
85
689
  });
86
690
  }
87
- this.#syncInFlight = this.#startSyncCycle();
88
- return this.#syncInFlight;
89
691
  }
90
- #startSyncCycle() {
91
- let sync;
692
+ /**
693
+ * §4.8 E1: primary keys of `table` referenced by any still-pending outbox
694
+ * commit — rows that MUST NOT be evicted until the commit drains.
695
+ */
696
+ #pinnedRowIds(table) {
697
+ const pinned = new Set();
698
+ for (const commit of listOutbox(this.#db)) {
699
+ for (const op of commit.operations) {
700
+ if (op.table === table)
701
+ pinned.add(op.rowId);
702
+ }
703
+ }
704
+ return pinned;
705
+ }
706
+ // -- local mutations --------------------------------------------------------
707
+ /**
708
+ * Record one atomic local commit (§7.1): appended to the outbox in
709
+ * schema-agnostic form and applied optimistically to the local mirror.
710
+ * Returns the generated `clientCommitId`.
711
+ */
712
+ mutate(mutations) {
713
+ this.#requireStarted();
714
+ const clientCommitId = crypto.randomUUID();
715
+ const operations = mutations.map((mutation) => {
716
+ const table = this.#table(mutation.table);
717
+ if (mutation.op === 'delete') {
718
+ return {
719
+ table: mutation.table,
720
+ rowId: mutation.rowId,
721
+ op: 'delete',
722
+ ...(mutation.baseVersion !== undefined
723
+ ? { baseVersion: mutation.baseVersion }
724
+ : {}),
725
+ };
726
+ }
727
+ const values = recordToRowValues(table, mutation.values);
728
+ const pkValue = values[table.primaryKeyIndex];
729
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
730
+ throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
731
+ }
732
+ const json = {};
733
+ table.columns.forEach((column, index) => {
734
+ json[column.name] = rowValueToJson(values[index] ?? null);
735
+ });
736
+ return {
737
+ table: mutation.table,
738
+ rowId: pkValue,
739
+ op: 'upsert',
740
+ ...(mutation.baseVersion !== undefined
741
+ ? { baseVersion: mutation.baseVersion }
742
+ : {}),
743
+ values: json,
744
+ };
745
+ });
746
+ this.#applyBatch((batch) => {
747
+ this.#db.transaction(() => {
748
+ appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
749
+ this.#applyOperationsLocally(operations, batch);
750
+ });
751
+ });
752
+ return clientCommitId;
753
+ }
754
+ // -- lease state (§7.3.5) ---------------------------------------------------
755
+ /** Merge and persist the lease state (opaque, §7.3.5). */
756
+ #setLeaseState(next) {
757
+ this.#leaseState = next;
758
+ setMeta(this.#db, 'leaseState', JSON.stringify(next));
759
+ }
760
+ /** The request-level lease error codes (§7.3.4): stop-and-surface. */
761
+ #isLeaseErrorCode(code) {
762
+ return (code === 'sync.auth_lease_required' || code === 'sync.auth_lease_revoked');
763
+ }
764
+ /**
765
+ * §7.4.4: encode every pending outbox commit with the current codec. A
766
+ * commit that cannot re-encode under the new schema (an
767
+ * `OutboxEncodeError` — a referenced column/table the bump removed) is
768
+ * dropped from the outbox and surfaced as a rejection (`sync.outbox_
769
+ * incompatible`); its purely-optimistic rows are undone (§7.2). Returns
770
+ * the encoded push frames index-aligned with the surviving `outbox`.
771
+ */
772
+ async #encodeOutboxForPush() {
773
+ const pending = listOutbox(this.#db);
774
+ const pushFrames = [];
775
+ const outbox = [];
776
+ let deferred = 0;
777
+ let ops = 0;
778
+ for (const commit of pending) {
779
+ // §6.1 splitBatch: whole commits in commit order, stopping before the
780
+ // per-request operation cap. A first commit that alone exceeds the cap
781
+ // is sent alone — the server rejects it loudly rather than the queue
782
+ // wedging silently. Deferred commits stay queued for the next round.
783
+ if (outbox.length > 0 &&
784
+ ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
785
+ deferred += 1;
786
+ continue;
787
+ }
788
+ try {
789
+ pushFrames.push(
790
+ // §5.11: encrypted columns are encrypted at this encode-at-send
791
+ // seam before the row codec serializes them.
792
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption));
793
+ outbox.push(commit);
794
+ ops += commit.operations.length;
795
+ }
796
+ catch (error) {
797
+ if (error instanceof OutboxEncodeError) {
798
+ this.#dropIncompatibleCommit(commit, error.message);
799
+ continue;
800
+ }
801
+ throw error;
802
+ }
803
+ }
804
+ return { pushFrames, outbox, deferred };
805
+ }
806
+ /**
807
+ * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
808
+ * §7.2 `rejected` surface — the commit leaves the outbox, its
809
+ * purely-optimistic rows are undone, and a rejection record is raised.
810
+ */
811
+ #dropIncompatibleCommit(commit, message) {
812
+ this.#db.transaction(() => {
813
+ deleteOutboxCommit(this.#db, commit.clientCommitId);
814
+ for (const operation of commit.operations) {
815
+ if (operation.op !== 'upsert')
816
+ continue;
817
+ const table = this.#schema.tables.get(operation.table);
818
+ if (table === undefined)
819
+ continue;
820
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
821
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
822
+ deleteLocalRow(this.#db, table, operation.rowId);
823
+ }
824
+ }
825
+ });
826
+ this.#rejections.push({
827
+ clientCommitId: commit.clientCommitId,
828
+ opIndex: 0,
829
+ code: OUTBOX_INCOMPATIBLE_CODE,
830
+ message,
831
+ retryable: false,
832
+ ...(commit.operations[0] !== undefined
833
+ ? { operation: commit.operations[0] }
834
+ : {}),
835
+ });
836
+ }
837
+ // -- sync -------------------------------------------------------------------
838
+ /**
839
+ * One combined push+pull round (§1.5, §7.2). The core owns one loop: a
840
+ * concurrent `sync()` while one is already outstanding is rejected loudly
841
+ * (the app must coalesce its own wake-ups, §8.4) — this check is
842
+ * SYNCHRONOUS so the second caller sees the first still in flight before
843
+ * the op chain would otherwise queue it. The round then runs serialized on
844
+ * the operation chain so it never interleaves with a delta apply or a
845
+ * `setWindow` at an await point.
846
+ */
847
+ sync() {
848
+ this.#requireStarted();
849
+ if (this.#syncOutstanding) {
850
+ return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
851
+ }
852
+ this.#syncOutstanding = true;
853
+ return this.#serialize(() => this.#runSync()).finally(() => {
854
+ this.#syncOutstanding = false;
855
+ });
856
+ }
857
+ async #runSync() {
858
+ if (this.#schemaFloor !== undefined) {
859
+ return {
860
+ ...emptySummary(0),
861
+ bootstrapping: [],
862
+ schemaFloor: this.#schemaFloor,
863
+ };
864
+ }
865
+ this.#syncing = true;
866
+ // Cleared before the round, not after: a wake-up (or a delta dropped
867
+ // because this pull is mid-flight) that lands during the round must
868
+ // survive it — the reference server keeps no replay buffer (§8.2).
869
+ this.#needsPull = false;
92
870
  try {
93
- sync = Promise.resolve(this.client.syncOnce());
871
+ // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
872
+ // them, so the server-side existence check (§6.6) passes.
873
+ if (this.#hasBlobs && this.#config.blobs !== undefined) {
874
+ await this.flushBlobUploads();
875
+ }
876
+ // §7.4.4: encode the outbox with the CURRENT codec; a commit that
877
+ // cannot express itself under the new schema (a dropped column/table)
878
+ // is removed from the push and surfaced as a rejection, never wedging
879
+ // the queue. `pushFrames` and `outbox` stay index-aligned for result
880
+ // mapping.
881
+ const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
882
+ const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
883
+ const limits = this.#config.limits;
884
+ const frames = [
885
+ {
886
+ type: 'REQ_HEADER',
887
+ clientId: this.#clientId,
888
+ schemaVersion: this.#schema.version,
889
+ },
890
+ ...pushFrames,
891
+ {
892
+ type: 'PULL_HEADER',
893
+ limitCommits: limits?.limitCommits ?? 0,
894
+ limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
895
+ maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
896
+ accept: this.#acceptMask(),
897
+ },
898
+ ...subs.map((sub) => ({
899
+ type: 'SUBSCRIPTION',
900
+ id: sub.id,
901
+ table: sub.table,
902
+ scopes: sub.scopes,
903
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
904
+ cursor: sub.cursor,
905
+ ...(sub.bootstrapState !== undefined
906
+ ? { bootstrapState: sub.bootstrapState }
907
+ : {}),
908
+ })),
909
+ ];
910
+ const requestBytes = encodeMessage({
911
+ wireVersion: PROTOCOL_WIRE_VERSION,
912
+ msgKind: 'request',
913
+ frames,
914
+ });
915
+ const responseBytes = await this.#roundTrip(requestBytes);
916
+ const message = decodeMessage(responseBytes);
917
+ if (message.msgKind !== 'response') {
918
+ throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
919
+ }
920
+ const summary = await this.#processResponse(message, outbox, subs, 'pull');
921
+ // §4.8 E1: the push half may have drained commits that pinned rows of
922
+ // a shrunk window unit — retry any deferred evictions now.
923
+ this.#drainPendingEvictions();
924
+ if (deferred > 0) {
925
+ // §6.1 splitBatch remainder: more queued commits than this request
926
+ // could carry — keep the sync-needed signal raised for the host.
927
+ this.#needsPull = true;
928
+ return { ...summary, deferredCommits: deferred };
929
+ }
930
+ return summary;
94
931
  }
95
932
  catch (error) {
96
- sync = Promise.reject(error);
933
+ // §7.3.5: a request-level lease code stops-and-surfaces — record it
934
+ // in leaseState (no local-data purge, §7.3.4) and re-throw. Not a
935
+ // silent retry: the app drives recovery to a live resolver. The
936
+ // error may arrive as a ClientSyncError or as a transport error
937
+ // carrying the server's `.code` (HTTP-JSON / loopback surface, §1.1).
938
+ const code = error.code;
939
+ if (typeof code === 'string' && this.#isLeaseErrorCode(code)) {
940
+ this.#setLeaseState({
941
+ ...(this.#leaseState ?? {}),
942
+ errorCode: code,
943
+ });
944
+ }
945
+ throw error;
97
946
  }
98
- const finish = () => {
99
- if (this.#syncInFlight === sync) {
100
- this.#syncInFlight = undefined;
947
+ finally {
948
+ this.#syncing = false;
949
+ }
950
+ }
951
+ /**
952
+ * Pull repeatedly until quiescent: no commits delivered, no bootstrap
953
+ * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
954
+ */
955
+ async syncUntilIdle(maxRounds = 20) {
956
+ let last;
957
+ for (let round = 0; round < maxRounds; round++) {
958
+ last = await this.sync();
959
+ if (last.schemaFloor !== undefined)
960
+ return last;
961
+ if (last.commitsApplied === 0 &&
962
+ last.segmentRowsApplied === 0 &&
963
+ last.bootstrapping.length === 0 &&
964
+ last.resets.length === 0 &&
965
+ (last.deferredCommits ?? 0) === 0) {
966
+ return last;
101
967
  }
102
- const waiters = this.#queuedSyncWaiters.splice(0);
103
- if (waiters.length === 0)
104
- return;
105
- if (!this.#started) {
106
- const error = new Error('Syncular lifecycle stopped before queued sync could run');
107
- for (const waiter of waiters)
108
- waiter.reject(error);
109
- return;
968
+ }
969
+ throw new ClientSyncError('sync.invalid_request', `sync did not reach idle within ${maxRounds} rounds`);
970
+ }
971
+ /**
972
+ * One request/response round trip (§8.7): over the socket whenever it
973
+ * is connected (Direction decision 1 — the socket IS the sync-round
974
+ * transport, not a fallback pair), otherwise through the configured
975
+ * `SyncTransport` seam (loopback/conformance hosts, HTTP-only
976
+ * producers).
977
+ */
978
+ #roundTrip(request) {
979
+ const socket = this.#socket;
980
+ if (socket === undefined)
981
+ return this.#config.transport(request);
982
+ return new Promise((resolve, reject) => {
983
+ // sync() already enforces one round in flight (§8.7).
984
+ this.#pendingRound = {
985
+ scanner: new MessageStreamScanner(),
986
+ resolve,
987
+ reject,
988
+ };
989
+ const tagged = new Uint8Array(request.length + 1);
990
+ tagged[0] = REALTIME_TAG_ROUND;
991
+ tagged.set(request, 1);
992
+ try {
993
+ socket.sendBytes(tagged);
994
+ }
995
+ catch (error) {
996
+ this.#pendingRound = undefined;
997
+ reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
110
998
  }
111
- const queued = this.#startSyncCycle();
112
- this.#syncInFlight = queued;
113
- queued.then((result) => {
114
- for (const waiter of waiters)
115
- waiter.resolve(result);
116
- }, (error) => {
117
- for (const waiter of waiters)
118
- waiter.reject(error);
119
- });
120
- };
121
- void sync.then(finish, finish).catch((error) => {
122
- this.#rejectQueuedSyncWaiters(error);
123
999
  });
124
- return sync;
125
- }
126
- #rejectQueuedSyncWaiters(error) {
127
- const waiters = this.#queuedSyncWaiters.splice(0);
128
- for (const waiter of waiters)
129
- waiter.reject(error);
130
1000
  }
131
- #handleDiagnostic(event) {
132
- if (event.source === 'sync' && event.details?.resyncRequired === true) {
133
- void this.client
134
- .forceSubscriptionsBootstrap()
135
- .then(() => this.sync())
136
- .catch(() => undefined);
1001
+ /** Abort the in-flight socket round (socket closed or disconnected). */
1002
+ #abortPendingRound(reason) {
1003
+ const round = this.#pendingRound;
1004
+ if (round === undefined)
137
1005
  return;
1006
+ this.#pendingRound = undefined;
1007
+ round.reject(new ClientSyncError('sync.transport_failed', reason, true));
1008
+ }
1009
+ // -- realtime (§8 client side) ----------------------------------------------
1010
+ async connectRealtime() {
1011
+ this.#requireStarted();
1012
+ const connector = this.#config.realtime;
1013
+ if (connector === undefined) {
1014
+ throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
138
1015
  }
139
- if (event.source !== 'realtime' ||
140
- event.code !== 'realtime.state' ||
141
- event.details?.state == null) {
1016
+ this.#socket = await connector({
1017
+ onText: (text) => this.#handleRealtimeText(text),
1018
+ onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1019
+ onClose: () => {
1020
+ this.#socket = undefined;
1021
+ this.#presence.clear(); // §8.6.1: presence is per-connection
1022
+ this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1023
+ },
1024
+ });
1025
+ }
1026
+ disconnectRealtime() {
1027
+ this.#socket?.close();
1028
+ this.#socket = undefined;
1029
+ this.#presence.clear(); // §8.6.1: presence is per-connection
1030
+ this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
1031
+ }
1032
+ /**
1033
+ * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
1034
+ * `0x01` chunks feed the in-flight round's assembler; `0x00` messages
1035
+ * are standalone deltas; unknown tags are ignored (forward compat).
1036
+ */
1037
+ #routeRealtimeBinary(bytes) {
1038
+ if (bytes.length === 0)
1039
+ return;
1040
+ const tag = bytes[0];
1041
+ const body = bytes.subarray(1);
1042
+ if (tag === REALTIME_TAG_ROUND) {
1043
+ const round = this.#pendingRound;
1044
+ if (round === undefined)
1045
+ return; // stale chunk after an abort
1046
+ let done;
1047
+ try {
1048
+ done = round.scanner.push(body);
1049
+ }
1050
+ catch (error) {
1051
+ this.#pendingRound = undefined;
1052
+ round.reject(new ClientSyncError('sync.invalid_request', `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`));
1053
+ return;
1054
+ }
1055
+ if (done === undefined)
1056
+ return;
1057
+ this.#pendingRound = undefined;
1058
+ if (done.excess > 0) {
1059
+ round.reject(new ClientSyncError('sync.invalid_request', 'response bytes past END of the round stream (§8.7)'));
1060
+ return;
1061
+ }
1062
+ round.resolve(done.message.slice());
142
1063
  return;
143
1064
  }
144
- const state = event.details.state;
145
- if (state !== 'connected')
1065
+ if (tag === REALTIME_TAG_DELTA) {
1066
+ void this.#handleRealtimeBinary(body);
1067
+ }
1068
+ // Unknown tag: tolerated and ignored (§8.7 closed registry).
1069
+ }
1070
+ #handleRealtimeText(text) {
1071
+ let parsed;
1072
+ try {
1073
+ parsed = parseRealtimeServerEvent(text);
1074
+ }
1075
+ catch {
1076
+ return; // §8.1: tolerate garbled/unknown control messages
1077
+ }
1078
+ if (!parsed.known)
146
1079
  return;
147
- const wasReconnect = this.#hasConnectedRealtime;
148
- this.#hasConnectedRealtime = true;
149
- const shouldSync = this.options.syncOnRealtimeConnect !== false &&
150
- (wasReconnect || this.options.initialSync === false);
151
- if (!shouldSync)
1080
+ const event = parsed.event;
1081
+ if (event.event === 'hello') {
1082
+ if (event.data.requiresSync) {
1083
+ this.#needsPull = true;
1084
+ this.#config.onSyncNeeded?.('hello');
1085
+ }
152
1086
  return;
153
- void this.sync().catch(() => undefined);
1087
+ }
1088
+ if (event.event === 'sync') {
1089
+ // §8.3: any wake-up means "run a pull soon", never data.
1090
+ this.#needsPull = true;
1091
+ this.#config.onSyncNeeded?.(event.data.reason);
1092
+ return;
1093
+ }
1094
+ if (event.event === 'presence') {
1095
+ this.#applyPresence(event.data);
1096
+ }
154
1097
  }
155
- #subscribeNetworkEvents() {
156
- const network = this.#network;
157
- if (!network?.addEventListener || !network.removeEventListener)
1098
+ /** §8.6 apply an inbound presence fanout to the local map. */
1099
+ #applyPresence(data) {
1100
+ // The publisher-directed error variant (§8.6.2) surfaces via the
1101
+ // sync-needed reason channel is inappropriate; it is an out-of-band
1102
+ // presence rejection — record nothing to the peer map, just ignore
1103
+ // here (setPresence callers observe it through the callback if wired).
1104
+ if (data.error !== undefined || data.kind === undefined)
158
1105
  return;
159
- const handleOnline = () => {
160
- if (!this.#started)
1106
+ const { scopeKey, kind, actorId, clientId } = data;
1107
+ if (actorId === undefined || clientId === undefined)
1108
+ return;
1109
+ const peerKey = `${actorId} ${clientId}`;
1110
+ let peers = this.#presence.get(scopeKey);
1111
+ if (kind === 'leave') {
1112
+ peers?.delete(peerKey);
1113
+ if (peers !== undefined && peers.size === 0) {
1114
+ this.#presence.delete(scopeKey);
1115
+ }
1116
+ }
1117
+ else {
1118
+ const doc = data.doc;
1119
+ if (doc === null || doc === undefined)
161
1120
  return;
162
- void this.#resumeOnline().catch(() => undefined);
163
- };
164
- network.addEventListener('online', handleOnline);
165
- return () => {
166
- network.removeEventListener?.('online', handleOnline);
167
- };
1121
+ if (peers === undefined) {
1122
+ peers = new Map();
1123
+ this.#presence.set(scopeKey, peers);
1124
+ }
1125
+ peers.set(peerKey, { actorId, clientId, doc });
1126
+ }
1127
+ this.#config.onPresence?.(scopeKey);
1128
+ for (const listener of this.#presenceListeners) {
1129
+ try {
1130
+ listener(scopeKey);
1131
+ }
1132
+ catch {
1133
+ // A UI listener must never break presence application.
1134
+ }
1135
+ }
1136
+ }
1137
+ async #handleRealtimeBinary(bytes) {
1138
+ if (this.#syncing) {
1139
+ // Fast path: a pull is mid-flight; let it win and recover the gap
1140
+ // itself — re-pulling is idempotent, interleaved application is not
1141
+ // worth it. (An optimization; the op chain below is the correctness
1142
+ // mechanism — it also excludes a delta from racing a `setWindow` or a
1143
+ // sync round that started between this check and the apply.)
1144
+ this.#needsPull = true;
1145
+ return;
1146
+ }
1147
+ // Serialize the apply on the operation chain: a delta must never
1148
+ // interleave its transactions or share the `#batch` accumulator with a
1149
+ // pull round or a `setWindow` at an await point (§8.2).
1150
+ await this.#serialize(async () => {
1151
+ try {
1152
+ const message = decodeMessage(bytes);
1153
+ if (message.msgKind !== 'response')
1154
+ return;
1155
+ // §8.2: deltas apply like pull responses; ack after apply.
1156
+ await this.#processResponse(message, [], undefined, 'delta');
1157
+ }
1158
+ catch {
1159
+ // A delta that cannot be applied is recovered by a pull (§8.3).
1160
+ this.#needsPull = true;
1161
+ this.#config.onSyncNeeded?.('catchup-required');
1162
+ }
1163
+ });
1164
+ }
1165
+ #sendAck(cursor) {
1166
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
1167
+ }
1168
+ /** Ack the highest cursor that is contiguously applied for every sub. */
1169
+ #ackAfterPull() {
1170
+ if (this.#socket === undefined)
1171
+ return;
1172
+ const cursors = loadSubscriptions(this.#db)
1173
+ .filter((sub) => sub.status === 'active' && sub.bootstrapState === undefined)
1174
+ .map((sub) => sub.cursor)
1175
+ .filter((cursor) => cursor >= 0);
1176
+ if (cursors.length === 0)
1177
+ return;
1178
+ this.#sendAck(Math.min(...cursors));
1179
+ }
1180
+ // -- response processing ------------------------------------------------------
1181
+ async #processResponse(message, sentCommits, sentSubs, mode) {
1182
+ const summary = emptySummary(sentCommits.length);
1183
+ const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
1184
+ const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
1185
+ const header = message.frames[0];
1186
+ if (header?.type !== 'RESP_HEADER') {
1187
+ throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
1188
+ }
1189
+ if (header.requiredSchemaVersion !== undefined) {
1190
+ // §1.6 schema floor: nothing else was processed — stop syncing and
1191
+ // surface the upgrade requirement. A live-round floor always stops:
1192
+ // the generated schema does not match what the server serves (behind,
1193
+ // or ahead of a lagging server), and no local reset changes the
1194
+ // version this client sends. The §7.4.2 trigger-2 convergence runs
1195
+ // when the APP updates (recreating the client with a new generated
1196
+ // schema), which fires the boot-time §7.4.1 marker check instead.
1197
+ this.#schemaFloor = {
1198
+ requiredSchemaVersion: header.requiredSchemaVersion,
1199
+ ...(header.latestSchemaVersion !== undefined
1200
+ ? { latestSchemaVersion: header.latestSchemaVersion }
1201
+ : {}),
1202
+ };
1203
+ return {
1204
+ ...summary,
1205
+ bootstrapping: [],
1206
+ schemaFloor: this.#schemaFloor,
1207
+ };
1208
+ }
1209
+ let section;
1210
+ let errorFrame;
1211
+ let deltaCursor = -1;
1212
+ // One apply batch per pull/delta round (I1): COMMIT + segment applies,
1213
+ // the revocation purge, and the optimistic replay all coalesce into a
1214
+ // single invalidation event emitted when this batch unwinds.
1215
+ await this.#applyBatchAsync(async () => {
1216
+ try {
1217
+ for (const frame of message.frames.slice(1)) {
1218
+ switch (frame.type) {
1219
+ case 'RESP_HEADER':
1220
+ break;
1221
+ case 'LEASE':
1222
+ // §7.3.5: persist the opaque lease and clear any prior lease
1223
+ // error — a fresh lease means the outage/revocation is over.
1224
+ this.#setLeaseState({
1225
+ leaseId: frame.leaseId,
1226
+ expiresAtMs: frame.expiresAtMs,
1227
+ });
1228
+ break;
1229
+ case 'PUSH_RESULT':
1230
+ this.#handlePushResult(frame, commitsById, summary);
1231
+ break;
1232
+ case 'SUB_START': {
1233
+ const sub = subsById.get(frame.id);
1234
+ const fresh = sub !== undefined &&
1235
+ sub.cursor < 0 &&
1236
+ sub.bootstrapState === undefined &&
1237
+ frame.bootstrap;
1238
+ const skip = sub === undefined ||
1239
+ (mode === 'delta' &&
1240
+ (sub.status !== 'active' ||
1241
+ sub.bootstrapState !== undefined));
1242
+ section = { start: frame, sub, fresh, skip, cleared: false };
1243
+ break;
1244
+ }
1245
+ case 'COMMIT':
1246
+ if (section !== undefined && !section.skip) {
1247
+ await this.#applyCommit(frame, summary);
1248
+ }
1249
+ break;
1250
+ case 'SEGMENT_INLINE': {
1251
+ if (section === undefined ||
1252
+ section.skip ||
1253
+ section.sub === undefined) {
1254
+ break;
1255
+ }
1256
+ const segment = decodeRowsSegment(frame.payload);
1257
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh && !section.cleared);
1258
+ break;
1259
+ }
1260
+ case 'SEGMENT_REF': {
1261
+ if (section === undefined ||
1262
+ section.skip ||
1263
+ section.sub === undefined) {
1264
+ break;
1265
+ }
1266
+ // §4.2: a descriptor whose mediaType was not advertised is a
1267
+ // broken server — fail loud, never skip or guess.
1268
+ if (frame.mediaType === 'sqlite' &&
1269
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
1270
+ throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
1271
+ }
1272
+ const bytes = await this.#downloadSegment(frame, section.sub);
1273
+ if (frame.mediaType === 'sqlite') {
1274
+ // §5.3: images are whole-table — a paged descriptor is
1275
+ // invalid, and the image is always its table's first page.
1276
+ if (frame.rowCursor !== undefined ||
1277
+ frame.nextRowCursor !== undefined) {
1278
+ throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
1279
+ }
1280
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
1281
+ table: frame.table,
1282
+ rowCount: frame.rowCount,
1283
+ asOfCommitSeq: frame.asOfCommitSeq,
1284
+ scopeDigest: frame.scopeDigest,
1285
+ }, { clearFirst, effective }), section.fresh && !section.cleared);
1286
+ }
1287
+ else {
1288
+ const segment = decodeRowsSegment(bytes);
1289
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh &&
1290
+ !section.cleared &&
1291
+ frame.rowCursor === undefined);
1292
+ }
1293
+ break;
1294
+ }
1295
+ case 'SUB_END': {
1296
+ if (section !== undefined &&
1297
+ !section.skip &&
1298
+ section.sub !== undefined) {
1299
+ const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
1300
+ if (mode === 'delta' && applied) {
1301
+ deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1302
+ }
1303
+ }
1304
+ section = undefined;
1305
+ break;
1306
+ }
1307
+ case 'ERROR':
1308
+ // §1.4 rule 5 / §1.6: the request failed; the open
1309
+ // subscription's SUB_END values are never persisted.
1310
+ errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable);
1311
+ section = undefined;
1312
+ break;
1313
+ case 'UNKNOWN':
1314
+ break; // §1.2 rule 2: skipped, never interpreted
1315
+ }
1316
+ if (errorFrame !== undefined)
1317
+ break;
1318
+ }
1319
+ }
1320
+ finally {
1321
+ // §7.1: local reads see outbox state applied optimistically — replay
1322
+ // the still-pending commits on top of the freshly applied server
1323
+ // state (the simple reconciliation mandated for B3).
1324
+ this.#replayOutbox();
1325
+ // §5.9.7 B1: after every apply/replay, refcounts follow the live rows.
1326
+ // A benign apply retains zero-ref bodies (LRU default); the revocation
1327
+ // purge below deletes orphaned bodies with deleteOrphans (B2).
1328
+ this.#reconcileBlobs(false);
1329
+ }
1330
+ });
1331
+ if (errorFrame !== undefined)
1332
+ throw errorFrame;
1333
+ if (mode === 'delta') {
1334
+ if (deltaCursor >= 0)
1335
+ this.#sendAck(deltaCursor);
1336
+ }
1337
+ else {
1338
+ this.#ackAfterPull();
1339
+ }
1340
+ const bootstrapping = loadSubscriptions(this.#db)
1341
+ .filter((sub) => sub.status === 'active' && sub.bootstrapState !== undefined)
1342
+ .map((sub) => sub.id);
1343
+ // §7.4.5: the reset is over once the first post-reset pull round leaves
1344
+ // no subscription mid-bootstrap — the tables are rebuilt and current.
1345
+ if (this.#upgrading && mode === 'pull' && bootstrapping.length === 0) {
1346
+ this.#setUpgrading(false);
1347
+ }
1348
+ return { ...summary, bootstrapping };
168
1349
  }
169
- async #resumeOnline() {
170
- if (!this.#started || !this.#isOnline())
1350
+ #handlePushResult(frame, commitsById, summary) {
1351
+ const commit = commitsById.get(frame.clientCommitId);
1352
+ if (commit === undefined)
1353
+ return;
1354
+ if (frame.status === 'applied' || frame.status === 'cached') {
1355
+ // §6.3: applied and cached both drain the outbox — cached means
1356
+ // "already applied, you may have missed the ack".
1357
+ deleteOutboxCommit(this.#db, frame.clientCommitId);
1358
+ summary.applied.push(frame.clientCommitId);
171
1359
  return;
172
- if (this.options.initialSync !== false) {
173
- await this.#syncForLifecycle();
174
1360
  }
175
- if (this.options.realtime !== false && !this.#realtimeStarted) {
176
- await this.#startRealtimeForLifecycle();
1361
+ // rejected
1362
+ const cacheMiss = frame.results.some((result) => result.status === 'error' &&
1363
+ result.code === 'sync.idempotency_cache_miss' &&
1364
+ result.retryable);
1365
+ if (cacheMiss) {
1366
+ // §6.3: a serving failure, not the commit's outcome — keep the
1367
+ // commit queued and retry the identical push later.
1368
+ summary.retryable.push(frame.clientCommitId);
1369
+ return;
1370
+ }
1371
+ for (const result of frame.results) {
1372
+ const operation = commit.operations[result.opIndex];
1373
+ if (result.status === 'conflict') {
1374
+ const conflict = {
1375
+ clientCommitId: frame.clientCommitId,
1376
+ opIndex: result.opIndex,
1377
+ table: operation?.table ?? '',
1378
+ rowId: operation?.rowId ?? '',
1379
+ code: result.code,
1380
+ message: result.message,
1381
+ serverVersion: result.serverVersion,
1382
+ serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
1383
+ ...(operation !== undefined ? { operation } : {}),
1384
+ };
1385
+ this.#conflicts.push(conflict);
1386
+ summary.conflicts.push(conflict);
1387
+ this.#config.onConflict?.(conflict);
1388
+ }
1389
+ else if (result.status === 'error') {
1390
+ this.#rejections.push({
1391
+ clientCommitId: frame.clientCommitId,
1392
+ opIndex: result.opIndex,
1393
+ code: result.code,
1394
+ message: result.message,
1395
+ retryable: result.retryable,
1396
+ ...(operation !== undefined ? { operation } : {}),
1397
+ });
1398
+ }
1399
+ }
1400
+ // §7.2: stop optimistic display and decide about dependents — the
1401
+ // commit leaves the outbox; rows it created that the server never
1402
+ // confirmed are undone here, rows it overwrote reconcile via the pull
1403
+ // half (the conflict record carries the server row for the app).
1404
+ this.#db.transaction(() => {
1405
+ deleteOutboxCommit(this.#db, frame.clientCommitId);
1406
+ for (const operation of commit.operations) {
1407
+ if (operation.op !== 'upsert')
1408
+ continue;
1409
+ const table = this.#schema.tables.get(operation.table);
1410
+ if (table === undefined)
1411
+ continue;
1412
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
1413
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1414
+ deleteLocalRow(this.#db, table, operation.rowId);
1415
+ }
1416
+ }
1417
+ });
1418
+ summary.rejected.push(frame.clientCommitId);
1419
+ }
1420
+ #decodeServerRow(tableName, payload) {
1421
+ if (tableName === undefined)
1422
+ return {};
1423
+ const table = this.#schema.tables.get(tableName);
1424
+ if (table === undefined)
1425
+ return {};
1426
+ const values = decodeRow(table.columns, payload);
1427
+ const record = {};
1428
+ table.columns.forEach((column, index) => {
1429
+ record[column.name] = values[index] ?? null;
1430
+ });
1431
+ return record;
1432
+ }
1433
+ async #applyCommit(frame, summary) {
1434
+ await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption);
1435
+ summary.commitsApplied += 1;
1436
+ // I1/I2: record touched tables and precise `prefix:value` scope keys —
1437
+ // COMMIT changes carry per-row stored scopes (§4.5), the finest honest
1438
+ // invalidation the wire provides.
1439
+ const batch = this.#batch;
1440
+ if (batch === undefined)
1441
+ return;
1442
+ for (const change of frame.changes) {
1443
+ const tableName = frame.tables[change.tableIndex];
1444
+ if (tableName === undefined)
1445
+ continue;
1446
+ const table = this.#schema.tables.get(tableName);
1447
+ if (table === undefined)
1448
+ continue;
1449
+ batch.table(tableName);
1450
+ batch.changeScopes(table, change.scopes);
177
1451
  }
178
1452
  }
179
- async #syncForLifecycle() {
1453
+ /**
1454
+ * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
1455
+ * (no local scope-column mapping) marks the subscription `failed` and
1456
+ * stops syncing the table without failing the whole request.
1457
+ */
1458
+ async #applySegmentOrFail(section, summary, apply, clearFirst) {
1459
+ const sub = section.sub;
1460
+ if (sub === undefined)
1461
+ return;
1462
+ const table = this.#table(sub.table);
180
1463
  try {
181
- await this.sync();
1464
+ summary.segmentRowsApplied += await apply(table, clearFirst, section.start.effectiveScopes);
1465
+ section.cleared = true;
1466
+ // I1/I2: segments carry only a table + scopeDigest, never per-row
1467
+ // scope keys — invalidate the table plus the subscription's effective
1468
+ // scope keys (the coarsest honest key for bulk data).
1469
+ this.#batch?.table(table.name);
1470
+ this.#batch?.scopeMap(table, section.start.effectiveScopes);
182
1471
  }
183
1472
  catch (error) {
184
- if (!isSyncularOfflineError(error))
185
- throw error;
1473
+ if (error instanceof ClientSyncError &&
1474
+ error.code === 'sync.scope_revoked') {
1475
+ saveSubscription(this.#db, {
1476
+ id: sub.id,
1477
+ table: sub.table,
1478
+ scopes: sub.scopes,
1479
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1480
+ cursor: sub.cursor,
1481
+ ...(sub.effectiveScopes !== undefined
1482
+ ? { effectiveScopes: sub.effectiveScopes }
1483
+ : {}),
1484
+ status: 'failed',
1485
+ reasonCode: 'sync.scope_revoked',
1486
+ });
1487
+ summary.failed.push(sub.id);
1488
+ section.skip = true;
1489
+ return;
1490
+ }
1491
+ throw error;
186
1492
  }
187
1493
  }
188
- async #startRealtimeForLifecycle() {
189
- try {
190
- await this.client.startRealtime(this.options.realtime);
191
- this.#realtimeStarted = true;
1494
+ async #downloadSegment(frame, sub) {
1495
+ const downloader = this.#config.segments;
1496
+ if (downloader === undefined) {
1497
+ throw new ClientSyncError('sync.invalid_request', 'received SEGMENT_REF but no segment downloader is configured');
192
1498
  }
193
- catch (error) {
194
- this.#realtimeStarted = false;
195
- if (!isSyncularOfflineError(error))
196
- throw error;
1499
+ let bytes;
1500
+ if (frame.url !== undefined) {
1501
+ // §5.4: a url-carrying descriptor MUST be fetched from that URL —
1502
+ // no fall-through to the direct endpoint; any failure invalidates
1503
+ // the descriptor and re-pulling recovers (§1.4 rule 5 keeps the
1504
+ // cursor/resume token unpersisted).
1505
+ const fetchUrl = downloader.fetchUrl;
1506
+ if (fetchUrl === undefined ||
1507
+ (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0) {
1508
+ throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)');
1509
+ }
1510
+ if (frame.urlExpiresAtMs !== undefined &&
1511
+ frame.urlExpiresAtMs <= this.#now()) {
1512
+ // §5.4: MUST NOT start a fetch at/past expiry.
1513
+ throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
1514
+ }
1515
+ bytes = await fetchUrl(frame.url);
1516
+ }
1517
+ else {
1518
+ bytes = await downloader({
1519
+ segmentId: frame.segmentId,
1520
+ table: frame.table,
1521
+ requestedScopesJson: canonicalScopeJson(sub.scopes),
1522
+ });
1523
+ }
1524
+ // §5.1: verify the content address before applying; on mismatch the
1525
+ // segment is discarded and the cursor/resume token stay unpersisted,
1526
+ // so the next pull re-delivers.
1527
+ const hash = await sha256Hex(bytes);
1528
+ if (`sha256:${hash}` !== frame.segmentId) {
1529
+ throw new ClientSyncError('sync.invalid_request', `segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
197
1530
  }
1531
+ return bytes;
198
1532
  }
199
- #isOnline() {
200
- return this.#network?.isOnline() !== false;
1533
+ /** Returns whether SUB_END state was persisted (section applied). */
1534
+ #finishSection(sub, start, nextCursor, bootstrapState, summary) {
1535
+ if (start.status === 'active') {
1536
+ // §1.4 rule 4: durable cursor/resume state persists only at SUB_END;
1537
+ // §3.3: the effective-scope echo is persisted for the purge contract.
1538
+ // An absent bootstrapState clears any previous resume token (§4.4:
1539
+ // absent = bootstrap complete, or not bootstrapping).
1540
+ saveSubscription(this.#db, {
1541
+ id: sub.id,
1542
+ table: sub.table,
1543
+ scopes: sub.scopes,
1544
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1545
+ cursor: nextCursor,
1546
+ ...(bootstrapState !== undefined ? { bootstrapState } : {}),
1547
+ effectiveScopes: start.effectiveScopes,
1548
+ status: 'active',
1549
+ });
1550
+ return true;
1551
+ }
1552
+ if (start.status === 'reset') {
1553
+ // §4.6: discard cursor + resume token, keep local rows, re-bootstrap
1554
+ // with cursor = -1 on the next pull. Staleness, not a purge.
1555
+ saveSubscription(this.#db, {
1556
+ id: sub.id,
1557
+ table: sub.table,
1558
+ scopes: sub.scopes,
1559
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1560
+ cursor: -1,
1561
+ ...(sub.effectiveScopes !== undefined
1562
+ ? { effectiveScopes: sub.effectiveScopes }
1563
+ : {}),
1564
+ status: 'active',
1565
+ reasonCode: start.reasonCode,
1566
+ });
1567
+ summary.resets.push(sub.id);
1568
+ return false;
1569
+ }
1570
+ // revoked (§3.3): purge rows matching the LAST-echoed effective scopes
1571
+ // (never the requested map), drop doomed outbox commits, stop pulling.
1572
+ const table = this.#table(sub.table);
1573
+ const lastEffective = sub.effectiveScopes;
1574
+ let failed = false;
1575
+ if (lastEffective !== undefined && Object.keys(lastEffective).length > 0) {
1576
+ try {
1577
+ this.#db.transaction(() => {
1578
+ deleteScopedRows(this.#db, table, lastEffective);
1579
+ });
1580
+ // I1: the §3.3 purge is a bulk delete — invalidate the table + the
1581
+ // purged effective scope keys so live queries over them re-run.
1582
+ this.#batch?.table(table.name);
1583
+ this.#batch?.scopeMap(table, lastEffective);
1584
+ dropOutboxCommitsInScope(this.#db, table, lastEffective);
1585
+ // §5.9.7 B2: revocation deletes now-unauthorized blob bodies —
1586
+ // reconcile with deleteOrphans (evicted ≠ revoked).
1587
+ this.#reconcileBlobs(true);
1588
+ }
1589
+ catch (error) {
1590
+ if (error instanceof ClientSyncError &&
1591
+ error.code === 'sync.scope_revoked') {
1592
+ // Fail closed: no local mapping — surface a fatal configuration
1593
+ // error and stop syncing the table without clearing anything.
1594
+ failed = true;
1595
+ }
1596
+ else {
1597
+ throw error;
1598
+ }
1599
+ }
1600
+ }
1601
+ saveSubscription(this.#db, {
1602
+ id: sub.id,
1603
+ table: sub.table,
1604
+ scopes: sub.scopes,
1605
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1606
+ cursor: nextCursor,
1607
+ ...(lastEffective !== undefined
1608
+ ? { effectiveScopes: lastEffective }
1609
+ : {}),
1610
+ status: failed ? 'failed' : 'revoked',
1611
+ reasonCode: start.reasonCode,
1612
+ });
1613
+ summary.revoked.push(sub.id);
1614
+ if (failed)
1615
+ summary.failed.push(sub.id);
1616
+ return false;
201
1617
  }
202
- #startPolling() {
203
- const interval = this.options.pollIntervalMs;
204
- if (interval === false || interval === undefined || interval <= 0)
1618
+ // -- optimistic state ----------------------------------------------------------
1619
+ #applyOperationsLocally(operations, batch) {
1620
+ for (const op of operations) {
1621
+ const table = this.#table(op.table);
1622
+ batch?.table(op.table);
1623
+ if (op.op === 'delete') {
1624
+ deleteLocalRow(this.#db, table, op.rowId);
1625
+ continue;
1626
+ }
1627
+ const values = table.columns.map((column) => {
1628
+ const value = op.values?.[column.name];
1629
+ return value === undefined ? null : jsonToRowValue(value);
1630
+ });
1631
+ // Record the row's scope keys from its scope columns (I2 refinement).
1632
+ if (batch !== undefined) {
1633
+ for (const [variable, column] of table.scopeColumnByVariable) {
1634
+ const idx = table.columnIndex.get(column);
1635
+ const cell = idx === undefined ? undefined : values[idx];
1636
+ const prefix = table.scopePrefixByVariable.get(variable);
1637
+ if (prefix !== undefined && cell != null) {
1638
+ batch.scopeKey(`${prefix}:${String(cell)}`);
1639
+ }
1640
+ }
1641
+ }
1642
+ const existing = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
1643
+ const version = existing === undefined ? OPTIMISTIC_VERSION : existing.v;
1644
+ upsertLocalRow(this.#db, table, values, version);
1645
+ }
1646
+ }
1647
+ /** Re-apply every pending outbox commit on top of server state (§7.1). */
1648
+ #replayOutbox() {
1649
+ const pending = listOutbox(this.#db);
1650
+ if (pending.length === 0)
205
1651
  return;
206
- this.#pollTimer = setInterval(() => {
207
- if (!this.#isOnline())
208
- return;
209
- void this.#syncForLifecycle();
210
- }, interval);
1652
+ this.#applyBatch((batch) => {
1653
+ this.#db.transaction(() => {
1654
+ for (const commit of pending) {
1655
+ this.#applyOperationsLocally(commit.operations, batch);
1656
+ }
1657
+ });
1658
+ });
211
1659
  }
212
- #stopPolling() {
213
- if (!this.#pollTimer)
1660
+ /**
1661
+ * §5.9.7 B1/B2: recompute blob-cache refcounts from live `blob_ref`
1662
+ * columns. No-op unless the schema has blob columns. `deleteOrphans`
1663
+ * triggers the revocation-side body deletion (B2).
1664
+ */
1665
+ #reconcileBlobs(deleteOrphans) {
1666
+ if (!this.#hasBlobs)
214
1667
  return;
215
- clearInterval(this.#pollTimer);
216
- this.#pollTimer = undefined;
1668
+ reconcileBlobRefcounts(this.#db, this.#schema, { deleteOrphans });
1669
+ }
1670
+ // -- helpers -----------------------------------------------------------------
1671
+ #table(name) {
1672
+ const table = this.#schema.tables.get(name);
1673
+ if (table === undefined) {
1674
+ throw new ClientSyncError('sync.unknown_table', `unknown local table ${JSON.stringify(name)}`);
1675
+ }
1676
+ return table;
1677
+ }
1678
+ #requireStarted() {
1679
+ if (!this.#started) {
1680
+ throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
1681
+ }
217
1682
  }
218
1683
  }
219
- //# sourceMappingURL=client.js.map