@abloatai/humans 0.37.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 (404) hide show
  1. package/README.md +52 -0
  2. package/dist/Ablo.d.ts +208 -0
  3. package/dist/Ablo.js +120 -0
  4. package/dist/client.d.ts +317 -0
  5. package/dist/client.js +13 -0
  6. package/dist/core.d.ts +35 -0
  7. package/dist/core.js +48 -0
  8. package/dist/humans.d.ts +28 -0
  9. package/dist/humans.js +34 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +6 -0
  12. package/dist/local/BaseSyncedStore.d.ts +807 -0
  13. package/dist/local/BaseSyncedStore.js +1516 -0
  14. package/dist/local/Database.d.ts +322 -0
  15. package/dist/local/Database.js +1589 -0
  16. package/dist/local/InstanceCache.d.ts +255 -0
  17. package/dist/local/InstanceCache.js +1263 -0
  18. package/dist/local/LazyReferenceCollection.d.ts +177 -0
  19. package/dist/local/LazyReferenceCollection.js +461 -0
  20. package/dist/local/Model.d.ts +475 -0
  21. package/dist/local/Model.js +950 -0
  22. package/dist/local/ModelRegistry.d.ts +225 -0
  23. package/dist/local/ModelRegistry.js +539 -0
  24. package/dist/local/NetworkMonitor.d.ts +28 -0
  25. package/dist/local/NetworkMonitor.js +79 -0
  26. package/dist/local/RuntimeContext.d.ts +52 -0
  27. package/dist/local/RuntimeContext.js +80 -0
  28. package/dist/local/SyncClient.d.ts +516 -0
  29. package/dist/local/SyncClient.js +1754 -0
  30. package/dist/local/adapters/alwaysOnline.d.ts +14 -0
  31. package/dist/local/adapters/alwaysOnline.js +17 -0
  32. package/dist/local/adapters/inMemoryStorage.d.ts +37 -0
  33. package/dist/local/adapters/inMemoryStorage.js +122 -0
  34. package/dist/local/client/clientPrelude.d.ts +52 -0
  35. package/dist/local/client/clientPrelude.js +60 -0
  36. package/dist/local/client/consoleLogger.d.ts +35 -0
  37. package/dist/local/client/consoleLogger.js +44 -0
  38. package/dist/local/client/createInternalComponents.d.ts +50 -0
  39. package/dist/local/client/createInternalComponents.js +98 -0
  40. package/dist/local/client/createModelProxy.d.ts +248 -0
  41. package/dist/local/client/createModelProxy.js +880 -0
  42. package/dist/local/client/modelRegistration.d.ts +10 -0
  43. package/dist/local/client/modelRegistration.js +316 -0
  44. package/dist/local/client/options.d.ts +440 -0
  45. package/dist/local/client/options.js +7 -0
  46. package/dist/local/client/reactiveEngine.d.ts +53 -0
  47. package/dist/local/client/reactiveEngine.js +705 -0
  48. package/dist/local/client/resourceTypes.d.ts +12 -0
  49. package/dist/local/client/resourceTypes.js +10 -0
  50. package/dist/local/client/schemaConfig.d.ts +44 -0
  51. package/dist/local/client/schemaConfig.js +185 -0
  52. package/dist/local/client/storeCluster.d.ts +46 -0
  53. package/dist/local/client/storeCluster.js +133 -0
  54. package/dist/local/client/storeLifecycle.d.ts +61 -0
  55. package/dist/local/client/storeLifecycle.js +236 -0
  56. package/dist/local/client/validateAbloOptions.d.ts +42 -0
  57. package/dist/local/client/validateAbloOptions.js +43 -0
  58. package/dist/local/client/wsMutationExecutor.d.ts +27 -0
  59. package/dist/local/client/wsMutationExecutor.js +72 -0
  60. package/dist/local/context.d.ts +42 -0
  61. package/dist/local/context.js +81 -0
  62. package/dist/local/coordination/ClaimLog.d.ts +26 -0
  63. package/dist/local/coordination/ClaimLog.js +32 -0
  64. package/dist/local/interfaces/index.d.ts +311 -0
  65. package/dist/local/interfaces/index.js +9 -0
  66. package/dist/local/localModelContract.d.ts +14 -0
  67. package/dist/local/localModelContract.js +1 -0
  68. package/dist/local/logPosition.d.ts +31 -0
  69. package/dist/local/logPosition.js +53 -0
  70. package/dist/local/mutationPersistence.d.ts +6 -0
  71. package/dist/local/mutationPersistence.js +1 -0
  72. package/dist/local/mutators/RecordingMutation.d.ts +36 -0
  73. package/dist/local/mutators/RecordingMutation.js +182 -0
  74. package/dist/local/mutators/Transaction.d.ts +40 -0
  75. package/dist/local/mutators/Transaction.js +58 -0
  76. package/dist/local/mutators/UndoManager.d.ts +258 -0
  77. package/dist/local/mutators/UndoManager.js +665 -0
  78. package/dist/local/mutators/defineMutators.d.ts +60 -0
  79. package/dist/local/mutators/defineMutators.js +18 -0
  80. package/dist/local/mutators/inverseOp.d.ts +126 -0
  81. package/dist/local/mutators/inverseOp.js +71 -0
  82. package/dist/local/mutators/mutateActions.d.ts +45 -0
  83. package/dist/local/mutators/mutateActions.js +105 -0
  84. package/dist/local/mutators/readerActions.d.ts +33 -0
  85. package/dist/local/mutators/readerActions.js +57 -0
  86. package/dist/local/mutators/undoApply.d.ts +51 -0
  87. package/dist/local/mutators/undoApply.js +117 -0
  88. package/dist/local/persistence.d.ts +7 -0
  89. package/dist/local/persistence.js +9 -0
  90. package/dist/local/query/QueryProcessor.d.ts +75 -0
  91. package/dist/local/query/QueryProcessor.js +255 -0
  92. package/dist/local/query/client.d.ts +64 -0
  93. package/dist/local/query/client.js +138 -0
  94. package/dist/local/query/types.d.ts +85 -0
  95. package/dist/local/query/types.js +16 -0
  96. package/dist/local/schema/serialize.d.ts +1 -0
  97. package/dist/local/schema/serialize.js +1 -0
  98. package/dist/local/store/queryApi.d.ts +13 -0
  99. package/dist/local/store/queryApi.js +35 -0
  100. package/dist/local/storeContract.d.ts +145 -0
  101. package/dist/local/storeContract.js +12 -0
  102. package/dist/local/stores/DatabaseManager.d.ts +112 -0
  103. package/dist/local/stores/DatabaseManager.js +400 -0
  104. package/dist/local/stores/ObjectStore.d.ts +115 -0
  105. package/dist/local/stores/ObjectStore.js +393 -0
  106. package/dist/local/stores/ObjectStoreContract.d.ts +38 -0
  107. package/dist/local/stores/ObjectStoreContract.js +1 -0
  108. package/dist/local/stores/StoreManager.d.ts +114 -0
  109. package/dist/local/stores/StoreManager.js +304 -0
  110. package/dist/local/stores/SyncActionStore.d.ts +99 -0
  111. package/dist/local/stores/SyncActionStore.js +506 -0
  112. package/dist/local/stores/openIDBWithTimeout.d.ts +65 -0
  113. package/dist/local/stores/openIDBWithTimeout.js +153 -0
  114. package/dist/local/stores/persistenceCleanup.d.ts +7 -0
  115. package/dist/local/stores/persistenceCleanup.js +26 -0
  116. package/dist/local/stores/persistenceIdentity.d.ts +27 -0
  117. package/dist/local/stores/persistenceIdentity.js +38 -0
  118. package/dist/local/stores/syncAction.d.ts +26 -0
  119. package/dist/local/stores/syncAction.js +16 -0
  120. package/dist/local/stores/v1PersistenceDeletion.d.ts +8 -0
  121. package/dist/local/stores/v1PersistenceDeletion.js +16 -0
  122. package/dist/local/sync/BootstrapFetcher.d.ts +284 -0
  123. package/dist/local/sync/BootstrapFetcher.js +964 -0
  124. package/dist/local/sync/ConnectionManager.d.ts +8 -0
  125. package/dist/local/sync/ConnectionManager.js +8 -0
  126. package/dist/local/sync/OnDemandLoader.d.ts +231 -0
  127. package/dist/local/sync/OnDemandLoader.js +743 -0
  128. package/dist/local/sync/SubscriptionManager.d.ts +159 -0
  129. package/dist/local/sync/SubscriptionManager.js +243 -0
  130. package/dist/local/sync/SyncWebSocket.d.ts +173 -0
  131. package/dist/local/sync/SyncWebSocket.js +438 -0
  132. package/dist/local/sync/bootstrapApply.d.ts +73 -0
  133. package/dist/local/sync/bootstrapApply.js +73 -0
  134. package/dist/local/sync/commitFrames.d.ts +8 -0
  135. package/dist/local/sync/commitFrames.js +8 -0
  136. package/dist/local/sync/connectionManagerLifecycle.d.ts +23 -0
  137. package/dist/local/sync/connectionManagerLifecycle.js +126 -0
  138. package/dist/local/sync/contextPorts.d.ts +18 -0
  139. package/dist/local/sync/contextPorts.js +31 -0
  140. package/dist/local/sync/createClaimStream.d.ts +64 -0
  141. package/dist/local/sync/createClaimStream.js +475 -0
  142. package/dist/local/sync/createSnapshot.d.ts +29 -0
  143. package/dist/local/sync/createSnapshot.js +116 -0
  144. package/dist/local/sync/credentialLifecycle.d.ts +7 -0
  145. package/dist/local/sync/credentialLifecycle.js +7 -0
  146. package/dist/local/sync/deltaPipeline.d.ts +116 -0
  147. package/dist/local/sync/deltaPipeline.js +357 -0
  148. package/dist/local/sync/groupChange.d.ts +116 -0
  149. package/dist/local/sync/groupChange.js +244 -0
  150. package/dist/local/sync/initialize.d.ts +27 -0
  151. package/dist/local/sync/initialize.js +137 -0
  152. package/dist/local/sync/participants.d.ts +132 -0
  153. package/dist/local/sync/participants.js +342 -0
  154. package/dist/local/sync/persistedPrefix.d.ts +12 -0
  155. package/dist/local/sync/persistedPrefix.js +22 -0
  156. package/dist/local/sync/reconnect.d.ts +23 -0
  157. package/dist/local/sync/reconnect.js +55 -0
  158. package/dist/local/sync/schemaDrift.d.ts +55 -0
  159. package/dist/local/sync/schemaDrift.js +53 -0
  160. package/dist/local/sync/schemas.d.ts +71 -0
  161. package/dist/local/sync/schemas.js +94 -0
  162. package/dist/local/sync/socketEventWiring.d.ts +31 -0
  163. package/dist/local/sync/socketEventWiring.js +130 -0
  164. package/dist/local/sync/syncCursor.d.ts +40 -0
  165. package/dist/local/sync/syncCursor.js +55 -0
  166. package/dist/local/sync/syncPlan.d.ts +54 -0
  167. package/dist/local/sync/syncPlan.js +50 -0
  168. package/dist/local/sync/terminalSessionLifecycle.d.ts +20 -0
  169. package/dist/local/sync/terminalSessionLifecycle.js +50 -0
  170. package/dist/local/sync/wsFrameHandlers.d.ts +8 -0
  171. package/dist/local/sync/wsFrameHandlers.js +8 -0
  172. package/dist/local/transactions/databaseCommitOutbox.d.ts +15 -0
  173. package/dist/local/transactions/databaseCommitOutbox.js +16 -0
  174. package/dist/local/transactions/localMutation.d.ts +10 -0
  175. package/dist/local/transactions/localMutation.js +37 -0
  176. package/dist/local/transactions/mutations/MutationQueue.d.ts +511 -0
  177. package/dist/local/transactions/mutations/MutationQueue.js +1498 -0
  178. package/dist/local/transactions/mutations/MutationStore.d.ts +20 -0
  179. package/dist/local/transactions/mutations/MutationStore.js +53 -0
  180. package/dist/local/transactions/mutations/UnconfirmedWrites.d.ts +82 -0
  181. package/dist/local/transactions/mutations/UnconfirmedWrites.js +104 -0
  182. package/dist/local/transactions/mutations/batchProcessing.d.ts +64 -0
  183. package/dist/local/transactions/mutations/batchProcessing.js +349 -0
  184. package/dist/local/transactions/mutations/coalesceRules.d.ts +58 -0
  185. package/dist/local/transactions/mutations/coalesceRules.js +140 -0
  186. package/dist/local/transactions/mutations/commitApi.d.ts +19 -0
  187. package/dist/local/transactions/mutations/commitApi.js +74 -0
  188. package/dist/local/transactions/mutations/commitLane.d.ts +70 -0
  189. package/dist/local/transactions/mutations/commitLane.js +140 -0
  190. package/dist/local/transactions/mutations/commitLatency.d.ts +52 -0
  191. package/dist/local/transactions/mutations/commitLatency.js +130 -0
  192. package/dist/local/transactions/mutations/commitPayload.d.ts +165 -0
  193. package/dist/local/transactions/mutations/commitPayload.js +152 -0
  194. package/dist/local/transactions/mutations/commitTransport.d.ts +36 -0
  195. package/dist/local/transactions/mutations/commitTransport.js +104 -0
  196. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +63 -0
  197. package/dist/local/transactions/mutations/deltaConfirmation.js +235 -0
  198. package/dist/local/transactions/mutations/durableCommitRestore.d.ts +17 -0
  199. package/dist/local/transactions/mutations/durableCommitRestore.js +95 -0
  200. package/dist/local/transactions/mutations/durableWriteStore.d.ts +14 -0
  201. package/dist/local/transactions/mutations/durableWriteStore.js +12 -0
  202. package/dist/local/transactions/mutations/executionSelection.d.ts +6 -0
  203. package/dist/local/transactions/mutations/executionSelection.js +42 -0
  204. package/dist/local/transactions/mutations/failureHandling.d.ts +16 -0
  205. package/dist/local/transactions/mutations/failureHandling.js +130 -0
  206. package/dist/local/transactions/mutations/failurePolicy.d.ts +13 -0
  207. package/dist/local/transactions/mutations/failurePolicy.js +48 -0
  208. package/dist/local/transactions/mutations/localMutation.d.ts +58 -0
  209. package/dist/local/transactions/mutations/localMutation.js +75 -0
  210. package/dist/local/transactions/mutations/modelOperations.d.ts +44 -0
  211. package/dist/local/transactions/mutations/modelOperations.js +144 -0
  212. package/dist/local/transactions/mutations/mutationPersistence.d.ts +25 -0
  213. package/dist/local/transactions/mutations/mutationPersistence.js +188 -0
  214. package/dist/local/transactions/mutations/pendingDrain.d.ts +33 -0
  215. package/dist/local/transactions/mutations/pendingDrain.js +112 -0
  216. package/dist/local/transactions/mutations/processingScheduler.d.ts +14 -0
  217. package/dist/local/transactions/mutations/processingScheduler.js +24 -0
  218. package/dist/local/transactions/mutations/queueCoalescing.d.ts +13 -0
  219. package/dist/local/transactions/mutations/queueCoalescing.js +35 -0
  220. package/dist/local/transactions/mutations/replayValidation.d.ts +187 -0
  221. package/dist/local/transactions/mutations/replayValidation.js +164 -0
  222. package/dist/local/transactions/reconnectDrain.d.ts +11 -0
  223. package/dist/local/transactions/reconnectDrain.js +13 -0
  224. package/dist/local/utils/mobxSetup.d.ts +53 -0
  225. package/dist/local/utils/mobxSetup.js +330 -0
  226. package/dist/local/views/QueryView.d.ts +79 -0
  227. package/dist/local/views/QueryView.js +218 -0
  228. package/dist/local/views/ViewRegistry.d.ts +20 -0
  229. package/dist/local/views/ViewRegistry.js +57 -0
  230. package/dist/local/views/incrementalView.d.ts +45 -0
  231. package/dist/local/views/incrementalView.js +69 -0
  232. package/dist/plugin.d.ts +285 -0
  233. package/dist/plugin.js +106 -0
  234. package/dist/presenceStream.d.ts +69 -0
  235. package/dist/presenceStream.js +200 -0
  236. package/dist/react/AbloProvider.d.ts +242 -0
  237. package/dist/react/AbloProvider.js +456 -0
  238. package/dist/react/ClientSideSuspense.d.ts +36 -0
  239. package/dist/react/ClientSideSuspense.js +17 -0
  240. package/dist/react/DefaultFallback.d.ts +24 -0
  241. package/dist/react/DefaultFallback.js +43 -0
  242. package/dist/react/context.d.ts +55 -0
  243. package/dist/react/context.js +29 -0
  244. package/dist/react/createAbloReact.d.ts +50 -0
  245. package/dist/react/createAbloReact.js +48 -0
  246. package/dist/react/internalContext.d.ts +33 -0
  247. package/dist/react/internalContext.js +3 -0
  248. package/dist/react/useAblo.d.ts +96 -0
  249. package/dist/react/useAblo.js +120 -0
  250. package/dist/react/useCurrentUserId.d.ts +2 -0
  251. package/dist/react/useCurrentUserId.js +12 -0
  252. package/dist/react/useErrorListener.d.ts +2 -0
  253. package/dist/react/useErrorListener.js +14 -0
  254. package/dist/react/useMutationFailureListener.d.ts +8 -0
  255. package/dist/react/useMutationFailureListener.js +19 -0
  256. package/dist/react/useMutators.d.ts +56 -0
  257. package/dist/react/useMutators.js +84 -0
  258. package/dist/react/useSyncStatus.d.ts +19 -0
  259. package/dist/react/useSyncStatus.js +37 -0
  260. package/dist/react/useUndoScope.d.ts +34 -0
  261. package/dist/react/useUndoScope.js +73 -0
  262. package/dist/react.d.ts +18 -0
  263. package/dist/react.js +14 -0
  264. package/dist/reactRuntime.d.ts +4 -0
  265. package/dist/reactRuntime.js +2 -0
  266. package/dist/surface.d.ts +36 -0
  267. package/dist/surface.js +77 -0
  268. package/dist/useReactive.d.ts +6 -0
  269. package/dist/useReactive.js +43 -0
  270. package/package.json +119 -0
  271. package/src/Ablo.ts +456 -0
  272. package/src/client.ts +374 -0
  273. package/src/core.ts +104 -0
  274. package/src/humans.ts +61 -0
  275. package/src/index.ts +40 -0
  276. package/src/local/BaseSyncedStore.ts +1991 -0
  277. package/src/local/Database.ts +2052 -0
  278. package/src/local/InstanceCache.ts +1503 -0
  279. package/src/local/LazyReferenceCollection.ts +563 -0
  280. package/src/local/Model.ts +1124 -0
  281. package/src/local/ModelRegistry.ts +762 -0
  282. package/src/local/NetworkMonitor.ts +88 -0
  283. package/src/local/RuntimeContext.ts +141 -0
  284. package/src/local/SyncClient.ts +2131 -0
  285. package/src/local/adapters/alwaysOnline.ts +20 -0
  286. package/src/local/adapters/inMemoryStorage.ts +141 -0
  287. package/src/local/client/clientPrelude.ts +112 -0
  288. package/src/local/client/consoleLogger.ts +60 -0
  289. package/src/local/client/createInternalComponents.ts +163 -0
  290. package/src/local/client/createModelProxy.ts +1390 -0
  291. package/src/local/client/modelRegistration.ts +350 -0
  292. package/src/local/client/options.ts +526 -0
  293. package/src/local/client/reactiveEngine.ts +935 -0
  294. package/src/local/client/resourceTypes.ts +37 -0
  295. package/src/local/client/schemaConfig.ts +194 -0
  296. package/src/local/client/storeCluster.ts +172 -0
  297. package/src/local/client/storeLifecycle.ts +343 -0
  298. package/src/local/client/validateAbloOptions.ts +95 -0
  299. package/src/local/client/wsMutationExecutor.ts +110 -0
  300. package/src/local/context.ts +96 -0
  301. package/src/local/coordination/ClaimLog.ts +39 -0
  302. package/src/local/interfaces/index.ts +468 -0
  303. package/src/local/localModelContract.ts +15 -0
  304. package/src/local/logPosition.ts +84 -0
  305. package/src/local/mutationPersistence.ts +7 -0
  306. package/src/local/mutators/RecordingMutation.ts +222 -0
  307. package/src/local/mutators/Transaction.ts +97 -0
  308. package/src/local/mutators/UndoManager.ts +741 -0
  309. package/src/local/mutators/defineMutators.ts +76 -0
  310. package/src/local/mutators/inverseOp.ts +83 -0
  311. package/src/local/mutators/mutateActions.ts +167 -0
  312. package/src/local/mutators/readerActions.ts +99 -0
  313. package/src/local/mutators/undoApply.ts +141 -0
  314. package/src/local/persistence.ts +16 -0
  315. package/src/local/query/QueryProcessor.ts +347 -0
  316. package/src/local/query/client.ts +197 -0
  317. package/src/local/query/types.ts +102 -0
  318. package/src/local/schema/serialize.ts +1 -0
  319. package/src/local/store/queryApi.ts +56 -0
  320. package/src/local/storeContract.ts +146 -0
  321. package/src/local/stores/DatabaseManager.ts +507 -0
  322. package/src/local/stores/ObjectStore.ts +449 -0
  323. package/src/local/stores/ObjectStoreContract.ts +48 -0
  324. package/src/local/stores/StoreManager.ts +388 -0
  325. package/src/local/stores/SyncActionStore.ts +579 -0
  326. package/src/local/stores/openIDBWithTimeout.ts +195 -0
  327. package/src/local/stores/persistenceCleanup.ts +43 -0
  328. package/src/local/stores/persistenceIdentity.ts +83 -0
  329. package/src/local/stores/syncAction.ts +21 -0
  330. package/src/local/stores/v1PersistenceDeletion.ts +21 -0
  331. package/src/local/sync/BootstrapFetcher.ts +1224 -0
  332. package/src/local/sync/ConnectionManager.ts +15 -0
  333. package/src/local/sync/OnDemandLoader.ts +927 -0
  334. package/src/local/sync/SubscriptionManager.ts +300 -0
  335. package/src/local/sync/SyncWebSocket.ts +584 -0
  336. package/src/local/sync/bootstrapApply.ts +130 -0
  337. package/src/local/sync/commitFrames.ts +16 -0
  338. package/src/local/sync/connectionManagerLifecycle.ts +158 -0
  339. package/src/local/sync/contextPorts.ts +37 -0
  340. package/src/local/sync/createClaimStream.ts +668 -0
  341. package/src/local/sync/createSnapshot.ts +160 -0
  342. package/src/local/sync/credentialLifecycle.ts +18 -0
  343. package/src/local/sync/deltaPipeline.ts +473 -0
  344. package/src/local/sync/groupChange.ts +343 -0
  345. package/src/local/sync/initialize.ts +205 -0
  346. package/src/local/sync/participants.ts +564 -0
  347. package/src/local/sync/persistedPrefix.ts +27 -0
  348. package/src/local/sync/reconnect.ts +88 -0
  349. package/src/local/sync/schemaDrift.ts +86 -0
  350. package/src/local/sync/schemas.ts +118 -0
  351. package/src/local/sync/socketEventWiring.ts +196 -0
  352. package/src/local/sync/syncCursor.ts +62 -0
  353. package/src/local/sync/syncPlan.ts +89 -0
  354. package/src/local/sync/terminalSessionLifecycle.ts +66 -0
  355. package/src/local/sync/wsFrameHandlers.ts +20 -0
  356. package/src/local/transactions/databaseCommitOutbox.ts +33 -0
  357. package/src/local/transactions/localMutation.ts +58 -0
  358. package/src/local/transactions/mutations/MutationQueue.ts +1998 -0
  359. package/src/local/transactions/mutations/MutationStore.ts +65 -0
  360. package/src/local/transactions/mutations/UnconfirmedWrites.ts +133 -0
  361. package/src/local/transactions/mutations/batchProcessing.ts +469 -0
  362. package/src/local/transactions/mutations/coalesceRules.ts +192 -0
  363. package/src/local/transactions/mutations/commitApi.ts +97 -0
  364. package/src/local/transactions/mutations/commitLane.ts +191 -0
  365. package/src/local/transactions/mutations/commitLatency.ts +164 -0
  366. package/src/local/transactions/mutations/commitPayload.ts +281 -0
  367. package/src/local/transactions/mutations/commitTransport.ts +174 -0
  368. package/src/local/transactions/mutations/deltaConfirmation.ts +298 -0
  369. package/src/local/transactions/mutations/durableCommitRestore.ts +128 -0
  370. package/src/local/transactions/mutations/durableWriteStore.ts +21 -0
  371. package/src/local/transactions/mutations/executionSelection.ts +42 -0
  372. package/src/local/transactions/mutations/failureHandling.ts +154 -0
  373. package/src/local/transactions/mutations/failurePolicy.ts +61 -0
  374. package/src/local/transactions/mutations/localMutation.ts +135 -0
  375. package/src/local/transactions/mutations/modelOperations.ts +210 -0
  376. package/src/local/transactions/mutations/mutationPersistence.ts +231 -0
  377. package/src/local/transactions/mutations/pendingDrain.ts +160 -0
  378. package/src/local/transactions/mutations/processingScheduler.ts +35 -0
  379. package/src/local/transactions/mutations/queueCoalescing.ts +45 -0
  380. package/src/local/transactions/mutations/replayValidation.ts +192 -0
  381. package/src/local/transactions/reconnectDrain.ts +24 -0
  382. package/src/local/utils/mobxSetup.ts +388 -0
  383. package/src/local/views/QueryView.ts +311 -0
  384. package/src/local/views/ViewRegistry.ts +61 -0
  385. package/src/local/views/incrementalView.ts +92 -0
  386. package/src/plugin.ts +396 -0
  387. package/src/presenceStream.ts +279 -0
  388. package/src/react/AbloProvider.tsx +744 -0
  389. package/src/react/ClientSideSuspense.tsx +57 -0
  390. package/src/react/DefaultFallback.tsx +60 -0
  391. package/src/react/context.ts +89 -0
  392. package/src/react/createAbloReact.ts +116 -0
  393. package/src/react/internalContext.ts +38 -0
  394. package/src/react/useAblo.ts +280 -0
  395. package/src/react/useCurrentUserId.ts +17 -0
  396. package/src/react/useErrorListener.ts +22 -0
  397. package/src/react/useMutationFailureListener.ts +34 -0
  398. package/src/react/useMutators.ts +184 -0
  399. package/src/react/useSyncStatus.ts +42 -0
  400. package/src/react/useUndoScope.ts +143 -0
  401. package/src/react.ts +69 -0
  402. package/src/reactRuntime.ts +10 -0
  403. package/src/surface.ts +106 -0
  404. package/src/useReactive.ts +51 -0
