@abloatai/ablo 0.34.1 → 0.35.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 (483) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +674 -5
  3. package/README.md +39 -22
  4. package/dist/BaseSyncedStore.d.ts +152 -44
  5. package/dist/BaseSyncedStore.js +300 -184
  6. package/dist/Database.d.ts +9 -24
  7. package/dist/Database.js +37 -22
  8. package/dist/InstanceCache.d.ts +25 -4
  9. package/dist/InstanceCache.js +48 -15
  10. package/dist/LazyReferenceCollection.d.ts +3 -3
  11. package/dist/LazyReferenceCollection.js +4 -4
  12. package/dist/Model.d.ts +6 -6
  13. package/dist/Model.js +10 -10
  14. package/dist/ModelRegistry.d.ts +4 -4
  15. package/dist/ModelRegistry.js +3 -3
  16. package/dist/{SyncEngineContext.d.ts → RuntimeContext.d.ts} +20 -13
  17. package/dist/{SyncEngineContext.js → RuntimeContext.js} +11 -12
  18. package/dist/SyncClient.d.ts +42 -32
  19. package/dist/SyncClient.js +166 -110
  20. package/dist/ai-sdk/coordinatedTool.d.ts +2 -2
  21. package/dist/ai-sdk/coordinatedTool.js +1 -1
  22. package/dist/ai-sdk/coordinationContext.d.ts +2 -2
  23. package/dist/ai-sdk/coordinationContext.js +1 -1
  24. package/dist/ai-sdk/wrap.d.ts +3 -3
  25. package/dist/ai-sdk/wrap.js +2 -2
  26. package/dist/auth/index.d.ts +1 -156
  27. package/dist/auth/index.js +8 -301
  28. package/dist/cli.cjs +3344 -1073
  29. package/dist/client/Ablo.d.ts +42 -287
  30. package/dist/client/Ablo.js +118 -963
  31. package/dist/client/abloClient.d.ts +309 -0
  32. package/dist/client/abloClient.js +13 -0
  33. package/dist/client/clientPrelude.d.ts +52 -0
  34. package/dist/client/clientPrelude.js +60 -0
  35. package/dist/client/consoleLogger.d.ts +2 -2
  36. package/dist/client/coreClient.d.ts +60 -0
  37. package/dist/client/coreClient.js +118 -0
  38. package/dist/client/createInternalComponents.d.ts +4 -4
  39. package/dist/client/createInternalComponents.js +9 -8
  40. package/dist/client/createModelProxy.d.ts +78 -373
  41. package/dist/client/createModelProxy.js +114 -86
  42. package/dist/client/humans.d.ts +48 -0
  43. package/dist/client/humans.js +52 -0
  44. package/dist/client/modelRegistration.d.ts +1 -1
  45. package/dist/client/modelRegistration.js +9 -9
  46. package/dist/client/options.d.ts +73 -17
  47. package/dist/client/reactiveEngine.d.ts +48 -0
  48. package/dist/client/reactiveEngine.js +910 -0
  49. package/dist/client/resourceTypes.d.ts +9 -250
  50. package/dist/client/resourceTypes.js +8 -5
  51. package/dist/client/schemaConfig.d.ts +4 -4
  52. package/dist/client/schemaConfig.js +6 -2
  53. package/dist/client/validateAbloOptions.d.ts +3 -2
  54. package/dist/client/validateAbloOptions.js +1 -1
  55. package/dist/client/wsMutationExecutor.d.ts +3 -3
  56. package/dist/client/wsMutationExecutor.js +3 -3
  57. package/dist/context.d.ts +9 -9
  58. package/dist/context.js +10 -9
  59. package/dist/coordination/ClaimLog.d.ts +26 -0
  60. package/dist/coordination/ClaimLog.js +32 -0
  61. package/dist/coordination/index.d.ts +1 -15
  62. package/dist/coordination/index.js +8 -31
  63. package/dist/core/DatabaseManager.js +1 -1
  64. package/dist/core/QueryView.d.ts +1 -1
  65. package/dist/core/QueryView.js +1 -1
  66. package/dist/core/StoreManager.d.ts +4 -23
  67. package/dist/core/StoreManager.js +5 -55
  68. package/dist/core/index.d.ts +2 -2
  69. package/dist/core/index.js +2 -2
  70. package/dist/core/storeContract.d.ts +2 -2
  71. package/dist/docs/catalog.d.ts +72 -0
  72. package/dist/docs/catalog.js +227 -0
  73. package/dist/docs/index.d.ts +10 -0
  74. package/dist/docs/index.js +10 -0
  75. package/dist/environment.d.ts +1 -40
  76. package/dist/environment.js +8 -37
  77. package/dist/index.d.ts +40 -34
  78. package/dist/index.js +26 -20
  79. package/dist/interfaces/index.d.ts +44 -134
  80. package/dist/keys/index.d.ts +1 -77
  81. package/dist/keys/index.js +8 -190
  82. package/dist/mutators/{RecordingTransaction.d.ts → RecordingMutation.d.ts} +4 -4
  83. package/dist/mutators/{RecordingTransaction.js → RecordingMutation.js} +2 -2
  84. package/dist/mutators/Transaction.d.ts +1 -1
  85. package/dist/mutators/Transaction.js +1 -1
  86. package/dist/mutators/UndoManager.d.ts +6 -6
  87. package/dist/mutators/UndoManager.js +5 -5
  88. package/dist/mutators/defineMutators.d.ts +3 -3
  89. package/dist/mutators/defineMutators.js +1 -1
  90. package/dist/mutators/inverseOp.js +2 -2
  91. package/dist/mutators/mutateActions.d.ts +3 -3
  92. package/dist/mutators/mutateActions.js +1 -1
  93. package/dist/mutators/readerActions.d.ts +1 -1
  94. package/dist/mutators/undoApply.d.ts +1 -1
  95. package/dist/mutators/undoApply.js +1 -1
  96. package/dist/policy/index.d.ts +2 -2
  97. package/dist/policy/index.js +1 -1
  98. package/dist/query/client.d.ts +2 -2
  99. package/dist/query/client.js +4 -4
  100. package/dist/query/types.d.ts +6 -41
  101. package/dist/query/types.js +2 -2
  102. package/dist/react/AbloProvider.d.ts +6 -8
  103. package/dist/react/AbloProvider.js +5 -7
  104. package/dist/react/context.d.ts +1 -1
  105. package/dist/react/context.js +1 -1
  106. package/dist/react/index.d.ts +5 -5
  107. package/dist/react/index.js +3 -3
  108. package/dist/react/internalContext.d.ts +1 -1
  109. package/dist/react/useAblo.d.ts +3 -3
  110. package/dist/react/useAblo.js +1 -1
  111. package/dist/react/useCurrentUserId.js +1 -1
  112. package/dist/react/useErrorListener.js +1 -1
  113. package/dist/react/useMutationFailureListener.d.ts +2 -2
  114. package/dist/react/useMutationFailureListener.js +1 -1
  115. package/dist/react/useMutators.d.ts +3 -3
  116. package/dist/react/useMutators.js +3 -3
  117. package/dist/react/useUndoScope.d.ts +5 -5
  118. package/dist/react/useUndoScope.js +1 -1
  119. package/dist/schema/coordination.d.ts +69 -10
  120. package/dist/schema/coordination.js +86 -9
  121. package/dist/schema/ddl.js +2 -2
  122. package/dist/schema/diff.d.ts +1 -1
  123. package/dist/schema/generate.js +1 -1
  124. package/dist/schema/index.d.ts +10 -10
  125. package/dist/schema/index.js +18 -18
  126. package/dist/schema/queries.d.ts +27 -27
  127. package/dist/schema/queries.js +23 -23
  128. package/dist/schema/select.d.ts +3 -3
  129. package/dist/schema/select.js +3 -3
  130. package/dist/schema/serialize.d.ts +15 -6
  131. package/dist/schema/serialize.js +17 -3
  132. package/dist/schema/sugar.d.ts +6 -7
  133. package/dist/schema/sugar.js +9 -12
  134. package/dist/schema/syncDeltaRow.d.ts +4 -152
  135. package/dist/schema/syncDeltaRow.js +4 -105
  136. package/dist/server/adapter.d.ts +18 -1
  137. package/dist/server/commit.d.ts +10 -16
  138. package/dist/server/index.d.ts +1 -1
  139. package/dist/server/index.js +1 -1
  140. package/dist/server/readConfig.d.ts +1 -1
  141. package/dist/source/adapters/drizzle.d.ts +1 -1
  142. package/dist/source/adapters/drizzle.js +2 -2
  143. package/dist/source/adapters/kysely.d.ts +1 -1
  144. package/dist/source/adapters/kysely.js +1 -1
  145. package/dist/source/adapters/kyselyMutationCore.d.ts +1 -1
  146. package/dist/source/adapters/kyselyMutationCore.js +2 -2
  147. package/dist/source/adapters/memory.js +1 -1
  148. package/dist/source/adapters/prisma.d.ts +8 -3
  149. package/dist/source/adapters/prisma.js +1 -1
  150. package/dist/source/connector.js +1 -1
  151. package/dist/source/connectorProtocol.d.ts +2 -8
  152. package/dist/source/connectorProtocol.js +3 -2
  153. package/dist/source/contract.d.ts +29 -17
  154. package/dist/source/contract.js +27 -22
  155. package/dist/source/factory.d.ts +1 -1
  156. package/dist/source/footprint.d.ts +111 -0
  157. package/dist/source/footprint.js +0 -0
  158. package/dist/source/idempotency.js +2 -2
  159. package/dist/source/index.d.ts +1 -0
  160. package/dist/source/index.js +3 -0
  161. package/dist/source/next.d.ts +1 -1
  162. package/dist/source/signing.d.ts +9 -2
  163. package/dist/source/signing.js +4 -1
  164. package/dist/source/types.d.ts +6 -4
  165. package/dist/source/types.js +1 -1
  166. package/dist/stores/ObjectStore.d.ts +1 -1
  167. package/dist/stores/SyncActionStore.d.ts +1 -1
  168. package/dist/stores/SyncActionStore.js +2 -10
  169. package/dist/stores/syncAction.d.ts +26 -0
  170. package/dist/stores/syncAction.js +16 -0
  171. package/dist/surface.d.ts +3 -3
  172. package/dist/surface.js +6 -4
  173. package/dist/sync/BootstrapFetcher.d.ts +123 -6
  174. package/dist/sync/BootstrapFetcher.js +492 -66
  175. package/dist/sync/ConnectionManager.d.ts +6 -198
  176. package/dist/sync/ConnectionManager.js +6 -677
  177. package/dist/sync/OnDemandLoader.d.ts +2 -2
  178. package/dist/sync/OnDemandLoader.js +60 -21
  179. package/dist/sync/SubscriptionManager.d.ts +13 -2
  180. package/dist/sync/SubscriptionManager.js +23 -5
  181. package/dist/sync/SyncWebSocket.d.ts +27 -510
  182. package/dist/sync/SyncWebSocket.js +76 -954
  183. package/dist/sync/awaitClaimGrant.d.ts +4 -44
  184. package/dist/sync/awaitClaimGrant.js +4 -109
  185. package/dist/sync/commitFrames.d.ts +6 -40
  186. package/dist/sync/commitFrames.js +6 -97
  187. package/dist/sync/contextPorts.d.ts +18 -0
  188. package/dist/sync/contextPorts.js +31 -0
  189. package/dist/sync/createClaimStream.d.ts +5 -49
  190. package/dist/sync/createClaimStream.js +5 -469
  191. package/dist/sync/createPresenceStream.d.ts +26 -4
  192. package/dist/sync/createPresenceStream.js +28 -20
  193. package/dist/sync/createSnapshot.d.ts +2 -2
  194. package/dist/sync/createSnapshot.js +1 -1
  195. package/dist/sync/credentialLifecycle.d.ts +5 -173
  196. package/dist/sync/credentialLifecycle.js +5 -320
  197. package/dist/sync/deltaPipeline.d.ts +1 -1
  198. package/dist/sync/participants.d.ts +5 -4
  199. package/dist/sync/participants.js +29 -22
  200. package/dist/sync/schemaDrift.d.ts +55 -0
  201. package/dist/sync/schemaDrift.js +53 -0
  202. package/dist/sync/schemas.d.ts +21 -32
  203. package/dist/sync/schemas.js +26 -17
  204. package/dist/sync/syncPlan.d.ts +3 -3
  205. package/dist/sync/wsFrameHandlers.d.ts +6 -114
  206. package/dist/sync/wsFrameHandlers.js +6 -392
  207. package/dist/testing/fixtures/bootstrap.d.ts +1 -1
  208. package/dist/testing/fixtures/deltas.d.ts +1 -1
  209. package/dist/testing/fixtures/httpResponses.d.ts +70 -0
  210. package/dist/testing/fixtures/httpResponses.js +90 -0
  211. package/dist/testing/fixtures/models.js +1 -1
  212. package/dist/testing/helpers/wait.js +1 -1
  213. package/dist/testing/mocks/MockMutationExecutor.d.ts +2 -2
  214. package/dist/testing/mocks/MockMutationExecutor.js +8 -14
  215. package/dist/testing/mocks/MockSyncContext.d.ts +11 -11
  216. package/dist/testing/mocks/MockSyncContext.js +10 -9
  217. package/dist/testing/mocks/MockSyncStore.js +1 -1
  218. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  219. package/dist/transaction/ablo.d.ts +88 -0
  220. package/dist/transaction/ablo.js +33 -0
  221. package/dist/{client/auth.d.ts → transaction/auth/apiKey.d.ts} +43 -8
  222. package/dist/{client/auth.js → transaction/auth/apiKey.js} +19 -0
  223. package/dist/transaction/auth/bootstrapScope.d.ts +15 -0
  224. package/dist/transaction/auth/bootstrapScope.js +1 -0
  225. package/dist/transaction/auth/capability.d.ts +177 -0
  226. package/dist/transaction/auth/capability.js +199 -0
  227. package/dist/{auth → transaction/auth}/credentialSource.d.ts +8 -1
  228. package/dist/{client → transaction/auth}/identity.d.ts +8 -7
  229. package/dist/{client → transaction/auth}/identity.js +1 -1
  230. package/dist/transaction/auth/index.d.ts +162 -0
  231. package/dist/transaction/auth/index.js +304 -0
  232. package/dist/{auth → transaction/auth}/schemas.d.ts +1 -1
  233. package/dist/{auth → transaction/auth}/schemas.js +13 -13
  234. package/dist/{client → transaction/auth}/sessionMint.d.ts +8 -8
  235. package/dist/{client → transaction/auth}/sessionMint.js +4 -7
  236. package/dist/transaction/coordination/awaitClaimGrant.d.ts +49 -0
  237. package/dist/transaction/coordination/awaitClaimGrant.js +112 -0
  238. package/dist/transaction/coordination/claimMeta.d.ts +49 -0
  239. package/dist/transaction/coordination/claimMeta.js +52 -0
  240. package/dist/transaction/coordination/createClaimStream.d.ts +64 -0
  241. package/dist/transaction/coordination/createClaimStream.js +475 -0
  242. package/dist/transaction/coordination/events.d.ts +74 -0
  243. package/dist/transaction/coordination/events.js +7 -0
  244. package/dist/transaction/coordination/index.d.ts +19 -0
  245. package/dist/transaction/coordination/index.js +44 -0
  246. package/dist/transaction/coordination/locator.d.ts +83 -0
  247. package/dist/transaction/coordination/locator.js +82 -0
  248. package/dist/transaction/coordination/schema.d.ts +1473 -0
  249. package/dist/{coordination → transaction/coordination}/schema.js +490 -55
  250. package/dist/transaction/coordination/targetConflict.d.ts +2 -0
  251. package/dist/transaction/coordination/targetConflict.js +103 -0
  252. package/dist/{coordination → transaction/coordination}/trace.d.ts +7 -18
  253. package/dist/{coordination → transaction/coordination}/trace.js +18 -25
  254. package/dist/transaction/durableWrites.d.ts +62 -0
  255. package/dist/{client → transaction}/durableWrites.js +28 -3
  256. package/dist/transaction/environment.d.ts +105 -0
  257. package/dist/transaction/environment.js +108 -0
  258. package/dist/{errorCodes.d.ts → transaction/errorCodes.d.ts} +11 -11
  259. package/dist/{errorCodes.js → transaction/errorCodes.js} +35 -12
  260. package/dist/{errors.d.ts → transaction/errors.d.ts} +37 -13
  261. package/dist/{errors.js → transaction/errors.js} +85 -16
  262. package/dist/transaction/index.d.ts +20 -0
  263. package/dist/transaction/index.js +20 -0
  264. package/dist/transaction/keys/index.d.ts +87 -0
  265. package/dist/transaction/keys/index.js +207 -0
  266. package/dist/transaction/log/syncDeltaRow.d.ts +158 -0
  267. package/dist/transaction/log/syncDeltaRow.js +95 -0
  268. package/dist/{sync/syncPosition.d.ts → transaction/logPosition.d.ts} +22 -8
  269. package/dist/{sync/syncPosition.js → transaction/logPosition.js} +15 -6
  270. package/dist/transaction/logger.d.ts +16 -0
  271. package/dist/transaction/logger.js +7 -0
  272. package/dist/transaction/observability.d.ts +53 -0
  273. package/dist/transaction/observability.js +19 -0
  274. package/dist/transaction/plugin.d.ts +192 -0
  275. package/dist/transaction/plugin.js +87 -0
  276. package/dist/{policy → transaction/policy}/types.d.ts +3 -3
  277. package/dist/{policy → transaction/policy}/types.js +2 -0
  278. package/dist/transaction/resources/httpResources.d.ts +266 -0
  279. package/dist/transaction/resources/httpResources.js +7 -0
  280. package/dist/transaction/resources/modelOperations.d.ts +319 -0
  281. package/dist/transaction/resources/modelOperations.js +12 -0
  282. package/dist/transaction/resources/mutationOptions.d.ts +66 -0
  283. package/dist/transaction/resources/mutationOptions.js +9 -0
  284. package/dist/transaction/resources/where.d.ts +85 -0
  285. package/dist/transaction/resources/where.js +70 -0
  286. package/dist/{client → transaction/resources}/writeOptionsSchema.d.ts +2 -6
  287. package/dist/{client → transaction/resources}/writeOptionsSchema.js +9 -5
  288. package/dist/{schema → transaction/schema}/field.d.ts +5 -5
  289. package/dist/{schema → transaction/schema}/field.js +5 -5
  290. package/dist/transaction/schema/loadStrategy.d.ts +45 -0
  291. package/dist/transaction/schema/loadStrategy.js +46 -0
  292. package/dist/{schema → transaction/schema}/model.d.ts +50 -35
  293. package/dist/{schema → transaction/schema}/model.js +30 -20
  294. package/dist/transaction/schema/openapi.d.ts +57 -0
  295. package/dist/transaction/schema/openapi.js +340 -0
  296. package/dist/{schema → transaction/schema}/relation.d.ts +14 -14
  297. package/dist/{schema → transaction/schema}/relation.js +7 -7
  298. package/dist/{schema → transaction/schema}/residency.d.ts +0 -9
  299. package/dist/{schema → transaction/schema}/residency.js +0 -5
  300. package/dist/{schema → transaction/schema}/roles.d.ts +5 -5
  301. package/dist/{schema → transaction/schema}/roles.js +5 -5
  302. package/dist/{schema → transaction/schema}/schema.d.ts +12 -10
  303. package/dist/{schema → transaction/schema}/schema.js +4 -3
  304. package/dist/{schema → transaction/schema}/tenancy.d.ts +1 -1
  305. package/dist/{schema → transaction/schema}/tenancy.js +7 -4
  306. package/dist/transaction/transactionLayer.d.ts +82 -0
  307. package/dist/transaction/transactionLayer.js +24 -0
  308. package/dist/{transactions → transaction/transactions/settlement}/commitEnvelope.d.ts +4 -5
  309. package/dist/{transactions → transaction/transactions/settlement}/commitEnvelope.js +9 -13
  310. package/dist/{transactions → transaction/transactions/settlement}/httpCommitEnvelope.d.ts +9 -2
  311. package/dist/{transactions → transaction/transactions/settlement}/httpCommitEnvelope.js +5 -9
  312. package/dist/{transactions/durableWriteStore.d.ts → transaction/transactions/settlement/pendingWrite.d.ts} +10 -36
  313. package/dist/transaction/transactions/settlement/pendingWrite.js +20 -0
  314. package/dist/transaction/transport/commitFrames.d.ts +90 -0
  315. package/dist/transaction/transport/commitFrames.js +134 -0
  316. package/dist/transaction/transport/connectionManager.d.ts +215 -0
  317. package/dist/transaction/transport/connectionManager.js +673 -0
  318. package/dist/transaction/transport/credentialLifecycle.d.ts +177 -0
  319. package/dist/transaction/transport/credentialLifecycle.js +324 -0
  320. package/dist/{sync → transaction/transport}/heartbeat.d.ts +3 -1
  321. package/dist/{sync → transaction/transport}/heartbeat.js +6 -4
  322. package/dist/{client → transaction/transport}/httpClient.d.ts +59 -16
  323. package/dist/{client → transaction/transport}/httpClient.js +5 -5
  324. package/dist/transaction/transport/httpOptions.d.ts +33 -0
  325. package/dist/transaction/transport/httpOptions.js +12 -0
  326. package/dist/{client → transaction/transport}/httpTransport.js +171 -85
  327. package/dist/{sync/NetworkProbe.d.ts → transaction/transport/networkProbe.d.ts} +7 -4
  328. package/dist/{sync/NetworkProbe.js → transaction/transport/networkProbe.js} +14 -13
  329. package/dist/transaction/transport/wsFrameHandlers.d.ts +128 -0
  330. package/dist/transaction/transport/wsFrameHandlers.js +429 -0
  331. package/dist/transaction/transport/wsTransport.d.ts +576 -0
  332. package/dist/transaction/transport/wsTransport.js +1017 -0
  333. package/dist/transaction/types/assertExact.d.ts +17 -0
  334. package/dist/transaction/types/assertExact.js +1 -0
  335. package/dist/{types → transaction/types}/global.d.ts +17 -2
  336. package/dist/{types → transaction/types}/global.js +2 -1
  337. package/dist/{types → transaction/types}/index.d.ts +14 -46
  338. package/dist/{types → transaction/types}/index.js +7 -16
  339. package/dist/{types → transaction/types}/streams.d.ts +63 -45
  340. package/dist/{utils → transaction/utils}/json.d.ts +18 -0
  341. package/dist/transaction/utils/json.js +276 -0
  342. package/dist/transaction/wire/accountResponses.d.ts +351 -0
  343. package/dist/transaction/wire/accountResponses.js +255 -0
  344. package/dist/transaction/wire/auth.d.ts +49 -0
  345. package/dist/transaction/wire/auth.js +57 -0
  346. package/dist/transaction/wire/claimEvent.d.ts +76 -0
  347. package/dist/transaction/wire/claimEvent.js +73 -0
  348. package/dist/transaction/wire/claims.d.ts +463 -0
  349. package/dist/transaction/wire/claims.js +229 -0
  350. package/dist/{wire → transaction/wire}/commit.d.ts +125 -193
  351. package/dist/{wire → transaction/wire}/commit.js +68 -47
  352. package/dist/{wire → transaction/wire}/delta.d.ts +66 -17
  353. package/dist/{wire → transaction/wire}/delta.js +37 -13
  354. package/dist/transaction/wire/errorEnvelope.d.ts +72 -0
  355. package/dist/{wire → transaction/wire}/errorEnvelope.js +36 -5
  356. package/dist/transaction/wire/feedCursor.d.ts +60 -0
  357. package/dist/transaction/wire/feedCursor.js +82 -0
  358. package/dist/transaction/wire/feedEvent.d.ts +177 -0
  359. package/dist/transaction/wire/feedEvent.js +39 -0
  360. package/dist/transaction/wire/frames.d.ts +194 -0
  361. package/dist/transaction/wire/frames.js +50 -0
  362. package/dist/transaction/wire/inboundFrames.d.ts +552 -0
  363. package/dist/transaction/wire/inboundFrames.js +116 -0
  364. package/dist/transaction/wire/index.d.ts +50 -0
  365. package/dist/transaction/wire/index.js +74 -0
  366. package/dist/{wire → transaction/wire}/listEnvelope.d.ts +13 -14
  367. package/dist/transaction/wire/listEnvelope.js +42 -0
  368. package/dist/transaction/wire/modelResponses.d.ts +85 -0
  369. package/dist/transaction/wire/modelResponses.js +43 -0
  370. package/dist/transactions/{TransactionQueue.d.ts → mutations/MutationQueue.d.ts} +79 -38
  371. package/dist/transactions/{TransactionQueue.js → mutations/MutationQueue.js} +110 -59
  372. package/dist/transactions/{TransactionStore.d.ts → mutations/MutationStore.d.ts} +8 -8
  373. package/dist/transactions/{TransactionStore.js → mutations/MutationStore.js} +2 -2
  374. package/dist/transactions/{coalesceRules.d.ts → mutations/coalesceRules.d.ts} +10 -10
  375. package/dist/transactions/{coalesceRules.js → mutations/coalesceRules.js} +1 -1
  376. package/dist/transactions/mutations/commitLatency.d.ts +52 -0
  377. package/dist/transactions/mutations/commitLatency.js +130 -0
  378. package/dist/transactions/{commitOutboxStore.d.ts → mutations/commitOutboxStore.d.ts} +1 -5
  379. package/dist/transactions/{commitOutboxStore.js → mutations/commitOutboxStore.js} +1 -1
  380. package/dist/transactions/{commitPayload.d.ts → mutations/commitPayload.d.ts} +16 -15
  381. package/dist/transactions/{commitPayload.js → mutations/commitPayload.js} +12 -12
  382. package/dist/transactions/{deltaConfirmation.d.ts → mutations/deltaConfirmation.d.ts} +11 -11
  383. package/dist/transactions/{deltaConfirmation.js → mutations/deltaConfirmation.js} +7 -7
  384. package/dist/transactions/mutations/durableWriteStore.d.ts +14 -0
  385. package/dist/transactions/mutations/durableWriteStore.js +12 -0
  386. package/dist/transactions/{optimisticApply.d.ts → mutations/optimisticApply.d.ts} +7 -7
  387. package/dist/transactions/{replayValidation.d.ts → mutations/replayValidation.d.ts} +3 -3
  388. package/dist/transactions/{replayValidation.js → mutations/replayValidation.js} +4 -3
  389. package/dist/utils/mobxSetup.d.ts +1 -1
  390. package/dist/utils/mobxSetup.js +5 -2
  391. package/dist/webhooks/events.d.ts +2 -2
  392. package/dist/wire/index.d.ts +1 -34
  393. package/dist/wire/index.js +8 -49
  394. package/docs/agent-messaging.md +3 -3
  395. package/docs/agents.md +19 -12
  396. package/docs/api-keys.md +8 -4
  397. package/docs/api.md +22 -18
  398. package/docs/audit.md +2 -0
  399. package/docs/cli.md +31 -3
  400. package/docs/client-behavior.md +8 -6
  401. package/docs/concurrency-convention.md +30 -24
  402. package/docs/coordination.md +48 -38
  403. package/docs/data-sources.md +3 -1
  404. package/docs/debugging.md +5 -3
  405. package/docs/deployment.md +267 -0
  406. package/docs/examples/agent-human.md +49 -42
  407. package/docs/examples/ai-sdk-tool.md +69 -44
  408. package/docs/examples/existing-python-backend.md +8 -6
  409. package/docs/examples/nextjs.md +129 -47
  410. package/docs/examples/scoped-agent.md +45 -44
  411. package/docs/examples/server-agent.md +46 -26
  412. package/docs/groups.md +32 -29
  413. package/docs/guarantees.md +4 -2
  414. package/docs/how-it-works.md +9 -7
  415. package/docs/idempotency.md +126 -0
  416. package/docs/identity.md +58 -54
  417. package/docs/index.md +172 -86
  418. package/docs/integration-guide.md +17 -16
  419. package/docs/interaction-model.md +6 -4
  420. package/docs/mcp.md +41 -16
  421. package/docs/migration.md +63 -5
  422. package/docs/operating-on-your-database.md +3 -1
  423. package/docs/projects.md +2 -0
  424. package/docs/quickstart.md +22 -5
  425. package/docs/react.md +12 -10
  426. package/docs/schema-contract.md +5 -3
  427. package/docs/session-settings.md +108 -0
  428. package/docs/sessions.md +3 -1
  429. package/docs/webhooks.md +3 -1
  430. package/llms.txt +47 -17
  431. package/package.json +10 -8
  432. package/dist/agent/Agent.d.ts +0 -366
  433. package/dist/agent/Agent.js +0 -514
  434. package/dist/agent/index.d.ts +0 -115
  435. package/dist/agent/index.js +0 -128
  436. package/dist/agent/session.d.ts +0 -93
  437. package/dist/agent/session.js +0 -149
  438. package/dist/agent/types.d.ts +0 -68
  439. package/dist/agent/types.js +0 -9
  440. package/dist/client/durableWrites.d.ts +0 -21
  441. package/dist/coordination/schema.d.ts +0 -722
  442. package/dist/schema/openapi.d.ts +0 -29
  443. package/dist/schema/openapi.js +0 -124
  444. package/dist/transactions/durableWriteStore.js +0 -30
  445. package/dist/utils/json.js +0 -88
  446. package/dist/wire/errorEnvelope.d.ts +0 -55
  447. package/dist/wire/frames.d.ts +0 -197
  448. package/dist/wire/frames.js +0 -49
  449. package/dist/wire/listEnvelope.js +0 -18
  450. /package/dist/{client → transaction/auth}/credentialEndpoint.d.ts +0 -0
  451. /package/dist/{client → transaction/auth}/credentialEndpoint.js +0 -0
  452. /package/dist/{auth → transaction/auth}/credentialPolicy.d.ts +0 -0
  453. /package/dist/{auth → transaction/auth}/credentialPolicy.js +0 -0
  454. /package/dist/{auth → transaction/auth}/credentialSource.js +0 -0
  455. /package/dist/{client → transaction/auth}/hostedEndpoints.d.ts +0 -0
  456. /package/dist/{client → transaction/auth}/hostedEndpoints.js +0 -0
  457. /package/dist/{client → transaction/coordination}/claimHeartbeatLoop.d.ts +0 -0
  458. /package/dist/{client → transaction/coordination}/claimHeartbeatLoop.js +0 -0
  459. /package/dist/{client → transaction}/persistence.d.ts +0 -0
  460. /package/dist/{client → transaction}/persistence.js +0 -0
  461. /package/dist/{client → transaction/resources}/functionalUpdate.d.ts +0 -0
  462. /package/dist/{client → transaction/resources}/functionalUpdate.js +0 -0
  463. /package/dist/{transactions → transaction/transactions/settlement}/idempotencyKey.d.ts +0 -0
  464. /package/dist/{transactions → transaction/transactions/settlement}/idempotencyKey.js +0 -0
  465. /package/dist/{client → transaction/transport}/httpTransport.d.ts +0 -0
  466. /package/dist/{types → transaction/types}/modelData.d.ts +0 -0
  467. /package/dist/{types → transaction/types}/modelData.js +0 -0
  468. /package/dist/{types → transaction/types}/participant.d.ts +0 -0
  469. /package/dist/{types → transaction/types}/participant.js +0 -0
  470. /package/dist/{types → transaction/types}/streams.js +0 -0
  471. /package/dist/{utils → transaction/utils}/asyncIterator.d.ts +0 -0
  472. /package/dist/{utils → transaction/utils}/asyncIterator.js +0 -0
  473. /package/dist/{utils → transaction/utils}/duration.d.ts +0 -0
  474. /package/dist/{utils → transaction/utils}/duration.js +0 -0
  475. /package/dist/{wire → transaction/wire}/bootstrapReason.d.ts +0 -0
  476. /package/dist/{wire → transaction/wire}/bootstrapReason.js +0 -0
  477. /package/dist/{wire → transaction/wire}/protocol.d.ts +0 -0
  478. /package/dist/{wire → transaction/wire}/protocol.js +0 -0
  479. /package/dist/{wire → transaction/wire}/protocolVersion.d.ts +0 -0
  480. /package/dist/{wire → transaction/wire}/protocolVersion.js +0 -0
  481. /package/dist/transactions/{UnconfirmedWrites.d.ts → mutations/UnconfirmedWrites.d.ts} +0 -0
  482. /package/dist/transactions/{UnconfirmedWrites.js → mutations/UnconfirmedWrites.js} +0 -0
  483. /package/dist/transactions/{optimisticApply.js → mutations/optimisticApply.js} +0 -0
