@abloatai/ablo 0.36.0 → 0.37.1

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 (597) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +55 -2014
  3. package/NOTICE +2 -2
  4. package/README.md +45 -63
  5. package/assets/banner.png +0 -0
  6. package/dist/auth.d.ts +2 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +2 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/client.d.ts +3 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +3 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/coordination.d.ts +2 -0
  15. package/dist/coordination.d.ts.map +1 -0
  16. package/dist/coordination.js +2 -0
  17. package/dist/coordination.js.map +1 -0
  18. package/dist/index.d.ts +3 -114
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -163
  21. package/dist/index.js.map +1 -0
  22. package/dist/react.d.ts +4 -0
  23. package/dist/react.d.ts.map +1 -0
  24. package/dist/react.js +3 -0
  25. package/dist/react.js.map +1 -0
  26. package/dist/schema.d.ts +2 -0
  27. package/dist/schema.d.ts.map +1 -0
  28. package/dist/schema.js +2 -0
  29. package/dist/schema.js.map +1 -0
  30. package/dist/server.d.ts +2 -0
  31. package/dist/server.d.ts.map +1 -0
  32. package/dist/server.js +2 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/source-conformance.d.ts +2 -0
  35. package/dist/source-conformance.d.ts.map +1 -0
  36. package/dist/source-conformance.js +2 -0
  37. package/dist/source-conformance.js.map +1 -0
  38. package/dist/source-drizzle.d.ts +2 -0
  39. package/dist/source-drizzle.d.ts.map +1 -0
  40. package/dist/source-drizzle.js +2 -0
  41. package/dist/source-drizzle.js.map +1 -0
  42. package/dist/source-kysely.d.ts +2 -0
  43. package/dist/source-kysely.d.ts.map +1 -0
  44. package/dist/source-kysely.js +2 -0
  45. package/dist/source-kysely.js.map +1 -0
  46. package/dist/source-next.d.ts +2 -0
  47. package/dist/source-next.d.ts.map +1 -0
  48. package/dist/source-next.js +2 -0
  49. package/dist/source-next.js.map +1 -0
  50. package/dist/source.d.ts +2 -0
  51. package/dist/source.d.ts.map +1 -0
  52. package/dist/source.js +2 -0
  53. package/dist/source.js.map +1 -0
  54. package/dist/wire.d.ts +2 -0
  55. package/dist/wire.d.ts.map +1 -0
  56. package/dist/wire.js +2 -0
  57. package/dist/wire.js.map +1 -0
  58. package/docs/agents.md +1 -1
  59. package/docs/api-keys.md +7 -6
  60. package/docs/api.md +10 -10
  61. package/docs/client-behavior.md +5 -5
  62. package/docs/coordination.md +52 -62
  63. package/docs/data-sources.md +1 -1
  64. package/docs/examples/agent-human.md +4 -4
  65. package/docs/examples/ai-sdk-tool.md +1 -1
  66. package/docs/examples/existing-python-backend.md +15 -4
  67. package/docs/examples/nextjs.md +27 -6
  68. package/docs/examples/server-agent.md +2 -2
  69. package/docs/how-it-works.md +4 -4
  70. package/docs/identity.md +2 -1
  71. package/docs/integration-guide.md +24 -13
  72. package/docs/internal/README.md +18 -0
  73. package/docs/internal/agent-fleet-coordination-design.md +171 -0
  74. package/docs/internal/agent-orchestration.md +58 -0
  75. package/docs/internal/commit-identifiers.md +91 -0
  76. package/docs/internal/concurrency-open-decisions.md +37 -0
  77. package/docs/internal/data-source-reverse-channel.md +150 -0
  78. package/docs/internal/per-field-conflict-detection.md +165 -0
  79. package/docs/internal/postgres-replication.md +64 -0
  80. package/docs/internal/serializable-schema.md +119 -0
  81. package/docs/internal/structure.md +32 -0
  82. package/docs/mcp.md +2 -2
  83. package/docs/migration.md +3 -3
  84. package/docs/quickstart.md +2 -2
  85. package/docs/react.md +5 -5
  86. package/docs/schema-contract.md +3 -3
  87. package/docs/sessions.md +91 -37
  88. package/examples/README.md +2 -2
  89. package/examples/data-source/README.md +1 -1
  90. package/examples/data-source/ablo-driver.ts +1 -1
  91. package/examples/data-source/customer-server.ts +1 -1
  92. package/examples/data-source/run.ts +1 -1
  93. package/examples/data-source/schema.ts +1 -1
  94. package/examples/quickstart.ts +2 -2
  95. package/llms.txt +8 -8
  96. package/package.json +63 -166
  97. package/bin/ablo.cjs +0 -39
  98. package/dist/BaseSyncedStore.d.ts +0 -843
  99. package/dist/BaseSyncedStore.js +0 -1971
  100. package/dist/Database.d.ts +0 -323
  101. package/dist/Database.js +0 -1502
  102. package/dist/InstanceCache.d.ts +0 -237
  103. package/dist/InstanceCache.js +0 -1166
  104. package/dist/LazyReferenceCollection.d.ts +0 -177
  105. package/dist/LazyReferenceCollection.js +0 -461
  106. package/dist/Model.d.ts +0 -454
  107. package/dist/Model.js +0 -919
  108. package/dist/ModelRegistry.d.ts +0 -225
  109. package/dist/ModelRegistry.js +0 -539
  110. package/dist/NetworkMonitor.d.ts +0 -28
  111. package/dist/NetworkMonitor.js +0 -79
  112. package/dist/RuntimeContext.d.ts +0 -52
  113. package/dist/RuntimeContext.js +0 -80
  114. package/dist/SyncClient.d.ts +0 -541
  115. package/dist/SyncClient.js +0 -2202
  116. package/dist/adapters/alwaysOnline.d.ts +0 -14
  117. package/dist/adapters/alwaysOnline.js +0 -17
  118. package/dist/adapters/inMemoryStorage.d.ts +0 -31
  119. package/dist/adapters/inMemoryStorage.js +0 -110
  120. package/dist/ai-sdk/coordinatedTool.d.ts +0 -120
  121. package/dist/ai-sdk/coordinatedTool.js +0 -134
  122. package/dist/ai-sdk/coordinationContext.d.ts +0 -46
  123. package/dist/ai-sdk/coordinationContext.js +0 -106
  124. package/dist/ai-sdk/index.d.ts +0 -121
  125. package/dist/ai-sdk/index.js +0 -121
  126. package/dist/ai-sdk/wrap.d.ts +0 -65
  127. package/dist/ai-sdk/wrap.js +0 -39
  128. package/dist/auth/index.d.ts +0 -1
  129. package/dist/auth/index.js +0 -8
  130. package/dist/batching/index.d.ts +0 -55
  131. package/dist/batching/index.js +0 -147
  132. package/dist/client/Ablo.d.ts +0 -231
  133. package/dist/client/Ablo.js +0 -160
  134. package/dist/client/abloClient.d.ts +0 -309
  135. package/dist/client/abloClient.js +0 -13
  136. package/dist/client/clientPrelude.d.ts +0 -52
  137. package/dist/client/clientPrelude.js +0 -60
  138. package/dist/client/consoleLogger.d.ts +0 -35
  139. package/dist/client/consoleLogger.js +0 -44
  140. package/dist/client/coreClient.d.ts +0 -60
  141. package/dist/client/coreClient.js +0 -118
  142. package/dist/client/createInternalComponents.d.ts +0 -50
  143. package/dist/client/createInternalComponents.js +0 -98
  144. package/dist/client/createModelProxy.d.ts +0 -248
  145. package/dist/client/createModelProxy.js +0 -884
  146. package/dist/client/humans.d.ts +0 -69
  147. package/dist/client/humans.js +0 -78
  148. package/dist/client/modelRegistration.d.ts +0 -10
  149. package/dist/client/modelRegistration.js +0 -312
  150. package/dist/client/options.d.ts +0 -461
  151. package/dist/client/options.js +0 -7
  152. package/dist/client/reactiveEngine.d.ts +0 -53
  153. package/dist/client/reactiveEngine.js +0 -688
  154. package/dist/client/resourceTypes.d.ts +0 -12
  155. package/dist/client/resourceTypes.js +0 -10
  156. package/dist/client/schemaConfig.d.ts +0 -44
  157. package/dist/client/schemaConfig.js +0 -185
  158. package/dist/client/storeCluster.d.ts +0 -47
  159. package/dist/client/storeCluster.js +0 -118
  160. package/dist/client/storeLifecycle.d.ts +0 -61
  161. package/dist/client/storeLifecycle.js +0 -231
  162. package/dist/client/validateAbloOptions.d.ts +0 -42
  163. package/dist/client/validateAbloOptions.js +0 -43
  164. package/dist/client/wsMutationExecutor.d.ts +0 -27
  165. package/dist/client/wsMutationExecutor.js +0 -72
  166. package/dist/context.d.ts +0 -42
  167. package/dist/context.js +0 -81
  168. package/dist/coordination/ClaimLog.d.ts +0 -26
  169. package/dist/coordination/ClaimLog.js +0 -32
  170. package/dist/coordination/index.d.ts +0 -1
  171. package/dist/coordination/index.js +0 -8
  172. package/dist/core/index.d.ts +0 -33
  173. package/dist/core/index.js +0 -48
  174. package/dist/docs/catalog.d.ts +0 -72
  175. package/dist/docs/catalog.js +0 -230
  176. package/dist/docs/index.d.ts +0 -10
  177. package/dist/docs/index.js +0 -10
  178. package/dist/environment.d.ts +0 -1
  179. package/dist/environment.js +0 -8
  180. package/dist/interfaces/index.d.ts +0 -311
  181. package/dist/interfaces/index.js +0 -9
  182. package/dist/keys/index.d.ts +0 -1
  183. package/dist/keys/index.js +0 -8
  184. package/dist/mutators/RecordingMutation.d.ts +0 -36
  185. package/dist/mutators/RecordingMutation.js +0 -182
  186. package/dist/mutators/Transaction.d.ts +0 -40
  187. package/dist/mutators/Transaction.js +0 -58
  188. package/dist/mutators/UndoManager.d.ts +0 -258
  189. package/dist/mutators/UndoManager.js +0 -658
  190. package/dist/mutators/defineMutators.d.ts +0 -60
  191. package/dist/mutators/defineMutators.js +0 -18
  192. package/dist/mutators/inverseOp.d.ts +0 -126
  193. package/dist/mutators/inverseOp.js +0 -71
  194. package/dist/mutators/mutateActions.d.ts +0 -45
  195. package/dist/mutators/mutateActions.js +0 -105
  196. package/dist/mutators/readerActions.d.ts +0 -33
  197. package/dist/mutators/readerActions.js +0 -57
  198. package/dist/mutators/undoApply.d.ts +0 -51
  199. package/dist/mutators/undoApply.js +0 -117
  200. package/dist/policy/index.d.ts +0 -21
  201. package/dist/policy/index.js +0 -20
  202. package/dist/query/QueryProcessor.d.ts +0 -75
  203. package/dist/query/QueryProcessor.js +0 -255
  204. package/dist/query/client.d.ts +0 -64
  205. package/dist/query/client.js +0 -138
  206. package/dist/query/types.d.ts +0 -85
  207. package/dist/query/types.js +0 -16
  208. package/dist/react/AbloProvider.d.ts +0 -242
  209. package/dist/react/AbloProvider.js +0 -458
  210. package/dist/react/ClientSideSuspense.d.ts +0 -36
  211. package/dist/react/ClientSideSuspense.js +0 -17
  212. package/dist/react/DefaultFallback.d.ts +0 -24
  213. package/dist/react/DefaultFallback.js +0 -43
  214. package/dist/react/context.d.ts +0 -55
  215. package/dist/react/context.js +0 -29
  216. package/dist/react/createAbloReact.d.ts +0 -56
  217. package/dist/react/createAbloReact.js +0 -51
  218. package/dist/react/index.d.ts +0 -62
  219. package/dist/react/index.js +0 -69
  220. package/dist/react/internalContext.d.ts +0 -33
  221. package/dist/react/internalContext.js +0 -3
  222. package/dist/react/useAblo.d.ts +0 -82
  223. package/dist/react/useAblo.js +0 -120
  224. package/dist/react/useCurrentUserId.d.ts +0 -22
  225. package/dist/react/useCurrentUserId.js +0 -34
  226. package/dist/react/useErrorListener.d.ts +0 -20
  227. package/dist/react/useErrorListener.js +0 -38
  228. package/dist/react/useMutationFailureListener.d.ts +0 -26
  229. package/dist/react/useMutationFailureListener.js +0 -38
  230. package/dist/react/useMutators.d.ts +0 -56
  231. package/dist/react/useMutators.js +0 -84
  232. package/dist/react/useReactive.d.ts +0 -35
  233. package/dist/react/useReactive.js +0 -123
  234. package/dist/react/useSyncStatus.d.ts +0 -59
  235. package/dist/react/useSyncStatus.js +0 -76
  236. package/dist/react/useUndoScope.d.ts +0 -34
  237. package/dist/react/useUndoScope.js +0 -81
  238. package/dist/schema/coordination.d.ts +0 -112
  239. package/dist/schema/coordination.js +0 -133
  240. package/dist/schema/ddl.d.ts +0 -97
  241. package/dist/schema/ddl.js +0 -491
  242. package/dist/schema/ddlLock.d.ts +0 -35
  243. package/dist/schema/ddlLock.js +0 -46
  244. package/dist/schema/diff.d.ts +0 -225
  245. package/dist/schema/diff.js +0 -289
  246. package/dist/schema/generate.d.ts +0 -19
  247. package/dist/schema/generate.js +0 -86
  248. package/dist/schema/index.d.ts +0 -42
  249. package/dist/schema/index.js +0 -80
  250. package/dist/schema/queries.d.ts +0 -201
  251. package/dist/schema/queries.js +0 -144
  252. package/dist/schema/select.d.ts +0 -40
  253. package/dist/schema/select.js +0 -90
  254. package/dist/schema/serialize.d.ts +0 -115
  255. package/dist/schema/serialize.js +0 -265
  256. package/dist/schema/sugar.d.ts +0 -109
  257. package/dist/schema/sugar.js +0 -83
  258. package/dist/schema/syncDeltaRow.d.ts +0 -6
  259. package/dist/schema/syncDeltaRow.js +0 -6
  260. package/dist/server/adapter.d.ts +0 -173
  261. package/dist/server/adapter.js +0 -18
  262. package/dist/server/commit.d.ts +0 -107
  263. package/dist/server/commit.js +0 -1
  264. package/dist/server/index.d.ts +0 -14
  265. package/dist/server/index.js +0 -2
  266. package/dist/server/readConfig.d.ts +0 -80
  267. package/dist/server/readConfig.js +0 -8
  268. package/dist/server/storageMode.d.ts +0 -23
  269. package/dist/server/storageMode.js +0 -17
  270. package/dist/source/adapter.d.ts +0 -83
  271. package/dist/source/adapter.js +0 -24
  272. package/dist/source/adapters/drizzle.d.ts +0 -48
  273. package/dist/source/adapters/drizzle.js +0 -219
  274. package/dist/source/adapters/kysely.d.ts +0 -42
  275. package/dist/source/adapters/kysely.js +0 -205
  276. package/dist/source/adapters/kyselyMutationCore.d.ts +0 -76
  277. package/dist/source/adapters/kyselyMutationCore.js +0 -125
  278. package/dist/source/adapters/memory.d.ts +0 -13
  279. package/dist/source/adapters/memory.js +0 -130
  280. package/dist/source/adapters/prisma.d.ts +0 -63
  281. package/dist/source/adapters/prisma.js +0 -202
  282. package/dist/source/conformance.d.ts +0 -37
  283. package/dist/source/conformance.js +0 -215
  284. package/dist/source/connector.d.ts +0 -95
  285. package/dist/source/connector.js +0 -266
  286. package/dist/source/connectorProtocol.d.ts +0 -154
  287. package/dist/source/connectorProtocol.js +0 -163
  288. package/dist/source/contract.d.ts +0 -195
  289. package/dist/source/contract.js +0 -164
  290. package/dist/source/factory.d.ts +0 -92
  291. package/dist/source/factory.js +0 -286
  292. package/dist/source/idempotency.d.ts +0 -61
  293. package/dist/source/idempotency.js +0 -144
  294. package/dist/source/index.d.ts +0 -23
  295. package/dist/source/index.js +0 -30
  296. package/dist/source/migrations.d.ts +0 -21
  297. package/dist/source/migrations.js +0 -103
  298. package/dist/source/next.d.ts +0 -32
  299. package/dist/source/next.js +0 -25
  300. package/dist/source/pushQueue.d.ts +0 -134
  301. package/dist/source/pushQueue.js +0 -256
  302. package/dist/source/signing.d.ts +0 -92
  303. package/dist/source/signing.js +0 -162
  304. package/dist/source/types.d.ts +0 -401
  305. package/dist/source/types.js +0 -59
  306. package/dist/storeContract.d.ts +0 -145
  307. package/dist/storeContract.js +0 -12
  308. package/dist/stores/DatabaseManager.d.ts +0 -107
  309. package/dist/stores/DatabaseManager.js +0 -388
  310. package/dist/stores/ObjectStore.d.ts +0 -115
  311. package/dist/stores/ObjectStore.js +0 -393
  312. package/dist/stores/ObjectStoreContract.d.ts +0 -38
  313. package/dist/stores/ObjectStoreContract.js +0 -1
  314. package/dist/stores/StoreManager.d.ts +0 -114
  315. package/dist/stores/StoreManager.js +0 -304
  316. package/dist/stores/SyncActionStore.d.ts +0 -99
  317. package/dist/stores/SyncActionStore.js +0 -506
  318. package/dist/stores/openIDBWithTimeout.d.ts +0 -65
  319. package/dist/stores/openIDBWithTimeout.js +0 -153
  320. package/dist/stores/syncAction.d.ts +0 -26
  321. package/dist/stores/syncAction.js +0 -16
  322. package/dist/surface.d.ts +0 -36
  323. package/dist/surface.js +0 -75
  324. package/dist/sync/BootstrapFetcher.d.ts +0 -284
  325. package/dist/sync/BootstrapFetcher.js +0 -964
  326. package/dist/sync/ConnectionManager.d.ts +0 -8
  327. package/dist/sync/ConnectionManager.js +0 -8
  328. package/dist/sync/OnDemandLoader.d.ts +0 -231
  329. package/dist/sync/OnDemandLoader.js +0 -743
  330. package/dist/sync/SubscriptionManager.d.ts +0 -159
  331. package/dist/sync/SubscriptionManager.js +0 -243
  332. package/dist/sync/SyncWebSocket.d.ts +0 -173
  333. package/dist/sync/SyncWebSocket.js +0 -438
  334. package/dist/sync/awaitClaimGrant.d.ts +0 -6
  335. package/dist/sync/awaitClaimGrant.js +0 -6
  336. package/dist/sync/bootstrapApply.d.ts +0 -73
  337. package/dist/sync/bootstrapApply.js +0 -73
  338. package/dist/sync/commitFrames.d.ts +0 -8
  339. package/dist/sync/commitFrames.js +0 -8
  340. package/dist/sync/contextPorts.d.ts +0 -18
  341. package/dist/sync/contextPorts.js +0 -31
  342. package/dist/sync/createClaimStream.d.ts +0 -7
  343. package/dist/sync/createClaimStream.js +0 -7
  344. package/dist/sync/createPresenceStream.d.ts +0 -69
  345. package/dist/sync/createPresenceStream.js +0 -200
  346. package/dist/sync/createSnapshot.d.ts +0 -29
  347. package/dist/sync/createSnapshot.js +0 -118
  348. package/dist/sync/credentialLifecycle.d.ts +0 -7
  349. package/dist/sync/credentialLifecycle.js +0 -7
  350. package/dist/sync/deltaPipeline.d.ts +0 -114
  351. package/dist/sync/deltaPipeline.js +0 -278
  352. package/dist/sync/groupChange.d.ts +0 -116
  353. package/dist/sync/groupChange.js +0 -244
  354. package/dist/sync/participants.d.ts +0 -132
  355. package/dist/sync/participants.js +0 -346
  356. package/dist/sync/persistedPrefix.d.ts +0 -12
  357. package/dist/sync/persistedPrefix.js +0 -22
  358. package/dist/sync/schemaDrift.d.ts +0 -55
  359. package/dist/sync/schemaDrift.js +0 -53
  360. package/dist/sync/schemas.d.ts +0 -71
  361. package/dist/sync/schemas.js +0 -94
  362. package/dist/sync/syncCursor.d.ts +0 -40
  363. package/dist/sync/syncCursor.js +0 -55
  364. package/dist/sync/syncPlan.d.ts +0 -54
  365. package/dist/sync/syncPlan.js +0 -50
  366. package/dist/sync/wsFrameHandlers.d.ts +0 -8
  367. package/dist/sync/wsFrameHandlers.js +0 -8
  368. package/dist/syncLog/contract.d.ts +0 -20
  369. package/dist/syncLog/contract.js +0 -19
  370. package/dist/syncLog/index.d.ts +0 -1
  371. package/dist/syncLog/index.js +0 -1
  372. package/dist/transaction/ablo.d.ts +0 -88
  373. package/dist/transaction/ablo.js +0 -33
  374. package/dist/transaction/auth/apiKey.d.ts +0 -152
  375. package/dist/transaction/auth/apiKey.js +0 -419
  376. package/dist/transaction/auth/bootstrapScope.d.ts +0 -15
  377. package/dist/transaction/auth/bootstrapScope.js +0 -1
  378. package/dist/transaction/auth/capability.d.ts +0 -212
  379. package/dist/transaction/auth/capability.js +0 -224
  380. package/dist/transaction/auth/credentialEndpoint.d.ts +0 -61
  381. package/dist/transaction/auth/credentialEndpoint.js +0 -86
  382. package/dist/transaction/auth/credentialPolicy.d.ts +0 -148
  383. package/dist/transaction/auth/credentialPolicy.js +0 -125
  384. package/dist/transaction/auth/credentialSource.d.ts +0 -30
  385. package/dist/transaction/auth/credentialSource.js +0 -55
  386. package/dist/transaction/auth/hostedEndpoints.d.ts +0 -21
  387. package/dist/transaction/auth/hostedEndpoints.js +0 -21
  388. package/dist/transaction/auth/identity.d.ts +0 -55
  389. package/dist/transaction/auth/identity.js +0 -210
  390. package/dist/transaction/auth/index.d.ts +0 -162
  391. package/dist/transaction/auth/index.js +0 -304
  392. package/dist/transaction/auth/schemas.d.ts +0 -59
  393. package/dist/transaction/auth/schemas.js +0 -85
  394. package/dist/transaction/auth/sessionMint.d.ts +0 -28
  395. package/dist/transaction/auth/sessionMint.js +0 -85
  396. package/dist/transaction/coordination/awaitClaimGrant.d.ts +0 -56
  397. package/dist/transaction/coordination/awaitClaimGrant.js +0 -124
  398. package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +0 -84
  399. package/dist/transaction/coordination/claimHeartbeatLoop.js +0 -108
  400. package/dist/transaction/coordination/claimMeta.d.ts +0 -49
  401. package/dist/transaction/coordination/claimMeta.js +0 -52
  402. package/dist/transaction/coordination/createClaimStream.d.ts +0 -64
  403. package/dist/transaction/coordination/createClaimStream.js +0 -475
  404. package/dist/transaction/coordination/events.d.ts +0 -74
  405. package/dist/transaction/coordination/events.js +0 -7
  406. package/dist/transaction/coordination/index.d.ts +0 -19
  407. package/dist/transaction/coordination/index.js +0 -45
  408. package/dist/transaction/coordination/locator.d.ts +0 -104
  409. package/dist/transaction/coordination/locator.js +0 -102
  410. package/dist/transaction/coordination/schema.d.ts +0 -1536
  411. package/dist/transaction/coordination/schema.js +0 -1177
  412. package/dist/transaction/coordination/targetConflict.d.ts +0 -2
  413. package/dist/transaction/coordination/targetConflict.js +0 -107
  414. package/dist/transaction/coordination/trace.d.ts +0 -78
  415. package/dist/transaction/coordination/trace.js +0 -138
  416. package/dist/transaction/durableWrites.d.ts +0 -62
  417. package/dist/transaction/durableWrites.js +0 -71
  418. package/dist/transaction/environment.d.ts +0 -105
  419. package/dist/transaction/environment.js +0 -108
  420. package/dist/transaction/errorCodes.d.ts +0 -403
  421. package/dist/transaction/errorCodes.js +0 -484
  422. package/dist/transaction/errors.d.ts +0 -428
  423. package/dist/transaction/errors.js +0 -686
  424. package/dist/transaction/footprint.d.ts +0 -111
  425. package/dist/transaction/footprint.js +0 -0
  426. package/dist/transaction/index.d.ts +0 -20
  427. package/dist/transaction/index.js +0 -20
  428. package/dist/transaction/keys/index.d.ts +0 -87
  429. package/dist/transaction/keys/index.js +0 -207
  430. package/dist/transaction/log/syncDeltaRow.d.ts +0 -158
  431. package/dist/transaction/log/syncDeltaRow.js +0 -95
  432. package/dist/transaction/logPosition.d.ts +0 -97
  433. package/dist/transaction/logPosition.js +0 -125
  434. package/dist/transaction/logger.d.ts +0 -16
  435. package/dist/transaction/logger.js +0 -7
  436. package/dist/transaction/observability.d.ts +0 -53
  437. package/dist/transaction/observability.js +0 -19
  438. package/dist/transaction/persistence.d.ts +0 -12
  439. package/dist/transaction/persistence.js +0 -11
  440. package/dist/transaction/plugin.d.ts +0 -285
  441. package/dist/transaction/plugin.js +0 -106
  442. package/dist/transaction/policy/types.d.ts +0 -217
  443. package/dist/transaction/policy/types.js +0 -126
  444. package/dist/transaction/resources/functionalUpdate.d.ts +0 -79
  445. package/dist/transaction/resources/functionalUpdate.js +0 -87
  446. package/dist/transaction/resources/httpResources.d.ts +0 -321
  447. package/dist/transaction/resources/httpResources.js +0 -7
  448. package/dist/transaction/resources/modelOperations.d.ts +0 -427
  449. package/dist/transaction/resources/modelOperations.js +0 -12
  450. package/dist/transaction/resources/mutationOptions.d.ts +0 -66
  451. package/dist/transaction/resources/mutationOptions.js +0 -9
  452. package/dist/transaction/resources/where.d.ts +0 -101
  453. package/dist/transaction/resources/where.js +0 -115
  454. package/dist/transaction/resources/writeOptionsSchema.d.ts +0 -47
  455. package/dist/transaction/resources/writeOptionsSchema.js +0 -73
  456. package/dist/transaction/schema/field.d.ts +0 -120
  457. package/dist/transaction/schema/field.js +0 -265
  458. package/dist/transaction/schema/fieldRef.d.ts +0 -38
  459. package/dist/transaction/schema/fieldRef.js +0 -11
  460. package/dist/transaction/schema/loadStrategy.d.ts +0 -45
  461. package/dist/transaction/schema/loadStrategy.js +0 -46
  462. package/dist/transaction/schema/model.d.ts +0 -379
  463. package/dist/transaction/schema/model.js +0 -123
  464. package/dist/transaction/schema/openapi.d.ts +0 -58
  465. package/dist/transaction/schema/openapi.js +0 -501
  466. package/dist/transaction/schema/relation.d.ts +0 -204
  467. package/dist/transaction/schema/relation.js +0 -104
  468. package/dist/transaction/schema/residency.d.ts +0 -29
  469. package/dist/transaction/schema/residency.js +0 -25
  470. package/dist/transaction/schema/roles.d.ts +0 -249
  471. package/dist/transaction/schema/roles.js +0 -230
  472. package/dist/transaction/schema/schema.d.ts +0 -351
  473. package/dist/transaction/schema/schema.js +0 -325
  474. package/dist/transaction/schema/tenancy.d.ts +0 -139
  475. package/dist/transaction/schema/tenancy.js +0 -190
  476. package/dist/transaction/transactionLayer.d.ts +0 -82
  477. package/dist/transaction/transactionLayer.js +0 -24
  478. package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +0 -143
  479. package/dist/transaction/transactions/settlement/commitEnvelope.js +0 -161
  480. package/dist/transaction/transactions/settlement/httpCommitEnvelope.d.ts +0 -53
  481. package/dist/transaction/transactions/settlement/httpCommitEnvelope.js +0 -207
  482. package/dist/transaction/transactions/settlement/idempotencyKey.d.ts +0 -10
  483. package/dist/transaction/transactions/settlement/idempotencyKey.js +0 -9
  484. package/dist/transaction/transactions/settlement/pendingWrite.d.ts +0 -112
  485. package/dist/transaction/transactions/settlement/pendingWrite.js +0 -20
  486. package/dist/transaction/transport/commitFrames.d.ts +0 -90
  487. package/dist/transaction/transport/commitFrames.js +0 -134
  488. package/dist/transaction/transport/connectionManager.d.ts +0 -215
  489. package/dist/transaction/transport/connectionManager.js +0 -673
  490. package/dist/transaction/transport/credentialLifecycle.d.ts +0 -177
  491. package/dist/transaction/transport/credentialLifecycle.js +0 -324
  492. package/dist/transaction/transport/heartbeat.d.ts +0 -65
  493. package/dist/transaction/transport/heartbeat.js +0 -93
  494. package/dist/transaction/transport/httpClient.d.ts +0 -131
  495. package/dist/transaction/transport/httpClient.js +0 -146
  496. package/dist/transaction/transport/httpOptions.d.ts +0 -33
  497. package/dist/transaction/transport/httpOptions.js +0 -12
  498. package/dist/transaction/transport/httpTransport.d.ts +0 -8
  499. package/dist/transaction/transport/httpTransport.js +0 -1388
  500. package/dist/transaction/transport/networkProbe.d.ts +0 -84
  501. package/dist/transaction/transport/networkProbe.js +0 -207
  502. package/dist/transaction/transport/wsFrameHandlers.d.ts +0 -128
  503. package/dist/transaction/transport/wsFrameHandlers.js +0 -429
  504. package/dist/transaction/transport/wsTransport.d.ts +0 -574
  505. package/dist/transaction/transport/wsTransport.js +0 -1023
  506. package/dist/transaction/types/assertExact.d.ts +0 -17
  507. package/dist/transaction/types/assertExact.js +0 -1
  508. package/dist/transaction/types/global.d.ts +0 -107
  509. package/dist/transaction/types/global.js +0 -40
  510. package/dist/transaction/types/index.d.ts +0 -205
  511. package/dist/transaction/types/index.js +0 -56
  512. package/dist/transaction/types/modelData.d.ts +0 -10
  513. package/dist/transaction/types/modelData.js +0 -9
  514. package/dist/transaction/types/participant.d.ts +0 -20
  515. package/dist/transaction/types/participant.js +0 -10
  516. package/dist/transaction/types/streams.d.ts +0 -550
  517. package/dist/transaction/types/streams.js +0 -11
  518. package/dist/transaction/utils/asyncIterator.d.ts +0 -34
  519. package/dist/transaction/utils/asyncIterator.js +0 -135
  520. package/dist/transaction/utils/duration.d.ts +0 -50
  521. package/dist/transaction/utils/duration.js +0 -77
  522. package/dist/transaction/utils/json.d.ts +0 -57
  523. package/dist/transaction/utils/json.js +0 -276
  524. package/dist/transaction/wire/accountResponses.d.ts +0 -420
  525. package/dist/transaction/wire/accountResponses.js +0 -290
  526. package/dist/transaction/wire/auth.d.ts +0 -56
  527. package/dist/transaction/wire/auth.js +0 -63
  528. package/dist/transaction/wire/bootstrapReason.d.ts +0 -9
  529. package/dist/transaction/wire/bootstrapReason.js +0 -8
  530. package/dist/transaction/wire/claimEvent.d.ts +0 -76
  531. package/dist/transaction/wire/claimEvent.js +0 -73
  532. package/dist/transaction/wire/claims.d.ts +0 -530
  533. package/dist/transaction/wire/claims.js +0 -327
  534. package/dist/transaction/wire/commit.d.ts +0 -603
  535. package/dist/transaction/wire/commit.js +0 -321
  536. package/dist/transaction/wire/delta.d.ts +0 -250
  537. package/dist/transaction/wire/delta.js +0 -147
  538. package/dist/transaction/wire/errorEnvelope.d.ts +0 -72
  539. package/dist/transaction/wire/errorEnvelope.js +0 -123
  540. package/dist/transaction/wire/feedCursor.d.ts +0 -60
  541. package/dist/transaction/wire/feedCursor.js +0 -82
  542. package/dist/transaction/wire/feedEvent.d.ts +0 -204
  543. package/dist/transaction/wire/feedEvent.js +0 -65
  544. package/dist/transaction/wire/frames.d.ts +0 -194
  545. package/dist/transaction/wire/frames.js +0 -50
  546. package/dist/transaction/wire/inboundFrames.d.ts +0 -562
  547. package/dist/transaction/wire/inboundFrames.js +0 -116
  548. package/dist/transaction/wire/index.d.ts +0 -54
  549. package/dist/transaction/wire/index.js +0 -83
  550. package/dist/transaction/wire/listEnvelope.d.ts +0 -37
  551. package/dist/transaction/wire/listEnvelope.js +0 -42
  552. package/dist/transaction/wire/modelMutations.d.ts +0 -31
  553. package/dist/transaction/wire/modelMutations.js +0 -52
  554. package/dist/transaction/wire/modelResponses.d.ts +0 -85
  555. package/dist/transaction/wire/modelResponses.js +0 -43
  556. package/dist/transaction/wire/modelShape.d.ts +0 -78
  557. package/dist/transaction/wire/modelShape.js +0 -74
  558. package/dist/transaction/wire/protocol.d.ts +0 -38
  559. package/dist/transaction/wire/protocol.js +0 -38
  560. package/dist/transaction/wire/protocolVersion.d.ts +0 -73
  561. package/dist/transaction/wire/protocolVersion.js +0 -83
  562. package/dist/transactions/mutations/MutationQueue.d.ts +0 -661
  563. package/dist/transactions/mutations/MutationQueue.js +0 -2807
  564. package/dist/transactions/mutations/MutationStore.d.ts +0 -20
  565. package/dist/transactions/mutations/MutationStore.js +0 -53
  566. package/dist/transactions/mutations/UnconfirmedWrites.d.ts +0 -82
  567. package/dist/transactions/mutations/UnconfirmedWrites.js +0 -104
  568. package/dist/transactions/mutations/coalesceRules.d.ts +0 -58
  569. package/dist/transactions/mutations/coalesceRules.js +0 -140
  570. package/dist/transactions/mutations/commitLatency.d.ts +0 -52
  571. package/dist/transactions/mutations/commitLatency.js +0 -130
  572. package/dist/transactions/mutations/commitOutboxStore.d.ts +0 -28
  573. package/dist/transactions/mutations/commitOutboxStore.js +0 -26
  574. package/dist/transactions/mutations/commitPayload.d.ts +0 -165
  575. package/dist/transactions/mutations/commitPayload.js +0 -152
  576. package/dist/transactions/mutations/deltaConfirmation.d.ts +0 -63
  577. package/dist/transactions/mutations/deltaConfirmation.js +0 -235
  578. package/dist/transactions/mutations/durableWriteStore.d.ts +0 -14
  579. package/dist/transactions/mutations/durableWriteStore.js +0 -12
  580. package/dist/transactions/mutations/optimisticApply.d.ts +0 -49
  581. package/dist/transactions/mutations/optimisticApply.js +0 -65
  582. package/dist/transactions/mutations/replayValidation.d.ts +0 -187
  583. package/dist/transactions/mutations/replayValidation.js +0 -164
  584. package/dist/utils/mobxSetup.d.ts +0 -53
  585. package/dist/utils/mobxSetup.js +0 -330
  586. package/dist/views/QueryView.d.ts +0 -79
  587. package/dist/views/QueryView.js +0 -218
  588. package/dist/views/ViewRegistry.d.ts +0 -20
  589. package/dist/views/ViewRegistry.js +0 -55
  590. package/dist/views/incrementalView.d.ts +0 -45
  591. package/dist/views/incrementalView.js +0 -69
  592. package/dist/webhooks/events.d.ts +0 -43
  593. package/dist/webhooks/events.js +0 -42
  594. package/dist/webhooks/index.d.ts +0 -8
  595. package/dist/webhooks/index.js +0 -8
  596. package/dist/wire/index.d.ts +0 -1
  597. package/dist/wire/index.js +0 -8
