@abloatai/ablo 0.25.0 → 0.27.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 (425) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +104 -88
  4. package/dist/BaseSyncedStore.d.ts +140 -266
  5. package/dist/BaseSyncedStore.js +338 -739
  6. package/dist/Database.d.ts +62 -77
  7. package/dist/Database.js +106 -127
  8. package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
  9. package/dist/{ObjectPool.js → InstanceCache.js} +91 -83
  10. package/dist/LazyReferenceCollection.d.ts +11 -15
  11. package/dist/LazyReferenceCollection.js +16 -15
  12. package/dist/Model.d.ts +37 -52
  13. package/dist/Model.js +52 -69
  14. package/dist/ModelRegistry.d.ts +46 -25
  15. package/dist/ModelRegistry.js +32 -30
  16. package/dist/NetworkMonitor.d.ts +5 -6
  17. package/dist/NetworkMonitor.js +6 -7
  18. package/dist/SyncClient.d.ts +119 -109
  19. package/dist/SyncClient.js +303 -224
  20. package/dist/SyncEngineContext.d.ts +1 -3
  21. package/dist/SyncEngineContext.js +1 -2
  22. package/dist/adapters/alwaysOnline.d.ts +6 -8
  23. package/dist/adapters/alwaysOnline.js +6 -8
  24. package/dist/adapters/inMemoryStorage.d.ts +9 -9
  25. package/dist/adapters/inMemoryStorage.js +9 -9
  26. package/dist/agent/Agent.d.ts +39 -31
  27. package/dist/agent/Agent.js +35 -23
  28. package/dist/agent/index.d.ts +4 -4
  29. package/dist/agent/index.js +5 -5
  30. package/dist/agent/session.d.ts +47 -44
  31. package/dist/agent/session.js +37 -48
  32. package/dist/agent/types.d.ts +26 -31
  33. package/dist/agent/types.js +6 -7
  34. package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
  35. package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
  36. package/dist/ai-sdk/coordinationContext.d.ts +46 -0
  37. package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +30 -31
  38. package/dist/ai-sdk/index.d.ts +25 -22
  39. package/dist/ai-sdk/index.js +25 -22
  40. package/dist/ai-sdk/wrap.d.ts +7 -8
  41. package/dist/ai-sdk/wrap.js +2 -2
  42. package/dist/auth/credentialPolicy.d.ts +74 -71
  43. package/dist/auth/credentialPolicy.js +51 -56
  44. package/dist/auth/credentialSource.d.ts +7 -18
  45. package/dist/auth/credentialSource.js +10 -18
  46. package/dist/auth/index.d.ts +59 -58
  47. package/dist/auth/index.js +34 -40
  48. package/dist/auth/schemas.d.ts +5 -4
  49. package/dist/auth/schemas.js +5 -4
  50. package/dist/batching/index.d.ts +19 -21
  51. package/dist/batching/index.js +14 -17
  52. package/dist/cli.cjs +483 -369
  53. package/dist/client/Ablo.d.ts +107 -836
  54. package/dist/client/Ablo.js +174 -833
  55. package/dist/client/ApiClient.d.ts +44 -20
  56. package/dist/client/ApiClient.js +193 -44
  57. package/dist/client/auth.d.ts +51 -60
  58. package/dist/client/auth.js +137 -110
  59. package/dist/client/claimHeartbeatLoop.d.ts +50 -0
  60. package/dist/client/claimHeartbeatLoop.js +88 -0
  61. package/dist/client/consoleLogger.d.ts +35 -0
  62. package/dist/client/consoleLogger.js +44 -0
  63. package/dist/client/createInternalComponents.d.ts +14 -17
  64. package/dist/client/createInternalComponents.js +26 -31
  65. package/dist/client/createModelProxy.d.ts +130 -120
  66. package/dist/client/createModelProxy.js +158 -124
  67. package/dist/client/credentialEndpoint.d.ts +61 -0
  68. package/dist/client/credentialEndpoint.js +86 -0
  69. package/dist/client/functionalUpdate.d.ts +29 -27
  70. package/dist/client/functionalUpdate.js +21 -21
  71. package/dist/client/hostedEndpoints.d.ts +21 -0
  72. package/dist/client/hostedEndpoints.js +21 -0
  73. package/dist/client/httpClient.d.ts +58 -54
  74. package/dist/client/httpClient.js +29 -31
  75. package/dist/client/identity.d.ts +15 -20
  76. package/dist/client/identity.js +49 -59
  77. package/dist/client/modelRegistration.d.ts +10 -0
  78. package/dist/client/modelRegistration.js +301 -0
  79. package/dist/client/options.d.ts +373 -0
  80. package/dist/client/options.js +6 -0
  81. package/dist/client/registerDataSource.d.ts +9 -9
  82. package/dist/client/registerDataSource.js +15 -16
  83. package/dist/client/resourceTypes.d.ts +333 -0
  84. package/dist/client/resourceTypes.js +7 -0
  85. package/dist/client/schemaConfig.d.ts +44 -0
  86. package/dist/client/schemaConfig.js +176 -0
  87. package/dist/client/sessionMint.d.ts +17 -13
  88. package/dist/client/sessionMint.js +26 -31
  89. package/dist/client/validateAbloOptions.d.ts +12 -14
  90. package/dist/client/validateAbloOptions.js +9 -10
  91. package/dist/client/writeOptionsSchema.d.ts +18 -16
  92. package/dist/client/writeOptionsSchema.js +23 -20
  93. package/dist/client/wsMutationExecutor.d.ts +28 -0
  94. package/dist/client/wsMutationExecutor.js +71 -0
  95. package/dist/context.d.ts +6 -4
  96. package/dist/context.js +6 -7
  97. package/dist/coordination/index.d.ts +13 -4
  98. package/dist/coordination/index.js +29 -4
  99. package/dist/coordination/schema.d.ts +176 -128
  100. package/dist/coordination/schema.js +197 -133
  101. package/dist/coordination/trace.d.ts +9 -11
  102. package/dist/coordination/trace.js +13 -15
  103. package/dist/core/DatabaseManager.d.ts +5 -8
  104. package/dist/core/DatabaseManager.js +38 -40
  105. package/dist/core/QueryProcessor.d.ts +7 -9
  106. package/dist/core/QueryProcessor.js +27 -34
  107. package/dist/core/QueryView.d.ts +17 -5
  108. package/dist/core/QueryView.js +6 -7
  109. package/dist/core/StoreManager.d.ts +14 -16
  110. package/dist/core/StoreManager.js +26 -25
  111. package/dist/core/ViewRegistry.d.ts +5 -5
  112. package/dist/core/ViewRegistry.js +4 -4
  113. package/dist/core/index.d.ts +18 -13
  114. package/dist/core/index.js +32 -26
  115. package/dist/core/openIDBWithTimeout.d.ts +38 -36
  116. package/dist/core/openIDBWithTimeout.js +57 -54
  117. package/dist/core/queryUtils.d.ts +45 -0
  118. package/dist/core/queryUtils.js +69 -0
  119. package/dist/core/storeContract.d.ts +145 -0
  120. package/dist/core/storeContract.js +12 -0
  121. package/dist/environment.d.ts +28 -0
  122. package/dist/environment.js +21 -0
  123. package/dist/errorCodes.d.ts +118 -101
  124. package/dist/errorCodes.js +277 -260
  125. package/dist/errors.d.ts +170 -165
  126. package/dist/errors.js +161 -151
  127. package/dist/index.d.ts +30 -27
  128. package/dist/index.js +90 -82
  129. package/dist/interfaces/index.d.ts +108 -133
  130. package/dist/interfaces/index.js +5 -4
  131. package/dist/keys/index.d.ts +27 -29
  132. package/dist/keys/index.js +59 -49
  133. package/dist/mutators/RecordingTransaction.d.ts +16 -16
  134. package/dist/mutators/RecordingTransaction.js +31 -37
  135. package/dist/mutators/Transaction.d.ts +18 -26
  136. package/dist/mutators/Transaction.js +14 -20
  137. package/dist/mutators/UndoManager.d.ts +122 -131
  138. package/dist/mutators/UndoManager.js +149 -155
  139. package/dist/mutators/defineMutators.d.ts +24 -37
  140. package/dist/mutators/defineMutators.js +14 -20
  141. package/dist/mutators/inverseOp.d.ts +12 -15
  142. package/dist/mutators/inverseOp.js +12 -15
  143. package/dist/mutators/mutateActions.d.ts +10 -9
  144. package/dist/mutators/mutateActions.js +1 -1
  145. package/dist/mutators/readerActions.d.ts +9 -8
  146. package/dist/mutators/readerActions.js +2 -2
  147. package/dist/mutators/undoApply.d.ts +31 -27
  148. package/dist/mutators/undoApply.js +26 -24
  149. package/dist/policy/index.d.ts +5 -3
  150. package/dist/policy/index.js +5 -3
  151. package/dist/policy/types.d.ts +105 -101
  152. package/dist/policy/types.js +67 -66
  153. package/dist/query/client.d.ts +32 -16
  154. package/dist/query/client.js +103 -72
  155. package/dist/query/types.d.ts +37 -60
  156. package/dist/query/types.js +13 -33
  157. package/dist/react/AbloProvider.d.ts +7 -11
  158. package/dist/react/AbloProvider.js +24 -17
  159. package/dist/react/context.d.ts +27 -146
  160. package/dist/react/context.js +9 -10
  161. package/dist/react/index.d.ts +41 -42
  162. package/dist/react/index.js +37 -38
  163. package/dist/react/internalContext.d.ts +17 -19
  164. package/dist/react/useAblo.d.ts +23 -22
  165. package/dist/react/useAblo.js +17 -15
  166. package/dist/react/useCurrentUserId.d.ts +8 -7
  167. package/dist/react/useCurrentUserId.js +8 -7
  168. package/dist/react/useErrorListener.d.ts +7 -7
  169. package/dist/react/useErrorListener.js +11 -12
  170. package/dist/react/useMutationFailureListener.d.ts +8 -8
  171. package/dist/react/useMutationFailureListener.js +9 -9
  172. package/dist/react/useMutators.d.ts +11 -11
  173. package/dist/react/useMutators.js +10 -4
  174. package/dist/react/useReactive.js +2 -3
  175. package/dist/react/useSyncStatus.d.ts +4 -6
  176. package/dist/react/useUndoScope.d.ts +7 -9
  177. package/dist/react/useUndoScope.js +3 -3
  178. package/dist/schema/coordination.d.ts +21 -25
  179. package/dist/schema/coordination.js +21 -25
  180. package/dist/schema/ddl.d.ts +43 -39
  181. package/dist/schema/ddl.js +75 -68
  182. package/dist/schema/ddlLock.d.ts +35 -0
  183. package/dist/schema/ddlLock.js +46 -0
  184. package/dist/schema/diff.d.ts +99 -61
  185. package/dist/schema/diff.js +43 -34
  186. package/dist/schema/field.d.ts +37 -42
  187. package/dist/schema/field.js +36 -49
  188. package/dist/schema/generate.d.ts +12 -12
  189. package/dist/schema/generate.js +12 -12
  190. package/dist/schema/index.d.ts +5 -4
  191. package/dist/schema/index.js +29 -21
  192. package/dist/schema/model.d.ts +121 -146
  193. package/dist/schema/model.js +24 -35
  194. package/dist/schema/openapi.d.ts +10 -9
  195. package/dist/schema/openapi.js +7 -1
  196. package/dist/schema/queries.d.ts +30 -32
  197. package/dist/schema/queries.js +24 -25
  198. package/dist/schema/relation.d.ts +89 -99
  199. package/dist/schema/relation.js +13 -13
  200. package/dist/schema/residency.d.ts +38 -0
  201. package/dist/schema/residency.js +30 -0
  202. package/dist/schema/roles.d.ts +45 -27
  203. package/dist/schema/roles.js +52 -21
  204. package/dist/schema/schema.d.ts +36 -45
  205. package/dist/schema/schema.js +42 -39
  206. package/dist/schema/select.d.ts +13 -13
  207. package/dist/schema/select.js +13 -13
  208. package/dist/schema/serialize.d.ts +36 -39
  209. package/dist/schema/serialize.js +27 -31
  210. package/dist/schema/sugar.d.ts +17 -32
  211. package/dist/schema/sugar.js +14 -29
  212. package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +27 -50
  213. package/dist/schema/syncDeltaRow.js +89 -0
  214. package/dist/schema/tenancy.d.ts +44 -46
  215. package/dist/schema/tenancy.js +46 -48
  216. package/dist/server/adapter.d.ts +58 -58
  217. package/dist/server/adapter.js +13 -14
  218. package/dist/server/commit.d.ts +60 -64
  219. package/dist/server/index.d.ts +9 -10
  220. package/dist/server/index.js +1 -1
  221. package/dist/server/readConfig.d.ts +70 -0
  222. package/dist/server/readConfig.js +8 -0
  223. package/dist/server/storageMode.d.ts +23 -0
  224. package/dist/server/storageMode.js +17 -0
  225. package/dist/source/adapter.d.ts +31 -26
  226. package/dist/source/adapter.js +10 -10
  227. package/dist/source/adapters/drizzle.d.ts +28 -23
  228. package/dist/source/adapters/drizzle.js +34 -28
  229. package/dist/source/adapters/kysely.d.ts +27 -25
  230. package/dist/source/adapters/kysely.js +28 -26
  231. package/dist/source/adapters/memory.d.ts +8 -7
  232. package/dist/source/adapters/memory.js +10 -9
  233. package/dist/source/adapters/prisma.d.ts +13 -12
  234. package/dist/source/adapters/prisma.js +27 -29
  235. package/dist/source/conformance.d.ts +18 -11
  236. package/dist/source/conformance.js +27 -19
  237. package/dist/source/connector.d.ts +31 -32
  238. package/dist/source/connector.js +30 -28
  239. package/dist/source/connectorProtocol.d.ts +160 -0
  240. package/dist/source/connectorProtocol.js +162 -0
  241. package/dist/source/contract.d.ts +26 -27
  242. package/dist/source/contract.js +28 -29
  243. package/dist/source/factory.d.ts +94 -0
  244. package/dist/source/factory.js +268 -0
  245. package/dist/source/index.d.ts +10 -462
  246. package/dist/source/index.js +17 -421
  247. package/dist/source/migrations.d.ts +9 -9
  248. package/dist/source/migrations.js +9 -9
  249. package/dist/source/next.d.ts +10 -11
  250. package/dist/source/next.js +7 -8
  251. package/dist/source/pushQueue.d.ts +70 -48
  252. package/dist/source/pushQueue.js +36 -29
  253. package/dist/source/signing.d.ts +88 -0
  254. package/dist/source/signing.js +159 -0
  255. package/dist/source/types.d.ts +351 -0
  256. package/dist/source/types.js +43 -0
  257. package/dist/stores/ObjectStore.d.ts +11 -12
  258. package/dist/stores/ObjectStore.js +34 -35
  259. package/dist/stores/ObjectStoreContract.d.ts +12 -15
  260. package/dist/stores/SyncActionStore.d.ts +8 -12
  261. package/dist/stores/SyncActionStore.js +77 -46
  262. package/dist/surface.d.ts +28 -21
  263. package/dist/surface.js +28 -20
  264. package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +37 -45
  265. package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +101 -80
  266. package/dist/sync/ConnectionManager.d.ts +47 -50
  267. package/dist/sync/ConnectionManager.js +74 -70
  268. package/dist/sync/NetworkProbe.d.ts +27 -31
  269. package/dist/sync/NetworkProbe.js +67 -72
  270. package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +49 -36
  271. package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +79 -54
  272. package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +45 -59
  273. package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +44 -52
  274. package/dist/sync/SyncWebSocket.d.ts +175 -250
  275. package/dist/sync/SyncWebSocket.js +431 -769
  276. package/dist/sync/awaitClaimGrant.d.ts +18 -18
  277. package/dist/sync/awaitClaimGrant.js +38 -30
  278. package/dist/sync/bootstrapApply.d.ts +70 -0
  279. package/dist/sync/bootstrapApply.js +73 -0
  280. package/dist/sync/commitFrames.d.ts +44 -0
  281. package/dist/sync/commitFrames.js +94 -0
  282. package/dist/sync/createClaimStream.d.ts +23 -22
  283. package/dist/sync/createClaimStream.js +108 -25
  284. package/dist/sync/createPresenceStream.d.ts +19 -18
  285. package/dist/sync/createPresenceStream.js +25 -26
  286. package/dist/sync/createSnapshot.d.ts +13 -17
  287. package/dist/sync/createSnapshot.js +20 -26
  288. package/dist/sync/credentialLifecycle.d.ts +175 -0
  289. package/dist/sync/credentialLifecycle.js +322 -0
  290. package/dist/sync/deltaPipeline.d.ts +113 -0
  291. package/dist/sync/deltaPipeline.js +261 -0
  292. package/dist/sync/groupChange.d.ts +113 -0
  293. package/dist/sync/groupChange.js +242 -0
  294. package/dist/sync/heartbeat.d.ts +63 -0
  295. package/dist/sync/heartbeat.js +91 -0
  296. package/dist/sync/participants.d.ts +27 -27
  297. package/dist/sync/schemas.d.ts +3 -2
  298. package/dist/sync/schemas.js +14 -10
  299. package/dist/sync/syncCursor.d.ts +40 -0
  300. package/dist/sync/syncCursor.js +55 -0
  301. package/dist/sync/syncPlan.d.ts +54 -0
  302. package/dist/sync/syncPlan.js +50 -0
  303. package/dist/sync/syncPosition.d.ts +54 -49
  304. package/dist/sync/syncPosition.js +57 -52
  305. package/dist/sync/wsFrameHandlers.d.ts +116 -0
  306. package/dist/sync/wsFrameHandlers.js +374 -0
  307. package/dist/testing/fixtures/bootstrap.d.ts +21 -17
  308. package/dist/testing/fixtures/bootstrap.js +12 -6
  309. package/dist/testing/fixtures/deltas.d.ts +31 -34
  310. package/dist/testing/fixtures/deltas.js +30 -33
  311. package/dist/testing/fixtures/models.d.ts +11 -10
  312. package/dist/testing/fixtures/models.js +12 -10
  313. package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
  314. package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
  315. package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -18
  316. package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +14 -11
  317. package/dist/testing/helpers/wait.d.ts +13 -8
  318. package/dist/testing/helpers/wait.js +13 -8
  319. package/dist/testing/index.d.ts +4 -4
  320. package/dist/testing/index.js +3 -3
  321. package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
  322. package/dist/testing/mocks/MockMutationExecutor.js +15 -14
  323. package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
  324. package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
  325. package/dist/testing/mocks/MockSyncContext.d.ts +21 -34
  326. package/dist/testing/mocks/MockSyncContext.js +16 -45
  327. package/dist/testing/mocks/MockSyncStore.d.ts +14 -14
  328. package/dist/testing/mocks/MockSyncStore.js +11 -11
  329. package/dist/testing/mocks/MockWebSocket.d.ts +28 -24
  330. package/dist/testing/mocks/MockWebSocket.js +22 -21
  331. package/dist/transactions/TransactionQueue.d.ts +190 -221
  332. package/dist/transactions/TransactionQueue.js +424 -822
  333. package/dist/transactions/TransactionStore.d.ts +20 -0
  334. package/dist/transactions/TransactionStore.js +53 -0
  335. package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
  336. package/dist/transactions/UnconfirmedWrites.js +104 -0
  337. package/dist/transactions/coalesceRules.d.ts +58 -0
  338. package/dist/transactions/coalesceRules.js +140 -0
  339. package/dist/transactions/commitPayload.d.ts +130 -0
  340. package/dist/transactions/commitPayload.js +143 -0
  341. package/dist/transactions/deltaConfirmation.d.ts +58 -0
  342. package/dist/transactions/deltaConfirmation.js +215 -0
  343. package/dist/transactions/optimisticApply.d.ts +49 -0
  344. package/dist/transactions/optimisticApply.js +65 -0
  345. package/dist/transactions/replayValidation.d.ts +99 -0
  346. package/dist/transactions/replayValidation.js +111 -0
  347. package/dist/types/global.d.ts +46 -41
  348. package/dist/types/global.js +20 -19
  349. package/dist/types/index.d.ts +74 -80
  350. package/dist/types/index.js +22 -27
  351. package/dist/types/modelData.d.ts +10 -0
  352. package/dist/types/modelData.js +9 -0
  353. package/dist/types/participant.d.ts +20 -0
  354. package/dist/types/participant.js +10 -0
  355. package/dist/types/streams.d.ts +216 -209
  356. package/dist/types/streams.js +7 -7
  357. package/dist/utils/asyncIterator.d.ts +25 -32
  358. package/dist/utils/asyncIterator.js +25 -32
  359. package/dist/utils/duration.d.ts +12 -15
  360. package/dist/utils/duration.js +12 -15
  361. package/dist/utils/mobxSetup.d.ts +53 -0
  362. package/dist/utils/{mobx-setup.js → mobxSetup.js} +44 -100
  363. package/dist/webhooks/events.d.ts +21 -16
  364. package/dist/webhooks/events.js +10 -8
  365. package/dist/webhooks/index.d.ts +5 -7
  366. package/dist/webhooks/index.js +5 -7
  367. package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
  368. package/dist/wire/delta.js +114 -0
  369. package/dist/wire/errorEnvelope.d.ts +35 -27
  370. package/dist/wire/errorEnvelope.js +38 -32
  371. package/dist/wire/frames.d.ts +150 -67
  372. package/dist/wire/frames.js +48 -1
  373. package/dist/wire/index.d.ts +18 -13
  374. package/dist/wire/index.js +36 -13
  375. package/dist/wire/listEnvelope.d.ts +16 -23
  376. package/dist/wire/listEnvelope.js +7 -6
  377. package/dist/wire/protocol.d.ts +38 -0
  378. package/dist/wire/protocol.js +38 -0
  379. package/dist/wire/protocolVersion.d.ts +60 -0
  380. package/dist/wire/protocolVersion.js +67 -0
  381. package/docs/api-keys.md +4 -3
  382. package/docs/coordination.md +59 -0
  383. package/docs/examples/existing-python-backend.md +3 -3
  384. package/docs/identity.md +4 -4
  385. package/docs/integration-guide.md +1 -1
  386. package/docs/react.md +1 -1
  387. package/docs/sessions.md +5 -7
  388. package/package.json +24 -21
  389. package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
  390. package/dist/ai-sdk/coordination-context.d.ts +0 -52
  391. package/dist/client/index.d.ts +0 -36
  392. package/dist/client/index.js +0 -33
  393. package/dist/config/index.d.ts +0 -10
  394. package/dist/config/index.js +0 -12
  395. package/dist/core/query-utils.d.ts +0 -34
  396. package/dist/core/query-utils.js +0 -59
  397. package/dist/interfaces/headless.d.ts +0 -95
  398. package/dist/interfaces/headless.js +0 -41
  399. package/dist/query/index.d.ts +0 -6
  400. package/dist/query/index.js +0 -5
  401. package/dist/realtime/index.d.ts +0 -10
  402. package/dist/realtime/index.js +0 -9
  403. package/dist/schema/plane.d.ts +0 -23
  404. package/dist/schema/plane.js +0 -19
  405. package/dist/schema/sync-delta-row.js +0 -103
  406. package/dist/schema/sync-delta-wire.js +0 -102
  407. package/dist/server/next.d.ts +0 -51
  408. package/dist/server/next.js +0 -47
  409. package/dist/server/read-config.d.ts +0 -67
  410. package/dist/server/read-config.js +0 -8
  411. package/dist/server/storage-mode.d.ts +0 -1
  412. package/dist/server/storage-mode.js +0 -18
  413. package/dist/source/connector-protocol.d.ts +0 -159
  414. package/dist/source/connector-protocol.js +0 -161
  415. package/dist/sync/OfflineFlush.d.ts +0 -9
  416. package/dist/sync/OfflineFlush.js +0 -22
  417. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  418. package/dist/sync/OfflineTransactionStore.js +0 -263
  419. package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
  420. package/dist/transactions/OptimisticEchoTracker.js +0 -104
  421. package/dist/transactions/index.d.ts +0 -16
  422. package/dist/transactions/index.js +0 -7
  423. package/dist/transactions/mutation-error-handler.d.ts +0 -5
  424. package/dist/transactions/mutation-error-handler.js +0 -39
  425. package/dist/utils/mobx-setup.d.ts +0 -42
