@abloatai/ablo 0.3.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 (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,1336 @@
1
+ /**
2
+ * SyncWebSocket - Manages WebSocket connection to Go sync engine
3
+ *
4
+ * Handles:
5
+ * - WebSocket lifecycle (connect, reconnect, disconnect)
6
+ * - Delta reception and processing
7
+ * - Multi-tab support
8
+ * - Automatic reconnection with exponential backoff
9
+ */
10
+ import { EventEmitter } from 'events';
11
+ import { getContext } from '../context.js';
12
+ import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
+ import { CapabilityError, SyncSessionError } from '../errors.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
+ // Consumers pass their own event types as TCollaboration generic parameter.
17
+ export class SyncWebSocket extends EventEmitter {
18
+ /**
19
+ * Subscribe to events with automatic cleanup.
20
+ * Returns unsubscribe function for clean disposal.
21
+ */
22
+ subscribe(event, handler) {
23
+ this.on(event, handler);
24
+ return () => this.off(event, handler);
25
+ }
26
+ /**
27
+ * Send a collaboration event (app-specific real-time message).
28
+ * The wire format is `{ type: messageType, payload: { ...payload, timestamp } }`.
29
+ */
30
+ sendCollaborationEvent(messageType, payload) {
31
+ if (this.ws?.readyState !== WebSocket.OPEN)
32
+ return;
33
+ this.send({
34
+ type: messageType.replace(/:/g, '_'), // 'sheet:selection' → 'sheet_selection' wire format
35
+ payload: { ...payload, timestamp: Date.now() },
36
+ });
37
+ }
38
+ ws = null;
39
+ options;
40
+ reconnectAttempts = 0;
41
+ /** Stop retrying after this many consecutive failures (backoff caps at 30s, so ~7.5 min total) */
42
+ static MAX_RECONNECT_ATTEMPTS = 15;
43
+ reconnectTimer = null;
44
+ /** Periodic catchup interval — polls for missed deltas every 30s while connected */
45
+ catchupInterval = null;
46
+ /**
47
+ * Application-level heartbeat. The browser WebSocket API hides RFC 6455
48
+ * protocol-level ping/pong from JavaScript, so the server's `ws.ping()`
49
+ * keepalive can't be observed by client code — meaning the client cannot
50
+ * tell a healthy idle connection apart from a "zombie" socket where TCP
51
+ * silently broke (laptop sleep, NAT timeout, mobile handoff). We send an
52
+ * application-level `{ type: 'ping' }` every 30s and force-close the
53
+ * socket if no inbound traffic arrives within 10s. ANY inbound message
54
+ * counts as proof-of-life — the explicit `pong` is just a guarantee that
55
+ * something will arrive even on an idle stream.
56
+ */
57
+ heartbeatTimer = null;
58
+ heartbeatTimeoutTimer = null;
59
+ static HEARTBEAT_INTERVAL_MS = 30_000;
60
+ static HEARTBEAT_TIMEOUT_MS = 10_000;
61
+ isConnecting = false;
62
+ isManualClose = false;
63
+ /** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
64
+ * Suppresses reconnection and Sentry error capture to avoid cascading noise. */
65
+ _sessionErrorDetected = false;
66
+ /** True once `onopen` has fired at least once on the current socket. Reset each
67
+ * time a new socket is created in `connect()`. Used by `onclose` to detect
68
+ * handshake failures (close before open) — the one signal we have for "the
69
+ * server rejected the upgrade" since browsers hide the HTTP status (e.g.
70
+ * 401) behind the opaque 1006 close code. */
71
+ _everOpened = false;
72
+ /**
73
+ * Diagnostic snapshot of the last connection lifecycle. Persisted across
74
+ * the lifetime of the SyncWebSocket so that any subsequent "not connected"
75
+ * rejection can quote the actual root cause (close code + reason + when)
76
+ * instead of bottoming out at a generic error string. Browser WS code 1006
77
+ * hides the real reason, so we layer on our own signals: `forceCloseReason`
78
+ * captures heartbeat trips / send failures, `everOpened` distinguishes
79
+ * handshake reject from mid-session drop, and `sessionErrorAt` tells us
80
+ * whether reconnect is suppressed.
81
+ */
82
+ lastOpenAt = null;
83
+ lastCloseAt = null;
84
+ lastCloseCode = null;
85
+ lastCloseReason = null;
86
+ lastForceCloseReason = null;
87
+ sessionErrorAt = null;
88
+ lastSyncId;
89
+ versionVector;
90
+ syncCursor = null;
91
+ /** Registered collaboration event keys (colon format) for dispatch in onmessage */
92
+ collaborationEventTypes;
93
+ /**
94
+ * In-flight `commit` mutation requests keyed by clientTxId. Resolved when
95
+ * a matching `mutation_result` frame arrives from the server, or rejected on
96
+ * timeout / disconnect. Lets consumers await a server ack for mutations
97
+ * sent over the same socket that streams deltas.
98
+ */
99
+ pendingMutations = new Map();
100
+ /**
101
+ * In-flight `claim` requests keyed by claimId. Resolved when the
102
+ * matching `claim_ack` arrives, or rejected on timeout/disconnect.
103
+ * Same shape as pendingMutations — Phoenix-style request/response
104
+ * over a multiplexed connection.
105
+ */
106
+ pendingClaims = new Map();
107
+ constructor(options) {
108
+ super();
109
+ // Construct WebSocket URL from base Go server URL
110
+ const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
111
+ const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
112
+ const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
113
+ this.options = {
114
+ url: wsUrl,
115
+ reconnectDelay: 1000,
116
+ maxReconnectDelay: 30000,
117
+ collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
118
+ syncGroups: [],
119
+ lastSyncId: 0,
120
+ versions: {
121
+ tasks: 0,
122
+ projects: 0,
123
+ users: 0,
124
+ events: 0,
125
+ inboxitems: 0,
126
+ teams: 0,
127
+ assignments: 0,
128
+ comments: 0,
129
+ threads: 0,
130
+ },
131
+ capabilities: {
132
+ partialBootstrap: true,
133
+ compressedDeltas: true,
134
+ streamingBootstrap: true,
135
+ batchedDeltas: true,
136
+ },
137
+ ...options,
138
+ };
139
+ this.lastSyncId = this.options.lastSyncId;
140
+ this.versionVector = { ...this.options.versions };
141
+ this.syncCursor = null;
142
+ this.collaborationEventTypes = new Set(options.collaborationEvents ?? ['sheet:selection', 'slide:selection', 'slide:cursor']);
143
+ }
144
+ /**
145
+ * Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
146
+ * Suppresses further reconnection attempts and Sentry error capture.
147
+ */
148
+ setSessionErrorDetected() {
149
+ this._sessionErrorDetected = true;
150
+ this.sessionErrorAt = Date.now();
151
+ }
152
+ /**
153
+ * Connect to the sync engine WebSocket
154
+ */
155
+ connect() {
156
+ if (this._sessionErrorDetected) {
157
+ getContext().logger.debug('WebSocket connect suppressed — session error detected');
158
+ return;
159
+ }
160
+ if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
161
+ getContext().logger.debug('WebSocket already connected or connecting');
162
+ return;
163
+ }
164
+ // Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
165
+ // handle failures. The default browser implementation reads navigator.onLine,
166
+ // which is unreliable but the only signal available; in Node it returns true
167
+ // (assume online) so the sidecar/agent path doesn't short-circuit here.
168
+ if (!getContext().onlineStatus.isOnline()) {
169
+ getContext().logger.warn('onlineStatus reports offline, but attempting connection anyway');
170
+ }
171
+ this.isConnecting = true;
172
+ this.isManualClose = false;
173
+ // Pattern: one credential, server-resolved identity. The WS URL
174
+ // carries the credential (cap-token bearer in `?authorization=`
175
+ // for the cap path; session cookie in headers for the cookie
176
+ // path). The server's AuthProvider chain (`apiKeyProvider →
177
+ // agentTokenProvider → betterAuthProvider`) resolves identity
178
+ // from the verified credential — userId/organizationId are
179
+ // NEVER read from URL params in production. See
180
+ // `apps/sync-server/src/auth/provider.ts:148` (betterAuthProvider
181
+ // calls `auth.api.getSession({headers})`) and `agentTokenProvider`
182
+ // for the cap-token path.
183
+ const params = new URLSearchParams({
184
+ // Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
185
+ // and ack messages to avoid stale baselines on reconnect.
186
+ cursor: this.syncCursor || '',
187
+ });
188
+ // Participant kind — defaults to `user` for backward compatibility
189
+ // with web sessions. Agent runtimes pass `'agent'` so the server's
190
+ // capability-token path activates instead of session auth.
191
+ if (this.options.kind && this.options.kind !== 'user') {
192
+ params.set('kind', this.options.kind);
193
+ }
194
+ // Capability bearer (query-param form so it works in both Node's
195
+ // global WebSocket — which can't set headers — and browsers).
196
+ if (this.options.capabilityToken) {
197
+ params.set('authorization', `Bearer ${this.options.capabilityToken}`);
198
+ }
199
+ // Add sync groups if provided
200
+ this.options.syncGroups.forEach((group) => {
201
+ params.append('syncGroups', group);
202
+ });
203
+ const wsUrl = `${this.options.url}?${params.toString()}`;
204
+ try {
205
+ // Reset the handshake flag before wiring the new socket. Each connect()
206
+ // gets its own lifecycle — a prior successful open on a previous socket
207
+ // must not mask a handshake failure on the new one.
208
+ this._everOpened = false;
209
+ this.ws = new WebSocket(wsUrl);
210
+ this.setupEventHandlers();
211
+ }
212
+ catch (error) {
213
+ // WebSocket constructor can throw if URL is invalid
214
+ const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
215
+ getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
216
+ this.isConnecting = false;
217
+ this.emit('error', new Error(errorMessage));
218
+ this.scheduleReconnect();
219
+ }
220
+ }
221
+ /**
222
+ * Setup WebSocket event handlers
223
+ */
224
+ setupEventHandlers() {
225
+ if (!this.ws)
226
+ return;
227
+ this.ws.onopen = () => {
228
+ getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
229
+ lastSyncId: this.lastSyncId,
230
+ reconnectAttempts: this.reconnectAttempts,
231
+ });
232
+ this.isConnecting = false;
233
+ this.reconnectAttempts = 0;
234
+ this._everOpened = true;
235
+ this.lastOpenAt = Date.now();
236
+ this.emit('connected');
237
+ // Send presence update with timezone (server sets presence to "online" on connect,
238
+ // this improves localTime accuracy by providing the user's actual timezone)
239
+ this.sendPresenceUpdate('online');
240
+ // Flush any queued offline mutations now that we're online
241
+ // Fire-and-forget; emit events for UI if desired in the future
242
+ (async () => {
243
+ try {
244
+ const res = await flushOfflineQueueOnce();
245
+ if (res.processed > 0) {
246
+ getContext().logger.info('Flushed offline mutations', res);
247
+ }
248
+ }
249
+ catch (e) {
250
+ getContext().observability.captureOfflineFlushFailure({
251
+ error: e instanceof Error ? e.message : String(e),
252
+ });
253
+ }
254
+ })();
255
+ // Immediately request incremental sync based on our stored cursor/versions
256
+ try {
257
+ if (this.lastSyncId && this.lastSyncId > 0) {
258
+ // Let server know where we left off before requesting deltas
259
+ this.sendAck(this.lastSyncId);
260
+ }
261
+ this.requestIncrementalSync();
262
+ }
263
+ catch (e) {
264
+ getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
265
+ error: e instanceof Error ? e.message : String(e),
266
+ });
267
+ }
268
+ // Start periodic catchup — polls for missed deltas every 30s.
269
+ // Real-time WebSocket delivery is best-effort (fire-and-forget Redis pub/sub).
270
+ // This interval guarantees eventual consistency by fetching any deltas that
271
+ // were committed to the DB but whose broadcast was lost in transit.
272
+ this.stopCatchupInterval();
273
+ this.catchupInterval = setInterval(() => {
274
+ if (this.ws?.readyState === WebSocket.OPEN) {
275
+ this.requestIncrementalSync();
276
+ }
277
+ }, 30_000);
278
+ // Start application-level heartbeat — see field declaration for rationale.
279
+ this.startHeartbeat();
280
+ };
281
+ this.ws.onmessage = (event) => {
282
+ try {
283
+ const message = JSON.parse(event.data);
284
+ // ANY inbound frame proves the socket is alive — clear the
285
+ // heartbeat-timeout timer so we don't false-trip force-close
286
+ // during normal traffic.
287
+ this.clearHeartbeatTimeout();
288
+ // Handle different message types
289
+ if (message.type === 'pong' || message.type === 'ping') {
290
+ // Ignore keepalive messages
291
+ getContext().logger.debug('Received keepalive', { type: message.type });
292
+ return;
293
+ }
294
+ // Handle different message types
295
+ switch (message.type) {
296
+ case 'sync_response':
297
+ this.handleSyncResponse(message.payload);
298
+ break;
299
+ case 'bootstrap_response':
300
+ this.handleBootstrapResponse(message.payload);
301
+ break;
302
+ case 'presence_update':
303
+ this.handlePresenceUpdate(message);
304
+ break;
305
+ case 'mutation_result': {
306
+ // Ack for a prior `commit` we sent. Wire format (mirrors
307
+ // apps/sync-server/src/hub/types.ts MutationResultMessage):
308
+ // { type: 'mutation_result',
309
+ // payload: { clientTxId, serverTxId, success,
310
+ // lastSyncId?, error? } }
311
+ const p = message.payload ?? message;
312
+ const { clientTxId, success, lastSyncId, error } = p ?? {};
313
+ const pending = typeof clientTxId === 'string'
314
+ ? this.pendingMutations.get(clientTxId)
315
+ : undefined;
316
+ if (!pending)
317
+ break;
318
+ clearTimeout(pending.timeout);
319
+ this.pendingMutations.delete(clientTxId);
320
+ if (success) {
321
+ pending.resolve({
322
+ lastSyncId: typeof lastSyncId === 'number' ? lastSyncId : 0,
323
+ });
324
+ }
325
+ else {
326
+ // Capture the FULL server error so the user can see what
327
+ // actually rejected the mutation. Without this, every
328
+ // rejection becomes the generic "mutation failed on
329
+ // server" — useless when debugging chart batches that
330
+ // tank 40+ ops at once. We stringify object errors so
331
+ // structured server payloads (e.g., Zod issues, schema
332
+ // violations) survive the trip through `new Error(...)`.
333
+ let errorMessage;
334
+ let errorCode;
335
+ let requiredCapability;
336
+ if (typeof error === 'string') {
337
+ errorMessage = error;
338
+ }
339
+ else if (error != null && typeof error === 'object') {
340
+ const obj = error;
341
+ if (typeof obj.code === 'string')
342
+ errorCode = obj.code;
343
+ if (typeof obj.message === 'string') {
344
+ errorMessage = obj.message;
345
+ }
346
+ else {
347
+ try {
348
+ errorMessage = JSON.stringify(error);
349
+ }
350
+ catch {
351
+ errorMessage = String(error);
352
+ }
353
+ }
354
+ if (obj.requiredCapability != null &&
355
+ typeof obj.requiredCapability === 'object' &&
356
+ typeof obj.requiredCapability.scope === 'string') {
357
+ requiredCapability = obj.requiredCapability;
358
+ }
359
+ }
360
+ else {
361
+ errorMessage = 'mutation failed on server';
362
+ }
363
+ // Capability denials route through the typed CapabilityError
364
+ // so callers can `instanceof CapabilityError` and read
365
+ // `.requiredCapability` to attenuate-and-retry without
366
+ // string-matching the error code.
367
+ if (errorCode === 'capability_scope_denied' ||
368
+ errorCode === 'capability_invalid') {
369
+ pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
370
+ }
371
+ else {
372
+ const rejection = new Error(errorMessage);
373
+ if (errorCode)
374
+ rejection.code = errorCode;
375
+ pending.reject(rejection);
376
+ }
377
+ }
378
+ break;
379
+ }
380
+ case 'claim_ack': {
381
+ // Ack for a prior `claim` we sent. Wire format mirrors
382
+ // apps/sync-server/src/hub/types.ts ClaimAckMessage:
383
+ // { type: 'claim_ack',
384
+ // payload: { claimId, success, syncGroups?,
385
+ // ttlSeconds?, error? } }
386
+ const p = message.payload ?? {};
387
+ const { claimId, success, syncGroups, ttlSeconds, error } = p;
388
+ const pending = typeof claimId === 'string'
389
+ ? this.pendingClaims.get(claimId)
390
+ : undefined;
391
+ if (!pending)
392
+ break;
393
+ clearTimeout(pending.timeout);
394
+ this.pendingClaims.delete(claimId);
395
+ if (success) {
396
+ pending.resolve({
397
+ syncGroups: Array.isArray(syncGroups) ? syncGroups : [],
398
+ ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
399
+ });
400
+ }
401
+ else {
402
+ const code = error?.code && typeof error.code === 'string'
403
+ ? error.code
404
+ : 'claim_rejected';
405
+ const msg = error?.message && typeof error.message === 'string'
406
+ ? error.message
407
+ : 'claim rejected by server';
408
+ // Capability denials get the typed CapabilityError so
409
+ // callers can read `.requiredCapability` and attenuate-
410
+ // and-retry the claim with a narrower token.
411
+ if (code === 'capability_scope_denied' ||
412
+ code === 'capability_invalid') {
413
+ const rc = error
414
+ ?.requiredCapability;
415
+ const requiredCapability = rc != null &&
416
+ typeof rc === 'object' &&
417
+ typeof rc.scope === 'string'
418
+ ? rc
419
+ : undefined;
420
+ pending.reject(new CapabilityError(code, msg, requiredCapability));
421
+ }
422
+ else {
423
+ // Attach `code` as a property on the rejection so callers
424
+ // can discriminate (`scope_conflict`, `malformed_claim`,
425
+ // ...) without parsing the message.
426
+ const rejection = Object.assign(new Error(`${code}: ${msg}`), { code });
427
+ pending.reject(rejection);
428
+ }
429
+ }
430
+ break;
431
+ }
432
+ case 'claim_expired': {
433
+ // Server-initiated expiry notification. Emit as a typed
434
+ // event so consumers can react (re-claim with a fresh
435
+ // capability, or accept the drop). The claim is already
436
+ // inactive server-side by the time this arrives.
437
+ const p = message.payload ?? {};
438
+ if (typeof p.claimId === 'string') {
439
+ this.emit('claim_expired', { claimId: p.claimId });
440
+ }
441
+ break;
442
+ }
443
+ case 'intent_rejected': {
444
+ // Server denied an `intent_begin` because the target is
445
+ // already claimed by another participant. Forward the
446
+ // payload as-is — the IntentStream consumer interprets
447
+ // the conflict shape (peerId, target, etc.).
448
+ this.emit('intent_rejected', message.payload ?? {});
449
+ break;
450
+ }
451
+ case 'delta': {
452
+ const p = message.payload;
453
+ if (p?.actionType || p?.modelName) {
454
+ this.handleDelta(p);
455
+ }
456
+ else if (Array.isArray(p?.deltas)) {
457
+ for (const d of p.deltas) {
458
+ if (d?.actionType || d?.modelName)
459
+ this.handleDelta(d);
460
+ }
461
+ if (p?.newVersions) {
462
+ Object.assign(this.versionVector, p.newVersions);
463
+ }
464
+ }
465
+ break;
466
+ }
467
+ case undefined: // Legacy support: bare delta
468
+ if (message.actionType || message.modelName) {
469
+ this.handleDelta(message);
470
+ }
471
+ break;
472
+ default: {
473
+ // Collaboration events use underscore wire format (e.g., 'sheet_selection')
474
+ // Convert to colon format for the event map (e.g., 'sheet:selection')
475
+ const eventKey = message.type?.replace(/_/g, ':');
476
+ if (eventKey && this.collaborationEventTypes.has(eventKey)) {
477
+ this.emit(eventKey, message.payload);
478
+ }
479
+ else {
480
+ getContext().logger.debug('Received unknown message type', { message });
481
+ }
482
+ }
483
+ }
484
+ }
485
+ catch (error) {
486
+ getContext().observability.captureWebSocketError({
487
+ context: 'parse-message',
488
+ error: error instanceof Error ? error.message : String(error),
489
+ });
490
+ }
491
+ };
492
+ this.ws.onerror = (_event) => {
493
+ // WebSocket errors are DOM Events, not Error objects
494
+ // Check if we're offline first
495
+ if (!getContext().onlineStatus.isOnline()) {
496
+ getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
497
+ this.emit('error', new Error('Network is offline'));
498
+ return;
499
+ }
500
+ // After session error, suppress Sentry capture — the root cause is already reported.
501
+ // Still emit so SyncedStore can update UI state.
502
+ const error = new Error(`WebSocket connection failed`);
503
+ if (!this._sessionErrorDetected) {
504
+ getContext().observability.captureWebSocketError({
505
+ context: 'connection-error',
506
+ error: error.message,
507
+ });
508
+ }
509
+ this.emit('error', error);
510
+ };
511
+ this.ws.onclose = (event) => {
512
+ const everOpened = this._everOpened;
513
+ this.lastCloseAt = Date.now();
514
+ this.lastCloseCode = event.code;
515
+ this.lastCloseReason = event.reason || null;
516
+ getContext().logger.info('WebSocket closed', {
517
+ code: event.code,
518
+ reason: event.reason,
519
+ everOpened,
520
+ reconnectAttempts: this.reconnectAttempts,
521
+ forceCloseReason: this.lastForceCloseReason,
522
+ msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
523
+ isManualClose: this.isManualClose,
524
+ });
525
+ this.isConnecting = false;
526
+ this.ws = null;
527
+ this.stopCatchupInterval();
528
+ this.stopHeartbeat();
529
+ // Cancel in-flight mutations — the socket that was carrying them is
530
+ // gone, and the server-side state may or may not have accepted each
531
+ // one. Rejecting promptly is better than hanging the caller forever;
532
+ // higher-level retry belongs to TransactionQueue, not here.
533
+ if (this.pendingMutations.size > 0) {
534
+ for (const pending of this.pendingMutations.values()) {
535
+ clearTimeout(pending.timeout);
536
+ pending.reject(Object.assign(new Error(`WebSocket closed while commit was in flight (code=${event.code}` +
537
+ (event.reason ? ` reason=${event.reason}` : '') +
538
+ (this.lastForceCloseReason
539
+ ? ` forceCloseReason=${this.lastForceCloseReason}`
540
+ : '') +
541
+ ')'), { diagnostics: this.getConnectionDiagnostics() }));
542
+ }
543
+ this.pendingMutations.clear();
544
+ }
545
+ // Cancel in-flight claims — same rationale. Server-side
546
+ // claims are bound to the connection; a reconnect will need
547
+ // to re-claim. Higher-level retry belongs to whoever holds
548
+ // the participant handle (typically the SDK's claim manager).
549
+ if (this.pendingClaims.size > 0) {
550
+ for (const pending of this.pendingClaims.values()) {
551
+ clearTimeout(pending.timeout);
552
+ pending.reject(new Error(`WebSocket closed while claim was in flight (code=${event.code})`));
553
+ }
554
+ this.pendingClaims.clear();
555
+ }
556
+ // Check for session-related close codes
557
+ // 1008 = Policy Violation (often auth)
558
+ // 4001 = Unauthorized (custom)
559
+ // 4003 = Forbidden (custom)
560
+ const isSessionClose = event.code === 1008 ||
561
+ event.code === 4001 ||
562
+ event.code === 4003 ||
563
+ SyncSessionError.isSessionError(event.reason || '');
564
+ if (isSessionClose) {
565
+ this._sessionErrorDetected = true;
566
+ this.sessionErrorAt = Date.now();
567
+ getContext().observability.captureWebSocketError({
568
+ context: 'session-error-close',
569
+ code: event.code,
570
+ reason: event.reason,
571
+ });
572
+ this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
573
+ // Don't reconnect for session errors - user needs to re-authenticate
574
+ this.emit('disconnected', event);
575
+ return;
576
+ }
577
+ // Handshake failure: `onclose` fired before `onopen` ever did, so the
578
+ // server rejected the upgrade (typically 401/403 on a bad cookie, but
579
+ // could also be a CORS/origin reject or an LB 5xx). The browser hides
580
+ // the HTTP status behind code 1006, so we can't tell which from here.
581
+ //
582
+ // Emit a dedicated event and SKIP the internal reconnect — the owner
583
+ // (SyncedStore / ConnectionStore) should run an auth-validating HTTP
584
+ // probe to distinguish session expiry from a transient network issue
585
+ // and transition the UI accordingly. Reconnecting blindly is what
586
+ // produced the infinite "offline → reconnecting → offline" loop on
587
+ // stale cookies.
588
+ if (!everOpened && !this.isManualClose) {
589
+ getContext().observability.captureWebSocketError({
590
+ context: 'handshake-failed-close',
591
+ code: event.code,
592
+ reason: event.reason,
593
+ });
594
+ this.emit('handshake_failed', event);
595
+ this.emit('disconnected', event);
596
+ return;
597
+ }
598
+ this.emit('disconnected', event);
599
+ // Reconnect if not manually closed
600
+ if (!this.isManualClose) {
601
+ this.scheduleReconnect();
602
+ }
603
+ };
604
+ }
605
+ /**
606
+ * Handle incoming sync delta
607
+ */
608
+ handleDelta(delta) {
609
+ getContext().logger.debug('Received delta', {
610
+ action: delta.actionType,
611
+ model: delta.modelName,
612
+ id: delta.modelId,
613
+ syncId: delta.id,
614
+ });
615
+ // DO NOT advance `this.lastSyncId` on receipt. The runtime cursor
616
+ // must stay consistent with what's persisted in IDB — otherwise the
617
+ // next `requestIncrementalSync()` (and the connect-time handshake)
618
+ // sends an optimistic cursor and the server skips deltas that never
619
+ // landed in IDB. `this.lastSyncId` is advanced only in `sendAck()`,
620
+ // which is gated on `BaseSyncedStore.flushPendingDeltas`'s
621
+ // `persistedSyncId` watermark. See Replicache's "lastMutationID
622
+ // read in the same transaction as the client view" rule.
623
+ //
624
+ // Version vector is also intentionally NOT updated here for the
625
+ // same reason — left to the persistence-gated path.
626
+ // Emit delta for processing. Ack will be sent by SyncedStore after persistence.
627
+ this.emit('delta', delta);
628
+ }
629
+ /**
630
+ * Send acknowledgment for received delta with version vector.
631
+ *
632
+ * This is the SOLE forward-mover of `this.lastSyncId` for live
633
+ * deltas. Called by `BaseSyncedStore.flushPendingDeltas` with the
634
+ * `persistedSyncId` watermark — i.e. only after the deltas have
635
+ * actually committed to IDB. Keeping the cursor advance here (rather
636
+ * than at receipt in `handleDelta`/`handleSyncResponse`) means the
637
+ * cursor never gets ahead of the persisted view, so reconnect/
638
+ * catch-up requests can't accidentally skip un-persisted deltas.
639
+ */
640
+ sendAck(syncId) {
641
+ // Advance the local cursor *and* the version vector for this ack —
642
+ // these are what `requestIncrementalSync` and the connect handshake
643
+ // will send next, and what `getLastSyncId()` reports for clean-
644
+ // shutdown persistence.
645
+ if (syncId > this.lastSyncId) {
646
+ this.lastSyncId = syncId;
647
+ }
648
+ if (this.ws?.readyState !== WebSocket.OPEN)
649
+ return;
650
+ this.send({
651
+ type: 'ack',
652
+ payload: {
653
+ lastSyncId: syncId,
654
+ versions: this.versionVector,
655
+ },
656
+ });
657
+ }
658
+ /**
659
+ * Public wrapper for sending ack from outside the class
660
+ */
661
+ acknowledge(syncId) {
662
+ this.sendAck(syncId);
663
+ }
664
+ /**
665
+ * Send message to server
666
+ */
667
+ send(message) {
668
+ if (this.ws?.readyState !== WebSocket.OPEN) {
669
+ // Only log at debug level when offline - this is expected behavior, not an error
670
+ if (getContext().onlineStatus.isOnline()) {
671
+ getContext().observability.breadcrumb('WebSocket not connected, cannot send message', 'sync.websocket', 'warning');
672
+ }
673
+ else {
674
+ getContext().logger.debug('WebSocket send skipped - offline');
675
+ }
676
+ return;
677
+ }
678
+ try {
679
+ this.ws.send(JSON.stringify(message));
680
+ }
681
+ catch (error) {
682
+ // Only log as error if we're online - offline send failures are expected
683
+ if (getContext().onlineStatus.isOnline()) {
684
+ getContext().observability.captureWebSocketError({
685
+ context: 'send-message',
686
+ error: error instanceof Error ? error.message : String(error),
687
+ });
688
+ }
689
+ else {
690
+ getContext().logger.debug('WebSocket send failed - offline');
691
+ }
692
+ }
693
+ }
694
+ /**
695
+ * Send a `commit` mutation request over the existing WebSocket and
696
+ * resolve when the server's `mutation_result` frame comes back with
697
+ * the same `clientTxId`. The wire-level frame is `{ type: 'commit',
698
+ * payload: { operations, clientTxId } }` — matching the
699
+ * `handleCommit` path on `apps/sync-server/src/hub/Hub.ts` (see the
700
+ * dispatch at Hub.ts:737).
701
+ *
702
+ * Historical naming note: this was originally `sendBatchAck` back when
703
+ * the Go sync-engine used a GraphQL `batchAck` mutation. The TS
704
+ * sync-server uses `type: 'commit'` over WebSocket exclusively. The
705
+ * method name now matches the wire protocol so the ack/commit naming
706
+ * confusion stops here.
707
+ *
708
+ * Times out after 15s of silence from the server. The socket may close
709
+ * during an in-flight mutation (network flap, server restart); we do
710
+ * NOT auto-retry here — the caller's TransactionQueue owns retry +
711
+ * offline replay semantics and the SDK shouldn't duplicate that logic.
712
+ */
713
+ sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId) {
714
+ if (this.ws?.readyState !== WebSocket.OPEN) {
715
+ return Promise.reject(this.notConnectedError('commit'));
716
+ }
717
+ return new Promise((resolve, reject) => {
718
+ const timeout = setTimeout(() => {
719
+ this.pendingMutations.delete(clientTxId);
720
+ reject(new Error(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`));
721
+ }, timeoutMs);
722
+ this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
723
+ try {
724
+ // `causedByTaskId` is included only when the agent SDK has
725
+ // an open turn — keeps the wire shape stable for sessions
726
+ // that don't use turns. Servers that don't know the field
727
+ // ignore it; newer servers stamp it onto every delta.
728
+ const payload = { operations, clientTxId };
729
+ if (causedByTaskId)
730
+ payload.causedByTaskId = causedByTaskId;
731
+ this.ws.send(JSON.stringify({ type: 'commit', payload }));
732
+ }
733
+ catch (error) {
734
+ clearTimeout(timeout);
735
+ this.pendingMutations.delete(clientTxId);
736
+ reject(error instanceof Error
737
+ ? error
738
+ : new Error(String(error)));
739
+ }
740
+ });
741
+ }
742
+ /**
743
+ * Send a commit frame without waiting for `mutation_result`.
744
+ *
745
+ * This backs the public `wait: 'queued'` API: the socket accepted the
746
+ * frame for delivery, but the server has not confirmed it yet. The
747
+ * eventual `mutation_result` frame is intentionally ignored by this
748
+ * instance because no pending resolver is registered.
749
+ */
750
+ sendCommitQueued(operations, clientTxId, causedByTaskId) {
751
+ if (this.ws?.readyState !== WebSocket.OPEN) {
752
+ throw this.notConnectedError('commit');
753
+ }
754
+ const payload = { operations, clientTxId };
755
+ if (causedByTaskId)
756
+ payload.causedByTaskId = causedByTaskId;
757
+ this.ws.send(JSON.stringify({ type: 'commit', payload }));
758
+ }
759
+ /**
760
+ * Activate a participant claim on this connection. Multiplexed
761
+ * subscription pattern (Phoenix Channels / Pusher) — the same
762
+ * connection can hold N concurrent claims, each scoped to a
763
+ * different set of sync groups.
764
+ *
765
+ * Returns a promise that resolves with the server-canonicalized
766
+ * `syncGroups` and effective `ttlSeconds` once `claim_ack` arrives,
767
+ * or rejects with a typed error on `success: false` ack /
768
+ * timeout / disconnect.
769
+ *
770
+ * Why this exists: the old scoped-participant path opened a separate
771
+ * WS per scope. With claims, the SDK reuses the existing session/agent
772
+ * connection — one TCP, N logical participants. See
773
+ * `apps/sync-server/docs/PARTICIPANT_CLAIMS.md` for the migration
774
+ * framing (Phase A.1).
775
+ */
776
+ sendClaim(claimId, syncGroups, options) {
777
+ if (this.ws?.readyState !== WebSocket.OPEN) {
778
+ return Promise.reject(this.notConnectedError('claim'));
779
+ }
780
+ const timeoutMs = options?.timeoutMs ?? 15_000;
781
+ return new Promise((resolve, reject) => {
782
+ const timeout = setTimeout(() => {
783
+ this.pendingClaims.delete(claimId);
784
+ reject(new Error(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`));
785
+ }, timeoutMs);
786
+ this.pendingClaims.set(claimId, { resolve, reject, timeout });
787
+ try {
788
+ this.ws.send(JSON.stringify({
789
+ type: 'claim',
790
+ payload: {
791
+ claimId,
792
+ syncGroups: [...syncGroups],
793
+ capabilityToken: options?.capabilityToken,
794
+ ttlSeconds: options?.ttlSeconds,
795
+ },
796
+ }));
797
+ }
798
+ catch (error) {
799
+ clearTimeout(timeout);
800
+ this.pendingClaims.delete(claimId);
801
+ reject(error instanceof Error ? error : new Error(String(error)));
802
+ }
803
+ });
804
+ }
805
+ /**
806
+ * Drop a previously-active claim. Idempotent — `release` is
807
+ * fire-and-forget per the wire contract; the server accepts
808
+ * unknown claimIds silently so disconnect-time release storms
809
+ * never error. No ack is expected.
810
+ *
811
+ * If a claim's send promise is still pending (no claim_ack yet),
812
+ * we reject it locally — the user explicitly chose to release.
813
+ */
814
+ sendRelease(claimId) {
815
+ // Cancel any in-flight claim that hadn't acked yet — the user
816
+ // changed their mind. Without this the timer would eventually
817
+ // reject; doing it now matches the user's intent immediately.
818
+ const pending = this.pendingClaims.get(claimId);
819
+ if (pending) {
820
+ clearTimeout(pending.timeout);
821
+ this.pendingClaims.delete(claimId);
822
+ pending.reject(new Error(`claim ${claimId} released before ack`));
823
+ }
824
+ if (this.ws?.readyState !== WebSocket.OPEN)
825
+ return;
826
+ try {
827
+ this.ws.send(JSON.stringify({ type: 'release', payload: { claimId } }));
828
+ }
829
+ catch {
830
+ // Idempotent contract — silent failure is acceptable here.
831
+ }
832
+ }
833
+ /**
834
+ * Replace the capability token used for authentication. The new
835
+ * value is read by the next URL-build (i.e., next connect / reconnect
836
+ * cycle). The currently-open WS is NOT torn down — servers keep
837
+ * connections alive past cap expiry until they decide to close, and
838
+ * a forced reconnect would interrupt in-flight deltas. The cap-mint
839
+ * scheduler in `Ablo.ts` calls this on each successful refresh so
840
+ * reconnects after server-initiated close pick up the fresh token.
841
+ */
842
+ setCapabilityToken(token) {
843
+ this.options.capabilityToken = token;
844
+ }
845
+ /**
846
+ * Send spreadsheet selection presence
847
+ */
848
+ sendSheetSelection(sheetId, selectedCells) {
849
+ this.sendCollaborationEvent('sheet:selection', {
850
+ sheetId,
851
+ selectedCells,
852
+ });
853
+ }
854
+ /**
855
+ * Send slide layer selection presence
856
+ */
857
+ sendSlideSelection(deckId, slideId, selectedLayers) {
858
+ this.sendCollaborationEvent('slide:selection', {
859
+ deckId,
860
+ slideId,
861
+ selectedLayers,
862
+ });
863
+ }
864
+ /**
865
+ * Send slide cursor position for real-time collaboration
866
+ * Note: Throttling should be handled by the caller (e.g., useSlideCursorBroadcast hook)
867
+ */
868
+ sendSlideCursor(deckId, slideId, x, y) {
869
+ this.sendCollaborationEvent('slide:cursor', {
870
+ deckId,
871
+ slideId,
872
+ x,
873
+ y,
874
+ });
875
+ }
876
+ /**
877
+ * Send presence update to server.
878
+ * Use this for:
879
+ * - Updating timezone (improves localTime accuracy shown to other users)
880
+ * - Manual status changes (away, custom status)
881
+ *
882
+ * Note: "online" status is automatically set by server on WebSocket connect,
883
+ * and "offline" is set on disconnect. You don't need to call this for basic online/offline.
884
+ *
885
+ * @param status - "online", "away", or custom status string
886
+ * @param customStatus - Optional custom status message
887
+ */
888
+ sendPresenceUpdate(status = 'online', customStatus) {
889
+ if (this.ws?.readyState !== WebSocket.OPEN)
890
+ return;
891
+ const timezone = (() => {
892
+ try {
893
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
894
+ }
895
+ catch {
896
+ return 'UTC';
897
+ }
898
+ })();
899
+ this.send({
900
+ type: 'presence_update',
901
+ payload: {
902
+ status,
903
+ timezone,
904
+ ...(customStatus ? { customStatus } : {}),
905
+ },
906
+ });
907
+ }
908
+ /**
909
+ * Schedule reconnection with exponential backoff
910
+ */
911
+ scheduleReconnect() {
912
+ if (this.reconnectTimer) {
913
+ clearTimeout(this.reconnectTimer);
914
+ }
915
+ // Session error means the user needs to re-authenticate — don't reconnect.
916
+ if (this._sessionErrorDetected) {
917
+ return;
918
+ }
919
+ // Don't attempt reconnection while offline.
920
+ // SyncedStore.handleNetworkOnline() owns the offline→online transition:
921
+ // it bootstraps first, then calls syncWebSocket.connect() explicitly.
922
+ // Self-reconnecting here would bypass the bootstrap gate and cause stale data.
923
+ if (!getContext().onlineStatus.isOnline()) {
924
+ this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
925
+ return;
926
+ }
927
+ // Give up after MAX_RECONNECT_ATTEMPTS consecutive failures.
928
+ // The user can recover by refreshing or when network comes back online
929
+ // (handleNetworkOnline resets attempts and reconnects).
930
+ if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
931
+ this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
932
+ return;
933
+ }
934
+ // Exponential backoff with ±15% jitter to prevent thundering herd
935
+ const baseDelay = Math.min(this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.options.maxReconnectDelay);
936
+ const jitter = baseDelay * (0.85 + Math.random() * 0.3);
937
+ const delay = Math.round(jitter);
938
+ // Emit reconnecting event so UI can show reconnection status
939
+ this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay });
940
+ this.reconnectTimer = setTimeout(() => {
941
+ this.reconnectAttempts++;
942
+ this.connect();
943
+ }, delay);
944
+ }
945
+ /**
946
+ * Reset reconnect attempt counter. Called when network comes back online
947
+ * to allow a fresh reconnect cycle after the max was previously reached.
948
+ */
949
+ resetReconnectAttempts() {
950
+ this.reconnectAttempts = 0;
951
+ }
952
+ /**
953
+ * Stop the periodic catchup interval
954
+ */
955
+ stopCatchupInterval() {
956
+ if (this.catchupInterval) {
957
+ clearInterval(this.catchupInterval);
958
+ this.catchupInterval = null;
959
+ }
960
+ }
961
+ /**
962
+ * Disconnect from WebSocket
963
+ */
964
+ disconnect() {
965
+ this.isManualClose = true;
966
+ this.stopCatchupInterval();
967
+ this.stopHeartbeat();
968
+ if (this.reconnectTimer) {
969
+ clearTimeout(this.reconnectTimer);
970
+ this.reconnectTimer = null;
971
+ }
972
+ if (this.ws) {
973
+ this.ws.close(1000, 'Manual disconnect');
974
+ this.ws = null;
975
+ }
976
+ }
977
+ /**
978
+ * Application-level heartbeat. Every `HEARTBEAT_INTERVAL_MS` while
979
+ * `OPEN`, send `{ type: 'ping' }` and arm a `HEARTBEAT_TIMEOUT_MS`
980
+ * watchdog. Any inbound frame (handled in `onmessage`) clears the
981
+ * watchdog. If the watchdog fires, we treat the connection as
982
+ * zombie and force-close it — `onclose` then triggers the existing
983
+ * reconnect path.
984
+ *
985
+ * Why both sides need this:
986
+ * - The server sends RFC 6455 protocol pings via `ws.ping()` every
987
+ * 30s. Browsers auto-respond with a pong but DO NOT expose either
988
+ * frame to JavaScript, so the client is blind to its own keepalive.
989
+ * - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
990
+ * the browser may keep `readyState === OPEN` for minutes before
991
+ * the OS surfaces the broken connection. App-level traffic is
992
+ * the only signal we can observe.
993
+ */
994
+ startHeartbeat() {
995
+ this.stopHeartbeat();
996
+ this.heartbeatTimer = setInterval(() => {
997
+ if (this.ws?.readyState !== WebSocket.OPEN)
998
+ return;
999
+ // Send the ping. If `send` throws, the socket is already dead —
1000
+ // force-close so onclose triggers the reconnect cycle.
1001
+ try {
1002
+ this.ws.send(JSON.stringify({ type: 'ping' }));
1003
+ }
1004
+ catch (err) {
1005
+ getContext().observability.captureWebSocketError({
1006
+ context: 'heartbeat-send-failed',
1007
+ error: err instanceof Error ? err.message : String(err),
1008
+ });
1009
+ this.forceClose('heartbeat-send-failed');
1010
+ return;
1011
+ }
1012
+ // Arm the timeout. ANY inbound message clears it (see onmessage).
1013
+ // We don't require an explicit `pong` — a delta or any other frame
1014
+ // is equally good proof-of-life.
1015
+ if (this.heartbeatTimeoutTimer)
1016
+ clearTimeout(this.heartbeatTimeoutTimer);
1017
+ this.heartbeatTimeoutTimer = setTimeout(() => {
1018
+ getContext().observability.captureWebSocketError({
1019
+ context: 'heartbeat-timeout',
1020
+ });
1021
+ this.forceClose('heartbeat-timeout');
1022
+ }, SyncWebSocket.HEARTBEAT_TIMEOUT_MS);
1023
+ }, SyncWebSocket.HEARTBEAT_INTERVAL_MS);
1024
+ }
1025
+ stopHeartbeat() {
1026
+ if (this.heartbeatTimer) {
1027
+ clearInterval(this.heartbeatTimer);
1028
+ this.heartbeatTimer = null;
1029
+ }
1030
+ this.clearHeartbeatTimeout();
1031
+ }
1032
+ clearHeartbeatTimeout() {
1033
+ if (this.heartbeatTimeoutTimer) {
1034
+ clearTimeout(this.heartbeatTimeoutTimer);
1035
+ this.heartbeatTimeoutTimer = null;
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Force-close the socket from the client side using a private 4xxx
1040
+ * code. Callers expect `onclose` to fire; that handler runs the
1041
+ * existing reconnect / handshake-failed dispatch. Wrapped in
1042
+ * try/catch because `close()` on a CLOSING/CLOSED socket throws on
1043
+ * some browsers.
1044
+ */
1045
+ forceClose(reason) {
1046
+ if (!this.ws)
1047
+ return;
1048
+ this.lastForceCloseReason = reason;
1049
+ getContext().logger.warn('[SyncWebSocket] forceClose', {
1050
+ reason,
1051
+ readyState: this.ws.readyState,
1052
+ msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
1053
+ });
1054
+ try {
1055
+ this.ws.close(4000, reason);
1056
+ }
1057
+ catch {
1058
+ // Already closing / closed — onclose will still fire.
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Get connection state
1063
+ */
1064
+ isConnected() {
1065
+ return this.ws?.readyState === WebSocket.OPEN;
1066
+ }
1067
+ /**
1068
+ * Snapshot of recent connection lifecycle state, for diagnostic logs
1069
+ * and error messages. Cheap to call (no I/O); safe to log every time
1070
+ * a send is rejected so we can attribute "not connected" rejections
1071
+ * to the actual root cause (handshake reject vs heartbeat zombie vs
1072
+ * session expiry vs explicit close).
1073
+ */
1074
+ getConnectionDiagnostics() {
1075
+ const now = Date.now();
1076
+ return {
1077
+ readyState: this.ws?.readyState ?? null,
1078
+ isConnecting: this.isConnecting,
1079
+ isManualClose: this.isManualClose,
1080
+ sessionErrorDetected: this._sessionErrorDetected,
1081
+ everOpened: this._everOpened,
1082
+ reconnectAttempts: this.reconnectAttempts,
1083
+ maxReconnectAttempts: SyncWebSocket.MAX_RECONNECT_ATTEMPTS,
1084
+ lastOpenAt: this.lastOpenAt,
1085
+ lastCloseAt: this.lastCloseAt,
1086
+ lastCloseCode: this.lastCloseCode,
1087
+ lastCloseReason: this.lastCloseReason,
1088
+ lastForceCloseReason: this.lastForceCloseReason,
1089
+ sessionErrorAt: this.sessionErrorAt,
1090
+ msSinceLastOpen: this.lastOpenAt != null ? now - this.lastOpenAt : null,
1091
+ msSinceLastClose: this.lastCloseAt != null ? now - this.lastCloseAt : null,
1092
+ };
1093
+ }
1094
+ /**
1095
+ * Build a richly-diagnosed "not connected" error so callers (and the
1096
+ * logs they emit) can attribute the rejection. The message embeds the
1097
+ * dominant signal in human-readable form; the structured detail is
1098
+ * also attached as `error.diagnostics` for log scrapers.
1099
+ */
1100
+ notConnectedError(action) {
1101
+ const d = this.getConnectionDiagnostics();
1102
+ let detail;
1103
+ if (d.sessionErrorDetected) {
1104
+ detail = 'session_error_suppressed_reconnect';
1105
+ }
1106
+ else if (d.isManualClose) {
1107
+ detail = 'manual_close';
1108
+ }
1109
+ else if (d.isConnecting) {
1110
+ detail = 'still_connecting';
1111
+ }
1112
+ else if (!d.everOpened && d.lastCloseAt != null) {
1113
+ detail = `handshake_failed code=${d.lastCloseCode}`;
1114
+ }
1115
+ else if (d.lastForceCloseReason) {
1116
+ detail = `force_closed reason=${d.lastForceCloseReason}`;
1117
+ }
1118
+ else if (d.lastCloseAt != null) {
1119
+ detail =
1120
+ `closed code=${d.lastCloseCode}` +
1121
+ (d.lastCloseReason ? ` reason=${d.lastCloseReason}` : '') +
1122
+ (d.msSinceLastClose != null ? ` ${d.msSinceLastClose}ms ago` : '') +
1123
+ (d.reconnectAttempts > 0
1124
+ ? ` reconnectAttempts=${d.reconnectAttempts}/${d.maxReconnectAttempts}`
1125
+ : '');
1126
+ }
1127
+ else {
1128
+ detail = 'never_connected';
1129
+ }
1130
+ const err = new Error(`SyncWebSocket not connected — cannot send ${action} (${detail})`);
1131
+ err.diagnostics = d;
1132
+ return err;
1133
+ }
1134
+ /** Returns the sync groups this connection is subscribed to. */
1135
+ getSyncGroups() {
1136
+ return this.options.syncGroups;
1137
+ }
1138
+ /**
1139
+ * Update last sync ID (for persistence)
1140
+ */
1141
+ setLastSyncId(syncId) {
1142
+ this.lastSyncId = syncId;
1143
+ }
1144
+ /**
1145
+ * Get current version vector
1146
+ */
1147
+ getVersionVector() {
1148
+ return { ...this.versionVector };
1149
+ }
1150
+ /**
1151
+ * Update version vector for specific entity type
1152
+ */
1153
+ updateVersionVector(entityType, version) {
1154
+ this.versionVector[entityType] = Math.max(this.versionVector[entityType] || 0, version);
1155
+ }
1156
+ /**
1157
+ * Set version vector (for initialization)
1158
+ */
1159
+ setVersionVector(versions) {
1160
+ this.versionVector = { ...versions };
1161
+ }
1162
+ /**
1163
+ * Update sync cursor (for incremental sync)
1164
+ */
1165
+ setSyncCursor(cursor) {
1166
+ this.syncCursor = cursor;
1167
+ }
1168
+ /**
1169
+ * Get current sync cursor
1170
+ */
1171
+ getSyncCursor() {
1172
+ return this.syncCursor;
1173
+ }
1174
+ /**
1175
+ * Get the highest syncId seen this session (for persistence on clean shutdown)
1176
+ */
1177
+ getLastSyncId() {
1178
+ return this.lastSyncId || 0;
1179
+ }
1180
+ /**
1181
+ * Linear-style incremental sync request
1182
+ */
1183
+ async requestIncrementalSync() {
1184
+ if (this.ws?.readyState !== WebSocket.OPEN) {
1185
+ // Silent when offline - not an error condition
1186
+ if (getContext().onlineStatus.isOnline()) {
1187
+ getContext().observability.breadcrumb('WebSocket not connected, cannot request sync', 'sync.websocket', 'warning');
1188
+ }
1189
+ return;
1190
+ }
1191
+ // Normalize capabilities to an array of strings for server compatibility
1192
+ const capsObj = this.options.capabilities || {};
1193
+ const capsArr = Object.entries(capsObj)
1194
+ .filter(([, v]) => !!v)
1195
+ .map(([k]) => k);
1196
+ this.send({
1197
+ type: 'sync_request',
1198
+ payload: {
1199
+ cursor: this.syncCursor,
1200
+ versions: this.versionVector,
1201
+ // Always send lastSyncId to ensure server uses client's current position
1202
+ lastSyncId: this.lastSyncId,
1203
+ capabilities: capsArr,
1204
+ },
1205
+ });
1206
+ }
1207
+ /**
1208
+ * Request bootstrap for specific entities
1209
+ */
1210
+ async requestBootstrap(entities) {
1211
+ if (this.ws?.readyState !== WebSocket.OPEN) {
1212
+ // Silent when offline - not an error condition
1213
+ if (getContext().onlineStatus.isOnline()) {
1214
+ getContext().observability.breadcrumb('WebSocket not connected, cannot request bootstrap', 'sync.websocket', 'warning');
1215
+ }
1216
+ return;
1217
+ }
1218
+ this.send({
1219
+ type: 'bootstrap_request',
1220
+ payload: {
1221
+ entities: entities || [],
1222
+ versions: this.versionVector,
1223
+ capabilities: this.options.capabilities,
1224
+ },
1225
+ });
1226
+ }
1227
+ /**
1228
+ * Handle sync response from server
1229
+ */
1230
+ handleSyncResponse(payload) {
1231
+ // Cursor reconciliation — Linear-style handshake. The server stamps
1232
+ // its authoritative `currentSyncId` on every sync_response. If our
1233
+ // local cursor is AHEAD of the server, our local view has somehow
1234
+ // diverged (corrupted metadata, future regression reintroducing an
1235
+ // eager-advance, IDB lying about a successful commit). Trust the
1236
+ // server, reset the cursor, and request another sync so any deltas
1237
+ // we *should* have applied get re-delivered. Backward-compatible
1238
+ // when the field is absent (older server build) — we just skip the
1239
+ // reconciliation step.
1240
+ //
1241
+ // We only reconcile when the response carries NO deltas. If deltas
1242
+ // are present, they'll advance our cursor through the normal
1243
+ // persistence-gated path anyway — and the in-flight request/response
1244
+ // round-trip means the snapshot's `currentSyncId` is naturally a
1245
+ // few syncIds behind our locally-advanced cursor at receive time
1246
+ // (live deltas may have landed in the meantime). Restricting to
1247
+ // empty-delta responses eliminates this benign false positive while
1248
+ // still catching the real corruption case (server head < local AND
1249
+ // server has nothing new to send).
1250
+ const hasDeltas = Array.isArray(payload.deltas) && payload.deltas.length > 0;
1251
+ if (!hasDeltas && typeof payload.currentSyncId === 'number') {
1252
+ const serverHead = payload.currentSyncId;
1253
+ if (serverHead < this.lastSyncId) {
1254
+ getContext().logger.warn('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
1255
+ local: this.lastSyncId,
1256
+ server: serverHead,
1257
+ drift: this.lastSyncId - serverHead,
1258
+ });
1259
+ getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.lastSyncId, server: serverHead });
1260
+ this.lastSyncId = serverHead;
1261
+ // Fire a follow-up incremental sync to re-deliver anything we
1262
+ // were missing. Fire-and-forget — the next response will go
1263
+ // through this same path. The infinite-loop concern is bounded
1264
+ // by the `serverHead < this.lastSyncId` strict-less check: once
1265
+ // we've reset to `serverHead`, the next response with the same
1266
+ // (or higher) `currentSyncId` won't re-enter this branch.
1267
+ void this.requestIncrementalSync();
1268
+ }
1269
+ }
1270
+ if (payload.requiresBootstrap) {
1271
+ this.emit('bootstrap_required', payload.bootstrapHint);
1272
+ return;
1273
+ }
1274
+ // Process incremental deltas
1275
+ if (payload.deltas && Array.isArray(payload.deltas)) {
1276
+ // Process all deltas from sync response - store handles idempotency
1277
+ const newDeltas = payload.deltas;
1278
+ if (newDeltas.length > 0) {
1279
+ // DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
1280
+ // `handleDelta`: the runtime cursor must stay consistent with
1281
+ // IDB. The delta_batch event routes through
1282
+ // `BaseSyncedStore.processDeltaWithBatching` →
1283
+ // `flushPendingDeltas`, which calls `acknowledge()` with the
1284
+ // honest `persistedSyncId` once IDB commits. That ack is what
1285
+ // moves `this.lastSyncId` forward.
1286
+ // Emit ALL deltas as a single batch event
1287
+ this.emit('delta_batch', newDeltas);
1288
+ }
1289
+ }
1290
+ // Update cursors and versions
1291
+ if (payload.newCursor) {
1292
+ this.syncCursor = payload.newCursor;
1293
+ }
1294
+ else if (payload.cursor) {
1295
+ this.syncCursor = payload.cursor;
1296
+ }
1297
+ if (payload.newVersions) {
1298
+ Object.assign(this.versionVector, payload.newVersions);
1299
+ }
1300
+ }
1301
+ /**
1302
+ * Handle bootstrap response from server
1303
+ */
1304
+ handleBootstrapResponse(payload) {
1305
+ // Emit bootstrap data for processing
1306
+ this.emit('bootstrap_data', {
1307
+ entityType: payload.entityType,
1308
+ data: payload.data,
1309
+ isComplete: payload.isComplete,
1310
+ cursor: payload.cursor,
1311
+ });
1312
+ // Update version vector if provided
1313
+ if (payload.version && payload.entityType) {
1314
+ this.updateVersionVector(payload.entityType.toLowerCase(), payload.version);
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Handle presence update from server. The wire frame's payload is
1319
+ * forwarded as-is so every consumer (web entity cache,
1320
+ * PresenceStream, agent runtime) reads from the same shape.
1321
+ * Stripping fields here was a prior bug — it silently dropped
1322
+ * `kind`, `activity`, `syncGroups`, `isAgent` for rich consumers.
1323
+ *
1324
+ * Wire frame (apps/sync-server/src/hub/types.ts PresenceUpdateMessage):
1325
+ * { type: 'presence_update', payload: { kind, userId, status,
1326
+ * syncGroups, activity, isAgent, timestamp, activeIntents } }
1327
+ */
1328
+ handlePresenceUpdate(message) {
1329
+ const event =
1330
+ // Server canonical path: `{ payload: {...} }`. Some legacy
1331
+ // pathways emit fields at the top level (test fixtures) — fall
1332
+ // back to reading from the message itself.
1333
+ message.payload ?? message;
1334
+ this.emit('presence_update', event);
1335
+ }
1336
+ }