@@ -1,27 +1,23 @@
1
1
  /**
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.
2
+ * The reactive engine's connection to the sync server: the settlement core's
3
+ * duplex transport ({@link WsTransport}, ADR 0016) plus everything that turns
4
+ * the stream into a local, watchable copy wire-delta validation, the resume
5
+ * cursor and its persistence-gated ack discipline, incremental sync and
6
+ * bootstrap requests, the catch-up poll, and presence. The socket lifecycle,
7
+ * reconnect backoff, heartbeat, and the commit/claim/release/subscription
8
+ * frames all live in the transport; this subclass overrides its frame hooks
9
+ * and open/close hooks with the materialisation the transport deliberately
10
+ * does not do.
8
11
  */
9
- import { EventEmitter } from 'events';
10
12
  import { getContext } from '../context.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';
13
+ import { clientSyncDeltaSchema } from '../transaction/wire/delta.js';
14
+ import { WsTransport, } from '../transaction/transport/wsTransport.js';
15
+ import { isRecord } from './wsFrameHandlers.js';
18
16
  // Sync-position state (lastSyncId watermark, version vector, server cursor).
19
17
  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';
23
- import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
24
- // SyncObservability replaced by getContext().observability
18
+ import { PROTOCOL_VERSION } from '../transaction/wire/protocolVersion.js';
19
+ // Context-backed ports in the shape the core transport takes.
20
+ import { contextLogger, contextSocketObservability, contextOnlineStatus, } from './contextPorts.js';
25
21
  /**
26
22
  * How often, while connected, the client polls for any deltas whose best-effort
27
23
  * broadcast was lost in transit. This is a client-side eventual-consistency
@@ -30,516 +26,81 @@ import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/cred
30
26
  * guarantee.
31
27
  */