@@ -1,22 +1,42 @@
1
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
2
+ * Manages the WebSocket connection to the sync server. It owns the socket
3
+ * lifecycle (connect, reconnect, disconnect), receives and validates the
4
+ * incoming delta stream, sends commits and claims over the same socket, and
5
+ * reconnects automatically with exponential backoff. Consumers subscribe to its
6
+ * typed events (see {@link CoreSyncEventMap}) to react to deltas, presence, and
7
+ * connection changes.
9
8
  */
10
9
  import { EventEmitter } from 'events';
11
10
  import { getContext } from '../context.js';
12
- import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
- import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
14
- import { subscriptionAckPayloadSchema, staleNotificationSchema, wireParticipantKindSchema, } from '../coordination/schema.js';
15
- import { formatClaim, formatConflict } from '../coordination/trace.js';
11
+ import { AbloConnectionError, AbloError, SyncSessionError, toAbloError, } from '../errors.js';
12
+ import { clientSyncDeltaSchema } from '../wire/delta.js';
13
+ // Commit-path frame builders (pure) extracted leaf; the host re-exports
14
+ // `CommitAck` below so importers keep this module as their path.
15
+ import { buildCommitFrame } from './commitFrames.js';
16
+ // Inbound frame dispatch table + the minimal session slice it operates on.
17
+ import { dispatchWsFrame, isRecord, isWsInboundFrame, } from './wsFrameHandlers.js';
18
+ // Sync-position state (lastSyncId watermark, version vector, server cursor).
19
+ import { SyncCursor } from './syncCursor.js';
20
+ import { PROTOCOL_VERSION, WS_CLOSE_PROTOCOL_VERSION } from '../wire/protocolVersion.js';
21
+ // Application-level heartbeat timers (see heartbeat.ts for the rationale).
22
+ import { HeartbeatController } from './heartbeat.js';
16
23
  import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