@@ -1,2807 +0,0 @@
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
- import { EventEmitter } from 'events';
15
- import { v4 as uuid } from 'uuid';
16
- import { Model } from '../../Model.js';
17
- import { globalRuntime } from '../../context.js';
18
- import { AbloError, AbloConnectionError, AbloIdempotencyError, AbloNotFoundError, AbloValidationError, errorCodeSpec, } from '../../transaction/errors.js';
19
- import { LogPosition } from '../../transaction/logPosition.js';
20
- import { mutationCommitResultSchema, } from '../../transaction/wire/commit.js';
21
- import { projectCommitPayload, computePriorityScore, normalizeModelKey,
22
- // Includes stale guards as well as request identity/audit barriers.
23
- hasCommitCoalescingBarrier, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
24
- import { MutationStore } from './MutationStore.js';
25
- import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
26
- import { DeltaConfirmationTracker } from './deltaConfirmation.js';
27
- import { deserializePersistedTransaction, isNonReplayablePersistedRow, pendingMutationRecordId, } from './replayValidation.js';
28
- import { createCommitEnvelopeMember, createDurableCommitEnvelope, commitEnvelopeRecordId, durableCommitEnvelopeSchema, } from '../../transaction/transactions/settlement/commitEnvelope.js';
29
- import { stableStringify } from '../../transaction/utils/json.js';
30
- import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
31
- export class MutationQueue extends EventEmitter {
32
- // Keep one hour of clock/network margin inside the server's 24-hour ledger.
33
- static DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
34
- store = new MutationStore();
35
- // Signature of the last permanent-error we logged at `warn`. A `create`
36
- // whose id already exists (`unique_violation`) is a permanent rejection
37
- // that the offline queue re-drives on every reconnect/bootstrap — without
38
- // this, the identical cause prints on a loop. We log the first occurrence
39
- // and demote exact repeats to `debug`.
40
- lastPermanentErrorSig;
41
- // The executor bound to this queue instance, set by `setMutationExecutor(...)`
42
- // just after construction. When unset it falls back to the ambient executor
43
- // from `getContext()`.
44
- //
45
- // The binding matters because the ambient executor is a module-level
46
- // singleton: constructing a second client instance overwrites the first
47
- // instance's executor. Without a per-instance binding, commits on one
48
- // instance would dispatch through another instance's executor closure; once
49
- // that other instance disposed its store, the closure would resolve no live
50
- // connection and every commit here would fail with `ws_not_ready`, which the
51
- // queue treats as transient and retries endlessly.
52
- _mutationExecutor = null;
53
- get mutationExecutor() {
54
- return this._mutationExecutor ?? this.runtime.mutationExecutor;
55
- }
56
- runtime;
57
- executionQueue = [];
58
- isProcessing = false;
59
- processTimer;
60
- processScheduled = false;
61
- // Staging area for transactions created in the same event-loop tick. Each one
62
- // lands here first, then a microtask commits them together.
63
- createdTransactions = [];
64
- commitScheduled = false;
65
- // Per-model in-flight tracking and merge buffer
66
- inFlightByModel = new Set();
67
- pendingMergeByModel = new Map();
68
- deferredDeletesByCreate = new Map();
69
- // Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
70
- // Drained serially (one envelope at a time) since each is atomic; no
71
- // coalescing with model-proxy transactions.
72
- commitLane = [];
73
- commitStore = new Map();
74
- /**
75
- * Small race buffer for authoritative echoes that arrive before the queued
76
- * mutation receipt. The forward and WAL stream are independent channels, so
77
- * either can win without changing the settlement result.
78
- */
79
- recentDeltaCorrelations = new Map();
80
- /**
81
- * Client-facing confirmation deadlines for source-forwarded writes. These
82
- * timers settle only the caller waiting for `confirmed`; the accepted write
83
- * remains in `awaiting_delta`, with its durable envelope intact, until the
84
- * authoritative WAL echo arrives.
85
- */
86
- replicationLagTimeouts = new Map();
87
- replicationLagErrors = new Map();
88
- commitProcessing = false;
89
- lastCommitSequence = 0;
90
- durableReplayBlock = null;
91
- /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
92
- commitOutbox = null;
93
- commitOutboxScope = null;
94
- nextCommitSequence() {
95
- const wallSequence = Date.now() * 1_000;
96
- this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
97
- return this.lastCommitSequence;
98
- }
99
- emitCommitLifecycle(event, payload) {
100
- try {
101
- this.emit(event, payload);
102
- }
103
- catch (error) {
104
- this.runtime.observability.captureMutationFailure({
105
- context: `commit-lifecycle-listener:${event}`,
106
- error: error instanceof Error ? error : String(error),
107
- });
108
- }
109
- }
110
- assertDurableReplayOpen() {
111
- if (this.durableReplayBlock)
112
- throw this.durableReplayBlock;
113
- }
114
- assertEnvelopeInsideReplayWindow(envelope) {
115
- this.assertDurableReplayOpen();
116
- if (envelope.acceptedAt === undefined &&
117
- Date.now() - envelope.sealedAt >=
118
- MutationQueue.DURABLE_REPLAY_WINDOW_MS) {
119
- this.durableReplayBlock = new AbloIdempotencyError('A pending commit is older than the server idempotency window; newer writes are blocked until it is reviewed.', { code: 'idempotency_conflict' });
120
- // This gate stops EVERY subsequent write on this client, and each of
121
- // those rejections is captured to observability rather than surfaced to
122
- // the caller — without this line the session degrades into "nothing
123
- // saves and nothing errors". One loud line at the moment the block
124
- // engages is the only visible trace.
125
- this.runtime.logger.warn('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', { sealedAt: envelope.sealedAt });
126
- throw this.durableReplayBlock;
127
- }
128
- }
129
- computePriorityScore(type, modelName) {
130
- return computePriorityScore(type, modelName, this.runtime);
131
- }
132
- ensureDerivedFields(transaction) {
133
- if (!transaction.modelKey) {
134
- transaction.modelKey = normalizeModelKey(transaction.modelName);
135
- }
136
- if (transaction.priorityScore === undefined) {
137
- transaction.priorityScore = this.computePriorityScore(transaction.type, transaction.modelName);
138
- }
139
- }
140
- entityKey(modelName, modelId) {
141
- return entityKey(modelName, modelId);
142
- }
143
- /** Collision-safe receipt target identity across models sharing a row id. */
144
- receiptTargetKey(modelName, modelId) {
145
- return stableStringify([normalizeModelKey(modelName), modelId]);
146
- }
147
- /**
148
- * Relates stale notifications back to write targets without assuming the
149
- * server's canonical model name uses the same spelling as the public schema
150
- * key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
151
- * is the compatibility fallback. An ambiguous same-id cross-model mismatch
152
- * is deliberately left unclassified, so it cannot falsely settle a queued
153
- * write. A notification with no write-target id (or an explicit group) is a
154
- * declared-read conflict and holds the whole batch.
155
- */
156
- classifyReceiptNotifications(operations, notifications) {
157
- const targets = operations.map((operation) => ({
158
- id: operation.id,
159
- key: this.receiptTargetKey(operation.model, operation.id),
160
- }));
161
- const heldTargets = new Set();
162
- const notificationsByTarget = new Map();
163
- let holdsEntireBatch = false;
164
- for (const notification of notifications) {
165
- const candidates = targets.filter((target) => target.id === notification.id);
166
- const notificationKey = this.receiptTargetKey(notification.model, notification.id);
167
- const exactTargets = candidates.filter((target) => target.key === notificationKey);
168
- const candidateKeys = new Set((exactTargets.length > 0 ? exactTargets : candidates).map((target) => target.key));
169
- if (notification.group || candidates.length === 0) {
170
- holdsEntireBatch = true;
171
- continue;
172
- }
173
- if (candidateKeys.size !== 1) {
174
- // Same id across multiple differently named models with no exact match:
175
- // the id-only compatibility fallback is ambiguous. Await the echo.
176
- continue;
177
- }
178
- const [targetKey] = candidateKeys;
179
- if (!targetKey)
180
- continue;
181
- heldTargets.add(targetKey);
182
- const targetNotifications = notificationsByTarget.get(targetKey) ?? [];
183
- targetNotifications.push(notification);
184
- notificationsByTarget.set(targetKey, targetNotifications);
185
- }
186
- return { holdsEntireBatch, heldTargets, notificationsByTarget };
187
- }
188
- resolveConfirmation(transaction) {
189
- const resolver = this.confirmationResolvers.get(transaction.id);
190
- if (!resolver)
191
- return;
192
- this.confirmationResolvers.delete(transaction.id);
193
- resolver.resolve();
194
- }
195
- takeUnsentCreateForModel(modelName, modelId) {
196
- return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
197
- }
198
- async cancelUnsentCreateForDelete(transaction) {
199
- this.store.updateStatus(transaction.id, 'rolled_back');
200
- if (this.config.enableOptimistic) {
201
- await this.rollbackOptimistic(transaction, 'model_cancelled');
202
- }
203
- this.resolveConfirmation(transaction);
204
- }
205
- findCreateBarrierForDelete(modelName, modelId) {
206
- return findCreateBarrierForDelete(this.store, modelName, modelId);
207
- }
208
- completeLocalDelete(model, context, writeOptions, sourceMutationIds = []) {
209
- const actualModelName = model.getModelName();
210
- const modelKey = normalizeModelKey(actualModelName);
211
- const transaction = {
212
- id: this.generateId(),
213
- type: 'delete',
214
- modelName: actualModelName,
215
- modelId: model.id,
216
- modelKey,
217
- priorityScore: this.computePriorityScore('delete', actualModelName),
218
- previousData: model.toJSON ? model.toJSON() : { ...model },
219
- context,
220
- status: 'completed',
221
- createdAt: Date.now(),
222
- attempts: 0,
223
- priority: 'high',
224
- writeOptions,
225
- ...(sourceMutationIds.length > 0
226
- ? { sourceMutationIds: [...new Set(sourceMutationIds)] }
227
- : {}),
228
- localOnly: true,
229
- };
230
- this.attachConfirmation(transaction);
231
- this.store.add(transaction);
232
- if (this.config.enableOptimistic) {
233
- this.applyOptimisticDelete(model, transaction);
234
- }
235
- this.emit('transaction:created', transaction);
236
- this.emit('transaction:completed', transaction);
237
- this.emit(`transaction:completed:${transaction.id}`, transaction);
238
- this.optimisticUpdates.delete(transaction.id);
239
- return transaction;
240
- }
241
- deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
242
- deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
243
- }
244
- releaseDeferredDeletesForCreate(createTransaction) {
245
- releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
246
- }
247
- // Default configuration, tuned so more operations coalesce into a single
248
- // commit: a larger batch size and delay give rapid operations more time to
249
- // merge before the batch is sent.
250
- config = {
251
- maxBatchSize: 50, // send up to this many operations per commit
252
- batchDelay: 150, // milliseconds to wait for more operations before sending
253
- maxRetries: 3,
254
- conflictResolution: {
255
- strategy: 'last-write-wins',
256
- },
257
- enablePersistence: true,
258
- enableOptimistic: true,
259
- // Backpressure: don't schedule more batches if too many transactions are executing
260
- maxExecutingTransactions: 100,
261
- // Delta confirmation initial timeout - first retry fires at 30s
262
- // On timeout: retries with exponential backoff (30s → 60s → 120s) instead of rolling back
263
- deltaConfirmationTimeout: 30000,
264
- retryBackoff: { baseMs: 200, capMs: 1500 },
265
- commitOfflineGraceMs: 30_000,
266
- commitDispatchTimeoutMs: 30_000,
267
- };
268
- // Track executing transactions for backpressure
269
- executingCount = 0;
270
- // Optimistic update tracking. The entry shape and apply/rollback rules live
271
- // in `./optimisticApply.js`; the map itself stays on the queue because the
272
- // completion paths, `getStats`, and `dispose` all read it.
273
- optimisticUpdates = new Map();
274
- // Stale-context notifications, keyed by transaction id. When the server
275
- // accepts a commit but reports that an operation's premise had moved,
276
- // the notification lands here from the commit acknowledgement and is drained
277
- // by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
278
- commitNotifications = new Map();
279
- /** Zero-row targets returned on a successful atomic commit receipt. */
280
- commitMissingIds = new Map();
281
- // Delta-confirmation tracking (ack watermark advance + the awaiting_delta
282
- // timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
283
- // constructor once `position` is bound.
284
- deltaConfirmation;
285
- // Connection-state check, supplied by the client, used to hold off rollbacks
286
- // while disconnected.
287
- isConnectedFn = () => true;
288
- // Grace timer that, when fired, fails any commit-lane transaction
289
- // still awaiting an ack. Started on `setConnectionState('disconnected')`,
290
- // cleared on `'connected'`. The reconnect-retry behavior of the queue
291
- // is preserved for brief blips; this only catches persistent disconnects.
292
- commitOfflineGraceTimer = null;
293
- /**
294
- * This client's place in the global order of sync deltas. The instance is
295
- * shared: the client injects one, and a standalone queue creates its own. The
296
- * queue advances the `acked` cursor as commit responses arrive, the store
297
- * advances `applied` and `persisted`, and snapshots and claims read
298
- * `readFloor`. See `../logPosition.js` for the full contract.
299
- */
300
- position;
301
- /** Applied-cursor alias, kept so the many internal read sites stay legible. */
302
- get lastSeenSyncId() {
303
- return this.position.applied;
304
- }
305
- noteAck(lastSyncId) {
306
- this.deltaConfirmation.noteAck(lastSyncId);
307
- }
308
- // Batch management
309
- batchIndex = 0;
310
- /** Mints the request identity once; retry paths only read the stored value. */
311
- generateCommitIdempotencyKey() {
312
- return `commit_${uuid()}`;
313
- }
314
- /**
315
- * Binds an ordered transaction batch to one wire-level idempotency key.
316
- * Existing envelopes are validated and restored to their original order;
317
- * they are never extended with newly queued work.
318
- */
319
- ensureCommitEnvelope(batch) {
320
- const firstTransaction = batch[0];
321
- if (!firstTransaction) {
322
- throw new Error('Cannot create an idempotency envelope for an empty commit');
323
- }
324
- const existingKeys = new Set(batch
325
- .map((tx) => tx.commitEnvelope?.idempotencyKey)
326
- .filter((key) => key !== undefined));
327
- if (existingKeys.size > 1) {
328
- throw new Error('Cannot combine transactions from different commit envelopes');
329
- }
330
- const existingKey = existingKeys.values().next().value;
331
- if (existingKey) {
332
- const expectedCount = firstTransaction.commitEnvelope?.operationCount;
333
- const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
334
- const isCompleteEnvelope = expectedCount === batch.length &&
335
- indexes.size === batch.length &&
336
- batch.every((tx) => tx.commitEnvelope?.idempotencyKey === existingKey &&
337
- tx.commitEnvelope.operationCount === expectedCount &&
338
- tx.commitEnvelope.operationIndex < expectedCount);
339
- if (!isCompleteEnvelope) {
340
- throw new Error('Cannot replay a partial or malformed commit envelope');
341
- }
342
- const persistedSealTimes = new Set(batch
343
- .map((transaction) => transaction.commitEnvelope?.sealedAt)
344
- .filter((sealedAt) => sealedAt !== undefined));
345
- if (persistedSealTimes.size > 1) {
346
- throw new Error('Cannot replay a commit envelope with inconsistent seal times');
347
- }
348
- const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
349
- for (const transaction of batch) {
350
- if (transaction.commitEnvelope)
351
- transaction.commitEnvelope.sealedAt = sealedAt;
352
- }
353
- batch.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
354
- (b.commitEnvelope?.operationIndex ?? 0));
355
- return existingKey;
356
- }
357
- // An explicit public key owns its request. Transactions carrying one are
358
- // selected as solo batches by takeNextExecutionBatch().
359
- const explicitKey = batch.length === 1
360
- ? firstTransaction.writeOptions?.idempotencyKey
361
- : undefined;
362
- const idempotencyKey = typeof explicitKey === 'string' && explicitKey.length > 0
363
- ? explicitKey
364
- : this.generateCommitIdempotencyKey();
365
- const sealedAt = Date.now();
366
- const sequence = this.nextCommitSequence();
367
- batch.forEach((tx, operationIndex) => {
368
- tx.commitEnvelope = createCommitEnvelopeMember({
369
- idempotencyKey,
370
- operationIndex,
371
- operationCount: batch.length,
372
- sealedAt,
373
- sequence,
374
- });
375
- });
376
- return idempotencyKey;
377
- }
378
- /** Bind the strict local outbox used before any mutation reaches the wire. */
379
- setCommitOutbox(outbox) {
380
- this.commitOutbox = outbox;
381
- }
382
- setCommitOutboxScope(scope) {
383
- this.commitOutboxScope = scope;
384
- }
385
- sourceMutationIdsFor(batch) {
386
- return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
387
- }
388
- /**
389
- * Atomically replaces staged mutation journal rows with one exact request.
390
- * The returned operations are the JSON-normalized values that must be sent;
391
- * callers never send a separately reconstructed payload after sealing.
392
- */
393
- async sealDurableCommit(input) {
394
- const sourceMutationIds = [...new Set(input.sourceMutationIds ?? [])];
395
- const envelope = createDurableCommitEnvelope({
396
- idempotencyKey: input.idempotencyKey,
397
- origin: input.origin,
398
- operations: [...input.operations],
399
- sourceMutationIds,
400
- commitOptions: {
401
- ...(input.commitOptions?.reads !== undefined
402
- ? {
403
- reads: input.commitOptions.reads === null
404
- ? null
405
- : [...input.commitOptions.reads],
406
- }
407
- : {}),
408
- ...(input.commitOptions?.track !== undefined
409
- ? {
410
- track: input.commitOptions.track === null
411
- ? null
412
- : [...input.commitOptions.track],
413
- }
414
- : {}),
415
- },
416
- ...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
417
- createdAt: input.createdAt,
418
- sealedAt: input.sealedAt,
419
- sequence: input.sequence ?? input.sealedAt * 1_000,
420
- });
421
- if (this.config.enablePersistence && this.commitOutbox) {
422
- try {
423
- await this.commitOutbox.seal(envelope, sourceMutationIds.map(pendingMutationRecordId));
424
- }
425
- catch (cause) {
426
- if (cause instanceof AbloError)
427
- throw cause;
428
- throw new AbloConnectionError('Could not persist the durable write before dispatch', {
429
- code: 'db_not_opened',
430
- cause,
431
- });
432
- }
433
- this.emitCommitLifecycle('commit:envelope_persisted', {
434
- idempotencyKey: envelope.idempotencyKey,
435
- sourceMutationIds: envelope.sourceMutationIds,
436
- });
437
- }
438
- return envelope;
439
- }
440
- /** Best-effort cleanup after a definitive rejection, confirmed ack, or echo. */
441
- async removeDurableCommit(idempotencyKey) {
442
- if (!this.config.enablePersistence || !this.commitOutbox)
443
- return;
444
- try {
445
- await this.commitOutbox.remove(commitEnvelopeRecordId(idempotencyKey));
446
- }
447
- catch (error) {
448
- this.runtime.logger.debug('[MutationQueue] Durable-write cleanup deferred', {
449
- idempotencyKey,
450
- error: error instanceof Error ? error.message : String(error),
451
- });
452
- }
453
- }
454
- /**
455
- * Upgrade a sealed request with permanent connected-source acceptance before
456
- * exposing the queued receipt. This is not completion: the envelope remains
457
- * until the matching WAL correlation arrives. The upgrade only makes a
458
- * crash/restart safe after the hosted 24-hour replay window has elapsed.
459
- */
460
- async persistDurableCommitAcceptance(envelope, result) {
461
- if (result.status !== 'queued')
462
- return envelope;
463
- const correlationId = result.correlationId;
464
- if (!correlationId) {
465
- throw new AbloConnectionError('The source accepted the commit without durable correlation evidence.', { code: 'commit_no_result' });
466
- }
467
- if (envelope.correlationId !== undefined &&
468
- envelope.correlationId !== correlationId) {
469
- throw new AbloIdempotencyError('The same commit replay returned a different source correlation.', { code: 'idempotency_conflict' });
470
- }
471
- if (envelope.acceptedAt !== undefined)
472
- return envelope;
473
- const accepted = durableCommitEnvelopeSchema.parse({
474
- ...envelope,
475
- acceptedAt: Math.max(Date.now(), envelope.sealedAt),
476
- correlationId,
477
- });
478
- if (this.config.enablePersistence && this.commitOutbox) {
479
- try {
480
- await this.commitOutbox.seal(accepted, []);
481
- }
482
- catch (cause) {
483
- if (cause instanceof AbloError)
484
- throw cause;
485
- throw new AbloConnectionError('The source accepted the commit, but that acceptance could not be persisted locally.', { code: 'db_not_opened', cause });
486
- }
487
- }
488
- return accepted;
489
- }
490
- /** Parse an untrusted/custom executor receipt without making ambiguity fatal. */
491
- parseMutationCommitResult(value) {
492
- const parsed = mutationCommitResultSchema.safeParse(value);
493
- if (parsed.success)
494
- return parsed.data;
495
- throw new AbloConnectionError('The mutation transport returned an invalid commit receipt; its outcome remains pending and is safe to retry.', { code: 'commit_no_result', cause: parsed.error });
496
- }
497
- /**
498
- * Dispatch a sealed envelope to the transport, bounded by
499
- * `commitDispatchTimeoutMs`. Every executor call site routes through here:
500
- * a transport that never answers must become a retryable no-receipt failure
501
- * (the same class as a malformed receipt) instead of an eternally in-flight
502
- * commit, because an unanswered commit holds the staged-batch lock and
503
- * silently stalls every later write in the session. The abandoned commit's
504
- * eventual result, if it ever arrives, is discarded; the retry re-sends the
505
- * identical idempotency key, so the server deduplicates an already-applied
506
- * write.
507
- */
508
- dispatchCommitBounded(...args) {
509
- const timeoutMs = this.config.commitDispatchTimeoutMs;
510
- const dispatched = this.mutationExecutor.commit(...args);
511
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
512
- return dispatched;
513
- return new Promise((resolve, reject) => {
514
- const timer = setTimeout(() => {
515
- reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' }));
516
- }, timeoutMs);
517
- dispatched.then((value) => {
518
- clearTimeout(timer);
519
- resolve(value);
520
- }, (error) => {
521
- clearTimeout(timer);
522
- reject(error instanceof Error ? error : new Error(String(error)));
523
- });
524
- });
525
- }
526
- clearReplicationLagState(transactionId) {
527
- const timeout = this.replicationLagTimeouts.get(transactionId);
528
- if (timeout)
529
- clearTimeout(timeout);
530
- this.replicationLagTimeouts.delete(transactionId);
531
- this.replicationLagErrors.delete(transactionId);
532
- }
533
- /**
534
- * Bounds the public `wait: 'confirmed'` promise without changing the
535
- * accepted write's lifecycle. A lag timeout is not a rejection from the
536
- * source database, so it must never emit `transaction:failed`, roll back
537
- * optimistic state, or remove the durable replay envelope.
538
- */
539
- scheduleReplicationLagTimeout(transactionId, clientTxId = transactionId, correlationId) {
540
- const previous = this.replicationLagTimeouts.get(transactionId);
541
- if (previous)
542
- clearTimeout(previous);
543
- this.replicationLagErrors.delete(transactionId);
544
- const timeoutMs = this.config.deltaConfirmationTimeout;
545
- const timeout = setTimeout(() => {
546
- this.replicationLagTimeouts.delete(transactionId);
547
- const modelTx = this.store.get(transactionId);
548
- const commitTx = this.commitStore.get(transactionId);
549
- if (modelTx?.status !== 'awaiting_delta' &&
550
- commitTx?.status !== 'awaiting_delta')
551
- return;
552
- const error = new AbloConnectionError(`The source accepted commit ${clientTxId}, but its replication echo did not arrive within ${timeoutMs}ms.`, {
553
- code: 'replication_lag_timeout',
554
- httpStatus: 504,
555
- details: {
556
- clientTxId,
557
- ...(correlationId ? { correlationId } : {}),
558
- timeoutMs,
559
- accepted: true,
560
- },
561
- });
562
- this.replicationLagErrors.set(transactionId, error);
563
- // Model-proxy writes expose their waiter through the resolver table.
564
- // Reject that promise without moving the transaction out of
565
- // `awaiting_delta`; the eventual echo still completes it normally.
566
- const resolver = this.confirmationResolvers.get(transactionId);
567
- if (resolver) {
568
- this.confirmationResolvers.delete(transactionId);
569
- resolver.reject(error);
570
- }
571
- this.emitCommitLifecycle('transaction:confirmation_lagged', {
572
- transactionId,
573
- error,
574
- });
575
- this.emitCommitLifecycle(`transaction:confirmation_lagged:${transactionId}`, {
576
- error,
577
- });
578
- if (commitTx) {
579
- const firstOperation = commitTx.operations[0];
580
- this.emitCommitLifecycle('reconciliation:needed', {
581
- reason: 'replication_lag_timeout',
582
- txId: transactionId,
583
- model: firstOperation?.model ?? 'commit',
584
- modelId: firstOperation?.id ?? transactionId,
585
- lastSeenSyncId: this.lastSeenSyncId,
586
- retryCount: 1,
587
- });
588
- }
589
- }, timeoutMs);
590
- this.replicationLagTimeouts.set(transactionId, timeout);
591
- }
592
- /**
593
- * Takes either one complete retry envelope or a fresh batch. A retry waits
594
- * until every original member has re-entered the queue, preventing an
595
- * ambiguous A+B commit from being replayed later as A and B separately.
596
- */
597
- takeNextExecutionBatch() {
598
- const retryGroups = new Map();
599
- for (const tx of this.executionQueue) {
600
- const envelope = tx.commitEnvelope;
601
- if (!envelope)
602
- continue;
603
- const group = retryGroups.get(envelope.idempotencyKey) ??
604
- new Map();
605
- group.set(tx.id, tx);
606
- retryGroups.set(envelope.idempotencyKey, group);
607
- }
608
- for (const [idempotencyKey, byId] of retryGroups) {
609
- const members = [...byId.values()];
610
- const expectedCount = members[0]?.commitEnvelope?.operationCount;
611
- if (expectedCount === undefined || members.length !== expectedCount)
612
- continue;
613
- this.executionQueue = this.executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
614
- members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
615
- (b.commitEnvelope?.operationIndex ?? 0));
616
- return members;
617
- }
618
- const fresh = this.executionQueue.filter((tx) => !tx.commitEnvelope);
619
- const firstFresh = fresh[0];
620
- if (!firstFresh)
621
- return [];
622
- // A caller-supplied key describes exactly one public mutation call. Keep
623
- // that transaction out of an SDK-created aggregate batch so the key maps
624
- // to the request its caller intended.
625
- const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
626
- const selected = explicitIndex === 0
627
- ? [firstFresh]
628
- : fresh.slice(0, Math.min(this.config.maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
629
- const selectedIds = new Set(selected.map((tx) => tx.id));
630
- this.executionQueue = this.executionQueue.filter((tx) => !selectedIds.has(tx.id));
631
- return selected;
632
- }
633
- /**
634
- * Selects one reconnect batch without changing the request identity of work
635
- * that was already attempted. Explicit caller keys remain one-call batches;
636
- * an existing envelope is replayed only with all of its original members.
637
- */
638
- takeOfflineFlushBatch(pending) {
639
- const first = pending[0];
640
- if (!first)
641
- return [];
642
- const envelope = first.commitEnvelope;
643
- if (envelope) {
644
- return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
645
- }
646
- if (typeof first.writeOptions?.idempotencyKey === 'string') {
647
- return [first];
648
- }
649
- const boundary = pending.findIndex((tx) => tx.commitEnvelope !== undefined ||
650
- typeof tx.writeOptions?.idempotencyKey === 'string');
651
- return pending.slice(0, Math.min(this.config.maxBatchSize, boundary > 0 ? boundary : pending.length));
652
- }
653
- /**
654
- * Resolvers for per-transaction `confirmation` promises. Populated in
655
- * `attachConfirmation` at staging time, consumed by the constructor-time
656
- * listeners on `transaction:completed` / `transaction:failed`. Kept off
657
- * the QueuedMutation row so the store's iteration order stays plain-data
658
- * and serialization-friendly.
659
- */
660
- confirmationResolvers = new Map();
661
- constructor(config) {
662
- super();
663
- this.runtime = config?.runtime ?? globalRuntime;
664
- this.position = config?.position ?? new LogPosition();
665
- // Bind the confirmation tracker to this queue's store/ledger/events.
666
- // `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
667
- // swaps stay visible to in-flight timeouts.
668
- this.deltaConfirmation = new DeltaConfirmationTracker({
669
- store: this.store,
670
- optimisticUpdates: this.optimisticUpdates,
671
- emit: (event, payload) => {
672
- this.emit(event, payload);
673
- },
674
- isConnected: () => this.isConnectedFn(),
675
- position: this.position,
676
- runtime: this.runtime,
677
- });
678
- if (config) {
679
- this.config = { ...this.config, ...config };
680
- }
681
- // Centralized fan-in for `tx.confirmation`. Completion/failure are
682
- // emitted from ~10 sites (delta confirm, immediate confirm, batch
683
- // success, permanent error, max_retries_exhausted, …). Subscribing
684
- // once here keeps every emit site intact and guarantees the call-site
685
- // promise always settles, regardless of which path produced the
686
- // terminal state.
687
- this.on('transaction:completed', (tx) => {
688
- // Any successful write clears the permanent-error dedup, so a genuine
689
- // recurrence after recovery warns again instead of staying demoted.
690
- this.lastPermanentErrorSig = undefined;
691
- this.clearReplicationLagState(tx.id);
692
- const r = this.confirmationResolvers.get(tx.id);
693
- if (r) {
694
- this.confirmationResolvers.delete(tx.id);
695
- r.resolve();
696
- }
697
- if (tx.type === 'create') {
698
- this.releaseDeferredDeletesForCreate(tx);
699
- }
700
- });
701
- this.on('transaction:failed', ({ transaction, error }) => {
702
- const r = this.confirmationResolvers.get(transaction.id);
703
- if (r) {
704
- this.confirmationResolvers.delete(transaction.id);
705
- r.reject(error);
706
- }
707
- if (transaction.type === 'create') {
708
- this.releaseDeferredDeletesForCreate(transaction);
709
- }
710
- });
711
- }
712
- /**
713
- * Returns the in-flight confirmation promise for a given model and id. When
714
- * several transactions match, it returns the most recent one's promise; when
715
- * none is open it resolves immediately, which covers both "already confirmed"
716
- * and "never staged".
717
- *
718
- * It considers the three non-terminal statuses in which the write can still
719
- * be rolled back — `pending`, `executing`, and `awaiting_delta` — and ignores
720
- * `completed` (already settled) and `failed`/`rolled_back` (already
721
- * rejected). This complements the `confirmation` promise carried on a known
722
- * {@link QueuedMutation}: use this method at call sites that hold a model
723
- * returned by `ablo.<model>.create()` but never see the underlying
724
- * transaction.
725
- */
726
- confirmationFor(modelName, modelId) {
727
- const candidates = [
728
- ...this.store.getByStatus('pending'),
729
- ...this.store.getByStatus('executing'),
730
- ...this.store.getByStatus('awaiting_delta'),
731
- ].filter((tx) => tx.modelName === modelName && tx.modelId === modelId);
732
- if (candidates.length === 0)
733
- return Promise.resolve();
734
- const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
735
- if (!latest)
736
- return Promise.resolve();
737
- return latest.confirmation ?? Promise.resolve();
738
- }
739
- /**
740
- * Attaches a `confirmation` promise to a newly created transaction. Call this
741
- * before the transaction is staged so a caller can `await tx.confirmation`
742
- * immediately after a create, update, or delete returns. It is idempotent and
743
- * returns early if one is already attached.
744
- *
745
- * It also attaches a no-op rejection handler. Most callers never await the
746
- * confirmation, and without this the runtime would report an unhandled
747
- * rejection when a write fails. Callers that do want to observe failure simply
748
- * attach their own `.then`/`.catch`.
749
- */
750
- attachConfirmation(tx) {
751
- if (tx.confirmation)
752
- return;
753
- tx.confirmation = new Promise((resolve, reject) => {
754
- this.confirmationResolvers.set(tx.id, { resolve, reject });
755
- });
756
- tx.confirmation.catch(() => {
757
- // Swallow unhandled rejections; callers that care attach their own handler.
758
- });
759
- }
760
- /**
761
- * Registers a predicate the queue uses to check whether it is connected.
762
- * While disconnected, confirmation timeouts re-schedule themselves instead of
763
- * escalating, so a transaction is never rolled back merely because the client
764
- * was briefly offline.
765
- */
766
- setConnectionChecker(fn) {
767
- this.isConnectedFn = fn;
768
- }
769
- /**
770
- * Drives the offline-grace timer for in-flight commit-lane transactions.
771
- *
772
- * On `'disconnected'` it starts a one-shot timer of
773
- * `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
774
- * outlasted the grace window — every commit-lane transaction still `pending`
775
- * or `executing` is failed with an {@link AbloConnectionError}, so
776
- * {@link waitForCommitReceipt} rejects within seconds instead of hanging.
777
- *
778
- * On `'connected'` it clears any pending grace timer. Brief disconnects are
779
- * absorbed transparently; {@link processCommitLane} and
780
- * {@link flushOfflineQueue} resume the work on reconnect.
781
- */
782
- setConnectionState(state) {
783
- if (state === 'connected') {
784
- if (this.commitOfflineGraceTimer !== null) {
785
- clearTimeout(this.commitOfflineGraceTimer);
786
- this.commitOfflineGraceTimer = null;
787
- }
788
- return;
789
- }
790
- // state === 'disconnected'
791
- if (this.commitOfflineGraceTimer !== null)
792
- return; // already armed
793
- const graceMs = this.config.commitOfflineGraceMs;
794
- this.commitOfflineGraceTimer = setTimeout(() => {
795
- this.commitOfflineGraceTimer = null;
796
- this.failInFlightCommitsOnOffline(graceMs);
797
- }, graceMs);
798
- }
799
- failInFlightCommitsOnOffline(graceMs) {
800
- const inFlight = [];
801
- for (const [id, tx] of this.commitStore.entries()) {
802
- if (tx.status === 'pending' || tx.status === 'executing') {
803
- inFlight.push(id);
804
- }
805
- }
806
- if (inFlight.length === 0)
807
- return;
808
- // Each failed commit reaches the consumer through its own rejection path,
809
- // so this aggregate line is forensic and logged at debug rather than warn.
810
- this.runtime.logger.debug(`[MutationQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
811
- for (const id of inFlight) {
812
- const tx = this.commitStore.get(id);
813
- if (!tx)
814
- continue;
815
- const err = new AbloConnectionError(`commit ack abandoned after ${graceMs}ms offline`, { code: 'commit_offline_grace_expired' });
816
- tx.status = 'failed';
817
- tx.error = err;
818
- this.emit(`transaction:failed:${id}`, { error: err });
819
- }
820
- }
821
- /**
822
- * Binds the mutation executor for this queue instance. The owning client
823
- * calls this right after construction, so commits made here always dispatch
824
- * through this instance's connection even when several client instances exist
825
- * in the same process.
826
- */
827
- setMutationExecutor(executor) {
828
- this._mutationExecutor = executor;
829
- }
830
- // ============================================================================
831
- // Microtask-based transaction staging
832
- // ============================================================================
833
- //
834
- // Every transaction lands in the `createdTransactions` staging area first.
835
- // A microtask then commits them together under one batch index, so a bulk
836
- // operation such as importing a hundred rows is sent efficiently.
837
- //
838
- // Flow:
839
- // 1. create()/update()/delete() calls stageTransaction().
840
- // 2. stageTransaction() adds to createdTransactions and schedules a microtask.
841
- // 3. The microtask runs commitCreatedTransactions() once the current
842
- // synchronous code finishes.
843
- // 4. All staged transactions share one batch index and move to the execution
844
- // queue.
845
- // ============================================================================
846
- /**
847
- * Stages a transaction for commit. Transactions staged within the same
848
- * event-loop tick are committed together.
849
- */
850
- stageTransaction(transaction) {
851
- this.createdTransactions.push(transaction);
852
- this.scheduleCommit();
853
- }
854
- /**
855
- * Schedules the staged transactions to commit on a microtask, so all
856
- * transactions created synchronously within one tick are batched together.
857
- */
858
- scheduleCommit() {
859
- if (this.commitScheduled)
860
- return;
861
- this.commitScheduled = true;
862
- // Use queueMicrotask to run after current sync code completes
863
- // All transactions created in same event loop will be committed together
864
- const schedule = typeof queueMicrotask === 'function'
865
- ? queueMicrotask
866
- : (cb) => Promise.resolve().then(cb);
867
- schedule(() => {
868
- this.commitCreatedTransactions();
869
- });
870
- }
871
- /**
872
- * Moves all staged transactions onto the execution queue, assigning them a
873
- * single shared batch index so they commit together.
874
- */
875
- commitCreatedTransactions() {
876
- this.commitScheduled = false;
877
- if (this.createdTransactions.length === 0)
878
- return;
879
- // Increment batch index - all transactions in this commit share it
880
- this.batchIndex++;
881
- const currentBatchIndex = this.batchIndex;
882
- // Log batch commit for performance monitoring
883
- this.runtime.logger.debug('[MutationQueue] commitCreatedTransactions', {
884
- count: this.createdTransactions.length,
885
- batchIndex: currentBatchIndex,
886
- types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
887
- });
888
- // Move all staged transactions to execution queue
889
- const staged = this.createdTransactions;
890
- this.createdTransactions = [];
891
- for (const transaction of staged) {
892
- // Assign batch ID based on current batch index
893
- transaction.batchId = `batch_${currentBatchIndex}`;
894
- this.enqueue(transaction);
895
- }
896
- }
897
- /**
898
- * Flushes every pending transaction in one commit, the fast path taken on
899
- * reconnect. If transport fails, the transactions retain this exact commit
900
- * envelope when they fall back to normal queue processing.
901
- */
902
- async flushOfflineQueue() {
903
- this.assertDurableReplayOpen();
904
- // Kick the commit lane too: atomic envelopes from `commits.create()` may
905
- // have been left at the head of the lane while the connection was down.
906
- // Fire-and-forget; processCommitLane serializes itself.
907
- void this.processCommitLane();
908
- // Collect pending transactions in created order
909
- const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
910
- if (pending.length === 0)
911
- return;
912
- const pendingIds = new Set(pending.map((tx) => tx.id));
913
- // These rows may already be waiting behind the normal batch timer. The
914
- // reconnect fast path takes ownership of them for this attempt so the same
915
- // transaction cannot dispatch concurrently through both paths.
916
- this.executionQueue = this.executionQueue.filter((tx) => !pendingIds.has(tx.id));
917
- const remaining = [...pending];
918
- while (remaining.length > 0) {
919
- const batch = this.takeOfflineFlushBatch(remaining);
920
- if (batch.length === 0)
921
- break;
922
- const batchIds = new Set(batch.map((tx) => tx.id));
923
- const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
924
- try {
925
- const idempotencyKey = this.ensureCommitEnvelope(batch);
926
- const projectedOperations = batch.map((tx) => {
927
- this.ensureDerivedFields(tx);
928
- return applyWriteOptions({
929
- type: TX_TYPE_TO_MUTATION_OP[tx.type],
930
- model: tx.modelKey,
931
- id: tx.modelId,
932
- input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
933
- transactionId: tx.id,
934
- }, tx);
935
- });
936
- const durableEnvelope = await this.sealDurableCommit({
937
- idempotencyKey,
938
- origin: 'model_batch',
939
- operations: projectedOperations,
940
- sourceMutationIds: this.sourceMutationIdsFor(batch),
941
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
942
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
943
- sequence: batch[0]?.commitEnvelope?.sequence,
944
- });
945
- this.assertEnvelopeInsideReplayWindow(durableEnvelope);
946
- const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(durableEnvelope.operations, {
947
- idempotencyKey,
948
- }));
949
- await this.persistDurableCommitAcceptance(durableEnvelope, result);
950
- if (result.status === 'queued') {
951
- // Reconnect flushes use the same accepted-vs-confirmed contract as
952
- // the normal lane. A queued source receipt retains the envelope and
953
- // waits for exact correlation; it is never promoted by the reconnect
954
- // shortcut itself.
955
- for (const tx of batch) {
956
- tx.requiresCorrelatedDelta = true;
957
- tx.syncIdNeededForCompletion = undefined;
958
- tx.correlationId = result.correlationId;
959
- const echoSyncId = result.correlationId
960
- ? this.recentDeltaCorrelations.get(result.correlationId)
961
- : undefined;
962
- if (echoSyncId !== undefined) {
963
- this.store.updateStatus(tx.id, 'completed');
964
- this.emit('transaction:completed', tx);
965
- this.emit(`transaction:completed:${tx.id}`, tx);
966
- this.optimisticUpdates.delete(tx.id);
967
- continue;
968
- }
969
- this.store.updateStatus(tx.id, 'awaiting_delta');
970
- this.scheduleReplicationLagTimeout(tx.id, idempotencyKey, result.correlationId);
971
- this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
972
- }
973
- if (batch.every((tx) => tx.status === 'completed')) {
974
- await this.removeDurableCommit(idempotencyKey);
975
- }
976
- }
977
- else {
978
- await this.removeDurableCommit(idempotencyKey);
979
- // Mark this request envelope as completed before moving to the next.
980
- for (const tx of batch) {
981
- this.store.updateStatus(tx.id, 'completed');
982
- this.emit('transaction:completed', tx);
983
- this.emit(`transaction:completed:${tx.id}`, tx);
984
- this.optimisticUpdates.delete(tx.id);
985
- }
986
- }
987
- this.runtime.logger.debug('txn:commit', 0, {
988
- count: batch.length,
989
- lastSyncId: result.lastSyncId,
990
- });
991
- remaining.splice(0, remaining.length, ...nextRemaining);
992
- }
993
- catch (err) {
994
- // If one request fails, hand it and every later request back to the
995
- // normal lane. Their envelopes stay attached for safe retry.
996
- const isOffline = !this.runtime.onlineStatus.isOnline();
997
- const isNetworkError = err instanceof Error &&
998
- (err.message.includes('Failed to fetch') ||
999
- err.message.includes('Network request failed') ||
1000
- err.message.includes('NetworkError'));
1001
- if (!isOffline || !isNetworkError) {
1002
- this.runtime.observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
1003
- error: err instanceof Error ? err.message : String(err),
1004
- });
1005
- }
1006
- for (const tx of [...batch, ...nextRemaining]) {
1007
- this.enqueue(tx);
1008
- }
1009
- return;
1010
- }
1011
- }
1012
- }
1013
- /**
1014
- * Records a create and applies it optimistically, then stages it for the next
1015
- * batched commit. Returns the {@link QueuedMutation}, whose `confirmation`
1016
- * promise settles once the server confirms the write.
1017
- */
1018
- async create(model, context, writeOptions, sourceMutationId) {
1019
- this.assertDurableReplayOpen();
1020
- const actualModelName = model.getModelName();
1021
- const transaction = {
1022
- id: this.generateId(),
1023
- type: 'create',
1024
- modelName: actualModelName,
1025
- modelId: model.id,
1026
- modelKey: normalizeModelKey(actualModelName),
1027
- priorityScore: this.computePriorityScore('create', actualModelName),
1028
- data: this.extractCreateData(model),
1029
- // Rolling back a create removes the row, so there is no prior state to
1030
- // restore and no snapshot is captured here.
1031
- previousData: null,
1032
- context,
1033
- status: 'pending',
1034
- createdAt: Date.now(),
1035
- attempts: 0,
1036
- priority: 'normal',
1037
- writeOptions,
1038
- ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
1039
- };
1040
- this.attachConfirmation(transaction);
1041
- this.store.add(transaction);
1042
- if (this.config.enableOptimistic) {
1043
- this.applyOptimisticCreate(model, transaction);
1044
- }
1045
- // The microtask coalescer (`scheduleCommit`) collapses all creates in this
1046
- // tick into one commit under a single `batchIndex` — see
1047
- // `commitCreatedTransactions`. No batch call is needed at the call site.
1048
- this.stageTransaction(transaction);
1049
- this.emit('transaction:created', transaction);
1050
- return transaction;
1051
- }
1052
- /**
1053
- * Records an update and applies it optimistically, then stages it for the next
1054
- * batched commit. Rapid updates to the same entity coalesce into a single wire
1055
- * operation.
1056
- * @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
1057
- */
1058
- async update(model, context, precomputedChanges, writeOptions, sourceMutationId) {
1059
- this.assertDurableReplayOpen();
1060
- const actualModelName = model.getModelName();
1061
- // Use pre-computed changes if provided, otherwise extract from model
1062
- const updateInput = precomputedChanges
1063
- ? this.mapChangesToInput(actualModelName, precomputedChanges)
1064
- : this.extractUpdateData(model);
1065
- const previousData = this.extractPreviousData(model, updateInput);
1066
- // Advance the per-field baseline for the keys just frozen into this
1067
- // transaction. The model records the first old value per field and clears it
1068
- // only on sync acknowledgement, so a second update to the same field before
1069
- // the first is acknowledged would otherwise re-capture the original value
1070
- // instead of this update's result, corrupting the recorded undo inverse. The
1071
- // wire payload is already frozen in `transaction.data`, so dropping the
1072
- // consumed entries is safe.
1073
- model.consumeModifiedFields(Object.keys(updateInput));
1074
- const modelKey = normalizeModelKey(actualModelName);
1075
- const priorityScore = this.computePriorityScore('update', actualModelName);
1076
- const transaction = {
1077
- id: this.generateId(),
1078
- type: 'update',
1079
- modelName: actualModelName,
1080
- modelId: model.id,
1081
- modelKey,
1082
- priorityScore,
1083
- data: updateInput,
1084
- previousData,
1085
- context,
1086
- status: 'pending',
1087
- createdAt: Date.now(),
1088
- attempts: 0,
1089
- priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
1090
- writeOptions,
1091
- ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
1092
- };
1093
- this.attachConfirmation(transaction);
1094
- this.store.add(transaction);
1095
- // Apply optimistic update
1096
- if (this.config.enableOptimistic) {
1097
- this.applyOptimisticUpdate(model, transaction);
1098
- }
1099
- // Stage the transaction for the microtask commit; updates made in the same
1100
- // tick are batched together, and enqueue() still coalesces same-entity
1101
- // updates.
1102
- this.stageTransaction(transaction);
1103
- this.emit('transaction:created', transaction);
1104
- return transaction;
1105
- }
1106
- /**
1107
- * Records a delete and applies it optimistically. If the row's own create has
1108
- * not yet been sent, both are cancelled locally rather than sending a create
1109
- * followed by a delete; if the create is already in flight, the delete waits
1110
- * until it settles so the server never sees a delete before the create.
1111
- */
1112
- async delete(model, context, writeOptions, sourceMutationId) {
1113
- this.assertDurableReplayOpen();
1114
- // Use getModelName() rather than constructor.name, which is unreliable once
1115
- // class names are minified.
1116
- const actualModelName = model.getModelName();
1117
- // Skip Activity delete transactions - activities are permanent audit records
1118
- if (actualModelName === 'Activity') {
1119
- this.runtime.logger.debug('MutationQueue.delete() skipping Activity deletion - permanent audit records', { modelId: model.id });
1120
- const modelKey = normalizeModelKey(actualModelName);
1121
- const priorityScore = this.computePriorityScore('delete', actualModelName);
1122
- const mockTransaction = {
1123
- id: this.generateId(),
1124
- type: 'delete',
1125
- modelName: actualModelName,
1126
- modelId: model.id,
1127
- modelKey,
1128
- priorityScore,
1129
- previousData: model.toJSON ? model.toJSON() : { ...model },
1130
- context,
1131
- status: 'completed',
1132
- createdAt: Date.now(),
1133
- attempts: 0,
1134
- priority: 'high',
1135
- writeOptions,
1136
- ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
1137
- localOnly: true,
1138
- // Activity deletes complete synchronously (audit-record skip path).
1139
- // Pre-resolved so consumers can still `await tx.confirmation` uniformly.
1140
- confirmation: Promise.resolve(),
1141
- };
1142
- // Apply optimistic delete for UI feedback
1143
- if (this.config.enableOptimistic) {
1144
- this.applyOptimisticDelete(model, mockTransaction);
1145
- }
1146
- this.emit('transaction:created', mockTransaction);
1147
- this.emit('transaction:completed', mockTransaction);
1148
- return mockTransaction;
1149
- }
1150
- const modelKey = normalizeModelKey(actualModelName);
1151
- const priorityScore = this.computePriorityScore('delete', actualModelName);
1152
- const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
1153
- if (unsentCreate) {
1154
- await this.cancelUnsentCreateForDelete(unsentCreate);
1155
- return this.completeLocalDelete(model, context, writeOptions, [
1156
- ...(unsentCreate.sourceMutationIds ?? []),
1157
- ...(sourceMutationId ? [sourceMutationId] : []),
1158
- ]);
1159
- }
1160
- const transaction = {
1161
- id: this.generateId(),
1162
- type: 'delete',
1163
- modelName: actualModelName,
1164
- modelId: model.id,
1165
- modelKey,
1166
- priorityScore,
1167
- previousData: model.toJSON ? model.toJSON() : { ...model },
1168
- context,
1169
- status: 'pending',
1170
- createdAt: Date.now(),
1171
- attempts: 0,
1172
- priority: 'high', // Deletes are high priority
1173
- writeOptions,
1174
- ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
1175
- };
1176
- this.attachConfirmation(transaction);
1177
- this.store.add(transaction);
1178
- // Cancel any pending/in-flight updates for this model to prevent "no rows" errors
1179
- // when the delete executes before the update (race condition fix)
1180
- const canceledUpdates = this.cancelTransactionsForModel(model.id, 'update');
1181
- const entityKey = this.entityKey(actualModelName, model.id);
1182
- const pendingMerge = this.pendingMergeByModel.get(entityKey);
1183
- transaction.sourceMutationIds = [
1184
- ...new Set([
1185
- ...(transaction.sourceMutationIds ?? []),
1186
- // Absorb journal sources only from updates that never sealed an
1187
- // envelope. A sealed update's seal already CONSUMED its journal rows
1188
- // — they belong to that envelope forever — so listing them here
1189
- // poisons this delete's own seal: the new-envelope guard finds its
1190
- // sources missing, rejects with `idempotency_conflict`, and the
1191
- // optimistic delete reverts. That was a user-visible lost delete
1192
- // (edit a layer, then delete it while the edit was on the wire).
1193
- ...canceledUpdates.flatMap((candidate) => candidate.commitEnvelope ? [] : (candidate.sourceMutationIds ?? [])),
1194
- ...(pendingMerge?.sourceMutationIds ?? []),
1195
- ]),
1196
- ];
1197
- this.pendingMergeByModel.delete(entityKey);
1198
- this.inFlightByModel.delete(entityKey);
1199
- // Apply optimistic delete
1200
- if (this.config.enableOptimistic) {
1201
- this.applyOptimisticDelete(model, transaction);
1202
- }
1203
- const createBarrier = this.findCreateBarrierForDelete(actualModelName, model.id);
1204
- if (createBarrier) {
1205
- this.deferDeleteUntilCreateSettles(createBarrier, transaction);
1206
- }
1207
- else {
1208
- // Stage the transaction for the microtask commit; deletes in the same
1209
- // tick are batched together.
1210
- this.stageTransaction(transaction);
1211
- }
1212
- this.emit('transaction:created', transaction);
1213
- return transaction;
1214
- }
1215
- /**
1216
- * Uploads a single attachment, delegating to the mutation executor.
1217
- */
1218
- async uploadAttachment(_file, options, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
1219
- ) {
1220
- return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
1221
- }
1222
- /**
1223
- * Uploads several attachments in one call, delegating to the mutation executor.
1224
- */
1225
- async batchUploadAttachments(_files, items, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
1226
- ) {
1227
- return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
1228
- }
1229
- /**
1230
- * Records an archive and applies it optimistically, then stages it for the
1231
- * next batched commit.
1232
- */
1233
- async archive(model, context, writeOptions, sourceMutationId) {
1234
- this.assertDurableReplayOpen();
1235
- // Use getModelName() rather than constructor.name, which is unreliable once
1236
- // class names are minified.
1237
- const actualModelName = model.getModelName();
1238
- const modelKey = normalizeModelKey(actualModelName);
1239
- const priorityScore = this.computePriorityScore('archive', actualModelName);
1240
- const transaction = {
1241
- id: this.generateId(),
1242
- type: 'archive',
1243
- modelName: actualModelName,
1244
- modelId: model.id,
1245
- modelKey,
1246
- priorityScore,
1247
- previousData: model.toJSON ? model.toJSON() : { ...model },
1248
- context,
1249
- status: 'pending',
1250
- createdAt: Date.now(),
1251
- attempts: 0,
1252
- priority: 'normal',
1253
- writeOptions,
1254
- ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
1255
- };
1256
- this.attachConfirmation(transaction);
1257
- this.store.add(transaction);
1258
- // Stage the transaction for the microtask commit.
1259
- this.stageTransaction(transaction);
1260
- this.emit('transaction:created', transaction);
1261
- return transaction;
1262
- }
1263
- /**
1264
- * Records an unarchive and applies it optimistically, then stages it for the
1265
- * next batched commit.
1266
- */
1267
- async unarchive(model, context) {
1268
- this.assertDurableReplayOpen();
1269
- // Use getModelName() rather than constructor.name, which is unreliable once
1270
- // class names are minified.
1271
- const actualModelName = model.getModelName();
1272
- const modelKey = normalizeModelKey(actualModelName);
1273
- const priorityScore = this.computePriorityScore('unarchive', actualModelName);
1274
- const transaction = {
1275
- id: this.generateId(),
1276
- type: 'unarchive',
1277
- modelName: actualModelName,
1278
- modelId: model.id,
1279
- modelKey,
1280
- priorityScore,
1281
- previousData: model.toJSON ? model.toJSON() : { ...model },
1282
- context,
1283
- status: 'pending',
1284
- createdAt: Date.now(),
1285
- attempts: 0,
1286
- priority: 'normal',
1287
- };
1288
- this.attachConfirmation(transaction);
1289
- this.store.add(transaction);
1290
- // Stage the transaction for the microtask commit.
1291
- this.stageTransaction(transaction);
1292
- this.emit('transaction:created', transaction);
1293
- return transaction;
1294
- }
1295
- /**
1296
- * Places a transaction on the execution queue, coalescing it into an existing
1297
- * same-entity update where possible so redundant writes collapse.
1298
- */
1299
- enqueue(transaction) {
1300
- this.ensureDerivedFields(transaction);
1301
- const modelKey = `${transaction.modelName}:${transaction.modelId}`;
1302
- // Coalescing for updates. Staging already batches everything created in one
1303
- // event-loop tick, so only two cases remain here: merging into an in-flight
1304
- // update, and merging into a pending same-entity update.
1305
- //
1306
- // Retries (attempts > 0 or an already assigned commit envelope) must not
1307
- // take either merge path. A retry is re-enqueued while its own model key may
1308
- // still be marked in `inFlightByModel`; treating it as a concurrent edit
1309
- // would change the already-attempted request, discard its stable envelope,
1310
- // and either defeat maxRetries or duplicate an ambiguously committed write.
1311
- if (transaction.type === 'update' &&
1312
- transaction.attempts === 0 &&
1313
- !transaction.commitEnvelope) {
1314
- const preserveWatermark = hasCommitCoalescingBarrier(transaction.writeOptions);
1315
- // If there is an in-flight update for this model, merge into post-flight buffer
1316
- if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
1317
- const previous = this.pendingMergeByModel.get(modelKey);
1318
- const merged = mergeUpdateData(previous?.data ?? {}, transaction.data || {}, transaction.modelName);
1319
- this.pendingMergeByModel.set(modelKey, {
1320
- data: merged,
1321
- sourceMutationIds: [
1322
- ...new Set([
1323
- ...(previous?.sourceMutationIds ?? []),
1324
- ...(transaction.sourceMutationIds ?? []),
1325
- ]),
1326
- ],
1327
- });
1328
- this.store.remove(transaction.id);
1329
- return;
1330
- }
1331
- // If there's a pending update for same model in execution queue, merge into it
1332
- const pendingInQueue = this.executionQueue.find((t) => t.id !== transaction.id &&
1333
- t.type === 'update' &&
1334
- t.modelId === transaction.modelId &&
1335
- t.modelName === transaction.modelName &&
1336
- !hasCommitCoalescingBarrier(t.writeOptions));
1337
- if (!preserveWatermark && pendingInQueue) {
1338
- pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
1339
- pendingInQueue.sourceMutationIds = [
1340
- ...new Set([
1341
- ...(pendingInQueue.sourceMutationIds ?? []),
1342
- ...(transaction.sourceMutationIds ?? []),
1343
- ]),
1344
- ];
1345
- this.store.remove(transaction.id);
1346
- return;
1347
- }
1348
- }
1349
- // Add to execution queue based on priority
1350
- if (transaction.priority === 'high') {
1351
- this.executionQueue.unshift(transaction);
1352
- }
1353
- else {
1354
- this.executionQueue.push(transaction);
1355
- }
1356
- this.scheduleProcessing(transaction.priority === 'high');
1357
- }
1358
- scheduleProcessing(immediate = false) {
1359
- if (this.processScheduled)
1360
- return;
1361
- // Backpressure: don't schedule another batch while too many transactions
1362
- // are already executing, so the server is not flooded with concurrent
1363
- // requests.
1364
- if (this.executingCount >= this.config.maxExecutingTransactions) {
1365
- this.runtime.logger.debug('[MutationQueue] Backpressure: delaying batch, too many executing', {
1366
- executingCount: this.executingCount,
1367
- max: this.config.maxExecutingTransactions,
1368
- });
1369
- return;
1370
- }
1371
- this.processScheduled = true;
1372
- if (immediate || (this.config.batchDelay ?? 0) <= 0) {
1373
- const schedule = typeof queueMicrotask === 'function'
1374
- ? queueMicrotask
1375
- : (cb) => Promise.resolve().then(cb);
1376
- schedule(() => {
1377
- this.processScheduled = false;
1378
- void this.processBatch();
1379
- });
1380
- return;
1381
- }
1382
- const delay = Math.max(0, this.config.batchDelay);
1383
- this.processTimer = setTimeout(() => {
1384
- this.processTimer = undefined;
1385
- this.processScheduled = false;
1386
- void this.processBatch();
1387
- }, delay);
1388
- }
1389
- /**
1390
- * Processes one batch of transactions in a single commit. Rather than calling
1391
- * the server once per operation type or model, it collects every batchable
1392
- * operation and sends them together; the server applies the mixed operations
1393
- * atomically within one transaction. This turns many round trips into one and
1394
- * greatly reduces batch latency.
1395
- */
1396
- async processBatch() {
1397
- if (this.durableReplayBlock)
1398
- return;
1399
- const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
1400
- if (this.isProcessing || this.executionQueue.length === 0) {
1401
- return;
1402
- }
1403
- this.isProcessing = true;
1404
- // Declare batch outside try so it's accessible in finally for backpressure tracking
1405
- let batch = [];
1406
- await this.runtime.observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
1407
- try {
1408
- // Sort the execution queue by foreign-key priority before selecting a
1409
- // batch, so a parent row is always committed before its children, even
1410
- // across batch boundaries.
1411
- this.executionQueue.sort((a, b) => {
1412
- // Ensure derived fields exist (covers restored/persisted transactions)
1413
- this.ensureDerivedFields(a);
1414
- this.ensureDerivedFields(b);
1415
- if (a.modelName === b.modelName && a.modelId === b.modelId && a.type !== b.type) {
1416
- if (a.type === 'create')
1417
- return -1;
1418
- if (b.type === 'create')
1419
- return 1;
1420
- }
1421
- return a.priorityScore - b.priorityScore;
1422
- });
1423
- // Take a fresh batch or one complete retry envelope. Retry envelopes
1424
- // retain both their original membership and operation order.
1425
- batch = this.takeNextExecutionBatch();
1426
- if (batch.length === 0)
1427
- return;
1428
- const commitIdempotencyKey = this.ensureCommitEnvelope(batch);
1429
- // Track executing count for backpressure
1430
- this.executingCount += batch.length;
1431
- // Mark all as executing
1432
- for (const tx of batch) {
1433
- const key = `${tx.modelName}:${tx.modelId}`;
1434
- if (tx.type === 'update')
1435
- this.inFlightByModel.add(key);
1436
- this.store.updateStatus(tx.id, 'executing');
1437
- }
1438
- // Build every operation for one unified commit (a single round trip).
1439
- const batchOps = [];
1440
- for (const tx of batch) {
1441
- // The per-operation `transactionId` carries the local transaction id
1442
- // over the wire so the server can stamp it on the resulting sync
1443
- // delta. The receive path matches it through
1444
- // {@link UnconfirmedWrites.consumeEcho} to avoid applying the
1445
- // client's own optimistic change twice. This is separate from the
1446
- // batch-level idempotency key recorded in `mutation_log`.
1447
- const op = applyWriteOptions({
1448
- type: TX_TYPE_TO_MUTATION_OP[tx.type],
1449
- model: tx.modelKey,
1450
- id: tx.modelId,
1451
- input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
1452
- transactionId: tx.id,
1453
- }, tx);
1454
- batchOps.push({ tx, op });
1455
- }
1456
- // Execute the unified commit for every operation in one round trip.
1457
- if (batchOps.length > 0) {
1458
- let dispatchStarted = false;
1459
- try {
1460
- const durableEnvelope = await this.sealDurableCommit({
1461
- idempotencyKey: commitIdempotencyKey,
1462
- origin: 'model_batch',
1463
- operations: batchOps.map(({ op }) => op),
1464
- sourceMutationIds: this.sourceMutationIdsFor(batch),
1465
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
1466
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
1467
- sequence: batch[0]?.commitEnvelope?.sequence,
1468
- });
1469
- const operations = durableEnvelope.operations;
1470
- // Capture lastSyncId from the server response for threshold-based
1471
- // confirmation.
1472
- //
1473
- // The queue owns request identity. A lost acknowledgement may be
1474
- // retried after a backoff or reconnect, so every retry must send
1475
- // the exact key assigned before the first transport attempt.
1476
- this.assertEnvelopeInsideReplayWindow(durableEnvelope);
1477
- dispatchStarted = true;
1478
- const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(operations, {
1479
- idempotencyKey: commitIdempotencyKey,
1480
- }));
1481
- await this.persistDurableCommitAcceptance(durableEnvelope, result);
1482
- const lastSyncId = result.lastSyncId;
1483
- const notifications = result.notifications;
1484
- const { holdsEntireBatch, heldTargets, notificationsByTarget, } = this.classifyReceiptNotifications(batchOps.map(({ op }) => ({ model: op.model, id: op.id })), notifications ?? []);
1485
- for (const { tx } of batchOps) {
1486
- const txTarget = this.receiptTargetKey(tx.modelKey, tx.modelId);
1487
- const txNotifs = holdsEntireBatch
1488
- ? notifications
1489
- : notificationsByTarget.get(txTarget);
1490
- if (txNotifs && txNotifs.length > 0) {
1491
- this.commitNotifications.set(tx.id, txNotifs);
1492
- }
1493
- }
1494
- const missingIds = new Set(result.missingIds ?? []);
1495
- const settlingBatchOps = [];
1496
- for (const entry of batchOps) {
1497
- const { tx } = entry;
1498
- if (missingIds.has(tx.modelId)) {
1499
- await this.handleFailure(tx, new AbloNotFoundError(`${tx.modelName}/${tx.modelId} was not found or is outside this credential's scope.`, [tx.modelId]));
1500
- continue;
1501
- }
1502
- if (holdsEntireBatch ||
1503
- heldTargets.has(this.receiptTargetKey(tx.modelKey, tx.modelId))) {
1504
- await this.rollbackOptimistic(tx, 'conflict_server_wins');
1505
- this.store.updateStatus(tx.id, 'completed');
1506
- this.emit('transaction:completed', tx);
1507
- this.emit(`transaction:completed:${tx.id}`, tx);
1508
- this.optimisticUpdates.delete(tx.id);
1509
- continue;
1510
- }
1511
- settlingBatchOps.push(entry);
1512
- }
1513
- // A forwarded write has been accepted, but the source database
1514
- // remains authoritative. It must not enter any of the confirmed
1515
- // paths below: in particular, a queued DELETE with sync id 0 is
1516
- // not the hosted-path idempotent-delete shortcut, and a later
1517
- // unrelated delta cannot stand in for the source echo.
1518
- if (result.status === 'queued') {
1519
- // Keep the exact envelope crash-durable while any forwarded
1520
- // member is awaiting its WAL echo. If coordination/missing-row
1521
- // handling consumed the whole request, there is no echo left
1522
- // to await and the durable intent is definitive.
1523
- if (settlingBatchOps.length === 0) {
1524
- await this.removeDurableCommit(commitIdempotencyKey);
1525
- }
1526
- for (const { tx } of settlingBatchOps) {
1527
- tx.requiresCorrelatedDelta = true;
1528
- tx.syncIdNeededForCompletion = undefined;
1529
- tx.correlationId = result.correlationId;
1530
- const echoSyncId = result.correlationId
1531
- ? this.recentDeltaCorrelations.get(result.correlationId)
1532
- : undefined;
1533
- if (echoSyncId !== undefined) {
1534
- this.store.updateStatus(tx.id, 'completed');
1535
- this.emit('transaction:completed', tx);
1536
- this.emit(`transaction:completed:${tx.id}`, tx);
1537
- this.optimisticUpdates.delete(tx.id);
1538
- continue;
1539
- }
1540
- this.store.updateStatus(tx.id, 'awaiting_delta');
1541
- this.scheduleReplicationLagTimeout(tx.id, commitIdempotencyKey, result.correlationId);
1542
- this.runtime.logger.debug('tx:awaiting_delta', {
1543
- txId: tx.id.slice(0, 8),
1544
- model: tx.modelName,
1545
- reason: 'queued_forward_waiting_for_correlated_echo',
1546
- });
1547
- this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
1548
- }
1549
- if (settlingBatchOps.length > 0 &&
1550
- settlingBatchOps.every(({ tx }) => tx.status === 'completed')) {
1551
- await this.removeDurableCommit(commitIdempotencyKey);
1552
- }
1553
- }
1554
- else {
1555
- await this.removeDurableCommit(commitIdempotencyKey);
1556
- this.noteAck(lastSyncId);
1557
- // A lastSyncId of 0 means the mutation succeeded but the server
1558
- // emitted no sync delta; record that anomaly for observability.
1559
- if (lastSyncId === 0) {
1560
- this.runtime.observability.captureCommitZeroSyncId({
1561
- operationCount: operations.length,
1562
- operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
1563
- });
1564
- }
1565
- // Mark each remaining transaction with the sync-id threshold
1566
- // that will confirm it: any delta whose id is at least
1567
- // lastSyncId.
1568
- for (const { tx } of settlingBatchOps) {
1569
- tx.syncIdNeededForCompletion = lastSyncId;
1570
- // Safety net: a confirmed zero-sync DELETE is idempotent.
1571
- // This shortcut is intentionally unreachable for queued
1572
- // forwards, which must wait for their correlated echo.
1573
- if (lastSyncId === 0 && tx.type === 'delete') {
1574
- this.store.updateStatus(tx.id, 'completed');
1575
- this.emit('transaction:completed', tx);
1576
- this.emit(`transaction:completed:${tx.id}`, tx);
1577
- this.optimisticUpdates.delete(tx.id);
1578
- this.runtime.logger.debug('tx:confirm_delete_zero_syncid', {
1579
- txId: tx.id.slice(0, 8),
1580
- model: tx.modelName,
1581
- reason: 'delete_idempotent_no_delta',
1582
- });
1583
- continue;
1584
- }
1585
- // A real watermark is the established hosted-path
1586
- // confirmation. A zero watermark on a non-delete stays on
1587
- // the anomaly/reconciliation path.
1588
- if (lastSyncId > 0) {
1589
- this.store.updateStatus(tx.id, 'completed');
1590
- this.emit('transaction:completed', tx);
1591
- this.emit(`transaction:completed:${tx.id}`, tx);
1592
- this.optimisticUpdates.delete(tx.id);
1593
- this.runtime.logger.debug('tx:confirm_ack', {
1594
- txId: tx.id.slice(0, 8),
1595
- model: tx.modelName,
1596
- serverSyncId: lastSyncId,
1597
- lastSeenSyncId: this.lastSeenSyncId,
1598
- });
1599
- }
1600
- else {
1601
- this.store.updateStatus(tx.id, 'awaiting_delta');
1602
- this.runtime.logger.debug('tx:awaiting_delta', {
1603
- txId: tx.id.slice(0, 8),
1604
- model: tx.modelName,
1605
- neededSyncId: lastSyncId,
1606
- lastSeenSyncId: this.lastSeenSyncId,
1607
- reason: 'zero_sync_id_anomaly',
1608
- });
1609
- this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
1610
- }
1611
- }
1612
- }
1613
- }
1614
- catch (error) {
1615
- const errorMessage = error.message || '';
1616
- if (dispatchStarted && this.isDefinitiveRejection(error)) {
1617
- await this.removeDurableCommit(commitIdempotencyKey);
1618
- }
1619
- // Surface the raw server rejection for the whole batch so a
1620
- // cascaded failure — for example a foreign-key violation that
1621
- // rolls back a multi-operation transaction — is attributable to a
1622
- // specific cause rather than showing as a generic permanent error
1623
- // on each operation.
1624
- const abloErr = error instanceof AbloError ? error : undefined;
1625
- // SyncWebSocket attaches a `diagnostics` snapshot to its
1626
- // "not connected" / "closed while in flight" rejections.
1627
- // Surface it here so the warn line attributes the drop to
1628
- // a specific cause (handshake reject, heartbeat zombie,
1629
- // session expiry, …) instead of just "AbloConnectionError".
1630
- const readDiagnostics = (e) => {
1631
- let cur = e;
1632
- // Walk up to 3 wrap layers (current err → its cause → its
1633
- // cause's cause) so diagnostics survive AbloConnectionError
1634
- // wrapping in Ablo.commit() and any future wrappers.
1635
- for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
1636
- if ('diagnostics' in cur && cur.diagnostics) {
1637
- return (cur).diagnostics;
1638
- }
1639
- cur = cur.cause;
1640
- }
1641
- return undefined;
1642
- };
1643
- const diagnostics = readDiagnostics(error);
1644
- // Mechanic-level breadcrumb. Every batch rejection — transient
1645
- // (reconnect retries it) or permanent (`handleFailure` logs the
1646
- // authoritative `warn` with the same typed cause) — passes
1647
- // through here. Logging it at `warn` made one rejected write
1648
- // surface three identical dumps; keep it at `debug`.
1649
- this.runtime.logger.debug('[MutationQueue] Batch commit rejected', {
1650
- batchSize: batchOps.length,
1651
- models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
1652
- errorType: abloErr?.type ?? error?.name,
1653
- errorCode: abloErr?.code,
1654
- httpStatus: abloErr?.httpStatus,
1655
- requestId: abloErr?.requestId,
1656
- message: errorMessage,
1657
- diagnostics,
1658
- });
1659
- // Handle "no rows in result set" gracefully: the row was already
1660
- // deleted, so for update and delete operations this is effectively
1661
- // success — the intended end state already holds — and the
1662
- // transaction is treated as completed.
1663
- if (errorMessage.includes('no rows in result set')) {
1664
- if (dispatchStarted) {
1665
- await this.removeDurableCommit(commitIdempotencyKey);
1666
- }
1667
- this.runtime.logger.info('[MutationQueue] Graceful handling: entity already deleted', {
1668
- batchSize: batchOps.length,
1669
- });
1670
- for (const { tx, op } of batchOps) {
1671
- if (op.type === 'UPDATE' || op.type === 'DELETE') {
1672
- // Row already gone: the intended state holds, mark completed.
1673
- this.store.updateStatus(tx.id, 'completed');
1674
- this.emit('transaction:completed', tx);
1675
- this.runtime.logger.debug('[MutationQueue] Orphaned transaction treated as success', {
1676
- txId: tx.id.slice(0, 12),
1677
- model: tx.modelName,
1678
- type: op.type,
1679
- });
1680
- }
1681
- else {
1682
- // CREATE operations on non-existent parent are real failures
1683
- await this.handleFailure(tx, error);
1684
- }
1685
- }
1686
- }
1687
- else {
1688
- // Handle other batch failures - mark all as failed
1689
- for (const { tx } of batchOps) {
1690
- await this.handleFailure(tx, error);
1691
- }
1692
- }
1693
- }
1694
- }
1695
- // Handle post-execution merge for updates
1696
- for (const tx of batch) {
1697
- const key = `${tx.modelName}:${tx.modelId}`;
1698
- if (tx.type === 'update') {
1699
- this.inFlightByModel.delete(key);
1700
- const pending = this.pendingMergeByModel.get(key);
1701
- if (pending && Object.keys(pending.data).length > 0) {
1702
- // Create a single merged follow-up transaction
1703
- const followUp = {
1704
- id: this.generateId(),
1705
- type: 'update',
1706
- modelName: tx.modelName,
1707
- modelId: tx.modelId,
1708
- modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
1709
- data: pending.data,
1710
- previousData: undefined,
1711
- context: tx.context,
1712
- status: 'pending',
1713
- createdAt: Date.now(),
1714
- attempts: 0,
1715
- priority: 'normal',
1716
- priorityScore: this.computePriorityScore('update', tx.modelName),
1717
- sourceMutationIds: pending.sourceMutationIds,
1718
- };
1719
- this.pendingMergeByModel.delete(key);
1720
- this.store.add(followUp);
1721
- this.enqueue(followUp);
1722
- }
1723
- }
1724
- }
1725
- }
1726
- finally {
1727
- this.isProcessing = false;
1728
- // Decrement executing count for backpressure tracking
1729
- this.executingCount -= batch.length;
1730
- // Process next batch if needed
1731
- if (this.executionQueue.length > 0 && batch.length > 0) {
1732
- this.scheduleProcessing(true);
1733
- }
1734
- const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
1735
- this.runtime.logger.debug('txn:batch', batchEnd - batchStart, {
1736
- maxBatchSize: this.config.maxBatchSize,
1737
- remaining: this.executionQueue.length,
1738
- executingCount: this.executingCount,
1739
- });
1740
- }
1741
- }, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
1742
- }
1743
- rememberDeltaCorrelation(correlationId, syncId) {
1744
- // Refresh insertion order when a replay repeats the same correlation id.
1745
- this.recentDeltaCorrelations.delete(correlationId);
1746
- this.recentDeltaCorrelations.set(correlationId, syncId);
1747
- if (this.recentDeltaCorrelations.size <= 2_048)
1748
- return;
1749
- const oldest = this.recentDeltaCorrelations.keys().next().value;
1750
- if (typeof oldest === 'string')
1751
- this.recentDeltaCorrelations.delete(oldest);
1752
- }
1753
- queuedCommitEchoSyncId(tx) {
1754
- return tx.correlationId
1755
- ? this.recentDeltaCorrelations.get(tx.correlationId)
1756
- : undefined;
1757
- }
1758
- queuedCommitMatchesCorrelation(tx, correlationId) {
1759
- return tx.correlationId !== undefined && tx.correlationId === correlationId;
1760
- }
1761
- completeQueuedCommit(tx, syncId) {
1762
- if (tx.status !== 'awaiting_delta')
1763
- return;
1764
- this.clearReplicationLagState(tx.id);
1765
- tx.lastSyncId = syncId;
1766
- tx.status = 'completed';
1767
- // The queued receipt was only acceptance. The correlated source echo is
1768
- // the first definitive success and therefore the point where the durable
1769
- // replay envelope may be removed.
1770
- void this.removeDurableCommit(tx.id);
1771
- this.emitCommitLifecycle('transaction:completed', tx);
1772
- this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
1773
- }
1774
- /**
1775
- * Confirms awaiting writes. Hosted/anomaly receipts keep their sync-id
1776
- * threshold semantics; queued forwards require the exact server correlation.
1777
- */
1778
- onDeltaReceived(syncId, _transactionId, correlationId) {
1779
- if (correlationId)
1780
- this.rememberDeltaCorrelation(correlationId, syncId);
1781
- const correlatedModelTransactions = correlationId
1782
- ? this.store.getByStatus('awaiting_delta').filter((tx) => tx.requiresCorrelatedDelta === true &&
1783
- tx.correlationId !== undefined &&
1784
- tx.correlationId === correlationId)
1785
- : [];
1786
- this.deltaConfirmation.onDeltaReceived(syncId, correlationId);
1787
- if (correlatedModelTransactions.length > 0) {
1788
- const envelopeIds = new Set();
1789
- for (const tx of correlatedModelTransactions) {
1790
- this.clearReplicationLagState(tx.id);
1791
- if (tx.commitEnvelope)
1792
- envelopeIds.add(tx.commitEnvelope.idempotencyKey);
1793
- }
1794
- for (const envelopeId of envelopeIds) {
1795
- const envelopeStillAwaiting = this.store
1796
- .getByStatus('awaiting_delta')
1797
- .some((tx) => tx.commitEnvelope?.idempotencyKey === envelopeId);
1798
- if (!envelopeStillAwaiting)
1799
- void this.removeDurableCommit(envelopeId);
1800
- }
1801
- }
1802
- if (!correlationId)
1803
- return;
1804
- for (const tx of this.commitStore.values()) {
1805
- if (tx.status !== 'awaiting_delta')
1806
- continue;
1807
- if (this.queuedCommitMatchesCorrelation(tx, correlationId)) {
1808
- this.completeQueuedCommit(tx, syncId);
1809
- }
1810
- }
1811
- }
1812
- // Schedule the retry-and-reconciliation wait for a transaction's confirming
1813
- // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1814
- scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1815
- this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1816
- }
1817
- /**
1818
- * Resolves once the given transaction is confirmed and rejects if it fails.
1819
- * The confirming delta's timeout is handled by
1820
- * {@link scheduleDeltaConfirmationTimeout}.
1821
- */
1822
- waitForConfirmation(transactionId) {
1823
- return new Promise((resolve, reject) => {
1824
- // Check if already completed
1825
- const tx = this.store.get(transactionId);
1826
- if (tx?.status === 'completed') {
1827
- resolve();
1828
- return;
1829
- }
1830
- const lagError = this.replicationLagErrors.get(transactionId);
1831
- if (lagError) {
1832
- reject(lagError);
1833
- return;
1834
- }
1835
- const onCompleted = () => {
1836
- cleanup();
1837
- resolve();
1838
- };
1839
- const onFailed = ({ error }) => {
1840
- cleanup();
1841
- reject(error);
1842
- };
1843
- const onLagged = ({ error }) => {
1844
- cleanup();
1845
- reject(error);
1846
- };
1847
- const cleanup = () => {
1848
- this.off(`transaction:completed:${transactionId}`, onCompleted);
1849
- this.off(`transaction:failed:${transactionId}`, onFailed);
1850
- this.off(`transaction:confirmation_lagged:${transactionId}`, onLagged);
1851
- };
1852
- // Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
1853
- this.on(`transaction:completed:${transactionId}`, onCompleted);
1854
- this.on(`transaction:failed:${transactionId}`, onFailed);
1855
- this.on(`transaction:confirmation_lagged:${transactionId}`, onLagged);
1856
- });
1857
- }
1858
- // Reports whether a client mutation id is known to this queue, which helps
1859
- // identify a delta as this client's own echo.
1860
- hasClientMutationId(id) {
1861
- return !!this.store.get(id) || this.commitStore.has(id);
1862
- }
1863
- /**
1864
- * Enqueues a pre-built, multi-operation atomic commit — the
1865
- * `ablo.commits.create()` path. The caller supplies the operations; the queue
1866
- * only retries on reconnect and de-duplicates, and does not apply the change
1867
- * optimistically or reorder for foreign keys. A duplicate `clientTxId`
1868
- * already in flight is ignored. After an accepted write exceeds its
1869
- * replication-confirmation deadline, an explicit same-key retry reuses the
1870
- * exact sealed envelope as an idempotent status probe; the server's
1871
- * `mutation_log` still prevents a second logical write.
1872
- */
1873
- async enqueueCommit(clientTxId, operations, options = {}) {
1874
- this.assertDurableReplayOpen();
1875
- const existing = this.commitStore.get(clientTxId);
1876
- if (existing) {
1877
- await existing.sealPromise;
1878
- const existingIntent = stableStringify({
1879
- operations: existing.operations,
1880
- reads: existing.reads ?? null,
1881
- track: existing.track ?? null,
1882
- });
1883
- const incomingIntent = stableStringify({
1884
- operations,
1885
- reads: options.reads ?? null,
1886
- track: options.track ?? null,
1887
- });
1888
- if (existingIntent !== incomingIntent) {
1889
- throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
1890
- }
1891
- if (existing.status === 'awaiting_delta' &&
1892
- this.replicationLagErrors.has(existing.id)) {
1893
- // An explicit same-key retry after the confirmation deadline is an
1894
- // idempotent status probe, not a second logical write. Reuse the exact
1895
- // durable envelope and ask the server again: once the WAL echo has
1896
- // materialized, mutation-log replay upgrades the old queued receipt to
1897
- // confirmed. Passive awaiting remains untouched before the deadline,
1898
- // so duplicate callers cannot cause eager re-dispatch.
1899
- this.clearReplicationLagState(existing.id);
1900
- existing.status = 'pending';
1901
- this.commitLane.push(existing);
1902
- }
1903
- if (existing.status === 'pending')
1904
- void this.processCommitLane();
1905
- return;
1906
- }
1907
- this.emitCommitLifecycle('commit:staging', {
1908
- clientTxId,
1909
- operations,
1910
- });
1911
- const tx = {
1912
- id: clientTxId,
1913
- kind: 'commit',
1914
- operations: [...operations],
1915
- ...(options.reads ? { reads: options.reads } : {}),
1916
- ...(options.track ? { track: options.track } : {}),
1917
- status: 'pending',
1918
- createdAt: Date.now(),
1919
- attempts: 0,
1920
- sealedAt: Date.now(),
1921
- sequence: this.nextCommitSequence(),
1922
- };
1923
- this.commitStore.set(clientTxId, tx);
1924
- tx.sealPromise = this.sealDurableCommit({
1925
- idempotencyKey: tx.id,
1926
- origin: 'atomic_commit',
1927
- operations: tx.operations,
1928
- commitOptions: {
1929
- ...(tx.reads ? { reads: tx.reads } : {}),
1930
- ...(tx.track ? { track: tx.track } : {}),
1931
- },
1932
- createdAt: tx.createdAt,
1933
- sealedAt: tx.sealedAt,
1934
- sequence: tx.sequence,
1935
- }).then((envelope) => {
1936
- tx.durableEnvelope = envelope;
1937
- tx.operations = envelope.operations.map((operation) => ({ ...operation }));
1938
- });
1939
- try {
1940
- await tx.sealPromise;
1941
- }
1942
- catch (error) {
1943
- this.commitStore.delete(clientTxId);
1944
- this.emitCommitLifecycle('commit:seal_failed', { clientTxId });
1945
- throw error;
1946
- }
1947
- finally {
1948
- tx.sealPromise = undefined;
1949
- }
1950
- this.commitLane.push(tx);
1951
- // Emit the envelope on its own event so the undo stream can record
1952
- // commit-lane writes as well. This deliberately avoids `transaction:created`,
1953
- // which also feeds the optimistic-echo tracker: commit-lane operations apply
1954
- // nothing optimistically and so have no echo to suppress.
1955
- this.emitCommitLifecycle('commit:created', {
1956
- clientTxId,
1957
- operations: tx.operations,
1958
- });
1959
- void this.processCommitLane();
1960
- }
1961
- /**
1962
- * Drains the pending commit-lane envelopes one at a time. A transient
1963
- * failure, such as a network error, leaves the envelope at the head of the
1964
- * lane in `pending` and stops; reconnect re-kicks it through
1965
- * {@link flushOfflineQueue}. A permanent failure emits
1966
- * `transaction:failed:<id>` and drops the envelope.
1967
- */
1968
- async processCommitLane() {
1969
- if (this.commitProcessing || this.durableReplayBlock)
1970
- return;
1971
- this.commitProcessing = true;
1972
- try {
1973
- while (this.commitLane.length > 0) {
1974
- const tx = this.commitLane[0];
1975
- if (!tx)
1976
- break;
1977
- if (tx.status !== 'pending') {
1978
- this.commitLane.shift();
1979
- continue;
1980
- }
1981
- tx.status = 'executing';
1982
- tx.attempts += 1;
1983
- let dispatchStarted = false;
1984
- try {
1985
- const durableEnvelope = tx.durableEnvelope ??
1986
- (await this.sealDurableCommit({
1987
- idempotencyKey: tx.id,
1988
- origin: 'atomic_commit',
1989
- operations: tx.operations,
1990
- sourceMutationIds: tx.sourceMutationIds,
1991
- commitOptions: {
1992
- ...(tx.reads ? { reads: tx.reads } : {}),
1993
- ...(tx.track ? { track: tx.track } : {}),
1994
- },
1995
- createdAt: tx.createdAt,
1996
- sealedAt: tx.sealedAt,
1997
- sequence: tx.sequence,
1998
- }));
1999
- tx.durableEnvelope = durableEnvelope;
2000
- this.assertEnvelopeInsideReplayWindow(durableEnvelope);
2001
- dispatchStarted = true;
2002
- const result = this.parseMutationCommitResult(await this.dispatchCommitBounded(durableEnvelope.operations, {
2003
- idempotencyKey: tx.id,
2004
- ...(durableEnvelope.commitOptions.reads
2005
- ? { reads: durableEnvelope.commitOptions.reads }
2006
- : {}),
2007
- ...(durableEnvelope.commitOptions.track
2008
- ? { track: durableEnvelope.commitOptions.track }
2009
- : {}),
2010
- }));
2011
- tx.durableEnvelope = await this.persistDurableCommitAcceptance(durableEnvelope, result);
2012
- tx.lastSyncId = result.lastSyncId;
2013
- const notifications = result.notifications;
2014
- if (notifications && notifications.length > 0) {
2015
- this.commitNotifications.set(tx.id, notifications);
2016
- }
2017
- const missingIds = result.missingIds;
2018
- if (missingIds && missingIds.length > 0) {
2019
- this.commitMissingIds.set(tx.id, missingIds);
2020
- }
2021
- this.commitLane.shift();
2022
- if (result.status === 'queued') {
2023
- tx.correlationId = result.correlationId;
2024
- tx.status = 'awaiting_delta';
2025
- const echoSyncId = this.queuedCommitEchoSyncId(tx);
2026
- if (echoSyncId !== undefined) {
2027
- this.completeQueuedCommit(tx, echoSyncId);
2028
- }
2029
- else {
2030
- this.scheduleReplicationLagTimeout(tx.id, tx.id, result.correlationId);
2031
- this.runtime.logger.debug('[MutationQueue] commit lane awaiting source echo', {
2032
- txId: tx.id.slice(0, 12),
2033
- });
2034
- }
2035
- }
2036
- else {
2037
- await this.removeDurableCommit(tx.id);
2038
- this.noteAck(tx.lastSyncId);
2039
- tx.status = 'completed';
2040
- // Guarded: a throwing observer here would land in the catch below,
2041
- // whose permanent branch shifts the lane a second time and rejects a
2042
- // commit the server has already durably applied.
2043
- this.emitCommitLifecycle('transaction:completed', tx);
2044
- this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
2045
- }
2046
- }
2047
- catch (err) {
2048
- const error = err instanceof Error ? err : new Error(String(err));
2049
- if (dispatchStarted && this.isDefinitiveRejection(error)) {
2050
- await this.removeDurableCommit(tx.id);
2051
- }
2052
- // A transport that is DOWN is not a failing write: the envelope is
2053
- // meant to wait for reconnect, which is what makes a commit survive a
2054
- // dropped connection. A transient error the SERVER keeps returning is
2055
- // a different thing, and this lane has no attempt bound of its own —
2056
- // so a 5xx carrying no wire code reads as transient on every kick,
2057
- // sits at the head of the lane, and `waitForCommitReceipt` never
2058
- // settles. The caller sees a write that neither lands nor fails.
2059
- //
2060
- // Counting only the non-connection failures keeps offline waiting
2061
- // unbounded while giving a repeating server rejection an end.
2062
- if (!(error instanceof AbloConnectionError)) {
2063
- tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
2064
- }
2065
- const exhausted = (tx.transientAttempts ?? 0) > this.config.maxRetries;
2066
- if (!this.isPermanentError(error) && !exhausted) {
2067
- // Transient: leave it at the head and retry on the next kick
2068
- // (reconnect or the next enqueueCommit) rather than tight-looping
2069
- // while the connection is down.
2070
- tx.status = 'pending';
2071
- this.runtime.logger.debug('[MutationQueue] commit lane transient', {
2072
- txId: tx.id.slice(0, 12),
2073
- attempts: tx.attempts,
2074
- transientAttempts: tx.transientAttempts ?? 0,
2075
- message: error.message,
2076
- });
2077
- break;
2078
- }
2079
- tx.status = 'failed';
2080
- tx.error = error;
2081
- this.commitLane.shift();
2082
- // Internal bookkeeping; the consumer-facing rejection is emitted on
2083
- // 'transaction:failed' and surfaced by the permanent-error headline,
2084
- // so this line stays at debug.
2085
- this.runtime.logger.debug('[MutationQueue] commit lane permanent error', {
2086
- txId: tx.id.slice(0, 12),
2087
- attempts: tx.attempts,
2088
- message: error.message,
2089
- });
2090
- this.emitCommitLifecycle('transaction:failed', { transaction: tx, error, permanent: true });
2091
- this.emitCommitLifecycle(`transaction:failed:${tx.id}`, { error });
2092
- }
2093
- }
2094
- }
2095
- finally {
2096
- this.commitProcessing = false;
2097
- }
2098
- }
2099
- /**
2100
- * Resolves once a commit-lane transaction is confirmed, returning the server's
2101
- * `lastSyncId` and any stale-context notifications; rejects on permanent
2102
- * failure. This backs the `wait: 'confirmed'` semantics of
2103
- * `ablo.commits.create()`.
2104
- */
2105
- waitForCommitReceipt(clientTxId) {
2106
- // Drain any stale-context notifications stamped for this tx on the ack.
2107
- const drainNotifications = () => {
2108
- const n = this.commitNotifications.get(clientTxId);
2109
- if (!n)
2110
- return undefined;
2111
- this.commitNotifications.delete(clientTxId);
2112
- return n.length > 0 ? n : undefined;
2113
- };
2114
- const drainMissingIds = () => {
2115
- const ids = this.commitMissingIds.get(clientTxId);
2116
- if (!ids)
2117
- return undefined;
2118
- this.commitMissingIds.delete(clientTxId);
2119
- return ids.length > 0 ? ids : undefined;
2120
- };
2121
- const receipt = (lastSyncId) => {
2122
- const notifications = drainNotifications();
2123
- const missingIds = drainMissingIds();
2124
- return {
2125
- lastSyncId,
2126
- notifications,
2127
- ...(missingIds ? { missingIds } : {}),
2128
- };
2129
- };
2130
- return new Promise((resolve, reject) => {
2131
- const existing = this.commitStore.get(clientTxId);
2132
- if (existing?.status === 'completed') {
2133
- resolve(receipt(existing.lastSyncId ?? 0));
2134
- return;
2135
- }
2136
- if (existing?.status === 'failed' && existing.error) {
2137
- reject(existing.error);
2138
- return;
2139
- }
2140
- const lagError = this.replicationLagErrors.get(clientTxId);
2141
- if (lagError) {
2142
- reject(lagError);
2143
- return;
2144
- }
2145
- const onCompleted = (tx) => {
2146
- cleanup();
2147
- resolve(receipt(tx.lastSyncId ?? 0));
2148
- };
2149
- const onFailed = ({ error }) => {
2150
- cleanup();
2151
- reject(error);
2152
- };
2153
- const onLagged = ({ error }) => {
2154
- cleanup();
2155
- reject(error);
2156
- };
2157
- const cleanup = () => {
2158
- this.off(`transaction:completed:${clientTxId}`, onCompleted);
2159
- this.off(`transaction:failed:${clientTxId}`, onFailed);
2160
- this.off(`transaction:confirmation_lagged:${clientTxId}`, onLagged);
2161
- };
2162
- this.on(`transaction:completed:${clientTxId}`, onCompleted);
2163
- this.on(`transaction:failed:${clientTxId}`, onFailed);
2164
- this.on(`transaction:confirmation_lagged:${clientTxId}`, onLagged);
2165
- });
2166
- }
2167
- isReorderPayload(data) {
2168
- if (!data || typeof data !== 'object')
2169
- return false;
2170
- return 'order' in data || 'orderKey' in data || 'position' in data;
2171
- }
2172
- /**
2173
- * Classifies an error as transient (worth retrying) or permanent. The
2174
- * approach is deliberately conservative: only known-transient errors are
2175
- * retried, and anything unrecognized is treated as permanent so a failing
2176
- * write cannot loop forever.
2177
- *
2178
- * Transient (retried):
2179
- * - Network failures, connection errors, and timeouts.
2180
- * - Server errors (HTTP 5xx).
2181
- * - Rate limiting (HTTP 429).
2182
- *
2183
- * Permanent (not retried), among others:
2184
- * - Validation errors and constraint violations.
2185
- * - Not found, unauthorized, and forbidden.
2186
- * - Any other business-logic error from the server.
2187
- */
2188
- isPermanentError(error) {
2189
- // Typed connection error (e.g. ws_not_ready, transport timeout) is
2190
- // always transient — the message text varies ("SyncWebSocket not
2191
- // connected", "commit timed out after ...") and string-matching them
2192
- // is brittle. Class identity is the right signal.
2193
- if (error instanceof AbloConnectionError) {
2194
- return false;
2195
- }
2196
- // Registry-driven retryability is authoritative when the error carries a
2197
- // known wire code: the error contract (errorCodes.ts) decides whether the
2198
- // same request can succeed on retry, not message string-matching. This is
2199
- // why rejected commits must arrive as typed AbloErrors (see
2200
- // `errorFromWire`) — a bare `Error` has no code and falls through to the
2201
- // heuristics below. Unknown / forward-compat codes (`errorCodeSpec`
2202
- // returns undefined) also fall through, preserving the safe default.
2203
- if (error instanceof AbloError && error.code) {
2204
- const spec = errorCodeSpec(error.code);
2205
- if (spec)
2206
- return !spec.retryable;
2207
- }
2208
- const message = error?.message?.toLowerCase() || '';
2209
- // Network/connection errors are transient - retry these
2210
- const isNetworkError = message.includes('failed to fetch') ||
2211
- message.includes('network error') ||
2212
- message.includes('networkerror') ||
2213
- message.includes('connection refused') ||
2214
- message.includes('connection reset') ||
2215
- message.includes('timeout') ||
2216
- message.includes('econnrefused') ||
2217
- message.includes('econnreset') ||
2218
- message.includes('etimedout') ||
2219
- message.includes('socket hang up');
2220
- if (isNetworkError) {
2221
- return false; // Transient - retry
2222
- }
2223
- // Check HTTP status codes
2224
- const status = extractStatusCode(error);
2225
- // 5xx server errors and 429 rate limiting are transient - retry
2226
- if (status !== undefined) {
2227
- if (status >= 500 || status === 429) {
2228
- return false; // Transient - retry
2229
- }
2230
- // Any other status code (4xx except 429) is permanent
2231
- return true;
2232
- }
2233
- // GraphQL errors with HTTP 200 but error payload are permanent
2234
- // These are validation/business logic errors that won't change on retry
2235
- const responseErrors = asTransportError(error).response?.errors;
2236
- if (Array.isArray(responseErrors) && responseErrors.length > 0) {
2237
- return true; // Permanent - don't retry
2238
- }
2239
- // Default: treat unknown errors as permanent to prevent infinite loops
2240
- // This is the safe default - better to fail fast than retry forever
2241
- return true;
2242
- }
2243
- /** True only when the server definitively rejected before applying. */
2244
- isDefinitiveRejection(error) {
2245
- if (error instanceof AbloError && error.code) {
2246
- const spec = errorCodeSpec(error.code);
2247
- if (spec)
2248
- return !spec.retryable;
2249
- }
2250
- const status = extractStatusCode(error);
2251
- return status !== undefined && status >= 400 && status < 500 && status !== 429;
2252
- }
2253
- /**
2254
- * Handles a failed transaction: retries transient failures with backoff and
2255
- * rolls back permanent ones, settling the transaction's confirmation promise
2256
- * either way.
2257
- */
2258
- async handleFailure(transaction, error) {
2259
- transaction.attempts++;
2260
- // Check whether this is a permanent error that should not be retried.
2261
- if (this.isPermanentError(error)) {
2262
- // Logged at warn: a permanent error means the server rejected the write,
2263
- // so the developer should see the reason in the console. The typed
2264
- // AbloError fields (`type`, `code`, `httpStatus`) are included so the
2265
- // cause is visible — for example a foreign-key violation
2266
- // (AbloValidationError) versus expired authentication
2267
- // (AbloAuthenticationError).
2268
- try {
2269
- const abloErr = error instanceof AbloError ? error : undefined;
2270
- const details = {
2271
- txId: transaction.id.slice(0, 8),
2272
- type: transaction.type,
2273
- model: transaction.modelName,
2274
- modelId: transaction.modelId.slice(0, 12),
2275
- errorType: abloErr?.type ?? error?.name,
2276
- errorCode: abloErr?.code,
2277
- httpStatus: abloErr?.httpStatus,
2278
- requestId: abloErr?.requestId,
2279
- message: error?.message,
2280
- inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
2281
- };
2282
- // A `create` whose id already exists is the benign idempotency case:
2283
- // "this row is already there." It's the least alarming permanent
2284
- // error, so it doesn't warrant a `warn` — `info` keeps it visible
2285
- // without crying wolf. Everything else (FK violation, auth expiry,
2286
- // server 500) stays at `warn`.
2287
- const isBenignIdempotent = transaction.type === 'create' &&
2288
- (abloErr?.code === 'unique_violation' ||
2289
- abloErr?.type === 'AbloIdempotencyError');
2290
- // Demote exact repeats (same write rejected for the same reason on
2291
- // each reconnect replay) to `debug` so the loop logs once.
2292
- const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
2293
- const isRepeat = sig === this.lastPermanentErrorSig;
2294
- this.lastPermanentErrorSig = sig;
2295
- const logger = this.runtime.logger;
2296
- // Two registers from one call site, split by log level (the default
2297
- // logger is gated at `warn`, so `debug` stays hidden unless
2298
- // ABLO_LOG_LEVEL=debug is set to inspect the engine):
2299
- // - the default-visible line speaks the application developer's
2300
- // language: their verb (such as `update`), their model, the typed
2301
- // error's own message, and the wire `code` for searching. It uses
2302
- // no engine jargon and prints no JSON dump, which would alarm
2303
- // without helping.
2304
- // - the forensic `details` ride a companion `debug` line for anyone
2305
- // debugging the engine internals.
2306
- const revertNote = this.config.enableOptimistic
2307
- ? ' The local change was reverted.'
2308
- : '';
2309
- const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
2310
- const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
2311
- // An optimistic write resolves before the server answers, so a later
2312
- // rejection has no caller left to return to and this log is the only
2313
- // place it appears. That reads to an application developer as their own
2314
- // save silently failing — the write showed, then vanished — and sends
2315
- // them into their editor instead of here. Name the subscription that
2316
- // hands them the same typed error, so the application can say what
2317
- // happened rather than only the console.
2318
- const channelNote = this.config.enableOptimistic
2319
- ? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
2320
- : '';
2321
- const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}.${revertNote}${channelNote}`;
2322
- if (isRepeat) {
2323
- // Same write rejected for the same reason on each reconnect replay —
2324
- // log the forensics once, stay quiet after.
2325
- logger.debug('write rejected again (same reason)', details);
2326
- }
2327
- else if (isBenignIdempotent) {
2328
- // Already-exists on a `create` is expected on replay, not a problem.
2329
- logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
2330
- logger.debug('idempotent skip — details', details);
2331
- }
2332
- else {
2333
- logger.warn(headline);
2334
- logger.debug('write rejection — details', details);
2335
- }
2336
- }
2337
- catch { }
2338
- // Mark as failed immediately and rollback
2339
- this.store.updateStatus(transaction.id, 'failed');
2340
- if (this.config.enableOptimistic) {
2341
- await this.rollbackOptimistic(transaction, 'permanent_error', error);
2342
- }
2343
- this.emit('transaction:failed', { transaction, error, permanent: true });
2344
- // The id-suffixed event is what `waitForConfirmation` (the
2345
- // `wait:'confirmed'` path) listens on — without it a permanently
2346
- // rejected write left the caller's promise hanging forever.
2347
- this.emit(`transaction:failed:${transaction.id}`, { error });
2348
- return;
2349
- }
2350
- if (transaction.attempts < this.config.maxRetries) {
2351
- // Exponential backoff with full jitter on every transient retry:
2352
- // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
2353
- // (429/503) use a longer base than other transient errors. The re-enqueue
2354
- // is scheduled rather than awaited, so one backing-off transaction cannot
2355
- // stall unrelated commits.
2356
- const { baseMs, capMs } = this.config.retryBackoff;
2357
- let base = baseMs;
2358
- try {
2359
- const status = extractStatusCode(error);
2360
- if (status === 429 || status === 503)
2361
- base = Math.max(baseMs, 1_000);
2362
- }
2363
- catch { }
2364
- const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
2365
- const delay = Math.floor(Math.random() * ceiling);
2366
- this.store.updateStatus(transaction.id, 'pending');
2367
- setTimeout(() => {
2368
- // The queue may have shut down or the tx may have been settled
2369
- // (e.g. delta-confirmed) while we backed off.
2370
- if (this.store.get(transaction.id)?.status !== 'pending')
2371
- return;
2372
- this.enqueue(transaction);
2373
- }, delay);
2374
- }
2375
- else {
2376
- // Mark as failed and rollback
2377
- this.store.updateStatus(transaction.id, 'failed');
2378
- if (this.config.enableOptimistic) {
2379
- await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
2380
- }
2381
- this.emit('transaction:failed', { transaction, error });
2382
- // Settle `waitForConfirmation` waiters (see the permanent branch above).
2383
- this.emit(`transaction:failed:${transaction.id}`, { error });
2384
- }
2385
- }
2386
- /**
2387
- * Resolves a conflict against server data using the configured strategy:
2388
- * last-write-wins rolls the local change back, merge and reject re-enqueue it,
2389
- * and custom applies the caller's resolver.
2390
- */
2391
- async handleConflict(transaction, serverData) {
2392
- const { strategy, resolver } = this.config.conflictResolution;
2393
- switch (strategy) {
2394
- case 'last-write-wins':
2395
- // Server wins, cancel transaction
2396
- this.store.updateStatus(transaction.id, 'rolled_back');
2397
- await this.rollbackOptimistic(transaction, 'conflict_server_wins');
2398
- break;
2399
- case 'merge':
2400
- // Merge changes
2401
- const merged = this.mergeData(transaction.data, serverData);
2402
- transaction.data = merged;
2403
- this.enqueue(transaction);
2404
- break;
2405
- case 'reject':
2406
- // Client wins, re-execute
2407
- this.enqueue(transaction);
2408
- break;
2409
- case 'custom':
2410
- if (resolver) {
2411
- const resolved = resolver(transaction.data, serverData);
2412
- transaction.data = resolved;
2413
- this.enqueue(transaction);
2414
- }
2415
- break;
2416
- }
2417
- }
2418
- /**
2419
- * Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
2420
- * these methods bind them to the queue's own tracking map and event emitter.
2421
- */
2422
- applyOptimisticCreate(model, transaction) {
2423
- applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
2424
- }
2425
- applyOptimisticUpdate(model, transaction) {
2426
- applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
2427
- }
2428
- applyOptimisticDelete(model, transaction) {
2429
- applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
2430
- }
2431
- async rollbackOptimistic(transaction, reason, error) {
2432
- await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
2433
- }
2434
- /**
2435
- * Loads transactions persisted from a previous session and re-enqueues them,
2436
- * so writes made while offline survive a restart. Does nothing when
2437
- * persistence is disabled.
2438
- */
2439
- async loadPersistedTransactions(database) {
2440
- if (!this.config.enablePersistence)
2441
- return;
2442
- try {
2443
- const persisted = await database.getPersistedTransactions();
2444
- for (const data of persisted) {
2445
- const transaction = this.deserializeTransaction(data);
2446
- if (!transaction)
2447
- continue;
2448
- this.store.add(transaction);
2449
- this.enqueue(transaction);
2450
- }
2451
- }
2452
- catch (error) {
2453
- this.runtime.observability.captureMutationFailure({
2454
- context: 'load-persisted-transactions',
2455
- error: error instanceof Error ? error : String(error),
2456
- });
2457
- }
2458
- }
2459
- /**
2460
- * Restore exact sealed requests after the local database is open. Sealed
2461
- * envelopes replay through the atomic commit lane and are never re-projected
2462
- * through model/schema state from the new process.
2463
- */
2464
- async restoreDurableCommits() {
2465
- if (!this.config.enablePersistence)
2466
- return new Set();
2467
- const sourceMutationIds = new Set();
2468
- try {
2469
- if (!this.commitOutbox)
2470
- return sourceMutationIds;
2471
- const rows = await this.commitOutbox.list();
2472
- const envelopes = [];
2473
- for (const row of rows) {
2474
- if (typeof row !== 'object' ||
2475
- row === null ||
2476
- row.type !== 'commit_envelope')
2477
- continue;
2478
- const parsed = durableCommitEnvelopeSchema.safeParse(row);
2479
- if (parsed.success) {
2480
- envelopes.push(parsed.data);
2481
- }
2482
- else {
2483
- this.runtime.logger.warn('A saved local write is unreadable and was held for review.');
2484
- this.runtime.observability.captureMutationFailure({
2485
- context: 'restore-commit-envelope',
2486
- error: parsed.error,
2487
- });
2488
- throw new AbloValidationError('A saved commit envelope is unreadable; replay stopped before newer writes were sent.', { code: 'write_options_invalid', cause: parsed.error });
2489
- }
2490
- }
2491
- envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
2492
- (b.sequence ?? b.sealedAt * 1_000) ||
2493
- a.id.localeCompare(b.id));
2494
- for (const envelope of envelopes) {
2495
- for (const mutationId of envelope.sourceMutationIds) {
2496
- sourceMutationIds.add(mutationId);
2497
- }
2498
- if (envelope.acceptedAt === undefined &&
2499
- Date.now() - envelope.sealedAt >=
2500
- MutationQueue.DURABLE_REPLAY_WINDOW_MS) {
2501
- this.runtime.logger.warn('A saved local write is too old to retry safely and was held for review.');
2502
- this.runtime.observability.captureMutationFailure({
2503
- context: 'quarantine-expired-commit-envelope',
2504
- error: `Envelope ${envelope.idempotencyKey} is too old to replay safely`,
2505
- });
2506
- throw new AbloIdempotencyError('A saved commit is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
2507
- }
2508
- if (this.commitOutboxScope &&
2509
- (!envelope.scope || // eslint-disable-line @typescript-eslint/prefer-optional-chain -- missing scope must quarantine
2510
- envelope.scope.organizationId !== this.commitOutboxScope.organizationId ||
2511
- envelope.scope.participantId !== this.commitOutboxScope.participantId ||
2512
- envelope.scope.namespace !== this.commitOutboxScope.namespace)) {
2513
- this.runtime.logger.warn('A saved local write belongs to a different account or server and was held for review.');
2514
- continue;
2515
- }
2516
- if (this.commitStore.has(envelope.idempotencyKey))
2517
- continue;
2518
- const transaction = {
2519
- id: envelope.idempotencyKey,
2520
- kind: 'commit',
2521
- operations: envelope.operations.map((operation) => ({ ...operation })),
2522
- ...(envelope.commitOptions.reads
2523
- ? { reads: [...envelope.commitOptions.reads] }
2524
- : {}),
2525
- ...(envelope.commitOptions.track
2526
- ? { track: [...envelope.commitOptions.track] }
2527
- : {}),
2528
- status: 'pending',
2529
- createdAt: envelope.createdAt,
2530
- sealedAt: envelope.sealedAt,
2531
- sequence: envelope.sequence ?? envelope.sealedAt * 1_000,
2532
- attempts: 0,
2533
- ...(envelope.correlationId
2534
- ? { correlationId: envelope.correlationId }
2535
- : {}),
2536
- sourceMutationIds: [...envelope.sourceMutationIds],
2537
- durableEnvelope: envelope,
2538
- };
2539
- this.commitStore.set(transaction.id, transaction);
2540
- this.commitLane.push(transaction);
2541
- }
2542
- if (this.commitLane.length > 0)
2543
- void this.processCommitLane();
2544
- }
2545
- catch (error) {
2546
- this.runtime.logger.debug('[MutationQueue] Failed to restore durable writes', {
2547
- error: error instanceof Error ? error.message : String(error),
2548
- });
2549
- this.runtime.observability.captureMutationFailure({
2550
- context: 'restore-commit-envelopes',
2551
- error: error instanceof Error ? error : String(error),
2552
- });
2553
- throw error;
2554
- }
2555
- return sourceMutationIds;
2556
- }
2557
- /**
2558
- * Validates and rehydrates one persisted row. Rows written to the same store
2559
- * by other subsystems are skipped, and rows that fail the persisted
2560
- * transaction schema — from an older version or corruption — are dropped and
2561
- * reported rather than replayed as commits.
2562
- */
2563
- deserializeTransaction(data) {
2564
- if (isNonReplayablePersistedRow(data))
2565
- return null;
2566
- const transaction = deserializePersistedTransaction(data, this.runtime);
2567
- if (!transaction) {
2568
- const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
2569
- ? data.id
2570
- : undefined;
2571
- this.runtime.logger.debug('[MutationQueue] Dropping malformed persisted transaction', {
2572
- rowId,
2573
- });
2574
- this.runtime.observability.captureMutationFailure({
2575
- context: 'deserialize-persisted-transaction',
2576
- error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
2577
- });
2578
- return null;
2579
- }
2580
- return transaction;
2581
- }
2582
- /**
2583
- * Cancels every pending or executing transaction for a given model id,
2584
- * optionally limited to one operation type, rolling back their optimistic
2585
- * state. Returns the cancelled transactions.
2586
- */
2587
- cancelTransactionsForModel(modelId, transactionType) {
2588
- const cancelledTransactions = [];
2589
- const allTransactions = [
2590
- ...this.store.getByStatus('pending'),
2591
- ...this.store.getByStatus('executing'),
2592
- ];
2593
- for (const transaction of allTransactions) {
2594
- if (transaction.modelId === modelId) {
2595
- if (!transactionType || transaction.type === transactionType) {
2596
- cancelledTransactions.push(transaction);
2597
- this.store.updateStatus(transaction.id, 'rolled_back');
2598
- // Sync caller: a rejected rollback (throwing optimistic:rollback
2599
- // listener) must surface, not vanish — the status flip above is
2600
- // already committed either way.
2601
- void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
2602
- this.runtime.observability.captureMutationFailure({
2603
- context: 'rollback-model-cancelled',
2604
- error: error instanceof Error ? error : String(error),
2605
- });
2606
- });
2607
- }
2608
- }
2609
- }
2610
- return cancelledTransactions;
2611
- }
2612
- /**
2613
- * Cancels pending transactions for child rows that reference a deleted parent,
2614
- * used to cascade a parent deletion. The caller supplies the foreign-key
2615
- * relationship; this method performs the cancellation.
2616
- *
2617
- * @param childModelName - The child model type (for example 'Block').
2618
- * @param foreignKey - The foreign-key property name (for example 'sectionId').
2619
- * @param parentId - The deleted parent's id.
2620
- * @returns The number of transactions cancelled.
2621
- */
2622
- cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
2623
- let cancelled = 0;
2624
- const allTransactions = [
2625
- ...this.store.getByStatus('pending'),
2626
- ...this.store.getByStatus('executing'),
2627
- ...this.store.getByStatus('awaiting_delta'),
2628
- ];
2629
- for (const transaction of allTransactions) {
2630
- if (transaction.modelName === childModelName) {
2631
- // Check if this transaction's data contains the parent FK
2632
- const fkValue = transaction.data?.[foreignKey];
2633
- if (fkValue === parentId) {
2634
- this.store.updateStatus(transaction.id, 'rolled_back');
2635
- void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
2636
- this.runtime.observability.captureMutationFailure({
2637
- context: 'rollback-cascade-parent-deleted',
2638
- error: error instanceof Error ? error : String(error),
2639
- });
2640
- });
2641
- cancelled++;
2642
- this.runtime.logger.debug('[MutationQueue] Cascade cancelled orphaned transaction', {
2643
- txId: transaction.id.slice(0, 12),
2644
- model: childModelName,
2645
- foreignKey,
2646
- parentId: parentId.slice(0, 12),
2647
- });
2648
- }
2649
- }
2650
- }
2651
- return cancelled;
2652
- }
2653
- /**
2654
- * Returns the number of transactions still pending or executing.
2655
- */
2656
- getOutstandingTransactionCount() {
2657
- return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
2658
- }
2659
- /** Generates a unique local transaction id. */
2660
- generateId() {
2661
- return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
2662
- }
2663
- mergeData(local, remote) {
2664
- return { ...(remote || {}), ...(local || {}) };
2665
- }
2666
- extractCreateData(model) {
2667
- return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false }, this.runtime);
2668
- }
2669
- mapChangesToInput(modelName, changes) {
2670
- return projectCommitPayload(modelName, changes, { dropUndefined: true }, this.runtime);
2671
- }
2672
- extractUpdateData(model) {
2673
- return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true }, this.runtime);
2674
- }
2675
- // Derive previous values for changed fields to support accurate rollback.
2676
- // Model-specific special cases do not belong here; a model that needs to
2677
- // surface previous state beyond `modifiedProperties` should expose a typed
2678
- // `getPreviousData()` accessor for this method to call.
2679
- extractPreviousData(model, updateInput) {
2680
- // When the update's written keys are known, capture a before-image for
2681
- // exactly those keys, so the recorded undo inverse reverts them and nothing
2682
- // else — a full-row inverse would clobber concurrent edits to unrelated
2683
- // fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
2684
- // any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
2685
- // inverse rather than inventing one. With no `updateInput` (a full extract)
2686
- // it falls back to every tracked field. `Model.capturePreviousValues` is the
2687
- // single before-image source, shared with
2688
- // `RecordingMutation.snapshotFields`.
2689
- const keys = updateInput
2690
- ? Object.keys(updateInput)
2691
- : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
2692
- return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
2693
- }
2694
- /** Returns a snapshot of queue counts and the current configuration. */
2695
- getStats() {
2696
- return {
2697
- pending: this.store.getByStatus('pending').length,
2698
- executing: this.store.getByStatus('executing').length,
2699
- completed: this.store.getByStatus('completed').length,
2700
- failed: this.store.getByStatus('failed').length,
2701
- optimistic: this.optimisticUpdates.size,
2702
- totalTransactions: this.store.getAll().length,
2703
- batchIndex: this.batchIndex,
2704
- config: { ...this.config },
2705
- };
2706
- }
2707
- /**
2708
- * Returns detailed internal state — pending, executing, and awaiting-delta
2709
- * transactions — to help diagnose delta-confirmation issues.
2710
- */
2711
- getDebugInfo() {
2712
- const awaitingDelta = this.store.getByStatus('awaiting_delta');
2713
- return {
2714
- lastSeenSyncId: this.lastSeenSyncId,
2715
- awaitingDeltaCount: awaitingDelta.length,
2716
- awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
2717
- id: tx.id.slice(0, 8),
2718
- type: tx.type,
2719
- modelName: tx.modelName,
2720
- modelId: tx.modelId.slice(0, 8),
2721
- syncIdNeeded: tx.syncIdNeededForCompletion,
2722
- createdAt: tx.createdAt,
2723
- age: Date.now() - tx.createdAt,
2724
- })),
2725
- pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
2726
- id: tx.id.slice(0, 8),
2727
- type: tx.type,
2728
- modelName: tx.modelName,
2729
- modelId: tx.modelId.slice(0, 8),
2730
- })),
2731
- executingTransactions: this.store.getByStatus('executing').map((tx) => ({
2732
- id: tx.id.slice(0, 8),
2733
- type: tx.type,
2734
- modelName: tx.modelName,
2735
- modelId: tx.modelId.slice(0, 8),
2736
- })),
2737
- };
2738
- }
2739
- /** Merges the given options into the queue's configuration. */
2740
- setConfig(config) {
2741
- this.config = { ...this.config, ...config };
2742
- }
2743
- /**
2744
- * Re-emits an incoming sync delta on the `sync:delta` event for the store to
2745
- * apply. Because rows use stable ids, no id reconciliation is needed here.
2746
- */
2747
- handleSyncDelta(delta) {
2748
- // Row ids are stable, so no reconciliation is needed; re-emit the delta for
2749
- // the store to apply directly.
2750
- this.emit('sync:delta', {
2751
- id: delta.id,
2752
- modelName: delta.modelName,
2753
- action: delta.action,
2754
- data: delta.data,
2755
- });
2756
- return true;
2757
- }
2758
- /**
2759
- * Releases the queue's resources: rolls back outstanding optimistic updates,
2760
- * clears all timers and stored transactions, and removes event listeners.
2761
- */
2762
- dispose() {
2763
- // Cancel all active optimistic updates
2764
- for (const [, optimistic] of this.optimisticUpdates) {
2765
- this.emit('optimistic:rollback', {
2766
- model: optimistic.model,
2767
- previousState: optimistic.previousState,
2768
- transaction: optimistic.transaction,
2769
- reason: 'dispose',
2770
- });
2771
- }
2772
- // Clear processing
2773
- if (this.processTimer) {
2774
- clearTimeout(this.processTimer);
2775
- }
2776
- // Clear every armed delta-confirmation timer (one per in-flight tx,
2777
- // 30–120s each) — a disposed queue must not keep the process alive or
2778
- // fire confirmation callbacks against the cleared store below.
2779
- this.deltaConfirmation.dispose();
2780
- for (const timeout of this.replicationLagTimeouts.values()) {
2781
- clearTimeout(timeout);
2782
- }
2783
- this.replicationLagTimeouts.clear();
2784
- this.replicationLagErrors.clear();
2785
- // Clear the offline-grace timer armed by setConnectionState('disconnected').
2786
- if (this.commitOfflineGraceTimer !== null) {
2787
- clearTimeout(this.commitOfflineGraceTimer);
2788
- this.commitOfflineGraceTimer = null;
2789
- }
2790
- // Clear store
2791
- this.store.clear();
2792
- this.optimisticUpdates.clear();
2793
- this.executionQueue = [];
2794
- this.createdTransactions = [];
2795
- this.deferredDeletesByCreate.clear();
2796
- this.recentDeltaCorrelations.clear();
2797
- this.commitLane = [];
2798
- this.commitStore.clear();
2799
- this.commitNotifications.clear();
2800
- this.commitMissingIds.clear();
2801
- // Clear event listeners
2802
- this.removeAllListeners();
2803
- // Reset state
2804
- this.isProcessing = false;
2805
- this.batchIndex = 0;
2806
- }
2807
- }