@syncular/client 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/src/client.ts CHANGED
@@ -1,352 +1,2322 @@
1
- import type { BlobRef, SyncAuthLeaseIssueRequest } from '@syncular/core';
2
- import type { SyncularDatabase } from './database';
3
- import { isSyncularOfflineError } from './errors';
4
- import type { MutationsApi } from './mutations';
5
- import { browserSyncularNetworkStatusSource } from './network';
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 {
11
+ type CommitFrame,
12
+ canonicalScopeJson,
13
+ decodeMessage,
14
+ decodeRow,
15
+ decodeRowsSegment,
16
+ encodeMessage,
17
+ encodePresencePublish,
18
+ MessageStreamScanner,
19
+ PROTOCOL_WIRE_VERSION,
20
+ type PresenceKind,
21
+ type PushResultFrame,
22
+ parseRealtimeServerEvent,
23
+ REALTIME_TAG_DELTA,
24
+ REALTIME_TAG_ROUND,
25
+ type RequestFrame,
26
+ type ResponseMessage,
27
+ type RowValue,
28
+ type ScopeMap,
29
+ type SegmentRefFrame,
30
+ type SubStartFrame,
31
+ type WakeReason,
32
+ } from '@syncular/core';
33
+ import {
34
+ applyCommitFrame,
35
+ applyRowsSegment,
36
+ applySqliteSegment,
37
+ deleteLocalRow,
38
+ deleteScopedRows,
39
+ evictScopedRows,
40
+ upsertLocalRow,
41
+ } from './apply';
42
+ import {
43
+ type BlobRef,
44
+ type BlobTransport,
45
+ type CachedBlob,
46
+ clearPendingUpload,
47
+ computeBlobId,
48
+ enforceBlobCacheCap,
49
+ ensureBlobSchema,
50
+ getCachedBlob,
51
+ listPendingUploads,
52
+ parseBlobRef,
53
+ putCachedBlob,
54
+ reconcileBlobRefcounts,
55
+ recordPendingUpload,
56
+ schemaHasBlobs,
57
+ serializeBlobRef,
58
+ } from './blob';
59
+ import type { ClientDatabase, SqlRow, SqlValue } from './database';
60
+ import type { EncryptionConfig } from './encryption';
61
+ import { ClientSyncError } from './errors';
62
+ import {
63
+ Invalidation,
64
+ InvalidationEmitter,
65
+ type InvalidationListener,
66
+ } from './invalidation';
67
+ import {
68
+ type LeaderLease,
69
+ type LeaderLock,
70
+ singleOwnerLock,
71
+ } from './leader-lock';
72
+ import {
73
+ appendOutboxCommit,
74
+ deleteOutboxCommit,
75
+ dropOutboxCommitsInScope,
76
+ encodeOutboxCommit,
77
+ listOutbox,
78
+ type OutboxCommit,
79
+ OutboxEncodeError,
80
+ type OutboxOperation,
81
+ } from './outbox';
82
+ import {
83
+ type ClientSchema,
84
+ type CompiledClientSchema,
85
+ type CompiledClientTable,
86
+ compileClientSchema,
87
+ dropAndRecreateSyncedTables,
88
+ ensureLocalSchema,
89
+ jsonToRowValue,
90
+ LOCAL_SCHEMA_VERSION_KEY,
91
+ OPTIMISTIC_VERSION,
92
+ quoteIdent,
93
+ recordToRowValues,
94
+ rowValueToJson,
95
+ SYNC_VERSION_COLUMN,
96
+ } from './schema';
97
+ import {
98
+ deleteSubscription,
99
+ getMeta,
100
+ getSubscription,
101
+ loadSubscriptions,
102
+ resetSubscriptionsForBump,
103
+ type SubscriptionRecord,
104
+ saveSubscription,
105
+ setMeta,
106
+ } from './state';
6
107
  import type {
7
- SyncularAuthLeaseRecord,
8
- SyncularBlobUploadQueueProcessOptions,
9
- SyncularBlobUploadQueueStats,
10
- SyncularClientEventSink,
11
- SyncularClientEventType,
12
- SyncularConflictResolution,
13
- SyncularConflictStats,
14
- SyncularConflictSummary,
15
- SyncularConnectionState,
16
- SyncularDiagnosticEvent,
17
- SyncularDiagnosticSnapshot,
18
- SyncularLifecycleState,
19
- SyncularNetworkStatusSource,
20
- SyncularOutboxStats,
21
- SyncularPresenceEntry,
22
- SyncularPresenceSink,
23
- SyncularRealtimeConnectionState,
24
- SyncularRealtimeOptions,
25
- SyncularRuntimeClient,
26
- SyncularSubscriptionSpec,
27
- SyncularSyncRequestOptions,
28
- SyncularSyncResult,
29
- } from './types';
30
-
31
- export interface SyncularClientLifecycleOptions {
32
- initialSync?: boolean;
33
- realtime?: boolean | SyncularRealtimeOptions;
34
- syncOnRealtimeConnect?: boolean;
35
- pollIntervalMs?: number | false;
36
- network?: SyncularNetworkStatusSource | false;
37
- subscriptions?: readonly SyncularSubscriptionSpec[];
108
+ RealtimeConnector,
109
+ RealtimeSocket,
110
+ SegmentDownloader,
111
+ SyncTransport,
112
+ } from './transport';
113
+ import {
114
+ deletePendingEviction,
115
+ deleteWindowUnit,
116
+ deriveSubId,
117
+ insertWindowUnit,
118
+ loadPendingEvictions,
119
+ loadWindowUnits,
120
+ savePendingEviction,
121
+ unitScopes,
122
+ type WindowBase,
123
+ windowBaseKey,
124
+ } from './window';
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Public shapes
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export type MutationInput =
131
+ | {
132
+ readonly table: string;
133
+ readonly op: 'upsert';
134
+ /** Full-row values keyed by column name (§6.1: full row payloads). */
135
+ readonly values: Readonly<Record<string, unknown>>;
136
+ readonly baseVersion?: number;
137
+ }
138
+ | {
139
+ readonly table: string;
140
+ readonly op: 'delete';
141
+ readonly rowId: string;
142
+ readonly baseVersion?: number;
143
+ };
144
+
145
+ /** A §6.3 conflict result, surfaced to the app — never auto-resolved. */
146
+ export interface ConflictRecord {
147
+ readonly clientCommitId: string;
148
+ readonly opIndex: number;
149
+ readonly table: string;
150
+ readonly rowId: string;
151
+ readonly code: string;
152
+ readonly message: string;
153
+ readonly serverVersion: number;
154
+ /** The current server row, decoded — resolve without a round-trip. */
155
+ readonly serverRow: Readonly<Record<string, RowValue>>;
156
+ /** The losing local operation (absent only for malformed op indexes). */
157
+ readonly operation?: OutboxOperation;
158
+ }
159
+
160
+ /** A non-conflict `error` result from a rejected commit (§6.3). */
161
+ export interface RejectionRecord {
162
+ readonly clientCommitId: string;
163
+ readonly opIndex: number;
164
+ readonly code: string;
165
+ readonly message: string;
166
+ readonly retryable: boolean;
167
+ readonly operation?: OutboxOperation;
168
+ }
169
+
170
+ export interface SchemaFloor {
171
+ readonly requiredSchemaVersion?: number;
172
+ readonly latestSchemaVersion?: number;
173
+ }
174
+
175
+ /**
176
+ * §7.3.5: the client's view of its current auth lease — opaque state, the
177
+ * mirror of `schemaFloor`. `leaseId`/`expiresAtMs` come from the last
178
+ * `LEASE` frame; `errorCode` is set (and syncing stops on the lease) when
179
+ * the server rejects a round with a request-level lease code (§7.3.4).
180
+ * The lease is never cryptographically verified by the client (§7.3
181
+ * non-goal 1), and lease errors never purge local data (§7.3.4).
182
+ */
183
+ export interface LeaseState {
184
+ readonly leaseId?: string;
185
+ readonly expiresAtMs?: number;
186
+ /** `sync.auth_lease_required` | `sync.auth_lease_revoked` when stopped. */
187
+ readonly errorCode?: string;
188
+ }
189
+
190
+ export interface SyncSummary {
191
+ /** Commits sent in this round's push half. */
192
+ readonly pushed: number;
193
+ /** clientCommitIds drained (`applied` or `cached`, §6.3). */
194
+ readonly applied: readonly string[];
195
+ /** clientCommitIds rejected and dropped from the outbox. */
196
+ readonly rejected: readonly string[];
197
+ /** clientCommitIds kept for retry (`sync.idempotency_cache_miss`). */
198
+ readonly retryable: readonly string[];
199
+ /** New conflict records surfaced this round. */
200
+ readonly conflicts: readonly ConflictRecord[];
201
+ /** `COMMIT` frames applied locally. */
202
+ readonly commitsApplied: number;
203
+ /** Snapshot rows applied from segments. */
204
+ readonly segmentRowsApplied: number;
205
+ /** Subscriptions still mid-bootstrap (resume token pending). */
206
+ readonly bootstrapping: readonly string[];
207
+ /** Subscriptions reset via `sync.cursor_expired` (re-pull needed). */
208
+ readonly resets: readonly string[];
209
+ /** Subscriptions revoked this round (§3.3 purge ran). */
210
+ readonly revoked: readonly string[];
211
+ /** Subscriptions stopped by a fatal configuration error (§3.3/§5.6
212
+ * fail-closed: no local scope-column mapping). */
213
+ readonly failed: readonly string[];
214
+ /** Present when the server declared a schema floor — syncing stopped. */
215
+ readonly schemaFloor?: SchemaFloor;
216
+ /** §6.1 splitBatch: outbox commits held back from THIS request because
217
+ * including them would exceed the per-request operation cap. They remain
218
+ * queued; sync-needed stays raised and `syncUntilIdle` keeps going. */
219
+ readonly deferredCommits?: number;
220
+ }
221
+
222
+ export interface SyncClientLimits {
223
+ readonly limitCommits?: number;
224
+ readonly limitSnapshotRows?: number;
225
+ readonly maxSnapshotPages?: number;
226
+ /**
227
+ * §4.2 accept bitmask; defaults to inline + external rows (0b0011)
228
+ * plus sqlite images (bit 2) when the database backend implements
229
+ * `withSqliteImage` and a segment downloader is configured (§5.3).
230
+ */
231
+ readonly accept?: number;
232
+ }
233
+
234
+ export interface SyncClientConfig {
235
+ readonly database: ClientDatabase;
236
+ readonly schema: ClientSchema;
237
+ readonly transport: SyncTransport;
238
+ readonly segments?: SegmentDownloader;
239
+ /** Blob upload/download (§5.9). Required to use `uploadBlob`/`fetchBlob`. */
240
+ readonly blobs?: BlobTransport;
241
+ /**
242
+ * §5.9.7 B1 blob-cache size cap (bytes). When the sum of cached body sizes
243
+ * exceeds this, zero-ref, non-pinned bodies are evicted LRU-first after each
244
+ * cache write (referenced/pinned bodies are never evicted — correctness
245
+ * beats the cap). Absent ⇒ retain until storage pressure (the shipped
246
+ * default; a referenced body always stays resolvable without a re-download).
247
+ */
248
+ readonly blobCacheMaxBytes?: number;
249
+ readonly realtime?: RealtimeConnector;
250
+ /** Stable per-device id (§1.5); defaults to a persisted random UUID. */
251
+ readonly clientId?: string;
252
+ readonly leaderLock?: LeaderLock;
253
+ readonly lockName?: string;
254
+ readonly limits?: SyncClientLimits;
255
+ readonly now?: () => number;
256
+ /** §8: hello `requiresSync` or a wake-up — run a pull soon. */
257
+ readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
258
+ readonly onConflict?: (conflict: ConflictRecord) => void;
259
+ /**
260
+ * §7.4.5: the schema-bump `upgrading` state changed. `true` when a reset
261
+ * (wipe + re-bootstrap) began, `false` when the first post-reset
262
+ * bootstrap round reached idle — the app's cue to re-run live queries.
263
+ */
264
+ readonly onUpgrading?: (upgrading: boolean) => void;
265
+ /**
266
+ * §8.6 presence: a scope-mate's presence on a key this client holds
267
+ * changed (join/update/leave). Fired after the local presence map is
268
+ * updated — the app's cue to re-render who's-online.
269
+ */
270
+ readonly onPresence?: (scopeKey: string) => void;
271
+ /**
272
+ * §5.11 client-side encryption. Supplies the key material (`keyProvider`)
273
+ * and key selection (`keyIdFor`) for columns the generated schema marks
274
+ * `encrypted`. Absent ⇒ no columns are encrypted (a schema with encrypted
275
+ * columns then fails loud on the first encode/apply — a missing key is
276
+ * `client.decrypt_failed`, never silent plaintext).
277
+ */
278
+ readonly encryption?: EncryptionConfig;
279
+ }
280
+
281
+ /** §8.6 a peer's ephemeral presence document on a scope key. */
282
+ export interface PresencePeer {
283
+ readonly actorId: string;
284
+ readonly clientId: string;
285
+ readonly doc: Record<string, unknown>;
286
+ }
287
+
288
+ export interface SubscribeInput {
289
+ readonly id: string;
290
+ readonly table: string;
291
+ readonly scopes: ScopeMap;
292
+ readonly params?: string;
293
+ }
294
+
295
+ /**
296
+ * The live state of a window base (§4.8) — the completeness oracle (I3),
297
+ * as plain serializable data so it crosses the worker/follower boundary
298
+ * unchanged. `units` are the scope values currently windowed-in; a query
299
+ * touching only these is answerable in full locally. Use
300
+ * {@link windowComplete} for the per-value verdict a live query renders
301
+ * "this data may be partial" from.
302
+ */
303
+ export interface WindowState {
304
+ /** Windowed-in units for this base, ordered by value. */
305
+ readonly units: readonly string[];
38
306
  }
