@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,202 +1,27 @@
1
1
  /**
2
- * TransactionQueue - Production-ready transaction management
2
+ * TransactionQueue manages the lifecycle of local writes on their way to the
3
+ * server: it applies each change optimistically, batches the writes made in one
4
+ * event-loop tick into a single commit, retries transient failures, and rolls
5
+ * back on permanent rejection.
3
6
  *
4
- * Key features:
5
- * - Optimistic updates with rollback
6
- * - Conflict resolution strategies
7
- * - LINEAR-style microtask batching (transactions in same event loop share batchId)
8
- * - Proper dependency injection (no singleton)
7
+ * Key behaviours:
8
+ * - Optimistic updates with rollback on failure.
9
+ * - Configurable conflict resolution.
10
+ * - Microtask batching: transactions created in the same event-loop tick share
11
+ * a batch id and commit together in one round trip.
12
+ * - A dependency-injected executor, so several queues can coexist.
9
13
  */
10
14
  import { EventEmitter } from 'events';
15
+ import { Model } from '../Model.js';
11
16
  import { getContext } from '../context.js';
12
- import { getActiveRegistry } from '../ModelRegistry.js';
13
- import { MutationOperationType } from '../types/index.js';
14
- import { handleMutationError } from './mutation-error-handler.js';
15
17
  import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
16
18
  import { SyncPosition } from '../sync/syncPosition.js';