32
28
  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;
38
29
  // ---------------------------------------------------------------------------
39
30
  // Consumers pass their own event types as the TCollaboration generic parameter.
40
- export class SyncWebSocket extends EventEmitter {
41
- /**
42
- * Subscribe to events with automatic cleanup.
43
- * Returns unsubscribe function for clean disposal.
44
- */
45
- subscribe(event, handler) {
46
- this.on(event, handler);
47
- return () => this.off(event, handler);
48
- }
49
- /**
50
- * Send a collaboration event (app-specific real-time message).
51
- * The wire format is `{ type: messageType, payload: { ...payload, timestamp } }`.
52
- */
53
- sendCollaborationEvent(messageType, payload) {
54
- if (this.ws?.readyState !== WebSocket.OPEN)
55
- return;
56
- this.send({
57
- type: messageType.replace(/:/g, '_'), // 'sheet:selection' → 'sheet_selection' wire format
58
- payload: { ...payload, timestamp: Date.now() },
59
- });
60
- }
61
- ws = null;
62
- options;
63
- reconnectAttempts = 0;
64
- /** Stop retrying after this many consecutive failures (backoff caps at 30s, so ~7.5 min total) */
65
- static MAX_RECONNECT_ATTEMPTS = 15;
66
- reconnectTimer = null;
31
+ export class SyncWebSocket extends WsTransport {
67
32
  /** Periodic catchup interval — polls for missed deltas every 30s while connected */
68
33
  catchupInterval = null;
69
- /**
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.
74
- */
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
- });
84
- isConnecting = false;
85
- isManualClose = false;
86
- /** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
87
- * Suppresses reconnection and Sentry error capture to avoid cascading noise. */
88
- _sessionErrorDetected = false;
89
- /** True once `onopen` has fired at least once on the current socket. Reset each
90
- * time a new socket is created in `connect()`. Used by `onclose` to detect
91
- * handshake failures (close before open) — the one signal we have for "the
92
- * server rejected the upgrade" since browsers hide the HTTP status (e.g.
93
- * 401) behind the opaque 1006 close code. */
94
- _everOpened = false;
95
- /**
96
- * Diagnostic snapshot of the last connection lifecycle. Persisted across
97
- * the lifetime of the SyncWebSocket so that any subsequent "not connected"
98
- * rejection can quote the actual root cause (close code + reason + when)
99
- * instead of bottoming out at a generic error string. Browser WS code 1006
100
- * hides the real reason, so we layer on our own signals: `forceCloseReason`
101
- * captures heartbeat trips / send failures, `everOpened` distinguishes
102
- * handshake reject from mid-session drop, and `sessionErrorAt` tells us
103
- * whether reconnect is suppressed.
104
- */
105
- lastOpenAt = null;
106
- lastCloseAt = null;
107
- lastCloseCode = null;
108
- lastCloseReason = null;
109
- lastForceCloseReason = null;
110
- sessionErrorAt = null;
111
34
  /**
112
35
  * Sync-position state: the lastSyncId watermark, version vector, and server
113
36
  * cursor. The advance discipline is documented at `sendAck` and `handleDelta`;
114
37
  * the state itself lives in {@link SyncCursor}.
115
38
  */