39
307
 
40
- export interface SyncularClientStatus {
41
- lifecycle: SyncularLifecycleState;
42
- connection: SyncularConnectionState;
43
- outbox: SyncularOutboxStats | null;
44
- conflicts: SyncularConflictStats | null;
45
- isConnected: boolean;
46
- isSyncing: boolean;
47
- hasPendingMutations: boolean;
48
- hasConflicts: boolean;
49
- requiresAction: boolean;
308
+ /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
309
+ export function windowComplete(state: WindowState, unit: string): boolean {
310
+ return state.units.includes(unit);
50
311
  }
51
312
 
52
- export interface SyncularBlobClientLike {
53
- getUploadQueueStats(): Promise<SyncularBlobUploadQueueStats>;
54
- processUploadQueue(
55
- options?: SyncularBlobUploadQueueProcessOptions
56
- ): Promise<{ uploaded: number; failed: number }>;
57
- retrieve(ref: BlobRef): Promise<Uint8Array>;
313
+ // ---------------------------------------------------------------------------
314
+ // Internals
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /** §4.2 accept bits the client cares about. */
318
+ const ACCEPT_ROWS_BASELINE = 0b0011;
319
+ const ACCEPT_SQLITE = 1 << 2;
320
+ const ACCEPT_SIGNED_URLS = 1 << 3;
321
+
322
+ /**
323
+ * §7.4.4 client-local code: a pending outbox commit cannot re-encode under
324
+ * the new generated schema after a bump (a referenced column is gone).
325
+ * Never a wire code (§10.3) — surfaced through the rejection channel.
326
+ */
327
+ const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
328
+
329
+ /** §6.1 per-request operation cap (matches the server's shipped default —
330
+ * `sync.too_many_operations` above it). The push half sends whole commits in
331
+ * commit order up to this cap and defers the rest to the next round. */
332
+ const MAX_OPS_PER_REQUEST = 500;
333
+
334
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
335
+ const digest = await crypto.subtle.digest(
336
+ 'SHA-256',
337
+ bytes.slice().buffer as ArrayBuffer,
338
+ );
339
+ return [...new Uint8Array(digest)]
340
+ .map((b) => b.toString(16).padStart(2, '0'))
341
+ .join('');
58
342
  }
59
343
 
60
- export interface SyncularPresenceClientLike {
61
- get<TMetadata = Record<string, unknown>>(
62
- scopeKey: string
63
- ): SyncularPresenceEntry<TMetadata>[];
64
- join(scopeKey: string, metadata?: Record<string, unknown>): void;
65
- leave(scopeKey: string): void;
66
- updateMetadata(scopeKey: string, metadata: Record<string, unknown>): void;
67
- onChange<TMetadata = Record<string, unknown>>(
68
- listener: SyncularPresenceSink<TMetadata>
69
- ): () => void;
344
+ interface OpenSection {
345
+ readonly start: SubStartFrame;
346
+ readonly sub: SubscriptionRecord | undefined;
347
+ /** Fresh bootstrap (§5.6): request had cursor < 0 and no resume token. */
348
+ readonly fresh: boolean;
349
+ /** Skip application entirely (unknown sub, delta during bootstrap, …). */
350
+ skip: boolean;
351
+ cleared: boolean;
70
352
  }
71
353
 
72
- export interface SyncularConflictsClientLike {
73
- list(): Promise<SyncularConflictSummary[]>;
74
- retryKeepLocal(id: string): Promise<string>;
75
- resolve(id: string, resolution: SyncularConflictResolution): Promise<void>;
354
+ /** §8.7 client side: one in-flight socket round's response assembly. */
355
+ interface PendingRound {
356
+ readonly scanner: MessageStreamScanner;
357
+ readonly resolve: (bytes: Uint8Array) => void;
358
+ readonly reject: (error: ClientSyncError) => void;
76
359
  }
77
360
 
78
- export interface SyncularClientLike<DB> {
79
- db: SyncularDatabase<DB>['db'];
80
- dialect?: SyncularDatabase<DB>['dialect'] | unknown;
81
- mutations: MutationsApi<DB, any>;
82
- leasedMutations: MutationsApi<DB, any>;
83
- blobs: SyncularBlobClientLike;
84
- on<T extends SyncularClientEventType>(
85
- event: T,
86
- listener: SyncularClientEventSink<T>
87
- ): () => void;
88
- getStatus(): SyncularClientStatus;
89
- setSubscriptions(
90
- subscriptions: readonly SyncularSubscriptionSpec[]
91
- ): Promise<void>;
92
- resumeFromBackground(
93
- options?: SyncularSyncRequestOptions
94
- ): Promise<SyncularSyncResult>;
95
- issueAuthLease(
96
- request: SyncAuthLeaseIssueRequest
97
- ): Promise<SyncularAuthLeaseRecord>;
98
- upsertAuthLease(lease: SyncularAuthLeaseRecord): Promise<void>;
99
- authLease(leaseId: string): Promise<SyncularAuthLeaseRecord | null>;
100
- activeAuthLeases(
101
- actorId?: string | null,
102
- nowMs?: number
103
- ): Promise<SyncularAuthLeaseRecord[]>;
104
- diagnosticSnapshot(): Promise<SyncularDiagnosticSnapshot>;
105
- presence: SyncularPresenceClientLike;
106
- conflicts: SyncularConflictsClientLike;
107
- start(): Promise<void>;
108
- stop(): Promise<void>;
109
- sync(): Promise<SyncularSyncResult>;
110
- close(): Promise<void>;
361
+ interface MutableSummary {
362
+ pushed: number;
363
+ applied: string[];
364
+ rejected: string[];
365
+ retryable: string[];
366
+ conflicts: ConflictRecord[];
367
+ commitsApplied: number;
368
+ segmentRowsApplied: number;
369
+ resets: string[];
370
+ revoked: string[];
371
+ failed: string[];
111
372
  }
112
373
 
113
- type LifecycleClient = Pick<
114
- SyncularRuntimeClient,
115
- | 'addDiagnosticListener'
116
- | 'connectionState'
117
- | 'forceSubscriptionsBootstrap'
118
- | 'setSubscriptions'
119
- | 'startRealtime'
120
- | 'stopRealtime'
121
- | 'syncOnce'
122
- >;
123
-
124
- type QueuedSyncWaiter = {
125
- resolve(result: SyncularSyncResult): void;
126
- reject(error: unknown): void;
127
- };
128
-
129
- export function getSyncularClientStatus(
130
- client: Pick<SyncularRuntimeClient, 'connectionState' | 'lifecycleState'>
131
- ): SyncularClientStatus {
132
- const lifecycle = client.lifecycleState();
133
- const connection = client.connectionState();
134
- const outbox = lifecycle.outbox ?? null;
135
- const conflicts = lifecycle.conflicts ?? null;
374
+ function emptySummary(pushed: number): MutableSummary {
136
375
  return {
137
- lifecycle,
138
- connection,
139
- outbox,
140
- conflicts,
141
- isConnected: connection.realtime === 'connected' && !connection.closed,
142
- isSyncing:
143
- lifecycle.phase === 'syncing' || lifecycle.phase === 'recovering',
144
- hasPendingMutations: (outbox?.pending ?? 0) + (outbox?.sending ?? 0) > 0,
145
- hasConflicts: (conflicts?.unresolved ?? 0) > 0,
146
- requiresAction: lifecycle.requiresAction,
376
+ pushed,
377
+ applied: [],
378
+ rejected: [],
379
+ retryable: [],
380
+ conflicts: [],
381
+ commitsApplied: 0,
382
+ segmentRowsApplied: 0,
383
+ resets: [],
384
+ revoked: [],
385
+ failed: [],
147
386
  };
148
387
  }
149
388
 
150
- export class SyncularClientLifecycle {
389
+ export class SyncClient {
390
+ readonly #config: SyncClientConfig;
391
+ readonly #db: ClientDatabase;
392
+ readonly #schema: CompiledClientSchema;
393
+ /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
394
+ readonly #encryption: EncryptionConfig | undefined;
395
+ readonly #now: () => number;
151
396
  #started = false;
152
- #pollTimer: ReturnType<typeof setInterval> | undefined;
153
- #unsubscribeDiagnostics: (() => void) | undefined;
154
- #unsubscribeNetwork: (() => void) | undefined;
155
- #syncInFlight: Promise<SyncularSyncResult> | undefined;
156
- #queuedSyncWaiters: QueuedSyncWaiter[] = [];
157
- #hasConnectedRealtime = false;
158
- #realtimeStarted = false;
159
- readonly #network: SyncularNetworkStatusSource | undefined;
160
-
161
- constructor(
162
- private readonly client: LifecycleClient,
163
- private readonly options: SyncularClientLifecycleOptions = {}
164
- ) {
165
- this.#network =
166
- options.network === false
167
- ? undefined
168
- : (options.network ?? browserSyncularNetworkStatusSource());
397
+ #lease: LeaderLease | undefined;
398
+ #clientId = '';
399
+ #schemaFloor: SchemaFloor | undefined;
400
+ #leaseState: LeaseState | undefined;
401
+ /** §7.4.5: true while a schema-bump reset + first bootstrap is in flight. */
402
+ #upgrading = false;
403
+ #conflicts: ConflictRecord[] = [];
404
+ #rejections: RejectionRecord[] = [];
405
+ #socket: RealtimeSocket | undefined;
406
+ #pendingRound: PendingRound | undefined;
407
+ #needsPull = false;
408
+ #syncing = false;
409
+ /**
410
+ * True from the synchronous entry of `sync()` until its serialized round
411
+ * settles — the "one loop owns the database" guard that rejects a
412
+ * concurrent `sync()` before the op chain would queue it. Distinct from
413
+ * `#syncing`, which is true only while `#runSync`'s body actually runs
414
+ * (the fast-bail the delta path reads).
415
+ */
416
+ #syncOutstanding = false;
417
+ readonly #hasBlobs: boolean;
418
+ /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
419
+ readonly #presence = new Map<string, Map<string, PresencePeer>>();
420
+ /** TODO 3.1 / I1: the ONE apply-path invalidation listener set. */
421
+ readonly #invalidation = new InvalidationEmitter();
422
+ /** §8.6: subscribable presence-change listeners (twin of onPresence). */
423
+ readonly #presenceListeners = new Set<(scopeKey: string) => void>();
424
+ /** The batch accumulator; non-undefined only inside `#applyBatch`. */
425
+ #batch: Invalidation | undefined;
426
+ /**
427
+ * Operation-serialization mutex (the core owns one loop). Every
428
+ * transaction-entering ASYNC operation — `sync`, the delta-apply body, and
429
+ * `setWindow` — runs to completion under this chain so no two interleave at
430
+ * an `await` point. This is the single guard that keeps the apply seam
431
+ * atomic: two `#processResponse` runs must never share the `#batch`
432
+ * accumulator or interleave their SQLite transactions (a delta arriving
433
+ * mid-`await this.#downloadSegment` of a pull, or a `setWindow` widen racing
434
+ * a delta). Synchronous ops (`mutate`, schema reset) never join the chain —
435
+ * single-threaded JS cannot interleave them, and because an async op holds
436
+ * no OPEN db transaction (and no installed `#batch`) across its awaits only
437
+ * while it is NOT actively running, a sync op can only land between chained
438
+ * sections, when the seam is quiescent.
439
+ */
440
+ #opChain: Promise<unknown> = Promise.resolve();
441
+
442
+ constructor(config: SyncClientConfig) {
443
+ this.#config = config;
444
+ this.#db = config.database;
445
+ this.#schema = compileClientSchema(config.schema);
446
+ this.#encryption = config.encryption;
447
+ this.#now = config.now ?? Date.now;
448
+ this.#hasBlobs = schemaHasBlobs(this.#schema);
169
449
  }
170
450
 
451
+ // -- lifecycle ------------------------------------------------------------
452
+
453
+ /** Acquire leadership, create local tables, resolve the clientId. */
171
454
  async start(): Promise<void> {
172
455
  if (this.#started) return;
456
+ const lock = this.#config.leaderLock ?? singleOwnerLock();
457
+ this.#lease = await lock.acquire(
458
+ this.#config.lockName ?? 'syncular-leader',
459
+ );
460
+ ensureLocalSchema(this.#db, this.#schema);
461
+ if (this.#hasBlobs) ensureBlobSchema(this.#db);
462
+ const persisted = getMeta(this.#db, 'clientId');
463
+ this.#clientId = this.#config.clientId ?? persisted ?? crypto.randomUUID();
464
+ if (persisted !== this.#clientId) {
465
+ setMeta(this.#db, 'clientId', this.#clientId);
466
+ }
467
+ // §7.3.5: restore the persisted lease so leaseState survives restart.
468
+ const leaseJson = getMeta(this.#db, 'leaseState');
469
+ if (leaseJson !== undefined) {
470
+ this.#leaseState = JSON.parse(leaseJson) as LeaseState;
471
+ }
472
+ // §7.4.2 trigger 1: the persisted local schema version differs from the
473
+ // generated version this client ships — run the wipe/re-bootstrap reset
474
+ // before the first sync round. A fresh install (no marker) is treated as
475
+ // already at the generated version.
476
+ this.#detectAndResetSchema();
173
477
  this.#started = true;
174
- this.#hasConnectedRealtime =
175
- this.client.connectionState().realtime === 'connected';
176
- this.#realtimeStarted = this.#hasConnectedRealtime;
177
- this.#unsubscribeDiagnostics = this.client.addDiagnosticListener((event) =>
178
- this.#handleDiagnostic(event)
478
+ }
479
+
480
+ /**
481
+ * §7.4.1/§7.4.2: compare the generated schema version to the persisted
482
+ * marker and run the §7.4.3 reset when they differ. Idempotent by the
483
+ * marker — a mid-reset crash re-runs the reset on the next boot.
484
+ */
485
+ #detectAndResetSchema(): void {
486
+ const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
487
+ if (markerJson === undefined) {
488
+ // Fresh install: the tables just created match the running code.
489
+ setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
490
+ return;
491
+ }
492
+ const marker = Number(markerJson);
493
+ if (marker === this.#schema.version) return;
494
+ this.#runSchemaReset();
495
+ }
496
+
497
+ /**
498
+ * §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
499
+ * and leaseState. Drops/recreates every synced table from the new schema,
500
+ * resets subscription sync-state (keeping registrations), clears any
501
+ * schema-floor stop state, rewrites the marker, and raises `upgrading`.
502
+ * The bump is idempotent by the marker (rewritten last).
503
+ */
504
+ #runSchemaReset(): void {
505
+ this.#setUpgrading(true);
506
+ this.#applyBatch((batch) => {
507
+ this.#db.transaction(() => {
508
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
509
+ resetSubscriptionsForBump(this.#db);
510
+ setMeta(
511
+ this.#db,
512
+ LOCAL_SCHEMA_VERSION_KEY,
513
+ String(this.#schema.version),
514
+ );
515
+ });
516
+ // Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
517
+ for (const table of this.#schema.tables.values()) batch.table(table.name);
518
+ });
519
+ // The stop state is over: this client now ships a servable schema. The
520
+ // outbox is re-applied optimistically over the (now empty) tables so
521
+ // pending offline writes stay visible across the bump (§7.4.5).
522
+ this.#schemaFloor = undefined;
523
+ this.#replayOutbox();
524
+ }
525
+
526
+ #setUpgrading(upgrading: boolean): void {
527
+ if (this.#upgrading === upgrading) return;
528
+ this.#upgrading = upgrading;
529
+ this.#config.onUpgrading?.(upgrading);
530
+ }
531
+
532
+ async close(): Promise<void> {
533
+ this.#socket?.close();
534
+ this.#socket = undefined;
535
+ this.#abortPendingRound('client closed mid-round');
536
+ await this.#lease?.release();
537
+ this.#lease = undefined;
538
+ this.#started = false;
539
+ }
540
+
541
+ // -- accessors ------------------------------------------------------------
542
+
543
+ get clientId(): string {
544
+ return this.#clientId;
545
+ }
546
+
547
+ /** The underlying database — raw SQL is the local query API (B3). */
548
+ get database(): ClientDatabase {
549
+ return this.#db;
550
+ }
551
+
552
+ query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
553
+ return this.#db.query(sql, params);
554
+ }
555
+
556
+ // -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
557
+
558
+ /**
559
+ * Subscribe to fine-grained invalidation. The callback fires ONCE per
560
+ * apply batch (never per row, I1) with the `{tables, scopeKeys}` touched
561
+ * this batch (§3.1 vocabulary, I2). Returns an unsubscribe function.
562
+ *
563
+ * Every local mutation flows through the same choke point: `COMMIT`
564
+ * apply, segment apply, the optimistic overlay rebuild, the §3.3 purge,
565
+ * the §7.4.3 schema-bump reset, and local `mutate`. `tables` is the
566
+ * reliable floor; `scopeKeys` refines it wherever the source carried
567
+ * per-row scopes (COMMIT changes) or a scope map (segments/purge).
568
+ */
569
+ onInvalidate(listener: InvalidationListener): () => void {
570
+ return this.#invalidation.on(listener);
571
+ }
572
+
573
+ /**
574
+ * Run `fn` as one apply batch: install a fresh accumulator, collect every
575
+ * touched key, then emit exactly one coalesced event if anything changed.
576
+ * Re-entrant calls share the outer batch so a nested apply never
577
+ * double-emits (e.g. purge → blob reconcile → replay inside one round).
578
+ */
579
+ #applyBatch<T>(fn: (batch: Invalidation) => T): T {
580
+ if (this.#batch !== undefined) return fn(this.#batch);
581
+ const batch = new Invalidation();
582
+ this.#batch = batch;
583
+ try {
584
+ return fn(batch);
585
+ } finally {
586
+ this.#batch = undefined;
587
+ const event = batch.finish();
588
+ if (event !== undefined) this.#invalidation.emit(event);
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Run `fn` as the next link in the operation-serialization chain
594
+ * ({@link #opChain}): it starts only after every previously-serialized
595
+ * operation has fully settled, so transaction-entering async operations
596
+ * never interleave at an await point. Both chain branches settle to
597
+ * `undefined` so one operation's rejection never poisons the next
598
+ * (mirrors the worker host's `serializedSync`). NOT re-entrant: a
599
+ * serialized operation must not call another serialized operation, or it
600
+ * would deadlock waiting on itself.
601
+ */
602
+ #serialize<T>(fn: () => Promise<T>): Promise<T> {
603
+ const next = this.#opChain.then(fn, fn);
604
+ this.#opChain = next.then(
605
+ () => undefined,
606
+ () => undefined,
179
607
  );
180
- this.#unsubscribeNetwork = this.#subscribeNetworkEvents();
608
+ return next;
609
+ }
610
+
611
+ /** Async twin of {@link #applyBatch} for the pull/delta apply round. */
612
+ async #applyBatchAsync<T>(
613
+ fn: (batch: Invalidation) => Promise<T>,
614
+ ): Promise<T> {
615
+ if (this.#batch !== undefined) return fn(this.#batch);
616
+ const batch = new Invalidation();
617
+ this.#batch = batch;
181
618
  try {
182
- if (this.options.subscriptions) {
183
- await this.client.setSubscriptions(this.options.subscriptions);
619
+ return await fn(batch);
620
+ } finally {
621
+ this.#batch = undefined;
622
+ const event = batch.finish();
623
+ if (event !== undefined) this.#invalidation.emit(event);
624
+ }
625
+ }
626
+
627
+ // -- blobs (§5.9) ---------------------------------------------------------
628
+
629
+ /**
630
+ * Stage a blob for attachment (§5.9.7): hash the bytes into the content
631
+ * address, cache them locally, and queue the upload (flushed before the
632
+ * next push — B4). Returns the canonical `BlobRef` **string** to store in
633
+ * a `blob_ref` column of a mutation. The referencing row MUST be written
634
+ * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
635
+ */
636
+ async uploadBlob(
637
+ bytes: Uint8Array,
638
+ options?: { readonly mediaType?: string; readonly name?: string },
639
+ ): Promise<BlobRef> {
640
+ if (this.#config.blobs === undefined) {
641
+ throw new ClientSyncError(
642
+ 'sync.invalid_request',
643
+ 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
644
+ );
645
+ }
646
+ const blobId = await computeBlobId(bytes);
647
+ this.#db.transaction(() => {
648
+ putCachedBlob(this.#db, blobId, bytes, this.#now(), options?.mediaType);
649
+ recordPendingUpload(this.#db, blobId, this.#now(), options?.mediaType);
650
+ });
651
+ // §5.9.7 B1: a staged upload is pinned (recordPendingUpload), so the cap
652
+ // trim below will never evict it — but a stage may push other zero-ref
653
+ // bodies over the cap, so run the trim.
654
+ this.#enforceBlobCacheCap();
655
+ return {
656
+ blobId,
657
+ byteLength: bytes.length,
658
+ ...(options?.mediaType !== undefined
659
+ ? { mediaType: options.mediaType }
660
+ : {}),
661
+ ...(options?.name !== undefined ? { name: options.name } : {}),
662
+ };
663
+ }
664
+
665
+ /** Serialize a BlobRef to the canonical string a `blob_ref` column holds. */
666
+ blobRefString(ref: BlobRef): string {
667
+ return serializeBlobRef(ref);
668
+ }
669
+
670
+ /**
671
+ * Resolve blob bytes for a `blobId` (§5.9.7): a content-addressed cache
672
+ * hit serves without a network fetch (B1); a miss downloads via the blob
673
+ * transport (§5.9.5), verifies the content address, caches, and returns.
674
+ * Accepts a raw `blob_ref` column string or a bare `blobId`.
675
+ */
676
+ async fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
677
+ const blobId = blobIdOrRef.startsWith('sha256:')
678
+ ? blobIdOrRef
679
+ : parseBlobRef(blobIdOrRef).blobId;
680
+ const cached = getCachedBlob(this.#db, blobId, this.#now());
681
+ if (cached !== undefined) return cached;
682
+ const transport = this.#config.blobs;
683
+ if (transport === undefined) {
684
+ throw new ClientSyncError(
685
+ 'sync.invalid_request',
686
+ 'fetchBlob requires a blob transport (SyncClientConfig.blobs, §5.9)',
687
+ );
688
+ }
689
+ // §5.9.5: the authorized endpoint serves bytes inline OR (always-issue,
690
+ // presign configured) a signed url the client fetches directly. On a url
691
+ // arm the client MUST NOT attach host auth and MUST NOT fall through:
692
+ // failure => re-request, the caller's next fetchBlob mints a fresh url.
693
+ const response = await transport.download(blobId);
694
+ let bytes: Uint8Array;
695
+ if (response.kind === 'url') {
696
+ if (transport.fetchUrl === undefined) {
697
+ throw new ClientSyncError(
698
+ 'sync.invalid_request',
699
+ 'blob download returned a url but the transport cannot fetch urls (§5.9.5)',
700
+ );
184
701
  }
185
- if (this.options.initialSync !== false && this.#isOnline()) {
186
- await this.#syncForLifecycle();
702
+ if (
703
+ response.urlExpiresAtMs !== undefined &&
704
+ response.urlExpiresAtMs <= this.#now()
705
+ ) {
706
+ // §5.9.5: MUST NOT start a fetch at/past expiry — re-request recovers.
707
+ throw new ClientSyncError(
708
+ 'sync.segment_expired',
709
+ `blob url for ${blobId} expired before fetch — re-request mints a fresh url (§5.9.5)`,
710
+ true,
711
+ );
187
712
  }
188
- if (this.options.realtime !== false && this.#isOnline()) {
189
- await this.#startRealtimeForLifecycle();
713
+ bytes = await transport.fetchUrl(response.url);
714
+ } else {
715
+ bytes = response.bytes;
716
+ }
717
+ const computed = await computeBlobId(bytes);
718
+ if (computed !== blobId) {
719
+ // §5.9.5 inherits §5.1: verify the content address, reject on mismatch.
720
+ // On the url path this invalidates the fetch (no fall-through) — the
721
+ // next fetchBlob re-requests the authorized endpoint (§5.9.5 recovery).
722
+ throw new ClientSyncError(
723
+ 'sync.invalid_request',
724
+ `blob content address mismatch for ${blobId} (§5.9.5)`,
725
+ );
726
+ }
727
+ putCachedBlob(this.#db, blobId, bytes, this.#now());
728
+ this.#enforceBlobCacheCap();
729
+ const stored = getCachedBlob(this.#db, blobId);
730
+ if (stored === undefined) {
731
+ throw new ClientSyncError(
732
+ 'sync.invalid_request',
733
+ 'blob cache write failed',
734
+ );
735
+ }
736
+ return stored;
737
+ }
738
+
739
+ /** §5.9.7 B1: trim the blob cache to the configured cap (no-op if unset). */
740
+ #enforceBlobCacheCap(): void {
741
+ const cap = this.#config.blobCacheMaxBytes;
742
+ if (cap === undefined) return;
743
+ enforceBlobCacheCap(this.#db, cap);
744
+ }
745
+
746
+ /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
747
+ async flushBlobUploads(): Promise<void> {
748
+ const transport = this.#config.blobs;
749
+ if (transport === undefined || !this.#hasBlobs) return;
750
+ for (const pending of listPendingUploads(this.#db)) {
751
+ const cached = getCachedBlob(this.#db, pending.blobId);
752
+ if (cached === undefined) {
753
+ // The bytes are gone (never happens for a well-behaved client);
754
+ // drop the upload so it does not wedge the queue.
755
+ clearPendingUpload(this.#db, pending.blobId);
756
+ continue;
190
757
  }
191
- this.#startPolling();
192
- } catch (error) {
193
- await this.stop().catch(() => undefined);
194
- throw error;
758
+ await this.#uploadOne(
759
+ transport,
760
+ pending.blobId,
761
+ cached.bytes,
762
+ pending.mediaType,
763
+ );
764
+ clearPendingUpload(this.#db, pending.blobId);
195
765
  }
196
766
  }
197
767
 
198
- async stop(): Promise<void> {
199
- if (!this.#started) return;
200
- this.#started = false;
201
- this.#stopPolling();
202
- this.#unsubscribeDiagnostics?.();
203
- this.#unsubscribeDiagnostics = undefined;
204
- this.#unsubscribeNetwork?.();
205
- this.#unsubscribeNetwork = undefined;
206
- this.#rejectQueuedSyncWaiters(
207
- new Error('Syncular lifecycle stopped before queued sync could run')
768
+ /**
769
+ * §5.9.3: upload one blob, preferring the presigned direct-to-storage grant
770
+ * when the transport supports it, else streaming through the direct endpoint
771
+ * (capability, not fallback). A `url` grant PUTs direct with no host auth; on
772
+ * a grant PUT failure the client streams through the direct endpoint — a
773
+ * *different, host-authenticated capability*, not a fall-through of the
774
+ * grant's authority (the direct endpoint was always the other path, B4).
775
+ */
776
+ async #uploadOne(
777
+ transport: BlobTransport,
778
+ blobId: string,
779
+ bytes: Uint8Array,
780
+ mediaType?: string,
781
+ ): Promise<void> {
782
+ if (
783
+ transport.uploadGrant !== undefined &&
784
+ transport.uploadToUrl !== undefined
785
+ ) {
786
+ const grant = await transport.uploadGrant(
787
+ blobId,
788
+ bytes.length,
789
+ mediaType,
790
+ );
791
+ if (grant.kind === 'present') return; // idempotent §5.9.3 — no PUT needed
792
+ if (grant.kind === 'url') {
793
+ if (
794
+ grant.urlExpiresAtMs === undefined ||
795
+ grant.urlExpiresAtMs > this.#now()
796
+ ) {
797
+ try {
798
+ await transport.uploadToUrl(grant.url, bytes, mediaType);
799
+ return;
800
+ } catch {
801
+ // Grant PUT failed — stream through the direct endpoint below.
802
+ }
803
+ }
804
+ }
805
+ // grant.kind === 'none' (no presign store) or a failed/expired grant:
806
+ // stream through the direct host-authenticated endpoint.
807
+ }
808
+ await transport.upload(blobId, bytes, mediaType);
809
+ }
810
+
811
+ get conflicts(): readonly ConflictRecord[] {
812
+ return this.#conflicts;
813
+ }
814
+
815
+ get rejections(): readonly RejectionRecord[] {
816
+ return this.#rejections;
817
+ }
818
+
819
+ /** Non-undefined once the server declared a schema floor (§1.6). */
820
+ get schemaFloor(): SchemaFloor | undefined {
821
+ return this.#schemaFloor;
822
+ }
823
+
824
+ /**
825
+ * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
826
+ * flight — the app's "upgrading…" cue. Clears when the first post-reset
827
+ * bootstrap round reaches idle (every subscription past its fresh
828
+ * bootstrap).
829
+ */
830
+ get upgrading(): boolean {
831
+ return this.#upgrading;
832
+ }
833
+
834
+ /**
835
+ * §7.3.5: the current auth-lease state (opaque). Undefined until a
836
+ * `LEASE` frame arrives. `errorCode` is set when a round was rejected
837
+ * with a request-level lease code — syncing on the lease has stopped.
838
+ */
839
+ get leaseState(): LeaseState | undefined {
840
+ return this.#leaseState;
841
+ }
842
+
843
+ /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
844
+ * `undefined` if no lease is held. Negative once expired. */
845
+ leaseRemainingMs(now: number = this.#now()): number | undefined {
846
+ const expiresAtMs = this.#leaseState?.expiresAtMs;
847
+ return expiresAtMs === undefined ? undefined : expiresAtMs - now;
848
+ }
849
+
850
+ /** True when syncing is stopped pending a client upgrade. */
851
+ get stopped(): boolean {
852
+ return this.#schemaFloor !== undefined;
853
+ }
854
+
855
+ /** §8: a hello/wake-up asked for a pull that has not run yet. */
856
+ get syncNeeded(): boolean {
857
+ return this.#needsPull;
858
+ }
859
+
860
+ /**
861
+ * §8.6 presence on a scope key: the current peers present there (a map
862
+ * of `actorId clientId` → peer). Empty for a key with no present peers.
863
+ * Ephemeral — reflects only what the socket has delivered.
864
+ */
865
+ presence(scopeKey: string): readonly PresencePeer[] {
866
+ const peers = this.#presence.get(scopeKey);
867
+ return peers === undefined ? [] : [...peers.values()];
868
+ }
869
+
870
+ /** Every scope key this client currently has presence state for. */
871
+ presenceKeys(): string[] {
872
+ return [...this.#presence.keys()];
873
+ }
874
+
875
+ /**
876
+ * §8.6: subscribe to presence changes (join/update/leave on any held key).
877
+ * The subscribable twin of the `onPresence` config callback — React's
878
+ * `usePresence` targets this so many components can watch one client.
879
+ * Returns an unsubscribe function.
880
+ */
881
+ onPresence(listener: (scopeKey: string) => void): () => void {
882
+ this.#presenceListeners.add(listener);
883
+ return () => {
884
+ this.#presenceListeners.delete(listener);
885
+ };
886
+ }
887
+
888
+ /**
889
+ * §8.6.2 publish (or clear, `doc: null`) this client's presence document
890
+ * for `scopeKey`. Requires a live socket; the document is ephemeral and
891
+ * lost on disconnect (the server emits leave). Authorization is the
892
+ * connection's registration (§8.6.3) — an unheld key is rejected loudly
893
+ * by the server with `presence.forbidden`.
894
+ */
895
+ setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
896
+ this.#requireStarted();
897
+ const socket = this.#socket;
898
+ if (socket === undefined) {
899
+ throw new ClientSyncError(
900
+ 'sync.invalid_request',
901
+ 'setPresence requires a connected realtime socket (§8.6)',
902
+ );
903
+ }
904
+ socket.send(encodePresencePublish(scopeKey, doc));
905
+ }
906
+
907
+ /**
908
+ * §4.2 accept mask: the configured override, or the rows baseline plus
909
+ * bit 2 when the backend can import sqlite images (§5.3) and a segment
910
+ * downloader exists (sqlite segments are never inline, §5.7), plus
911
+ * bit 3 when the downloader exposes a direct URL fetch (§5.4
912
+ * capability negotiation).
913
+ */
914
+ #acceptMask(): number {
915
+ const configured = this.#config.limits?.accept;
916
+ if (configured !== undefined) return configured;
917
+ const segments = this.#config.segments;
918
+ const sqliteCapable =
919
+ typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
920
+ const urlCapable = typeof segments?.fetchUrl === 'function';
921
+ return (
922
+ ACCEPT_ROWS_BASELINE |
923
+ (sqliteCapable ? ACCEPT_SQLITE : 0) |
924
+ (urlCapable ? ACCEPT_SIGNED_URLS : 0)
208
925
  );
209
- if (this.options.realtime !== false) {
210
- await this.client.stopRealtime();
926
+ }
927
+
928
+ subscriptions(): SubscriptionRecord[] {
929
+ this.#requireStarted();
930
+ return loadSubscriptions(this.#db);
931
+ }
932
+
933
+ subscription(id: string): SubscriptionRecord | undefined {
934
+ this.#requireStarted();
935
+ return getSubscription(this.#db, id);
936
+ }
937
+
938
+ pendingCommits(): OutboxCommit[] {
939
+ this.#requireStarted();
940
+ return listOutbox(this.#db);
941
+ }
942
+
943
+ // -- subscriptions ----------------------------------------------------------
944
+
945
+ subscribe(input: SubscribeInput): void {
946
+ this.#requireStarted();
947
+ if (!this.#schema.tables.has(input.table)) {
948
+ throw new ClientSyncError(
949
+ 'sync.unknown_table',
950
+ `subscribe: unknown local table ${JSON.stringify(input.table)}`,
951
+ );
952
+ }
953
+ const existing = getSubscription(this.#db, input.id);
954
+ if (existing !== undefined) {
955
+ saveSubscription(this.#db, {
956
+ ...existing,
957
+ table: input.table,
958
+ scopes: input.scopes,
959
+ ...(input.params !== undefined ? { params: input.params } : {}),
960
+ });
961
+ return;
211
962
  }
212
- this.#realtimeStarted = false;
963
+ saveSubscription(this.#db, {
964
+ id: input.id,
965
+ table: input.table,
966
+ scopes: input.scopes,
967
+ ...(input.params !== undefined ? { params: input.params } : {}),
968
+ cursor: -1,
969
+ status: 'active',
970
+ });
971
+ }
972
+
973
+ unsubscribe(id: string): void {
974
+ this.#requireStarted();
975
+ deleteSubscription(this.#db, id);
213
976
  }
214
977
 
215
- async sync(): Promise<SyncularSyncResult> {
216
- if (this.#syncInFlight) {
217
- return new Promise((resolve, reject) => {
218
- this.#queuedSyncWaiters.push({ resolve, reject });
978
+ // -- windowed subscriptions (§4.8) ------------------------------------------
979
+
980
+ /**
981
+ * Set the live window units for a base (§4.8): a value-sharded family of
982
+ * subscriptions, one per unit. Computes the diff against the registry —
983
+ * added units get fresh subscriptions (image-lane bootstrap on the next
984
+ * sync); removed units are unsubscribed and evicted, fused in one local
985
+ * transaction (E1–E4). Idempotent: calling with the same units is a
986
+ * no-op. Re-entry (a unit removed then re-added) cancels any deferred
987
+ * eviction and fresh-bootstraps.
988
+ *
989
+ * The change takes effect on the next `sync()`/socket round — the pull's
990
+ * subscription list (now with the added unit, without the removed one)
991
+ * re-registers realtime at round end (§8.7). No socket cycle needed.
992
+ */
993
+ async setWindow(base: WindowBase, units: readonly string[]): Promise<void> {
994
+ this.#requireStarted();
995
+ const table = this.#table(base.table);
996
+ if (!table.scopeColumnByVariable.has(base.variable)) {
997
+ throw new ClientSyncError(
998
+ 'sync.invalid_request',
999
+ `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`,
1000
+ );
1001
+ }
1002
+ // Serialize the whole window edit: it spans an `await deriveSubId` between
1003
+ // db transactions, so without the chain a delta apply (or a concurrent
1004
+ // setWindow) could interleave its transactions and corrupt the registry.
1005
+ await this.#serialize(async () => {
1006
+ const baseKey = windowBaseKey(base);
1007
+ const wanted = new Set(units);
1008
+ const live = loadWindowUnits(this.#db, baseKey);
1009
+ const liveByUnit = new Map(live.map((u) => [u.unit, u.subId]));
1010
+
1011
+ // Widen: units wanted but not live → fresh subscription + registry row.
1012
+ for (const unit of wanted) {
1013
+ if (liveByUnit.has(unit)) continue;
1014
+ const subId = await deriveSubId(base, unit);
1015
+ this.#db.transaction(() => {
1016
+ // Re-entry cancels any deferred eviction for this sub id.
1017
+ deletePendingEviction(this.#db, subId);
1018
+ insertWindowUnit(this.#db, baseKey, unit, subId);
1019
+ saveSubscription(this.#db, {
1020
+ id: subId,
1021
+ table: base.table,
1022
+ scopes: unitScopes(base, unit),
1023
+ ...(base.params !== undefined ? { params: base.params } : {}),
1024
+ cursor: -1,
1025
+ status: 'active',
1026
+ });
1027
+ });
1028
+ }
1029
+
1030
+ // Shrink: units live but not wanted → unsubscribe fused with eviction.
1031
+ for (const { unit, subId } of live) {
1032
+ if (wanted.has(unit)) continue;
1033
+ this.#evictUnit(baseKey, base, unit, subId);
1034
+ }
1035
+ });
1036
+ }
1037
+
1038
+ /**
1039
+ * The completeness oracle (§4.8 I3): which units of a base are windowed-in
1040
+ * locally, and a per-unit verdict a live query renders "may be partial"
1041
+ * from. A query whose scope footprint includes an un-windowed unit is
1042
+ * NOT answerable in full — the host widens or shows partial, never
1043
+ * silently-complete.
1044
+ */
1045
+ windowState(base: WindowBase): WindowState {
1046
+ this.#requireStarted();
1047
+ const baseKey = windowBaseKey(base);
1048
+ return { units: loadWindowUnits(this.#db, baseKey).map((u) => u.unit) };
1049
+ }
1050
+
1051
+ /**
1052
+ * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
1053
+ * one transaction. Deletes the unit's rows EXCEPT those pinned by a
1054
+ * pending outbox commit (E1); if any pin remains, records a deferred
1055
+ * eviction retried on the next outbox drain. Discards the subscription's
1056
+ * cursor/resume/effective-echo (E3) and its version state with the rows
1057
+ * (E2). Emits the evicted table's invalidation keys (I1). Fail-closed:
1058
+ * with no local scope-column mapping, surfaces a configuration error and
1059
+ * evicts nothing (§4.8/§3.3).
1060
+ */
1061
+ #evictUnit(
1062
+ baseKey: string,
1063
+ base: WindowBase,
1064
+ unit: string,
1065
+ subId: string,
1066
+ ): void {
1067
+ const table = this.#table(base.table);
1068
+ const sub = getSubscription(this.#db, subId);
1069
+ // The rows a unit holds live under its LAST effective scopes if it ever
1070
+ // synced; before first sync, the requested unit scopes are the match.
1071
+ const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
1072
+ const pinned = this.#pinnedRowIds(base.table);
1073
+ this.#applyBatch((batch) => {
1074
+ this.#db.transaction(() => {
1075
+ const deferred = evictScopedRows(this.#db, table, effective, pinned);
1076
+ deleteWindowUnit(this.#db, baseKey, unit);
1077
+ deleteSubscription(this.#db, subId);
1078
+ if (deferred) {
1079
+ savePendingEviction(this.#db, subId, base.table, effective);
1080
+ } else {
1081
+ deletePendingEviction(this.#db, subId);
1082
+ }
1083
+ });
1084
+ // I1: eviction is a bulk delete — a query over the evicted unit re-runs.
1085
+ batch.table(table.name);
1086
+ batch.scopeMap(table, effective);
1087
+ });
1088
+ }
1089
+
1090
+ /**
1091
+ * §4.8 E1: retry deferred evictions after the outbox drains. A pinned
1092
+ * unit's rows are removed once no pending commit references them; a unit
1093
+ * that re-entered the window in the meantime has no pending record left.
1094
+ */
1095
+ #drainPendingEvictions(): void {
1096
+ const pending = loadPendingEvictions(this.#db);
1097
+ if (pending.length === 0) return;
1098
+ for (const entry of pending) {
1099
+ const table = this.#schema.tables.get(entry.table);
1100
+ if (table === undefined) {
1101
+ deletePendingEviction(this.#db, entry.subId);
1102
+ continue;
1103
+ }
1104
+ const pinned = this.#pinnedRowIds(entry.table);
1105
+ this.#applyBatch((batch) => {
1106
+ let deferred = false;
1107
+ this.#db.transaction(() => {
1108
+ deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
1109
+ if (!deferred) deletePendingEviction(this.#db, entry.subId);
1110
+ });
1111
+ batch.table(table.name);
1112
+ batch.scopeMap(table, entry.effective);
219
1113
  });
220
1114
  }
221
- this.#syncInFlight = this.#startSyncCycle();
222
- return this.#syncInFlight;
223
1115
  }
224
1116
 
225
- #startSyncCycle(): Promise<SyncularSyncResult> {
226
- let sync: Promise<SyncularSyncResult>;
1117
+ /**
1118
+ * §4.8 E1: primary keys of `table` referenced by any still-pending outbox
1119
+ * commit — rows that MUST NOT be evicted until the commit drains.
1120
+ */
1121
+ #pinnedRowIds(table: string): Set<string> {
1122
+ const pinned = new Set<string>();
1123
+ for (const commit of listOutbox(this.#db)) {
1124
+ for (const op of commit.operations) {
1125
+ if (op.table === table) pinned.add(op.rowId);
1126
+ }
1127
+ }
1128
+ return pinned;
1129
+ }
1130
+
1131
+ // -- local mutations --------------------------------------------------------
1132
+
1133
+ /**
1134
+ * Record one atomic local commit (§7.1): appended to the outbox in
1135
+ * schema-agnostic form and applied optimistically to the local mirror.
1136
+ * Returns the generated `clientCommitId`.
1137
+ */
1138
+ mutate(mutations: readonly MutationInput[]): string {
1139
+ this.#requireStarted();
1140
+ const clientCommitId = crypto.randomUUID();
1141
+ const operations: OutboxOperation[] = mutations.map((mutation) => {
1142
+ const table = this.#table(mutation.table);
1143
+ if (mutation.op === 'delete') {
1144
+ return {
1145
+ table: mutation.table,
1146
+ rowId: mutation.rowId,
1147
+ op: 'delete',
1148
+ ...(mutation.baseVersion !== undefined
1149
+ ? { baseVersion: mutation.baseVersion }
1150
+ : {}),
1151
+ };
1152
+ }
1153
+ const values = recordToRowValues(table, mutation.values);
1154
+ const pkValue = values[table.primaryKeyIndex];
1155
+ if (typeof pkValue !== 'string' || pkValue.length === 0) {
1156
+ throw new ClientSyncError(
1157
+ 'sync.invalid_request',
1158
+ `table ${table.name}: upsert requires a non-empty string primary key`,
1159
+ );
1160
+ }
1161
+ const json: Record<string, ReturnType<typeof rowValueToJson>> = {};
1162
+ table.columns.forEach((column, index) => {
1163
+ json[column.name] = rowValueToJson(values[index] ?? null);
1164
+ });
1165
+ return {
1166
+ table: mutation.table,
1167
+ rowId: pkValue,
1168
+ op: 'upsert',
1169
+ ...(mutation.baseVersion !== undefined
1170
+ ? { baseVersion: mutation.baseVersion }
1171
+ : {}),
1172
+ values: json,
1173
+ };
1174
+ });
1175
+ this.#applyBatch((batch) => {
1176
+ this.#db.transaction(() => {
1177
+ appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
1178
+ this.#applyOperationsLocally(operations, batch);
1179
+ });
1180
+ });
1181
+ return clientCommitId;
1182
+ }
1183
+
1184
+ // -- lease state (§7.3.5) ---------------------------------------------------
1185
+
1186
+ /** Merge and persist the lease state (opaque, §7.3.5). */
1187
+ #setLeaseState(next: LeaseState): void {
1188
+ this.#leaseState = next;
1189
+ setMeta(this.#db, 'leaseState', JSON.stringify(next));
1190
+ }
1191
+
1192
+ /** The request-level lease error codes (§7.3.4): stop-and-surface. */
1193
+ #isLeaseErrorCode(code: string): boolean {
1194
+ return (
1195
+ code === 'sync.auth_lease_required' || code === 'sync.auth_lease_revoked'
1196
+ );
1197
+ }
1198
+
1199
+ /**
1200
+ * §7.4.4: encode every pending outbox commit with the current codec. A
1201
+ * commit that cannot re-encode under the new schema (an
1202
+ * `OutboxEncodeError` — a referenced column/table the bump removed) is
1203
+ * dropped from the outbox and surfaced as a rejection (`sync.outbox_
1204
+ * incompatible`); its purely-optimistic rows are undone (§7.2). Returns
1205
+ * the encoded push frames index-aligned with the surviving `outbox`.
1206
+ */
1207
+ async #encodeOutboxForPush(): Promise<{
1208
+ pushFrames: RequestFrame[];
1209
+ outbox: OutboxCommit[];
1210
+ deferred: number;
1211
+ }> {
1212
+ const pending = listOutbox(this.#db);
1213
+ const pushFrames: RequestFrame[] = [];
1214
+ const outbox: OutboxCommit[] = [];
1215
+ let deferred = 0;
1216
+ let ops = 0;
1217
+ for (const commit of pending) {
1218
+ // §6.1 splitBatch: whole commits in commit order, stopping before the
1219
+ // per-request operation cap. A first commit that alone exceeds the cap
1220
+ // is sent alone — the server rejects it loudly rather than the queue
1221
+ // wedging silently. Deferred commits stay queued for the next round.
1222
+ if (
1223
+ outbox.length > 0 &&
1224
+ ops + commit.operations.length > MAX_OPS_PER_REQUEST
1225
+ ) {
1226
+ deferred += 1;
1227
+ continue;
1228
+ }
1229
+ try {
1230
+ pushFrames.push(
1231
+ // §5.11: encrypted columns are encrypted at this encode-at-send
1232
+ // seam before the row codec serializes them.
1233
+ await encodeOutboxCommit(this.#schema, commit, this.#encryption),
1234
+ );
1235
+ outbox.push(commit);
1236
+ ops += commit.operations.length;
1237
+ } catch (error) {
1238
+ if (error instanceof OutboxEncodeError) {
1239
+ this.#dropIncompatibleCommit(commit, error.message);
1240
+ continue;
1241
+ }
1242
+ throw error;
1243
+ }
1244
+ }
1245
+ return { pushFrames, outbox, deferred };
1246
+ }
1247
+
1248
+ /**
1249
+ * §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
1250
+ * §7.2 `rejected` surface — the commit leaves the outbox, its
1251
+ * purely-optimistic rows are undone, and a rejection record is raised.
1252
+ */
1253
+ #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
1254
+ this.#db.transaction(() => {
1255
+ deleteOutboxCommit(this.#db, commit.clientCommitId);
1256
+ for (const operation of commit.operations) {
1257
+ if (operation.op !== 'upsert') continue;
1258
+ const table = this.#schema.tables.get(operation.table);
1259
+ if (table === undefined) continue;
1260
+ const row = this.#db.query(
1261
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
1262
+ [operation.rowId],
1263
+ )[0];
1264
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1265
+ deleteLocalRow(this.#db, table, operation.rowId);
1266
+ }
1267
+ }
1268
+ });
1269
+ this.#rejections.push({
1270
+ clientCommitId: commit.clientCommitId,
1271
+ opIndex: 0,
1272
+ code: OUTBOX_INCOMPATIBLE_CODE,
1273
+ message,
1274
+ retryable: false,
1275
+ ...(commit.operations[0] !== undefined
1276
+ ? { operation: commit.operations[0] }
1277
+ : {}),
1278
+ });
1279
+ }
1280
+
1281
+ // -- sync -------------------------------------------------------------------
1282
+
1283
+ /**
1284
+ * One combined push+pull round (§1.5, §7.2). The core owns one loop: a
1285
+ * concurrent `sync()` while one is already outstanding is rejected loudly
1286
+ * (the app must coalesce its own wake-ups, §8.4) — this check is
1287
+ * SYNCHRONOUS so the second caller sees the first still in flight before
1288
+ * the op chain would otherwise queue it. The round then runs serialized on
1289
+ * the operation chain so it never interleaves with a delta apply or a
1290
+ * `setWindow` at an await point.
1291
+ */
1292
+ sync(): Promise<SyncSummary> {
1293
+ this.#requireStarted();
1294
+ if (this.#syncOutstanding) {
1295
+ return Promise.reject(
1296
+ new ClientSyncError(
1297
+ 'sync.invalid_request',
1298
+ 'sync() is already running — the core owns one loop (coalesce wake-ups)',
1299
+ ),
1300
+ );
1301
+ }
1302
+ this.#syncOutstanding = true;
1303
+ return this.#serialize(() => this.#runSync()).finally(() => {
1304
+ this.#syncOutstanding = false;
1305
+ });
1306
+ }
1307
+
1308
+ async #runSync(): Promise<SyncSummary> {
1309
+ if (this.#schemaFloor !== undefined) {
1310
+ return {
1311
+ ...emptySummary(0),
1312
+ bootstrapping: [],
1313
+ schemaFloor: this.#schemaFloor,
1314
+ };
1315
+ }
1316
+ this.#syncing = true;
1317
+ // Cleared before the round, not after: a wake-up (or a delta dropped
1318
+ // because this pull is mid-flight) that lands during the round must
1319
+ // survive it — the reference server keeps no replay buffer (§8.2).
1320
+ this.#needsPull = false;
227
1321
  try {
228
- sync = Promise.resolve(this.client.syncOnce());
1322
+ // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1323
+ // them, so the server-side existence check (§6.6) passes.
1324
+ if (this.#hasBlobs && this.#config.blobs !== undefined) {
1325
+ await this.flushBlobUploads();
1326
+ }
1327
+ // §7.4.4: encode the outbox with the CURRENT codec; a commit that
1328
+ // cannot express itself under the new schema (a dropped column/table)
1329
+ // is removed from the push and surfaced as a rejection, never wedging
1330
+ // the queue. `pushFrames` and `outbox` stay index-aligned for result
1331
+ // mapping.
1332
+ const { pushFrames, outbox, deferred } =
1333
+ await this.#encodeOutboxForPush();
1334
+ const subs = loadSubscriptions(this.#db).filter(
1335
+ (sub) => sub.status === 'active',
1336
+ );
1337
+ const limits = this.#config.limits;
1338
+ const frames: RequestFrame[] = [
1339
+ {
1340
+ type: 'REQ_HEADER',
1341
+ clientId: this.#clientId,
1342
+ schemaVersion: this.#schema.version,
1343
+ },
1344
+ ...pushFrames,
1345
+ {
1346
+ type: 'PULL_HEADER',
1347
+ limitCommits: limits?.limitCommits ?? 0,
1348
+ limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
1349
+ maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
1350
+ accept: this.#acceptMask(),
1351
+ },
1352
+ ...subs.map(
1353
+ (sub): RequestFrame => ({
1354
+ type: 'SUBSCRIPTION',
1355
+ id: sub.id,
1356
+ table: sub.table,
1357
+ scopes: sub.scopes,
1358
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1359
+ cursor: sub.cursor,
1360
+ ...(sub.bootstrapState !== undefined
1361
+ ? { bootstrapState: sub.bootstrapState }
1362
+ : {}),
1363
+ }),
1364
+ ),
1365
+ ];
1366
+ const requestBytes = encodeMessage({
1367
+ wireVersion: PROTOCOL_WIRE_VERSION,
1368
+ msgKind: 'request',
1369
+ frames,
1370
+ });
1371
+ const responseBytes = await this.#roundTrip(requestBytes);
1372
+ const message = decodeMessage(responseBytes);
1373
+ if (message.msgKind !== 'response') {
1374
+ throw new ClientSyncError(
1375
+ 'sync.invalid_request',
1376
+ 'transport returned a non-response message',
1377
+ );
1378
+ }
1379
+ const summary = await this.#processResponse(
1380
+ message,
1381
+ outbox,
1382
+ subs,
1383
+ 'pull',
1384
+ );
1385
+ // §4.8 E1: the push half may have drained commits that pinned rows of
1386
+ // a shrunk window unit — retry any deferred evictions now.
1387
+ this.#drainPendingEvictions();
1388
+ if (deferred > 0) {
1389
+ // §6.1 splitBatch remainder: more queued commits than this request
1390
+ // could carry — keep the sync-needed signal raised for the host.
1391
+ this.#needsPull = true;
1392
+ return { ...summary, deferredCommits: deferred };
1393
+ }
1394
+ return summary;
229
1395
  } catch (error) {
230
- sync = Promise.reject(error);
1396
+ // §7.3.5: a request-level lease code stops-and-surfaces — record it
1397
+ // in leaseState (no local-data purge, §7.3.4) and re-throw. Not a
1398
+ // silent retry: the app drives recovery to a live resolver. The
1399
+ // error may arrive as a ClientSyncError or as a transport error
1400
+ // carrying the server's `.code` (HTTP-JSON / loopback surface, §1.1).
1401
+ const code = (error as { code?: unknown }).code;
1402
+ if (typeof code === 'string' && this.#isLeaseErrorCode(code)) {
1403
+ this.#setLeaseState({
1404
+ ...(this.#leaseState ?? {}),
1405
+ errorCode: code,
1406
+ });
1407
+ }
1408
+ throw error;
1409
+ } finally {
1410
+ this.#syncing = false;
231
1411
  }
232
- const finish = () => {
233
- if (this.#syncInFlight === sync) {
234
- this.#syncInFlight = undefined;
1412
+ }
1413
+
1414
+ /**
1415
+ * Pull repeatedly until quiescent: no commits delivered, no bootstrap
1416
+ * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
1417
+ */
1418
+ async syncUntilIdle(maxRounds = 20): Promise<SyncSummary> {
1419
+ let last: SyncSummary | undefined;
1420
+ for (let round = 0; round < maxRounds; round++) {
1421
+ last = await this.sync();
1422
+ if (last.schemaFloor !== undefined) return last;
1423
+ if (
1424
+ last.commitsApplied === 0 &&
1425
+ last.segmentRowsApplied === 0 &&
1426
+ last.bootstrapping.length === 0 &&
1427
+ last.resets.length === 0 &&
1428
+ (last.deferredCommits ?? 0) === 0
1429
+ ) {
1430
+ return last;
235
1431
  }
236
- const waiters = this.#queuedSyncWaiters.splice(0);
237
- if (waiters.length === 0) return;
238
- if (!this.#started) {
239
- const error = new Error(
240
- 'Syncular lifecycle stopped before queued sync could run'
1432
+ }
1433
+ throw new ClientSyncError(
1434
+ 'sync.invalid_request',
1435
+ `sync did not reach idle within ${maxRounds} rounds`,
1436
+ );
1437
+ }
1438
+
1439
+ /**
1440
+ * One request/response round trip (§8.7): over the socket whenever it
1441
+ * is connected (Direction decision 1 — the socket IS the sync-round
1442
+ * transport, not a fallback pair), otherwise through the configured
1443
+ * `SyncTransport` seam (loopback/conformance hosts, HTTP-only
1444
+ * producers).
1445
+ */
1446
+ #roundTrip(request: Uint8Array): Promise<Uint8Array> {
1447
+ const socket = this.#socket;
1448
+ if (socket === undefined) return this.#config.transport(request);
1449
+ return new Promise<Uint8Array>((resolve, reject) => {
1450
+ // sync() already enforces one round in flight (§8.7).
1451
+ this.#pendingRound = {
1452
+ scanner: new MessageStreamScanner(),
1453
+ resolve,
1454
+ reject,
1455
+ };
1456
+ const tagged = new Uint8Array(request.length + 1);
1457
+ tagged[0] = REALTIME_TAG_ROUND;
1458
+ tagged.set(request, 1);
1459
+ try {
1460
+ socket.sendBytes(tagged);
1461
+ } catch (error) {
1462
+ this.#pendingRound = undefined;
1463
+ reject(
1464
+ new ClientSyncError(
1465
+ 'sync.transport_failed',
1466
+ `socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
1467
+ true,
1468
+ ),
241
1469
  );
242
- for (const waiter of waiters) waiter.reject(error);
243
- return;
244
1470
  }
245
- const queued = this.#startSyncCycle();
246
- this.#syncInFlight = queued;
247
- queued.then(
248
- (result) => {
249
- for (const waiter of waiters) waiter.resolve(result);
250
- },
251
- (error) => {
252
- for (const waiter of waiters) waiter.reject(error);
253
- }
1471
+ });
1472
+ }
1473
+
1474
+ /** Abort the in-flight socket round (socket closed or disconnected). */
1475
+ #abortPendingRound(reason: string): void {
1476
+ const round = this.#pendingRound;
1477
+ if (round === undefined) return;
1478
+ this.#pendingRound = undefined;
1479
+ round.reject(new ClientSyncError('sync.transport_failed', reason, true));
1480
+ }
1481
+
1482
+ // -- realtime (§8 client side) ----------------------------------------------
1483
+
1484
+ async connectRealtime(): Promise<void> {
1485
+ this.#requireStarted();
1486
+ const connector = this.#config.realtime;
1487
+ if (connector === undefined) {
1488
+ throw new ClientSyncError(
1489
+ 'sync.invalid_request',
1490
+ 'no realtime connector configured',
254
1491
  );
255
- };
256
- void sync.then(finish, finish).catch((error) => {
257
- this.#rejectQueuedSyncWaiters(error);
1492
+ }
1493
+ this.#socket = await connector({
1494
+ onText: (text) => this.#handleRealtimeText(text),
1495
+ onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1496
+ onClose: () => {
1497
+ this.#socket = undefined;
1498
+ this.#presence.clear(); // §8.6.1: presence is per-connection
1499
+ this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1500
+ },
258
1501
  });
259
- return sync;
260
1502
  }
261
1503
 
262
- #rejectQueuedSyncWaiters(error: unknown): void {
263
- const waiters = this.#queuedSyncWaiters.splice(0);
264
- for (const waiter of waiters) waiter.reject(error);
1504
+ disconnectRealtime(): void {
1505
+ this.#socket?.close();
1506
+ this.#socket = undefined;
1507
+ this.#presence.clear(); // §8.6.1: presence is per-connection
1508
+ this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
265
1509
  }
266
1510
 
267
- #handleDiagnostic(event: SyncularDiagnosticEvent): void {
268
- if (event.source === 'sync' && event.details?.resyncRequired === true) {
269
- void this.client
270
- .forceSubscriptionsBootstrap()
271
- .then(() => this.sync())
272
- .catch(() => undefined);
1511
+ /**
1512
+ * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
1513
+ * `0x01` chunks feed the in-flight round's assembler; `0x00` messages
1514
+ * are standalone deltas; unknown tags are ignored (forward compat).
1515
+ */
1516
+ #routeRealtimeBinary(bytes: Uint8Array): void {
1517
+ if (bytes.length === 0) return;
1518
+ const tag = bytes[0];
1519
+ const body = bytes.subarray(1);
1520
+ if (tag === REALTIME_TAG_ROUND) {
1521
+ const round = this.#pendingRound;
1522
+ if (round === undefined) return; // stale chunk after an abort
1523
+ let done: ReturnType<MessageStreamScanner['push']>;
1524
+ try {
1525
+ done = round.scanner.push(body);
1526
+ } catch (error) {
1527
+ this.#pendingRound = undefined;
1528
+ round.reject(
1529
+ new ClientSyncError(
1530
+ 'sync.invalid_request',
1531
+ `malformed round response stream (§8.7): ${error instanceof Error ? error.message : String(error)}`,
1532
+ ),
1533
+ );
1534
+ return;
1535
+ }
1536
+ if (done === undefined) return;
1537
+ this.#pendingRound = undefined;
1538
+ if (done.excess > 0) {
1539
+ round.reject(
1540
+ new ClientSyncError(
1541
+ 'sync.invalid_request',
1542
+ 'response bytes past END of the round stream (§8.7)',
1543
+ ),
1544
+ );
1545
+ return;
1546
+ }
1547
+ round.resolve(done.message.slice());
273
1548
  return;
274
1549
  }
275
- if (
276
- event.source !== 'realtime' ||
277
- event.code !== 'realtime.state' ||
278
- event.details?.state == null
279
- ) {
1550
+ if (tag === REALTIME_TAG_DELTA) {
1551
+ void this.#handleRealtimeBinary(body);
1552
+ }
1553
+ // Unknown tag: tolerated and ignored (§8.7 closed registry).
1554
+ }
1555
+
1556
+ #handleRealtimeText(text: string): void {
1557
+ let parsed: ReturnType<typeof parseRealtimeServerEvent>;
1558
+ try {
1559
+ parsed = parseRealtimeServerEvent(text);
1560
+ } catch {
1561
+ return; // §8.1: tolerate garbled/unknown control messages
1562
+ }
1563
+ if (!parsed.known) return;
1564
+ const event = parsed.event;
1565
+ if (event.event === 'hello') {
1566
+ if (event.data.requiresSync) {
1567
+ this.#needsPull = true;
1568
+ this.#config.onSyncNeeded?.('hello');
1569
+ }
280
1570
  return;
281
1571
  }
282
- const state = event.details.state as SyncularRealtimeConnectionState;
283
- if (state !== 'connected') return;
284
- const wasReconnect = this.#hasConnectedRealtime;
285
- this.#hasConnectedRealtime = true;
286
- const shouldSync =
287
- this.options.syncOnRealtimeConnect !== false &&
288
- (wasReconnect || this.options.initialSync === false);
289
- if (!shouldSync) return;
290
- void this.sync().catch(() => undefined);
291
- }
292
-
293
- #subscribeNetworkEvents(): (() => void) | undefined {
294
- const network = this.#network;
295
- if (!network?.addEventListener || !network.removeEventListener) return;
296
- const handleOnline = () => {
297
- if (!this.#started) return;
298
- void this.#resumeOnline().catch(() => undefined);
299
- };
300
- network.addEventListener('online', handleOnline);
301
- return () => {
302
- network.removeEventListener?.('online', handleOnline);
303
- };
1572
+ if (event.event === 'sync') {
1573
+ // §8.3: any wake-up means "run a pull soon", never data.
1574
+ this.#needsPull = true;
1575
+ this.#config.onSyncNeeded?.(event.data.reason);
1576
+ return;
1577
+ }
1578
+ if (event.event === 'presence') {
1579
+ this.#applyPresence(event.data);
1580
+ }
304
1581
  }
305
1582
 
306
- async #resumeOnline(): Promise<void> {
307
- if (!this.#started || !this.#isOnline()) return;
308
- if (this.options.initialSync !== false) {
309
- await this.#syncForLifecycle();
1583
+ /** §8.6 apply an inbound presence fanout to the local map. */
1584
+ #applyPresence(data: {
1585
+ scopeKey: string;
1586
+ kind?: PresenceKind;
1587
+ actorId?: string;
1588
+ clientId?: string;
1589
+ doc?: Record<string, unknown> | null;
1590
+ error?: string;
1591
+ }): void {
1592
+ // The publisher-directed error variant (§8.6.2) surfaces via the
1593
+ // sync-needed reason channel is inappropriate; it is an out-of-band
1594
+ // presence rejection — record nothing to the peer map, just ignore
1595
+ // here (setPresence callers observe it through the callback if wired).
1596
+ if (data.error !== undefined || data.kind === undefined) return;
1597
+ const { scopeKey, kind, actorId, clientId } = data;
1598
+ if (actorId === undefined || clientId === undefined) return;
1599
+ const peerKey = `${actorId} ${clientId}`;
1600
+ let peers = this.#presence.get(scopeKey);
1601
+ if (kind === 'leave') {
1602
+ peers?.delete(peerKey);
1603
+ if (peers !== undefined && peers.size === 0) {
1604
+ this.#presence.delete(scopeKey);
1605
+ }
1606
+ } else {
1607
+ const doc = data.doc;
1608
+ if (doc === null || doc === undefined) return;
1609
+ if (peers === undefined) {
1610
+ peers = new Map();
1611
+ this.#presence.set(scopeKey, peers);
1612
+ }
1613
+ peers.set(peerKey, { actorId, clientId, doc });
310
1614
  }
311
- if (this.options.realtime !== false && !this.#realtimeStarted) {
312
- await this.#startRealtimeForLifecycle();
1615
+ this.#config.onPresence?.(scopeKey);
1616
+ for (const listener of this.#presenceListeners) {
1617
+ try {
1618
+ listener(scopeKey);
1619
+ } catch {
1620
+ // A UI listener must never break presence application.
1621
+ }
313
1622
  }
314
1623
  }
315
1624
 
316
- async #syncForLifecycle(): Promise<void> {
317
- try {
318
- await this.sync();
319
- } catch (error) {
320
- if (!isSyncularOfflineError(error)) throw error;
1625
+ async #handleRealtimeBinary(bytes: Uint8Array): Promise<void> {
1626
+ if (this.#syncing) {
1627
+ // Fast path: a pull is mid-flight; let it win and recover the gap
1628
+ // itself re-pulling is idempotent, interleaved application is not
1629
+ // worth it. (An optimization; the op chain below is the correctness
1630
+ // mechanism — it also excludes a delta from racing a `setWindow` or a
1631
+ // sync round that started between this check and the apply.)
1632
+ this.#needsPull = true;
1633
+ return;
1634
+ }
1635
+ // Serialize the apply on the operation chain: a delta must never
1636
+ // interleave its transactions or share the `#batch` accumulator with a
1637
+ // pull round or a `setWindow` at an await point (§8.2).
1638
+ await this.#serialize(async () => {
1639
+ try {
1640
+ const message = decodeMessage(bytes);
1641
+ if (message.msgKind !== 'response') return;
1642
+ // §8.2: deltas apply like pull responses; ack after apply.
1643
+ await this.#processResponse(message, [], undefined, 'delta');
1644
+ } catch {
1645
+ // A delta that cannot be applied is recovered by a pull (§8.3).
1646
+ this.#needsPull = true;
1647
+ this.#config.onSyncNeeded?.('catchup-required');
1648
+ }
1649
+ });
1650
+ }
1651
+
1652
+ #sendAck(cursor: number): void {
1653
+ this.#socket?.send(JSON.stringify({ type: 'ack', cursor }));
1654
+ }
1655
+
1656
+ /** Ack the highest cursor that is contiguously applied for every sub. */
1657
+ #ackAfterPull(): void {
1658
+ if (this.#socket === undefined) return;
1659
+ const cursors = loadSubscriptions(this.#db)
1660
+ .filter(
1661
+ (sub) => sub.status === 'active' && sub.bootstrapState === undefined,
1662
+ )
1663
+ .map((sub) => sub.cursor)
1664
+ .filter((cursor) => cursor >= 0);
1665
+ if (cursors.length === 0) return;
1666
+ this.#sendAck(Math.min(...cursors));
1667
+ }
1668
+
1669
+ // -- response processing ------------------------------------------------------
1670
+
1671
+ async #processResponse(
1672
+ message: ResponseMessage,
1673
+ sentCommits: readonly OutboxCommit[],
1674
+ sentSubs: readonly SubscriptionRecord[] | undefined,
1675
+ mode: 'pull' | 'delta',
1676
+ ): Promise<SyncSummary> {
1677
+ const summary = emptySummary(sentCommits.length);
1678
+ const commitsById = new Map(
1679
+ sentCommits.map((commit) => [commit.clientCommitId, commit]),
1680
+ );
1681
+ const subsById = new Map(
1682
+ (sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]),
1683
+ );
1684
+
1685
+ const header = message.frames[0];
1686
+ if (header?.type !== 'RESP_HEADER') {
1687
+ throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
1688
+ }
1689
+ if (header.requiredSchemaVersion !== undefined) {
1690
+ // §1.6 schema floor: nothing else was processed — stop syncing and
1691
+ // surface the upgrade requirement. A live-round floor always stops:
1692
+ // the generated schema does not match what the server serves (behind,
1693
+ // or ahead of a lagging server), and no local reset changes the
1694
+ // version this client sends. The §7.4.2 trigger-2 convergence runs
1695
+ // when the APP updates (recreating the client with a new generated
1696
+ // schema), which fires the boot-time §7.4.1 marker check instead.
1697
+ this.#schemaFloor = {
1698
+ requiredSchemaVersion: header.requiredSchemaVersion,
1699
+ ...(header.latestSchemaVersion !== undefined
1700
+ ? { latestSchemaVersion: header.latestSchemaVersion }
1701
+ : {}),
1702
+ };
1703
+ return {
1704
+ ...summary,
1705
+ bootstrapping: [],
1706
+ schemaFloor: this.#schemaFloor,
1707
+ };
1708
+ }
1709
+
1710
+ let section: OpenSection | undefined;
1711
+ let errorFrame: ClientSyncError | undefined;
1712
+ let deltaCursor = -1;
1713
+
1714
+ // One apply batch per pull/delta round (I1): COMMIT + segment applies,
1715
+ // the revocation purge, and the optimistic replay all coalesce into a
1716
+ // single invalidation event emitted when this batch unwinds.
1717
+ await this.#applyBatchAsync(async () => {
1718
+ try {
1719
+ for (const frame of message.frames.slice(1)) {
1720
+ switch (frame.type) {
1721
+ case 'RESP_HEADER':
1722
+ break;
1723
+ case 'LEASE':
1724
+ // §7.3.5: persist the opaque lease and clear any prior lease
1725
+ // error — a fresh lease means the outage/revocation is over.
1726
+ this.#setLeaseState({
1727
+ leaseId: frame.leaseId,
1728
+ expiresAtMs: frame.expiresAtMs,
1729
+ });
1730
+ break;
1731
+ case 'PUSH_RESULT':
1732
+ this.#handlePushResult(frame, commitsById, summary);
1733
+ break;
1734
+ case 'SUB_START': {
1735
+ const sub = subsById.get(frame.id);
1736
+ const fresh =
1737
+ sub !== undefined &&
1738
+ sub.cursor < 0 &&
1739
+ sub.bootstrapState === undefined &&
1740
+ frame.bootstrap;
1741
+ const skip =
1742
+ sub === undefined ||
1743
+ (mode === 'delta' &&
1744
+ (sub.status !== 'active' ||
1745
+ sub.bootstrapState !== undefined));
1746
+ section = { start: frame, sub, fresh, skip, cleared: false };
1747
+ break;
1748
+ }
1749
+ case 'COMMIT':
1750
+ if (section !== undefined && !section.skip) {
1751
+ await this.#applyCommit(frame, summary);
1752
+ }
1753
+ break;
1754
+ case 'SEGMENT_INLINE': {
1755
+ if (
1756
+ section === undefined ||
1757
+ section.skip ||
1758
+ section.sub === undefined
1759
+ ) {
1760
+ break;
1761
+ }
1762
+ const segment = decodeRowsSegment(frame.payload);
1763
+ await this.#applySegmentOrFail(
1764
+ section,
1765
+ summary,
1766
+ (table, clearFirst, effective) =>
1767
+ applyRowsSegment(
1768
+ this.#db,
1769
+ this.#schema,
1770
+ table,
1771
+ segment,
1772
+ { clearFirst, effective },
1773
+ this.#encryption,
1774
+ ),
1775
+ section.fresh && !section.cleared,
1776
+ );
1777
+ break;
1778
+ }
1779
+ case 'SEGMENT_REF': {
1780
+ if (
1781
+ section === undefined ||
1782
+ section.skip ||
1783
+ section.sub === undefined
1784
+ ) {
1785
+ break;
1786
+ }
1787
+ // §4.2: a descriptor whose mediaType was not advertised is a
1788
+ // broken server — fail loud, never skip or guess.
1789
+ if (
1790
+ frame.mediaType === 'sqlite' &&
1791
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0
1792
+ ) {
1793
+ throw new ClientSyncError(
1794
+ 'sync.invalid_request',
1795
+ 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
1796
+ );
1797
+ }
1798
+ const bytes = await this.#downloadSegment(frame, section.sub);
1799
+ if (frame.mediaType === 'sqlite') {
1800
+ // §5.3: images are whole-table — a paged descriptor is
1801
+ // invalid, and the image is always its table's first page.
1802
+ if (
1803
+ frame.rowCursor !== undefined ||
1804
+ frame.nextRowCursor !== undefined
1805
+ ) {
1806
+ throw new ClientSyncError(
1807
+ 'sync.invalid_request',
1808
+ 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
1809
+ );
1810
+ }
1811
+ await this.#applySegmentOrFail(
1812
+ section,
1813
+ summary,
1814
+ (table, clearFirst, effective) =>
1815
+ applySqliteSegment(
1816
+ this.#db,
1817
+ this.#schema,
1818
+ table,
1819
+ bytes,
1820
+ {
1821
+ table: frame.table,
1822
+ rowCount: frame.rowCount,
1823
+ asOfCommitSeq: frame.asOfCommitSeq,
1824
+ scopeDigest: frame.scopeDigest,
1825
+ },
1826
+ { clearFirst, effective },
1827
+ ),
1828
+ section.fresh && !section.cleared,
1829
+ );
1830
+ } else {
1831
+ const segment = decodeRowsSegment(bytes);
1832
+ await this.#applySegmentOrFail(
1833
+ section,
1834
+ summary,
1835
+ (table, clearFirst, effective) =>
1836
+ applyRowsSegment(
1837
+ this.#db,
1838
+ this.#schema,
1839
+ table,
1840
+ segment,
1841
+ { clearFirst, effective },
1842
+ this.#encryption,
1843
+ ),
1844
+ section.fresh &&
1845
+ !section.cleared &&
1846
+ frame.rowCursor === undefined,
1847
+ );
1848
+ }
1849
+ break;
1850
+ }
1851
+ case 'SUB_END': {
1852
+ if (
1853
+ section !== undefined &&
1854
+ !section.skip &&
1855
+ section.sub !== undefined
1856
+ ) {
1857
+ const applied = this.#finishSection(
1858
+ section.sub,
1859
+ section.start,
1860
+ frame.nextCursor,
1861
+ frame.bootstrapState,
1862
+ summary,
1863
+ );
1864
+ if (mode === 'delta' && applied) {
1865
+ deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1866
+ }
1867
+ }
1868
+ section = undefined;
1869
+ break;
1870
+ }
1871
+ case 'ERROR':
1872
+ // §1.4 rule 5 / §1.6: the request failed; the open
1873
+ // subscription's SUB_END values are never persisted.
1874
+ errorFrame = new ClientSyncError(
1875
+ frame.code,
1876
+ frame.message,
1877
+ frame.retryable,
1878
+ );
1879
+ section = undefined;
1880
+ break;
1881
+ case 'UNKNOWN':
1882
+ break; // §1.2 rule 2: skipped, never interpreted
1883
+ }
1884
+ if (errorFrame !== undefined) break;
1885
+ }
1886
+ } finally {
1887
+ // §7.1: local reads see outbox state applied optimistically — replay
1888
+ // the still-pending commits on top of the freshly applied server
1889
+ // state (the simple reconciliation mandated for B3).
1890
+ this.#replayOutbox();
1891
+ // §5.9.7 B1: after every apply/replay, refcounts follow the live rows.
1892
+ // A benign apply retains zero-ref bodies (LRU default); the revocation
1893
+ // purge below deletes orphaned bodies with deleteOrphans (B2).
1894
+ this.#reconcileBlobs(false);
1895
+ }
1896
+ });
1897
+
1898
+ if (errorFrame !== undefined) throw errorFrame;
1899
+
1900
+ if (mode === 'delta') {
1901
+ if (deltaCursor >= 0) this.#sendAck(deltaCursor);
1902
+ } else {
1903
+ this.#ackAfterPull();
1904
+ }
1905
+
1906
+ const bootstrapping = loadSubscriptions(this.#db)
1907
+ .filter(
1908
+ (sub) => sub.status === 'active' && sub.bootstrapState !== undefined,
1909
+ )
1910
+ .map((sub) => sub.id);
1911
+ // §7.4.5: the reset is over once the first post-reset pull round leaves
1912
+ // no subscription mid-bootstrap — the tables are rebuilt and current.
1913
+ if (this.#upgrading && mode === 'pull' && bootstrapping.length === 0) {
1914
+ this.#setUpgrading(false);
321
1915
  }
1916
+ return { ...summary, bootstrapping };
322
1917
  }
323
1918
 
324
- async #startRealtimeForLifecycle(): Promise<void> {
1919
+ #handlePushResult(
1920
+ frame: PushResultFrame,
1921
+ commitsById: ReadonlyMap<string, OutboxCommit>,
1922
+ summary: MutableSummary,
1923
+ ): void {
1924
+ const commit = commitsById.get(frame.clientCommitId);
1925
+ if (commit === undefined) return;
1926
+ if (frame.status === 'applied' || frame.status === 'cached') {
1927
+ // §6.3: applied and cached both drain the outbox — cached means
1928
+ // "already applied, you may have missed the ack".
1929
+ deleteOutboxCommit(this.#db, frame.clientCommitId);
1930
+ summary.applied.push(frame.clientCommitId);
1931
+ return;
1932
+ }
1933
+ // rejected
1934
+ const cacheMiss = frame.results.some(
1935
+ (result) =>
1936
+ result.status === 'error' &&
1937
+ result.code === 'sync.idempotency_cache_miss' &&
1938
+ result.retryable,
1939
+ );
1940
+ if (cacheMiss) {
1941
+ // §6.3: a serving failure, not the commit's outcome — keep the
1942
+ // commit queued and retry the identical push later.
1943
+ summary.retryable.push(frame.clientCommitId);
1944
+ return;
1945
+ }
1946
+ for (const result of frame.results) {
1947
+ const operation = commit.operations[result.opIndex];
1948
+ if (result.status === 'conflict') {
1949
+ const conflict: ConflictRecord = {
1950
+ clientCommitId: frame.clientCommitId,
1951
+ opIndex: result.opIndex,
1952
+ table: operation?.table ?? '',
1953
+ rowId: operation?.rowId ?? '',
1954
+ code: result.code,
1955
+ message: result.message,
1956
+ serverVersion: result.serverVersion,
1957
+ serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
1958
+ ...(operation !== undefined ? { operation } : {}),
1959
+ };
1960
+ this.#conflicts.push(conflict);
1961
+ summary.conflicts.push(conflict);
1962
+ this.#config.onConflict?.(conflict);
1963
+ } else if (result.status === 'error') {
1964
+ this.#rejections.push({
1965
+ clientCommitId: frame.clientCommitId,
1966
+ opIndex: result.opIndex,
1967
+ code: result.code,
1968
+ message: result.message,
1969
+ retryable: result.retryable,
1970
+ ...(operation !== undefined ? { operation } : {}),
1971
+ });
1972
+ }
1973
+ }
1974
+ // §7.2: stop optimistic display and decide about dependents — the
1975
+ // commit leaves the outbox; rows it created that the server never
1976
+ // confirmed are undone here, rows it overwrote reconcile via the pull
1977
+ // half (the conflict record carries the server row for the app).
1978
+ this.#db.transaction(() => {
1979
+ deleteOutboxCommit(this.#db, frame.clientCommitId);
1980
+ for (const operation of commit.operations) {
1981
+ if (operation.op !== 'upsert') continue;
1982
+ const table = this.#schema.tables.get(operation.table);
1983
+ if (table === undefined) continue;
1984
+ const row = this.#db.query(
1985
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
1986
+ [operation.rowId],
1987
+ )[0];
1988
+ if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1989
+ deleteLocalRow(this.#db, table, operation.rowId);
1990
+ }
1991
+ }
1992
+ });
1993
+ summary.rejected.push(frame.clientCommitId);
1994
+ }
1995
+
1996
+ #decodeServerRow(
1997
+ tableName: string | undefined,
1998
+ payload: Uint8Array,
1999
+ ): Record<string, RowValue> {
2000
+ if (tableName === undefined) return {};
2001
+ const table = this.#schema.tables.get(tableName);
2002
+ if (table === undefined) return {};
2003
+ const values = decodeRow(table.columns, payload);
2004
+ const record: Record<string, RowValue> = {};
2005
+ table.columns.forEach((column, index) => {
2006
+ record[column.name] = values[index] ?? null;
2007
+ });
2008
+ return record;
2009
+ }
2010
+
2011
+ async #applyCommit(
2012
+ frame: CommitFrame,
2013
+ summary: MutableSummary,
2014
+ ): Promise<void> {
2015
+ await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption);
2016
+ summary.commitsApplied += 1;
2017
+ // I1/I2: record touched tables and precise `prefix:value` scope keys —
2018
+ // COMMIT changes carry per-row stored scopes (§4.5), the finest honest
2019
+ // invalidation the wire provides.
2020
+ const batch = this.#batch;
2021
+ if (batch === undefined) return;
2022
+ for (const change of frame.changes) {
2023
+ const tableName = frame.tables[change.tableIndex];
2024
+ if (tableName === undefined) continue;
2025
+ const table = this.#schema.tables.get(tableName);
2026
+ if (table === undefined) continue;
2027
+ batch.table(tableName);
2028
+ batch.changeScopes(table, change.scopes);
2029
+ }
2030
+ }
2031
+
2032
+ /**
2033
+ * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
2034
+ * (no local scope-column mapping) marks the subscription `failed` and
2035
+ * stops syncing the table without failing the whole request.
2036
+ */
2037
+ async #applySegmentOrFail(
2038
+ section: OpenSection,
2039
+ summary: MutableSummary,
2040
+ apply: (
2041
+ table: CompiledClientTable,
2042
+ clearFirst: boolean,
2043
+ effective: ScopeMap,
2044
+ ) => number | Promise<number>,
2045
+ clearFirst: boolean,
2046
+ ): Promise<void> {
2047
+ const sub = section.sub;
2048
+ if (sub === undefined) return;
2049
+ const table = this.#table(sub.table);
325
2050
  try {
326
- await this.client.startRealtime(this.options.realtime);
327
- this.#realtimeStarted = true;
2051
+ summary.segmentRowsApplied += await apply(
2052
+ table,
2053
+ clearFirst,
2054
+ section.start.effectiveScopes,
2055
+ );
2056
+ section.cleared = true;
2057
+ // I1/I2: segments carry only a table + scopeDigest, never per-row
2058
+ // scope keys — invalidate the table plus the subscription's effective
2059
+ // scope keys (the coarsest honest key for bulk data).
2060
+ this.#batch?.table(table.name);
2061
+ this.#batch?.scopeMap(table, section.start.effectiveScopes);
328
2062
  } catch (error) {
329
- this.#realtimeStarted = false;
330
- if (!isSyncularOfflineError(error)) throw error;
2063
+ if (
2064
+ error instanceof ClientSyncError &&
2065
+ error.code === 'sync.scope_revoked'
2066
+ ) {
2067
+ saveSubscription(this.#db, {
2068
+ id: sub.id,
2069
+ table: sub.table,
2070
+ scopes: sub.scopes,
2071
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2072
+ cursor: sub.cursor,
2073
+ ...(sub.effectiveScopes !== undefined
2074
+ ? { effectiveScopes: sub.effectiveScopes }
2075
+ : {}),
2076
+ status: 'failed',
2077
+ reasonCode: 'sync.scope_revoked',
2078
+ });
2079
+ summary.failed.push(sub.id);
2080
+ section.skip = true;
2081
+ return;
2082
+ }
2083
+ throw error;
331
2084
  }
332
2085
  }
333
2086
 
334
- #isOnline(): boolean {
335
- return this.#network?.isOnline() !== false;
2087
+ async #downloadSegment(
2088
+ frame: SegmentRefFrame,
2089
+ sub: SubscriptionRecord,
2090
+ ): Promise<Uint8Array> {
2091
+ const downloader = this.#config.segments;
2092
+ if (downloader === undefined) {
2093
+ throw new ClientSyncError(
2094
+ 'sync.invalid_request',
2095
+ 'received SEGMENT_REF but no segment downloader is configured',
2096
+ );
2097
+ }
2098
+ let bytes: Uint8Array;
2099
+ if (frame.url !== undefined) {
2100
+ // §5.4: a url-carrying descriptor MUST be fetched from that URL —
2101
+ // no fall-through to the direct endpoint; any failure invalidates
2102
+ // the descriptor and re-pulling recovers (§1.4 rule 5 keeps the
2103
+ // cursor/resume token unpersisted).
2104
+ const fetchUrl = downloader.fetchUrl;
2105
+ if (
2106
+ fetchUrl === undefined ||
2107
+ (this.#acceptMask() & ACCEPT_SIGNED_URLS) === 0
2108
+ ) {
2109
+ throw new ClientSyncError(
2110
+ 'sync.invalid_request',
2111
+ 'SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)',
2112
+ );
2113
+ }
2114
+ if (
2115
+ frame.urlExpiresAtMs !== undefined &&
2116
+ frame.urlExpiresAtMs <= this.#now()
2117
+ ) {
2118
+ // §5.4: MUST NOT start a fetch at/past expiry.
2119
+ throw new ClientSyncError(
2120
+ 'sync.segment_expired',
2121
+ `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`,
2122
+ true,
2123
+ );
2124
+ }
2125
+ bytes = await fetchUrl(frame.url);
2126
+ } else {
2127
+ bytes = await downloader({
2128
+ segmentId: frame.segmentId,
2129
+ table: frame.table,
2130
+ requestedScopesJson: canonicalScopeJson(sub.scopes),
2131
+ });
2132
+ }
2133
+ // §5.1: verify the content address before applying; on mismatch the
2134
+ // segment is discarded and the cursor/resume token stay unpersisted,
2135
+ // so the next pull re-delivers.
2136
+ const hash = await sha256Hex(bytes);
2137
+ if (`sha256:${hash}` !== frame.segmentId) {
2138
+ throw new ClientSyncError(
2139
+ 'sync.invalid_request',
2140
+ `segment ${frame.segmentId} failed content-address verification (§5.1)`,
2141
+ true,
2142
+ );
2143
+ }
2144
+ return bytes;
336
2145
  }
337
2146
 
338
- #startPolling(): void {
339
- const interval = this.options.pollIntervalMs;
340
- if (interval === false || interval === undefined || interval <= 0) return;
341
- this.#pollTimer = setInterval(() => {
342
- if (!this.#isOnline()) return;
343
- void this.#syncForLifecycle();
344
- }, interval);
2147
+ /** Returns whether SUB_END state was persisted (section applied). */
2148
+ #finishSection(
2149
+ sub: SubscriptionRecord,
2150
+ start: SubStartFrame,
2151
+ nextCursor: number,
2152
+ bootstrapState: string | undefined,
2153
+ summary: MutableSummary,
2154
+ ): boolean {
2155
+ if (start.status === 'active') {
2156
+ // §1.4 rule 4: durable cursor/resume state persists only at SUB_END;
2157
+ // §3.3: the effective-scope echo is persisted for the purge contract.
2158
+ // An absent bootstrapState clears any previous resume token (§4.4:
2159
+ // absent = bootstrap complete, or not bootstrapping).
2160
+ saveSubscription(this.#db, {
2161
+ id: sub.id,
2162
+ table: sub.table,
2163
+ scopes: sub.scopes,
2164
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2165
+ cursor: nextCursor,
2166
+ ...(bootstrapState !== undefined ? { bootstrapState } : {}),
2167
+ effectiveScopes: start.effectiveScopes,
2168
+ status: 'active',
2169
+ });
2170
+ return true;
2171
+ }
2172
+
2173
+ if (start.status === 'reset') {
2174
+ // §4.6: discard cursor + resume token, keep local rows, re-bootstrap
2175
+ // with cursor = -1 on the next pull. Staleness, not a purge.
2176
+ saveSubscription(this.#db, {
2177
+ id: sub.id,
2178
+ table: sub.table,
2179
+ scopes: sub.scopes,
2180
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2181
+ cursor: -1,
2182
+ ...(sub.effectiveScopes !== undefined
2183
+ ? { effectiveScopes: sub.effectiveScopes }
2184
+ : {}),
2185
+ status: 'active',
2186
+ reasonCode: start.reasonCode,
2187
+ });
2188
+ summary.resets.push(sub.id);
2189
+ return false;
2190
+ }
2191
+
2192
+ // revoked (§3.3): purge rows matching the LAST-echoed effective scopes
2193
+ // (never the requested map), drop doomed outbox commits, stop pulling.
2194
+ const table = this.#table(sub.table);
2195
+ const lastEffective = sub.effectiveScopes;
2196
+ let failed = false;
2197
+ if (lastEffective !== undefined && Object.keys(lastEffective).length > 0) {
2198
+ try {
2199
+ this.#db.transaction(() => {
2200
+ deleteScopedRows(this.#db, table, lastEffective);
2201
+ });
2202
+ // I1: the §3.3 purge is a bulk delete — invalidate the table + the
2203
+ // purged effective scope keys so live queries over them re-run.
2204
+ this.#batch?.table(table.name);
2205
+ this.#batch?.scopeMap(table, lastEffective);
2206
+ dropOutboxCommitsInScope(this.#db, table, lastEffective);
2207
+ // §5.9.7 B2: revocation deletes now-unauthorized blob bodies —
2208
+ // reconcile with deleteOrphans (evicted ≠ revoked).
2209
+ this.#reconcileBlobs(true);
2210
+ } catch (error) {
2211
+ if (
2212
+ error instanceof ClientSyncError &&
2213
+ error.code === 'sync.scope_revoked'
2214
+ ) {
2215
+ // Fail closed: no local mapping — surface a fatal configuration
2216
+ // error and stop syncing the table without clearing anything.
2217
+ failed = true;
2218
+ } else {
2219
+ throw error;
2220
+ }
2221
+ }
2222
+ }
2223
+ saveSubscription(this.#db, {
2224
+ id: sub.id,
2225
+ table: sub.table,
2226
+ scopes: sub.scopes,
2227
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2228
+ cursor: nextCursor,
2229
+ ...(lastEffective !== undefined
2230
+ ? { effectiveScopes: lastEffective }
2231
+ : {}),
2232
+ status: failed ? 'failed' : 'revoked',
2233
+ reasonCode: start.reasonCode,
2234
+ });
2235
+ summary.revoked.push(sub.id);
2236
+ if (failed) summary.failed.push(sub.id);
2237
+ return false;
345
2238
  }