17
- /**
18
- * Framework-internal keys added by `Model.toJSON()` that must never
19
- * reach the wire. The server treats each top-level key as a target
20
- * column, so shipping these would blow up the INSERT/UPDATE.
21
- */
22
- const FRAMEWORK_KEYS = new Set(['__class', '__typename', 'clientId', 'syncStatus']);
23
- /**
24
- * Project a Model's serialized data onto its schema-declared fields
25
- * and return a wire-safe commit payload. Two jobs:
26
- *
27
- * 1. Drop framework internals (`__class`, `__typename`, `clientId`,
28
- * `syncStatus`) and anything not declared on the model's schema.
29
- * 2. JSON.stringify values typed as `field.json()` — TEXT columns
30
- * storing JSON need explicit stringification; postgres.js won't
31
- * auto-serialize for non-JSONB columns.
32
- *
33
- * For updates (`dropUndefined: true`), `undefined` values are also
34
- * stripped so they don't translate to `SET column = NULL` on the
35
- * server side.
36
- *
37
- * Fields are read from `ModelRegistry`, populated by
38
- * `registerModelsFromSchema` at SDK initialization. If the model
39
- * isn't registered with field metadata (edge case — e.g., tests or
40
- * manually registered models), projection falls back to identity and
41
- * the caller gets whatever the Model serialized.
42
- */
43
- function projectCommitPayload(modelName, source, opts) {
44
- const metadata = getActiveRegistry().getMetadata(modelName);
45
- const fields = metadata?.fields;
46
- const out = {};
47
- if (!fields) {
48
- // Unknown registration — strip framework keys and ship the rest.
49
- for (const [k, v] of Object.entries(source)) {
50
- if (FRAMEWORK_KEYS.has(k))
51
- continue;
52
- if (opts.dropUndefined && v === undefined)
53
- continue;
54
- out[k] = v;
55
- }
56
- return out;
57
- }
58
- for (const [key, meta] of Object.entries(fields)) {
59
- if (!(key in source))
60
- continue;
61
- const value = source[key];
62
- if (opts.dropUndefined && value === undefined)
63
- continue;
64
- // JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
65
- // the wire, not pre-stringified strings. Previously we stringified
66
- // here, which round-tripped incorrectly:
67
- //
68
- // 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
69
- // 2. Server writes to jsonb column (parses string → jsonb object, fine)
70
- // 3. Server's delta echoes `data: JSON.stringify(op.input)` where
71
- // `op.input.position` is still the STRING from step 1
72
- // 4. Client merges delta → `model.position = "{...}"` (STRING)
73
- // 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
74
- // char-by-char, producing corrupted char-indexed objects like
75
- // `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
76
- // 6. That corrupt object lands in the next commit, stored in jsonb.
77
- //
78
- // Sending objects avoids the round-trip mismatch: the wire carries
79
- // the object through delta + commit unchanged, and `postgres-js`
80
- // serializes JS objects to jsonb correctly via its own
81
- // `json.serialize` (triggered by Postgres's ParameterDescription
82
- // response identifying the column as type 3802 / jsonb).
83
- out[key] = value;
84
- }
85
- return out;
86
- }
87
- const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
88
- const stripModelSuffix = (modelName) => modelName.replace('Model', '');
89
- /**
90
- * FK-ordered create priority.
91
- *
92
- * Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
93
- * this map is populated once at `createSyncEngine(...)` time by walking the
94
- * schema's `belongsTo` graph (see `computeFKDepthPriority` in
95
- * `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
96
- * names appear here, and consumer applications can override specific
97
- * priorities via `configOverrides.modelCreatePriority` without touching the
98
- * SDK.
99
- *
100
- * Non-create ops (update/delete/archive/unarchive) don't need FK ordering
101
- * because the row already exists, so they all share
102
- * `config.defaultNonCreatePriority`.
103
- */
104
- const computePriorityScore = (type, modelName) => {
105
- const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
106
- if (type !== 'create')
107
- return defaultNonCreatePriority;
108
- return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
109
- };
110
- const TX_TYPE_TO_MUTATION_OP = {
111
- create: MutationOperationType.CREATE,
112
- update: MutationOperationType.UPDATE,
113
- delete: MutationOperationType.DELETE,
114
- archive: MutationOperationType.ARCHIVE,
115
- unarchive: MutationOperationType.UNARCHIVE,
116
- };
117
- function hasStaleWriteOptions(options) {
118
- return (options?.readAt !== undefined ||
119
- options?.onStale !== undefined);
120
- }
121
- /**
122
- * Project a transaction's `writeOptions` onto the wire operation. Stale
123
- * guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
124
- * ride in the op's `options` slot (`MutationOperation.options` — the
125
- * mutation_log cache key + audit tag). This is the single place the
126
- * caller-supplied write vocabulary crosses onto the wire.
127
- */
128
- function applyWriteOptions(op, transaction) {
129
- const operation = op;
130
- const writeOptions = transaction.writeOptions;
131
- if (!writeOptions)
132
- return operation;
133
- if (writeOptions.readAt !== undefined) {
134
- operation.readAt = writeOptions.readAt;
135
- }
136
- if (writeOptions.onStale !== undefined) {
137
- operation.onStale = writeOptions.onStale;
138
- }
139
- if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
140
- operation.options = {
141
- ...(writeOptions.idempotencyKey != null
142
- ? { idempotencyKey: writeOptions.idempotencyKey }
143
- : {}),
144
- ...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
145
- };
146
- }
147
- return operation;
148
- }
149
- function asTransportError(value) {
150
- return (value && typeof value === 'object' ? value : {});
151
- }
152
- function extractStatusCode(error) {
153
- return asTransportError(error).response?.status;
154
- }
155
- class TransactionStore {
156
- transactions = new Map();
157
- byStatus = new Map();
158
- add(transaction) {
159
- this.transactions.set(transaction.id, transaction);
160
- if (!this.byStatus.has(transaction.status)) {
161
- this.byStatus.set(transaction.status, new Set());
162
- }
163
- this.byStatus.get(transaction.status).add(transaction.id);
164
- }
165
- get(id) {
166
- return this.transactions.get(id);
167
- }
168
- updateStatus(id, newStatus) {
169
- const tx = this.transactions.get(id);
170
- if (!tx)
171
- return;
172
- this.byStatus.get(tx.status)?.delete(id);
173
- tx.status = newStatus;
174
- if (!this.byStatus.has(newStatus)) {
175
- this.byStatus.set(newStatus, new Set());
176
- }
177
- this.byStatus.get(newStatus).add(id);
178
- }
179
- getByStatus(status) {
180
- const ids = this.byStatus.get(status) || new Set();
181
- return Array.from(ids)
182
- .map((id) => this.transactions.get(id))
183
- .filter(Boolean);
184
- }
185
- remove(id) {
186
- const tx = this.transactions.get(id);
187
- if (!tx)
188
- return;
189
- this.transactions.delete(id);
190
- this.byStatus.get(tx.status)?.delete(id);
191
- }
192
- clear() {
193
- this.transactions.clear();
194
- this.byStatus.clear();
195
- }
196
- getAll() {
197
- return Array.from(this.transactions.values());
198
- }
199
- }
19
+ import { projectCommitPayload, computePriorityScore, normalizeModelKey, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
20
+ import { TransactionStore } from './TransactionStore.js';
21
+ import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
22
+ import { DeltaConfirmationTracker } from './deltaConfirmation.js';
23
+ import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './replayValidation.js';
24
+ import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
200
25
  export class TransactionQueue extends EventEmitter {
201
26
  store = new TransactionStore();
202
27
  // Signature of the last permanent-error we logged at `warn`. A `create`
@@ -205,17 +30,17 @@ export class TransactionQueue extends EventEmitter {
205
30
  // this, the identical cause prints on a loop. We log the first occurrence
206
31
  // and demote exact repeats to `debug`.
207
32
  lastPermanentErrorSig;
208
- // Per-instance executor binding. Set by `setMutationExecutor(...)` from the
209
- // owning Ablo right after construction. Falls back to `getContext()` only
210
- // when unset (preserves legacy tests / SDK consumers that haven't migrated).
33
+ // The executor bound to this queue instance, set by `setMutationExecutor(...)`
34
+ // just after construction. When unset it falls back to the ambient executor
35
+ // from `getContext()`.
211
36
  //
212
- // Why this exists: `initSyncEngine()` writes a *module-level* singleton.
213
- // Constructing a second Ablo (e.g. worker + per-job peer in agent-worker)
214
- // overwrites the first instance's executor. Without an instance binding,
215
- // queue commits on Ablo A would dispatch through Ablo B's executor closure,
216
- // which captures B's `storeHolder.store` and once B disposes its store,
217
- // that closure returns `null` for `getWs()` and every commit on A throws
218
- // `ws_not_ready` forever (queue classifies it as transient retry loop).
37
+ // The binding matters because the ambient executor is a module-level
38
+ // singleton: constructing a second client instance overwrites the first
39
+ // instance's executor. Without a per-instance binding, commits on one
40
+ // instance would dispatch through another instance's executor closure; once
41
+ // that other instance disposed its store, the closure would resolve no live
42
+ // connection and every commit here would fail with `ws_not_ready`, which the
43
+ // queue treats as transient and retries endlessly.
219
44
  _mutationExecutor = null;
220
45
  get mutationExecutor() {
221
46
  return this._mutationExecutor ?? getContext().mutationExecutor;
@@ -224,8 +49,8 @@ export class TransactionQueue extends EventEmitter {
224
49
  isProcessing = false;
225
50
  processTimer;
226
51
  processScheduled = false;
227
- // LINEAR PATTERN: Staging area for transactions created in same event loop tick
228
- // All transactions go here first, then get committed together via microtask
52
+ // Staging area for transactions created in the same event-loop tick. Each one
53
+ // lands here first, then a microtask commits them together.
229
54
  createdTransactions = [];
230
55
  commitScheduled = false;
231
56
  // Per-model in-flight tracking and merge buffer
@@ -250,10 +75,7 @@ export class TransactionQueue extends EventEmitter {
250
75
  }
251
76
  }
252
77
  entityKey(modelName, modelId) {
253
- return `${modelName}:${modelId}`;
254
- }
255
- isTransactionForModel(transaction, modelName, modelId) {
256
- return transaction.modelName === modelName && transaction.modelId === modelId;
78
+ return entityKey(modelName, modelId);
257
79
  }
258
80
  resolveConfirmation(transaction) {
259
81
  const resolver = this.confirmationResolvers.get(transaction.id);
@@ -263,19 +85,7 @@ export class TransactionQueue extends EventEmitter {
263
85
  resolver.resolve();
264
86
  }
265
87
  takeUnsentCreateForModel(modelName, modelId) {
266
- const isUnsentCreate = (tx) => tx.type === 'create' &&
267
- tx.status === 'pending' &&
268
- tx.attempts === 0 &&
269
- this.isTransactionForModel(tx, modelName, modelId);
270
- const stagedIndex = this.createdTransactions.findIndex(isUnsentCreate);
271
- if (stagedIndex >= 0) {
272
- return this.createdTransactions.splice(stagedIndex, 1)[0];
273
- }
274
- const queuedIndex = this.executionQueue.findIndex(isUnsentCreate);
275
- if (queuedIndex >= 0) {
276
- return this.executionQueue.splice(queuedIndex, 1)[0];
277
- }
278
- return this.store.getByStatus('pending').find(isUnsentCreate);
88
+ return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
279
89
  }
280
90
  async cancelUnsentCreateForDelete(transaction) {
281
91
  this.store.updateStatus(transaction.id, 'rolled_back');
@@ -285,17 +95,7 @@ export class TransactionQueue extends EventEmitter {
285
95
  this.resolveConfirmation(transaction);
286
96
  }
287
97
  findCreateBarrierForDelete(modelName, modelId) {
288
- const liveCreates = [
289
- ...this.store.getByStatus('pending'),
290
- ...this.store.getByStatus('executing'),
291
- ...this.store.getByStatus('awaiting_delta'),
292
- ].filter((tx) => tx.type === 'create' &&
293
- this.isTransactionForModel(tx, modelName, modelId) &&
294
- // A never-attempted pending create can be cancelled instead. Once the
295
- // create has been sent, even a retry-pending state is a causal barrier:
296
- // the server may already have applied it and only the response was lost.
297
- (tx.status !== 'pending' || tx.attempts > 0));
298
- return liveCreates.sort((a, b) => b.createdAt - a.createdAt)[0];
98
+ return findCreateBarrierForDelete(this.store, modelName, modelId);
299
99
  }
300
100
  completeLocalDelete(model, context, writeOptions) {
301
101
  const actualModelName = model.getModelName();
@@ -328,75 +128,17 @@ export class TransactionQueue extends EventEmitter {
328
128
  return transaction;
329
129
  }
330
130
  deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
331
- const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
332
- const deferred = this.deferredDeletesByCreate.get(key) ?? [];
333
- deferred.push(deleteTransaction);
334
- this.deferredDeletesByCreate.set(key, deferred);
131
+ deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
335
132
  }
336
133
  releaseDeferredDeletesForCreate(createTransaction) {
337
- const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
338
- const deferred = this.deferredDeletesByCreate.get(key);
339
- if (!deferred || deferred.length === 0)
340
- return;
341
- this.deferredDeletesByCreate.delete(key);
342
- for (const deleteTransaction of deferred) {
343
- if (this.store.get(deleteTransaction.id)?.status !== 'pending')
344
- continue;
345
- this.enqueue(deleteTransaction);
346
- }
134
+ releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
347
135
  }
348
- // Merge two GraphQL update payloads with special handling for metadata fields
349
- mergeUpdateData(left, right, _modelName) {
350
- const out = { ...(left || {}) };
351
- const src = right || {};
352
- for (const key of Object.keys(src)) {
353
- // Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
354
- if (key === 'metadata') {
355
- const l = out.metadata;
356
- const r = src.metadata;
357
- // If both sides undefined/null, continue
358
- if (l == null && r == null) {
359
- continue;
360
- }
361
- // Normalize to objects
362
- const toObj = (v) => {
363
- if (v == null)
364
- return {};
365
- if (typeof v === 'string') {
366
- try {
367
- return JSON.parse(v);
368
- }
369
- catch {
370
- return {};
371
- }
372
- }
373
- if (typeof v === 'object')
374
- return v;
375
- return {};
376
- };
377
- const lobj = toObj(l);
378
- const robj = toObj(r);
379
- const merged = { ...lobj, ...robj };
380
- // Re-stringify to match schema input type
381
- try {
382
- out.metadata = JSON.stringify(merged);
383
- }
384
- catch {
385
- // Fallback to right-hand side if stringify fails
386
- out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
387
- }
388
- continue;
389
- }
390
- // Default: shallow overwrite with right-hand value
391
- out[key] = src[key];
392
- }
393
- return out;
394
- }
395
- // Configuration - tuned for LINEAR-style batching
396
- // Higher batch size and delay allows more operations to coalesce into single HTTP call
136
+ // Default configuration, tuned so more operations coalesce into a single
137
+ // commit: a larger batch size and delay give rapid operations more time to
138
+ // merge before the batch is sent.
397
139
  config = {
398
- maxBatchSize: 50, // Increased from 10 - matches Linear's batch size
399
- batchDelay: 150, // Increased from 50ms - more time to coalesce rapid operations
140
+ maxBatchSize: 50, // send up to this many operations per commit
141
+ batchDelay: 150, // milliseconds to wait for more operations before sending
400
142
  maxRetries: 3,
401
143
  conflictResolution: {
402
144
  strategy: 'last-write-wins',
@@ -413,18 +155,21 @@ export class TransactionQueue extends EventEmitter {
413
155
  };
414
156
  // Track executing transactions for backpressure
415
157
  executingCount = 0;
416
- // Optimistic update tracking
158
+ // Optimistic update tracking. The entry shape and apply/rollback rules live
159
+ // in `./optimisticApply.js`; the map itself stays on the queue because the
160
+ // completion paths, `getStats`, and `dispose` all read it.
417
161
  optimisticUpdates = new Map();
418
- // Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
419
- // transaction id, populated from the commit ack and drained by
420
- // `waitForCommitReceipt` so the receipt carries the self-heal signal.
162
+ // Stale-context notifications, keyed by transaction id. When the server
163
+ // accepts a commit but reports that an operation's read premise had moved,
164
+ // the notification lands here from the commit acknowledgement and is drained
165
+ // by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
421
166
  commitNotifications = new Map();
422
- // LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
423
- // Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
424
- deltaConfirmationTimeouts = new Map();
425
- // Track retry attempts per transaction for exponential backoff
426
- deltaConfirmationRetries = new Map();
427
- // Connection state check - set by SyncClient to prevent rollbacks during disconnection
167
+ // Delta-confirmation tracking (ack watermark advance + the awaiting_delta
168
+ // timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
169
+ // constructor once `position` is bound.
170
+ deltaConfirmation;
171
+ // Connection-state check, supplied by the client, used to hold off rollbacks
172
+ // while disconnected.
428
173
  isConnectedFn = () => true;
429
174
  // Grace timer that, when fired, fails any commit-lane transaction
430
175
  // still awaiting an ack. Started on `setConnectionState('disconnected')`,
@@ -432,11 +177,11 @@ export class TransactionQueue extends EventEmitter {
432
177
  // is preserved for brief blips; this only catches persistent disconnects.
433
178
  commitOfflineGraceTimer = null;
434
179
  /**
435
- * THE client's place in the global delta order the SHARED instance
436
- * (injected by SyncClient; standalone construction gets its own). The
437
- * queue advances `acked` on commit responses; the store advances
438
- * `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
439
- * rationale live in `sync/syncPosition.ts`.
180
+ * This client's place in the global order of sync deltas. The instance is
181
+ * shared: the client injects one, and a standalone queue creates its own. The
182
+ * queue advances the `acked` cursor as commit responses arrive, the store
183
+ * advances `applied` and `persisted`, and snapshots and claims read
184
+ * `readFloor`. See `../sync/syncPosition.js` for the full contract.
440
185
  */
441
186
  position;
442
187
  /** Applied-cursor alias, kept so the many internal read sites stay legible. */
@@ -444,15 +189,8 @@ export class TransactionQueue extends EventEmitter {
444
189
  return this.position.applied;
445
190
  }
446
191
  noteAck(lastSyncId) {
447
- this.position.noteAck(lastSyncId);
448
- }
449
- // Delta confirmation retry config (Replicache-style exponential backoff)
450
- // Max retries before requesting full reconciliation
451
- static DELTA_MAX_RETRIES = 5;
452
- // Initial timeout (first attempt)
453
- static DELTA_INITIAL_TIMEOUT_MS = 30_000;
454
- // Max timeout cap (like Replicache's maxDelayMs of 60s)
455
- static DELTA_MAX_TIMEOUT_MS = 120_000;
192
+ this.deltaConfirmation.noteAck(lastSyncId);
193
+ }
456
194
  // Batch management
457
195
  batchIndex = 0;
458
196
  /**
@@ -466,6 +204,18 @@ export class TransactionQueue extends EventEmitter {
466
204
  constructor(config) {
467
205
  super();
468
206
  this.position = config?.position ?? new SyncPosition();
207
+ // Bind the confirmation tracker to this queue's store/ledger/events.
208
+ // `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
209
+ // swaps stay visible to in-flight timeouts.
210
+ this.deltaConfirmation = new DeltaConfirmationTracker({
211
+ store: this.store,
212
+ optimisticUpdates: this.optimisticUpdates,
213
+ emit: (event, payload) => {
214
+ this.emit(event, payload);
215
+ },
216
+ isConnected: () => this.isConnectedFn(),
217
+ position: this.position,
218
+ });
469
219
  if (config) {
470
220
  this.config = { ...this.config, ...config };
471
221
  }
@@ -500,22 +250,18 @@ export class TransactionQueue extends EventEmitter {
500
250
  });
501
251
  }
502
252
  /**
503
- * Look up the in-flight `confirmation` promise for a (model, id) pair.
504
- * Returns the promise from the most-recent live transaction matching
505
- * the given model+id, or `Promise.resolve()` if none is open (which
506
- * means either "already confirmed" or "never staged" — both safe
507
- * outcomes for the routing-helper grace-window use case).
508
- *
509
- * Looks across `pending`, `executing`, and `awaiting_delta` — these
510
- * are the three non-terminal statuses where rollback is still
511
- * possible. Skips `completed` (already settled) and `failed` /
512
- * `rolled_back` (already rejected; the call site missed the
513
- * `confirmation` window and should rely on `onMutationFailure` toast
514
- * instead).
253
+ * Returns the in-flight confirmation promise for a given model and id. When
254
+ * several transactions match, it returns the most recent one's promise; when
255
+ * none is open it resolves immediately, which covers both "already confirmed"
256
+ * and "never staged".
515
257
  *
516
- * Distinct from `tx.confirmation` on a known transaction used by
517
- * call sites that hold a Model reference (returned by
518
- * `ablo.<model>.create()`) but never see the underlying transaction.
258
+ * It considers the three non-terminal statuses in which the write can still
259
+ * be rolled back `pending`, `executing`, and `awaiting_delta` — and ignores
260
+ * `completed` (already settled) and `failed`/`rolled_back` (already
261
+ * rejected). This complements the `confirmation` promise carried on a known
262
+ * {@link Transaction}: use this method at call sites that hold a model
263
+ * returned by `ablo.<model>.create()` but never see the underlying
264
+ * transaction.
519
265
  */
520
266
  confirmationFor(modelName, modelId) {
521
267
  const candidates = [
@@ -526,18 +272,20 @@ export class TransactionQueue extends EventEmitter {
526
272
  if (candidates.length === 0)
527
273
  return Promise.resolve();
528
274
  const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
275
+ if (!latest)
276
+ return Promise.resolve();
529
277
  return latest.confirmation ?? Promise.resolve();
530
278
  }
531
279
  /**
532
- * Attach a hot `confirmation` promise to a freshly created transaction.
533
- * Must be called BEFORE the transaction is staged so the call site can
534
- * `await tx.confirmation` synchronously after the create/update/delete
535
- * call returns. Idempotent: returns early if the tx already has one.
280
+ * Attaches a `confirmation` promise to a newly created transaction. Call this
281
+ * before the transaction is staged so a caller can `await tx.confirmation`
282
+ * immediately after a create, update, or delete returns. It is idempotent and
283
+ * returns early if one is already attached.
536
284
  *
537
- * The unhandled-rejection trap is mandatory most call sites won't
538
- * `await confirmation`, and Node/browser would otherwise crash on the
539
- * rejection. Consumers who *do* want failure visibility just attach a
540
- * `.then`/`.catch` and the trap becomes a no-op.
285
+ * It also attaches a no-op rejection handler. Most callers never await the
286
+ * confirmation, and without this the runtime would report an unhandled
287
+ * rejection when a write fails. Callers that do want to observe failure simply
288
+ * attach their own `.then`/`.catch`.
541
289
  */
542
290
  attachConfirmation(tx) {
543
291
  if (tx.confirmation)
@@ -546,34 +294,30 @@ export class TransactionQueue extends EventEmitter {
546
294
  this.confirmationResolvers.set(tx.id, { resolve, reject });
547
295
  });
548
296
  tx.confirmation.catch(() => {
549
- // Swallow unhandled rejections explicit consumers attach their own
550
- // handler; silent failure is the leak we're already fixing elsewhere.
297
+ // Swallow unhandled rejections; callers that care attach their own handler.
551
298
  });
552
299
  }
553
300
  /**
554
- * Set connection state checker - prevents rollbacks during disconnection.
555
- * When disconnected, timeouts re-schedule instead of rolling back.
301
+ * Registers a predicate the queue uses to check whether it is connected.
302
+ * While disconnected, confirmation timeouts re-schedule themselves instead of
303
+ * escalating, so a transaction is never rolled back merely because the client
304
+ * was briefly offline.
556
305
  */
557
306
  setConnectionChecker(fn) {
558
307
  this.isConnectedFn = fn;
559
308
  }
560
309
  /**
561
- * Drive the offline-grace timer for in-flight commit-lane transactions.
562
- *
563
- * On `'disconnected'`: start a one-shot timer of
564
- * `config.commitOfflineGraceMs`. If the timer fires (disconnect
565
- * persisted past grace), iterate every commit-lane transaction with
566
- * `status ∈ {'pending', 'executing'}` and emit
567
- * `transaction:failed:${id}` with an `AbloConnectionError`. That
568
- * lets `waitForCommitReceipt` reject in seconds instead of hanging
569
- * forever — which is what wedged the 2026-05-15 subagent run.
310
+ * Drives the offline-grace timer for in-flight commit-lane transactions.
570
311
  *
571
- * On `'connected'`: clear any pending grace timer. Brief blips are
572
- * absorbed transparently; the existing reconnect-retry path in
573
- * `processCommitLane` / `flushOfflineQueue` handles the resumption.
312
+ * On `'disconnected'` it starts a one-shot timer of
313
+ * `config.commitOfflineGraceMs`. If that timer fires meaning the disconnect
314
+ * outlasted the grace window every commit-lane transaction still `pending`
315
+ * or `executing` is failed with an {@link AbloConnectionError}, so
316
+ * {@link waitForCommitReceipt} rejects within seconds instead of hanging.
574
317
  *
575
- * Called from SyncClient's `setConnectionState` after the
576
- * `'connection:disconnected'` / `'connection:established'` events.
318
+ * On `'connected'` it clears any pending grace timer. Brief disconnects are
319
+ * absorbed transparently; {@link processCommitLane} and
320
+ * {@link flushOfflineQueue} resume the work on reconnect.
577
321
  */
578
322
  setConnectionState(state) {
579
323
  if (state === 'connected') {
@@ -601,8 +345,8 @@ export class TransactionQueue extends EventEmitter {
601
345
  }
602
346
  if (inFlight.length === 0)
603
347
  return;
604
- // Internal mechanic: each failed commit surfaces to the consumer via its own
605
- // rejection path, so this aggregate is forensic debug, not warn.
348
+ // Each failed commit reaches the consumer through its own rejection path,
349
+ // so this aggregate line is forensic and logged at debug rather than warn.
606
350
  getContext().logger.debug(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
607
351
  for (const id of inFlight) {
608
352
  const tx = this.commitStore.get(id);
@@ -615,39 +359,41 @@ export class TransactionQueue extends EventEmitter {
615
359
  }
616
360
  }
617
361
  /**
618
- * Bind the executor for this queue instance. Called by the owning Ablo
619
- * right after `BaseSyncedStore` is constructed so the executor's
620
- * `storeHolder.store` closure resolves to *this* Ablo's WS not whichever
621
- * Ablo most recently called `initSyncEngine()`.
362
+ * Binds the mutation executor for this queue instance. The owning client
363
+ * calls this right after construction, so commits made here always dispatch
364
+ * through this instance's connection even when several client instances exist
365
+ * in the same process.
622
366
  */
623
367
  setMutationExecutor(executor) {
624
368
  this._mutationExecutor = executor;
625
369
  }
626
370
  // ============================================================================
627
- // LINEAR PATTERN: Microtask-based Transaction Staging
371
+ // Microtask-based transaction staging
628
372
  // ============================================================================
629
373
  //
630
- // All transactions first go to `createdTransactions` staging area.
631
- // A microtask commits them all together with the same batchIndex.
632
- // This ensures that bulk operations (like importing 100 layers) are batched efficiently.
374
+ // Every transaction lands in the `createdTransactions` staging area first.
375
+ // A microtask then commits them together under one batch index, so a bulk
376
+ // operation such as importing a hundred rows is sent efficiently.
633
377
  //
634
378
  // Flow:
635
- // 1. create()/update()/delete() calls stageTransaction()
636
- // 2. stageTransaction() adds to createdTransactions and schedules microtask
637
- // 3. Microtask runs commitCreatedTransactions() after current sync code completes
638
- // 4. All staged transactions get same batchIndex and move to executionQueue
379
+ // 1. create()/update()/delete() calls stageTransaction().
380
+ // 2. stageTransaction() adds to createdTransactions and schedules a microtask.
381
+ // 3. The microtask runs commitCreatedTransactions() once the current
382
+ // synchronous code finishes.
383
+ // 4. All staged transactions share one batch index and move to the execution
384
+ // queue.
639
385
  // ============================================================================
640
386
  /**
641
- * Stage a transaction for commit (Linear pattern)
642
- * Transactions staged in the same event loop tick will be committed together
387
+ * Stages a transaction for commit. Transactions staged within the same
388
+ * event-loop tick are committed together.
643
389
  */
644
390
  stageTransaction(transaction) {
645
391
  this.createdTransactions.push(transaction);
646
392
  this.scheduleCommit();
647
393
  }
648
394
  /**
649
- * Schedule commit of staged transactions via microtask
650
- * This ensures all synchronous transaction creates are batched together
395
+ * Schedules the staged transactions to commit on a microtask, so all
396
+ * transactions created synchronously within one tick are batched together.
651
397
  */
652
398
  scheduleCommit() {
653
399
  if (this.commitScheduled)
@@ -663,8 +409,8 @@ export class TransactionQueue extends EventEmitter {
663
409
  });
664
410
  }
665
411
  /**
666
- * Commit all staged transactions to the execution queue (Linear pattern)
667
- * All transactions get the same batchIndex for efficient batching
412
+ * Moves all staged transactions onto the execution queue, assigning them a
413
+ * single shared batch index so they commit together.
668
414
  */
669
415
  commitCreatedTransactions() {
670
416
  this.commitScheduled = false;
@@ -688,11 +434,15 @@ export class TransactionQueue extends EventEmitter {
688
434
  this.enqueue(transaction);
689
435
  }
690
436
  }
691
- // Batch flush all pending transactions via commit (fast path on reconnect)
437
+ /**
438
+ * Flushes every pending transaction in one commit, the fast path taken on
439
+ * reconnect. If the batch fails, each transaction falls back to normal,
440
+ * one-by-one processing.
441
+ */
692
442
  async flushOfflineQueue() {
693
- // Kick the commit lane too — pending atomic envelopes from
694
- // `commits.create()` were left at the head of the lane while the WS
695
- // was down. Fire-and-forget; processCommitLane self-serializes.
443
+ // Kick the commit lane too: atomic envelopes from `commits.create()` may
444
+ // have been left at the head of the lane while the connection was down.
445
+ // Fire-and-forget; processCommitLane serializes itself.
696
446
  void this.processCommitLane();
697
447
  // Collect pending transactions in created order
698
448
  const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
@@ -743,7 +493,9 @@ export class TransactionQueue extends EventEmitter {
743
493
  }
744
494
  }
745
495
  /**
746
- * Create operation with optimistic update
496
+ * Records a create and applies it optimistically, then stages it for the next
497
+ * batched commit. Returns the {@link Transaction}, whose `confirmation`
498
+ * promise settles once the server confirms the write.
747
499
  */
748
500
  async create(model, context, writeOptions) {
749
501
  const actualModelName = model.getModelName();
@@ -755,8 +507,8 @@ export class TransactionQueue extends EventEmitter {
755
507
  modelKey: normalizeModelKey(actualModelName),
756
508
  priorityScore: this.computePriorityScore('create', actualModelName),
757
509
  data: this.extractCreateData(model),
758
- // CREATE rollback removes the row there is no prior state to
759
- // restore, so allocating a `toJSON()` snapshot here was waste.
510
+ // Rolling back a create removes the row, so there is no prior state to
511
+ // restore and no snapshot is captured here.
760
512
  previousData: null,
761
513
  context,
762
514
  status: 'pending',
@@ -770,16 +522,18 @@ export class TransactionQueue extends EventEmitter {
770
522
  if (this.config.enableOptimistic) {
771
523
  this.applyOptimisticCreate(model, transaction);
772
524
  }
773
- // Microtask coalescer (`scheduleCommit`) collapses all creates in
774
- // this tick into one wire commit with one `batchIndex` — see
775
- // `commitCreatedTransactions`. No batch API needed at the call site.
525
+ // The microtask coalescer (`scheduleCommit`) collapses all creates in this
526
+ // tick into one commit under a single `batchIndex` — see
527
+ // `commitCreatedTransactions`. No batch call is needed at the call site.
776
528
  this.stageTransaction(transaction);
777
529
  this.emit('transaction:created', transaction);
778
530
  return transaction;
779
531
  }
780
532
  /**
781
- * Update operation with conflict detection
782
- * @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
533
+ * Records an update and applies it optimistically, then stages it for the next
534
+ * batched commit. Rapid updates to the same entity coalesce into a single wire
535
+ * operation.
536
+ * @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
783
537
  */
784
538
  async update(model, context, precomputedChanges, writeOptions) {
785
539
  const actualModelName = model.getModelName();
@@ -788,12 +542,11 @@ export class TransactionQueue extends EventEmitter {
788
542
  ? this.mapChangesToInput(actualModelName, precomputedChanges)
789
543
  : this.extractUpdateData(model);
790
544
  const previousData = this.extractPreviousData(model, updateInput);
791
- // Advance the per-field baseline for the keys we just froze into this
792
- // transaction. `Model.propertyChanged` is first-old-wins and only cleared on
793
- // sync-ack, so without this a SECOND update to the same field before the
794
- // first acks would re-capture the original `.old` (the pre-session value)
795
- // instead of THIS update's result corrupting the stream-recorded undo
796
- // inverse (the second move's "before" would point all the way back). The
545
+ // Advance the per-field baseline for the keys just frozen into this
546
+ // transaction. The model records the first old value per field and clears it
547
+ // only on sync acknowledgement, so a second update to the same field before
548
+ // the first is acknowledged would otherwise re-capture the original value
549
+ // instead of this update's result, corrupting the recorded undo inverse. The
797
550
  // wire payload is already frozen in `transaction.data`, so dropping the
798
551
  // consumed entries is safe.
799
552
  model.consumeModifiedFields(Object.keys(updateInput));
@@ -821,18 +574,22 @@ export class TransactionQueue extends EventEmitter {
821
574
  if (this.config.enableOptimistic) {
822
575
  this.applyOptimisticUpdate(model, transaction);
823
576
  }
824
- // LINEAR PATTERN: Stage transaction for microtask commit
825
- // Multiple updates in same event loop will be batched together
826
- // enqueue() will still apply its coalescing logic for same-entity updates
577
+ // Stage the transaction for the microtask commit; updates made in the same
578
+ // tick are batched together, and enqueue() still coalesces same-entity
579
+ // updates.
827
580
  this.stageTransaction(transaction);
828
581
  this.emit('transaction:created', transaction);
829
582
  return transaction;
830
583
  }
831
584
  /**
832
- * Delete operation with cascade handling
585
+ * Records a delete and applies it optimistically. If the row's own create has
586
+ * not yet been sent, both are cancelled locally rather than sending a create
587
+ * followed by a delete; if the create is already in flight, the delete waits
588
+ * until it settles so the server never sees a delete before the create.
833
589
  */
834
590
  async delete(model, context, writeOptions) {
835
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
591
+ // Use getModelName() rather than constructor.name, which is unreliable once
592
+ // class names are minified.
836
593
  const actualModelName = model.getModelName();
837
594
  // Skip Activity delete transactions - activities are permanent audit records
838
595
  if (actualModelName === 'Activity') {
@@ -905,30 +662,32 @@ export class TransactionQueue extends EventEmitter {
905
662
  this.deferDeleteUntilCreateSettles(createBarrier, transaction);
906
663
  }
907
664
  else {
908
- // LINEAR PATTERN: Stage transaction for microtask commit
909
- // All deletes in same event loop will be batched together
665
+ // Stage the transaction for the microtask commit; deletes in the same
666
+ // tick are batched together.
910
667
  this.stageTransaction(transaction);
911
668
  }
912
669
  this.emit('transaction:created', transaction);
913
670
  return transaction;
914
671
  }
915
672
  /**
916
- * Upload attachment delegates to attachment-uploader.ts
673
+ * Uploads a single attachment, delegating to the mutation executor.
917
674
  */
918
675
  async uploadAttachment(_file, options, _context) {
919
676
  return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
920
677
  }
921
678
  /**
922
- * Batch upload attachments delegates to MutationExecutor
679
+ * Uploads several attachments in one call, delegating to the mutation executor.
923
680
  */
924
681
  async batchUploadAttachments(_files, items, _context) {
925
682
  return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
926
683
  }
927
684
  /**
928
- * Archive operation
685
+ * Records an archive and applies it optimistically, then stages it for the
686
+ * next batched commit.
929
687
  */
930
688
  async archive(model, context, writeOptions) {
931
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
689
+ // Use getModelName() rather than constructor.name, which is unreliable once
690
+ // class names are minified.
932
691
  const actualModelName = model.getModelName();
933
692
  const modelKey = normalizeModelKey(actualModelName);
934
693
  const priorityScore = this.computePriorityScore('archive', actualModelName);
@@ -949,16 +708,18 @@ export class TransactionQueue extends EventEmitter {
949
708
  };
950
709
  this.attachConfirmation(transaction);
951
710
  this.store.add(transaction);
952
- // LINEAR PATTERN: Stage transaction for microtask commit
711
+ // Stage the transaction for the microtask commit.
953
712
  this.stageTransaction(transaction);
954
713
  this.emit('transaction:created', transaction);
955
714
  return transaction;
956
715
  }
957
716
  /**
958
- * Unarchive operation
717
+ * Records an unarchive and applies it optimistically, then stages it for the
718
+ * next batched commit.
959
719
  */
960
720
  async unarchive(model, context) {
961
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
721
+ // Use getModelName() rather than constructor.name, which is unreliable once
722
+ // class names are minified.
962
723
  const actualModelName = model.getModelName();
963
724
  const modelKey = normalizeModelKey(actualModelName);
964
725
  const priorityScore = this.computePriorityScore('unarchive', actualModelName);
@@ -978,36 +739,35 @@ export class TransactionQueue extends EventEmitter {
978
739
  };
979
740
  this.attachConfirmation(transaction);
980
741
  this.store.add(transaction);
981
- // LINEAR PATTERN: Stage transaction for microtask commit
742
+ // Stage the transaction for the microtask commit.
982
743
  this.stageTransaction(transaction);
983
744
  this.emit('transaction:created', transaction);
984
745
  return transaction;
985
746
  }
986
747
  /**
987
- * Enqueue transaction for execution
748
+ * Places a transaction on the execution queue, coalescing it into an existing
749
+ * same-entity update where possible so redundant writes collapse.
988
750
  */
989
751
  enqueue(transaction) {
990
752
  this.ensureDerivedFields(transaction);
991
753
  const modelKey = `${transaction.modelName}:${transaction.modelId}`;
992
- // LINEAR PATTERN: Simplified coalescing for updates
993
- // Staging already batches all transactions in same event loop tick
994
- // We only need to handle: (1) in-flight merging, (2) same-entity merging
754
+ // Coalescing for updates. Staging already batches everything created in one
755
+ // event-loop tick, so only two cases remain here: merging into an in-flight
756
+ // update, and merging into a pending same-entity update.
995
757
  //
996
- // RETRIES (attempts > 0) must NEVER take either merge path. A retry is
997
- // re-enqueued from `handleFailure` while its own modelKey is still in
998
- // `inFlightByModel` (the post-batch cleanup runs after the catch), so the
999
- // in-flight merge mistook the retry for a concurrent user edit: it moved
1000
- // the data into `pendingMergeByModel`, REMOVED the transaction, and the
1001
- // post-batch block minted a fresh follow-up with `attempts: 0`. The
1002
- // attempt counter never accumulated and `maxRetries` never tripped — an
1003
- // infinite ~`batchDelay` resend storm of self-laundering transaction
1004
- // clones (found by the claims journey, 2026-06-10).
758
+ // Retries (attempts > 0) must not take either merge path. A retry is
759
+ // re-enqueued from `handleFailure` while its own model key is still marked
760
+ // in `inFlightByModel`, so treating it as a concurrent edit would move its
761
+ // data into `pendingMergeByModel`, remove the transaction, and then mint a
762
+ // fresh follow-up with the attempt counter reset to zero. The counter would
763
+ // never accumulate, `maxRetries` would never trip, and the write would
764
+ // resend in an endless loop.
1005
765
  if (transaction.type === 'update' && transaction.attempts === 0) {
1006
766
  const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
1007
767
  // If there is an in-flight update for this model, merge into post-flight buffer
1008
768
  if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
1009
769
  const prev = this.pendingMergeByModel.get(modelKey) || {};
1010
- const merged = this.mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
770
+ const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
1011
771
  this.pendingMergeByModel.set(modelKey, merged);
1012
772
  this.store.remove(transaction.id);
1013
773
  return;
@@ -1019,7 +779,7 @@ export class TransactionQueue extends EventEmitter {
1019
779
  t.modelName === transaction.modelName &&
1020
780
  !hasStaleWriteOptions(t.writeOptions));
1021
781
  if (!preserveWatermark && pendingInQueue) {
1022
- pendingInQueue.data = this.mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
782
+ pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
1023
783
  this.store.remove(transaction.id);
1024
784
  return;
1025
785
  }
@@ -1036,8 +796,9 @@ export class TransactionQueue extends EventEmitter {
1036
796
  scheduleProcessing(immediate = false) {
1037
797
  if (this.processScheduled)
1038
798
  return;
1039
- // BACKPRESSURE: Don't schedule if too many transactions are already executing
1040
- // This prevents overwhelming the server with concurrent requests (Linear pattern)
799
+ // Backpressure: don't schedule another batch while too many transactions
800
+ // are already executing, so the server is not flooded with concurrent
801
+ // requests.
1041
802
  if (this.executingCount >= this.config.maxExecutingTransactions) {
1042
803
  getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
1043
804
  executingCount: this.executingCount,
@@ -1064,13 +825,11 @@ export class TransactionQueue extends EventEmitter {
1064
825
  }, delay);
1065
826
  }
1066
827
  /**
1067
- * Process batch of transactions using LINEAR-style unified batch execution.
1068
- *
1069
- * Key optimization: Instead of making separate calls per operation type/model,
1070
- * we collect ALL batchable operations and send them in a SINGLE commit call.
1071
- * The sync-server handles mixed types atomically inside one transaction.
1072
- *
1073
- * This reduces N round-trips to 1, dramatically improving batch latency.
828
+ * Processes one batch of transactions in a single commit. Rather than calling
829
+ * the server once per operation type or model, it collects every batchable
830
+ * operation and sends them together; the server applies the mixed operations
831
+ * atomically within one transaction. This turns many round trips into one and
832
+ * greatly reduces batch latency.
1074
833
  */
1075
834
  async processBatch() {
1076
835
  const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -1082,9 +841,9 @@ export class TransactionQueue extends EventEmitter {
1082
841
  let batch = [];
1083
842
  await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
1084
843
  try {
1085
- // Sort executionQueue by FK priority before batch selection
1086
- // This ensures parent entities (Layout, SlideLayout) are always processed
1087
- // before their children (SlideLayoutLayer) across batch boundaries
844
+ // Sort the execution queue by foreign-key priority before selecting a
845
+ // batch, so a parent row is always committed before its children, even
846
+ // across batch boundaries.
1088
847
  this.executionQueue.sort((a, b) => {
1089
848
  // Ensure derived fields exist (covers restored/persisted transactions)
1090
849
  this.ensureDerivedFields(a);
@@ -1108,15 +867,15 @@ export class TransactionQueue extends EventEmitter {
1108
867
  this.inFlightByModel.add(key);
1109
868
  this.store.updateStatus(tx.id, 'executing');
1110
869
  }
1111
- // Build ALL operations for unified commit (SINGLE WS round-trip)
870
+ // Build every operation for one unified commit (a single round trip).
1112
871
  const batchOps = [];
1113
872
  for (const tx of batch) {
1114
- // Per-op `transactionId` carries the local tx UUID through
1115
- // the wire so the server can stamp it on the resulting
1116
- // sync delta. The receive path (`SyncClient.applyDeltaBatchToPool`)
1117
- // matches it via `OptimisticEchoTracker.consumeEcho` to suppress
1118
- // double-applying optimistic mutations. Distinct from the
1119
- // batch-level idempotency key in mutation_log.
873
+ // The per-operation `transactionId` carries the local transaction id
874
+ // over the wire so the server can stamp it on the resulting sync
875
+ // delta. The receive path matches it through
876
+ // {@link UnconfirmedWrites.consumeEcho} to avoid applying the
877
+ // client's own optimistic change twice. This is separate from the
878
+ // batch-level idempotency key recorded in `mutation_log`.
1120
879
  const op = applyWriteOptions({
1121
880
  type: TX_TYPE_TO_MUTATION_OP[tx.type],
1122
881
  model: tx.modelKey,
@@ -1126,28 +885,27 @@ export class TransactionQueue extends EventEmitter {
1126
885
  }, tx);
1127
886
  batchOps.push({ tx, op });
1128
887
  }
1129
- // Execute unified commit for ALL operations (SINGLE WS round-trip)
888
+ // Execute the unified commit for every operation in one round trip.
1130
889
  if (batchOps.length > 0) {
1131
890
  const operations = batchOps.map(({ op }) => op);
1132
891
  try {
1133
- // LINEAR PATTERN: Capture lastSyncId from server response for threshold-based confirmation
892
+ // Capture lastSyncId from the server response for threshold-based
893
+ // confirmation.
1134
894
  //
1135
- // Idempotency note: the default HTTP executor derives a
1136
- // stable `Idempotency-Key` from the operations array
1137
- // itself (sorted sha256), so retries of the SAME batch
1138
- // hit the server's `mutation_log` replay path without
1139
- // requiring us to thread a key through the microtask
1140
- // boundary here. Keeping this path await-free preserves
1141
- // the coalescing test's tight bound on batch count.
895
+ // Idempotency: the default HTTP executor derives a stable
896
+ // idempotency key from the operations array itself (a sorted
897
+ // SHA-256), so retrying the same batch reaches the server's
898
+ // `mutation_log` replay path without threading a key through the
899
+ // microtask boundary here.
1142
900
  const result = await this.mutationExecutor.commit(operations);
1143
901
  const lastSyncId = result?.lastSyncId ?? 0;
1144
902
  this.noteAck(lastSyncId);
1145
- // Notify-instead-of-abort: the server returned stale-context
1146
- // notifications for `onStale: 'notify'` ops whose premise moved.
1147
- // Every notified op was HELD (not written) its optimistic state
1148
- // must be rolled back here; no delta will ever confirm it. We also
1149
- // stamp the signal so `waitForCommitReceipt` carries it onto the
1150
- // receipt.
903
+ // The server returned stale-context notifications for operations
904
+ // marked `onStale: 'notify'` whose read premise had moved. Each
905
+ // notified operation was held rather than written, so its
906
+ // optimistic state must be rolled back here no delta will ever
907
+ // confirm it and the signal is stamped so
908
+ // {@link waitForCommitReceipt} can carry it onto the receipt.
1151
909
  const notifications = result?.notifications;
1152
910
  const heldIds = new Set((notifications ?? []).map((n) => n.id));
1153
911
  for (const { tx } of batchOps) {
@@ -1156,15 +914,16 @@ export class TransactionQueue extends EventEmitter {
1156
914
  this.commitNotifications.set(tx.id, txNotifs);
1157
915
  }
1158
916
  }
1159
- // Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
917
+ // A lastSyncId of 0 means the mutation succeeded but the server
918
+ // emitted no sync delta; record that anomaly for observability.
1160
919
  if (lastSyncId === 0) {
1161
920
  getContext().observability.captureCommitZeroSyncId({
1162
921
  operationCount: operations.length,
1163
922
  operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
1164
923
  });
1165
924
  }
1166
- // LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
1167
- // Transactions will be confirmed when any delta with id >= lastSyncId arrives
925
+ // Mark each transaction with the sync-id threshold that will
926
+ // confirm it: any delta whose id is at least lastSyncId.
1168
927
  for (const { tx } of batchOps) {
1169
928
  // Held op ('notify'): the server withheld the write, so no delta
1170
929
  // will confirm it. Roll back the optimistic update (server
@@ -1196,17 +955,17 @@ export class TransactionQueue extends EventEmitter {
1196
955
  });
1197
956
  continue;
1198
957
  }
1199
- // ACK-BASED CONFIRMATION. A successful commit response with a
1200
- // real watermark means the server durably applied the write —
1201
- // that IS the confirmation (the documented `wait: 'confirmed'`
1202
- // contract, and how Replicache/Zero treat the push response's
1203
- // lastMutationID). The delta echo is NOT an acknowledgement
1204
- // channel: it's replication for OTHER clients, and this
1205
- // client's own echo is suppressed by the OptimisticEchoTracker
1206
- // anyway. Gating confirmation on the echo coupled "did my
1207
- // write land" to subscription-stream health a bare-Node
1208
- // client with no live delta stream hung forever in
1209
- // `awaiting_delta` on a write the server had already applied.
958
+ // Acknowledgement-based confirmation. A successful commit
959
+ // response carrying a real watermark means the server durably
960
+ // applied the write, and that is the confirmation the
961
+ // `wait: 'confirmed'` contract promises. The delta echo is not
962
+ // an acknowledgement channel: it exists to replicate the change
963
+ // to other clients, and this client's own echo is suppressed by
964
+ // the {@link UnconfirmedWrites} regardless. Waiting on the
965
+ // echo would tie "did my write land?" to the health of the
966
+ // subscription stream, so a client with no live delta stream
967
+ // would wait forever in `awaiting_delta` for a write the server
968
+ // had already applied.
1210
969
  if (lastSyncId > 0) {
1211
970
  this.store.updateStatus(tx.id, 'completed');
1212
971
  this.emit('transaction:completed', tx);
@@ -1220,12 +979,11 @@ export class TransactionQueue extends EventEmitter {
1220
979
  });
1221
980
  }
1222
981
  else {
1223
- // lastSyncId === 0 on a non-DELETE: the server accepted the
1224
- // commit but emitted no delta a server-side anomaly
1225
- // (already captured via captureCommitZeroSyncId above). Keep
1226
- // the delta-wait + reconciliation timeout for THIS case
1227
- // only, so the anomaly surfaces instead of silently
1228
- // confirming a write with no watermark.
982
+ // A lastSyncId of 0 on a non-delete: the server accepted the
983
+ // commit but emitted no delta, which is an anomaly (already
984
+ // captured above). Keep the delta wait and reconciliation
985
+ // timeout only for this case, so the anomaly surfaces instead
986
+ // of silently confirming a write that carries no watermark.
1229
987
  this.store.updateStatus(tx.id, 'awaiting_delta');
1230
988
  getContext().logger.debug('tx:awaiting_delta', {
1231
989
  txId: tx.id.slice(0, 8),
@@ -1240,11 +998,11 @@ export class TransactionQueue extends EventEmitter {
1240
998
  }
1241
999
  catch (error) {
1242
1000
  const errorMessage = error.message || '';
1243
- // Surface the raw server rejection for the whole batch so
1244
- // cascaded failures (e.g. Layout FK violation that rolls
1245
- // back a 6-op transaction) are attributable to a specific
1246
- // cause instead of each op showing as a generic permanent
1247
- // error downstream.
1001
+ // Surface the raw server rejection for the whole batch so a
1002
+ // cascaded failure for example a foreign-key violation that
1003
+ // rolls back a multi-operation transaction is attributable to a
1004
+ // specific cause rather than showing as a generic permanent error
1005
+ // on each operation.
1248
1006
  const abloErr = error instanceof AbloError ? error : undefined;
1249
1007
  // SyncWebSocket attaches a `diagnostics` snapshot to its
1250
1008
  // "not connected" / "closed while in flight" rejections.
@@ -1258,7 +1016,7 @@ export class TransactionQueue extends EventEmitter {
1258
1016
  // wrapping in Ablo.commit() and any future wrappers.
1259
1017
  for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
1260
1018
  if ('diagnostics' in cur && cur.diagnostics) {
1261
- return cur.diagnostics;
1019
+ return (cur).diagnostics;
1262
1020
  }
1263
1021
  cur = cur.cause;
1264
1022
  }
@@ -1280,16 +1038,17 @@ export class TransactionQueue extends EventEmitter {
1280
1038
  message: errorMessage,
1281
1039
  diagnostics,
1282
1040
  });
1283
- // LINEAR PATTERN: Handle "no rows in result set" gracefully
1284
- // This error means the entity was already deleted - for UPDATE/DELETE ops, this is success
1285
- // The claim was achieved (the data doesn't exist), so treat as completed
1041
+ // Handle "no rows in result set" gracefully: the row was already
1042
+ // deleted, so for update and delete operations this is effectively
1043
+ // success the intended end state already holds and the
1044
+ // transaction is treated as completed.
1286
1045
  if (errorMessage.includes('no rows in result set')) {
1287
1046
  getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
1288
1047
  batchSize: batchOps.length,
1289
1048
  });
1290
1049
  for (const { tx, op } of batchOps) {
1291
1050
  if (op.type === 'UPDATE' || op.type === 'DELETE') {
1292
- // Entity gone = claim achieved, mark as completed
1051
+ // Row already gone: the intended state holds, mark completed.
1293
1052
  this.store.updateStatus(tx.id, 'completed');
1294
1053
  this.emit('transaction:completed', tx);
1295
1054
  getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
@@ -1360,179 +1119,23 @@ export class TransactionQueue extends EventEmitter {
1360
1119
  }, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
1361
1120
  }
1362
1121
  /**
1363
- * LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
1364
- * This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
1365
- * @param syncId - The sync ID of the received delta
1122
+ * Confirms every awaiting transaction whose sync-id threshold this delta meets
1123
+ * or exceeds. The confirmation policy and timeout tracking live in
1124
+ * {@link DeltaConfirmationTracker} (`./deltaConfirmation.js`).
1125
+ * @param syncId - The sync id of the received delta.
1366
1126
  */
1367
1127
  onDeltaReceived(syncId) {
1368
- // Cursor advancing happens where the delta is APPLIED (the store calls
1369
- // position.advanceApplied / advancePersisted); this hook only resolves
1370
- // confirmation thresholds against the incoming id.
1371
- const awaitingTxs = this.store.getByStatus('awaiting_delta');
1372
- const executingTxs = this.store.getByStatus('executing');
1373
- // Debug: Show state when delta arrives
1374
- if (awaitingTxs.length > 0 || executingTxs.length > 0) {
1375
- getContext().logger.debug('tx:delta_received', {
1376
- syncId,
1377
- lastSeenSyncId: this.lastSeenSyncId,
1378
- awaitingCount: awaitingTxs.length,
1379
- executingCount: executingTxs.length,
1380
- awaitingThresholds: awaitingTxs.map((tx) => ({
1381
- txId: tx.id.slice(0, 8),
1382
- model: tx.modelName,
1383
- needed: tx.syncIdNeededForCompletion,
1384
- willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
1385
- })),
1386
- });
1387
- }
1388
- // Fast path: no awaiting transactions
1389
- if (awaitingTxs.length === 0)
1390
- return;
1391
- let confirmedCount = 0;
1392
- for (const tx of awaitingTxs) {
1393
- // Confirm if this delta's ID meets or exceeds the threshold
1394
- if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
1395
- this.cancelDeltaConfirmationTimeout(tx.id);
1396
- this.store.updateStatus(tx.id, 'completed');
1397
- this.emit('transaction:completed', tx);
1398
- this.emit(`transaction:completed:${tx.id}`, tx);
1399
- this.optimisticUpdates.delete(tx.id);
1400
- confirmedCount++;
1401
- getContext().logger.debug('tx:confirm_via_delta', {
1402
- txId: tx.id.slice(0, 8),
1403
- model: tx.modelName,
1404
- neededSyncId: tx.syncIdNeededForCompletion,
1405
- receivedSyncId: syncId,
1406
- });
1407
- }
1408
- }
1409
- // Log batch summary only if we confirmed something
1410
- if (confirmedCount > 0) {
1411
- // Use warn for staging visibility when transactions confirm
1412
- getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
1413
- count: confirmedCount,
1414
- syncId,
1415
- remainingAwaiting: awaitingTxs.length - confirmedCount,
1416
- });
1417
- }
1128
+ this.deltaConfirmation.onDeltaReceived(syncId);
1418
1129
  }
1419
- // REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry + reconciliation
1420
- // Instead of rolling back on timeout (which destroys confirmed server state),
1421
- // retry with exponential backoff and request reconciliation to catch up on missed deltas.
1422
- // Only rollback on explicit server rejection, never on timeout.
1130
+ // Schedule the retry-and-reconciliation wait for a transaction's confirming
1131
+ // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1423
1132
  scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1424
- // Cancel any existing timeout for this transaction
1425
- this.cancelDeltaConfirmationTimeout(tx.id);
1426
- const timeoutHandle = setTimeout(async () => {
1427
- const currentTx = this.store.get(tx.id);
1428
- if (!currentTx || currentTx.status !== 'awaiting_delta') {
1429
- this.deltaConfirmationRetries.delete(tx.id);
1430
- return; // Already confirmed or failed
1431
- }
1432
- // If disconnected, re-schedule with same timeout (no backoff while offline)
1433
- if (!this.isConnectedFn()) {
1434
- // Self-healing: re-schedule the confirmation wait while offline, no
1435
- // consumer action needed → debug.
1436
- getContext().logger.debug('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
1437
- txId: tx.id.slice(0, 8),
1438
- model: tx.modelName,
1439
- });
1440
- this.deltaConfirmationTimeouts.delete(tx.id);
1441
- this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1442
- return;
1443
- }
1444
- const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
1445
- const diagnosis = this.lastSeenSyncId === 0
1446
- ? 'No deltas received - delta pipeline may be broken'
1447
- : currentTx.syncIdNeededForCompletion &&
1448
- this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
1449
- ? 'Delta not yet received - may be lost or delayed'
1450
- : 'Delta should have confirmed - possible race condition';
1451
- getContext().observability.captureReconciliation({
1452
- reason: 'delta_timeout',
1453
- model: tx.modelName,
1454
- modelId: tx.modelId,
1455
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1456
- lastSeenSyncId: this.lastSeenSyncId,
1457
- retryCount,
1458
- connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
1459
- });
1460
- if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
1461
- // RETRY: Request reconciliation and re-schedule with exponential backoff
1462
- // The server already committed this mutation — we just need the delta to arrive
1463
- this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
1464
- this.deltaConfirmationTimeouts.delete(tx.id);
1465
- // Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
1466
- const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
1467
- // Emit reconciliation request so SyncedStore can cycle the WebSocket
1468
- // to trigger delta catch-up from the server
1469
- this.emit('reconciliation:needed', {
1470
- reason: 'delta_confirmation_timeout',
1471
- txId: tx.id,
1472
- model: tx.modelName,
1473
- modelId: tx.modelId,
1474
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1475
- lastSeenSyncId: this.lastSeenSyncId,
1476
- retryCount: retryCount + 1,
1477
- });
1478
- // Self-healing retry with backoff — the server already committed; we're
1479
- // just waiting on the delta. No consumer action → debug.
1480
- getContext().logger.debug('[TransactionQueue] Re-scheduling with backoff', {
1481
- txId: tx.id.slice(0, 8),
1482
- model: tx.modelName,
1483
- nextTimeoutMs: nextTimeout,
1484
- retry: retryCount + 1,
1485
- });
1486
- this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
1487
- }
1488
- else {
1489
- // LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
1490
- // The transaction succeeded on the server (HTTP 200), so the data exists server-side.
1491
- // Persist the awaiting state so it survives tab close. On next session, the WebSocket
1492
- // reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
1493
- this.deltaConfirmationRetries.delete(tx.id);
1494
- this.deltaConfirmationTimeouts.delete(tx.id);
1495
- getContext().observability.captureDeltaRetryExhausted({
1496
- txId: tx.id,
1497
- model: tx.modelName,
1498
- modelId: tx.modelId,
1499
- retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1500
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1501
- });
1502
- // Emit persist event — SyncClient handles the IDB write
1503
- this.emit('transaction:persist_awaiting', {
1504
- txId: tx.id,
1505
- model: tx.modelName,
1506
- modelId: tx.modelId,
1507
- operationType: tx.type,
1508
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1509
- });
1510
- // Also request one final reconciliation cycle
1511
- this.emit('reconciliation:needed', {
1512
- reason: 'delta_retries_exhausted',
1513
- txId: tx.id,
1514
- model: tx.modelName,
1515
- modelId: tx.modelId,
1516
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1517
- lastSeenSyncId: this.lastSeenSyncId,
1518
- retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1519
- });
1520
- }
1521
- }, timeoutMs);
1522
- this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
1523
- }
1524
- // Cancel a pending delta confirmation timeout and clean up retry tracking
1525
- cancelDeltaConfirmationTimeout(id) {
1526
- const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
1527
- if (timeoutHandle) {
1528
- clearTimeout(timeoutHandle);
1529
- this.deltaConfirmationTimeouts.delete(id);
1530
- }
1531
- this.deltaConfirmationRetries.delete(id);
1133
+ this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1532
1134
  }
1533
1135
  /**
1534
- * Wait for a transaction to be confirmed via delta echo (Linear pattern)
1535
- * Reuses existing timeout mechanism from scheduleDeltaConfirmationTimeout
1136
+ * Resolves once the given transaction is confirmed and rejects if it fails.
1137
+ * The confirming delta's timeout is handled by
1138
+ * {@link scheduleDeltaConfirmationTimeout}.
1536
1139
  */
1537
1140
  waitForConfirmation(transactionId) {
1538
1141
  return new Promise((resolve, reject) => {
@@ -1559,17 +1162,18 @@ export class TransactionQueue extends EventEmitter {
1559
1162
  this.on(`transaction:failed:${transactionId}`, onFailed);
1560
1163
  });
1561
1164
  }
1562
- // Public: check if a clientMutationId exists in this queue (helps identify self-echo deltas)
1165
+ // Reports whether a client mutation id is known to this queue, which helps
1166
+ // identify a delta as this client's own echo.
1563
1167
  hasClientMutationId(id) {
1564
1168
  return !!this.store.get(id) || this.commitStore.has(id);
1565
1169
  }
1566
1170
  /**
1567
- * Enqueue a raw multi-op atomic commit envelope (the `ablo.commits.create`
1568
- * path). Operations are pre-built by the caller; the queue's job is
1569
- * retry-on-reconnect + idempotent dedup, NOT optimistic apply or FK
1570
- * ordering. Same idempotency key (clientTxId) is dropped on the floor
1571
- * if already in flight server-side `mutation_log` handles cross-session
1572
- * dedup; this guard handles same-session double-enqueue.
1171
+ * Enqueues a pre-built, multi-operation atomic commit the
1172
+ * `ablo.commits.create()` path. The caller supplies the operations; the queue
1173
+ * only retries on reconnect and de-duplicates, and does not apply the change
1174
+ * optimistically or reorder for foreign keys. A duplicate `clientTxId`
1175
+ * already in flight is ignored: the server's `mutation_log` de-duplicates
1176
+ * across sessions, and this guard covers a double-enqueue within one session.
1573
1177
  */
1574
1178
  enqueueCommit(clientTxId, operations, options = {}) {
1575
1179
  if (this.commitStore.has(clientTxId))
@@ -1586,19 +1190,19 @@ export class TransactionQueue extends EventEmitter {
1586
1190
  };
1587
1191
  this.commitStore.set(clientTxId, tx);
1588
1192
  this.commitLane.push(tx);
1589
- // Surface the envelope on its OWN event so the undo stream can record
1590
- // commit-lane writes too (`SyncClient.onLocalTransaction` enriches each
1591
- // operation with pool-captured previous state). Deliberately NOT
1592
- // `transaction:created` that event also feeds the optimistic-echo
1593
- // tracker, and commit-lane ops have no optimistic pool apply to echo.
1193
+ // Emit the envelope on its own event so the undo stream can record
1194
+ // commit-lane writes as well. This deliberately avoids `transaction:created`,
1195
+ // which also feeds the optimistic-echo tracker: commit-lane operations apply
1196
+ // nothing optimistically and so have no echo to suppress.
1594
1197
  this.emit('commit:created', { clientTxId, operations: tx.operations });
1595
1198
  void this.processCommitLane();
1596
1199
  }
1597
1200
  /**
1598
- * Drain pending commit-lane envelopes serially. Transient failures
1599
- * (network, ws_not_ready) leave the head-of-queue tx in `pending` and
1600
- * break reconnect handler re-kicks via `flushOfflineQueue`.
1601
- * Permanent failures emit `transaction:failed:<id>` and drop the tx.
1201
+ * Drains the pending commit-lane envelopes one at a time. A transient
1202
+ * failure, such as a network error, leaves the envelope at the head of the
1203
+ * lane in `pending` and stops; reconnect re-kicks it through
1204
+ * {@link flushOfflineQueue}. A permanent failure emits
1205
+ * `transaction:failed:<id>` and drops the envelope.
1602
1206
  */
1603
1207
  async processCommitLane() {
1604
1208
  if (this.commitProcessing)
@@ -1607,6 +1211,8 @@ export class TransactionQueue extends EventEmitter {
1607
1211
  try {
1608
1212
  while (this.commitLane.length > 0) {
1609
1213
  const tx = this.commitLane[0];
1214
+ if (!tx)
1215
+ break;
1610
1216
  if (tx.status !== 'pending') {
1611
1217
  this.commitLane.shift();
1612
1218
  continue;
@@ -1629,8 +1235,9 @@ export class TransactionQueue extends EventEmitter {
1629
1235
  catch (err) {
1630
1236
  const error = err instanceof Error ? err : new Error(String(err));
1631
1237
  if (!this.isPermanentError(error)) {
1632
- // Transient leave at head, retry on next kick (reconnect or
1633
- // next enqueueCommit). Don't tight-loop while WS is down.
1238
+ // Transient: leave it at the head and retry on the next kick
1239
+ // (reconnect or the next enqueueCommit) rather than tight-looping
1240
+ // while the connection is down.
1634
1241
  tx.status = 'pending';
1635
1242
  getContext().logger.debug('[TransactionQueue] commit lane transient', {
1636
1243
  txId: tx.id.slice(0, 12),
@@ -1642,8 +1249,9 @@ export class TransactionQueue extends EventEmitter {
1642
1249
  tx.status = 'failed';
1643
1250
  tx.error = error;
1644
1251
  this.commitLane.shift();
1645
- // Internal bookkeeping the consumer-facing rejection is emitted via
1646
- // 'transaction:failed' and surfaced by the permanent-error headline → debug.
1252
+ // Internal bookkeeping; the consumer-facing rejection is emitted on
1253
+ // 'transaction:failed' and surfaced by the permanent-error headline,
1254
+ // so this line stays at debug.
1647
1255
  getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
1648
1256
  txId: tx.id.slice(0, 12),
1649
1257
  attempts: tx.attempts,
@@ -1659,10 +1267,10 @@ export class TransactionQueue extends EventEmitter {
1659
1267
  }
1660
1268
  }
1661
1269
  /**
1662
- * Promise-based confirmation for a commit-lane transaction. Resolves
1663
- * with the server-side `lastSyncId` once `mutation_result` lands;
1664
- * rejects on permanent failure. Backs the `wait: 'confirmed'` semantics
1665
- * of `ablo.commits.create()`.
1270
+ * Resolves once a commit-lane transaction is confirmed, returning the server's
1271
+ * `lastSyncId` and any stale-context notifications; rejects on permanent
1272
+ * failure. This backs the `wait: 'confirmed'` semantics of
1273
+ * `ablo.commits.create()`.
1666
1274
  */
1667
1275
  waitForCommitReceipt(clientTxId) {
1668
1276
  // Drain any stale-context notifications stamped for this tx on the ack.
@@ -1705,20 +1313,20 @@ export class TransactionQueue extends EventEmitter {
1705
1313
  return 'order' in data || 'orderKey' in data || 'position' in data;
1706
1314
  }
1707
1315
  /**
1708
- * Determine if an error is transient (retryable) vs permanent (non-retryable).
1316
+ * Classifies an error as transient (worth retrying) or permanent. The
1317
+ * approach is deliberately conservative: only known-transient errors are
1318
+ * retried, and anything unrecognized is treated as permanent so a failing
1319
+ * write cannot loop forever.
1709
1320
  *
1710
- * IMPORTANT: Uses a BLOCKLIST approach for safety - only retry on known transient errors.
1711
- * Any unknown error type defaults to permanent (don't retry) to prevent infinite loops.
1321
+ * Transient (retried):
1322
+ * - Network failures, connection errors, and timeouts.
1323
+ * - Server errors (HTTP 5xx).
1324
+ * - Rate limiting (HTTP 429).
1712
1325
  *
1713
- * Transient errors (will retry):
1714
- * - Network failures, connection errors, timeouts
1715
- * - Server errors (5xx status codes)
1716
- * - Rate limiting (429)
1717
- *
1718
- * Permanent errors (won't retry - includes but not limited to):
1719
- * - Validation errors, constraint violations
1720
- * - Not found, unauthorized, forbidden
1721
- * - Any other business logic error from the server
1326
+ * Permanent (not retried), among others:
1327
+ * - Validation errors and constraint violations.
1328
+ * - Not found, unauthorized, and forbidden.
1329
+ * - Any other business-logic error from the server.
1722
1330
  */
1723
1331
  isPermanentError(error) {
1724
1332
  // Typed connection error (e.g. ws_not_ready, transport timeout) is
@@ -1776,18 +1384,20 @@ export class TransactionQueue extends EventEmitter {
1776
1384
  return true;
1777
1385
  }
1778
1386
  /**
1779
- * Handle transaction failure
1387
+ * Handles a failed transaction: retries transient failures with backoff and
1388
+ * rolls back permanent ones, settling the transaction's confirmation promise
1389
+ * either way.
1780
1390
  */
1781
1391
  async handleFailure(transaction, error) {
1782
1392
  transaction.attempts++;
1783
- // Check if this is a permanent error that should NOT be retried
1393
+ // Check whether this is a permanent error that should not be retried.
1784
1394
  if (this.isPermanentError(error)) {
1785
- // Elevated to warn permanent errors mean user writes were rejected
1786
- // by the server, so the user should be able to see WHY in the
1787
- // console (not just via Sentry). Include the typed AbloError fields
1788
- // so the cause is visible: `type`/`code`/`httpStatus` are what
1789
- // distinguish e.g. FK-violation (AbloValidationError) from auth
1790
- // expiry (AbloAuthenticationError).
1395
+ // Logged at warn: a permanent error means the server rejected the write,
1396
+ // so the developer should see the reason in the console. The typed
1397
+ // AbloError fields (`type`, `code`, `httpStatus`) are included so the
1398
+ // cause is visible for example a foreign-key violation
1399
+ // (AbloValidationError) versus expired authentication
1400
+ // (AbloAuthenticationError).
1791
1401
  try {
1792
1402
  const abloErr = error instanceof AbloError ? error : undefined;
1793
1403
  const details = {
@@ -1816,17 +1426,16 @@ export class TransactionQueue extends EventEmitter {
1816
1426
  const isRepeat = sig === this.lastPermanentErrorSig;
1817
1427
  this.lastPermanentErrorSig = sig;
1818
1428
  const logger = getContext().logger;
1819
- // Two registers, one call site, split by log level (the default
1820
- // consumer logger is gated at `warn`, so `debug` is invisible unless
1821
- // someone sets ABLO_LOG_LEVEL=debug to debug the engine itself):
1822
- // the default-visible line speaks the APP DEVELOPER's language —
1823
- // their verb (`update`), their model, the typed error's own human
1824
- // message, and the wire `code` for grep the way AI SDK / Next.js
1825
- // surface errors. No engine nouns ("TransactionQueue", "permanent",
1826
- // "rolling back") and no JSON dump on this line: those alarm and
1827
- // don't help someone who just installed @abloatai/ablo.
1828
- // the forensic `details` ride a companion `debug` line for whoever
1829
- // is debugging the engine internals.
1429
+ // Two registers from one call site, split by log level (the default
1430
+ // logger is gated at `warn`, so `debug` stays hidden unless
1431
+ // ABLO_LOG_LEVEL=debug is set to inspect the engine):
1432
+ // - the default-visible line speaks the application developer's
1433
+ // language: their verb (such as `update`), their model, the typed
1434
+ // error's own message, and the wire `code` for searching. It uses
1435
+ // no engine jargon and prints no JSON dump, which would alarm
1436
+ // without helping.
1437
+ // - the forensic `details` ride a companion `debug` line for anyone
1438
+ // debugging the engine internals.
1830
1439
  const revertNote = this.config.enableOptimistic
1831
1440
  ? ' The local change was reverted.'
1832
1441
  : '';
@@ -1862,15 +1471,11 @@ export class TransactionQueue extends EventEmitter {
1862
1471
  return;
1863
1472
  }
1864
1473
  if (transaction.attempts < this.config.maxRetries) {
1865
- // Exponential backoff with FULL jitter for EVERY transient retry
1866
- // (AWS-standard shape: `sleep = random(0, min(cap, base × 2^attempt))`
1867
- // see the Exponential Backoff And Jitter analysis). Throttling
1868
- // signals (429/503) get a longer base, mirroring the AWS SDK's
1869
- // transient-vs-throttle split. Previously only 429/503 backed off
1870
- // AND the sleep blocked the whole batch loop — every other failure
1871
- // retried at raw `batchDelay` cadence. The re-enqueue is scheduled
1872
- // (never awaited) so one backing-off transaction can't stall
1873
- // unrelated commits.
1474
+ // Exponential backoff with full jitter on every transient retry:
1475
+ // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
1476
+ // (429/503) use a longer base than other transient errors. The re-enqueue
1477
+ // is scheduled rather than awaited, so one backing-off transaction cannot
1478
+ // stall unrelated commits.
1874
1479
  const { baseMs, capMs } = this.config.retryBackoff;
1875
1480
  let base = baseMs;
1876
1481
  try {
@@ -1902,7 +1507,9 @@ export class TransactionQueue extends EventEmitter {
1902
1507
  }
1903
1508
  }
1904
1509
  /**
1905
- * Conflict resolution
1510
+ * Resolves a conflict against server data using the configured strategy:
1511
+ * last-write-wins rolls the local change back, merge and reject re-enqueue it,
1512
+ * and custom applies the caller's resolver.
1906
1513
  */
1907
1514
  async handleConflict(transaction, serverData) {
1908
1515
  const { strategy, resolver } = this.config.conflictResolution;
@@ -1932,65 +1539,25 @@ export class TransactionQueue extends EventEmitter {
1932
1539
  }
1933
1540
  }
1934
1541
  /**
1935
- * Optimistic updates
1542
+ * Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
1543
+ * these methods bind them to the queue's own tracking map and event emitter.
1936
1544
  */
1937
1545
  applyOptimisticCreate(model, transaction) {
1938
- this.optimisticUpdates.set(transaction.id, {
1939
- model,
1940
- previousState: null,
1941
- transaction,
1942
- });
1943
- this.emit('optimistic:create', { model, transaction });
1546
+ applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
1944
1547
  }
1945
1548
  applyOptimisticUpdate(model, transaction) {
1946
- this.optimisticUpdates.set(transaction.id, {
1947
- model,
1948
- previousState: transaction.previousData,
1949
- transaction,
1950
- });
1951
- this.emit('optimistic:update', { model, transaction });
1549
+ applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
1952
1550
  }
1953
1551
  applyOptimisticDelete(model, transaction) {
1954
- this.optimisticUpdates.set(transaction.id, {
1955
- model,
1956
- previousState: transaction.previousData,
1957
- transaction,
1958
- });
1959
- this.emit('optimistic:delete', { model, transaction });
1552
+ applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
1960
1553
  }
1961
1554
  async rollbackOptimistic(transaction, reason, error) {
1962
- const optimistic = this.optimisticUpdates.get(transaction.id);
1963
- if (!optimistic)
1964
- return;
1965
- this.emit('optimistic:rollback', {
1966
- model: optimistic.model,
1967
- previousState: optimistic.previousState,
1968
- transaction,
1969
- reason: reason ?? 'unknown',
1970
- error,
1971
- });
1972
- this.optimisticUpdates.delete(transaction.id);
1973
- }
1974
- /**
1975
- * Execute individual transaction via the unified commit path
1976
- */
1977
- async executeTransaction(transaction) {
1978
- const { type, modelName, modelId, data } = transaction;
1979
- const schemaName = stripModelSuffix(modelName);
1980
- const mutationType = TX_TYPE_TO_MUTATION_OP[type];
1981
- const model = normalizeModelKey(modelName);
1982
- const input = (type === 'create' || type === 'update') ? data : undefined;
1983
- try {
1984
- await this.mutationExecutor.commit([
1985
- applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
1986
- ]);
1987
- }
1988
- catch (error) {
1989
- handleMutationError(error, `${type}-mutation`, schemaName, modelId);
1990
- }
1555
+ await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
1991
1556
  }
1992
1557
  /**
1993
- * Persistence
1558
+ * Loads transactions persisted from a previous session and re-enqueues them,
1559
+ * so writes made while offline survive a restart. Does nothing when
1560
+ * persistence is disabled.
1994
1561
  */
1995
1562
  async loadPersistedTransactions(database) {
1996
1563
  if (!this.config.enablePersistence)
@@ -1999,6 +1566,8 @@ export class TransactionQueue extends EventEmitter {
1999
1566
  const persisted = await database.getPersistedTransactions();
2000
1567
  for (const data of persisted) {
2001
1568
  const transaction = this.deserializeTransaction(data);
1569
+ if (!transaction)
1570
+ continue;
2002
1571
  this.store.add(transaction);
2003
1572
  this.enqueue(transaction);
2004
1573
  }
@@ -2010,11 +1579,35 @@ export class TransactionQueue extends EventEmitter {
2010
1579
  });
2011
1580
  }
2012
1581
  }
1582
+ /**
1583
+ * Validates and rehydrates one persisted row. Rows written to the same store
1584
+ * by other subsystems are skipped, and rows that fail the persisted
1585
+ * transaction schema — from an older version or corruption — are dropped and
1586
+ * reported rather than replayed as commits.
1587
+ */
2013
1588
  deserializeTransaction(data) {
2014
- return { ...data, status: 'pending' };
1589
+ if (isNonReplayablePersistedRow(data))
1590
+ return null;
1591
+ const transaction = deserializePersistedTransaction(data);
1592
+ if (!transaction) {
1593
+ const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
1594
+ ? data.id
1595
+ : undefined;
1596
+ getContext().logger.debug('[TransactionQueue] Dropping malformed persisted transaction', {
1597
+ rowId,
1598
+ });
1599
+ getContext().observability.captureTransactionFailure({
1600
+ context: 'deserialize-persisted-transaction',
1601
+ error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
1602
+ });
1603
+ return null;
1604
+ }
1605
+ return transaction;
2015
1606
  }
2016
1607
  /**
2017
- * Cancel transactions for a specific model
1608
+ * Cancels every pending or executing transaction for a given model id,
1609
+ * optionally limited to one operation type, rolling back their optimistic
1610
+ * state. Returns the cancelled transactions.
2018
1611
  */
2019
1612
  cancelTransactionsForModel(modelId, transactionType) {
2020
1613
  const cancelledTransactions = [];
@@ -2027,23 +1620,29 @@ export class TransactionQueue extends EventEmitter {
2027
1620
  if (!transactionType || transaction.type === transactionType) {
2028
1621
  cancelledTransactions.push(transaction);
2029
1622
  this.store.updateStatus(transaction.id, 'rolled_back');
2030
- this.rollbackOptimistic(transaction, 'model_cancelled');
1623
+ // Sync caller: a rejected rollback (throwing optimistic:rollback
1624
+ // listener) must surface, not vanish — the status flip above is
1625
+ // already committed either way.
1626
+ void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
1627
+ getContext().observability.captureTransactionFailure({
1628
+ context: 'rollback-model-cancelled',
1629
+ error: error instanceof Error ? error : String(error),
1630
+ });
1631
+ });
2031
1632
  }
2032
1633
  }
2033
1634
  }
2034
1635
  return cancelledTransactions;
2035
1636
  }
2036
1637
  /**
2037
- * LINEAR PATTERN: Cancel transactions for child entities by foreign key
1638
+ * Cancels pending transactions for child rows that reference a deleted parent,
1639
+ * used to cascade a parent deletion. The caller supplies the foreign-key
1640
+ * relationship; this method performs the cancellation.
2038
1641
  *
2039
- * Used by SyncedStore for cascade cancellation when a parent is deleted.
2040
- * This keeps FK relationship knowledge in ModelRegistry/SyncedStore,
2041
- * while TransactionQueue just handles the cancellation mechanics.
2042
- *
2043
- * @param childModelName - The child model type (e.g., 'SlideLayer')
2044
- * @param foreignKey - The FK property name (e.g., 'slideId')
2045
- * @param parentId - The deleted parent's ID
2046
- * @returns Number of transactions cancelled
1642
+ * @param childModelName - The child model type (for example 'SlideLayer').
1643
+ * @param foreignKey - The foreign-key property name (for example 'slideId').
1644
+ * @param parentId - The deleted parent's id.
1645
+ * @returns The number of transactions cancelled.
2047
1646
  */
2048
1647
  cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
2049
1648
  let cancelled = 0;
@@ -2058,7 +1657,12 @@ export class TransactionQueue extends EventEmitter {
2058
1657
  const fkValue = transaction.data?.[foreignKey];
2059
1658
  if (fkValue === parentId) {
2060
1659
  this.store.updateStatus(transaction.id, 'rolled_back');
2061
- this.rollbackOptimistic(transaction, 'cascade_parent_deleted');
1660
+ void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
1661
+ getContext().observability.captureTransactionFailure({
1662
+ context: 'rollback-cascade-parent-deleted',
1663
+ error: error instanceof Error ? error : String(error),
1664
+ });
1665
+ });
2062
1666
  cancelled++;
2063
1667
  getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
2064
1668
  txId: transaction.id.slice(0, 12),
@@ -2072,14 +1676,12 @@ export class TransactionQueue extends EventEmitter {
2072
1676
  return cancelled;
2073
1677
  }
2074
1678
  /**
2075
- * Get count of outstanding transactions
1679
+ * Returns the number of transactions still pending or executing.
2076
1680
  */
2077
1681
  getOutstandingTransactionCount() {
2078
1682
  return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
2079
1683
  }
2080
- /**
2081
- * Utilities
2082
- */
1684
+ /** Generates a unique local transaction id. */
2083
1685
  generateId() {
2084
1686
  return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
2085
1687
  }
@@ -2095,35 +1697,26 @@ export class TransactionQueue extends EventEmitter {
2095
1697
  extractUpdateData(model) {
2096
1698
  return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
2097
1699
  }
2098
- buildUpdateInput(modelName, changes) {
2099
- return projectCommitPayload(modelName, changes, { dropUndefined: true });
2100
- }
2101
1700
  // Derive previous values for changed fields to support accurate rollback.
2102
- //
2103
- // The previous Slide-specific branch reaching into `_data` was removed:
2104
- // the field name in the comment (`_localChanges`) didn't match the
2105
- // code (`_data`), no `Slide` class still defines either field, and
2106
- // hardcoded model-name checks don't belong in a generic queue. If a
2107
- // model ever needs to surface previous-state outside `modifiedProperties`,
2108
- // expose a typed `getPreviousData()` accessor on Model and call that.
1701
+ // Model-specific special cases do not belong here; a model that needs to
1702
+ // surface previous state beyond `modifiedProperties` should expose a typed
1703
+ // `getPreviousData()` accessor for this method to call.
2109
1704
  extractPreviousData(model, updateInput) {
2110
1705
  // When the update's written keys are known, capture a before-image for
2111
- // EXACTLY those keys so the recorded undo inverse can revert them and only
2112
- // them (a full-row inverse would clobber concurrent edits to unrelated
2113
- // fields). `fallbackToLive: false` makes `Model.capturePreviousValues` OMIT
2114
- // any key it can't resolve from `modifiedProperties.old` / the original
2115
- // snapshot `buildUndoOps` then drops an un-revertible inverse rather than
2116
- // inventing one. With no `updateInput` (full extract) fall back to every
2117
- // tracked field. `Model.capturePreviousValues` is the single before-image
2118
- // source shared with `RecordingTransaction.snapshotFields`.
1706
+ // exactly those keys, so the recorded undo inverse reverts them and nothing
1707
+ // else a full-row inverse would clobber concurrent edits to unrelated
1708
+ // fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
1709
+ // any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
1710
+ // inverse rather than inventing one. With no `updateInput` (a full extract)
1711
+ // it falls back to every tracked field. `Model.capturePreviousValues` is the
1712
+ // single before-image source, shared with
1713
+ // `RecordingTransaction.snapshotFields`.
2119
1714
  const keys = updateInput
2120
1715
  ? Object.keys(updateInput)
2121
1716
  : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
2122
1717
  return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
2123
1718
  }
2124
- /**
2125
- * Public API
2126
- */
1719
+ /** Returns a snapshot of queue counts and the current configuration. */
2127
1720
  getStats() {
2128
1721
  return {
2129
1722
  pending: this.store.getByStatus('pending').length,
@@ -2137,8 +1730,8 @@ export class TransactionQueue extends EventEmitter {
2137
1730
  };
2138
1731
  }
2139
1732
  /**
2140
- * Get detailed debug info for the sync debug page
2141
- * Exposes internal state that helps diagnose delta confirmation issues
1733
+ * Returns detailed internal state pending, executing, and awaiting-delta
1734
+ * transactions to help diagnose delta-confirmation issues.
2142
1735
  */
2143
1736
  getDebugInfo() {
2144
1737
  const awaitingDelta = this.store.getByStatus('awaiting_delta');
@@ -2168,18 +1761,17 @@ export class TransactionQueue extends EventEmitter {
2168
1761
  })),
2169
1762
  };
2170
1763
  }
2171
- /**
2172
- * Set configuration
2173
- */
1764
+ /** Merges the given options into the queue's configuration. */
2174
1765
  setConfig(config) {
2175
1766
  this.config = { ...this.config, ...config };
2176
1767
  }
2177
1768
  /**
2178
- * Handle incoming sync delta - simplified for permanent IDs
1769
+ * Re-emits an incoming sync delta on the `sync:delta` event for the store to
1770
+ * apply. Because rows use stable ids, no id reconciliation is needed here.
2179
1771
  */
2180
1772
  handleSyncDelta(delta) {
2181
- // With permanent IDs, no reconciliation needed!
2182
- // Just emit the delta for ObjectPool to handle directly
1773
+ // Row ids are stable, so no reconciliation is needed; re-emit the delta for
1774
+ // the store to apply directly.
2183
1775
  this.emit('sync:delta', {
2184
1776
  id: delta.id,
2185
1777
  modelName: delta.modelName,
@@ -2189,7 +1781,8 @@ export class TransactionQueue extends EventEmitter {
2189
1781
  return true;
2190
1782
  }
2191
1783
  /**
2192
- * Cleanup and dispose resources
1784
+ * Releases the queue's resources: rolls back outstanding optimistic updates,
1785
+ * clears all timers and stored transactions, and removes event listeners.
2193
1786
  */
2194
1787
  dispose() {
2195
1788
  // Cancel all active optimistic updates
@@ -2205,6 +1798,15 @@ export class TransactionQueue extends EventEmitter {
2205
1798
  if (this.processTimer) {
2206
1799
  clearTimeout(this.processTimer);
2207
1800
  }
1801
+ // Clear every armed delta-confirmation timer (one per in-flight tx,
1802
+ // 30–120s each) — a disposed queue must not keep the process alive or
1803
+ // fire confirmation callbacks against the cleared store below.
1804
+ this.deltaConfirmation.dispose();
1805
+ // Clear the offline-grace timer armed by setConnectionState('disconnected').
1806
+ if (this.commitOfflineGraceTimer !== null) {
1807
+ clearTimeout(this.commitOfflineGraceTimer);
1808
+ this.commitOfflineGraceTimer = null;
1809
+ }
2208
1810
  // Clear store
2209
1811
  this.store.clear();
2210
1812
  this.optimisticUpdates.clear();