24
+ // SyncObservability replaced by getContext().observability
25
+ /**
26
+ * How often, while connected, the client polls for any deltas whose best-effort
27
+ * broadcast was lost in transit. This is a client-side eventual-consistency
28
+ * setting, not part of the wire contract, so the server derives nothing from
29
+ * it. That it currently equals the 30-second ping is a coincidence, not a
30
+ * guarantee.
31
+ */
32
+ const CATCHUP_POLL_INTERVAL_MS = 30_000;
33
+ /**
34
+ * Ceiling for the exponential reconnect backoff (`reconnectDelay * 2^n`,
35
+ * ±15% jitter). Local for the same reason as the catch-up poll.
36
+ */
37
+ const MAX_RECONNECT_DELAY_MS = 30_000;
17
38
  // ---------------------------------------------------------------------------
18
- // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
19
- // Consumers pass their own event types as TCollaboration generic parameter.
39
+ // Consumers pass their own event types as the TCollaboration generic parameter.
20
40
  export class SyncWebSocket extends EventEmitter {
21
41
  /**
22
42
  * Subscribe to events with automatic cleanup.
@@ -47,20 +67,20 @@ export class SyncWebSocket extends EventEmitter {
47
67
  /** Periodic catchup interval — polls for missed deltas every 30s while connected */
48
68
  catchupInterval = null;
49
69
  /**
50
- * Application-level heartbeat. The browser WebSocket API hides RFC 6455
51
- * protocol-level ping/pong from JavaScript, so the server's `ws.ping()`
52
- * keepalive can't be observed by client code meaning the client cannot
53
- * tell a healthy idle connection apart from a "zombie" socket where TCP
54
- * silently broke (laptop sleep, NAT timeout, mobile handoff). We send an
55
- * application-level `{ type: 'ping' }` every 30s and force-close the
56
- * socket if no inbound traffic arrives within 10s. ANY inbound message
57
- * counts as proof-of-life — the explicit `pong` is just a guarantee that
58
- * something will arrive even on an idle stream.
70
+ * Application-level heartbeat: ping every 30 seconds and force-close after a
71
+ * 10-second silence. The {@link HeartbeatController} holds the timing and the
72
+ * zombie-socket rationale; the closures below are the only socket access it
73
+ * gets.
59
74
  */
60
- heartbeatTimer = null;
61
- heartbeatTimeoutTimer = null;
62
- static HEARTBEAT_INTERVAL_MS = 30_000;
63
- static HEARTBEAT_TIMEOUT_MS = 10_000;
75
+ heartbeat = new HeartbeatController({
76
+ isSocketOpen: () => this.ws?.readyState === WebSocket.OPEN,
77
+ // Optional-chained rather than asserted: the controller only calls
78
+ // this synchronously after `isSocketOpen()`, so `ws` is present.
79
+ sendPing: () => {
80
+ this.ws?.send(JSON.stringify({ type: 'ping' }));
81
+ },
82
+ forceClose: (reason) => { this.forceClose(reason); },
83
+ });
64
84
  isConnecting = false;
65
85
  isManualClose = false;
66
86
  /** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
@@ -88,11 +108,22 @@ export class SyncWebSocket extends EventEmitter {
88
108
  lastCloseReason = null;
89
109
  lastForceCloseReason = null;
90
110
  sessionErrorAt = null;
91
- lastSyncId;
92
- versionVector;
93
- syncCursor = null;
111
+ /**
112
+ * Sync-position state: the lastSyncId watermark, version vector, and server
113
+ * cursor. The advance discipline is documented at `sendAck` and `handleDelta`;
114
+ * the state itself lives in {@link SyncCursor}.
115
+ */
116
+ cursor;
94
117
  /** Registered collaboration event keys (colon format) for dispatch in onmessage */
95
118
  collaborationEventTypes;
119
+ /**
120
+ * A minimal session adapter handed to the inbound frame dispatch table
121
+ * ({@link dispatchWsFrame}). It exposes only the members the handlers touch;
122
+ * the closure members read live state so a reassignment here (for example the
123
+ * `pendingSubscriptions` reset on close) cannot strand a handler on a stale
124
+ * reference. Built in the constructor, after the state it captures exists.
125
+ */
126
+ frameSession;
96
127
  /**
97
128
  * In-flight `commit` mutation requests keyed by clientTxId. Resolved when
98
129
  * a matching `mutation_result` frame arrives from the server, or rejected on
@@ -101,10 +132,10 @@ export class SyncWebSocket extends EventEmitter {
101
132
  */
102
133
  pendingMutations = new Map();
103
134
  /**
104
- * In-flight `claim` requests keyed by claimId. Resolved when the
105
- * matching `claim_ack` arrives, or rejected on timeout/disconnect.
106
- * Same shape as pendingMutations Phoenix-style request/response
107
- * over a multiplexed connection.
135
+ * In-flight `claim` requests keyed by claimId. Resolved when the matching
136
+ * `claim_ack` arrives, or rejected on timeout or disconnect — the same
137
+ * request/response pattern as `pendingMutations`, multiplexed over the one
138
+ * connection.
108
139
  */
109
140
  pendingClaims = new Map();
110
141
  /**
@@ -118,28 +149,17 @@ export class SyncWebSocket extends EventEmitter {
118
149
  pendingSubscriptions = [];
119
150
  constructor(options) {
120
151
  super();
121
- // Construct WebSocket URL from base Go server URL
152
+ // Construct the WebSocket URL from the base server URL.
122
153
  const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
123
154
  const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
124
155
  const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
125
156
  this.options = {
126
157
  url: wsUrl,
127
158
  reconnectDelay: 1000,
128
- maxReconnectDelay: 30000,
159
+ maxReconnectDelay: MAX_RECONNECT_DELAY_MS,
129
160
  collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
130
161
  syncGroups: [],
131
162
  lastSyncId: 0,
132
- versions: {
133
- tasks: 0,
134
- projects: 0,
135
- users: 0,
136
- events: 0,
137
- inboxitems: 0,
138
- teams: 0,
139
- assignments: 0,
140
- comments: 0,
141
- threads: 0,
142
- },
143
163
  capabilities: {
144
164
  partialBootstrap: true,
145
165
  compressedDeltas: true,
@@ -148,10 +168,25 @@ export class SyncWebSocket extends EventEmitter {
148
168
  },
149
169
  ...options,
150
170
  };
151
- this.lastSyncId = this.options.lastSyncId;
152
- this.versionVector = { ...this.options.versions };
153
- this.syncCursor = null;
171
+ this.cursor = new SyncCursor(this.options.lastSyncId);
154
172
  this.collaborationEventTypes = new Set(options.collaborationEvents ?? ['sheet:selection', 'slide:selection', 'slide:cursor']);
173
+ // Session slice for the inbound frame dispatch table — see the field doc on
174
+ // `frameSession` for why reassigned members are exposed through closures
175
+ // instead of captured references.
176
+ this.frameSession = {
177
+ emit: (event, ...args) => this.emit(event, ...args),
178
+ pendingMutations: this.pendingMutations,
179
+ pendingClaims: this.pendingClaims,
180
+ shiftPendingSubscription: () => this.pendingSubscriptions.shift(),
181
+ options: this.options,
182
+ collaborationEventTypes: this.collaborationEventTypes,
183
+ handleDelta: (delta) => { this.handleDelta(delta); },
184
+ handleSyncResponse: (payload) => { this.handleSyncResponse(payload); },
185
+ handleBootstrapResponse: (payload) => { this.handleBootstrapResponse(payload); },
186
+ handlePresenceUpdate: (message) => {
187
+ this.handlePresenceUpdate(message);
188
+ },
189
+ };
155
190
  }
156
191
  /**
157
192
  * Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
@@ -161,6 +196,17 @@ export class SyncWebSocket extends EventEmitter {
161
196
  this._sessionErrorDetected = true;
162
197
  this.sessionErrorAt = Date.now();
163
198
  }
199
+ /**
200
+ * Clear the session-error latch so `connect()` / `scheduleReconnect()`
201
+ * work again. Called by the store's access-credential recovery path when
202
+ * the close was a re-mintable `ek_`/`rk_` expiry (`4001 credential_expired`),
203
+ * not a login loss — see `isAccessCredentialExpiryCloseReason`. Genuine
204
+ * session losses never clear the latch; re-auth builds a fresh client.
205
+ */
206
+ clearSessionError() {
207
+ this._sessionErrorDetected = false;
208
+ this.sessionErrorAt = null;
209
+ }
164
210
  /**
165
211
  * Connect to the sync engine WebSocket
166
212
  */
@@ -169,8 +215,15 @@ export class SyncWebSocket extends EventEmitter {
169
215
  getContext().logger.debug('WebSocket connect suppressed — session error detected');
170
216
  return;
171
217
  }
172
- if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
173
- getContext().logger.debug('WebSocket already connected or connecting');
218
+ // CLOSING counts as busy: the socket's close teardown is still in
219
+ // flight and its `onclose` (which runs `scheduleReconnect`) hasn't
220
+ // fired yet. Overwriting `this.ws` mid-teardown is what produced the
221
+ // orphaned-socket race — see the stale-socket guards in
222
+ // `setupEventHandlers`.
223
+ if (this.ws?.readyState === WebSocket.OPEN ||
224
+ this.ws?.readyState === WebSocket.CLOSING ||
225
+ this.isConnecting) {
226
+ getContext().logger.debug('WebSocket already connected, connecting, or closing');
174
227
  return;
175
228
  }
176
229
  // Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
@@ -182,18 +235,18 @@ export class SyncWebSocket extends EventEmitter {
182
235
  }
183
236
  this.isConnecting = true;
184
237
  this.isManualClose = false;
185
- // Pattern: one credential, server-resolved identity. The bearer travels
186
- // in a `Sec-WebSocket-Protocol` value (built below), NOT the URL. The
187
- // server is bearer-only (`apiKeyProvider`) and resolves identity from the
188
- // verified token — userId/organizationId are NEVER read from URL params.
238
+ // One credential, server-resolved identity. The bearer travels in a
239
+ // `Sec-WebSocket-Protocol` value (built below), not the URL. The server is
240
+ // bearer-only and resolves identity from the verified token; userId and
241
+ // organizationId are never read from URL parameters.
189
242
  const params = new URLSearchParams({
190
- // Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
243
+ // Intentionally omit lastSyncId, capabilities from URL; these are sent in sync_request
191
244
  // and ack messages to avoid stale baselines on reconnect.
192
- cursor: this.syncCursor || '',
245
+ cursor: this.cursor.syncCursor || '',
193
246
  });
194
- // Participant kind — defaults to `user` for backward compatibility
195
- // with web sessions. Agent runtimes pass `'agent'` so the server's
196
- // capability-token path activates instead of session auth.
247
+ // Participant kind — defaults to `user` for session connections. Agent
248
+ // runtimes pass `'agent'` so the server's capability-token path activates
249
+ // instead of session auth.
197
250
  if (this.options.kind && this.options.kind !== 'user') {
198
251
  params.set('kind', this.options.kind);
199
252
  }
@@ -202,13 +255,13 @@ export class SyncWebSocket extends EventEmitter {
202
255
  params.append('syncGroups', group);
203
256
  });
204
257
  const wsUrl = `${this.options.url}?${params.toString()}`;