116
39
  cursor;
117
- /** Registered collaboration event keys (colon format) for dispatch in onmessage */
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;
127
- /**
128
- * In-flight `commit` mutation requests keyed by clientTxId. Resolved when
129
- * a matching `mutation_result` frame arrives from the server, or rejected on
130
- * timeout / disconnect. Lets consumers await a server ack for mutations
131
- * sent over the same socket that streams deltas.
132
- */
133
- pendingMutations = new Map();
134
- /**
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.
139
- */
140
- pendingClaims = new Map();
141
- /**
142
- * In-flight `update_subscription` frames awaiting `subscription_ack`.
143
- * A FIFO queue rather than a keyed Map because the wire ack carries no
144
- * correlation id — the server applies subscription updates in receive
145
- * order and acks in the same order, so `shift()` on ack matches the
146
- * oldest pending request. (Read-interest changes are infrequent and
147
- * usually settle before the next one, so depth is ~1 in practice.)
148
- */
149
- pendingSubscriptions = [];
150
40
  constructor(options) {
151
- super();
152
- // Construct the WebSocket URL from the base server URL.
153
- const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
154
- const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
155
- const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
156
- this.options = {
157
- url: wsUrl,
158
- reconnectDelay: 1000,
159
- maxReconnectDelay: MAX_RECONNECT_DELAY_MS,
160
- collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
161
- syncGroups: [],
162
- lastSyncId: 0,
163
- capabilities: {
164
- partialBootstrap: true,
165
- compressedDeltas: true,
166
- streamingBootstrap: true,
167
- batchedDeltas: true,
168
- },
41
+ super({
169
42
  ...options,
170
- };
171
- this.cursor = new SyncCursor(this.options.lastSyncId);
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
- };
190
- }
191
- /**
192
- * Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
193
- * Suppresses further reconnection attempts and Sentry error capture.
194
- */
195
- setSessionErrorDetected() {
196
- this._sessionErrorDetected = true;
197
- this.sessionErrorAt = Date.now();
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
- }
210
- /**
211
- * Connect to the sync engine WebSocket
212
- */
213
- connect() {
214
- if (this._sessionErrorDetected) {
215
- getContext().logger.debug('WebSocket connect suppressed — session error detected');
216
- return;
217
- }
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');
227
- return;
228
- }
229
- // Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
230
- // handle failures. The default browser implementation reads navigator.onLine,
231
- // which is unreliable but the only signal available; in Node it returns true
232
- // (assume online) so the sidecar/agent path doesn't short-circuit here.
233
- if (!getContext().onlineStatus.isOnline()) {
234
- getContext().logger.debug('onlineStatus reports offline, but attempting connection anyway');
235
- }
236
- this.isConnecting = true;
237
- this.isManualClose = false;
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.
242
- const params = new URLSearchParams({
243
- // Intentionally omit lastSyncId, capabilities from URL; these are sent in sync_request
244
- // and ack messages to avoid stale baselines on reconnect.
245
- cursor: this.cursor.syncCursor || '',
246
- });
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.
250
- if (this.options.kind && this.options.kind !== 'user') {
251
- params.set('kind', this.options.kind);
252
- }
253
- // Add sync groups if provided
254
- this.options.syncGroups.forEach((group) => {
255
- params.append('syncGroups', group);
43
+ logger: contextLogger,
44
+ observability: contextSocketObservability,
45
+ onlineStatus: contextOnlineStatus,
256
46
  });
257
- const wsUrl = `${this.options.url}?${params.toString()}`;
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 `_`).
265
- const authToken = this.resolveAuthToken();
266
- const protocols = authToken
267
- ? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
268
- : [WS_SYNC_SUBPROTOCOL];
47
+ this.cursor = new SyncCursor(options.lastSyncId ?? 0);
48
+ }
49
+ /** The persisted resume position, sent on the upgrade URL. */
50
+ resumeCursor() {
51
+ return this.cursor.syncCursor || '';
52
+ }
53
+ /**
54
+ * The open ritual, run by the transport between its `connected` emit and the
55
+ * heartbeat start: announce presence, tell the server where we left off,
56
+ * request the deltas we missed, and start the catch-up poll.
57
+ */
58
+ onOpened() {
59
+ // Send presence update with timezone (server sets presence to "online" on connect,
60
+ // this improves localTime accuracy by providing the user's actual timezone)
61
+ this.sendPresenceUpdate('online');
62
+ // Immediately request incremental sync based on our stored cursor.
63
+ // `requestIncrementalSync` is async — a bare call inside try/catch is a
64
+ // rejection hole (the catch never sees it); route failures through
65
+ // `.catch` so they land in the same breadcrumb instead of an
66
+ // unhandled rejection.
67
+ const reportSyncRequestFailure = (e) => {
68
+ getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
69
+ error: e instanceof Error ? e.message : String(e),
70
+ });
71
+ };
269
72
  try {
270
- // Reset the handshake flag before wiring the new socket. Each connect()
271
- // gets its own lifecycle a prior successful open on a previous socket
272
- // must not mask a handshake failure on the new one.
273
- this._everOpened = false;
274
- this.ws = new WebSocket(wsUrl, protocols);
275
- this.setupEventHandlers();
73
+ if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
74
+ // Let server know where we left off before requesting deltas
75
+ this.sendAck(this.cursor.lastSyncId);
76
+ }
276
77
  }