346
2239
 
347
- #stopPolling(): void {
348
- if (!this.#pollTimer) return;
349
- clearInterval(this.#pollTimer);
350
- this.#pollTimer = undefined;
2240
+ // -- optimistic state ----------------------------------------------------------
2241
+
2242
+ #applyOperationsLocally(
2243
+ operations: readonly OutboxOperation[],
2244
+ batch?: Invalidation,
2245
+ ): void {
2246
+ for (const op of operations) {
2247
+ const table = this.#table(op.table);
2248
+ batch?.table(op.table);
2249
+ if (op.op === 'delete') {
2250
+ deleteLocalRow(this.#db, table, op.rowId);
2251
+ continue;
2252
+ }
2253
+ const values = table.columns.map((column) => {
2254
+ const value = op.values?.[column.name];
2255
+ return value === undefined ? null : jsonToRowValue(value);
2256
+ });
2257
+ // Record the row's scope keys from its scope columns (I2 refinement).
2258
+ if (batch !== undefined) {
2259
+ for (const [variable, column] of table.scopeColumnByVariable) {
2260
+ const idx = table.columnIndex.get(column);
2261
+ const cell = idx === undefined ? undefined : values[idx];
2262
+ const prefix = table.scopePrefixByVariable.get(variable);
2263
+ if (prefix !== undefined && cell != null) {
2264
+ batch.scopeKey(`${prefix}:${String(cell)}`);
2265
+ }
2266
+ }
2267
+ }
2268
+ const existing = this.#db.query(
2269
+ `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2270
+ [op.rowId],
2271
+ )[0];
2272
+ const version =
2273
+ existing === undefined ? OPTIMISTIC_VERSION : (existing.v as number);
2274
+ upsertLocalRow(this.#db, table, values, version);
2275
+ }
2276
+ }
2277
+
2278
+ /** Re-apply every pending outbox commit on top of server state (§7.1). */
2279
+ #replayOutbox(): void {
2280
+ const pending = listOutbox(this.#db);
2281
+ if (pending.length === 0) return;
2282
+ this.#applyBatch((batch) => {
2283
+ this.#db.transaction(() => {
2284
+ for (const commit of pending) {
2285
+ this.#applyOperationsLocally(commit.operations, batch);
2286
+ }
2287
+ });
2288
+ });
2289
+ }
2290
+
2291
+ /**
2292
+ * §5.9.7 B1/B2: recompute blob-cache refcounts from live `blob_ref`
2293
+ * columns. No-op unless the schema has blob columns. `deleteOrphans`
2294
+ * triggers the revocation-side body deletion (B2).
2295
+ */
2296
+ #reconcileBlobs(deleteOrphans: boolean): void {
2297
+ if (!this.#hasBlobs) return;
2298
+ reconcileBlobRefcounts(this.#db, this.#schema, { deleteOrphans });
2299
+ }
2300
+
2301
+ // -- helpers -----------------------------------------------------------------
2302
+
2303
+ #table(name: string): CompiledClientTable {
2304
+ const table = this.#schema.tables.get(name);
2305
+ if (table === undefined) {
2306
+ throw new ClientSyncError(
2307
+ 'sync.unknown_table',
2308
+ `unknown local table ${JSON.stringify(name)}`,
2309
+ );
2310
+ }
2311
+ return table;
2312
+ }
2313
+
2314
+ #requireStarted(): void {
2315
+ if (!this.#started) {
2316
+ throw new ClientSyncError(
2317
+ 'sync.invalid_request',
2318
+ 'SyncClient.start() has not completed',
2319
+ );
2320
+ }
351
2321
  }
352
2322
  }