205
- // Carry the bearer in a `Sec-WebSocket-Protocol` value, NOT the URL. A
206
- // browser can't set an Authorization header on a WS, but it CAN offer
207
- // subprotocols — and unlike the query string, those don't land in ALB
208
- // access logs, proxies, or browser history. The server reads
209
- // `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol,
210
- // never echoing the token-bearing value back. (Token is the raw ek_/rk_,
211
- // which is subprotocol-token-safe alphanumerics + `_`.)
258
+ // Carry the bearer in a `Sec-WebSocket-Protocol` value, not the URL. A
259
+ // browser cannot set an Authorization header on a WebSocket, but it can
260
+ // offer subprotocols — and unlike the query string, those do not land in
261
+ // load-balancer access logs, proxies, or browser history. The server reads
262
+ // `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol, never
263
+ // echoing the token-bearing value back. The token is the raw `ek_`/`rk_`,
264
+ // which is safe as a subprotocol value (alphanumerics and `_`).
212
265
  const authToken = this.resolveAuthToken();
213
266
  const protocols = authToken
214
267
  ? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
@@ -234,11 +287,22 @@ export class SyncWebSocket extends EventEmitter {
234
287
  * Setup WebSocket event handlers
235
288
  */
236
289
  setupEventHandlers() {
237
- if (!this.ws)
290
+ // Capture the socket this call wires. Every handler below guards on
291
+ // `this.ws === socket` (onclose additionally tolerates a nulled field —
292
+ // see there), so a handler firing late, after `connect()` has replaced the
293
+ // socket, can never clobber the new connection's shared state. Without this
294
+ // guard, an old socket's `onclose` would unconditionally run `this.ws =
295
+ // null; stopCatchupInterval(); stopHeartbeat()` — a reconnect during close
296
+ // teardown then orphaned the fresh socket (a zombie receiving deltas with
297
+ // no timers and broken send paths).
298
+ const socket = this.ws;
299
+ if (!socket)
238
300
  return;
239
- this.ws.onopen = () => {
301
+ socket.onopen = () => {
302
+ if (this.ws !== socket)
303
+ return; // stale socket — a newer connect() owns the state
240
304
  getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
241
- lastSyncId: this.lastSyncId,
305
+ lastSyncId: this.cursor.lastSyncId,
242
306
  reconnectAttempts: this.reconnectAttempts,
243
307
  });
244
308
  this.isConnecting = false;
@@ -249,376 +313,69 @@ export class SyncWebSocket extends EventEmitter {
249
313
  // Send presence update with timezone (server sets presence to "online" on connect,
250
314
  // this improves localTime accuracy by providing the user's actual timezone)
251
315
  this.sendPresenceUpdate('online');
252
- // Flush any queued offline mutations now that we're online
253
- // Fire-and-forget; emit events for UI if desired in the future
254
- (async () => {
255
- try {
256
- const res = await flushOfflineQueueOnce();
257
- if (res.processed > 0) {
258
- getContext().logger.info('Flushed offline mutations', res);
259
- }
260
- }
261
- catch (e) {
262
- getContext().observability.captureOfflineFlushFailure({
263
- error: e instanceof Error ? e.message : String(e),
264
- });
265
- }
266
- })();
267
- // Immediately request incremental sync based on our stored cursor/versions
316
+ // Immediately request incremental sync based on our stored cursor.
317
+ // `requestIncrementalSync` is async a bare call inside try/catch is a
318
+ // rejection hole (the catch never sees it); route failures through
319
+ // `.catch` so they land in the same breadcrumb instead of an
320
+ // unhandled rejection.
321
+ const reportSyncRequestFailure = (e) => {
322
+ getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
323
+ error: e instanceof Error ? e.message : String(e),
324
+ });
325
+ };
268
326
  try {
269
- if (this.lastSyncId && this.lastSyncId > 0) {
327
+ if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
270
328
  // Let server know where we left off before requesting deltas
271
- this.sendAck(this.lastSyncId);
329
+ this.sendAck(this.cursor.lastSyncId);
272
330
  }
273
- this.requestIncrementalSync();
274
331
  }
275
332
  catch (e) {
276
- getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
277
- error: e instanceof Error ? e.message : String(e),
278
- });
333
+ reportSyncRequestFailure(e);
279
334
  }
280
- // Start periodic catchup — polls for missed deltas every 30s.
281
- // Real-time WebSocket delivery is best-effort (fire-and-forget Redis pub/sub).
282
- // This interval guarantees eventual consistency by fetching any deltas that
283
- // were committed to the DB but whose broadcast was lost in transit.
335
+ this.requestIncrementalSync().catch(reportSyncRequestFailure);
336
+ // Start periodic catchup polls for missed deltas every
337
+ // CATCHUP_POLL_INTERVAL_MS while connected. Real-time WebSocket delivery
338
+ // is best-effort, so this interval guarantees eventual consistency by
339
+ // fetching any deltas that were committed but whose broadcast was lost in
340
+ // transit.
284
341
  this.stopCatchupInterval();
285
342
  this.catchupInterval = setInterval(() => {
286
343
  if (this.ws?.readyState === WebSocket.OPEN) {
287
- this.requestIncrementalSync();
344
+ // A rejected sync request must never surface as an unhandled
345
+ // rejection from a background interval — log and let the next
346
+ // poll retry.
347
+ this.requestIncrementalSync().catch((e) => {
348
+ getContext().observability.breadcrumb('Periodic catchup sync request failed', 'sync.websocket', 'warning', {
349
+ error: e instanceof Error ? e.message : String(e),
350
+ });
351
+ });
288
352
  }
289
- }, 30_000);
290
- // Start application-level heartbeat see field declaration for rationale.
291
- this.startHeartbeat();
353
+ }, CATCHUP_POLL_INTERVAL_MS);
354
+ // Start the application-level heartbeat (see HeartbeatController).
355
+ this.heartbeat.start();
292
356
  };