277
- catch (error) {
278
- // WebSocket constructor can throw if URL is invalid
279
- const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
280
- getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
281
- this.isConnecting = false;
282
- this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
283
- this.scheduleReconnect();
78
+ catch (e) {
79
+ reportSyncRequestFailure(e);
284
80
  }
285
- }
286
- /**
287
- * Setup WebSocket event handlers
288
- */
289
- setupEventHandlers() {
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)
300
- return;
301
- socket.onopen = () => {
302
- if (this.ws !== socket)
303
- return; // stale socket — a newer connect() owns the state
304
- getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
305
- lastSyncId: this.cursor.lastSyncId,
306
- reconnectAttempts: this.reconnectAttempts,
307
- });
308
- this.isConnecting = false;
309
- this.reconnectAttempts = 0;
310
- this._everOpened = true;
311
- this.lastOpenAt = Date.now();
312
- this.emit('connected');
313
- // Send presence update with timezone (server sets presence to "online" on connect,
314
- // this improves localTime accuracy by providing the user's actual timezone)
315
- this.sendPresenceUpdate('online');
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
- };
326
- try {
327
- if (this.cursor.lastSyncId && this.cursor.lastSyncId > 0) {
328
- // Let server know where we left off before requesting deltas
329
- this.sendAck(this.cursor.lastSyncId);
330
- }
331
- }
332
- catch (e) {
333
- reportSyncRequestFailure(e);
334
- }
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.
341
- this.stopCatchupInterval();
342
- this.catchupInterval = setInterval(() => {
343
- if (this.ws?.readyState === WebSocket.OPEN) {
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
- });
352
- }
353
- }, CATCHUP_POLL_INTERVAL_MS);
354
- // Start the application-level heartbeat (see HeartbeatController).
355
- this.heartbeat.start();
356
- };
357
- socket.onmessage = (event) => {
358
- if (this.ws !== socket)
359
- return; // stale socket — drop, don't feed shared state
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.
364
- const message = JSON.parse(event.data);
365
- // Any inbound frame proves the socket is alive — clear the
366
- // heartbeat-timeout timer so we don't false-trip force-close
367
- // during normal traffic.
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,
81
+ this.requestIncrementalSync().catch(reportSyncRequestFailure);
82
+ // Start periodic catchup — polls for missed deltas every
83
+ // CATCHUP_POLL_INTERVAL_MS while connected. Real-time WebSocket delivery
84
+ // is best-effort, so this interval guarantees eventual consistency by
85
+ // fetching any deltas that were committed but whose broadcast was lost in
86
+ // transit.
87
+ this.stopCatchupInterval();
88
+ this.catchupInterval = setInterval(() => {
89
+ if (this.isConnected()) {
90
+ // A rejected sync request must never surface as an unhandled
91
+ // rejection from a background interval log and let the next
92
+ // poll retry.
93
+ this.requestIncrementalSync().catch((e) => {
94
+ getContext().observability.breadcrumb('Periodic catchup sync request failed', 'sync.websocket', 'warning', {
95
+ error: e instanceof Error ? e.message : String(e),
372
96
  });
373
- return;
374
- }
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);
379
- }
380
- catch (error) {
381
- getContext().observability.captureWebSocketError({
382
- context: 'parse-message',
383
- error: error instanceof Error ? error.message : String(error),
384
- });
385
- }
386
- };
387
- socket.onerror = (_event) => {
388
- if (this.ws !== socket)
389
- return; // stale socket — its errors are no longer ours
390
- // WebSocket errors are DOM Events, not Error objects
391
- // Check if we're offline first
392
- if (!getContext().onlineStatus.isOnline()) {
393
- getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
394
- this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
395
- return;
396
- }
397
- // After a session error, suppress error capture — the root cause is
398
- // already reported. Still emit so the store can update UI state.
399
- const error = new AbloConnectionError(`WebSocket connection failed`);
400
- if (!this._sessionErrorDetected) {
401
- getContext().observability.captureWebSocketError({
402
- context: 'connection-error',
403
- error: error.message,
404
97
  });
405
98
  }
406
- this.emit('error', error);
407
- };
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;
419
- const everOpened = this._everOpened;
420
- this.lastCloseAt = Date.now();
421
- this.lastCloseCode = event.code;
422
- this.lastCloseReason = event.reason || null;
423
- getContext().logger.info('WebSocket closed', {
424
- code: event.code,
425
- reason: event.reason,
426
- everOpened,
427
- reconnectAttempts: this.reconnectAttempts,
428
- forceCloseReason: this.lastForceCloseReason,
429
- msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
430
- isManualClose: this.isManualClose,
431
- });
432
- this.isConnecting = false;
433
- this.ws = null;
434
- this.stopCatchupInterval();
435
- this.heartbeat.stop();
436
- // Cancel in-flight mutations — the socket that was carrying them is
437
- // gone, and the server-side state may or may not have accepted each
438
- // one. Rejecting promptly is better than hanging the caller forever;
439
- // higher-level retry belongs to TransactionQueue, not here.
440
- if (this.pendingMutations.size > 0) {
441
- for (const pending of this.pendingMutations.values()) {
442
- clearTimeout(pending.timeout);
443
- // AbloConnectionError → `isPermanentError` treats it as transient,
444
- // so TransactionQueue retries the commit on reconnect rather than
445
- // rolling it back. `diagnostics` is preserved as a property (the
446
- // queue's failure log walks the cause chain for it).
447
- pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
448
- (event.reason ? ` reason=${event.reason}` : '') +
449
- (this.lastForceCloseReason
450
- ? ` forceCloseReason=${this.lastForceCloseReason}`
451
- : '') +
452
- ')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
453
- }
454
- this.pendingMutations.clear();
455
- }
456
- // Cancel in-flight claims — same rationale. Server-side
457
- // claims are bound to the connection; a reconnect will need
458
- // to re-claim. Higher-level retry belongs to whoever holds
459
- // the participant handle (typically the SDK's claim manager).
460
- if (this.pendingClaims.size > 0) {
461
- for (const pending of this.pendingClaims.values()) {
462
- clearTimeout(pending.timeout);
463
- pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
464
- }
465
- this.pendingClaims.clear();
466
- }
467
- // Cancel in-flight subscription updates — the reconnect handshake
468
- // re-sends `options.syncGroups` (the last acked interest) in the
469
- // upgrade URL, so a pending change that never acked is simply
470
- // retried by the caller against the fresh connection.
471
- if (this.pendingSubscriptions.length > 0) {
472
- for (const pending of this.pendingSubscriptions) {
473
- clearTimeout(pending.timeout);
474
- pending.reject(new AbloConnectionError(`WebSocket closed while update_subscription was in flight (code=${event.code})`));
475
- }
476
- this.pendingSubscriptions = [];
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
- }
492
- // Check for session-related close codes
493
- // 1008 = Policy Violation (often auth)
494
- // 4001 = Unauthorized (custom)
495
- // 4003 = Forbidden (custom)
496
- const isSessionClose = event.code === 1008 ||
497
- event.code === 4001 ||
498
- event.code === 4003 ||
499
- SyncSessionError.isSessionError(event.reason || '');
500
- if (isSessionClose) {
501
- this._sessionErrorDetected = true;
502
- this.sessionErrorAt = Date.now();
503
- getContext().observability.captureWebSocketError({
504
- context: 'session-error-close',
505
- code: event.code,
506
- reason: event.reason,
507
- });
508
- this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
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.
513
- this.emit('disconnected', event);
514
- return;
515
- }
516
- // Handshake failure: `onclose` fired before `onopen` ever did, so the
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.
521
- //
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.
527
- if (!everOpened && !this.isManualClose) {
528
- getContext().observability.captureWebSocketError({
529
- context: 'handshake-failed-close',
530
- code: event.code,
531
- reason: event.reason,
532
- });
533
- this.emit('handshake_failed', event);
534
- this.emit('disconnected', event);
535
- return;
536
- }
537
- this.emit('disconnected', event);
538
- // Reconnect if not manually closed
539
- if (!this.isManualClose) {
540
- this.scheduleReconnect();
541
- }
542
- };
99
+ }, CATCHUP_POLL_INTERVAL_MS);
100
+ }
101
+ /** The transport clears its socket reference before this runs. */
102
+ onClosed() {
103
+ this.stopCatchupInterval();
543
104
  }