@@ -0,0 +1,1998 @@
1
+ /**
2
+ * MutationQueue 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.
6
+ *
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.
13
+ */
14
+
15
+ import { EventEmitter } from 'events';
16
+ import { v4 as uuid } from 'uuid';
17
+ import type { LocalModel } from '../../localModelContract.js';
18
+ import type { MutationPersistencePort } from '../../mutationPersistence.js';
19
+ import { globalRuntime } from '../../context.js';
20
+ import type { RuntimeContext } from '../../RuntimeContext.js';
21
+ import type { MutationOperationType } from '@abloatai/transaction/types';
22
+ import {
23
+ AbloError,
24
+ AbloConnectionError,
25
+ AbloIdempotencyError,
26
+ AbloNotFoundError,
27
+ AbloValidationError,
28
+ errorCodeSpec,
29
+ } from '@abloatai/transaction/errors';
30
+ import {
31
+ LogPosition,
32
+ type LogPositionPort,
33
+ } from '../../logPosition.js';
34
+ import type { WriteOptions } from '../../interfaces/index.js';
35
+ import type { OnStaleMode, StaleNotification, ReadDependency, TrackDependency } from '@abloatai/transaction/coordination/schema';
36
+ import {
37
+ mutationCommitResultSchema,
38
+ type MutationCommitResult,
39
+ } from '@abloatai/transaction/wire/commit';
40
+ import {
41
+ projectCommitPayload,
42
+ computePriorityScore,
43
+ normalizeModelKey,
44
+ // Includes stale guards as well as request identity/audit barriers.
45
+ hasCommitCoalescingBarrier,
46
+ applyWriteOptions,
47
+ asTransportError,
48
+ extractStatusCode,
49
+ TX_TYPE_TO_MUTATION_OP,
50
+ type MutationInput,
51
+ type QueuedMutation,
52
+ type UserContext,
53
+ type WriteOperationFields,
54
+ } from './commitPayload.js';
55
+ import { MutationStore } from './MutationStore.js';
56
+ import {
57
+ entityKey,
58
+ mergeUpdateData,
59
+ takeUnsentCreateForModel,
60
+ findCreateBarrierForDelete,
61
+ deferDeleteUntilCreateSettles,
62
+ releaseDeferredDeletesForCreate,
63
+ } from './coalesceRules.js';
64
+ import { DeltaConfirmationTracker } from './deltaConfirmation.js';
65
+ import {
66
+ deserializePersistedTransaction,
67
+ isNonReplayablePersistedRow,
68
+ pendingMutationRecordId,
69
+ legacyPendingMutationRecordSchema,
70
+ pendingMutationRecordSchema,
71
+ persistedMutationSchema,
72
+ } from './replayValidation.js';
73
+ import {
74
+ deserializeLegacyPendingMutation,
75
+ loadPersistedTransactions,
76
+ persistQueuedTransaction,
77
+ removePersistedTransaction,
78
+ settlePersistedFailure,
79
+ type MutationPersistenceContext,
80
+ } from './mutationPersistence.js';
81
+ import {
82
+ createCommitEnvelopeMember,
83
+ createDurableCommitEnvelope,
84
+ commitEnvelopeRecordId,
85
+ durableCommitEnvelopeSchema,
86
+ type DurableCommitEnvelope,
87
+ type DurableCommitOperation,
88
+ type DurableCommitOperationInput,
89
+ type CommitOutboxScope,
90
+ } from '@abloatai/transaction/transactions/settlement/commitEnvelope';
91
+ import type { DurableWriteStore } from './durableWriteStore.js';
92
+ import { stableStringify } from '@abloatai/transaction/utils/json';
93
+ import {
94
+ createLocalMutationPort,
95
+ type LocalMutationPort,
96
+ } from './localMutation.js';
97
+ import {
98
+ dispatchCommitBounded,
99
+ parseMutationCommitResult,
100
+ persistDurableCommitAcceptance,
101
+ removeDurableCommit,
102
+ sealDurableCommit,
103
+ type CommitTransportContext,
104
+ type SealDurableCommitInput,
105
+ } from './commitTransport.js';
106
+ import {
107
+ processCommitLane,
108
+ waitForCommitReceipt,
109
+ type CommitLaneContext,
110
+ type CommitReceiptContext,
111
+ type CommitTransaction,
112
+ } from './commitLane.js';
113
+ import { enqueueCommit, type CommitApiContext } from './commitApi.js';
114
+ import {
115
+ archive as archiveModel,
116
+ create as createModel,
117
+ remove as deleteModel,
118
+ unarchive as unarchiveModel,
119
+ update as updateModel,
120
+ type ModelMutationContext,
121
+ } from './modelOperations.js';
122
+ import { enqueueTransaction, type QueueCoalescingContext } from './queueCoalescing.js';
123
+ import { processBatch, type BatchProcessingContext } from './batchProcessing.js';
124
+ import { handleFailure, type FailureHandlingContext } from './failureHandling.js';
125
+ import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictPolicyContext } from './failurePolicy.js';
126
+ import { takeNextExecutionBatch as selectExecutionBatch, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
127
+ import { scheduleProcessing as scheduleProcessingExternal, type ProcessingSchedulerContext } from './processingScheduler.js';
128
+ import {
129
+ drainPendingSettlements,
130
+ type PendingDrainContext,
131
+ } from './pendingDrain.js';
132
+ import { restoreDurableCommits as restoreDurableCommitsExternal, type DurableCommitRestoreContext } from './durableCommitRestore.js';
133
+
134
+ // The queue is split across sibling modules (`commitPayload`,
135
+ // `MutationStore`, `coalesceRules`, `deltaConfirmation`, `optimistic`).
136
+ // Re-export the shared public types here so importers can continue to reach
137
+ // them through this module.
138
+ export type { QueuedMutation, UserContext } from './commitPayload.js';
139
+ /**
140
+ * A pre-built, multi-operation commit submitted through
141
+ * `ablo.commits.create()`. Unlike the per-model {@link QueuedMutation} (see
142
+ * `./commitPayload.js`), the caller supplies the operations and the whole
143
+ * envelope commits atomically: the queue does not coalesce it, reorder its
144
+ * operations for foreign keys, or apply it optimistically. It runs through the
145
+ * same `mutationExecutor.commit()` as the model batch path, so its
146
+ * retry-on-reconnect behaviour is identical.
147
+ */
148
+ interface ConflictResolution {
149
+ strategy: 'last-write-wins' | 'merge' | 'reject' | 'custom';
150
+ resolver?: (local: MutationInput | undefined, remote: MutationInput) => MutationInput;
151
+ }
152
+
153
+ export interface MutationQueueConfig {
154
+ /** Shared client position (see logPosition.ts). One per client. */
155
+ position?: LogPositionPort;
156
+ /** The owning client's runtime. Defaults to the module-global bridge. */
157
+ runtime?: RuntimeContext;
158
+ maxBatchSize: number;
159
+ batchDelay: number;
160
+ maxRetries: number;
161
+ conflictResolution: ConflictResolution;
162
+ enablePersistence: boolean;
163
+ enableOptimistic: boolean;
164
+ /** Local adapter for applying and rolling back local writes. */
165
+ localMutationPort?: LocalMutationPort;
166
+ maxExecutingTransactions: number;
167
+ // How long to wait, in milliseconds, for a change's confirming sync delta
168
+ // before the retry-and-reconciliation cycle begins. For a source-forwarded
169
+ // write this is also the public `wait: 'confirmed'` deadline: expiry rejects
170
+ // the waiter with `replication_lag_timeout` while the accepted write remains
171
+ // pending. Defaults to 30000 (30 seconds); raise it for slow networks.
172
+ deltaConfirmationTimeout: number;
173
+ /**
174
+ * Exponential backoff for retryable server responses (HTTP 429/503).
175
+ * `baseMs` is the first retry delay; each subsequent attempt doubles
176
+ * up to `capMs`. Final delay = min(capMs, baseMs * 2^(attempt-1)) +
177
+ * up to 100ms of jitter. Defaults: 200ms / 1500ms.
178
+ */
179
+ retryBackoff: {
180
+ baseMs: number;
181
+ capMs: number;
182
+ };
183
+ /**
184
+ * How long, in milliseconds, to wait after the connection drops before
185
+ * failing any in-flight commit-lane transaction with an
186
+ * {@link AbloConnectionError}. Brief disconnects, such as a server restart
187
+ * or mobile network jitter, are absorbed transparently; only a disconnect
188
+ * that outlasts this window surfaces as a failure. Set it lower for
189
+ * interactive use (for example 10 seconds for chat) and higher for
190
+ * background batch work. Defaults to 30 seconds.
191
+ *
192
+ * Without this deadline, `commits.create({ wait: 'confirmed' })` would wait
193
+ * forever if the connection died while a commit was in flight.
194
+ */
195
+ commitOfflineGraceMs: number;
196
+ /**
197
+ * How long, in milliseconds, to wait for the transport to acknowledge a
198
+ * dispatched commit before treating the silence as a retryable no-receipt
199
+ * failure. A transport that never answers — a half-open socket, a dropped
200
+ * response frame — would otherwise hold the commit in flight forever, and
201
+ * with it the client's staged-batch lock: every later write in the session
202
+ * queues silently behind the unanswered one. The envelope's idempotency key
203
+ * is stable across retries, so a commit the server DID apply before the
204
+ * timeout is deduplicated on the retry rather than double-applied.
205
+ * Defaults to 30 seconds. Set `0` to disable the bound.
206
+ */
207
+ commitDispatchTimeoutMs: number;
208
+ }
209
+
210
+ export class MutationQueue extends EventEmitter {
211
+ // Keep one hour of clock/network margin inside the server's 24-hour ledger.
212
+ private static readonly DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
213
+ private store = new MutationStore();
214
+ // Signature of the last permanent-error we logged at `warn`. A `create`
215
+ // whose id already exists (`unique_violation`) is a permanent rejection
216
+ // that a pending-work drain re-drives after reconnect/bootstrap — without
217
+ // this, the identical cause prints on a loop. We log the first occurrence
218
+ // and demote exact repeats to `debug`.
219
+ private lastPermanentErrorSig?: string;
220
+ // The executor bound to this queue instance, set by `setMutationExecutor(...)`
221
+ // just after construction. When unset it falls back to the ambient executor
222
+ // from `getContext()`.
223
+ //
224
+ // The binding matters because the ambient executor is a module-level
225
+ // singleton: constructing a second client instance overwrites the first
226
+ // instance's executor. Without a per-instance binding, commits on one
227
+ // instance would dispatch through another instance's executor closure; once
228
+ // that other instance disposed its store, the closure would resolve no live
229
+ // connection and every commit here would fail with `ws_not_ready`, which the
230
+ // queue treats as transient and retries endlessly.
231
+ private _mutationExecutor: import('../../interfaces/index.js').MutationExecutor | null = null;
232
+ private get mutationExecutor() {
233
+ return this._mutationExecutor ?? this.runtime.mutationExecutor;
234
+ }
235
+
236
+ private readonly runtime: RuntimeContext;
237
+ /** Durable transaction journal owned by this queue, before commit sealing. */
238
+ private persistence: MutationPersistencePort | null = null;
239
+ private deferredMutations: Array<{
240
+ type: 'create' | 'update' | 'delete' | 'archive';
241
+ model: LocalModel;
242
+ capturedChanges?: Record<string, unknown>;
243
+ writeOptions?: WriteOptions;
244
+ }> = [];
245
+ private pendingPersistenceStages: Array<{
246
+ transaction: QueuedMutation;
247
+ modelData: Record<string, unknown>;
248
+ resolve: () => void;
249
+ reject: (error: Error) => void;
250
+ }> = [];
251
+ private persistenceStageScheduled = false;
252
+ private pendingDrainPromise: Promise<void> | null = null;
253
+
254
+ private executionQueue: QueuedMutation[] = [];
255
+ private isProcessing = false;
256
+ private processTimer?: NodeJS.Timeout;
257
+ private processScheduled = false;
258
+
259
+ // Staging area for transactions created in the same event-loop tick. Each one
260
+ // lands here first, then a microtask commits them together.
261
+ private createdTransactions: QueuedMutation[] = [];
262
+ private commitScheduled = false;
263
+
264
+ // Per-model in-flight tracking and merge buffer
265
+ private inFlightByModel = new Set<string>();
266
+ private pendingMergeByModel = new Map<
267
+ string,
268
+ { data: MutationInput; sourceMutationIds: string[] }
269
+ >();
270
+ private deferredDeletesByCreate = new Map<string, QueuedMutation[]>();
271
+
272
+ // Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
273
+ // Drained serially (one envelope at a time) since each is atomic; no
274
+ // coalescing with model-proxy transactions.
275
+ private commitLane: CommitTransaction[] = [];
276
+ private commitStore = new Map<string, CommitTransaction>();
277
+ /**
278
+ * Small race buffer for authoritative echoes that arrive before the queued
279
+ * mutation receipt. The forward and WAL stream are independent channels, so
280
+ * either can win without changing the settlement result.
281
+ */
282
+ private recentDeltaCorrelations = new Map<string, number>();
283
+ /**
284
+ * Client-facing confirmation deadlines for source-forwarded writes. These
285
+ * timers settle only the caller waiting for `confirmed`; the accepted write
286
+ * remains in `awaiting_delta`, with its durable envelope intact, until the
287
+ * authoritative WAL echo arrives.
288
+ */
289
+ private replicationLagTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
290
+ private replicationLagErrors = new Map<string, AbloConnectionError>();
291
+ private commitProcessing = false;
292
+ private lastCommitSequence = 0;
293
+ private durableReplayBlock: AbloIdempotencyError | null = null;
294
+ /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
295
+ private commitOutbox: DurableWriteStore | null = null;
296
+ private commitOutboxScope: CommitOutboxScope | null = null;
297
+
298
+ private get commitTransportContext(): CommitTransportContext {
299
+ return {
300
+ runtime: this.runtime,
301
+ config: {
302
+ enablePersistence: this.config.enablePersistence,
303
+ commitDispatchTimeoutMs: this.config.commitDispatchTimeoutMs,
304
+ },
305
+ commitOutbox: this.commitOutbox,
306
+ commitOutboxScope: this.commitOutboxScope,
307
+ mutationExecutor: this.mutationExecutor,
308
+ emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
309
+ };
310
+ }
311
+
312
+ private get commitLaneContext(): CommitLaneContext {
313
+ return {
314
+ runtime: this.runtime,
315
+ config: { maxRetries: this.config.maxRetries },
316
+ commitLane: this.commitLane,
317
+ commitNotifications: this.commitNotifications,
318
+ commitMissingIds: this.commitMissingIds,
319
+ commitProcessing: this.commitProcessing,
320
+ setCommitProcessing: (value) => { this.commitProcessing = value; },
321
+ durableReplayBlock: this.durableReplayBlock,
322
+ sealDurableCommit: (input) => this.sealDurableCommit(input),
323
+ assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
324
+ dispatchCommit: async (envelope) => this.parseMutationCommitResult(
325
+ await this.dispatchCommitBounded(envelope.operations, {
326
+ idempotencyKey: envelope.idempotencyKey,
327
+ ...(envelope.commitOptions.reads ? { reads: envelope.commitOptions.reads } : {}),
328
+ ...(envelope.commitOptions.track ? { track: envelope.commitOptions.track } : {}),
329
+ }),
330
+ ),
331
+ persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
332
+ removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
333
+ queuedCommitEchoSyncId: (transaction) => this.queuedCommitEchoSyncId(transaction),
334
+ completeQueuedCommit: (transaction, syncId) => this.completeQueuedCommit(transaction, syncId),
335
+ scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
336
+ noteAck: (syncId) => this.noteAck(syncId),
337
+ isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
338
+ isPermanentError: (error) => this.isPermanentError(error),
339
+ emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
340
+ };
341
+ }
342
+
343
+ private get commitReceiptContext(): CommitReceiptContext {
344
+ return {
345
+ commitStore: this.commitStore,
346
+ commitNotifications: this.commitNotifications,
347
+ commitMissingIds: this.commitMissingIds,
348
+ replicationLagErrors: this.replicationLagErrors,
349
+ on: (event, listener) => this.on(event, listener),
350
+ off: (event, listener) => this.off(event, listener),
351
+ };
352
+ }
353
+
354
+ private get commitApiContext(): CommitApiContext {
355
+ return {
356
+ assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
357
+ commitStore: this.commitStore,
358
+ commitLane: this.commitLane,
359
+ replicationLagErrors: this.replicationLagErrors,
360
+ clearReplicationLagState: (transactionId) => this.clearReplicationLagState(transactionId),
361
+ nextCommitSequence: () => this.nextCommitSequence(),
362
+ sealDurableCommit: (input) => this.sealDurableCommit(input),
363
+ processCommitLane: () => this.processCommitLane(),
364
+ emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
365
+ };
366
+ }
367
+
368
+ private get modelMutationContext(): ModelMutationContext {
369
+ return {
370
+ enableOptimistic: this.config.enableOptimistic,
371
+ persistenceReady: !!this.persistence && !!this.commitOutboxScope,
372
+ assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
373
+ generateId: () => this.generateId(),
374
+ normalizeModelKey,
375
+ computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
376
+ extractCreateData: (model) => this.extractCreateData(model),
377
+ extractUpdateData: (model) => this.extractUpdateData(model),
378
+ extractPreviousData: (model, input) => this.extractPreviousData(model, input),
379
+ mapChangesToInput: (modelName, changes) => this.mapChangesToInput(modelName, changes),
380
+ isReorderPayload: (input) => this.isReorderPayload(input),
381
+ attachConfirmation: (transaction) => this.attachConfirmation(transaction),
382
+ add: (transaction) => this.store.add(transaction),
383
+ applyOptimisticCreate: (model, transaction) => this.applyOptimisticCreate(model, transaction),
384
+ applyOptimisticUpdate: (model, transaction) => this.applyOptimisticUpdate(model, transaction),
385
+ applyOptimisticDelete: (model, transaction) => this.applyOptimisticDelete(model, transaction),
386
+ takeUnsentCreateForModel: (modelName, modelId) => this.takeUnsentCreateForModel(modelName, modelId),
387
+ cancelUnsentCreateForDelete: (transaction) => this.cancelUnsentCreateForDelete(transaction),
388
+ completeLocalDelete: (model, context, writeOptions, sourceMutationIds) => this.completeLocalDelete(model, context, writeOptions, sourceMutationIds),
389
+ cancelTransactionsForModel: (modelId, type) => this.cancelTransactionsForModel(modelId, type),
390
+ pendingMergeByModel: this.pendingMergeByModel,
391
+ inFlightByModel: this.inFlightByModel,
392
+ findCreateBarrierForDelete: (modelName, modelId) => this.findCreateBarrierForDelete(modelName, modelId),
393
+ deferDeleteUntilCreateSettles: (create, transaction) => this.deferDeleteUntilCreateSettles(create, transaction),
394
+ logger: this.runtime.logger,
395
+ persistAndStage: (transaction, modelData) => this.persistAndStage(transaction, modelData),
396
+ persistQueuedTransaction: (transaction, modelData) => this.persistQueuedTransaction(transaction, modelData),
397
+ stageTransaction: (transaction) => this.stageTransaction(transaction),
398
+ emit: (event, payload) => this.emit(event, payload),
399
+ };
400
+ }
401
+
402
+ private get queueCoalescingContext(): QueueCoalescingContext {
403
+ return {
404
+ executionQueue: this.executionQueue,
405
+ inFlightByModel: this.inFlightByModel,
406
+ pendingMergeByModel: this.pendingMergeByModel,
407
+ ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
408
+ scheduleProcessing: (immediate) => this.scheduleProcessing(immediate),
409
+ storeRemove: (transactionId) => this.store.remove(transactionId),
410
+ };
411
+ }
412
+
413
+ private get batchProcessingContext(): BatchProcessingContext {
414
+ return {
415
+ runtime: this.runtime,
416
+ config: this.config,
417
+ durableReplayBlock: this.durableReplayBlock,
418
+ executionQueue: this.executionQueue,
419
+ isProcessing: this.isProcessing,
420
+ setIsProcessing: (value) => { this.isProcessing = value; },
421
+ takeNextExecutionBatch: () => this.takeNextExecutionBatch(),
422
+ ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
423
+ ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope([...batch]),
424
+ executingCount: this.executingCount,
425
+ setExecutingCount: (value) => { this.executingCount = value; },
426
+ inFlightByModel: this.inFlightByModel,
427
+ pendingMergeByModel: this.pendingMergeByModel,
428
+ generateId: () => this.generateId(),
429
+ computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
430
+ store: this.store,
431
+ enqueue: (transaction) => this.enqueue(transaction),
432
+ optimisticUpdates: this.localMutationPort.updates,
433
+ commitNotifications: this.commitNotifications,
434
+ commitMissingIds: this.commitMissingIds,
435
+ sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
436
+ dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
437
+ parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
438
+ persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
439
+ removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
440
+ assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
441
+ sealDurableCommit: (input) => this.sealDurableCommit(input),
442
+ noteAck: (syncId) => this.noteAck(syncId),
443
+ classifyReceiptNotifications: (operations, notifications) => this.classifyReceiptNotifications(operations, notifications),
444
+ receiptTargetKey: (modelName, modelId) => this.receiptTargetKey(modelName, modelId),
445
+ scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
446
+ scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs),
447
+ clearReplicationLagState: (transactionId) => this.clearReplicationLagState(transactionId),
448
+ completeQueuedCommit: (transaction, syncId) => this.completeQueuedCommit(transaction, syncId),
449
+ queuedCommitMatchesCorrelation: (transaction, correlationId) => this.queuedCommitMatchesCorrelation(transaction, correlationId),
450
+ recentDeltaCorrelations: this.recentDeltaCorrelations,
451
+ lastSeenSyncId: this.lastSeenSyncId,
452
+ scheduleProcessing: (immediate) => this.scheduleProcessing(immediate),
453
+ handleFailure: (transaction, error) => this.handleFailure(transaction, error),
454
+ isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
455
+ emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
456
+ emit: (event, payload) => this.emit(event, payload),
457
+ rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
458
+ };
459
+ }
460
+
461
+ private get failureHandlingContext(): FailureHandlingContext {
462
+ return {
463
+ runtime: this.runtime,
464
+ config: this.config,
465
+ store: this.store,
466
+ isPermanentError: (error) => this.isPermanentError(error),
467
+ rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
468
+ enqueue: (transaction) => this.enqueue(transaction),
469
+ getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
470
+ setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
471
+ emit: (event, payload) => this.emit(event, payload),
472
+ };
473
+ }
474
+
475
+ private get conflictPolicyContext(): ConflictPolicyContext {
476
+ return {
477
+ config: this.config,
478
+ store: this.store,
479
+ rollbackOptimistic: (transaction, reason) => this.rollbackOptimistic(transaction, reason),
480
+ mergeData: (local, remote) => this.mergeData(local, remote),
481
+ enqueue: (transaction) => this.enqueue(transaction),
482
+ };
483
+ }
484
+
485
+ private get processingSchedulerContext(): ProcessingSchedulerContext {
486
+ return {
487
+ processScheduled: this.processScheduled,
488
+ setProcessScheduled: (value) => { this.processScheduled = value; },
489
+ processTimer: this.processTimer,
490
+ setProcessTimer: (timer) => { this.processTimer = timer; },
491
+ executingCount: this.executingCount,
492
+ maxExecutingTransactions: this.config.maxExecutingTransactions,
493
+ batchDelay: this.config.batchDelay,
494
+ processBatch: () => { void this.processBatch(); },
495
+ logger: this.runtime.logger,
496
+ };
497
+ }
498
+
499
+ private get pendingDrainContext(): PendingDrainContext {
500
+ return {
501
+ runtime: this.runtime,
502
+ config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
503
+ store: this.store,
504
+ executionQueue: this.executionQueue,
505
+ optimisticUpdates: this.localMutationPort.updates,
506
+ assertDurableReplayOpen: () => this.assertDurableReplayOpen(),
507
+ processCommitLane: () => this.processCommitLane(),
508
+ takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
509
+ ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
510
+ ensureDerivedFields: (transaction) => this.ensureDerivedFields(transaction),
511
+ sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
512
+ sealDurableCommit: (input) => this.sealDurableCommit(input),
513
+ assertEnvelopeInsideReplayWindow: (envelope) => this.assertEnvelopeInsideReplayWindow(envelope),
514
+ parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
515
+ dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
516
+ persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
517
+ removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
518
+ scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId),
519
+ scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs),
520
+ enqueue: (transaction) => this.enqueue(transaction),
521
+ recentDeltaCorrelations: this.recentDeltaCorrelations,
522
+ emit: (event, payload) => this.emit(event, payload),
523
+ };
524
+ }
525
+
526
+ private get durableCommitRestoreContext(): DurableCommitRestoreContext {
527
+ return {
528
+ config: this.config,
529
+ commitOutbox: this.commitOutbox,
530
+ commitOutboxScope: this.commitOutboxScope,
531
+ commitStore: this.commitStore,
532
+ commitLane: this.commitLane,
533
+ runtime: this.runtime,
534
+ processCommitLane: () => this.processCommitLane(),
535
+ durableReplayWindowMs: MutationQueue.DURABLE_REPLAY_WINDOW_MS,
536
+ };
537
+ }
538
+
539
+ private get persistenceContext(): MutationPersistenceContext {
540
+ return {
541
+ runtime: this.runtime,
542
+ persistence: this.persistence,
543
+ commitOutboxScope: this.commitOutboxScope,
544
+ config: this.config,
545
+ store: this.store,
546
+ enqueue: (transaction) => this.enqueue(transaction),
547
+ computePriorityScore: (type, modelName) => this.computePriorityScore(type, modelName),
548
+ deserializeTransaction: (data) => this.deserializeTransaction(data),
549
+ };
550
+ }
551
+
552
+ private nextCommitSequence(): number {
553
+ const wallSequence = Date.now() * 1_000;
554
+ this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
555
+ return this.lastCommitSequence;
556
+ }
557
+
558
+ private emitCommitLifecycle(event: string, payload: unknown): void {
559
+ try {
560
+ this.emit(event, payload);
561
+ } catch (error) {
562
+ this.runtime.observability.captureMutationFailure({
563
+ context: `commit-lifecycle-listener:${event}`,
564
+ error: error instanceof Error ? error : String(error),
565
+ });
566
+ }
567
+ }
568
+
569
+ private assertDurableReplayOpen(): void {
570
+ if (this.durableReplayBlock) throw this.durableReplayBlock;
571
+ }
572
+
573
+ private assertEnvelopeInsideReplayWindow(
574
+ envelope: Pick<DurableCommitEnvelope, 'sealedAt' | 'acceptedAt'>,
575
+ ): void {
576
+ this.assertDurableReplayOpen();
577
+ if (
578
+ envelope.acceptedAt === undefined &&
579
+ Date.now() - envelope.sealedAt >=
580
+ MutationQueue.DURABLE_REPLAY_WINDOW_MS
581
+ ) {
582
+ this.durableReplayBlock = new AbloIdempotencyError(
583
+ 'A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.',
584
+ { code: 'idempotency_conflict' },
585
+ );
586
+ // This gate stops EVERY subsequent write on this client, and each of
587
+ // those rejections is captured to observability rather than surfaced to
588
+ // the caller — without this line the session degrades into "nothing
589
+ // saves and nothing errors". One loud line at the moment the block
590
+ // engages is the only visible trace.
591
+ this.runtime.logger.warn(
592
+ 'sync paused: a saved write from an earlier session is older than the server replay window, so newer writes are held until it is reviewed',
593
+ { sealedAt: envelope.sealedAt },
594
+ );
595
+ throw this.durableReplayBlock;
596
+ }
597
+ }
598
+
599
+ private computePriorityScore(type: QueuedMutation['type'], modelName: string): number {
600
+ return computePriorityScore(type, modelName, this.runtime);
601
+ }
602
+
603
+ private ensureDerivedFields(transaction: QueuedMutation): void {
604
+ if (!transaction.modelKey) {
605
+ transaction.modelKey = normalizeModelKey(transaction.modelName);
606
+ }
607
+ if (transaction.priorityScore === undefined) {
608
+ transaction.priorityScore = this.computePriorityScore(
609
+ transaction.type,
610
+ transaction.modelName
611
+ );
612
+ }
613
+ }
614
+
615
+ private entityKey(modelName: string, modelId: string): string {
616
+ return entityKey(modelName, modelId);
617
+ }
618
+
619
+ /** Collision-safe receipt target identity across models sharing a row id. */
620
+ private receiptTargetKey(modelName: string, modelId: string): string {
621
+ return stableStringify([normalizeModelKey(modelName), modelId]);
622
+ }
623
+
624
+ /**
625
+ * Relates stale notifications back to write targets without assuming the
626
+ * server's canonical model name uses the same spelling as the public schema
627
+ * key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
628
+ * is the compatibility fallback. An ambiguous same-id cross-model mismatch
629
+ * is deliberately left unclassified, so it cannot falsely settle a queued
630
+ * write. A notification with no write-target id (or an explicit group) is a
631
+ * declared-read conflict and holds the whole batch.
632
+ */
633
+ private classifyReceiptNotifications(
634
+ operations: readonly { model: string; id: string }[],
635
+ notifications: readonly StaleNotification[],
636
+ ): {
637
+ holdsEntireBatch: boolean;
638
+ heldTargets: Set<string>;
639
+ notificationsByTarget: Map<string, StaleNotification[]>;
640
+ } {
641
+ const targets = operations.map((operation) => ({
642
+ id: operation.id,
643
+ key: this.receiptTargetKey(operation.model, operation.id),
644
+ }));
645
+ const heldTargets = new Set<string>();
646
+ const notificationsByTarget = new Map<string, StaleNotification[]>();
647
+ let holdsEntireBatch = false;
648
+
649
+ for (const notification of notifications) {
650
+ const candidates = targets.filter((target) => target.id === notification.id);
651
+ const notificationKey = this.receiptTargetKey(
652
+ notification.model,
653
+ notification.id,
654
+ );
655
+ const exactTargets = candidates.filter(
656
+ (target) => target.key === notificationKey,
657
+ );
658
+ const candidateKeys = new Set(
659
+ (exactTargets.length > 0 ? exactTargets : candidates).map(
660
+ (target) => target.key,
661
+ ),
662
+ );
663
+
664
+ if (notification.group || candidates.length === 0) {
665
+ holdsEntireBatch = true;
666
+ continue;
667
+ }
668
+ if (candidateKeys.size !== 1) {
669
+ // Same id across multiple differently named models with no exact match:
670
+ // the id-only compatibility fallback is ambiguous. Await the echo.
671
+ continue;
672
+ }
673
+ const [targetKey] = candidateKeys;
674
+ if (!targetKey) continue;
675
+ heldTargets.add(targetKey);
676
+ const targetNotifications = notificationsByTarget.get(targetKey) ?? [];
677
+ targetNotifications.push(notification);
678
+ notificationsByTarget.set(targetKey, targetNotifications);
679
+ }
680
+
681
+ return { holdsEntireBatch, heldTargets, notificationsByTarget };
682
+ }
683
+
684
+ private resolveConfirmation(transaction: QueuedMutation): void {
685
+ const resolver = this.confirmationResolvers.get(transaction.id);
686
+ if (!resolver) return;
687
+ this.confirmationResolvers.delete(transaction.id);
688
+ resolver.resolve();
689
+ }
690
+
691
+ private takeUnsentCreateForModel(modelName: string, modelId: string): QueuedMutation | undefined {
692
+ return takeUnsentCreateForModel(
693
+ this.createdTransactions,
694
+ this.executionQueue,
695
+ this.store,
696
+ modelName,
697
+ modelId,
698
+ );
699
+ }
700
+
701
+ private async cancelUnsentCreateForDelete(transaction: QueuedMutation): Promise<void> {
702
+ this.store.updateStatus(transaction.id, 'rolled_back');
703
+ if (this.config.enableOptimistic) {
704
+ await this.rollbackOptimistic(transaction, 'model_cancelled');
705
+ }
706
+ this.resolveConfirmation(transaction);
707
+ }
708
+
709
+ private findCreateBarrierForDelete(modelName: string, modelId: string): QueuedMutation | undefined {
710
+ return findCreateBarrierForDelete(this.store, modelName, modelId);
711
+ }
712
+
713
+ private completeLocalDelete(
714
+ model: LocalModel,
715
+ context: UserContext,
716
+ writeOptions: WriteOptions | undefined,
717
+ sourceMutationIds: readonly string[] = [],
718
+ ): QueuedMutation {
719
+ const actualModelName = model.getModelName();
720
+ const modelKey = normalizeModelKey(actualModelName);
721
+ const transaction: QueuedMutation = {
722
+ id: this.generateId(),
723
+ type: 'delete',
724
+ modelName: actualModelName,
725
+ modelId: model.id,
726
+ modelKey,
727
+ priorityScore: this.computePriorityScore('delete', actualModelName),
728
+ previousData: model.toJSON ? model.toJSON() : { ...model },
729
+ context,
730
+ status: 'completed',
731
+ createdAt: Date.now(),
732
+ attempts: 0,
733
+ priority: 'high',
734
+ writeOptions,
735
+ ...(sourceMutationIds.length > 0
736
+ ? { sourceMutationIds: [...new Set(sourceMutationIds)] }
737
+ : {}),
738
+ localOnly: true,
739
+ };
740
+
741
+ this.attachConfirmation(transaction);
742
+ this.store.add(transaction);
743
+
744
+ if (this.config.enableOptimistic) {
745
+ this.applyOptimisticDelete(model, transaction);
746
+ }
747
+
748
+ this.emit('transaction:created', transaction);
749
+ this.emit('transaction:completed', transaction);
750
+ this.emit(`transaction:completed:${transaction.id}`, transaction);
751
+ this.localMutationPort.updates.delete(transaction.id);
752
+ return transaction;
753
+ }
754
+
755
+ private deferDeleteUntilCreateSettles(createTransaction: QueuedMutation, deleteTransaction: QueuedMutation): void {
756
+ deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
757
+ }
758
+
759
+ private releaseDeferredDeletesForCreate(createTransaction: QueuedMutation): void {
760
+ releaseDeferredDeletesForCreate(
761
+ this.deferredDeletesByCreate,
762
+ this.store,
763
+ (tx) => { this.enqueue(tx); },
764
+ createTransaction,
765
+ );
766
+ }
767
+
768
+ // Default configuration, tuned so more operations coalesce into a single
769
+ // commit: a larger batch size and delay give rapid operations more time to
770
+ // merge before the batch is sent.
771
+ private config: MutationQueueConfig = {
772
+ maxBatchSize: 50, // send up to this many operations per commit
773
+ batchDelay: 150, // milliseconds to wait for more operations before sending
774
+ maxRetries: 3,
775
+ conflictResolution: {
776
+ strategy: 'last-write-wins',
777
+ },
778
+ enablePersistence: true,
779
+ enableOptimistic: true,
780
+ maxExecutingTransactions: 100,
781
+ deltaConfirmationTimeout: 30000,
782
+ retryBackoff: { baseMs: 200, capMs: 1500 },
783
+ commitOfflineGraceMs: 30_000,
784
+ commitDispatchTimeoutMs: 30_000,
785
+ };
786
+
787
+ // Track executing transactions for backpressure
788
+ private executingCount = 0;
789
+
790
+ private readonly localMutationPort: LocalMutationPort;
791
+
792
+ // Stale-context notifications, keyed by transaction id. When the server
793
+ // accepts a commit but reports that an operation's premise had moved,
794
+ // the notification lands here from the commit acknowledgement and is drained
795
+ // by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
796
+ private commitNotifications = new Map<string, StaleNotification[]>();
797
+ /** Zero-row targets returned on a successful atomic commit receipt. */
798
+ private commitMissingIds = new Map<string, string[]>();
799
+
800
+ // Delta-confirmation tracking (ack watermark advance + the awaiting_delta
801
+ // timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
802
+ // constructor once `position` is bound.
803
+ private readonly deltaConfirmation: DeltaConfirmationTracker;
804
+
805
+ // Connection-state check, supplied by the client, used to hold off rollbacks
806
+ // while disconnected.
807
+ private isConnectedFn: () => boolean = () => true;
808
+
809
+ // Grace timer that, when fired, fails any commit-lane transaction
810
+ // still awaiting an ack. Started on `setConnectionState('disconnected')`,
811
+ // cleared on `'connected'`. The reconnect-retry behavior of the queue
812
+ // is preserved for brief blips; this only catches persistent disconnects.
813
+ private commitOfflineGraceTimer: ReturnType<typeof setTimeout> | null = null;
814
+
815
+ /**
816
+ * This client's place in the global order of sync deltas. The instance is
817
+ * shared: the client injects one, and a standalone queue creates its own. The
818
+ * queue advances the `acked` cursor as commit responses arrive, the store
819
+ * advances `applied` and `persisted`, and snapshots and claims read
820
+ * `readFloor`. See `../logPosition.js` for the full contract.
821
+ */
822
+ readonly position: LogPositionPort;
823
+
824
+ /** Applied-cursor alias, kept so the many internal read sites stay legible. */
825
+ private get lastSeenSyncId(): number {
826
+ return this.position.applied;
827
+ }
828
+
829
+ private noteAck(lastSyncId: number | undefined): void {
830
+ this.deltaConfirmation.noteAck(lastSyncId);
831
+ }
832
+
833
+ // Batch management
834
+ private batchIndex = 0;
835
+
836
+ /** Mints the request identity once; retry paths only read the stored value. */
837
+ private generateCommitIdempotencyKey(): string {
838
+ return `commit_${uuid()}`;
839
+ }
840
+
841
+ /**
842
+ * Binds an ordered transaction batch to one wire-level idempotency key.
843
+ * Existing envelopes are validated and restored to their original order;
844
+ * they are never extended with newly queued work.
845
+ */
846
+ private ensureCommitEnvelope(batch: QueuedMutation[]): string {
847
+ const firstTransaction = batch[0];
848
+ if (!firstTransaction) {
849
+ throw new Error('Cannot create an idempotency envelope for an empty commit');
850
+ }
851
+
852
+ const existingKeys = new Set(
853
+ batch
854
+ .map((tx) => tx.commitEnvelope?.idempotencyKey)
855
+ .filter((key) => key !== undefined),
856
+ );
857
+ if (existingKeys.size > 1) {
858
+ throw new Error('Cannot combine transactions from different commit envelopes');
859
+ }
860
+
861
+ const existingKey = existingKeys.values().next().value;
862
+ if (existingKey) {
863
+ const expectedCount = firstTransaction.commitEnvelope?.operationCount;
864
+ const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
865
+ const isCompleteEnvelope =
866
+ expectedCount === batch.length &&
867
+ indexes.size === batch.length &&
868
+ batch.every(
869
+ (tx) =>
870
+ tx.commitEnvelope?.idempotencyKey === existingKey &&
871
+ tx.commitEnvelope.operationCount === expectedCount &&
872
+ tx.commitEnvelope.operationIndex < expectedCount,
873
+ );
874
+ if (!isCompleteEnvelope) {
875
+ throw new Error('Cannot replay a partial or malformed commit envelope');
876
+ }
877
+ const persistedSealTimes = new Set(
878
+ batch
879
+ .map((transaction) => transaction.commitEnvelope?.sealedAt)
880
+ .filter((sealedAt) => sealedAt !== undefined),
881
+ );
882
+ if (persistedSealTimes.size > 1) {
883
+ throw new Error('Cannot replay a commit envelope with inconsistent seal times');
884
+ }
885
+ const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
886
+ for (const transaction of batch) {
887
+ if (transaction.commitEnvelope) transaction.commitEnvelope.sealedAt = sealedAt;
888
+ }
889
+ batch.sort(
890
+ (a, b) =>
891
+ (a.commitEnvelope?.operationIndex ?? 0) -
892
+ (b.commitEnvelope?.operationIndex ?? 0),
893
+ );
894
+ return existingKey;
895
+ }
896
+
897
+ // An explicit public key owns its request. Transactions carrying one are
898
+ // selected as solo batches by takeNextExecutionBatch().
899
+ const explicitKey =
900
+ batch.length === 1
901
+ ? firstTransaction.writeOptions?.idempotencyKey
902
+ : undefined;
903
+ const idempotencyKey =
904
+ typeof explicitKey === 'string' && explicitKey.length > 0
905
+ ? explicitKey
906
+ : this.generateCommitIdempotencyKey();
907
+ const sealedAt = Date.now();
908
+ const sequence = this.nextCommitSequence();
909
+
910
+ batch.forEach((tx, operationIndex) => {
911
+ tx.commitEnvelope = createCommitEnvelopeMember({
912
+ idempotencyKey,
913
+ operationIndex,
914
+ operationCount: batch.length,
915
+ sealedAt,
916
+ sequence,
917
+ });
918
+ });
919
+ return idempotencyKey;
920
+ }
921
+
922
+ /** Bind the strict local outbox used before any mutation reaches the wire. */
923
+ setCommitOutbox(outbox: DurableWriteStore): void {
924
+ this.commitOutbox = outbox;
925
+ }
926
+
927
+ async setCommitOutboxScope(scope: CommitOutboxScope): Promise<void> {
928
+ this.commitOutboxScope = scope;
929
+ const deferred = this.deferredMutations;
930
+ this.deferredMutations = [];
931
+ await Promise.all(deferred.map((mutation) =>
932
+ this.enqueueModelMutation(
933
+ mutation.type,
934
+ mutation.model,
935
+ { userId: scope.participantId, organizationId: scope.organizationId },
936
+ mutation.capturedChanges,
937
+ mutation.writeOptions,
938
+ ),
939
+ ));
940
+ }
941
+ deferMutation(
942
+ type: 'create' | 'update' | 'delete' | 'archive',
943
+ model: LocalModel,
944
+ capturedChanges?: Record<string, unknown>,
945
+ writeOptions?: WriteOptions,
946
+ ): void {
947
+ this.runtime.logger.debug('[MutationQueue] Deferring mutation until identity is resolved', { type, model: model.getModelName(), modelId: model.id });
948
+ this.deferredMutations.push({ type, model, capturedChanges, writeOptions });
949
+ }
950
+
951
+ async enqueueModelMutation(
952
+ type: 'create' | 'update' | 'delete' | 'archive',
953
+ model: LocalModel,
954
+ context: UserContext,
955
+ capturedChanges?: Record<string, unknown>,
956
+ writeOptions?: WriteOptions,
957
+ ): Promise<QueuedMutation> {
958
+ switch (type) {
959
+ case 'create': return this.create(model, context, writeOptions);
960
+ case 'update': return this.update(model, context, capturedChanges, writeOptions);
961
+ case 'delete': return this.delete(model, context, writeOptions);
962
+ case 'archive': return this.archive(model, context, writeOptions);
963
+ }
964
+ }
965
+
966
+ /** Attach the durable journal supplied by the local/materializer layer. */
967
+ setPersistence(persistence: MutationPersistencePort): void {
968
+ this.persistence = persistence;
969
+ }
970
+
971
+ private async persistQueuedTransaction(transaction: QueuedMutation, modelData?: Record<string, unknown>): Promise<void> {
972
+ await persistQueuedTransaction(this.persistenceContext, transaction, modelData);
973
+ }
974
+
975
+ private persistAndStage(
976
+ transaction: QueuedMutation,
977
+ modelData: Record<string, unknown>,
978
+ ): Promise<void> {
979
+ if (!this.persistence || !this.commitOutboxScope) {
980
+ this.stageTransaction(transaction);
981
+ return Promise.resolve();
982
+ }
983
+ return new Promise<void>((resolve, reject) => {
984
+ this.pendingPersistenceStages.push({ transaction, modelData, resolve, reject });
985
+ if (this.persistenceStageScheduled) return;
986
+ this.persistenceStageScheduled = true;
987
+ queueMicrotask(async () => {
988
+ this.persistenceStageScheduled = false;
989
+ const batch = this.pendingPersistenceStages;
990
+ this.pendingPersistenceStages = [];
991
+ const results = await Promise.allSettled(
992
+ batch.map(({ transaction: queued, modelData: data }) =>
993
+ this.persistQueuedTransaction(queued, data),
994
+ ),
995
+ );
996
+ for (const [index, result] of results.entries()) {
997
+ const item = batch[index];
998
+ if (!item) continue;
999
+ if (result.status === 'fulfilled') {
1000
+ this.stageTransaction(item.transaction);
1001
+ item.resolve();
1002
+ } else {
1003
+ item.reject(result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
1004
+ }
1005
+ }
1006
+ });
1007
+ });
1008
+ }
1009
+
1010
+ private removePersistedTransaction(transactionId: string): void {
1011
+ removePersistedTransaction(this.persistenceContext, transactionId);
1012
+ }
1013
+
1014
+ private settlePersistedFailure(transaction: { id?: string; sourceMutationIds?: string[] }): void {
1015
+ settlePersistedFailure(this.persistenceContext, transaction);
1016
+ }
1017
+
1018
+ private sourceMutationIdsFor(batch: readonly QueuedMutation[]): string[] {
1019
+ return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
1020
+ }
1021
+
1022
+ /** Atomically seals a commit before it is allowed onto the dispatch lane. */
1023
+ private async sealDurableCommit(input: Parameters<typeof sealDurableCommit>[1]): Promise<DurableCommitEnvelope> {
1024
+ return sealDurableCommit(this.commitTransportContext, input, pendingMutationRecordId);
1025
+ }
1026
+
1027
+ private async removeDurableCommit(idempotencyKey: string): Promise<void> {
1028
+ return removeDurableCommit(this.commitTransportContext, idempotencyKey);
1029
+ }
1030
+
1031
+ private async persistDurableCommitAcceptance(
1032
+ envelope: DurableCommitEnvelope,
1033
+ result: MutationCommitResult,
1034
+ ): Promise<DurableCommitEnvelope> {
1035
+ return persistDurableCommitAcceptance(this.commitTransportContext, envelope, result);
1036
+ }
1037
+
1038
+ private parseMutationCommitResult(
1039
+ value: Awaited<ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']>>,
1040
+ ): MutationCommitResult {
1041
+ return parseMutationCommitResult(value);
1042
+ }
1043
+
1044
+ private dispatchCommitBounded(
1045
+ ...args: Parameters<import('../../interfaces/index.js').MutationExecutor['commit']>
1046
+ ): ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']> {
1047
+ return dispatchCommitBounded(this.commitTransportContext, ...args);
1048
+ }
1049
+
1050
+ private clearReplicationLagState(transactionId: string): void {
1051
+ const timeout = this.replicationLagTimeouts.get(transactionId);
1052
+ if (timeout) clearTimeout(timeout);
1053
+ this.replicationLagTimeouts.delete(transactionId);
1054
+ this.replicationLagErrors.delete(transactionId);
1055
+ }
1056
+
1057
+ /**
1058
+ * Bounds the public `wait: 'confirmed'` promise without changing the
1059
+ * accepted write's lifecycle. A lag timeout is not a rejection from the
1060
+ * source database, so it must never emit `transaction:failed`, roll back
1061
+ * optimistic state, or remove the durable replay envelope.
1062
+ */
1063
+ private scheduleReplicationLagTimeout(
1064
+ transactionId: string,
1065
+ clientTxId = transactionId,
1066
+ correlationId?: string,
1067
+ ): void {
1068
+ const previous = this.replicationLagTimeouts.get(transactionId);
1069
+ if (previous) clearTimeout(previous);
1070
+ this.replicationLagErrors.delete(transactionId);
1071
+
1072
+ const timeoutMs = this.config.deltaConfirmationTimeout;
1073
+ const timeout = setTimeout(() => {
1074
+ this.replicationLagTimeouts.delete(transactionId);
1075
+ const modelTx = this.store.get(transactionId);
1076
+ const commitTx = this.commitStore.get(transactionId);
1077
+ if (
1078
+ modelTx?.status !== 'awaiting_delta' &&
1079
+ commitTx?.status !== 'awaiting_delta'
1080
+ ) return;
1081
+
1082
+ const error = new AbloConnectionError(
1083
+ `The source accepted commit ${clientTxId}, but its replication echo did not arrive within ${timeoutMs}ms.`,
1084
+ {
1085
+ code: 'replication_lag_timeout',
1086
+ httpStatus: 504,
1087
+ details: {
1088
+ clientTxId,
1089
+ ...(correlationId ? { correlationId } : {}),
1090
+ timeoutMs,
1091
+ accepted: true,
1092
+ },
1093
+ },
1094
+ );
1095
+ this.replicationLagErrors.set(transactionId, error);
1096
+
1097
+ // Model-proxy writes expose their waiter through the resolver table.
1098
+ // Reject that promise without moving the transaction out of
1099
+ // `awaiting_delta`; the eventual echo still completes it normally.
1100
+ const resolver = this.confirmationResolvers.get(transactionId);
1101
+ if (resolver) {
1102
+ this.confirmationResolvers.delete(transactionId);
1103
+ resolver.reject(error);
1104
+ }
1105
+
1106
+ this.emitCommitLifecycle('transaction:confirmation_lagged', {
1107
+ transactionId,
1108
+ error,
1109
+ });
1110
+ this.emitCommitLifecycle(`transaction:confirmation_lagged:${transactionId}`, {
1111
+ error,
1112
+ });
1113
+ if (commitTx) {
1114
+ const firstOperation = commitTx.operations[0];
1115
+ this.emitCommitLifecycle('reconciliation:needed', {
1116
+ reason: 'replication_lag_timeout',
1117
+ txId: transactionId,
1118
+ model: firstOperation?.model ?? 'commit',
1119
+ modelId: firstOperation?.id ?? transactionId,
1120
+ lastSeenSyncId: this.lastSeenSyncId,
1121
+ retryCount: 1,
1122
+ });
1123
+ }
1124
+ }, timeoutMs);
1125
+ this.replicationLagTimeouts.set(transactionId, timeout);
1126
+ }
1127
+
1128
+ private takeNextExecutionBatch(): QueuedMutation[] {
1129
+ const selected = selectExecutionBatch(this.executionQueue, this.config.maxBatchSize);
1130
+ this.executionQueue = selected.remaining;
1131
+ return selected.batch;
1132
+ }
1133
+
1134
+ private takePendingDrainBatch(pending: QueuedMutation[]): QueuedMutation[] {
1135
+ return selectPendingDrainBatch(pending, this.config.maxBatchSize);
1136
+ }
1137
+
1138
+ /**
1139
+ * Resolvers for per-transaction `confirmation` promises. Populated in
1140
+ * `attachConfirmation` at staging time, consumed by the constructor-time
1141
+ * listeners on `transaction:completed` / `transaction:failed`. Kept off
1142
+ * the QueuedMutation row so the store's iteration order stays plain-data
1143
+ * and serialization-friendly.
1144
+ */
1145
+ private confirmationResolvers = new Map<
1146
+ string,
1147
+ { resolve: () => void; reject: (err: Error) => void }
1148
+ >();
1149
+
1150
+ constructor(config?: Partial<MutationQueueConfig>) {
1151
+ super();
1152
+ this.runtime = config?.runtime ?? globalRuntime;
1153
+ this.position = config?.position ?? new LogPosition();
1154
+ this.localMutationPort = config?.localMutationPort ?? createLocalMutationPort(this);
1155
+ // Bind the confirmation tracker to this queue's store/ledger/events.
1156
+ // `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
1157
+ // swaps stay visible to in-flight timeouts.
1158
+ this.deltaConfirmation = new DeltaConfirmationTracker({
1159
+ store: this.store,
1160
+ optimisticUpdates: this.localMutationPort.updates,
1161
+ emit: (event, payload) => {
1162
+ this.emit(event, payload);
1163
+ },
1164
+ isConnected: () => this.isConnectedFn(),
1165
+ position: this.position,
1166
+ runtime: this.runtime,
1167
+ });
1168
+
1169
+ if (config) {
1170
+ this.config = { ...this.config, ...config };
1171
+ }
1172
+
1173
+ // Centralized fan-in for `tx.confirmation`. Completion/failure are
1174
+ // emitted from ~10 sites (delta confirm, immediate confirm, batch
1175
+ // success, permanent error, max_retries_exhausted, …). Subscribing
1176
+ // once here keeps every emit site intact and guarantees the call-site
1177
+ // promise always settles, regardless of which path produced the
1178
+ // terminal state.
1179
+ this.on('transaction:completed', (tx: QueuedMutation) => {
1180
+ // Any successful write clears the permanent-error dedup, so a genuine
1181
+ // recurrence after recovery warns again instead of staying demoted.
1182
+ this.lastPermanentErrorSig = undefined;
1183
+ this.clearReplicationLagState(tx.id);
1184
+ const r = this.confirmationResolvers.get(tx.id);
1185
+ if (r) {
1186
+ this.confirmationResolvers.delete(tx.id);
1187
+ r.resolve();
1188
+ }
1189
+ if (tx.type === 'create') {
1190
+ this.releaseDeferredDeletesForCreate(tx);
1191
+ }
1192
+ this.removePersistedTransaction(tx.id);
1193
+ });
1194
+ this.on(
1195
+ 'transaction:failed',
1196
+ ({ transaction, error }: { transaction: QueuedMutation; error: Error }) => {
1197
+ const r = this.confirmationResolvers.get(transaction.id);
1198
+ if (r) {
1199
+ this.confirmationResolvers.delete(transaction.id);
1200
+ r.reject(error);
1201
+ }
1202
+ if (transaction.type === 'create') {
1203
+ this.releaseDeferredDeletesForCreate(transaction);
1204
+ }
1205
+ this.settlePersistedFailure(transaction);
1206
+ }
1207
+ );
1208
+ }
1209
+
1210
+ /**
1211
+ * Returns the in-flight confirmation promise for a given model and id. When
1212
+ * several transactions match, it returns the most recent one's promise; when
1213
+ * none is open it resolves immediately, which covers both "already confirmed"
1214
+ * and "never staged".
1215
+ *
1216
+ * It considers the three non-terminal statuses in which the write can still
1217
+ * be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
1218
+ * `completed` (already settled) and `failed`/`rolled_back` (already
1219
+ * rejected). This complements the `confirmation` promise carried on a known
1220
+ * {@link QueuedMutation}: use this method at call sites that hold a model
1221
+ * returned by `ablo.<model>.create()` but never see the underlying
1222
+ * transaction.
1223
+ */
1224
+ confirmationFor(modelName: string, modelId: string): Promise<void> {
1225
+ const candidates = [
1226
+ ...this.store.getByStatus('pending'),
1227
+ ...this.store.getByStatus('executing'),
1228
+ ...this.store.getByStatus('awaiting_delta'),
1229
+ ].filter(
1230
+ (tx) => tx.modelName === modelName && tx.modelId === modelId,
1231
+ );
1232
+ if (candidates.length === 0) return Promise.resolve();
1233
+ const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
1234
+ if (!latest) return Promise.resolve();
1235
+ return latest.confirmation ?? Promise.resolve();
1236
+ }
1237
+
1238
+ /**
1239
+ * Attaches a `confirmation` promise to a newly created transaction. Call this
1240
+ * before the transaction is staged so a caller can `await tx.confirmation`
1241
+ * immediately after a create, update, or delete returns. It is idempotent and
1242
+ * returns early if one is already attached.
1243
+ *
1244
+ * It also attaches a no-op rejection handler. Most callers never await the
1245
+ * confirmation, and without this the runtime would report an unhandled
1246
+ * rejection when a write fails. Callers that do want to observe failure simply
1247
+ * attach their own `.then`/`.catch`.
1248
+ */
1249
+ private attachConfirmation(tx: QueuedMutation): void {
1250
+ if (tx.confirmation) return;
1251
+ tx.confirmation = new Promise<void>((resolve, reject) => {
1252
+ this.confirmationResolvers.set(tx.id, { resolve, reject });
1253
+ });
1254
+ tx.confirmation.catch(() => {
1255
+ // Swallow unhandled rejections; callers that care attach their own handler.
1256
+ });
1257
+ }
1258
+
1259
+ /**
1260
+ * Registers a predicate the queue uses to check whether it is connected.
1261
+ * While disconnected, confirmation timeouts re-schedule themselves instead of
1262
+ * escalating, so a transaction is never rolled back merely because the client
1263
+ * was briefly offline.
1264
+ */
1265
+ setConnectionChecker(fn: () => boolean): void {
1266
+ this.isConnectedFn = fn;
1267
+ }
1268
+
1269
+ /**
1270
+ * Drives the offline-grace timer for in-flight commit-lane transactions.
1271
+ *
1272
+ * On `'disconnected'` it starts a one-shot timer of
1273
+ * `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
1274
+ * outlasted the grace window — every commit-lane transaction still `pending`
1275
+ * or `executing` is failed with an {@link AbloConnectionError}, so
1276
+ * {@link waitForCommitReceipt} rejects within seconds instead of hanging.
1277
+ *
1278
+ * On `'connected'` it clears any pending grace timer. Brief disconnects are
1279
+ * absorbed transparently; {@link processCommitLane} and
1280
+ * {@link drainPending} resumes the work when the owner decides to drain.
1281
+ */
1282
+ setConnectionState(state: 'connected' | 'disconnected'): void {
1283
+ if (state === 'connected') {
1284
+ if (this.commitOfflineGraceTimer !== null) {
1285
+ clearTimeout(this.commitOfflineGraceTimer);
1286
+ this.commitOfflineGraceTimer = null;
1287
+ }
1288
+ return;
1289
+ }
1290
+ // state === 'disconnected'
1291
+ if (this.commitOfflineGraceTimer !== null) return; // already armed
1292
+ const graceMs = this.config.commitOfflineGraceMs;
1293
+ this.commitOfflineGraceTimer = setTimeout(() => {
1294
+ this.commitOfflineGraceTimer = null;
1295
+ this.failInFlightCommitsOnOffline(graceMs);
1296
+ }, graceMs);
1297
+ }
1298
+
1299
+ private failInFlightCommitsOnOffline(graceMs: number): void {
1300
+ const inFlight: string[] = [];
1301
+ for (const [id, tx] of this.commitStore.entries()) {
1302
+ if (tx.status === 'pending' || tx.status === 'executing') {
1303
+ inFlight.push(id);
1304
+ }
1305
+ }
1306
+ if (inFlight.length === 0) return;
1307
+ // Each failed commit reaches the consumer through its own rejection path,
1308
+ // so this aggregate line is forensic and logged at debug rather than warn.
1309
+ this.runtime.logger.debug(
1310
+ `[MutationQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`,
1311
+ { inFlightIds: inFlight.map((id) => id.slice(0, 8)) },
1312
+ );
1313
+ for (const id of inFlight) {
1314
+ const tx = this.commitStore.get(id);
1315
+ if (!tx) continue;
1316
+ const err = new AbloConnectionError(
1317
+ `commit ack abandoned after ${graceMs}ms offline`,
1318
+ { code: 'commit_offline_grace_expired' },
1319
+ );
1320
+ tx.status = 'failed';
1321
+ tx.error = err;
1322
+ this.emit(`transaction:failed:${id}`, { error: err });
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * Binds the mutation executor for this queue instance. The owning client
1328
+ * calls this right after construction, so commits made here always dispatch
1329
+ * through this instance's connection even when several client instances exist
1330
+ * in the same process.
1331
+ */
1332
+ setMutationExecutor(executor: import('../../interfaces/index.js').MutationExecutor): void {
1333
+ this._mutationExecutor = executor;
1334
+ }
1335
+
1336
+ // ============================================================================
1337
+ // Microtask-based transaction staging
1338
+ // ============================================================================
1339
+ //
1340
+ // Every transaction lands in the `createdTransactions` staging area first.
1341
+ // A microtask then commits them together under one batch index, so a bulk
1342
+ // operation such as importing a hundred rows is sent efficiently.
1343
+ //
1344
+ // Flow:
1345
+ // 1. create()/update()/delete() calls stageTransaction().
1346
+ // 2. stageTransaction() adds to createdTransactions and schedules a microtask.
1347
+ // 3. The microtask runs commitCreatedTransactions() once the current
1348
+ // synchronous code finishes.
1349
+ // 4. All staged transactions share one batch index and move to the execution
1350
+ // queue.
1351
+ // ============================================================================
1352
+
1353
+ /**
1354
+ * Stages a transaction for commit. Transactions staged within the same
1355
+ * event-loop tick are committed together.
1356
+ */
1357
+ private stageTransaction(transaction: QueuedMutation): void {
1358
+ this.createdTransactions.push(transaction);
1359
+ this.scheduleCommit();
1360
+ }
1361
+
1362
+ /**
1363
+ * Schedules the staged transactions to commit on a microtask, so all
1364
+ * transactions created synchronously within one tick are batched together.
1365
+ */
1366
+ private scheduleCommit(): void {
1367
+ if (this.commitScheduled) return;
1368
+ this.commitScheduled = true;
1369
+
1370
+ // Use queueMicrotask to run after current sync code completes
1371
+ // All transactions created in same event loop will be committed together
1372
+ const schedule =
1373
+ typeof queueMicrotask === 'function'
1374
+ ? queueMicrotask
1375
+ : (cb: () => void) => Promise.resolve().then(cb);
1376
+
1377
+ schedule(() => {
1378
+ this.commitCreatedTransactions();
1379
+ });
1380
+ }
1381
+
1382
+ /**
1383
+ * Moves all staged transactions onto the execution queue, assigning them a
1384
+ * single shared batch index so they commit together.
1385
+ */
1386
+ private commitCreatedTransactions(): void {
1387
+ this.commitScheduled = false;
1388
+
1389
+ if (this.createdTransactions.length === 0) return;
1390
+
1391
+ // Increment batch index - all transactions in this commit share it
1392
+ this.batchIndex++;
1393
+ const currentBatchIndex = this.batchIndex;
1394
+
1395
+ // Log batch commit for performance monitoring
1396
+ this.runtime.logger.debug('[MutationQueue] commitCreatedTransactions', {
1397
+ count: this.createdTransactions.length,
1398
+ batchIndex: currentBatchIndex,
1399
+ types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
1400
+ });
1401
+
1402
+ // Move all staged transactions to execution queue
1403
+ const staged = this.createdTransactions;
1404
+ this.createdTransactions = [];
1405
+
1406
+ for (const transaction of staged) {
1407
+ // Assign batch ID based on current batch index
1408
+ transaction.batchId = `batch_${currentBatchIndex}`;
1409
+ this.enqueue(transaction);
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Flushes every pending transaction in one commit, the fast path taken on
1415
+ * reconnect. If transport fails, the transactions retain this exact commit
1416
+ * envelope when they fall back to normal queue processing.
1417
+ */
1418
+ async drainPending(): Promise<void> {
1419
+ if (this.pendingDrainPromise) return this.pendingDrainPromise;
1420
+ const drain = this.drainPendingInternal();
1421
+ this.pendingDrainPromise = drain.finally(() => {
1422
+ this.pendingDrainPromise = null;
1423
+ });
1424
+ return this.pendingDrainPromise;
1425
+ }
1426
+
1427
+ private async drainPendingInternal(): Promise<void> {
1428
+ await drainPendingSettlements(this.pendingDrainContext);
1429
+ }
1430
+ async create(
1431
+ model: LocalModel,
1432
+ context: UserContext,
1433
+ writeOptions?: WriteOptions,
1434
+ sourceMutationId?: string,
1435
+ ): Promise<QueuedMutation> {
1436
+ return createModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
1437
+ }
1438
+
1439
+ async update(
1440
+ model: LocalModel,
1441
+ context: UserContext,
1442
+ precomputedChanges?: Record<string, unknown>,
1443
+ writeOptions?: WriteOptions,
1444
+ sourceMutationId?: string,
1445
+ ): Promise<QueuedMutation> {
1446
+ return updateModel(this.modelMutationContext, model, context, precomputedChanges, writeOptions, sourceMutationId);
1447
+ }
1448
+
1449
+ async delete(
1450
+ model: LocalModel,
1451
+ context: UserContext,
1452
+ writeOptions?: WriteOptions,
1453
+ sourceMutationId?: string,
1454
+ ): Promise<QueuedMutation> {
1455
+ return deleteModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
1456
+ }
1457
+
1458
+ async uploadAttachment(
1459
+ _file: File,
1460
+ options: { id: string; [key: string]: unknown },
1461
+ _context: UserContext // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
1462
+ ): Promise<{ url: string } | null> {
1463
+ return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
1464
+ }
1465
+
1466
+ async batchUploadAttachments(
1467
+ _files: File[],
1468
+ items: { id: string; [key: string]: unknown }[],
1469
+ _context: UserContext // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
1470
+ ): Promise<{ id: string; url: string }[]> {
1471
+ return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
1472
+ }
1473
+
1474
+ async archive(
1475
+ model: LocalModel,
1476
+ context: UserContext,
1477
+ writeOptions?: WriteOptions,
1478
+ sourceMutationId?: string,
1479
+ ): Promise<QueuedMutation> {
1480
+ return archiveModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
1481
+ }
1482
+
1483
+ async unarchive(model: LocalModel, context: UserContext): Promise<QueuedMutation> {
1484
+ return unarchiveModel(this.modelMutationContext, model, context);
1485
+ }
1486
+
1487
+ private enqueue(transaction: QueuedMutation): void {
1488
+ enqueueTransaction(this.queueCoalescingContext, transaction);
1489
+ }
1490
+
1491
+ private scheduleProcessing(immediate = false): void {
1492
+ scheduleProcessingExternal(this.processingSchedulerContext, immediate);
1493
+ }
1494
+
1495
+ private async processBatch(): Promise<void> {
1496
+ await processBatch(this.batchProcessingContext);
1497
+ }
1498
+
1499
+ private rememberDeltaCorrelation(correlationId: string, syncId: number): void {
1500
+ // Refresh insertion order when a replay repeats the same correlation id.
1501
+ this.recentDeltaCorrelations.delete(correlationId);
1502
+ this.recentDeltaCorrelations.set(correlationId, syncId);
1503
+ if (this.recentDeltaCorrelations.size <= 2_048) return;
1504
+ const oldest = this.recentDeltaCorrelations.keys().next().value;
1505
+ if (typeof oldest === 'string') this.recentDeltaCorrelations.delete(oldest);
1506
+ }
1507
+
1508
+ private queuedCommitEchoSyncId(tx: CommitTransaction): number | undefined {
1509
+ return tx.correlationId
1510
+ ? this.recentDeltaCorrelations.get(tx.correlationId)
1511
+ : undefined;
1512
+ }
1513
+
1514
+ private queuedCommitMatchesCorrelation(
1515
+ tx: CommitTransaction,
1516
+ correlationId: string,
1517
+ ): boolean {
1518
+ return tx.correlationId !== undefined && tx.correlationId === correlationId;
1519
+ }
1520
+
1521
+ private completeQueuedCommit(tx: CommitTransaction, syncId: number): void {
1522
+ if (tx.status !== 'awaiting_delta') return;
1523
+ this.clearReplicationLagState(tx.id);
1524
+ tx.lastSyncId = syncId;
1525
+ tx.status = 'completed';
1526
+ // The queued receipt was only acceptance. The correlated source echo is
1527
+ // the first definitive success and therefore the point where the durable
1528
+ // replay envelope may be removed.
1529
+ void this.removeDurableCommit(tx.id);
1530
+ this.emitCommitLifecycle('transaction:completed', tx);
1531
+ this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
1532
+ }
1533
+
1534
+ /**
1535
+ * Confirms awaiting writes. Hosted/anomaly receipts keep their sync-id
1536
+ * threshold semantics; queued forwards require the exact server correlation.
1537
+ */
1538
+ onDeltaReceived(
1539
+ syncId: number,
1540
+ _transactionId?: string,
1541
+ correlationId?: string,
1542
+ ): void {
1543
+ if (correlationId) this.rememberDeltaCorrelation(correlationId, syncId);
1544
+ const correlatedModelTransactions = correlationId
1545
+ ? this.store.getByStatus('awaiting_delta').filter(
1546
+ (tx) =>
1547
+ tx.requiresCorrelatedDelta === true &&
1548
+ tx.correlationId !== undefined &&
1549
+ tx.correlationId === correlationId,
1550
+ )
1551
+ : [];
1552
+ this.deltaConfirmation.onDeltaReceived(syncId, correlationId);
1553
+ if (correlatedModelTransactions.length > 0) {
1554
+ const envelopeIds = new Set<string>();
1555
+ for (const tx of correlatedModelTransactions) {
1556
+ this.clearReplicationLagState(tx.id);
1557
+ if (tx.commitEnvelope) envelopeIds.add(tx.commitEnvelope.idempotencyKey);
1558
+ }
1559
+ for (const envelopeId of envelopeIds) {
1560
+ const envelopeStillAwaiting = this.store
1561
+ .getByStatus('awaiting_delta')
1562
+ .some(
1563
+ (tx) => tx.commitEnvelope?.idempotencyKey === envelopeId,
1564
+ );
1565
+ if (!envelopeStillAwaiting) void this.removeDurableCommit(envelopeId);
1566
+ }
1567
+ }
1568
+ if (!correlationId) return;
1569
+ for (const tx of this.commitStore.values()) {
1570
+ if (tx.status !== 'awaiting_delta') continue;
1571
+ if (this.queuedCommitMatchesCorrelation(tx, correlationId)) {
1572
+ this.completeQueuedCommit(tx, syncId);
1573
+ }
1574
+ }
1575
+ }
1576
+
1577
+ // Schedule the retry-and-reconciliation wait for a transaction's confirming
1578
+ // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1579
+ private scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
1580
+ this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1581
+ }
1582
+
1583
+ /**
1584
+ * Resolves once the given transaction is confirmed and rejects if it fails.
1585
+ * The confirming delta's timeout is handled by
1586
+ * {@link scheduleDeltaConfirmationTimeout}.
1587
+ */
1588
+ waitForConfirmation(transactionId: string): Promise<void> {
1589
+ return new Promise((resolve, reject) => {
1590
+ // Check if already completed
1591
+ const tx = this.store.get(transactionId);
1592
+ if (tx?.status === 'completed') {
1593
+ resolve();
1594
+ return;
1595
+ }
1596
+ const lagError = this.replicationLagErrors.get(transactionId);
1597
+ if (lagError) {
1598
+ reject(lagError);
1599
+ return;
1600
+ }
1601
+
1602
+ const onCompleted = () => {
1603
+ cleanup();
1604
+ resolve();
1605
+ };
1606
+
1607
+ const onFailed = ({ error }: { error: Error }) => {
1608
+ cleanup();
1609
+ reject(error);
1610
+ };
1611
+
1612
+ const onLagged = ({ error }: { error: Error }) => {
1613
+ cleanup();
1614
+ reject(error);
1615
+ };
1616
+
1617
+ const cleanup = () => {
1618
+ this.off(`transaction:completed:${transactionId}`, onCompleted);
1619
+ this.off(`transaction:failed:${transactionId}`, onFailed);
1620
+ this.off(`transaction:confirmation_lagged:${transactionId}`, onLagged);
1621
+ };
1622
+
1623
+ // Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
1624
+ this.on(`transaction:completed:${transactionId}`, onCompleted);
1625
+ this.on(`transaction:failed:${transactionId}`, onFailed);
1626
+ this.on(`transaction:confirmation_lagged:${transactionId}`, onLagged);
1627
+ });
1628
+ }
1629
+
1630
+ // Reports whether a client mutation id is known to this queue, which helps
1631
+ // identify a delta as this client's own echo.
1632
+ hasClientMutationId(id: string): boolean {
1633
+ return !!this.store.get(id) || this.commitStore.has(id);
1634
+ }
1635
+
1636
+ /** Enqueues a pre-built atomic commit through the commit API coordinator. */
1637
+ async enqueueCommit(
1638
+ clientTxId: string,
1639
+ operations: CommitTransaction['operations'],
1640
+ options: { reads?: ReadDependency[] | null; track?: TrackDependency[] | null } = {},
1641
+ ): Promise<void> {
1642
+ return enqueueCommit(this.commitApiContext, clientTxId, operations, options);
1643
+ }
1644
+
1645
+ private async processCommitLane(): Promise<void> {
1646
+ await processCommitLane(this.commitLaneContext);
1647
+ }
1648
+
1649
+ waitForCommitReceipt(
1650
+ clientTxId: string,
1651
+ ): Promise<{
1652
+ lastSyncId: number;
1653
+ notifications?: StaleNotification[];
1654
+ missingIds?: string[];
1655
+ }> {
1656
+ return waitForCommitReceipt(this.commitReceiptContext, clientTxId);
1657
+ }
1658
+
1659
+ private isReorderPayload(data: MutationInput | undefined): boolean {
1660
+ if (!data || typeof data !== 'object') return false;
1661
+ return 'order' in data || 'orderKey' in data || 'position' in data;
1662
+ }
1663
+
1664
+ private isPermanentError(error: Error): boolean {
1665
+ return classifyPermanentError(error);
1666
+ }
1667
+
1668
+ private isDefinitiveRejection(error: Error): boolean {
1669
+ return classifyDefinitiveRejection(error);
1670
+ }
1671
+
1672
+ private async handleFailure(transaction: QueuedMutation, error: Error): Promise<void> {
1673
+ await handleFailure(this.failureHandlingContext, transaction, error);
1674
+ }
1675
+
1676
+ async handleConflict(transaction: QueuedMutation, serverData: MutationInput): Promise<void> {
1677
+ await resolveConflict(this.conflictPolicyContext, transaction, serverData);
1678
+ }
1679
+
1680
+ private applyOptimisticCreate(model: LocalModel, transaction: QueuedMutation): void {
1681
+ this.localMutationPort.applyCreate(model, transaction);
1682
+ }
1683
+
1684
+ private applyOptimisticUpdate(model: LocalModel, transaction: QueuedMutation): void {
1685
+ this.localMutationPort.applyUpdate(model, transaction);
1686
+ }
1687
+
1688
+ private applyOptimisticDelete(model: LocalModel, transaction: QueuedMutation): void {
1689
+ this.localMutationPort.applyDelete(model, transaction);
1690
+ }
1691
+
1692
+ private async rollbackOptimistic(
1693
+ transaction: QueuedMutation,
1694
+ reason?: string,
1695
+ error?: Error
1696
+ ): Promise<void> {
1697
+ await this.localMutationPort.rollback(transaction, reason, error);
1698
+ }
1699
+
1700
+ private deserializeLegacyPendingMutation(row: object, fallbackMutationId?: string): QueuedMutation | null {
1701
+ return deserializeLegacyPendingMutation(this.persistenceContext, row, fallbackMutationId);
1702
+ }
1703
+
1704
+ async loadPersistedTransactions(persistence: MutationPersistencePort, sealedMutationIds: ReadonlySet<string> = new Set()): Promise<void> {
1705
+ await loadPersistedTransactions(this.persistenceContext, persistence, sealedMutationIds);
1706
+ }
1707
+
1708
+ async restoreDurableCommits(): Promise<Set<string>> {
1709
+ return restoreDurableCommitsExternal(this.durableCommitRestoreContext);
1710
+ }
1711
+
1712
+ private deserializeTransaction(data: unknown): QueuedMutation | null {
1713
+ if (isNonReplayablePersistedRow(data)) return null;
1714
+
1715
+ const transaction = deserializePersistedTransaction(data, this.runtime);
1716
+ if (!transaction) {
1717
+ const rowId =
1718
+ typeof data === 'object' && data !== null && typeof (data as { id?: unknown }).id === 'string'
1719
+ ? (data as { id: string }).id
1720
+ : undefined;
1721
+ this.runtime.logger.debug('[MutationQueue] Dropping malformed persisted transaction', {
1722
+ rowId,
1723
+ });
1724
+ this.runtime.observability.captureMutationFailure({
1725
+ context: 'deserialize-persisted-transaction',
1726
+ error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
1727
+ });
1728
+ return null;
1729
+ }
1730
+ return transaction;
1731
+ }
1732
+
1733
+ cancelTransactionsForModel(modelId: string, transactionType?: string): QueuedMutation[] {
1734
+ const cancelledTransactions: QueuedMutation[] = [];
1735
+
1736
+ const allTransactions = [
1737
+ ...this.store.getByStatus('pending'),
1738
+ ...this.store.getByStatus('executing'),
1739
+ ];
1740
+
1741
+ for (const transaction of allTransactions) {
1742
+ if (transaction.modelId === modelId) {
1743
+ if (!transactionType || transaction.type === transactionType) {
1744
+ cancelledTransactions.push(transaction);
1745
+ this.store.updateStatus(transaction.id, 'rolled_back');
1746
+ // Sync caller: a rejected rollback (throwing optimistic:rollback
1747
+ // listener) must surface, not vanish — the status flip above is
1748
+ // already committed either way.
1749
+ void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error: unknown) => {
1750
+ this.runtime.observability.captureMutationFailure({
1751
+ context: 'rollback-model-cancelled',
1752
+ error: error instanceof Error ? error : String(error),
1753
+ });
1754
+ });
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ return cancelledTransactions;
1760
+ }
1761
+
1762
+ /**
1763
+ * Cancels pending transactions for child rows that reference a deleted parent,
1764
+ * used to cascade a parent deletion. The caller supplies the foreign-key
1765
+ * relationship; this method performs the cancellation.
1766
+ *
1767
+ * @param childModelName - The child model type (for example 'Block').
1768
+ * @param foreignKey - The foreign-key property name (for example 'sectionId').
1769
+ * @param parentId - The deleted parent's id.
1770
+ * @returns The number of transactions cancelled.
1771
+ */
1772
+ cancelTransactionsByForeignKey(
1773
+ childModelName: string,
1774
+ foreignKey: string,
1775
+ parentId: string
1776
+ ): number {
1777
+ let cancelled = 0;
1778
+
1779
+ const allTransactions = [
1780
+ ...this.store.getByStatus('pending'),
1781
+ ...this.store.getByStatus('executing'),
1782
+ ...this.store.getByStatus('awaiting_delta'),
1783
+ ];
1784
+
1785
+ for (const transaction of allTransactions) {
1786
+ if (transaction.modelName === childModelName) {
1787
+ // Check if this transaction's data contains the parent FK
1788
+ const fkValue = transaction.data?.[foreignKey];
1789
+ if (fkValue === parentId) {
1790
+ this.store.updateStatus(transaction.id, 'rolled_back');
1791
+ void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch(
1792
+ (error: unknown) => {
1793
+ this.runtime.observability.captureMutationFailure({
1794
+ context: 'rollback-cascade-parent-deleted',
1795
+ error: error instanceof Error ? error : String(error),
1796
+ });
1797
+ }
1798
+ );
1799
+ cancelled++;
1800
+
1801
+ this.runtime.logger.debug('[MutationQueue] Cascade cancelled orphaned transaction', {
1802
+ txId: transaction.id.slice(0, 12),
1803
+ model: childModelName,
1804
+ foreignKey,
1805
+ parentId: parentId.slice(0, 12),
1806
+ });
1807
+ }
1808
+ }
1809
+ }
1810
+
1811
+ return cancelled;
1812
+ }
1813
+
1814
+ /**
1815
+ * Returns the number of transactions still pending or executing.
1816
+ */
1817
+ getOutstandingTransactionCount(): number {
1818
+ return this.deferredMutations.length +
1819
+ this.store.getByStatus('pending').length +
1820
+ this.store.getByStatus('executing').length;
1821
+ }
1822
+
1823
+ getOutstandingTransactions(): readonly QueuedMutation[] {
1824
+ return [
1825
+ ...this.store.getByStatus('pending'),
1826
+ ...this.store.getByStatus('executing'),
1827
+ ];
1828
+ }
1829
+
1830
+ /** Generates a unique local transaction id. */
1831
+ private generateId(): string {
1832
+ return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1833
+ }
1834
+
1835
+ private mergeData(
1836
+ local: MutationInput | undefined,
1837
+ remote: MutationInput | undefined
1838
+ ): MutationInput {
1839
+ return { ...(remote || {}), ...(local || {}) };
1840
+ }
1841
+
1842
+ private extractCreateData(model: LocalModel): MutationInput {
1843
+ return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false }, this.runtime);
1844
+ }
1845
+
1846
+ private mapChangesToInput(modelName: string, changes: Record<string, unknown>): MutationInput {
1847
+ return projectCommitPayload(modelName, changes, { dropUndefined: true }, this.runtime);
1848
+ }
1849
+
1850
+ private extractUpdateData(model: LocalModel): MutationInput {
1851
+ return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true }, this.runtime);
1852
+ }
1853
+
1854
+ // Derive previous values for changed fields to support accurate rollback.
1855
+ // Model-specific special cases do not belong here; a model that needs to
1856
+ // surface previous state beyond `modifiedProperties` should expose a typed
1857
+ // `getPreviousData()` accessor for this method to call.
1858
+ private extractPreviousData(model: LocalModel, updateInput?: MutationInput): MutationInput {
1859
+ // When the update's written keys are known, capture a before-image for
1860
+ // exactly those keys, so the recorded undo inverse reverts them and nothing
1861
+ // else — a full-row inverse would clobber concurrent edits to unrelated
1862
+ // fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
1863
+ // any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
1864
+ // inverse rather than inventing one. With no `updateInput` (a full extract)
1865
+ // it falls back to every tracked field. `Model.capturePreviousValues` is the
1866
+ // single before-image source, shared with
1867
+ // `RecordingMutation.snapshotFields`.
1868
+ const keys = updateInput
1869
+ ? Object.keys(updateInput)
1870
+ : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
1871
+ return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
1872
+ }
1873
+
1874
+ /** Returns a snapshot of queue counts and the current configuration. */
1875
+ getStats() {
1876
+ return {
1877
+ pending: this.store.getByStatus('pending').length,
1878
+ executing: this.store.getByStatus('executing').length,
1879
+ completed: this.store.getByStatus('completed').length,
1880
+ failed: this.store.getByStatus('failed').length,
1881
+ optimistic: this.localMutationPort.updates.size,
1882
+ totalTransactions: this.store.getAll().length,
1883
+ batchIndex: this.batchIndex,
1884
+ config: { ...this.config },
1885
+ };
1886
+ }
1887
+
1888
+ /**
1889
+ * Returns detailed internal state — pending, executing, and awaiting-delta
1890
+ * transactions — to help diagnose delta-confirmation issues.
1891
+ */
1892
+ getDebugInfo() {
1893
+ const awaitingDelta = this.store.getByStatus('awaiting_delta');
1894
+ return {
1895
+ lastSeenSyncId: this.lastSeenSyncId,
1896
+ awaitingDeltaCount: awaitingDelta.length,
1897
+ awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
1898
+ id: tx.id.slice(0, 8),
1899
+ type: tx.type,
1900
+ modelName: tx.modelName,
1901
+ modelId: tx.modelId.slice(0, 8),
1902
+ syncIdNeeded: tx.syncIdNeededForCompletion,
1903
+ createdAt: tx.createdAt,
1904
+ age: Date.now() - tx.createdAt,
1905
+ })),
1906
+ pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
1907
+ id: tx.id.slice(0, 8),
1908
+ type: tx.type,
1909
+ modelName: tx.modelName,
1910
+ modelId: tx.modelId.slice(0, 8),
1911
+ })),
1912
+ executingTransactions: this.store.getByStatus('executing').map((tx) => ({
1913
+ id: tx.id.slice(0, 8),
1914
+ type: tx.type,
1915
+ modelName: tx.modelName,
1916
+ modelId: tx.modelId.slice(0, 8),
1917
+ })),
1918
+ };
1919
+ }
1920
+
1921
+ /** Merges the given options into the queue's configuration. */
1922
+ setConfig(config: Partial<MutationQueueConfig>): void {
1923
+ this.config = { ...this.config, ...config };
1924
+ }
1925
+
1926
+ /**
1927
+ * Re-emits an incoming sync delta on the `sync:delta` event for the store to
1928
+ * apply. Because rows use stable ids, no id reconciliation is needed here.
1929
+ */
1930
+ handleSyncDelta(delta: { id: string; modelName: string; action: string; data: any }): boolean {
1931
+ // Row ids are stable, so no reconciliation is needed; re-emit the delta for
1932
+ // the store to apply directly.
1933
+ this.emit('sync:delta', {
1934
+ id: delta.id,
1935
+ modelName: delta.modelName,
1936
+ action: delta.action,
1937
+ data: delta.data,
1938
+ });
1939
+
1940
+ return true;
1941
+ }
1942
+
1943
+ /**
1944
+ * Releases the queue's resources: rolls back outstanding optimistic updates,
1945
+ * clears all timers and stored transactions, and removes event listeners.
1946
+ */
1947
+ dispose(): void {
1948
+ // Cancel all active optimistic updates
1949
+ for (const [, optimistic] of this.localMutationPort.updates) {
1950
+ this.emit('optimistic:rollback', {
1951
+ model: optimistic.model,
1952
+ previousState: optimistic.previousState,
1953
+ transaction: optimistic.transaction,
1954
+ reason: 'dispose',
1955
+ });
1956
+ }
1957
+
1958
+ // Clear processing
1959
+ if (this.processTimer) {
1960
+ clearTimeout(this.processTimer);
1961
+ }
1962
+
1963
+ // Clear every armed delta-confirmation timer (one per in-flight tx,
1964
+ // 30–120s each) — a disposed queue must not keep the process alive or
1965
+ // fire confirmation callbacks against the cleared store below.
1966
+ this.deltaConfirmation.dispose();
1967
+ for (const timeout of this.replicationLagTimeouts.values()) {
1968
+ clearTimeout(timeout);
1969
+ }
1970
+ this.replicationLagTimeouts.clear();
1971
+ this.replicationLagErrors.clear();
1972
+
1973
+ // Clear the offline-grace timer armed by setConnectionState('disconnected').
1974
+ if (this.commitOfflineGraceTimer !== null) {
1975
+ clearTimeout(this.commitOfflineGraceTimer);
1976
+ this.commitOfflineGraceTimer = null;
1977
+ }
1978
+
1979
+ // Clear store
1980
+ this.store.clear();
1981
+ this.localMutationPort.updates.clear();
1982
+ this.executionQueue = [];
1983
+ this.createdTransactions = [];
1984
+ this.deferredDeletesByCreate.clear();
1985
+ this.recentDeltaCorrelations.clear();
1986
+ this.commitLane = [];
1987
+ this.commitStore.clear();
1988
+ this.commitNotifications.clear();
1989
+ this.commitMissingIds.clear();
1990
+
1991
+ // Clear event listeners
1992
+ this.removeAllListeners();
1993
+
1994
+ // Reset state
1995
+ this.isProcessing = false;
1996
+ this.batchIndex = 0;
1997
+ }
1998
+ }