293
- this.ws.onmessage = (event) => {
357
+ socket.onmessage = (event) => {
358
+ if (this.ws !== socket)
359
+ return; // stale socket — drop, don't feed shared state
294
360
  try {
361
+ // Untrusted wire input: parse to `unknown`, then narrow through
362
+ // the frame-envelope guard before dispatch. Payload-level
363
+ // validation (deltas etc.) happens per-frame downstream.
295
364
  const message = JSON.parse(event.data);
296
- // ANY inbound frame proves the socket is alive — clear the
365
+ // Any inbound frame proves the socket is alive — clear the
297
366
  // heartbeat-timeout timer so we don't false-trip force-close
298
367
  // during normal traffic.
299
- this.clearHeartbeatTimeout();
300
- // Handle different message types
301
- if (message.type === 'pong' || message.type === 'ping') {
302
- // Ignore keepalive messages
303
- getContext().logger.debug('Received keepalive', { type: message.type });
368
+ this.heartbeat.clearHeartbeatTimeout();
369
+ if (!isWsInboundFrame(message)) {
370
+ getContext().logger.debug('[SyncWebSocket] dropped malformed wire frame', {
371
+ received: Array.isArray(message) ? 'array' : typeof message,
372
+ });
304
373
  return;
305
374
  }
306
- // Handle different message types
307
- switch (message.type) {
308
- case 'sync_response':
309
- this.handleSyncResponse(message.payload);
310
- break;
311
- case 'bootstrap_response':
312
- this.handleBootstrapResponse(message.payload);
313
- break;
314
- case 'presence_update':
315
- this.handlePresenceUpdate(message);
316
- break;
317
- case 'mutation_result': {
318
- // Ack for a prior `commit` we sent. Canonical shape is
319
- // `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
320
- // DEFENSIVE parse (not a typed cast) because the payload is
321
- // untrusted wire data that may be malformed or from an older server.
322
- const p = message.payload ?? message;
323
- const { clientTxId, success, lastSyncId, error } = p ?? {};
324
- // Defensive: validate notifications against the canonical schema —
325
- // untrusted wire data from a possibly-older/newer server.
326
- const notifications = this.parseNotifications(p?.notifications);
327
- const pending = typeof clientTxId === 'string'
328
- ? this.pendingMutations.get(clientTxId)
329
- : undefined;
330
- if (!pending)
331
- break;
332
- clearTimeout(pending.timeout);
333
- this.pendingMutations.delete(clientTxId);
334
- if (success) {
335
- // Coerce defensively — bigint columns serialize as strings
336
- // from older servers (see normalizeWireDelta).
337
- const ackedSyncId = Number(lastSyncId);
338
- // Notify-instead-of-abort: a guarded write's premise moved. Emit
339
- // the advisory signal so an agent loop can self-heal, AND resolve
340
- // the receipt with it (the commit still succeeded).
341
- if (notifications && notifications.length > 0) {
342
- const txId = typeof clientTxId === 'string' ? clientTxId : '';
343
- const event = {
344
- clientTxId: txId,
345
- rows: notifications.map((n) => ({
346
- model: n.model,
347
- id: n.id,
348
- fields: n.conflictingFields,
349
- writtenBy: n.writtenBy?.kind,
350
- })),
351
- };
352
- const message = formatConflict(event);
353
- const ctx = getContext();
354
- ctx.logger.warn(message);
355
- ctx.observability.breadcrumb(message, 'sync.coordination', 'warning');
356
- ctx.observability.captureConflict(event);
357
- this.emit('conflict:notified', {
358
- clientTxId: txId,
359
- notifications,
360
- });
361
- }
362
- pending.resolve({
363
- lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
364
- ...(notifications && notifications.length > 0
365
- ? { notifications }
366
- : {}),
367
- });
368
- }
369
- else {
370
- // Capture the FULL server error so the user can see what
371
- // actually rejected the mutation. Without this, every
372
- // rejection becomes the generic "mutation failed on
373
- // server" — useless when debugging chart batches that
374
- // tank 40+ ops at once. We stringify object errors so
375
- // structured server payloads (e.g., Zod issues, schema
376
- // violations) survive the trip through `new Error(...)`.
377
- let errorMessage;
378
- let errorCode;
379
- let requiredCapability;
380
- if (typeof error === 'string') {
381
- errorMessage = error;
382
- }
383
- else if (error != null && typeof error === 'object') {
384
- const obj = error;
385
- if (typeof obj.code === 'string')
386
- errorCode = obj.code;
387
- if (typeof obj.message === 'string') {
388
- errorMessage = obj.message;
389
- }
390
- else {
391
- try {
392
- errorMessage = JSON.stringify(error);
393
- }
394
- catch {
395
- errorMessage = String(error);
396
- }
397
- }
398
- if (obj.requiredCapability != null &&
399
- typeof obj.requiredCapability === 'object' &&
400
- typeof obj.requiredCapability.scope === 'string') {
401
- requiredCapability = obj.requiredCapability;
402
- }
403
- }
404
- else {
405
- errorMessage = 'mutation failed on server';
406
- }
407
- // Coordination collision: a stale-context rejection (the write's
408
- // readAt premise moved underneath) or a foreign-claim conflict is
409
- // exactly the collision ClaimLog exists to surface. The notify
410
- // path (success + notifications) emits captureConflict above; a
411
- // HARD rejection must too — otherwise observability.collisions()
412
- // silently misses every rejected write. The conflicted rows ride
413
- // along on the typed error's `conflicts` detail (see
414
- // AbloStaleContextError.toJSON / errorEnvelope).
415
- if (errorCode === 'stale_context' ||
416
- errorCode === 'claim_conflict' ||
417
- errorCode === 'entity_claimed' ||
418
- errorCode?.startsWith('policy:') === true) {
419
- const rawConflicts = error != null &&
420
- typeof error === 'object' &&
421
- Array.isArray(error.conflicts)
422
- ? error
423
- .conflicts
424
- : [];
425
- const conflictEvent = {
426
- clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
427
- rows: rawConflicts.map((r) => ({
428
- model: typeof r.model === 'string' ? r.model : 'unknown',
429
- id: typeof r.id === 'string' ? r.id : 'unknown',
430
- fields: [],
431
- })),
432
- };
433
- const ctx = getContext();
434
- ctx.observability.breadcrumb(formatConflict(conflictEvent), 'sync.coordination', 'warning');
435
- ctx.observability.captureConflict(conflictEvent);
436
- }
437
- // Build the proper typed AbloError from the wire code via the
438
- // shared factory — the same code→class mapping the HTTP commit
439
- // path uses (`translateHttpError`). This keeps rejected commits
440
- // inside the typed hierarchy (capability denials →
441
- // CapabilityError with `.requiredCapability`; foreign-claim
442
- // conflicts → AbloClaimedError; everything else → the subclass
443
- // its registry `httpStatus` implies) instead of a hand-rolled
444
- // `new Error`, so callers can `instanceof`/`e.type` it and
445
- // downstream retry logic can read the contract's retryability.
446
- pending.reject(errorFromWire(errorMessage, {
447
- code: errorCode,
448
- requiredCapability,
449
- }));
450
- }
451
- break;
452
- }
453
- case 'claim_ack': {
454
- // Ack for a prior `claim` we sent. Wire format mirrors
455
- // apps/sync-server/src/hub/types.ts ClaimAckMessage:
456
- // { type: 'claim_ack',
457
- // payload: { claimId, success, syncGroups?,
458
- // ttlSeconds?, error? } }
459
- const p = message.payload ?? {};
460
- const { claimId, success, syncGroups, ttlSeconds, error } = p;
461
- const pending = typeof claimId === 'string'
462
- ? this.pendingClaims.get(claimId)
463
- : undefined;
464
- if (!pending)
465
- break;
466
- clearTimeout(pending.timeout);
467
- this.pendingClaims.delete(claimId);
468
- if (success) {
469
- pending.resolve({
470
- syncGroups: Array.isArray(syncGroups) ? syncGroups : [],
471
- ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
472
- });
473
- }
474
- else {
475
- const code = error?.code && typeof error.code === 'string'
476
- ? error.code
477
- : 'claim_rejected';
478
- const msg = error?.message && typeof error.message === 'string'
479
- ? error.message
480
- : 'claim rejected by server';
481
- // Capability denials get the typed CapabilityError so
482
- // callers can read `.requiredCapability` and attenuate-
483
- // and-retry the claim with a narrower token.
484
- if (code === 'capability_scope_denied' ||
485
- code === 'capability_invalid') {
486
- const rc = error
487
- ?.requiredCapability;
488
- const requiredCapability = rc != null &&
489
- typeof rc === 'object' &&
490
- typeof rc.scope === 'string'
491
- ? rc
492
- : undefined;
493
- pending.reject(new CapabilityError(code, msg, requiredCapability));
494
- }
495
- else {
496
- // Route through the shared factory so a failed claim_ack is a
497
- // typed AbloError (registry code → right subclass), symmetric
498
- // with the commit `mutation_result` path — never a bare Error.
499
- pending.reject(errorFromWire(msg, { code }));
500
- }
501
- }
502
- break;
503
- }
504
- case 'subscription_ack': {
505
- // Ack for a prior `update_subscription`. The wire carries no
506
- // correlation id, so FIFO-match against the oldest pending
507
- // request — the server applies and acks subscription updates
508
- // in receive order. Validated through the canonical zod schema
509
- // (mirrors how the Hub validates inbound frames).
510
- const pending = this.pendingSubscriptions.shift();
511
- if (!pending)
512
- break;
513
- clearTimeout(pending.timeout);
514
- const parsed = subscriptionAckPayloadSchema.safeParse(message.payload);
515
- if (!parsed.success) {
516
- // Unreadable ack — resolve the pending request as a failure
517
- // rather than hang it until timeout.
518
- pending.reject(errorFromWire('malformed subscription_ack from server', {
519
- code: 'malformed_subscription',
520
- }));
521
- break;
522
- }
523
- const ack = parsed.data;
524
- if (ack.success) {
525
- // Keep the reconnect URL aligned with current interest: a
526
- // reconnect re-subscribes from `this.options.syncGroups`.
527
- this.options.syncGroups = ack.syncGroups;
528
- pending.resolve({ syncGroups: ack.syncGroups });
529
- }
530
- else {
531
- pending.reject(errorFromWire(ack.error?.message ?? 'update_subscription rejected by server', { code: ack.error?.code ?? 'malformed_subscription' }));
532
- }
533
- break;
534
- }
535
- case 'claim_expired': {
536
- // Server-initiated expiry notification. Emit as a typed
537
- // event so consumers can react (re-claim with a fresh
538
- // capability, or accept the drop). The claim is already
539
- // inactive server-side by the time this arrives.
540
- const p = message.payload ?? {};
541
- if (typeof p.claimId === 'string') {
542
- this.recordClaim('expired', p);
543
- this.emit('claim_expired', { claimId: p.claimId });
544
- }
545
- break;
546
- }
547
- case 'claim_rejected': {
548
- // Server denied an `claim_begin` because the target is
549
- // already claimed by another participant. Forward the
550
- // payload as-is — the ClaimStream consumer interprets
551
- // the conflict shape (peerId, target, etc.).
552
- this.recordClaim('rejected', message.payload ?? {});
553
- this.emit('claim_rejected', message.payload ?? {});
554
- break;
555
- }
556
- case 'claim_acquired': {
557
- // Opt-in fair queue: the target was free, so the lease is ours
558
- // immediately (no waiting). Payload carries { claimId, target }.
559
- this.recordClaim('acquired', message.payload ?? {});
560
- this.emit('claim_acquired', message.payload ?? {});
561
- break;
562
- }
563
- case 'claim_queue': {
564
- // Per-entity wait-queue snapshot for reactive `queue(id)`. Not a
565
- // single claim's state change, so it isn't logged — the per-claim
566
- // `queued`/`granted` events already tell that story.
567
- this.emit('claim_queue', message.payload ?? {});
568
- break;
569
- }
570
- case 'claim_queued': {
571
- // Opt-in fair queue: our claim is waiting in line. Payload
572
- // carries { claimId, target, position }.
573
- this.recordClaim('queued', message.payload ?? {});
574
- this.emit('claim_queued', message.payload ?? {});
575
- break;
576
- }
577
- case 'claim_granted': {
578
- // Our queued claim reached the head — the lease is now ours.
579
- this.recordClaim('granted', message.payload ?? {});
580
- this.emit('claim_granted', message.payload ?? {});
581
- break;
582
- }
583
- case 'claim_lost': {
584
- // A held/granted claim was taken from us (TTL lapse, revoke).
585
- this.recordClaim('lost', message.payload ?? {});
586
- this.emit('claim_lost', message.payload ?? {});
587
- break;
588
- }
589
- case 'delta': {
590
- const p = message.payload;
591
- if (p?.actionType || p?.modelName) {
592
- this.handleDelta(p);
593
- }
594
- else if (Array.isArray(p?.deltas)) {
595
- for (const d of p.deltas) {
596
- if (d?.actionType || d?.modelName)
597
- this.handleDelta(d);
598
- }
599
- if (p?.newVersions) {
600
- Object.assign(this.versionVector, p.newVersions);
601
- }
602
- }
603
- break;
604
- }
605
- case undefined: // Legacy support: bare delta
606
- if (message.actionType || message.modelName) {
607
- this.handleDelta(message);
608
- }
609
- break;
610
- default: {
611
- // Collaboration events use underscore wire format (e.g., 'sheet_selection')
612
- // Convert to colon format for the event map (e.g., 'sheet:selection')
613
- const eventKey = message.type?.replace(/_/g, ':');
614
- if (eventKey && this.collaborationEventTypes.has(eventKey)) {
615
- this.emit(eventKey, message.payload);
616
- }
617
- else {
618
- getContext().logger.debug('Received unknown message type', { message });
619
- }
620
- }
621
- }
375
+ // Dispatch by frame type (see dispatchWsFrame). The session adapter
376
+ // exposes only the members the handlers touch; keepalives, the older
377
+ // bare-delta form, and collaboration events are all routed there too.
378
+ dispatchWsFrame(this.frameSession, message);
622
379
  }
623
380
  catch (error) {
624
381
  getContext().observability.captureWebSocketError({
@@ -627,7 +384,9 @@ export class SyncWebSocket extends EventEmitter {
627
384
  });
628
385
  }
629
386
  };
630
- this.ws.onerror = (_event) => {
387
+ socket.onerror = (_event) => {
388
+ if (this.ws !== socket)
389
+ return; // stale socket — its errors are no longer ours
631
390
  // WebSocket errors are DOM Events, not Error objects
632
391
  // Check if we're offline first
633
392
  if (!getContext().onlineStatus.isOnline()) {
@@ -635,8 +394,8 @@ export class SyncWebSocket extends EventEmitter {
635
394
  this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
636
395
  return;
637
396
  }
638
- // After session error, suppress Sentry capture — the root cause is already reported.
639
- // Still emit so SyncedStore can update UI state.
397
+ // After a session error, suppress error capture — the root cause is
398
+ // already reported. Still emit so the store can update UI state.
640
399
  const error = new AbloConnectionError(`WebSocket connection failed`);
641
400
  if (!this._sessionErrorDetected) {
642
401
  getContext().observability.captureWebSocketError({
@@ -646,7 +405,17 @@ export class SyncWebSocket extends EventEmitter {
646
405
  }
647
406
  this.emit('error', error);
648
407
  };
649
- this.ws.onclose = (event) => {
408
+ socket.onclose = (event) => {
409
+ // Stale-socket close: a newer socket already owns the connection
410
+ // state — don't null it, stop its timers, or schedule a duplicate
411
+ // reconnect (the orphaning race this guard exists for). The one
412
+ // deliberate asymmetry vs the other handlers: `this.ws === null`
413
+ // (manual `disconnect()` nulls the field before the close event
414
+ // lands) still runs the full body, so in-flight work is rejected
415
+ // promptly and 'disconnected' reaches consumers — the pre-guard
416
+ // behavior manual close always had.
417
+ if (this.ws !== null && this.ws !== socket)
418
+ return;
650
419
  const everOpened = this._everOpened;
651
420
  this.lastCloseAt = Date.now();
652
421
  this.lastCloseCode = event.code;
@@ -663,7 +432,7 @@ export class SyncWebSocket extends EventEmitter {
663
432
  this.isConnecting = false;
664
433
  this.ws = null;
665
434
  this.stopCatchupInterval();
666
- this.stopHeartbeat();
435
+ this.heartbeat.stop();
667
436
  // Cancel in-flight mutations — the socket that was carrying them is
668
437
  // gone, and the server-side state may or may not have accepted each
669
438
  // one. Rejecting promptly is better than hanging the caller forever;
@@ -706,6 +475,20 @@ export class SyncWebSocket extends EventEmitter {
706
475
  }
707
476
  this.pendingSubscriptions = [];
708
477
  }
478
+ // Protocol-version rejection (4010): terminal. Reconnecting cannot heal a
479
+ // version mismatch — only upgrading the SDK, or rolling the server
480
+ // forward, can — so a blind retry here would loop forever against the
481
+ // same typed close. Surface it and stop.
482
+ if (event.code === WS_CLOSE_PROTOCOL_VERSION) {
483
+ getContext().observability.captureWebSocketError({
484
+ context: 'protocol-version-close',
485
+ code: event.code,
486
+ reason: event.reason,
487
+ });
488
+ this.emit('protocol_mismatch', event);
489
+ this.emit('disconnected', event);
490
+ return;
491
+ }
709
492
  // Check for session-related close codes
710
493
  // 1008 = Policy Violation (often auth)
711
494
  // 4001 = Unauthorized (custom)
@@ -723,21 +506,24 @@ export class SyncWebSocket extends EventEmitter {
723
506
  reason: event.reason,
724
507
  });
725
508
  this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
726
- // Don't reconnect for session errors - user needs to re-authenticate
509
+ // Don't reconnect from here. For a genuine session loss the user must
510
+ // re-authenticate; for an expired access credential (`credential_expired`)
511
+ // the store's session-error handler re-mints, clears the latch, and
512
+ // drives the reconnect itself.
727
513
  this.emit('disconnected', event);
728
514
  return;
729
515
  }
730
516
  // Handshake failure: `onclose` fired before `onopen` ever did, so the
731
- // server rejected the upgrade (typically 401/403 on a bad cookie, but
732
- // could also be a CORS/origin reject or an LB 5xx). The browser hides
733
- // the HTTP status behind code 1006, so we can't tell which from here.
517
+ // server rejected the upgrade (typically 401/403 on a bad cookie, but it
518
+ // could also be a CORS/origin reject or a load-balancer 5xx). The browser
519
+ // hides the HTTP status behind code 1006, so we cannot tell which from
520
+ // here.
734
521
  //
735
- // Emit a dedicated event and SKIP the internal reconnect — the owner
736
- // (SyncedStore / ConnectionStore) should run an auth-validating HTTP
737
- // probe to distinguish session expiry from a transient network issue
738
- // and transition the UI accordingly. Reconnecting blindly is what
739
- // produced the infinite "offline → reconnecting → offline" loop on
740
- // stale cookies.
522
+ // Emit a dedicated event and skip the internal reconnect — the owner
523
+ // should run an auth-validating HTTP probe to distinguish session expiry
524
+ // from a transient network issue and transition the UI accordingly.
525
+ // Reconnecting blindly is what produced the infinite
526
+ // "offline → reconnecting → offline" loop on stale cookies.
741
527
  if (!everOpened && !this.isManualClose) {
742
528
  getContext().observability.captureWebSocketError({
743
529
  context: 'handshake-failed-close',
@@ -756,67 +542,97 @@ export class SyncWebSocket extends EventEmitter {
756
542
  };
757
543
  }
758
544
  /**
759
- * Normalize a wire delta at the receive boundary. The contract
760
- * (`syncDeltaWireCoreSchema`) says `id: number`, but deployed servers
761
- * have sent the raw Postgres BIGINT serialization a STRING and
762
- * every downstream watermark gate (`typeof syncId === 'number'` in
763
- * `Database.processDeltaBatch`, the metadata-cursor update, numeric
764
- * `>=` threshold comparisons in TransactionQueue) silently breaks on
765
- * strings: acks are withheld, the resume cursor never advances, and
766
- * every reconnect replays from 0 (or force-bootstraps once the gap
767
- * exceeds maxDeltaGapForPartial). Coerce ONCE here so the rest of the
768
- * client can trust the declared type and old servers stay compatible.
545
+ * Validates and normalizes a wire delta at the receive boundary the single
546
+ * seam every inbound delta (a `delta` frame, a batch element, a `sync_response`
547
+ * replay, or the older bare frame) passes through before it is emitted,
548
+ * persisted, or allowed to advance any watermark.
549
+ *
550
+ * Normalization keeps already-deployed servers compatible:
551
+ * - `id`: the contract says `number`, but some servers have sent the raw
552
+ * Postgres BIGINT serialization a string and every downstream watermark
553
+ * gate treats a string as invalid, so acks are withheld, the resume cursor
554
+ * never advances, and every reconnect replays from zero. Coerce it once here.
555
+ * - `transactionId` / `createdBy`: the server projection sends these as
556
+ * nullable (and `createdBy` as a nested reference); the client contract
557
+ * types them as optional strings and never reads them, so normalize them to
558
+ * absent rather than reject every real server delta.
559
+ *
560
+ * Validation runs `clientSyncDeltaSchema.safeParse`, the canonical wire
561
+ * contract. A frame that fails is dropped (returns `null`) with a debug log
562
+ * and an observability breadcrumb; it is never applied. There is one parse per
563
+ * delta — callers must not re-parse.
769
564
  */
770
- normalizeWireDelta(delta) {
771
- if (typeof delta.id === 'number')
772
- return delta;
773
- const coerced = Number(delta.id);
774
- return { ...delta, id: Number.isFinite(coerced) ? coerced : 0 };
565
+ normalizeWireDelta(raw) {
566
+ let candidate = raw;
567
+ if (isRecord(raw)) {
568
+ const normalized = { ...raw };
569
+ if (typeof normalized.id !== 'number') {
570
+ const coerced = Number(normalized.id);
571
+ normalized.id = Number.isFinite(coerced) ? coerced : 0;
572
+ }
573
+ if (normalized.transactionId === null)
574
+ delete normalized.transactionId;
575
+ if (normalized.createdBy !== undefined && typeof normalized.createdBy !== 'string') {
576
+ delete normalized.createdBy;
577
+ }
578
+ candidate = normalized;
579
+ }
580
+ const parsed = clientSyncDeltaSchema.safeParse(candidate);
581
+ if (!parsed.success) {
582
+ const issue = parsed.error.issues[0];
583
+ const detail = {
584
+ issue: issue ? `${issue.path.join('.')}: ${issue.message}` : 'not an object',
585
+ modelName: isRecord(raw) && typeof raw.modelName === 'string' ? raw.modelName : undefined,
586
+ actionType: isRecord(raw) && typeof raw.actionType === 'string' ? raw.actionType : undefined,
587
+ };
588
+ getContext().logger.debug('[SyncWebSocket] dropped malformed wire delta', detail);
589
+ getContext().observability.breadcrumb('Dropped malformed wire delta', 'sync.websocket', 'warning', detail);
590
+ return null;
591
+ }
592
+ return parsed.data;
775
593
  }
776
594
  /**
777
- * Handle incoming sync delta
595
+ * Handle incoming sync delta (untrusted wire input — validated and
596
+ * normalized by {@link normalizeWireDelta}; malformed deltas are dropped).
778
597
  */
779
598
  handleDelta(rawDelta) {
780
599
  const delta = this.normalizeWireDelta(rawDelta);
600
+ if (!delta)
601
+ return;
781
602
  getContext().logger.debug('Received delta', {
782
603
  action: delta.actionType,
783
604
  model: delta.modelName,
784
605
  id: delta.modelId,
785
606
  syncId: delta.id,
786
607
  });
787
- // DO NOT advance `this.lastSyncId` on receipt. The runtime cursor
788
- // must stay consistent with what's persisted in IDB otherwise the
789
- // next `requestIncrementalSync()` (and the connect-time handshake)
790
- // sends an optimistic cursor and the server skips deltas that never
791
- // landed in IDB. `this.lastSyncId` is advanced only in `sendAck()`,
792
- // which is gated on `BaseSyncedStore.flushPendingDeltas`'s
793
- // `persistedSyncId` watermark. See Replicache's "lastMutationID
794
- // read in the same transaction as the client view" rule.
608
+ // Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
609
+ // must stay consistent with what has been persisted locally; otherwise the
610
+ // next `requestIncrementalSync()` (and the connect-time handshake) would
611
+ // send an optimistic cursor and the server would skip deltas that never
612
+ // landed in local storage. `this.cursor.lastSyncId` advances only in
613
+ // `sendAck()`, which the store gates on its persisted-syncId watermark, so
614
+ // the cursor is never read ahead of the persisted client view.
795
615
  //
796
- // Version vector is also intentionally NOT updated here for the
797
- // same reason left to the persistence-gated path.
616
+ // The version vector is intentionally not updated here for the same reason;
617
+ // it is left to the persistence-gated path.
798
618
  // Emit delta for processing. Ack will be sent by SyncedStore after persistence.
799
619
  this.emit('delta', delta);
800
620
  }
801
621
  /**
802
- * Send acknowledgment for received delta with version vector.
803
- *
804
- * This is the SOLE forward-mover of `this.lastSyncId` for live
805
- * deltas. Called by `BaseSyncedStore.flushPendingDeltas` with the
806
- * `persistedSyncId` watermark i.e. only after the deltas have
807
- * actually committed to IDB. Keeping the cursor advance here (rather
808
- * than at receipt in `handleDelta`/`handleSyncResponse`) means the
809
- * cursor never gets ahead of the persisted view, so reconnect/
810
- * catch-up requests can't accidentally skip un-persisted deltas.
622
+ * Acknowledges received deltas up to the given syncId. This is the only place
623
+ * `this.cursor.lastSyncId` moves forward for live deltas. The store calls it
624
+ * with its persisted-syncId watermark that is, only after the deltas have
625
+ * committed to local storage. Advancing the cursor here, rather than at
626
+ * receipt in `handleDelta` or `handleSyncResponse`, keeps the cursor from
627
+ * getting ahead of the persisted view, so reconnect and catch-up requests
628
+ * cannot skip un-persisted deltas.
811
629
  */
812
630
  sendAck(syncId) {
813
631
  // Advance the local cursor *and* the version vector for this ack —
814
632
  // these are what `requestIncrementalSync` and the connect handshake
815
633
  // will send next, and what `getLastSyncId()` reports for clean-
816
634
  // shutdown persistence.
817
- if (syncId > this.lastSyncId) {
818
- this.lastSyncId = syncId;
819
- }
635
+ this.cursor.ackAdvance(syncId);
820
636
  if (this.ws?.readyState !== WebSocket.OPEN)
821
637
  return;
822
638
  this.send({
@@ -863,109 +679,16 @@ export class SyncWebSocket extends EventEmitter {
863
679
  }
864
680
  }
865
681
  /**
866
- * Project the SDK's `MutationOperation[]` onto the canonical wire
867
- * `CommitMessage`. This is the single serialize boundary between the SDK op
868
- * type (loose `type: string`, plus an SDK-internal `options` the server never
869
- * reads) and the strict wire contract. The per-field map gives compile-time
870
- * drift detection (a `CommitOperation` shape change breaks here) and the lone
871
- * `as` narrows the validated op `type` to the wire union — the only
872
- * loosening, localized to this boundary.
873
- */
874
- buildCommitFrame(operations, clientTxId, causedByTaskId, reads) {
875
- const payload = {
876
- operations: operations.map((op) => ({
877
- type: op.type,
878
- model: op.model,
879
- id: op.id,
880
- input: op.input,
881
- transactionId: op.transactionId,
882
- readAt: op.readAt,
883
- onStale: op.onStale,
884
- })),
885
- clientTxId,
886
- };
887
- if (causedByTaskId)
888
- payload.causedByTaskId = causedByTaskId;
889
- // Batch-level read-set (STORM layer): rows/groups the batch was premised on.
890
- if (reads && reads.length > 0)
891
- payload.reads = [...reads];
892
- return { type: 'commit', payload };
893
- }
894
- /**
895
- * Send a `commit` mutation request over the existing WebSocket and
896
- * resolve when the server's `mutation_result` frame comes back with
897
- * the same `clientTxId`. The wire-level frame is `{ type: 'commit',
898
- * payload: { operations, clientTxId } }` — matching the
899
- * `handleCommit` path on `apps/sync-server/src/hub/Hub.ts` (see the
900
- * dispatch at Hub.ts:737).
682
+ * Sends a `commit` mutation request over the existing WebSocket and resolves
683
+ * when the server's `mutation_result` frame comes back with the same
684
+ * `clientTxId`. The wire frame is `{ type: 'commit', payload: { operations,
685
+ * clientTxId } }`.
901
686
  *
902
- * Historical naming note: this was originally `sendBatchAck` back when
903
- * the Go sync-engine used a GraphQL `batchAck` mutation. The TS
904
- * sync-server uses `type: 'commit'` over WebSocket exclusively. The
905
- * method name now matches the wire protocol so the ack/commit naming
906
- * confusion stops here.
907
- *
908
- * Times out after 15s of silence from the server. The socket may close
909
- * during an in-flight mutation (network flap, server restart); we do
910
- * NOT auto-retry here — the caller's TransactionQueue owns retry +
911
- * offline replay semantics and the SDK shouldn't duplicate that logic.
912
- */
913
- /**
914
- * Defensively validate the optional `notifications` array off a commit ack.
915
- * Untrusted wire data — a malformed entry is dropped rather than throwing,
916
- * so a bad notification never sinks an otherwise-successful commit.
687
+ * Times out after 15 seconds of silence from the server. The socket may close
688
+ * during an in-flight mutation (a network flap, a server restart); this does
689
+ * not auto-retry the caller's transaction queue owns retry and offline
690
+ * replay, and the SDK does not duplicate that logic.
917
691
  */
918
- parseNotifications(raw) {
919
- if (!Array.isArray(raw) || raw.length === 0)
920
- return undefined;
921
- const out = [];
922
- for (const entry of raw) {
923
- const parsed = staleNotificationSchema.safeParse(entry);
924
- if (parsed.success)
925
- out.push(parsed.data);
926
- }
927
- return out.length > 0 ? out : undefined;
928
- }
929
- /**
930
- * Single instrumentation point for claim events. Every `claim_*` frame routes
931
- * through here so a developer debugging a collision gets one consistent trace
932
- * — a console line AND a structured capture — without each dispatch case
933
- * re-deriving the row/holder shape. The wire payload is loosely typed
934
- * (`Record<string, unknown>`), so this is the one place that narrows it into
935
- * a {@link ClaimEvent}.
936
- */
937
- recordClaim(phase, payload) {
938
- const str = (v) => typeof v === 'string' ? v : undefined;
939
- // Targets arrive flat ({ entityType, entityId }) or nested under `target`.
940
- const target = payload.target && typeof payload.target === 'object'
941
- ? payload.target
942
- : payload;
943
- const kind = wireParticipantKindSchema.safeParse(payload.participantKind);
944
- const event = {
945
- phase,
946
- claimId: str(payload.claimId),
947
- model: str(target.entityType) ?? str(target.model),
948
- id: str(target.entityId) ?? str(target.id),
949
- field: str(target.field),
950
- actor: str(payload.actor) ?? str(payload.heldBy),
951
- participantKind: kind.success ? kind.data : undefined,
952
- position: typeof payload.position === 'number' ? payload.position : undefined,
953
- reason: str(payload.policyReason) ?? str(payload.reason),
954
- };
955
- const message = formatClaim(event);
956
- // A rejection or lost lease is the collision a developer is actively
957
- // debugging → warn (shows at the default log level). The routine events
958
- // (acquired/queued/granted/expired) are debug-only so they never drown the
959
- // console until you opt in with `new Ablo({ debug: true })`.
960
- const isCollision = phase === 'rejected' || phase === 'lost';
961
- const ctx = getContext();
962
- if (isCollision)
963
- ctx.logger.warn(message);
964
- else
965
- ctx.logger.debug(message);
966
- ctx.observability.breadcrumb(message, 'sync.coordination', isCollision ? 'warning' : 'info');
967
- ctx.observability.captureClaim(event);
968
- }
969
692
  sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
970
693
  if (this.ws?.readyState !== WebSocket.OPEN) {
971
694
  return Promise.reject(this.notConnectedError('commit'));
@@ -981,7 +704,7 @@ export class SyncWebSocket extends EventEmitter {
981
704
  // an open turn — keeps the wire shape stable for sessions
982
705
  // that don't use turns. Servers that don't know the field
983
706
  // ignore it; newer servers stamp it onto every delta.
984
- const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
707
+ const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
985
708
  this.ws.send(JSON.stringify(frame));
986
709
  }
987
710
  catch (error) {
@@ -1003,25 +726,18 @@ export class SyncWebSocket extends EventEmitter {
1003
726
  if (this.ws?.readyState !== WebSocket.OPEN) {
1004
727
  throw this.notConnectedError('commit');
1005
728
  }
1006
- const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
729
+ const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
1007
730
  this.ws.send(JSON.stringify(frame));
1008
731
  }
1009
732
  /**
1010
- * Activate a participant claim on this connection. Multiplexed
1011
- * subscription pattern (Phoenix Channels / Pusher) the same
1012
- * connection can hold N concurrent claims, each scoped to a
1013
- * different set of sync groups.
733
+ * Activates a participant claim on this connection. One connection can hold
734
+ * several concurrent claims at once, each scoped to a different set of sync
735
+ * groups, so the SDK reuses the existing connection instead of opening a
736
+ * separate socket per scope.
1014
737
  *
1015
- * Returns a promise that resolves with the server-canonicalized
1016
- * `syncGroups` and effective `ttlSeconds` once `claim_ack` arrives,
1017
- * or rejects with a typed error on `success: false` ack /
1018
- * timeout / disconnect.
1019
- *
1020
- * Why this exists: the old scoped-participant path opened a separate
1021
- * WS per scope. With claims, the SDK reuses the existing session/agent
1022
- * connection — one TCP, N logical participants. See
1023
- * `apps/sync-server/docs/PARTICIPANT_CLAIMS.md` for the migration
1024
- * framing (Phase A.1).
738
+ * Returns a promise that resolves with the server-canonicalized `syncGroups`
739
+ * and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
740
+ * error on a failed ack, a timeout, or a disconnect.
1025
741
  */
1026
742
  sendClaim(claimId, syncGroups, options) {
1027
743
  if (this.ws?.readyState !== WebSocket.OPEN) {
@@ -1086,22 +802,21 @@ export class SyncWebSocket extends EventEmitter {
1086
802
  }
1087
803
  }
1088
804
  /**
1089
- * Move this connection's READ interest — replace the connection-level
1090
- * sync groups mid-session as the user opens/closes entities. This is the
1091
- * area-of-interest (AOI) navigation primitive: the server fans out
1092
- * deltas only for groups currently in view, instead of the frozen set
1093
- * chosen at connect.
805
+ * Moves this connection's read interest — replaces the connection-level sync
806
+ * groups mid-session as the user opens and closes entities. This is the
807
+ * area-of-interest navigation primitive: the server fans out deltas only for
808
+ * the groups currently in view, rather than the fixed set chosen at connect.
1094
809
  *
1095
- * Full-set replace semantics — pass the complete new group list, not a
1096
- * delta. Resolves with the server's effective set once `subscription_ack`
1097
- * arrives; rejects (typed) on a scope denial (a restricted `rk_` key
1098
- * requesting a group outside its allowlist), timeout, or disconnect. On
1099
- * success the new set is recorded as `options.syncGroups` so a later
1100
- * reconnect re-subscribes to current interest, not the connect-time set.
810
+ * This is a full-set replace: pass the complete new group list, not a delta.
811
+ * Resolves with the server's effective set once `subscription_ack` arrives;
812
+ * rejects (with a typed error) on a scope denial (a restricted `rk_` key
813
+ * requesting a group outside its allowlist), a timeout, or a disconnect. On
814
+ * success the new set is recorded as `options.syncGroups`, so a later reconnect
815
+ * re-subscribes to the current interest rather than the connect-time set.
1101
816
  *
1102
- * Distinct from {@link sendClaim} (write-claim, per-op, TTL'd) — this is
1103
- * the read side and carries no capability token of its own; it's bounded
1104
- * by the connection credential's grant.
817
+ * Distinct from {@link sendClaim} (a write claim, per operation, with a TTL):
818
+ * this is the read side, carries no capability token of its own, and is
819
+ * bounded by the connection credential's grant.
1105
820
  */
1106
821
  updateSubscription(syncGroups, options) {
1107
822
  if (this.ws?.readyState !== WebSocket.OPEN) {
@@ -1136,9 +851,9 @@ export class SyncWebSocket extends EventEmitter {
1136
851
  });
1137
852
  }
1138
853
  /**
1139
- * Compatibility setter for direct SyncWebSocket users. The SDK-owned
1140
- * `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
1141
- * shared credential source instead of this copied value.
854
+ * Sets a fixed credential for callers that construct the socket directly. The
855
+ * SDK instead supplies `getAuthToken`, so reconnects read the shared
856
+ * credential source rather than this copied value.
1142
857
  */
1143
858
  setCapabilityToken(token) {
1144
859
  this.options.capabilityToken = token;
@@ -1233,17 +948,17 @@ export class SyncWebSocket extends EventEmitter {
1233
948
  if (this._sessionErrorDetected) {
1234
949
  return;
1235
950
  }
1236
- // Don't attempt reconnection while offline.
1237
- // SyncedStore.handleNetworkOnline() owns the offline→online transition:
1238
- // it bootstraps first, then calls syncWebSocket.connect() explicitly.
1239
- // Self-reconnecting here would bypass the bootstrap gate and cause stale data.
951
+ // Don't attempt reconnection while offline. The owning store manages the
952
+ // offline→online transition: it bootstraps first, then calls `connect()`
953
+ // explicitly. Self-reconnecting here would bypass that bootstrap gate and
954
+ // surface stale data.
1240
955
  if (!getContext().onlineStatus.isOnline()) {
1241
956
  this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
1242
957
  return;
1243
958
  }
1244
- // Give up after MAX_RECONNECT_ATTEMPTS consecutive failures.
1245
- // The user can recover by refreshing or when network comes back online
1246
- // (handleNetworkOnline resets attempts and reconnects).
959
+ // Give up after MAX_RECONNECT_ATTEMPTS consecutive failures. The user can
960
+ // recover by refreshing, or the store resets the attempt count and
961
+ // reconnects when the network returns.
1247
962
  if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
1248
963
  this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
1249
964
  return;
@@ -1281,7 +996,7 @@ export class SyncWebSocket extends EventEmitter {
1281
996
  disconnect() {
1282
997
  this.isManualClose = true;
1283
998
  this.stopCatchupInterval();
1284
- this.stopHeartbeat();
999
+ this.heartbeat.stop();
1285
1000
  if (this.reconnectTimer) {
1286
1001
  clearTimeout(this.reconnectTimer);
1287
1002
  this.reconnectTimer = null;
@@ -1291,67 +1006,6 @@ export class SyncWebSocket extends EventEmitter {
1291
1006
  this.ws = null;
1292
1007
  }
1293
1008
  }
1294
- /**
1295
- * Application-level heartbeat. Every `HEARTBEAT_INTERVAL_MS` while
1296
- * `OPEN`, send `{ type: 'ping' }` and arm a `HEARTBEAT_TIMEOUT_MS`
1297
- * watchdog. Any inbound frame (handled in `onmessage`) clears the
1298
- * watchdog. If the watchdog fires, we treat the connection as
1299
- * zombie and force-close it — `onclose` then triggers the existing
1300
- * reconnect path.
1301
- *
1302
- * Why both sides need this:
1303
- * - The server sends RFC 6455 protocol pings via `ws.ping()` every
1304
- * 30s. Browsers auto-respond with a pong but DO NOT expose either
1305
- * frame to JavaScript, so the client is blind to its own keepalive.
1306
- * - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
1307
- * the browser may keep `readyState === OPEN` for minutes before
1308
- * the OS surfaces the broken connection. App-level traffic is
1309
- * the only signal we can observe.
1310
- */
1311
- startHeartbeat() {
1312
- this.stopHeartbeat();
1313
- this.heartbeatTimer = setInterval(() => {
1314
- if (this.ws?.readyState !== WebSocket.OPEN)
1315
- return;
1316
- // Send the ping. If `send` throws, the socket is already dead —
1317
- // force-close so onclose triggers the reconnect cycle.
1318
- try {
1319
- this.ws.send(JSON.stringify({ type: 'ping' }));
1320
- }
1321
- catch (err) {
1322
- getContext().observability.captureWebSocketError({
1323
- context: 'heartbeat-send-failed',
1324
- error: err instanceof Error ? err.message : String(err),
1325
- });
1326
- this.forceClose('heartbeat-send-failed');
1327
- return;
1328
- }
1329
- // Arm the timeout. ANY inbound message clears it (see onmessage).
1330
- // We don't require an explicit `pong` — a delta or any other frame
1331
- // is equally good proof-of-life.
1332
- if (this.heartbeatTimeoutTimer)
1333
- clearTimeout(this.heartbeatTimeoutTimer);
1334
- this.heartbeatTimeoutTimer = setTimeout(() => {
1335
- getContext().observability.captureWebSocketError({
1336
- context: 'heartbeat-timeout',
1337
- });
1338
- this.forceClose('heartbeat-timeout');
1339
- }, SyncWebSocket.HEARTBEAT_TIMEOUT_MS);
1340
- }, SyncWebSocket.HEARTBEAT_INTERVAL_MS);
1341
- }
1342
- stopHeartbeat() {
1343
- if (this.heartbeatTimer) {
1344
- clearInterval(this.heartbeatTimer);
1345
- this.heartbeatTimer = null;
1346
- }
1347
- this.clearHeartbeatTimeout();
1348
- }
1349
- clearHeartbeatTimeout() {
1350
- if (this.heartbeatTimeoutTimer) {
1351
- clearTimeout(this.heartbeatTimeoutTimer);
1352
- this.heartbeatTimeoutTimer = null;
1353
- }
1354
- }
1355
1009
  /**
1356
1010
  * Force-close the socket from the client side using a private 4xxx
1357
1011
  * code. Callers expect `onclose` to fire; that handler runs the
@@ -1416,11 +1070,19 @@ export class SyncWebSocket extends EventEmitter {
1416
1070
  */
1417
1071
  notConnectedError(action) {
1418
1072
  const d = this.getConnectionDiagnostics();
1419
- let detail;
1073
+ // A session-latched socket is not a transient transport hiccup: reconnection
1074
+ // is suppressed until re-auth (or the store's credential re-mint clears the
1075
+ // latch), so retrying can never succeed. Reject with the permanent session
1076
+ // error type — `isPermanentError` surfaces it to the caller as
1077
+ // "re-authenticate" instead of parking the write for a reconnect that will
1078
+ // never happen.
1420
1079
  if (d.sessionErrorDetected) {
1421
- detail = 'session_error_suppressed_reconnect';
1080
+ return Object.assign(new SyncSessionError(`SyncWebSocket not connected — cannot send ${action}: session expired` +
1081
+ (d.lastCloseReason ? ` (${d.lastCloseReason})` : '') +
1082
+ '; re-authenticate'), { diagnostics: d });
1422
1083
  }
1423
- else if (d.isManualClose) {
1084
+ let detail;
1085
+ if (d.isManualClose) {
1424
1086
  detail = 'manual_close';
1425
1087
  }
1426
1088
  else if (d.isConnecting) {
@@ -1444,10 +1106,10 @@ export class SyncWebSocket extends EventEmitter {
1444
1106
  else {
1445
1107
  detail = 'never_connected';
1446
1108
  }
1447
- // Typed so it lands in the AbloError hierarchy AND `isPermanentError`
1448
- // sees a transient transport failure (retry on reconnect, don't roll
1449
- // back). `diagnostics` stays a property — the queue's failure log walks
1450
- // the cause chain for it.
1109
+ // Typed so it lands in the AbloError hierarchy and `isPermanentError` sees a
1110
+ // transient transport failure (retry on reconnect, don't roll back).
1111
+ // `diagnostics` stays a property — the queue's failure log walks the cause
1112
+ // chain for it.
1451
1113
  const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
1452
1114
  return err;
1453
1115
  }
@@ -1455,50 +1117,34 @@ export class SyncWebSocket extends EventEmitter {
1455
1117
  getSyncGroups() {
1456
1118
  return this.options.syncGroups;
1457
1119
  }
1120
+ // Cursor accessors — thin delegates; the state and semantics live in
1121
+ // SyncCursor.
1458
1122
  /**
1459
1123
  * Update last sync ID (for persistence)
1460
1124
  */
1461
1125
  setLastSyncId(syncId) {
1462
- this.lastSyncId = syncId;
1463
- }
1464
- /**
1465
- * Get current version vector
1466
- */
1467
- getVersionVector() {
1468
- return { ...this.versionVector };
1469
- }
1470
- /**
1471
- * Update version vector for specific entity type
1472
- */
1473
- updateVersionVector(entityType, version) {
1474
- this.versionVector[entityType] = Math.max(this.versionVector[entityType] || 0, version);
1475
- }
1476
- /**
1477
- * Set version vector (for initialization)
1478
- */
1479
- setVersionVector(versions) {
1480
- this.versionVector = { ...versions };
1126
+ this.cursor.setLastSyncId(syncId);
1481
1127
  }
1482
1128
  /**
1483
1129
  * Update sync cursor (for incremental sync)
1484
1130
  */
1485
1131
  setSyncCursor(cursor) {
1486
- this.syncCursor = cursor;
1132
+ this.cursor.setSyncCursor(cursor);
1487
1133
  }
1488
1134
  /**
1489
1135
  * Get current sync cursor
1490
1136
  */
1491
1137
  getSyncCursor() {
1492
- return this.syncCursor;
1138
+ return this.cursor.getSyncCursor();
1493
1139
  }
1494
1140
  /**
1495
1141
  * Get the highest syncId seen this session (for persistence on clean shutdown)
1496
1142
  */
1497
1143
  getLastSyncId() {
1498
- return this.lastSyncId || 0;
1144
+ return this.cursor.getLastSyncId();
1499
1145
  }
1500
1146
  /**
1501
- * Linear-style incremental sync request
1147
+ * Requests an incremental sync from the server, starting at the current cursor.
1502
1148
  */
1503
1149
  async requestIncrementalSync() {
1504
1150
  if (this.ws?.readyState !== WebSocket.OPEN) {
@@ -1516,9 +1162,12 @@ export class SyncWebSocket extends EventEmitter {
1516
1162
  this.send({
1517
1163
  type: 'sync_request',
1518
1164
  payload: {
1519
- cursor: this.syncCursor, // Always send lastSyncId to ensure server uses client's current position
1520
- lastSyncId: this.lastSyncId,
1165
+ cursor: this.cursor.syncCursor, // Always send lastSyncId to ensure server uses client's current position
1166
+ lastSyncId: this.cursor.lastSyncId,
1521
1167
  capabilities: capsArr,
1168
+ // Protocol handshake: the server rejects an out-of-range version with
1169
+ // WebSocket close code 4010 before serving any deltas.
1170
+ protocolVersion: PROTOCOL_VERSION,
1522
1171
  },
1523
1172
  });
1524
1173
  }
@@ -1541,46 +1190,56 @@ export class SyncWebSocket extends EventEmitter {
1541
1190
  });
1542
1191
  }
1543
1192
  /**
1544
- * Handle sync response from server
1193
+ * Handle sync response from server. Untrusted wire input — the envelope
1194
+ * fields are narrowed defensively and every delta is validated through
1195
+ * {@link normalizeWireDelta} (exactly once each; malformed ones drop out
1196
+ * of the batch).
1545
1197
  */
1546
- handleSyncResponse(payload) {
1547
- // Cursor reconciliation Linear-style handshake. The server stamps
1548
- // its authoritative `currentSyncId` on every sync_response. If our
1549
- // local cursor is AHEAD of the server, our local view has somehow
1550
- // diverged (corrupted metadata, future regression reintroducing an
1551
- // eager-advance, IDB lying about a successful commit). Trust the
1552
- // server, reset the cursor, and request another sync so any deltas
1553
- // we *should* have applied get re-delivered. Backward-compatible
1554
- // when the field is absent (older server build) we just skip the
1555
- // reconciliation step.
1198
+ handleSyncResponse(rawPayload) {
1199
+ const payload = isRecord(rawPayload) ? rawPayload : {};
1200
+ const rawDeltas = Array.isArray(payload.deltas)
1201
+ ? payload.deltas
1202
+ : null;
1203
+ // Cursor reconciliation. The server stamps its authoritative `currentSyncId`
1204
+ // on every sync_response. If our local cursor is ahead of the server, our
1205
+ // local view has somehow diverged (corrupted metadata, a regression that
1206
+ // reintroduced an eager advance, or local storage lying about a successful
1207
+ // commit). Trust the server, reset the cursor, and request another sync so
1208
+ // any deltas we should have applied get re-delivered. When the field is
1209
+ // absent (an older server build) we simply skip this step.
1556
1210
  //
1557
- // We only reconcile when the response carries NO deltas. If deltas
1558
- // are present, they'll advance our cursor through the normal
1559
- // persistence-gated path anyway — and the in-flight request/response
1560
- // round-trip means the snapshot's `currentSyncId` is naturally a
1561
- // few syncIds behind our locally-advanced cursor at receive time
1562
- // (live deltas may have landed in the meantime). Restricting to
1563
- // empty-delta responses eliminates this benign false positive while
1564
- // still catching the real corruption case (server head < local AND
1565
- // server has nothing new to send).
1566
- const hasDeltas = Array.isArray(payload.deltas) && payload.deltas.length > 0;
1211
+ // We only reconcile when the response carries no deltas. If deltas are
1212
+ // present they advance our cursor through the normal persistence-gated path
1213
+ // anyway — and the in-flight round-trip means the snapshot's `currentSyncId`
1214
+ // is naturally a few syncIds behind our locally-advanced cursor at receive
1215
+ // time (live deltas may have landed in the meantime). Restricting to
1216
+ // empty-delta responses eliminates that benign false positive while still
1217
+ // catching the real corruption case (server head < local, and the server
1218
+ // has nothing new to send).
1219
+ const hasDeltas = rawDeltas !== null && rawDeltas.length > 0;
1567
1220
  if (!hasDeltas && typeof payload.currentSyncId === 'number') {
1568
1221
  const serverHead = payload.currentSyncId;
1569
- if (serverHead < this.lastSyncId) {
1222
+ if (serverHead < this.cursor.lastSyncId) {
1570
1223
  getContext().logger.debug('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
1571
- local: this.lastSyncId,
1224
+ local: this.cursor.lastSyncId,
1572
1225
  server: serverHead,
1573
- drift: this.lastSyncId - serverHead,
1226
+ drift: this.cursor.lastSyncId - serverHead,
1574
1227
  });
1575
- getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.lastSyncId, server: serverHead });
1576
- this.lastSyncId = serverHead;
1228
+ getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.cursor.lastSyncId, server: serverHead });
1229
+ this.cursor.lastSyncId = serverHead;
1577
1230
  // Fire a follow-up incremental sync to re-deliver anything we
1578
1231
  // were missing. Fire-and-forget — the next response will go
1579
1232
  // through this same path. The infinite-loop concern is bounded
1580
- // by the `serverHead < this.lastSyncId` strict-less check: once
1233
+ // by the `serverHead < this.cursor.lastSyncId` strict-less check: once
1581
1234
  // we've reset to `serverHead`, the next response with the same
1582
- // (or higher) `currentSyncId` won't re-enter this branch.
1583
- void this.requestIncrementalSync();
1235
+ // (or higher) `currentSyncId` won't re-enter this branch. A `.catch`
1236
+ // (not `void`) so a failed send logs instead of surfacing as an
1237
+ // unhandled rejection.
1238
+ this.requestIncrementalSync().catch((e) => {
1239
+ getContext().observability.breadcrumb('Post-cursor-reset sync request failed', 'sync.websocket', 'warning', {
1240
+ error: e instanceof Error ? e.message : String(e),
1241
+ });
1242
+ });
1584
1243
  }
1585
1244
  }
1586
1245
  if (payload.requiresBootstrap) {
@@ -1588,58 +1247,61 @@ export class SyncWebSocket extends EventEmitter {
1588
1247
  return;
1589
1248
  }
1590
1249
  // Process incremental deltas
1591
- if (payload.deltas && Array.isArray(payload.deltas)) {
1250
+ if (rawDeltas) {
1592
1251
  // Process all deltas from sync response - store handles idempotency.
1593
- // Same receive-boundary normalization as handleDelta — catch-up
1594
- // replays from older servers carry string ids too.
1595
- const newDeltas = payload.deltas.map((d) => this.normalizeWireDelta(d));
1252
+ // Same receive-boundary validation + normalization as handleDelta —
1253
+ // catch-up replays from older servers carry string ids too, and
1254
+ // malformed deltas drop out of the batch instead of being applied.
1255
+ const newDeltas = [];
1256
+ for (const d of rawDeltas) {
1257
+ const delta = this.normalizeWireDelta(d);
1258
+ if (delta)
1259
+ newDeltas.push(delta);
1260
+ }
1596
1261
  if (newDeltas.length > 0) {
1597
- // DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
1262
+ // DO NOT pre-advance `this.cursor.lastSyncId` here. Same reasoning as
1598
1263
  // `handleDelta`: the runtime cursor must stay consistent with
1599
1264
  // IDB. The delta_batch event routes through
1600
1265
  // `BaseSyncedStore.processDeltaWithBatching` →
1601
1266
  // `flushPendingDeltas`, which calls `acknowledge()` with the
1602
1267
  // honest `persistedSyncId` once IDB commits. That ack is what
1603
- // moves `this.lastSyncId` forward.
1268
+ // moves `this.cursor.lastSyncId` forward.
1604
1269
  // Emit ALL deltas as a single batch event
1605
1270
  this.emit('delta_batch', newDeltas);
1606
1271
  }
1607
1272
  }
1608
- // Update cursors and versions
1609
- if (payload.newCursor) {
1610
- this.syncCursor = payload.newCursor;
1273
+ // Update cursor. (`newVersions` from pre-cutover servers is ignored —
1274
+ // the version vector was removed in W4a; `sync_id` is the causality token.)
1275
+ if (typeof payload.newCursor === 'string' && payload.newCursor) {
1276
+ this.cursor.syncCursor = payload.newCursor;
1611
1277
  }
1612
- else if (payload.cursor) {
1613
- this.syncCursor = payload.cursor;
1614
- }
1615
- if (payload.newVersions) {
1616
- Object.assign(this.versionVector, payload.newVersions);
1278
+ else if (typeof payload.cursor === 'string' && payload.cursor) {
1279
+ this.cursor.syncCursor = payload.cursor;
1617
1280
  }
1618
1281
  }
1619
1282
  /**
1620
1283
  * Handle bootstrap response from server
1621
1284
  */
1622
1285
  handleBootstrapResponse(payload) {
1623
- // Emit bootstrap data for processing
1286
+ // Emit the bootstrap data for processing. (A `version` field from older
1287
+ // servers is ignored; the version vector is no longer used.) The typeof
1288
+ // guards mirror the cursor handling above: the frame is server-produced, so
1289
+ // coercion only bites on a malformed frame.
1290
+ const p = (payload && typeof payload === 'object' ? payload : {});
1624
1291
  this.emit('bootstrap_data', {
1625
- entityType: payload.entityType,
1626
- data: payload.data,
1627
- isComplete: payload.isComplete,
1628
- cursor: payload.cursor,
1292
+ entityType: typeof p.entityType === 'string' ? p.entityType : '',
1293
+ data: p.data,
1294
+ isComplete: p.isComplete === true,
1295
+ cursor: typeof p.cursor === 'string' ? p.cursor : undefined,
1629
1296
  });
1630
- // Update version vector if provided
1631
- if (payload.version && payload.entityType) {
1632
- this.updateVersionVector(payload.entityType.toLowerCase(), payload.version);
1633
- }
1634
1297
  }
1635
1298
  /**
1636
- * Handle presence update from server. The wire frame's payload is
1637
- * forwarded as-is so every consumer (web entity cache,
1638
- * PresenceStream, agent runtime) reads from the same shape.
1639
- * Stripping fields here was a prior bug — it silently dropped
1640
- * `kind`, `activity`, `syncGroups`, `isAgent` for rich consumers.
1299
+ * Handles a presence update from the server. The wire frame's payload is
1300
+ * forwarded as-is, so every consumer reads the same shape; stripping fields
1301
+ * here would drop `kind`, `activity`, `syncGroups`, and `isAgent` for
1302
+ * consumers that need them.
1641
1303
  *
1642
- * Wire frame (apps/sync-server/src/hub/types.ts PresenceUpdateMessage):
1304
+ * The wire frame is:
1643
1305
  * { type: 'presence_update', payload: { kind, userId, status,
1644
1306
  * syncGroups, activity, isAgent, timestamp, activeClaims } }
1645
1307
  */