544
105
  /**
545
106
  * Validates and normalizes a wire delta at the receive boundary — the single
@@ -633,7 +194,7 @@ export class SyncWebSocket extends EventEmitter {
633
194
  // will send next, and what `getLastSyncId()` reports for clean-
634
195
  // shutdown persistence.
635
196
  this.cursor.ackAdvance(syncId);
636
- if (this.ws?.readyState !== WebSocket.OPEN)
197
+ if (!this.isConnected())
637
198
  return;
638
199
  this.send({
639
200
  type: 'ack',
@@ -648,263 +209,6 @@ export class SyncWebSocket extends EventEmitter {
648
209
  acknowledge(syncId) {
649
210
  this.sendAck(syncId);
650
211
  }
651
- /**
652
- * Send message to server
653
- */
654
- send(message) {
655
- if (this.ws?.readyState !== WebSocket.OPEN) {
656
- // Only log at debug level when offline - this is expected behavior, not an error
657
- if (getContext().onlineStatus.isOnline()) {
658
- getContext().observability.breadcrumb('WebSocket not connected, cannot send message', 'sync.websocket', 'warning');
659
- }
660
- else {
661
- getContext().logger.debug('WebSocket send skipped - offline');
662
- }
663
- return;
664
- }
665
- try {
666
- this.ws.send(JSON.stringify(message));
667
- }
668
- catch (error) {
669
- // Only log as error if we're online - offline send failures are expected
670
- if (getContext().onlineStatus.isOnline()) {
671
- getContext().observability.captureWebSocketError({
672
- context: 'send-message',
673
- error: error instanceof Error ? error.message : String(error),
674
- });
675
- }
676
- else {
677
- getContext().logger.debug('WebSocket send failed - offline');
678
- }
679
- }
680
- }
681
- /**
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 } }`.
686
- *
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.
691
- */
692
- sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads, track) {
693
- if (this.ws?.readyState !== WebSocket.OPEN) {
694
- return Promise.reject(this.notConnectedError('commit'));
695
- }
696
- return new Promise((resolve, reject) => {
697
- const timeout = setTimeout(() => {
698
- this.pendingMutations.delete(clientTxId);
699
- reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
700
- }, timeoutMs);
701
- this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
702
- try {
703
- // `causedByTaskId` is included only when the agent SDK has
704
- // an open turn — keeps the wire shape stable for sessions
705
- // that don't use turns. Servers that don't know the field
706
- // ignore it; newer servers stamp it onto every delta.
707
- const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
708
- this.ws.send(JSON.stringify(frame));
709
- }
710
- catch (error) {
711
- clearTimeout(timeout);
712
- this.pendingMutations.delete(clientTxId);
713
- reject(toAbloError(error));
714
- }
715
- });
716
- }
717
- /**
718
- * Send a commit frame without waiting for `mutation_result`.
719
- *
720
- * This backs the public `wait: 'queued'` API: the socket accepted the
721
- * frame for delivery, but the server has not confirmed it yet. The
722
- * eventual `mutation_result` frame is intentionally ignored by this
723
- * instance because no pending resolver is registered.
724
- */
725
- sendCommitQueued(operations, clientTxId, causedByTaskId, reads, track) {
726
- if (this.ws?.readyState !== WebSocket.OPEN) {
727
- throw this.notConnectedError('commit');
728
- }
729
- const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
730
- this.ws.send(JSON.stringify(frame));
731
- }
732
- /**
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.
737
- *
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.
741
- */
742
- sendClaim(claimId, syncGroups, options) {
743
- if (this.ws?.readyState !== WebSocket.OPEN) {
744
- return Promise.reject(this.notConnectedError('claim'));
745
- }
746
- const timeoutMs = options?.timeoutMs ?? 15_000;
747
- return new Promise((resolve, reject) => {
748
- const timeout = setTimeout(() => {
749
- this.pendingClaims.delete(claimId);
750
- reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
751
- code: 'wait_for_timeout',
752
- }));
753
- }, timeoutMs);
754
- this.pendingClaims.set(claimId, { resolve, reject, timeout });
755
- try {
756
- this.ws.send(JSON.stringify({
757
- type: 'claim',
758
- payload: {
759
- claimId,
760
- syncGroups: [...syncGroups],
761
- capabilityToken: options?.capabilityToken,
762
- ttlSeconds: options?.ttlSeconds,
763
- },
764
- }));
765
- }
766
- catch (error) {
767
- clearTimeout(timeout);
768
- this.pendingClaims.delete(claimId);
769
- reject(toAbloError(error));
770
- }
771
- });
772
- }
773
- /**
774
- * Drop a previously-active claim. Idempotent — `release` is
775
- * fire-and-forget per the wire contract; the server accepts
776
- * unknown claimIds silently so disconnect-time release storms
777
- * never error. No ack is expected.
778
- *
779
- * If a claim's send promise is still pending (no claim_ack yet),
780
- * we reject it locally — the user explicitly chose to release.
781
- */
782
- sendRelease(claimId) {
783
- // Cancel any in-flight claim that hadn't acked yet — the user
784
- // changed their mind. Without this the timer would eventually
785
- // reject; doing it now matches the user's claim immediately.
786
- const pending = this.pendingClaims.get(claimId);
787
- if (pending) {
788
- clearTimeout(pending.timeout);
789
- this.pendingClaims.delete(claimId);
790
- pending.reject(new AbloError(`claim ${claimId} released before ack`, {
791
- code: 'claim_wait_aborted',
792
- httpStatus: 409,
793
- }));
794
- }
795
- if (this.ws?.readyState !== WebSocket.OPEN)
796
- return;
797
- try {
798
- this.ws.send(JSON.stringify({ type: 'release', payload: { claimId } }));
799
- }
800
- catch {
801
- // Idempotent contract — silent failure is acceptable here.
802
- }
803
- }
804
- /**
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.
809
- *
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.
816
- *
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.
820
- */
821
- updateSubscription(syncGroups, options) {
822
- if (this.ws?.readyState !== WebSocket.OPEN) {
823
- return Promise.reject(this.notConnectedError('update_subscription'));
824
- }
825
- const timeoutMs = options?.timeoutMs ?? 15_000;
826
- return new Promise((resolve, reject) => {
827
- const entry = {
828
- resolve,
829
- reject,
830
- timeout: setTimeout(() => {
831
- const idx = this.pendingSubscriptions.indexOf(entry);
832
- if (idx !== -1)
833
- this.pendingSubscriptions.splice(idx, 1);
834
- reject(new AbloConnectionError(`update_subscription timed out after ${timeoutMs}ms`, { code: 'wait_for_timeout' }));
835
- }, timeoutMs),
836
- };
837
- this.pendingSubscriptions.push(entry);
838
- try {
839
- this.ws.send(JSON.stringify({
840
- type: 'update_subscription',
841
- payload: { syncGroups: [...syncGroups] },
842
- }));
843
- }
844
- catch (error) {
845
- clearTimeout(entry.timeout);
846
- const idx = this.pendingSubscriptions.indexOf(entry);
847
- if (idx !== -1)
848
- this.pendingSubscriptions.splice(idx, 1);
849
- reject(toAbloError(error));
850
- }
851
- });
852
- }
853
- /**
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.
857
- */
858
- setCapabilityToken(token) {
859
- this.options.capabilityToken = token;
860
- }
861
- getAuthToken() {
862
- return this.resolveAuthToken();
863
- }
864
- /**
865
- * Return the credential that will be used by the next WebSocket upgrade.
866
- * ConnectionManager reads this for HTTP auth probes so visibility/network
867
- * checks authenticate the same way reconnects do.
868
- */
869
- getCapabilityToken() {
870
- return this.resolveAuthToken();
871
- }
872
- resolveAuthToken = () => {
873
- return this.options.getAuthToken?.()
874
- ?? this.options.getCapabilityToken?.()
875
- ?? this.options.capabilityToken;
876
- };
877
- /**
878
- * Send spreadsheet selection presence
879
- */
880
- sendSheetSelection(sheetId, selectedCells) {
881
- this.sendCollaborationEvent('sheet:selection', {
882
- sheetId,
883
- selectedCells,
884
- });
885
- }
886
- /**
887
- * Send slide layer selection presence
888
- */
889
- sendSlideSelection(deckId, slideId, selectedLayers) {
890
- this.sendCollaborationEvent('slide:selection', {
891
- deckId,
892
- slideId,
893
- selectedLayers,
894
- });
895
- }
896
- /**
897
- * Send slide cursor position for real-time collaboration
898
- * Note: Throttling should be handled by the caller (e.g., useSlideCursorBroadcast hook)
899
- */
900
- sendSlideCursor(deckId, slideId, x, y) {
901
- this.sendCollaborationEvent('slide:cursor', {
902
- deckId,
903
- slideId,
904
- x,
905
- y,
906
- });
907
- }
908
212
  /**
909
213
  * Send presence update to server.
910
214
  * Use this for:
@@ -918,7 +222,7 @@ export class SyncWebSocket extends EventEmitter {
918
222
  * @param customStatus - Optional custom status message
919
223
  */
920
224
  sendPresenceUpdate(status = 'online', customStatus) {
921
- if (this.ws?.readyState !== WebSocket.OPEN)
225
+ if (!this.isConnected())
922
226
  return;
923
227
  const timezone = (() => {
924
228
  try {
@@ -937,50 +241,6 @@ export class SyncWebSocket extends EventEmitter {
937
241
  },
938
242
  });
939
243
  }
940
- /**
941
- * Schedule reconnection with exponential backoff
942
- */
943
- scheduleReconnect() {
944
- if (this.reconnectTimer) {
945
- clearTimeout(this.reconnectTimer);
946
- }
947
- // Session error means the user needs to re-authenticate — don't reconnect.
948
- if (this._sessionErrorDetected) {
949
- return;
950
- }
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.
955
- if (!getContext().onlineStatus.isOnline()) {
956
- this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
957
- return;
958
- }
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.
962
- if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
963
- this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
964
- return;
965
- }
966
- // Exponential backoff with ±15% jitter to prevent thundering herd
967
- const baseDelay = Math.min(this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.options.maxReconnectDelay);
968
- const jitter = baseDelay * (0.85 + Math.random() * 0.3);
969
- const delay = Math.round(jitter);
970
- // Emit reconnecting event so UI can show reconnection status
971
- this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay });
972
- this.reconnectTimer = setTimeout(() => {
973
- this.reconnectAttempts++;
974
- this.connect();
975
- }, delay);
976
- }
977
- /**
978
- * Reset reconnect attempt counter. Called when network comes back online
979
- * to allow a fresh reconnect cycle after the max was previously reached.
980
- */
981
- resetReconnectAttempts() {
982
- this.reconnectAttempts = 0;
983
- }
984
244
  /**
985
245
  * Stop the periodic catchup interval
986
246
  */
@@ -994,128 +254,8 @@ export class SyncWebSocket extends EventEmitter {
994
254
  * Disconnect from WebSocket
995
255
  */
996
256
  disconnect() {
997
- this.isManualClose = true;
998
257
  this.stopCatchupInterval();
999
- this.heartbeat.stop();
1000
- if (this.reconnectTimer) {
1001
- clearTimeout(this.reconnectTimer);
1002
- this.reconnectTimer = null;
1003
- }
1004
- if (this.ws) {
1005
- this.ws.close(1000, 'Manual disconnect');
1006
- this.ws = null;
1007
- }
1008
- }
1009
- /**
1010
- * Force-close the socket from the client side using a private 4xxx
1011
- * code. Callers expect `onclose` to fire; that handler runs the
1012
- * existing reconnect / handshake-failed dispatch. Wrapped in
1013
- * try/catch because `close()` on a CLOSING/CLOSED socket throws on
1014
- * some browsers.
1015
- */
1016
- forceClose(reason) {
1017
- if (!this.ws)
1018
- return;
1019
- this.lastForceCloseReason = reason;
1020
- getContext().logger.debug('[SyncWebSocket] forceClose', {
1021
- reason,
1022
- readyState: this.ws.readyState,
1023
- msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
1024
- });
1025
- try {
1026
- this.ws.close(4000, reason);
1027
- }
1028
- catch {
1029
- // Already closing / closed — onclose will still fire.
1030
- }
1031
- }
1032
- /**
1033
- * Get connection state
1034
- */
1035
- isConnected() {
1036
- return this.ws?.readyState === WebSocket.OPEN;
1037
- }
1038
- /**
1039
- * Snapshot of recent connection lifecycle state, for diagnostic logs
1040
- * and error messages. Cheap to call (no I/O); safe to log every time
1041
- * a send is rejected so we can attribute "not connected" rejections
1042
- * to the actual root cause (handshake reject vs heartbeat zombie vs
1043
- * session expiry vs explicit close).
1044
- */
1045
- getConnectionDiagnostics() {
1046
- const now = Date.now();
1047
- return {
1048
- readyState: this.ws?.readyState ?? null,
1049
- isConnecting: this.isConnecting,
1050
- isManualClose: this.isManualClose,
1051
- sessionErrorDetected: this._sessionErrorDetected,
1052
- everOpened: this._everOpened,
1053
- reconnectAttempts: this.reconnectAttempts,
1054
- maxReconnectAttempts: SyncWebSocket.MAX_RECONNECT_ATTEMPTS,
1055
- lastOpenAt: this.lastOpenAt,
1056
- lastCloseAt: this.lastCloseAt,
1057
- lastCloseCode: this.lastCloseCode,
1058
- lastCloseReason: this.lastCloseReason,
1059
- lastForceCloseReason: this.lastForceCloseReason,
1060
- sessionErrorAt: this.sessionErrorAt,
1061
- msSinceLastOpen: this.lastOpenAt != null ? now - this.lastOpenAt : null,
1062
- msSinceLastClose: this.lastCloseAt != null ? now - this.lastCloseAt : null,
1063
- };
1064
- }
1065
- /**
1066
- * Build a richly-diagnosed "not connected" error so callers (and the
1067
- * logs they emit) can attribute the rejection. The message embeds the
1068
- * dominant signal in human-readable form; the structured detail is
1069
- * also attached as `error.diagnostics` for log scrapers.
1070
- */
1071
- notConnectedError(action) {
1072
- const d = this.getConnectionDiagnostics();
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.
1079
- if (d.sessionErrorDetected) {
1080
- return Object.assign(new SyncSessionError(`SyncWebSocket not connected — cannot send ${action}: session expired` +
1081
- (d.lastCloseReason ? ` (${d.lastCloseReason})` : '') +
1082
- '; re-authenticate'), { diagnostics: d });
1083
- }
1084
- let detail;
1085
- if (d.isManualClose) {
1086
- detail = 'manual_close';
1087
- }
1088
- else if (d.isConnecting) {
1089
- detail = 'still_connecting';
1090
- }
1091
- else if (!d.everOpened && d.lastCloseAt != null) {
1092
- detail = `handshake_failed code=${d.lastCloseCode}`;
1093
- }
1094
- else if (d.lastForceCloseReason) {
1095
- detail = `force_closed reason=${d.lastForceCloseReason}`;
1096
- }
1097
- else if (d.lastCloseAt != null) {
1098
- detail =
1099
- `closed code=${d.lastCloseCode}` +
1100
- (d.lastCloseReason ? ` reason=${d.lastCloseReason}` : '') +
1101
- (d.msSinceLastClose != null ? ` ${d.msSinceLastClose}ms ago` : '') +
1102
- (d.reconnectAttempts > 0
1103
- ? ` reconnectAttempts=${d.reconnectAttempts}/${d.maxReconnectAttempts}`
1104
- : '');
1105
- }
1106
- else {
1107
- detail = 'never_connected';
1108
- }
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.
1113
- const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
1114
- return err;
1115
- }
1116
- /** Returns the sync groups this connection is subscribed to. */
1117
- getSyncGroups() {
1118
- return this.options.syncGroups;
258
+ super.disconnect();
1119
259
  }
1120
260
  // Cursor accessors — thin delegates; the state and semantics live in
1121
261
  // SyncCursor.
@@ -1147,7 +287,7 @@ export class SyncWebSocket extends EventEmitter {
1147
287
  * Requests an incremental sync from the server, starting at the current cursor.
1148
288
  */
1149
289
  async requestIncrementalSync() {
1150
- if (this.ws?.readyState !== WebSocket.OPEN) {
290
+ if (!this.isConnected()) {
1151
291
  // Silent when offline - not an error condition
1152
292
  if (getContext().onlineStatus.isOnline()) {
1153
293
  getContext().observability.breadcrumb('WebSocket not connected, cannot request sync', 'sync.websocket', 'warning');
@@ -1175,7 +315,7 @@ export class SyncWebSocket extends EventEmitter {
1175
315
  * Request bootstrap for specific entities
1176
316
  */
1177
317
  async requestBootstrap(entities) {
1178
- if (this.ws?.readyState !== WebSocket.OPEN) {
318
+ if (!this.isConnected()) {
1179
319
  // Silent when offline - not an error condition
1180
320
  if (getContext().onlineStatus.isOnline()) {
1181
321
  getContext().observability.breadcrumb('WebSocket not connected, cannot request bootstrap', 'sync.websocket', 'warning');
@@ -1295,22 +435,4 @@ export class SyncWebSocket extends EventEmitter {
1295
435
  cursor: typeof p.cursor === 'string' ? p.cursor : undefined,
1296
436
  });
1297
437
  }
1298
- /**
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.
1303
- *
1304
- * The wire frame is:
1305
- * { type: 'presence_update', payload: { kind, userId, status,
1306
- * syncGroups, activity, isAgent, timestamp, activeClaims } }
1307
- */
1308
- handlePresenceUpdate(message) {
1309
- const event =
1310
- // Server canonical path: `{ payload: {...} }`. Some legacy
1311
- // pathways emit fields at the top level (test fixtures) — fall
1312
- // back to reading from the message itself.
1313
- message.payload ?? message;
1314
- this.emit('presence_update', event);
1315
- }
1316
438
  }