@abloatai/ablo 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/CHANGELOG.md +42 -2
  2. package/README.md +102 -86
  3. package/dist/BaseSyncedStore.d.ts +85 -88
  4. package/dist/BaseSyncedStore.js +134 -151
  5. package/dist/Database.d.ts +68 -69
  6. package/dist/Database.js +316 -135
  7. package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
  8. package/dist/{ObjectPool.js → InstanceCache.js} +85 -83
  9. package/dist/LazyReferenceCollection.d.ts +11 -15
  10. package/dist/LazyReferenceCollection.js +12 -16
  11. package/dist/Model.d.ts +54 -52
  12. package/dist/Model.js +78 -62
  13. package/dist/ModelRegistry.d.ts +21 -19
  14. package/dist/ModelRegistry.js +23 -27
  15. package/dist/NetworkMonitor.d.ts +5 -6
  16. package/dist/NetworkMonitor.js +5 -6
  17. package/dist/SyncClient.d.ts +122 -118
  18. package/dist/SyncClient.js +541 -245
  19. package/dist/adapters/alwaysOnline.d.ts +6 -8
  20. package/dist/adapters/alwaysOnline.js +6 -8
  21. package/dist/adapters/inMemoryStorage.d.ts +10 -9
  22. package/dist/adapters/inMemoryStorage.js +21 -9
  23. package/dist/agent/Agent.d.ts +27 -32
  24. package/dist/agent/Agent.js +18 -19
  25. package/dist/agent/index.d.ts +4 -4
  26. package/dist/agent/index.js +5 -5
  27. package/dist/agent/session.d.ts +47 -44
  28. package/dist/agent/session.js +37 -48
  29. package/dist/agent/types.d.ts +26 -31
  30. package/dist/agent/types.js +6 -7
  31. package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
  32. package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
  33. package/dist/ai-sdk/coordinationContext.d.ts +46 -0
  34. package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +26 -33
  35. package/dist/ai-sdk/index.d.ts +25 -22
  36. package/dist/ai-sdk/index.js +25 -22
  37. package/dist/ai-sdk/wrap.d.ts +6 -7
  38. package/dist/ai-sdk/wrap.js +1 -1
  39. package/dist/auth/credentialPolicy.d.ts +69 -74
  40. package/dist/auth/credentialPolicy.js +51 -56
  41. package/dist/auth/credentialSource.d.ts +6 -5
  42. package/dist/auth/credentialSource.js +9 -10
  43. package/dist/auth/index.d.ts +59 -58
  44. package/dist/auth/index.js +31 -37
  45. package/dist/auth/schemas.d.ts +5 -4
  46. package/dist/auth/schemas.js +5 -4
  47. package/dist/batching/index.d.ts +19 -21
  48. package/dist/batching/index.js +14 -17
  49. package/dist/cli.cjs +173 -121
  50. package/dist/client/Ablo.d.ts +97 -74
  51. package/dist/client/Ablo.js +129 -163
  52. package/dist/client/ApiClient.d.ts +30 -19
  53. package/dist/client/ApiClient.js +442 -81
  54. package/dist/client/auth.d.ts +47 -47
  55. package/dist/client/auth.js +108 -117
  56. package/dist/client/claimHeartbeatLoop.d.ts +50 -0
  57. package/dist/client/claimHeartbeatLoop.js +88 -0
  58. package/dist/client/consoleLogger.d.ts +5 -6
  59. package/dist/client/consoleLogger.js +5 -6
  60. package/dist/client/createInternalComponents.d.ts +16 -17
  61. package/dist/client/createInternalComponents.js +26 -31
  62. package/dist/client/createModelProxy.d.ts +130 -120
  63. package/dist/client/createModelProxy.js +152 -122
  64. package/dist/client/credentialEndpoint.d.ts +40 -42
  65. package/dist/client/credentialEndpoint.js +35 -36
  66. package/dist/client/functionalUpdate.d.ts +29 -27
  67. package/dist/client/functionalUpdate.js +21 -21
  68. package/dist/client/hostedEndpoints.d.ts +9 -12
  69. package/dist/client/hostedEndpoints.js +9 -12
  70. package/dist/client/httpClient.d.ts +59 -53
  71. package/dist/client/httpClient.js +29 -31
  72. package/dist/client/identity.d.ts +15 -20
  73. package/dist/client/identity.js +47 -58
  74. package/dist/client/modelRegistration.d.ts +5 -9
  75. package/dist/client/modelRegistration.js +78 -87
  76. package/dist/client/options.d.ts +157 -157
  77. package/dist/client/options.js +3 -7
  78. package/dist/client/registerDataSource.d.ts +9 -9
  79. package/dist/client/registerDataSource.js +15 -16
  80. package/dist/client/resourceTypes.d.ts +64 -75
  81. package/dist/client/resourceTypes.js +4 -10
  82. package/dist/client/schemaConfig.d.ts +31 -43
  83. package/dist/client/schemaConfig.js +38 -50
  84. package/dist/client/sessionMint.d.ts +16 -12
  85. package/dist/client/sessionMint.js +26 -31
  86. package/dist/client/validateAbloOptions.d.ts +12 -14
  87. package/dist/client/validateAbloOptions.js +8 -9
  88. package/dist/client/writeOptionsSchema.d.ts +18 -16
  89. package/dist/client/writeOptionsSchema.js +23 -20
  90. package/dist/client/wsMutationExecutor.d.ts +16 -20
  91. package/dist/client/wsMutationExecutor.js +18 -23
  92. package/dist/commit/contract.d.ts +493 -0
  93. package/dist/commit/contract.js +187 -0
  94. package/dist/commit/index.d.ts +6 -0
  95. package/dist/commit/index.js +5 -0
  96. package/dist/context.d.ts +6 -4
  97. package/dist/context.js +6 -4
  98. package/dist/coordination/index.d.ts +10 -8
  99. package/dist/coordination/index.js +14 -12
  100. package/dist/coordination/schema.d.ts +176 -128
  101. package/dist/coordination/schema.js +197 -133
  102. package/dist/coordination/trace.d.ts +9 -10
  103. package/dist/coordination/trace.js +13 -14
  104. package/dist/core/DatabaseManager.d.ts +5 -7
  105. package/dist/core/DatabaseManager.js +15 -19
  106. package/dist/core/QueryProcessor.d.ts +7 -9
  107. package/dist/core/QueryProcessor.js +22 -28
  108. package/dist/core/QueryView.d.ts +8 -8
  109. package/dist/core/QueryView.js +2 -2
  110. package/dist/core/StoreManager.d.ts +14 -14
  111. package/dist/core/StoreManager.js +33 -24
  112. package/dist/core/ViewRegistry.d.ts +5 -5
  113. package/dist/core/ViewRegistry.js +4 -4
  114. package/dist/core/index.d.ts +17 -12
  115. package/dist/core/index.js +32 -26
  116. package/dist/core/openIDBWithTimeout.d.ts +38 -36
  117. package/dist/core/openIDBWithTimeout.js +42 -43
  118. package/dist/core/queryUtils.d.ts +45 -0
  119. package/dist/core/queryUtils.js +69 -0
  120. package/dist/core/storeContract.d.ts +63 -61
  121. package/dist/core/storeContract.js +8 -12
  122. package/dist/environment.d.ts +28 -0
  123. package/dist/environment.js +21 -0
  124. package/dist/errorCodes.d.ts +107 -99
  125. package/dist/errorCodes.js +137 -134
  126. package/dist/errors.d.ts +160 -166
  127. package/dist/errors.js +155 -158
  128. package/dist/index.d.ts +36 -27
  129. package/dist/index.js +91 -86
  130. package/dist/interfaces/index.d.ts +102 -113
  131. package/dist/interfaces/index.js +5 -4
  132. package/dist/keys/index.d.ts +27 -29
  133. package/dist/keys/index.js +41 -40
  134. package/dist/mutators/RecordingTransaction.d.ts +16 -16
  135. package/dist/mutators/RecordingTransaction.js +31 -37
  136. package/dist/mutators/Transaction.d.ts +18 -26
  137. package/dist/mutators/Transaction.js +14 -20
  138. package/dist/mutators/UndoManager.d.ts +124 -131
  139. package/dist/mutators/UndoManager.js +177 -156
  140. package/dist/mutators/defineMutators.d.ts +23 -34
  141. package/dist/mutators/defineMutators.js +14 -20
  142. package/dist/mutators/inverseOp.d.ts +12 -15
  143. package/dist/mutators/inverseOp.js +12 -15
  144. package/dist/mutators/mutateActions.d.ts +10 -9
  145. package/dist/mutators/mutateActions.js +1 -1
  146. package/dist/mutators/readerActions.d.ts +9 -8
  147. package/dist/mutators/readerActions.js +2 -2
  148. package/dist/mutators/undoApply.d.ts +31 -27
  149. package/dist/mutators/undoApply.js +26 -24
  150. package/dist/policy/index.d.ts +5 -3
  151. package/dist/policy/index.js +5 -3
  152. package/dist/policy/types.d.ts +104 -100
  153. package/dist/policy/types.js +67 -66
  154. package/dist/query/client.d.ts +28 -23
  155. package/dist/query/client.js +45 -43
  156. package/dist/query/types.d.ts +37 -60
  157. package/dist/query/types.js +13 -33
  158. package/dist/react/AbloProvider.d.ts +1 -1
  159. package/dist/react/AbloProvider.js +2 -2
  160. package/dist/react/context.d.ts +25 -28
  161. package/dist/react/context.js +9 -10
  162. package/dist/react/index.d.ts +41 -42
  163. package/dist/react/index.js +37 -38
  164. package/dist/react/internalContext.d.ts +17 -19
  165. package/dist/react/useAblo.d.ts +28 -25
  166. package/dist/react/useAblo.js +41 -17
  167. package/dist/react/useCurrentUserId.d.ts +8 -7
  168. package/dist/react/useCurrentUserId.js +8 -7
  169. package/dist/react/useErrorListener.d.ts +7 -7
  170. package/dist/react/useErrorListener.js +10 -11
  171. package/dist/react/useMutationFailureListener.d.ts +8 -8
  172. package/dist/react/useMutationFailureListener.js +8 -8
  173. package/dist/react/useMutators.d.ts +11 -11
  174. package/dist/react/useMutators.js +3 -3
  175. package/dist/react/useReactive.js +2 -2
  176. package/dist/react/useSyncStatus.d.ts +4 -6
  177. package/dist/react/useUndoScope.d.ts +7 -9
  178. package/dist/react/useUndoScope.js +1 -1
  179. package/dist/schema/coordination.d.ts +21 -25
  180. package/dist/schema/coordination.js +21 -25
  181. package/dist/schema/ddl.d.ts +43 -39
  182. package/dist/schema/ddl.js +75 -68
  183. package/dist/schema/ddlLock.d.ts +20 -24
  184. package/dist/schema/ddlLock.js +18 -23
  185. package/dist/schema/diff.d.ts +99 -61
  186. package/dist/schema/diff.js +43 -34
  187. package/dist/schema/field.d.ts +37 -42
  188. package/dist/schema/field.js +35 -48
  189. package/dist/schema/generate.d.ts +12 -12
  190. package/dist/schema/generate.js +12 -12
  191. package/dist/schema/index.d.ts +3 -3
  192. package/dist/schema/index.js +21 -23
  193. package/dist/schema/model.d.ts +118 -143
  194. package/dist/schema/model.js +22 -33
  195. package/dist/schema/openapi.d.ts +10 -9
  196. package/dist/schema/openapi.js +5 -3
  197. package/dist/schema/queries.d.ts +29 -31
  198. package/dist/schema/queries.js +23 -25
  199. package/dist/schema/relation.d.ts +89 -99
  200. package/dist/schema/relation.js +13 -13
  201. package/dist/schema/residency.d.ts +16 -13
  202. package/dist/schema/residency.js +16 -13
  203. package/dist/schema/roles.d.ts +36 -43
  204. package/dist/schema/roles.js +31 -37
  205. package/dist/schema/schema.d.ts +64 -43
  206. package/dist/schema/schema.js +31 -32
  207. package/dist/schema/select.d.ts +13 -13
  208. package/dist/schema/select.js +13 -13
  209. package/dist/schema/serialize.d.ts +28 -31
  210. package/dist/schema/serialize.js +27 -31
  211. package/dist/schema/sugar.d.ts +17 -32
  212. package/dist/schema/sugar.js +14 -29
  213. package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +26 -49
  214. package/dist/schema/syncDeltaRow.js +89 -0
  215. package/dist/schema/tenancy.d.ts +44 -46
  216. package/dist/schema/tenancy.js +46 -48
  217. package/dist/server/adapter.d.ts +58 -58
  218. package/dist/server/adapter.js +13 -14
  219. package/dist/server/commit.d.ts +60 -64
  220. package/dist/server/index.d.ts +9 -10
  221. package/dist/server/index.js +1 -1
  222. package/dist/server/readConfig.d.ts +70 -0
  223. package/dist/server/readConfig.js +8 -0
  224. package/dist/server/storageMode.d.ts +23 -0
  225. package/dist/server/storageMode.js +17 -0
  226. package/dist/source/adapter.d.ts +30 -25
  227. package/dist/source/adapter.js +10 -10
  228. package/dist/source/adapters/drizzle.d.ts +28 -23
  229. package/dist/source/adapters/drizzle.js +30 -25
  230. package/dist/source/adapters/kysely.d.ts +27 -25
  231. package/dist/source/adapters/kysely.js +24 -23
  232. package/dist/source/adapters/memory.d.ts +8 -7
  233. package/dist/source/adapters/memory.js +9 -8
  234. package/dist/source/adapters/prisma.d.ts +13 -12
  235. package/dist/source/adapters/prisma.js +22 -25
  236. package/dist/source/conformance.d.ts +18 -11
  237. package/dist/source/conformance.js +17 -11
  238. package/dist/source/connector.d.ts +31 -32
  239. package/dist/source/connector.js +28 -28
  240. package/dist/source/connectorProtocol.d.ts +160 -0
  241. package/dist/source/connectorProtocol.js +162 -0
  242. package/dist/source/contract.d.ts +26 -27
  243. package/dist/source/contract.js +28 -29
  244. package/dist/source/factory.d.ts +46 -58
  245. package/dist/source/factory.js +22 -27
  246. package/dist/source/index.d.ts +7 -9
  247. package/dist/source/index.js +12 -14
  248. package/dist/source/migrations.d.ts +9 -9
  249. package/dist/source/migrations.js +9 -9
  250. package/dist/source/next.d.ts +9 -10
  251. package/dist/source/next.js +6 -7
  252. package/dist/source/pushQueue.d.ts +69 -47
  253. package/dist/source/pushQueue.js +32 -28
  254. package/dist/source/signing.d.ts +46 -17
  255. package/dist/source/signing.js +28 -11
  256. package/dist/source/types.d.ts +121 -104
  257. package/dist/source/types.js +13 -14
  258. package/dist/stores/ObjectStore.d.ts +24 -12
  259. package/dist/stores/ObjectStore.js +38 -16
  260. package/dist/stores/ObjectStoreContract.d.ts +14 -15
  261. package/dist/stores/SyncActionStore.d.ts +7 -11
  262. package/dist/stores/SyncActionStore.js +13 -17
  263. package/dist/surface.d.ts +28 -21
  264. package/dist/surface.js +29 -20
  265. package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +36 -42
  266. package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +76 -76
  267. package/dist/sync/ConnectionManager.d.ts +39 -50
  268. package/dist/sync/ConnectionManager.js +55 -66
  269. package/dist/sync/NetworkProbe.d.ts +24 -29
  270. package/dist/sync/NetworkProbe.js +63 -69
  271. package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +42 -41
  272. package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +59 -54
  273. package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +43 -57
  274. package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +43 -51
  275. package/dist/sync/SyncWebSocket.d.ts +141 -166
  276. package/dist/sync/SyncWebSocket.js +191 -223
  277. package/dist/sync/awaitClaimGrant.d.ts +18 -18
  278. package/dist/sync/awaitClaimGrant.js +11 -11
  279. package/dist/sync/bootstrapApply.d.ts +34 -24
  280. package/dist/sync/bootstrapApply.js +27 -19
  281. package/dist/sync/commitFrames.d.ts +21 -20
  282. package/dist/sync/commitFrames.js +18 -18
  283. package/dist/sync/createClaimStream.d.ts +23 -22
  284. package/dist/sync/createClaimStream.js +105 -23
  285. package/dist/sync/createPresenceStream.d.ts +19 -18
  286. package/dist/sync/createPresenceStream.js +25 -26
  287. package/dist/sync/createSnapshot.d.ts +12 -14
  288. package/dist/sync/createSnapshot.js +20 -26
  289. package/dist/sync/credentialLifecycle.d.ts +104 -104
  290. package/dist/sync/credentialLifecycle.js +140 -147
  291. package/dist/sync/deltaPipeline.d.ts +36 -34
  292. package/dist/sync/deltaPipeline.js +64 -65
  293. package/dist/sync/groupChange.d.ts +63 -61
  294. package/dist/sync/groupChange.js +74 -78
  295. package/dist/sync/heartbeat.d.ts +34 -33
  296. package/dist/sync/heartbeat.js +31 -31
  297. package/dist/sync/participants.d.ts +19 -19
  298. package/dist/sync/persistedPrefix.d.ts +12 -0
  299. package/dist/sync/persistedPrefix.js +22 -0
  300. package/dist/sync/schemas.d.ts +3 -2
  301. package/dist/sync/schemas.js +14 -10
  302. package/dist/sync/syncCursor.d.ts +17 -21
  303. package/dist/sync/syncCursor.js +17 -21
  304. package/dist/sync/syncPlan.d.ts +28 -36
  305. package/dist/sync/syncPlan.js +18 -19
  306. package/dist/sync/syncPosition.d.ts +54 -49
  307. package/dist/sync/syncPosition.js +57 -52
  308. package/dist/sync/wsFrameHandlers.d.ts +35 -36
  309. package/dist/sync/wsFrameHandlers.js +63 -67
  310. package/dist/testing/fixtures/bootstrap.d.ts +12 -6
  311. package/dist/testing/fixtures/bootstrap.js +12 -6
  312. package/dist/testing/fixtures/deltas.d.ts +30 -33
  313. package/dist/testing/fixtures/deltas.js +30 -33
  314. package/dist/testing/fixtures/models.d.ts +11 -10
  315. package/dist/testing/fixtures/models.js +11 -10
  316. package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
  317. package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
  318. package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -15
  319. package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +12 -10
  320. package/dist/testing/helpers/wait.d.ts +13 -8
  321. package/dist/testing/helpers/wait.js +13 -8
  322. package/dist/testing/index.d.ts +5 -3
  323. package/dist/testing/index.js +3 -2
  324. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  325. package/dist/testing/mocks/FakeDatabase.js +10 -0
  326. package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
  327. package/dist/testing/mocks/MockMutationExecutor.js +15 -14
  328. package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
  329. package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
  330. package/dist/testing/mocks/MockSyncContext.d.ts +20 -17
  331. package/dist/testing/mocks/MockSyncContext.js +15 -13
  332. package/dist/testing/mocks/MockSyncStore.d.ts +10 -10
  333. package/dist/testing/mocks/MockSyncStore.js +11 -11
  334. package/dist/testing/mocks/MockWebSocket.d.ts +28 -23
  335. package/dist/testing/mocks/MockWebSocket.js +22 -21
  336. package/dist/transactions/TransactionQueue.d.ts +244 -181
  337. package/dist/transactions/TransactionQueue.js +929 -423
  338. package/dist/transactions/TransactionStore.d.ts +6 -4
  339. package/dist/transactions/TransactionStore.js +6 -4
  340. package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
  341. package/dist/transactions/UnconfirmedWrites.js +104 -0
  342. package/dist/transactions/coalesceRules.d.ts +41 -17
  343. package/dist/transactions/coalesceRules.js +40 -17
  344. package/dist/transactions/commitEnvelope.d.ts +132 -0
  345. package/dist/transactions/commitEnvelope.js +139 -0
  346. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  347. package/dist/transactions/commitOutboxStore.js +26 -0
  348. package/dist/transactions/commitPayload.d.ts +63 -52
  349. package/dist/transactions/commitPayload.js +54 -57
  350. package/dist/transactions/deltaConfirmation.d.ts +20 -22
  351. package/dist/transactions/deltaConfirmation.js +37 -45
  352. package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
  353. package/dist/transactions/httpCommitEnvelope.js +179 -0
  354. package/dist/transactions/optimisticApply.d.ts +49 -0
  355. package/dist/transactions/optimisticApply.js +65 -0
  356. package/dist/transactions/replayValidation.d.ts +182 -0
  357. package/dist/transactions/replayValidation.js +156 -0
  358. package/dist/types/global.d.ts +46 -41
  359. package/dist/types/global.js +20 -19
  360. package/dist/types/index.d.ts +71 -77
  361. package/dist/types/index.js +22 -22
  362. package/dist/types/modelData.d.ts +6 -8
  363. package/dist/types/modelData.js +5 -7
  364. package/dist/types/participant.d.ts +10 -11
  365. package/dist/types/participant.js +6 -8
  366. package/dist/types/streams.d.ts +208 -195
  367. package/dist/types/streams.js +7 -7
  368. package/dist/utils/asyncIterator.d.ts +25 -32
  369. package/dist/utils/asyncIterator.js +25 -32
  370. package/dist/utils/duration.d.ts +12 -15
  371. package/dist/utils/duration.js +12 -15
  372. package/dist/utils/mobxSetup.d.ts +53 -0
  373. package/dist/utils/{mobx-setup.js → mobxSetup.js} +42 -98
  374. package/dist/webhooks/events.d.ts +21 -16
  375. package/dist/webhooks/events.js +10 -8
  376. package/dist/webhooks/index.d.ts +5 -7
  377. package/dist/webhooks/index.js +5 -7
  378. package/dist/wire/bootstrapReason.d.ts +9 -0
  379. package/dist/wire/bootstrapReason.js +8 -0
  380. package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
  381. package/dist/wire/delta.js +114 -0
  382. package/dist/wire/errorEnvelope.d.ts +30 -31
  383. package/dist/wire/errorEnvelope.js +34 -40
  384. package/dist/wire/frames.d.ts +315 -86
  385. package/dist/wire/frames.js +47 -33
  386. package/dist/wire/index.d.ts +18 -14
  387. package/dist/wire/index.js +32 -27
  388. package/dist/wire/listEnvelope.d.ts +16 -23
  389. package/dist/wire/listEnvelope.js +7 -6
  390. package/dist/wire/protocol.d.ts +25 -32
  391. package/dist/wire/protocol.js +25 -32
  392. package/dist/wire/protocolVersion.d.ts +44 -40
  393. package/dist/wire/protocolVersion.js +44 -40
  394. package/docs/api.md +10 -10
  395. package/docs/coordination.md +59 -0
  396. package/docs/mcp.md +1 -1
  397. package/package.json +17 -11
  398. package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
  399. package/dist/ai-sdk/coordination-context.d.ts +0 -52
  400. package/dist/core/query-utils.d.ts +0 -34
  401. package/dist/core/query-utils.js +0 -59
  402. package/dist/schema/sync-delta-row.js +0 -103
  403. package/dist/schema/sync-delta-wire.js +0 -102
  404. package/dist/server/read-config.d.ts +0 -67
  405. package/dist/server/read-config.js +0 -8
  406. package/dist/server/storage-mode.d.ts +0 -8
  407. package/dist/server/storage-mode.js +0 -28
  408. package/dist/source/connector-protocol.d.ts +0 -159
  409. package/dist/source/connector-protocol.js +0 -161
  410. package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
  411. package/dist/transactions/OptimisticEchoTracker.js +0 -104
  412. package/dist/transactions/mutation-error-handler.d.ts +0 -5
  413. package/dist/transactions/mutation-error-handler.js +0 -39
  414. package/dist/transactions/optimistic.d.ts +0 -24
  415. package/dist/transactions/optimistic.js +0 -45
  416. package/dist/transactions/persistedReplay.d.ts +0 -93
  417. package/dist/transactions/persistedReplay.js +0 -105
  418. package/dist/utils/mobx-setup.d.ts +0 -42
@@ -1,25 +1,35 @@
1
1
  /**
2
- * TransactionQueue - Production-ready transaction management
2
+ * TransactionQueue manages the lifecycle of local writes on their way to the
3
+ * server: it applies each change optimistically, batches the writes made in one
4
+ * event-loop tick into a single commit, retries transient failures, and rolls
5
+ * back on permanent rejection.
3
6
  *
4
- * Key features:
5
- * - Optimistic updates with rollback
6
- * - Conflict resolution strategies
7
- * - LINEAR-style microtask batching (transactions in same event loop share batchId)
8
- * - Proper dependency injection (no singleton)
7
+ * Key behaviours:
8
+ * - Optimistic updates with rollback on failure.
9
+ * - Configurable conflict resolution.
10
+ * - Microtask batching: transactions created in the same event-loop tick share
11
+ * a batch id and commit together in one round trip.
12
+ * - A dependency-injected executor, so several queues can coexist.
9
13
  */
10
14
  import { EventEmitter } from 'events';
15
+ import { v4 as uuid } from 'uuid';
11
16
  import { Model } from '../Model.js';
12
17
  import { getContext } from '../context.js';
13
- import { handleMutationError } from './mutation-error-handler.js';
14
- import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
18
+ import { AbloError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, errorCodeSpec, } from '../errors.js';
15
19
  import { SyncPosition } from '../sync/syncPosition.js';
16
- import { projectCommitPayload, computePriorityScore, normalizeModelKey, stripModelSuffix, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
20
+ import { projectCommitPayload, computePriorityScore, normalizeModelKey,
21
+ // Includes stale guards as well as request identity/audit barriers.
22
+ hasCommitCoalescingBarrier, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
17
23
  import { TransactionStore } from './TransactionStore.js';
18
24
  import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
19
25
  import { DeltaConfirmationTracker } from './deltaConfirmation.js';
20
- import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './persistedReplay.js';
21
- import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimistic.js';
26
+ import { deserializePersistedTransaction, isNonReplayablePersistedRow, pendingMutationRecordId, } from './replayValidation.js';
27
+ import { createCommitEnvelopeMember, createDurableCommitEnvelope, commitEnvelopeRecordId, durableCommitEnvelopeSchema, } from './commitEnvelope.js';
28
+ import { stableStringify } from '../utils/json.js';
29
+ import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimisticApply.js';
22
30
  export class TransactionQueue extends EventEmitter {
31
+ // Keep one hour of clock/network margin inside the server's 24-hour ledger.
32
+ static DURABLE_REPLAY_WINDOW_MS = 23 * 60 * 60 * 1000;
23
33
  store = new TransactionStore();
24
34
  // Signature of the last permanent-error we logged at `warn`. A `create`
25
35
  // whose id already exists (`unique_violation`) is a permanent rejection
@@ -27,17 +37,17 @@ export class TransactionQueue extends EventEmitter {
27
37
  // this, the identical cause prints on a loop. We log the first occurrence
28
38
  // and demote exact repeats to `debug`.
29
39
  lastPermanentErrorSig;
30
- // Per-instance executor binding. Set by `setMutationExecutor(...)` from the
31
- // owning Ablo right after construction. Falls back to `getContext()` only
32
- // when unset (preserves legacy tests / SDK consumers that haven't migrated).
40
+ // The executor bound to this queue instance, set by `setMutationExecutor(...)`
41
+ // just after construction. When unset it falls back to the ambient executor
42
+ // from `getContext()`.
33
43
  //
34
- // Why this exists: `initSyncEngine()` writes a *module-level* singleton.
35
- // Constructing a second Ablo (e.g. worker + per-job peer in agent-worker)
36
- // overwrites the first instance's executor. Without an instance binding,
37
- // queue commits on Ablo A would dispatch through Ablo B's executor closure,
38
- // which captures B's `storeHolder.store` and once B disposes its store,
39
- // that closure returns `null` for `getWs()` and every commit on A throws
40
- // `ws_not_ready` forever (queue classifies it as transient retry loop).
44
+ // The binding matters because the ambient executor is a module-level
45
+ // singleton: constructing a second client instance overwrites the first
46
+ // instance's executor. Without a per-instance binding, commits on one
47
+ // instance would dispatch through another instance's executor closure; once
48
+ // that other instance disposed its store, the closure would resolve no live
49
+ // connection and every commit here would fail with `ws_not_ready`, which the
50
+ // queue treats as transient and retries endlessly.
41
51
  _mutationExecutor = null;
42
52
  get mutationExecutor() {
43
53
  return this._mutationExecutor ?? getContext().mutationExecutor;
@@ -46,8 +56,8 @@ export class TransactionQueue extends EventEmitter {
46
56
  isProcessing = false;
47
57
  processTimer;
48
58
  processScheduled = false;
49
- // LINEAR PATTERN: Staging area for transactions created in same event loop tick
50
- // All transactions go here first, then get committed together via microtask
59
+ // Staging area for transactions created in the same event-loop tick. Each one
60
+ // lands here first, then a microtask commits them together.
51
61
  createdTransactions = [];
52
62
  commitScheduled = false;
53
63
  // Per-model in-flight tracking and merge buffer
@@ -60,6 +70,39 @@ export class TransactionQueue extends EventEmitter {
60
70
  commitLane = [];
61
71
  commitStore = new Map();
62
72
  commitProcessing = false;
73
+ lastCommitSequence = 0;
74
+ durableReplayBlock = null;
75
+ /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
76
+ commitOutbox = null;
77
+ commitOutboxScope = null;
78
+ nextCommitSequence() {
79
+ const wallSequence = Date.now() * 1_000;
80
+ this.lastCommitSequence = Math.max(wallSequence, this.lastCommitSequence + 1);
81
+ return this.lastCommitSequence;
82
+ }
83
+ emitCommitLifecycle(event, payload) {
84
+ try {
85
+ this.emit(event, payload);
86
+ }
87
+ catch (error) {
88
+ getContext().observability.captureTransactionFailure({
89
+ context: `commit-lifecycle-listener:${event}`,
90
+ error: error instanceof Error ? error : String(error),
91
+ });
92
+ }
93
+ }
94
+ assertDurableReplayOpen() {
95
+ if (this.durableReplayBlock)
96
+ throw this.durableReplayBlock;
97
+ }
98
+ assertEnvelopeInsideReplayWindow(envelope) {
99
+ this.assertDurableReplayOpen();
100
+ if (Date.now() - envelope.sealedAt >=
101
+ TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
102
+ 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' });
103
+ throw this.durableReplayBlock;
104
+ }
105
+ }
63
106
  computePriorityScore(type, modelName) {
64
107
  return computePriorityScore(type, modelName);
65
108
  }
@@ -94,7 +137,7 @@ export class TransactionQueue extends EventEmitter {
94
137
  findCreateBarrierForDelete(modelName, modelId) {
95
138
  return findCreateBarrierForDelete(this.store, modelName, modelId);
96
139
  }
97
- completeLocalDelete(model, context, writeOptions) {
140
+ completeLocalDelete(model, context, writeOptions, sourceMutationIds = []) {
98
141
  const actualModelName = model.getModelName();
99
142
  const modelKey = normalizeModelKey(actualModelName);
100
143
  const transaction = {
@@ -111,6 +154,9 @@ export class TransactionQueue extends EventEmitter {
111
154
  attempts: 0,
112
155
  priority: 'high',
113
156
  writeOptions,
157
+ ...(sourceMutationIds.length > 0
158
+ ? { sourceMutationIds: [...new Set(sourceMutationIds)] }
159
+ : {}),
114
160
  localOnly: true,
115
161
  };
116
162
  this.attachConfirmation(transaction);
@@ -130,11 +176,12 @@ export class TransactionQueue extends EventEmitter {
130
176
  releaseDeferredDeletesForCreate(createTransaction) {
131
177
  releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
132
178
  }
133
- // Configuration - tuned for LINEAR-style batching
134
- // Higher batch size and delay allows more operations to coalesce into single HTTP call
179
+ // Default configuration, tuned so more operations coalesce into a single
180
+ // commit: a larger batch size and delay give rapid operations more time to
181
+ // merge before the batch is sent.
135
182
  config = {
136
- maxBatchSize: 50, // Increased from 10 - matches Linear's batch size
137
- batchDelay: 150, // Increased from 50ms - more time to coalesce rapid operations
183
+ maxBatchSize: 50, // send up to this many operations per commit
184
+ batchDelay: 150, // milliseconds to wait for more operations before sending
138
185
  maxRetries: 3,
139
186
  conflictResolution: {
140
187
  strategy: 'last-write-wins',
@@ -151,19 +198,21 @@ export class TransactionQueue extends EventEmitter {
151
198
  };
152
199
  // Track executing transactions for backpressure
153
200
  executingCount = 0;
154
- // Optimistic update tracking. The entry shape + apply/rollback rules live
155
- // in `./optimistic.js`; the ledger itself stays host state because the
201
+ // Optimistic update tracking. The entry shape and apply/rollback rules live
202
+ // in `./optimisticApply.js`; the map itself stays on the queue because the
156
203
  // completion paths, `getStats`, and `dispose` all read it.
157
204
  optimisticUpdates = new Map();
158
- // Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
159
- // transaction id, populated from the commit ack and drained by
160
- // `waitForCommitReceipt` so the receipt carries the self-heal signal.
205
+ // Stale-context notifications, keyed by transaction id. When the server
206
+ // accepts a commit but reports that an operation's read premise had moved,
207
+ // the notification lands here from the commit acknowledgement and is drained
208
+ // by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
161
209
  commitNotifications = new Map();
162
210
  // Delta-confirmation tracking (ack watermark advance + the awaiting_delta
163
211
  // timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
164
212
  // constructor once `position` is bound.
165
213
  deltaConfirmation;
166
- // Connection state check - set by SyncClient to prevent rollbacks during disconnection
214
+ // Connection-state check, supplied by the client, used to hold off rollbacks
215
+ // while disconnected.
167
216
  isConnectedFn = () => true;
168
217
  // Grace timer that, when fired, fails any commit-lane transaction
169
218
  // still awaiting an ack. Started on `setConnectionState('disconnected')`,
@@ -171,11 +220,11 @@ export class TransactionQueue extends EventEmitter {
171
220
  // is preserved for brief blips; this only catches persistent disconnects.
172
221
  commitOfflineGraceTimer = null;
173
222
  /**
174
- * THE client's place in the global delta order the SHARED instance
175
- * (injected by SyncClient; standalone construction gets its own). The
176
- * queue advances `acked` on commit responses; the store advances
177
- * `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
178
- * rationale live in `sync/syncPosition.ts`.
223
+ * This client's place in the global order of sync deltas. The instance is
224
+ * shared: the client injects one, and a standalone queue creates its own. The
225
+ * queue advances the `acked` cursor as commit responses arrive, the store
226
+ * advances `applied` and `persisted`, and snapshots and claims read
227
+ * `readFloor`. See `../sync/syncPosition.js` for the full contract.
179
228
  */
180
229
  position;
181
230
  /** Applied-cursor alias, kept so the many internal read sites stay legible. */
@@ -187,6 +236,207 @@ export class TransactionQueue extends EventEmitter {
187
236
  }
188
237
  // Batch management
189
238
  batchIndex = 0;
239
+ /** Mints the request identity once; retry paths only read the stored value. */
240
+ generateCommitIdempotencyKey() {
241
+ return `commit_${uuid()}`;
242
+ }
243
+ /**
244
+ * Binds an ordered transaction batch to one wire-level idempotency key.
245
+ * Existing envelopes are validated and restored to their original order;
246
+ * they are never extended with newly queued work.
247
+ */
248
+ ensureCommitEnvelope(batch) {
249
+ const firstTransaction = batch[0];
250
+ if (!firstTransaction) {
251
+ throw new Error('Cannot create an idempotency envelope for an empty commit');
252
+ }
253
+ const existingKeys = new Set(batch
254
+ .map((tx) => tx.commitEnvelope?.idempotencyKey)
255
+ .filter((key) => key !== undefined));
256
+ if (existingKeys.size > 1) {
257
+ throw new Error('Cannot combine transactions from different commit envelopes');
258
+ }
259
+ const existingKey = existingKeys.values().next().value;
260
+ if (existingKey) {
261
+ const expectedCount = firstTransaction.commitEnvelope?.operationCount;
262
+ const indexes = new Set(batch.map((tx) => tx.commitEnvelope?.operationIndex));
263
+ const isCompleteEnvelope = expectedCount === batch.length &&
264
+ indexes.size === batch.length &&
265
+ batch.every((tx) => tx.commitEnvelope?.idempotencyKey === existingKey &&
266
+ tx.commitEnvelope.operationCount === expectedCount &&
267
+ tx.commitEnvelope.operationIndex < expectedCount);
268
+ if (!isCompleteEnvelope) {
269
+ throw new Error('Cannot replay a partial or malformed commit envelope');
270
+ }
271
+ const persistedSealTimes = new Set(batch
272
+ .map((transaction) => transaction.commitEnvelope?.sealedAt)
273
+ .filter((sealedAt) => sealedAt !== undefined));
274
+ if (persistedSealTimes.size > 1) {
275
+ throw new Error('Cannot replay a commit envelope with inconsistent seal times');
276
+ }
277
+ const sealedAt = persistedSealTimes.values().next().value ?? Date.now();
278
+ for (const transaction of batch) {
279
+ if (transaction.commitEnvelope)
280
+ transaction.commitEnvelope.sealedAt = sealedAt;
281
+ }
282
+ batch.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
283
+ (b.commitEnvelope?.operationIndex ?? 0));
284
+ return existingKey;
285
+ }
286
+ // An explicit public key owns its request. Transactions carrying one are
287
+ // selected as solo batches by takeNextExecutionBatch().
288
+ const explicitKey = batch.length === 1
289
+ ? firstTransaction.writeOptions?.idempotencyKey
290
+ : undefined;
291
+ const idempotencyKey = typeof explicitKey === 'string' && explicitKey.length > 0
292
+ ? explicitKey
293
+ : this.generateCommitIdempotencyKey();
294
+ const sealedAt = Date.now();
295
+ const sequence = this.nextCommitSequence();
296
+ batch.forEach((tx, operationIndex) => {
297
+ tx.commitEnvelope = createCommitEnvelopeMember({
298
+ idempotencyKey,
299
+ operationIndex,
300
+ operationCount: batch.length,
301
+ sealedAt,
302
+ sequence,
303
+ });
304
+ });
305
+ return idempotencyKey;
306
+ }
307
+ /** Bind the strict local outbox used before any mutation reaches the wire. */
308
+ setCommitOutbox(outbox) {
309
+ this.commitOutbox = outbox;
310
+ }
311
+ setCommitOutboxScope(scope) {
312
+ this.commitOutboxScope = scope;
313
+ }
314
+ sourceMutationIdsFor(batch) {
315
+ return [...new Set(batch.flatMap((transaction) => transaction.sourceMutationIds ?? []))];
316
+ }
317
+ /**
318
+ * Atomically replaces staged mutation journal rows with one exact request.
319
+ * The returned operations are the JSON-normalized values that must be sent;
320
+ * callers never send a separately reconstructed payload after sealing.
321
+ */
322
+ async sealDurableCommit(input) {
323
+ const sourceMutationIds = [...new Set(input.sourceMutationIds ?? [])];
324
+ const envelope = createDurableCommitEnvelope({
325
+ idempotencyKey: input.idempotencyKey,
326
+ origin: input.origin,
327
+ operations: [...input.operations],
328
+ sourceMutationIds,
329
+ commitOptions: {
330
+ ...(input.commitOptions?.causedByTaskId !== undefined
331
+ ? { causedByTaskId: input.commitOptions.causedByTaskId }
332
+ : {}),
333
+ ...(input.commitOptions?.reads !== undefined
334
+ ? {
335
+ reads: input.commitOptions.reads === null
336
+ ? null
337
+ : [...input.commitOptions.reads],
338
+ }
339
+ : {}),
340
+ },
341
+ ...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
342
+ createdAt: input.createdAt,
343
+ sealedAt: input.sealedAt,
344
+ sequence: input.sequence ?? input.sealedAt * 1_000,
345
+ });
346
+ if (this.config.enablePersistence && this.commitOutbox) {
347
+ try {
348
+ await this.commitOutbox.seal(envelope, sourceMutationIds.map(pendingMutationRecordId));
349
+ }
350
+ catch (cause) {
351
+ if (cause instanceof AbloError)
352
+ throw cause;
353
+ throw new AbloConnectionError('Could not seal the commit outbox before dispatch', {
354
+ code: 'db_not_opened',
355
+ cause,
356
+ });
357
+ }
358
+ this.emitCommitLifecycle('commit:envelope_persisted', {
359
+ idempotencyKey: envelope.idempotencyKey,
360
+ sourceMutationIds: envelope.sourceMutationIds,
361
+ });
362
+ }
363
+ return envelope;
364
+ }
365
+ /** Best-effort cleanup after a definitive result; replay is safe if it fails. */
366
+ async removeDurableCommit(idempotencyKey) {
367
+ if (!this.config.enablePersistence || !this.commitOutbox)
368
+ return;
369
+ try {
370
+ await this.commitOutbox.remove(commitEnvelopeRecordId(idempotencyKey));
371
+ }
372
+ catch (error) {
373
+ getContext().logger.debug('[TransactionQueue] Commit outbox cleanup deferred', {
374
+ idempotencyKey,
375
+ error: error instanceof Error ? error.message : String(error),
376
+ });
377
+ }
378
+ }
379
+ /**
380
+ * Takes either one complete retry envelope or a fresh batch. A retry waits
381
+ * until every original member has re-entered the queue, preventing an
382
+ * ambiguous A+B commit from being replayed later as A and B separately.
383
+ */
384
+ takeNextExecutionBatch() {
385
+ const retryGroups = new Map();
386
+ for (const tx of this.executionQueue) {
387
+ const envelope = tx.commitEnvelope;
388
+ if (!envelope)
389
+ continue;
390
+ const group = retryGroups.get(envelope.idempotencyKey) ??
391
+ new Map();
392
+ group.set(tx.id, tx);
393
+ retryGroups.set(envelope.idempotencyKey, group);
394
+ }
395
+ for (const [idempotencyKey, byId] of retryGroups) {
396
+ const members = [...byId.values()];
397
+ const expectedCount = members[0]?.commitEnvelope?.operationCount;
398
+ if (expectedCount === undefined || members.length !== expectedCount)
399
+ continue;
400
+ this.executionQueue = this.executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
401
+ members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) -
402
+ (b.commitEnvelope?.operationIndex ?? 0));
403
+ return members;
404
+ }
405
+ const fresh = this.executionQueue.filter((tx) => !tx.commitEnvelope);
406
+ const firstFresh = fresh[0];
407
+ if (!firstFresh)
408
+ return [];
409
+ // A caller-supplied key describes exactly one public mutation call. Keep
410
+ // that transaction out of an SDK-created aggregate batch so the key maps
411
+ // to the request its caller intended.
412
+ const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
413
+ const selected = explicitIndex === 0
414
+ ? [firstFresh]
415
+ : fresh.slice(0, Math.min(this.config.maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
416
+ const selectedIds = new Set(selected.map((tx) => tx.id));
417
+ this.executionQueue = this.executionQueue.filter((tx) => !selectedIds.has(tx.id));
418
+ return selected;
419
+ }
420
+ /**
421
+ * Selects one reconnect batch without changing the request identity of work
422
+ * that was already attempted. Explicit caller keys remain one-call batches;
423
+ * an existing envelope is replayed only with all of its original members.
424
+ */
425
+ takeOfflineFlushBatch(pending) {
426
+ const first = pending[0];
427
+ if (!first)
428
+ return [];
429
+ const envelope = first.commitEnvelope;
430
+ if (envelope) {
431
+ return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
432
+ }
433
+ if (typeof first.writeOptions?.idempotencyKey === 'string') {
434
+ return [first];
435
+ }
436
+ const boundary = pending.findIndex((tx) => tx.commitEnvelope !== undefined ||
437
+ typeof tx.writeOptions?.idempotencyKey === 'string');
438
+ return pending.slice(0, Math.min(this.config.maxBatchSize, boundary > 0 ? boundary : pending.length));
439
+ }
190
440
  /**
191
441
  * Resolvers for per-transaction `confirmation` promises. Populated in
192
442
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -244,22 +494,18 @@ export class TransactionQueue extends EventEmitter {
244
494
  });
245
495
  }
246
496
  /**
247
- * Look up the in-flight `confirmation` promise for a (model, id) pair.
248
- * Returns the promise from the most-recent live transaction matching
249
- * the given model+id, or `Promise.resolve()` if none is open (which
250
- * means either "already confirmed" or "never staged" — both safe
251
- * outcomes for the routing-helper grace-window use case).
252
- *
253
- * Looks across `pending`, `executing`, and `awaiting_delta` — these
254
- * are the three non-terminal statuses where rollback is still
255
- * possible. Skips `completed` (already settled) and `failed` /
256
- * `rolled_back` (already rejected; the call site missed the
257
- * `confirmation` window and should rely on `onMutationFailure` toast
258
- * instead).
497
+ * Returns the in-flight confirmation promise for a given model and id. When
498
+ * several transactions match, it returns the most recent one's promise; when
499
+ * none is open it resolves immediately, which covers both "already confirmed"
500
+ * and "never staged".
259
501
  *
260
- * Distinct from `tx.confirmation` on a known transaction used by
261
- * call sites that hold a Model reference (returned by
262
- * `ablo.<model>.create()`) but never see the underlying transaction.
502
+ * It considers the three non-terminal statuses in which the write can still
503
+ * be rolled back `pending`, `executing`, and `awaiting_delta` — and ignores
504
+ * `completed` (already settled) and `failed`/`rolled_back` (already
505
+ * rejected). This complements the `confirmation` promise carried on a known
506
+ * {@link Transaction}: use this method at call sites that hold a model
507
+ * returned by `ablo.<model>.create()` but never see the underlying
508
+ * transaction.
263
509
  */
264
510
  confirmationFor(modelName, modelId) {
265
511
  const candidates = [
@@ -275,15 +521,15 @@ export class TransactionQueue extends EventEmitter {
275
521
  return latest.confirmation ?? Promise.resolve();
276
522
  }
277
523
  /**
278
- * Attach a hot `confirmation` promise to a freshly created transaction.
279
- * Must be called BEFORE the transaction is staged so the call site can
280
- * `await tx.confirmation` synchronously after the create/update/delete
281
- * call returns. Idempotent: returns early if the tx already has one.
524
+ * Attaches a `confirmation` promise to a newly created transaction. Call this
525
+ * before the transaction is staged so a caller can `await tx.confirmation`
526
+ * immediately after a create, update, or delete returns. It is idempotent and
527
+ * returns early if one is already attached.
282
528
  *
283
- * The unhandled-rejection trap is mandatory most call sites won't
284
- * `await confirmation`, and Node/browser would otherwise crash on the
285
- * rejection. Consumers who *do* want failure visibility just attach a
286
- * `.then`/`.catch` and the trap becomes a no-op.
529
+ * It also attaches a no-op rejection handler. Most callers never await the
530
+ * confirmation, and without this the runtime would report an unhandled
531
+ * rejection when a write fails. Callers that do want to observe failure simply
532
+ * attach their own `.then`/`.catch`.
287
533
  */
288
534
  attachConfirmation(tx) {
289
535
  if (tx.confirmation)
@@ -292,34 +538,30 @@ export class TransactionQueue extends EventEmitter {
292
538
  this.confirmationResolvers.set(tx.id, { resolve, reject });
293
539
  });
294
540
  tx.confirmation.catch(() => {
295
- // Swallow unhandled rejections explicit consumers attach their own
296
- // handler; silent failure is the leak we're already fixing elsewhere.
541
+ // Swallow unhandled rejections; callers that care attach their own handler.
297
542
  });
298
543
  }
299
544
  /**
300
- * Set connection state checker - prevents rollbacks during disconnection.
301
- * When disconnected, timeouts re-schedule instead of rolling back.
545
+ * Registers a predicate the queue uses to check whether it is connected.
546
+ * While disconnected, confirmation timeouts re-schedule themselves instead of
547
+ * escalating, so a transaction is never rolled back merely because the client
548
+ * was briefly offline.
302
549
  */
303
550
  setConnectionChecker(fn) {
304
551
  this.isConnectedFn = fn;
305
552
  }
306
553
  /**
307
- * Drive the offline-grace timer for in-flight commit-lane transactions.
554
+ * Drives the offline-grace timer for in-flight commit-lane transactions.
308
555
  *
309
- * On `'disconnected'`: start a one-shot timer of
310
- * `config.commitOfflineGraceMs`. If the timer fires (disconnect
311
- * persisted past grace), iterate every commit-lane transaction with
312
- * `status {'pending', 'executing'}` and emit
313
- * `transaction:failed:${id}` with an `AbloConnectionError`. That
314
- * lets `waitForCommitReceipt` reject in seconds instead of hanging
315
- * forever — which is what wedged the 2026-05-15 subagent run.
556
+ * On `'disconnected'` it starts a one-shot timer of
557
+ * `config.commitOfflineGraceMs`. If that timer fires — meaning the disconnect
558
+ * outlasted the grace window every commit-lane transaction still `pending`
559
+ * or `executing` is failed with an {@link AbloConnectionError}, so
560
+ * {@link waitForCommitReceipt} rejects within seconds instead of hanging.
316
561
  *
317
- * On `'connected'`: clear any pending grace timer. Brief blips are
318
- * absorbed transparently; the existing reconnect-retry path in
319
- * `processCommitLane` / `flushOfflineQueue` handles the resumption.
320
- *
321
- * Called from SyncClient's `setConnectionState` after the
322
- * `'connection:disconnected'` / `'connection:established'` events.
562
+ * On `'connected'` it clears any pending grace timer. Brief disconnects are
563
+ * absorbed transparently; {@link processCommitLane} and
564
+ * {@link flushOfflineQueue} resume the work on reconnect.
323
565
  */
324
566
  setConnectionState(state) {
325
567
  if (state === 'connected') {
@@ -347,8 +589,8 @@ export class TransactionQueue extends EventEmitter {
347
589
  }
348
590
  if (inFlight.length === 0)
349
591
  return;
350
- // Internal mechanic: each failed commit surfaces to the consumer via its own
351
- // rejection path, so this aggregate is forensic debug, not warn.
592
+ // Each failed commit reaches the consumer through its own rejection path,
593
+ // so this aggregate line is forensic and logged at debug rather than warn.
352
594
  getContext().logger.debug(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
353
595
  for (const id of inFlight) {
354
596
  const tx = this.commitStore.get(id);
@@ -361,39 +603,41 @@ export class TransactionQueue extends EventEmitter {
361
603
  }
362
604
  }
363
605
  /**
364
- * Bind the executor for this queue instance. Called by the owning Ablo
365
- * right after `BaseSyncedStore` is constructed so the executor's
366
- * `storeHolder.store` closure resolves to *this* Ablo's WS not whichever
367
- * Ablo most recently called `initSyncEngine()`.
606
+ * Binds the mutation executor for this queue instance. The owning client
607
+ * calls this right after construction, so commits made here always dispatch
608
+ * through this instance's connection even when several client instances exist
609
+ * in the same process.
368
610
  */
369
611
  setMutationExecutor(executor) {
370
612
  this._mutationExecutor = executor;
371
613
  }
372
614
  // ============================================================================
373
- // LINEAR PATTERN: Microtask-based Transaction Staging
615
+ // Microtask-based transaction staging
374
616
  // ============================================================================
375
617
  //
376
- // All transactions first go to `createdTransactions` staging area.
377
- // A microtask commits them all together with the same batchIndex.
378
- // This ensures that bulk operations (like importing 100 layers) are batched efficiently.
618
+ // Every transaction lands in the `createdTransactions` staging area first.
619
+ // A microtask then commits them together under one batch index, so a bulk
620
+ // operation such as importing a hundred rows is sent efficiently.
379
621
  //
380
622
  // Flow:
381
- // 1. create()/update()/delete() calls stageTransaction()
382
- // 2. stageTransaction() adds to createdTransactions and schedules microtask
383
- // 3. Microtask runs commitCreatedTransactions() after current sync code completes
384
- // 4. All staged transactions get same batchIndex and move to executionQueue
623
+ // 1. create()/update()/delete() calls stageTransaction().
624
+ // 2. stageTransaction() adds to createdTransactions and schedules a microtask.
625
+ // 3. The microtask runs commitCreatedTransactions() once the current
626
+ // synchronous code finishes.
627
+ // 4. All staged transactions share one batch index and move to the execution
628
+ // queue.
385
629
  // ============================================================================
386
630
  /**
387
- * Stage a transaction for commit (Linear pattern)
388
- * Transactions staged in the same event loop tick will be committed together
631
+ * Stages a transaction for commit. Transactions staged within the same
632
+ * event-loop tick are committed together.
389
633
  */
390
634
  stageTransaction(transaction) {
391
635
  this.createdTransactions.push(transaction);
392
636
  this.scheduleCommit();
393
637
  }
394
638
  /**
395
- * Schedule commit of staged transactions via microtask
396
- * This ensures all synchronous transaction creates are batched together
639
+ * Schedules the staged transactions to commit on a microtask, so all
640
+ * transactions created synchronously within one tick are batched together.
397
641
  */
398
642
  scheduleCommit() {
399
643
  if (this.commitScheduled)
@@ -409,8 +653,8 @@ export class TransactionQueue extends EventEmitter {
409
653
  });
410
654
  }
411
655
  /**
412
- * Commit all staged transactions to the execution queue (Linear pattern)
413
- * All transactions get the same batchIndex for efficient batching
656
+ * Moves all staged transactions onto the execution queue, assigning them a
657
+ * single shared batch index so they commit together.
414
658
  */
415
659
  commitCreatedTransactions() {
416
660
  this.commitScheduled = false;
@@ -434,64 +678,99 @@ export class TransactionQueue extends EventEmitter {
434
678
  this.enqueue(transaction);
435
679
  }
436
680
  }
437
- // Batch flush all pending transactions via commit (fast path on reconnect)
681
+ /**
682
+ * Flushes every pending transaction in one commit, the fast path taken on
683
+ * reconnect. If transport fails, the transactions retain this exact commit
684
+ * envelope when they fall back to normal queue processing.
685
+ */
438
686
  async flushOfflineQueue() {
439
- // Kick the commit lane too — pending atomic envelopes from
440
- // `commits.create()` were left at the head of the lane while the WS
441
- // was down. Fire-and-forget; processCommitLane self-serializes.
687
+ this.assertDurableReplayOpen();
688
+ // Kick the commit lane too: atomic envelopes from `commits.create()` may
689
+ // have been left at the head of the lane while the connection was down.
690
+ // Fire-and-forget; processCommitLane serializes itself.
442
691
  void this.processCommitLane();
443
692
  // Collect pending transactions in created order
444
693
  const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
445
694
  if (pending.length === 0)
446
695
  return;
447
- // Build operations list
448
- const operations = pending.map((tx) => {
449
- this.ensureDerivedFields(tx);
450
- return applyWriteOptions({
451
- type: TX_TYPE_TO_MUTATION_OP[tx.type],
452
- model: tx.modelKey,
453
- id: tx.modelId,
454
- input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
455
- }, tx);
456
- });
457
- try {
458
- const res = await this.mutationExecutor.commit(operations);
459
- // Mark all as completed
460
- for (const tx of pending) {
461
- this.store.updateStatus(tx.id, 'completed');
462
- this.emit('transaction:completed', tx);
463
- this.emit(`transaction:completed:${tx.id}`, tx);
464
- this.optimisticUpdates.delete(tx.id);
465
- }
466
- // Simple perf note
467
- getContext().logger.debug('txn:commit', 0, {
468
- count: pending.length,
469
- lastSyncId: res?.lastSyncId,
470
- });
471
- }
472
- catch (err) {
473
- // If batch fails, fall back to normal processing
474
- // Only log if we're online (if we're offline, this is expected)
475
- const isOffline = !getContext().onlineStatus.isOnline();
476
- const isNetworkError = err instanceof Error &&
477
- (err.message.includes('Failed to fetch') ||
478
- err.message.includes('Network request failed') ||
479
- err.message.includes('NetworkError'));
480
- if (!isOffline || !isNetworkError) {
481
- getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
482
- error: err instanceof Error ? err.message : String(err),
696
+ const pendingIds = new Set(pending.map((tx) => tx.id));
697
+ // These rows may already be waiting behind the normal batch timer. The
698
+ // reconnect fast path takes ownership of them for this attempt so the same
699
+ // transaction cannot dispatch concurrently through both paths.
700
+ this.executionQueue = this.executionQueue.filter((tx) => !pendingIds.has(tx.id));
701
+ const remaining = [...pending];
702
+ while (remaining.length > 0) {
703
+ const batch = this.takeOfflineFlushBatch(remaining);
704
+ if (batch.length === 0)
705
+ break;
706
+ const batchIds = new Set(batch.map((tx) => tx.id));
707
+ const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
708
+ try {
709
+ const idempotencyKey = this.ensureCommitEnvelope(batch);
710
+ const projectedOperations = batch.map((tx) => {
711
+ this.ensureDerivedFields(tx);
712
+ return applyWriteOptions({
713
+ type: TX_TYPE_TO_MUTATION_OP[tx.type],
714
+ model: tx.modelKey,
715
+ id: tx.modelId,
716
+ input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
717
+ transactionId: tx.id,
718
+ }, tx);
719
+ });
720
+ const durableEnvelope = await this.sealDurableCommit({
721
+ idempotencyKey,
722
+ origin: 'model_batch',
723
+ operations: projectedOperations,
724
+ sourceMutationIds: this.sourceMutationIdsFor(batch),
725
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
726
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
727
+ sequence: batch[0]?.commitEnvelope?.sequence,
728
+ });
729
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
730
+ const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
731
+ idempotencyKey,
732
+ });
733
+ await this.removeDurableCommit(idempotencyKey);
734
+ // Mark this request envelope as completed before moving to the next.
735
+ for (const tx of batch) {
736
+ this.store.updateStatus(tx.id, 'completed');
737
+ this.emit('transaction:completed', tx);
738
+ this.emit(`transaction:completed:${tx.id}`, tx);
739
+ this.optimisticUpdates.delete(tx.id);
740
+ }
741
+ getContext().logger.debug('txn:commit', 0, {
742
+ count: batch.length,
743
+ lastSyncId: result.lastSyncId,
483
744
  });
745
+ remaining.splice(0, remaining.length, ...nextRemaining);
484
746
  }
485
- // Enqueue pending ones to executionQueue
486
- for (const tx of pending) {
487
- this.enqueue(tx);
747
+ catch (err) {
748
+ // If one request fails, hand it and every later request back to the
749
+ // normal lane. Their envelopes stay attached for safe retry.
750
+ const isOffline = !getContext().onlineStatus.isOnline();
751
+ const isNetworkError = err instanceof Error &&
752
+ (err.message.includes('Failed to fetch') ||
753
+ err.message.includes('Network request failed') ||
754
+ err.message.includes('NetworkError'));
755
+ if (!isOffline || !isNetworkError) {
756
+ getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
757
+ error: err instanceof Error ? err.message : String(err),
758
+ });
759
+ }
760
+ for (const tx of [...batch, ...nextRemaining]) {
761
+ this.enqueue(tx);
762
+ }
763
+ return;
488
764
  }
489
765
  }
490
766
  }
491
767
  /**
492
- * Create operation with optimistic update
768
+ * Records a create and applies it optimistically, then stages it for the next
769
+ * batched commit. Returns the {@link Transaction}, whose `confirmation`
770
+ * promise settles once the server confirms the write.
493
771
  */
494
- async create(model, context, writeOptions) {
772
+ async create(model, context, writeOptions, sourceMutationId) {
773
+ this.assertDurableReplayOpen();
495
774
  const actualModelName = model.getModelName();
496
775
  const transaction = {
497
776
  id: this.generateId(),
@@ -501,8 +780,8 @@ export class TransactionQueue extends EventEmitter {
501
780
  modelKey: normalizeModelKey(actualModelName),
502
781
  priorityScore: this.computePriorityScore('create', actualModelName),
503
782
  data: this.extractCreateData(model),
504
- // CREATE rollback removes the row there is no prior state to
505
- // restore, so allocating a `toJSON()` snapshot here was waste.
783
+ // Rolling back a create removes the row, so there is no prior state to
784
+ // restore and no snapshot is captured here.
506
785
  previousData: null,
507
786
  context,
508
787
  status: 'pending',
@@ -510,36 +789,39 @@ export class TransactionQueue extends EventEmitter {
510
789
  attempts: 0,
511
790
  priority: 'normal',
512
791
  writeOptions,
792
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
513
793
  };
514
794
  this.attachConfirmation(transaction);
515
795
  this.store.add(transaction);
516
796
  if (this.config.enableOptimistic) {
517
797
  this.applyOptimisticCreate(model, transaction);
518
798
  }
519
- // Microtask coalescer (`scheduleCommit`) collapses all creates in
520
- // this tick into one wire commit with one `batchIndex` — see
521
- // `commitCreatedTransactions`. No batch API needed at the call site.
799
+ // The microtask coalescer (`scheduleCommit`) collapses all creates in this
800
+ // tick into one commit under a single `batchIndex` — see
801
+ // `commitCreatedTransactions`. No batch call is needed at the call site.
522
802
  this.stageTransaction(transaction);
523
803
  this.emit('transaction:created', transaction);
524
804
  return transaction;
525
805
  }
526
806
  /**
527
- * Update operation with conflict detection
528
- * @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
807
+ * Records an update and applies it optimistically, then stages it for the next
808
+ * batched commit. Rapid updates to the same entity coalesce into a single wire
809
+ * operation.
810
+ * @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
529
811
  */
530
- async update(model, context, precomputedChanges, writeOptions) {
812
+ async update(model, context, precomputedChanges, writeOptions, sourceMutationId) {
813
+ this.assertDurableReplayOpen();
531
814
  const actualModelName = model.getModelName();
532
815
  // Use pre-computed changes if provided, otherwise extract from model
533
816
  const updateInput = precomputedChanges
534
817
  ? this.mapChangesToInput(actualModelName, precomputedChanges)
535
818
  : this.extractUpdateData(model);
536
819
  const previousData = this.extractPreviousData(model, updateInput);
537
- // Advance the per-field baseline for the keys we just froze into this
538
- // transaction. `Model.propertyChanged` is first-old-wins and only cleared on
539
- // sync-ack, so without this a SECOND update to the same field before the
540
- // first acks would re-capture the original `.old` (the pre-session value)
541
- // instead of THIS update's result corrupting the stream-recorded undo
542
- // inverse (the second move's "before" would point all the way back). The
820
+ // Advance the per-field baseline for the keys just frozen into this
821
+ // transaction. The model records the first old value per field and clears it
822
+ // only on sync acknowledgement, so a second update to the same field before
823
+ // the first is acknowledged would otherwise re-capture the original value
824
+ // instead of this update's result, corrupting the recorded undo inverse. The
543
825
  // wire payload is already frozen in `transaction.data`, so dropping the
544
826
  // consumed entries is safe.
545
827
  model.consumeModifiedFields(Object.keys(updateInput));
@@ -560,6 +842,7 @@ export class TransactionQueue extends EventEmitter {
560
842
  attempts: 0,
561
843
  priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
562
844
  writeOptions,
845
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
563
846
  };
564
847
  this.attachConfirmation(transaction);
565
848
  this.store.add(transaction);
@@ -567,18 +850,23 @@ export class TransactionQueue extends EventEmitter {
567
850
  if (this.config.enableOptimistic) {
568
851
  this.applyOptimisticUpdate(model, transaction);
569
852
  }
570
- // LINEAR PATTERN: Stage transaction for microtask commit
571
- // Multiple updates in same event loop will be batched together
572
- // enqueue() will still apply its coalescing logic for same-entity updates
853
+ // Stage the transaction for the microtask commit; updates made in the same
854
+ // tick are batched together, and enqueue() still coalesces same-entity
855
+ // updates.
573
856
  this.stageTransaction(transaction);
574
857
  this.emit('transaction:created', transaction);
575
858
  return transaction;
576
859
  }
577
860
  /**
578
- * Delete operation with cascade handling
861
+ * Records a delete and applies it optimistically. If the row's own create has
862
+ * not yet been sent, both are cancelled locally rather than sending a create
863
+ * followed by a delete; if the create is already in flight, the delete waits
864
+ * until it settles so the server never sees a delete before the create.
579
865
  */
580
- async delete(model, context, writeOptions) {
581
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
866
+ async delete(model, context, writeOptions, sourceMutationId) {
867
+ this.assertDurableReplayOpen();
868
+ // Use getModelName() rather than constructor.name, which is unreliable once
869
+ // class names are minified.
582
870
  const actualModelName = model.getModelName();
583
871
  // Skip Activity delete transactions - activities are permanent audit records
584
872
  if (actualModelName === 'Activity') {
@@ -599,6 +887,7 @@ export class TransactionQueue extends EventEmitter {
599
887
  attempts: 0,
600
888
  priority: 'high',
601
889
  writeOptions,
890
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
602
891
  localOnly: true,
603
892
  // Activity deletes complete synchronously (audit-record skip path).
604
893
  // Pre-resolved so consumers can still `await tx.confirmation` uniformly.
@@ -617,7 +906,10 @@ export class TransactionQueue extends EventEmitter {
617
906
  const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
618
907
  if (unsentCreate) {
619
908
  await this.cancelUnsentCreateForDelete(unsentCreate);
620
- return this.completeLocalDelete(model, context, writeOptions);
909
+ return this.completeLocalDelete(model, context, writeOptions, [
910
+ ...(unsentCreate.sourceMutationIds ?? []),
911
+ ...(sourceMutationId ? [sourceMutationId] : []),
912
+ ]);
621
913
  }
622
914
  const transaction = {
623
915
  id: this.generateId(),
@@ -633,13 +925,22 @@ export class TransactionQueue extends EventEmitter {
633
925
  attempts: 0,
634
926
  priority: 'high', // Deletes are high priority
635
927
  writeOptions,
928
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
636
929
  };
637
930
  this.attachConfirmation(transaction);
638
931
  this.store.add(transaction);
639
932
  // Cancel any pending/in-flight updates for this model to prevent "no rows" errors
640
933
  // when the delete executes before the update (race condition fix)
641
- this.cancelTransactionsForModel(model.id, 'update');
934
+ const canceledUpdates = this.cancelTransactionsForModel(model.id, 'update');
642
935
  const entityKey = this.entityKey(actualModelName, model.id);
936
+ const pendingMerge = this.pendingMergeByModel.get(entityKey);
937
+ transaction.sourceMutationIds = [
938
+ ...new Set([
939
+ ...(transaction.sourceMutationIds ?? []),
940
+ ...canceledUpdates.flatMap((candidate) => candidate.sourceMutationIds ?? []),
941
+ ...(pendingMerge?.sourceMutationIds ?? []),
942
+ ]),
943
+ ];
643
944
  this.pendingMergeByModel.delete(entityKey);
644
945
  this.inFlightByModel.delete(entityKey);
645
946
  // Apply optimistic delete
@@ -651,30 +952,35 @@ export class TransactionQueue extends EventEmitter {
651
952
  this.deferDeleteUntilCreateSettles(createBarrier, transaction);
652
953
  }
653
954
  else {
654
- // LINEAR PATTERN: Stage transaction for microtask commit
655
- // All deletes in same event loop will be batched together
955
+ // Stage the transaction for the microtask commit; deletes in the same
956
+ // tick are batched together.
656
957
  this.stageTransaction(transaction);
657
958
  }
658
959
  this.emit('transaction:created', transaction);
659
960
  return transaction;
660
961
  }
661
962
  /**
662
- * Upload attachment delegates to attachment-uploader.ts
963
+ * Uploads a single attachment, delegating to the mutation executor.
663
964
  */
664
- async uploadAttachment(_file, options, _context) {
965
+ async uploadAttachment(_file, options, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
966
+ ) {
665
967
  return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
666
968
  }
667
969
  /**
668
- * Batch upload attachments delegates to MutationExecutor
970
+ * Uploads several attachments in one call, delegating to the mutation executor.
669
971
  */
670
- async batchUploadAttachments(_files, items, _context) {
972
+ async batchUploadAttachments(_files, items, _context // eslint-disable-line @typescript-eslint/no-unused-vars -- reserved executor context
973
+ ) {
671
974
  return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
672
975
  }
673
976
  /**
674
- * Archive operation
977
+ * Records an archive and applies it optimistically, then stages it for the
978
+ * next batched commit.
675
979
  */
676
- async archive(model, context, writeOptions) {
677
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
980
+ async archive(model, context, writeOptions, sourceMutationId) {
981
+ this.assertDurableReplayOpen();
982
+ // Use getModelName() rather than constructor.name, which is unreliable once
983
+ // class names are minified.
678
984
  const actualModelName = model.getModelName();
679
985
  const modelKey = normalizeModelKey(actualModelName);
680
986
  const priorityScore = this.computePriorityScore('archive', actualModelName);
@@ -692,19 +998,23 @@ export class TransactionQueue extends EventEmitter {
692
998
  attempts: 0,
693
999
  priority: 'normal',
694
1000
  writeOptions,
1001
+ ...(sourceMutationId ? { sourceMutationIds: [sourceMutationId] } : {}),
695
1002
  };
696
1003
  this.attachConfirmation(transaction);
697
1004
  this.store.add(transaction);
698
- // LINEAR PATTERN: Stage transaction for microtask commit
1005
+ // Stage the transaction for the microtask commit.
699
1006
  this.stageTransaction(transaction);
700
1007
  this.emit('transaction:created', transaction);
701
1008
  return transaction;
702
1009
  }
703
1010
  /**
704
- * Unarchive operation
1011
+ * Records an unarchive and applies it optimistically, then stages it for the
1012
+ * next batched commit.
705
1013
  */
706
1014
  async unarchive(model, context) {
707
- // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
1015
+ this.assertDurableReplayOpen();
1016
+ // Use getModelName() rather than constructor.name, which is unreliable once
1017
+ // class names are minified.
708
1018
  const actualModelName = model.getModelName();
709
1019
  const modelKey = normalizeModelKey(actualModelName);
710
1020
  const priorityScore = this.computePriorityScore('unarchive', actualModelName);
@@ -724,37 +1034,44 @@ export class TransactionQueue extends EventEmitter {
724
1034
  };
725
1035
  this.attachConfirmation(transaction);
726
1036
  this.store.add(transaction);
727
- // LINEAR PATTERN: Stage transaction for microtask commit
1037
+ // Stage the transaction for the microtask commit.
728
1038
  this.stageTransaction(transaction);
729
1039
  this.emit('transaction:created', transaction);
730
1040
  return transaction;
731
1041
  }
732
1042
  /**
733
- * Enqueue transaction for execution
1043
+ * Places a transaction on the execution queue, coalescing it into an existing
1044
+ * same-entity update where possible so redundant writes collapse.
734
1045
  */
735
1046
  enqueue(transaction) {
736
1047
  this.ensureDerivedFields(transaction);
737
1048
  const modelKey = `${transaction.modelName}:${transaction.modelId}`;
738
- // LINEAR PATTERN: Simplified coalescing for updates
739
- // Staging already batches all transactions in same event loop tick
740
- // We only need to handle: (1) in-flight merging, (2) same-entity merging
1049
+ // Coalescing for updates. Staging already batches everything created in one
1050
+ // event-loop tick, so only two cases remain here: merging into an in-flight
1051
+ // update, and merging into a pending same-entity update.
741
1052
  //
742
- // RETRIES (attempts > 0) must NEVER take either merge path. A retry is
743
- // re-enqueued from `handleFailure` while its own modelKey is still in
744
- // `inFlightByModel` (the post-batch cleanup runs after the catch), so the
745
- // in-flight merge mistook the retry for a concurrent user edit: it moved
746
- // the data into `pendingMergeByModel`, REMOVED the transaction, and the
747
- // post-batch block minted a fresh follow-up with `attempts: 0`. The
748
- // attempt counter never accumulated and `maxRetries` never tripped — an
749
- // infinite ~`batchDelay` resend storm of self-laundering transaction
750
- // clones (found by the claims journey, 2026-06-10).
751
- if (transaction.type === 'update' && transaction.attempts === 0) {
752
- const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
1053
+ // Retries (attempts > 0 or an already assigned commit envelope) must not
1054
+ // take either merge path. A retry is re-enqueued while its own model key may
1055
+ // still be marked in `inFlightByModel`; treating it as a concurrent edit
1056
+ // would change the already-attempted request, discard its stable envelope,
1057
+ // and either defeat maxRetries or duplicate an ambiguously committed write.
1058
+ if (transaction.type === 'update' &&
1059
+ transaction.attempts === 0 &&
1060
+ !transaction.commitEnvelope) {
1061
+ const preserveWatermark = hasCommitCoalescingBarrier(transaction.writeOptions);
753
1062
  // If there is an in-flight update for this model, merge into post-flight buffer
754
1063
  if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
755
- const prev = this.pendingMergeByModel.get(modelKey) || {};
756
- const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
757
- this.pendingMergeByModel.set(modelKey, merged);
1064
+ const previous = this.pendingMergeByModel.get(modelKey);
1065
+ const merged = mergeUpdateData(previous?.data ?? {}, transaction.data || {}, transaction.modelName);
1066
+ this.pendingMergeByModel.set(modelKey, {
1067
+ data: merged,
1068
+ sourceMutationIds: [
1069
+ ...new Set([
1070
+ ...(previous?.sourceMutationIds ?? []),
1071
+ ...(transaction.sourceMutationIds ?? []),
1072
+ ]),
1073
+ ],
1074
+ });
758
1075
  this.store.remove(transaction.id);
759
1076
  return;
760
1077
  }
@@ -763,9 +1080,15 @@ export class TransactionQueue extends EventEmitter {
763
1080
  t.type === 'update' &&
764
1081
  t.modelId === transaction.modelId &&
765
1082
  t.modelName === transaction.modelName &&
766
- !hasStaleWriteOptions(t.writeOptions));
1083
+ !hasCommitCoalescingBarrier(t.writeOptions));
767
1084
  if (!preserveWatermark && pendingInQueue) {
768
1085
  pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
1086
+ pendingInQueue.sourceMutationIds = [
1087
+ ...new Set([
1088
+ ...(pendingInQueue.sourceMutationIds ?? []),
1089
+ ...(transaction.sourceMutationIds ?? []),
1090
+ ]),
1091
+ ];
769
1092
  this.store.remove(transaction.id);
770
1093
  return;
771
1094
  }
@@ -782,8 +1105,9 @@ export class TransactionQueue extends EventEmitter {
782
1105
  scheduleProcessing(immediate = false) {
783
1106
  if (this.processScheduled)
784
1107
  return;
785
- // BACKPRESSURE: Don't schedule if too many transactions are already executing
786
- // This prevents overwhelming the server with concurrent requests (Linear pattern)
1108
+ // Backpressure: don't schedule another batch while too many transactions
1109
+ // are already executing, so the server is not flooded with concurrent
1110
+ // requests.
787
1111
  if (this.executingCount >= this.config.maxExecutingTransactions) {
788
1112
  getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
789
1113
  executingCount: this.executingCount,
@@ -810,15 +1134,15 @@ export class TransactionQueue extends EventEmitter {
810
1134
  }, delay);
811
1135
  }
812
1136
  /**
813
- * Process batch of transactions using LINEAR-style unified batch execution.
814
- *
815
- * Key optimization: Instead of making separate calls per operation type/model,
816
- * we collect ALL batchable operations and send them in a SINGLE commit call.
817
- * The sync-server handles mixed types atomically inside one transaction.
818
- *
819
- * This reduces N round-trips to 1, dramatically improving batch latency.
1137
+ * Processes one batch of transactions in a single commit. Rather than calling
1138
+ * the server once per operation type or model, it collects every batchable
1139
+ * operation and sends them together; the server applies the mixed operations
1140
+ * atomically within one transaction. This turns many round trips into one and
1141
+ * greatly reduces batch latency.
820
1142
  */
821
1143
  async processBatch() {
1144
+ if (this.durableReplayBlock)
1145
+ return;
822
1146
  const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
823
1147
  if (this.isProcessing || this.executionQueue.length === 0) {
824
1148
  return;
@@ -828,9 +1152,9 @@ export class TransactionQueue extends EventEmitter {
828
1152
  let batch = [];
829
1153
  await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
830
1154
  try {
831
- // Sort executionQueue by FK priority before batch selection
832
- // This ensures parent entities (Layout, SlideLayout) are always processed
833
- // before their children (SlideLayoutLayer) across batch boundaries
1155
+ // Sort the execution queue by foreign-key priority before selecting a
1156
+ // batch, so a parent row is always committed before its children, even
1157
+ // across batch boundaries.
834
1158
  this.executionQueue.sort((a, b) => {
835
1159
  // Ensure derived fields exist (covers restored/persisted transactions)
836
1160
  this.ensureDerivedFields(a);
@@ -843,8 +1167,12 @@ export class TransactionQueue extends EventEmitter {
843
1167
  }
844
1168
  return a.priorityScore - b.priorityScore;
845
1169
  });
846
- // Get batch (now guaranteed to have parent entities before children)
847
- batch = this.executionQueue.splice(0, this.config.maxBatchSize);
1170
+ // Take a fresh batch or one complete retry envelope. Retry envelopes
1171
+ // retain both their original membership and operation order.
1172
+ batch = this.takeNextExecutionBatch();
1173
+ if (batch.length === 0)
1174
+ return;
1175
+ const commitIdempotencyKey = this.ensureCommitEnvelope(batch);
848
1176
  // Track executing count for backpressure
849
1177
  this.executingCount += batch.length;
850
1178
  // Mark all as executing
@@ -854,15 +1182,15 @@ export class TransactionQueue extends EventEmitter {
854
1182
  this.inFlightByModel.add(key);
855
1183
  this.store.updateStatus(tx.id, 'executing');
856
1184
  }
857
- // Build ALL operations for unified commit (SINGLE WS round-trip)
1185
+ // Build every operation for one unified commit (a single round trip).
858
1186
  const batchOps = [];
859
1187
  for (const tx of batch) {
860
- // Per-op `transactionId` carries the local tx UUID through
861
- // the wire so the server can stamp it on the resulting
862
- // sync delta. The receive path (`SyncClient.applyDeltaBatchToPool`)
863
- // matches it via `OptimisticEchoTracker.consumeEcho` to suppress
864
- // double-applying optimistic mutations. Distinct from the
865
- // batch-level idempotency key in mutation_log.
1188
+ // The per-operation `transactionId` carries the local transaction id
1189
+ // over the wire so the server can stamp it on the resulting sync
1190
+ // delta. The receive path matches it through
1191
+ // {@link UnconfirmedWrites.consumeEcho} to avoid applying the
1192
+ // client's own optimistic change twice. This is separate from the
1193
+ // batch-level idempotency key recorded in `mutation_log`.
866
1194
  const op = applyWriteOptions({
867
1195
  type: TX_TYPE_TO_MUTATION_OP[tx.type],
868
1196
  model: tx.modelKey,
@@ -872,28 +1200,40 @@ export class TransactionQueue extends EventEmitter {
872
1200
  }, tx);
873
1201
  batchOps.push({ tx, op });
874
1202
  }
875
- // Execute unified commit for ALL operations (SINGLE WS round-trip)
1203
+ // Execute the unified commit for every operation in one round trip.
876
1204
  if (batchOps.length > 0) {
877
- const operations = batchOps.map(({ op }) => op);
1205
+ let dispatchStarted = false;
878
1206
  try {
879
- // LINEAR PATTERN: Capture lastSyncId from server response for threshold-based confirmation
1207
+ const durableEnvelope = await this.sealDurableCommit({
1208
+ idempotencyKey: commitIdempotencyKey,
1209
+ origin: 'model_batch',
1210
+ operations: batchOps.map(({ op }) => op),
1211
+ sourceMutationIds: this.sourceMutationIdsFor(batch),
1212
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
1213
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
1214
+ sequence: batch[0]?.commitEnvelope?.sequence,
1215
+ });
1216
+ const operations = durableEnvelope.operations;
1217
+ // Capture lastSyncId from the server response for threshold-based
1218
+ // confirmation.
880
1219
  //
881
- // Idempotency note: the default HTTP executor derives a
882
- // stable `Idempotency-Key` from the operations array
883
- // itself (sorted sha256), so retries of the SAME batch
884
- // hit the server's `mutation_log` replay path without
885
- // requiring us to thread a key through the microtask
886
- // boundary here. Keeping this path await-free preserves
887
- // the coalescing test's tight bound on batch count.
888
- const result = await this.mutationExecutor.commit(operations);
1220
+ // The queue owns request identity. A lost acknowledgement may be
1221
+ // retried after a backoff or reconnect, so every retry must send
1222
+ // the exact key assigned before the first transport attempt.
1223
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
1224
+ dispatchStarted = true;
1225
+ const result = await this.mutationExecutor.commit(operations, {
1226
+ idempotencyKey: commitIdempotencyKey,
1227
+ });
1228
+ await this.removeDurableCommit(commitIdempotencyKey);
889
1229
  const lastSyncId = result?.lastSyncId ?? 0;
890
1230
  this.noteAck(lastSyncId);
891
- // Notify-instead-of-abort: the server returned stale-context
892
- // notifications for `onStale: 'notify'` ops whose premise moved.
893
- // Every notified op was HELD (not written) its optimistic state
894
- // must be rolled back here; no delta will ever confirm it. We also
895
- // stamp the signal so `waitForCommitReceipt` carries it onto the
896
- // receipt.
1231
+ // The server returned stale-context notifications for operations
1232
+ // marked `onStale: 'notify'` whose read premise had moved. Each
1233
+ // notified operation was held rather than written, so its
1234
+ // optimistic state must be rolled back here no delta will ever
1235
+ // confirm it and the signal is stamped so
1236
+ // {@link waitForCommitReceipt} can carry it onto the receipt.
897
1237
  const notifications = result?.notifications;
898
1238
  const heldIds = new Set((notifications ?? []).map((n) => n.id));
899
1239
  for (const { tx } of batchOps) {
@@ -902,15 +1242,16 @@ export class TransactionQueue extends EventEmitter {
902
1242
  this.commitNotifications.set(tx.id, txNotifs);
903
1243
  }
904
1244
  }
905
- // Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
1245
+ // A lastSyncId of 0 means the mutation succeeded but the server
1246
+ // emitted no sync delta; record that anomaly for observability.
906
1247
  if (lastSyncId === 0) {
907
1248
  getContext().observability.captureCommitZeroSyncId({
908
1249
  operationCount: operations.length,
909
1250
  operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
910
1251
  });
911
1252
  }
912
- // LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
913
- // Transactions will be confirmed when any delta with id >= lastSyncId arrives
1253
+ // Mark each transaction with the sync-id threshold that will
1254
+ // confirm it: any delta whose id is at least lastSyncId.
914
1255
  for (const { tx } of batchOps) {
915
1256
  // Held op ('notify'): the server withheld the write, so no delta
916
1257
  // will confirm it. Roll back the optimistic update (server
@@ -942,17 +1283,17 @@ export class TransactionQueue extends EventEmitter {
942
1283
  });
943
1284
  continue;
944
1285
  }
945
- // ACK-BASED CONFIRMATION. A successful commit response with a
946
- // real watermark means the server durably applied the write —
947
- // that IS the confirmation (the documented `wait: 'confirmed'`
948
- // contract, and how Replicache/Zero treat the push response's
949
- // lastMutationID). The delta echo is NOT an acknowledgement
950
- // channel: it's replication for OTHER clients, and this
951
- // client's own echo is suppressed by the OptimisticEchoTracker
952
- // anyway. Gating confirmation on the echo coupled "did my
953
- // write land" to subscription-stream health a bare-Node
954
- // client with no live delta stream hung forever in
955
- // `awaiting_delta` on a write the server had already applied.
1286
+ // Acknowledgement-based confirmation. A successful commit
1287
+ // response carrying a real watermark means the server durably
1288
+ // applied the write, and that is the confirmation the
1289
+ // `wait: 'confirmed'` contract promises. The delta echo is not
1290
+ // an acknowledgement channel: it exists to replicate the change
1291
+ // to other clients, and this client's own echo is suppressed by
1292
+ // the {@link UnconfirmedWrites} regardless. Waiting on the
1293
+ // echo would tie "did my write land?" to the health of the
1294
+ // subscription stream, so a client with no live delta stream
1295
+ // would wait forever in `awaiting_delta` for a write the server
1296
+ // had already applied.
956
1297
  if (lastSyncId > 0) {
957
1298
  this.store.updateStatus(tx.id, 'completed');
958
1299
  this.emit('transaction:completed', tx);
@@ -966,12 +1307,11 @@ export class TransactionQueue extends EventEmitter {
966
1307
  });
967
1308
  }
968
1309
  else {
969
- // lastSyncId === 0 on a non-DELETE: the server accepted the
970
- // commit but emitted no delta a server-side anomaly
971
- // (already captured via captureCommitZeroSyncId above). Keep
972
- // the delta-wait + reconciliation timeout for THIS case
973
- // only, so the anomaly surfaces instead of silently
974
- // confirming a write with no watermark.
1310
+ // A lastSyncId of 0 on a non-delete: the server accepted the
1311
+ // commit but emitted no delta, which is an anomaly (already
1312
+ // captured above). Keep the delta wait and reconciliation
1313
+ // timeout only for this case, so the anomaly surfaces instead
1314
+ // of silently confirming a write that carries no watermark.
975
1315
  this.store.updateStatus(tx.id, 'awaiting_delta');
976
1316
  getContext().logger.debug('tx:awaiting_delta', {
977
1317
  txId: tx.id.slice(0, 8),
@@ -986,11 +1326,14 @@ export class TransactionQueue extends EventEmitter {
986
1326
  }
987
1327
  catch (error) {
988
1328
  const errorMessage = error.message || '';
989
- // Surface the raw server rejection for the whole batch so
990
- // cascaded failures (e.g. Layout FK violation that rolls
991
- // back a 6-op transaction) are attributable to a specific
992
- // cause instead of each op showing as a generic permanent
993
- // error downstream.
1329
+ if (dispatchStarted && this.isDefinitiveRejection(error)) {
1330
+ await this.removeDurableCommit(commitIdempotencyKey);
1331
+ }
1332
+ // Surface the raw server rejection for the whole batch so a
1333
+ // cascaded failure — for example a foreign-key violation that
1334
+ // rolls back a multi-operation transaction — is attributable to a
1335
+ // specific cause rather than showing as a generic permanent error
1336
+ // on each operation.
994
1337
  const abloErr = error instanceof AbloError ? error : undefined;
995
1338
  // SyncWebSocket attaches a `diagnostics` snapshot to its
996
1339
  // "not connected" / "closed while in flight" rejections.
@@ -1026,16 +1369,20 @@ export class TransactionQueue extends EventEmitter {
1026
1369
  message: errorMessage,
1027
1370
  diagnostics,
1028
1371
  });
1029
- // LINEAR PATTERN: Handle "no rows in result set" gracefully
1030
- // This error means the entity was already deleted - for UPDATE/DELETE ops, this is success
1031
- // The claim was achieved (the data doesn't exist), so treat as completed
1372
+ // Handle "no rows in result set" gracefully: the row was already
1373
+ // deleted, so for update and delete operations this is effectively
1374
+ // success the intended end state already holds and the
1375
+ // transaction is treated as completed.
1032
1376
  if (errorMessage.includes('no rows in result set')) {
1377
+ if (dispatchStarted) {
1378
+ await this.removeDurableCommit(commitIdempotencyKey);
1379
+ }
1033
1380
  getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
1034
1381
  batchSize: batchOps.length,
1035
1382
  });
1036
1383
  for (const { tx, op } of batchOps) {
1037
1384
  if (op.type === 'UPDATE' || op.type === 'DELETE') {
1038
- // Entity gone = claim achieved, mark as completed
1385
+ // Row already gone: the intended state holds, mark completed.
1039
1386
  this.store.updateStatus(tx.id, 'completed');
1040
1387
  this.emit('transaction:completed', tx);
1041
1388
  getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
@@ -1064,7 +1411,7 @@ export class TransactionQueue extends EventEmitter {
1064
1411
  if (tx.type === 'update') {
1065
1412
  this.inFlightByModel.delete(key);
1066
1413
  const pending = this.pendingMergeByModel.get(key);
1067
- if (pending && Object.keys(pending).length > 0) {
1414
+ if (pending && Object.keys(pending.data).length > 0) {
1068
1415
  // Create a single merged follow-up transaction
1069
1416
  const followUp = {
1070
1417
  id: this.generateId(),
@@ -1072,7 +1419,7 @@ export class TransactionQueue extends EventEmitter {
1072
1419
  modelName: tx.modelName,
1073
1420
  modelId: tx.modelId,
1074
1421
  modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
1075
- data: pending,
1422
+ data: pending.data,
1076
1423
  previousData: undefined,
1077
1424
  context: tx.context,
1078
1425
  status: 'pending',
@@ -1080,6 +1427,7 @@ export class TransactionQueue extends EventEmitter {
1080
1427
  attempts: 0,
1081
1428
  priority: 'normal',
1082
1429
  priorityScore: this.computePriorityScore('update', tx.modelName),
1430
+ sourceMutationIds: pending.sourceMutationIds,
1083
1431
  };
1084
1432
  this.pendingMergeByModel.delete(key);
1085
1433
  this.store.add(followUp);
@@ -1093,7 +1441,7 @@ export class TransactionQueue extends EventEmitter {
1093
1441
  // Decrement executing count for backpressure tracking
1094
1442
  this.executingCount -= batch.length;
1095
1443
  // Process next batch if needed
1096
- if (this.executionQueue.length > 0) {
1444
+ if (this.executionQueue.length > 0 && batch.length > 0) {
1097
1445
  this.scheduleProcessing(true);
1098
1446
  }
1099
1447
  const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -1106,22 +1454,23 @@ export class TransactionQueue extends EventEmitter {
1106
1454
  }, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
1107
1455
  }
1108
1456
  /**
1109
- * LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
1110
- * This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
1111
- * Policy + timeout maps live in the `./deltaConfirmation.js` leaf.
1112
- * @param syncId - The sync ID of the received delta
1457
+ * Confirms every awaiting transaction whose sync-id threshold this delta meets
1458
+ * or exceeds. The confirmation policy and timeout tracking live in
1459
+ * {@link DeltaConfirmationTracker} (`./deltaConfirmation.js`).
1460
+ * @param syncId - The sync id of the received delta.
1113
1461
  */
1114
1462
  onDeltaReceived(syncId) {
1115
1463
  this.deltaConfirmation.onDeltaReceived(syncId);
1116
1464
  }
1117
- // REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry +
1118
- // reconciliation see DeltaConfirmationTracker in `./deltaConfirmation.js`.
1465
+ // Schedule the retry-and-reconciliation wait for a transaction's confirming
1466
+ // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1119
1467
  scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1120
1468
  this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1121
1469
  }
1122
1470
  /**
1123
- * Wait for a transaction to be confirmed via delta echo (Linear pattern)
1124
- * Reuses existing timeout mechanism from scheduleDeltaConfirmationTimeout
1471
+ * Resolves once the given transaction is confirmed and rejects if it fails.
1472
+ * The confirming delta's timeout is handled by
1473
+ * {@link scheduleDeltaConfirmationTimeout}.
1125
1474
  */
1126
1475
  waitForConfirmation(transactionId) {
1127
1476
  return new Promise((resolve, reject) => {
@@ -1148,21 +1497,45 @@ export class TransactionQueue extends EventEmitter {
1148
1497
  this.on(`transaction:failed:${transactionId}`, onFailed);
1149
1498
  });
1150
1499
  }
1151
- // Public: check if a clientMutationId exists in this queue (helps identify self-echo deltas)
1500
+ // Reports whether a client mutation id is known to this queue, which helps
1501
+ // identify a delta as this client's own echo.
1152
1502
  hasClientMutationId(id) {
1153
1503
  return !!this.store.get(id) || this.commitStore.has(id);
1154
1504
  }
1155
1505
  /**
1156
- * Enqueue a raw multi-op atomic commit envelope (the `ablo.commits.create`
1157
- * path). Operations are pre-built by the caller; the queue's job is
1158
- * retry-on-reconnect + idempotent dedup, NOT optimistic apply or FK
1159
- * ordering. Same idempotency key (clientTxId) is dropped on the floor
1160
- * if already in flight server-side `mutation_log` handles cross-session
1161
- * dedup; this guard handles same-session double-enqueue.
1506
+ * Enqueues a pre-built, multi-operation atomic commit the
1507
+ * `ablo.commits.create()` path. The caller supplies the operations; the queue
1508
+ * only retries on reconnect and de-duplicates, and does not apply the change
1509
+ * optimistically or reorder for foreign keys. A duplicate `clientTxId`
1510
+ * already in flight is ignored: the server's `mutation_log` de-duplicates
1511
+ * across sessions, and this guard covers a double-enqueue within one session.
1162
1512
  */
1163
- enqueueCommit(clientTxId, operations, options = {}) {
1164
- if (this.commitStore.has(clientTxId))
1513
+ async enqueueCommit(clientTxId, operations, options = {}) {
1514
+ this.assertDurableReplayOpen();
1515
+ const existing = this.commitStore.get(clientTxId);
1516
+ if (existing) {
1517
+ await existing.sealPromise;
1518
+ const existingIntent = stableStringify({
1519
+ operations: existing.operations,
1520
+ causedByTaskId: existing.causedByTaskId ?? null,
1521
+ reads: existing.reads ?? null,
1522
+ });
1523
+ const incomingIntent = stableStringify({
1524
+ operations,
1525
+ causedByTaskId: options.causedByTaskId ?? null,
1526
+ reads: options.reads ?? null,
1527
+ });
1528
+ if (existingIntent !== incomingIntent) {
1529
+ throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
1530
+ }
1531
+ if (existing.status === 'pending')
1532
+ void this.processCommitLane();
1165
1533
  return;
1534
+ }
1535
+ this.emitCommitLifecycle('commit:staging', {
1536
+ clientTxId,
1537
+ operations,
1538
+ });
1166
1539
  const tx = {
1167
1540
  id: clientTxId,
1168
1541
  kind: 'commit',
@@ -1172,25 +1545,56 @@ export class TransactionQueue extends EventEmitter {
1172
1545
  status: 'pending',
1173
1546
  createdAt: Date.now(),
1174
1547
  attempts: 0,
1548
+ sealedAt: Date.now(),
1549
+ sequence: this.nextCommitSequence(),
1175
1550
  };
1176
1551
  this.commitStore.set(clientTxId, tx);
1552
+ tx.sealPromise = this.sealDurableCommit({
1553
+ idempotencyKey: tx.id,
1554
+ origin: 'atomic_commit',
1555
+ operations: tx.operations,
1556
+ commitOptions: {
1557
+ causedByTaskId: tx.causedByTaskId ?? null,
1558
+ ...(tx.reads ? { reads: tx.reads } : {}),
1559
+ },
1560
+ createdAt: tx.createdAt,
1561
+ sealedAt: tx.sealedAt,
1562
+ sequence: tx.sequence,
1563
+ }).then((envelope) => {
1564
+ tx.durableEnvelope = envelope;
1565
+ tx.operations = envelope.operations.map((operation) => ({ ...operation }));
1566
+ });
1567
+ try {
1568
+ await tx.sealPromise;
1569
+ }
1570
+ catch (error) {
1571
+ this.commitStore.delete(clientTxId);
1572
+ this.emitCommitLifecycle('commit:seal_failed', { clientTxId });
1573
+ throw error;
1574
+ }
1575
+ finally {
1576
+ tx.sealPromise = undefined;
1577
+ }
1177
1578
  this.commitLane.push(tx);
1178
- // Surface the envelope on its OWN event so the undo stream can record
1179
- // commit-lane writes too (`SyncClient.onLocalTransaction` enriches each
1180
- // operation with pool-captured previous state). Deliberately NOT
1181
- // `transaction:created` that event also feeds the optimistic-echo
1182
- // tracker, and commit-lane ops have no optimistic pool apply to echo.
1183
- this.emit('commit:created', { clientTxId, operations: tx.operations });
1579
+ // Emit the envelope on its own event so the undo stream can record
1580
+ // commit-lane writes as well. This deliberately avoids `transaction:created`,
1581
+ // which also feeds the optimistic-echo tracker: commit-lane operations apply
1582
+ // nothing optimistically and so have no echo to suppress.
1583
+ this.emitCommitLifecycle('commit:created', {
1584
+ clientTxId,
1585
+ operations: tx.operations,
1586
+ });
1184
1587
  void this.processCommitLane();
1185
1588
  }
1186
1589
  /**
1187
- * Drain pending commit-lane envelopes serially. Transient failures
1188
- * (network, ws_not_ready) leave the head-of-queue tx in `pending` and
1189
- * break reconnect handler re-kicks via `flushOfflineQueue`.
1190
- * Permanent failures emit `transaction:failed:<id>` and drop the tx.
1590
+ * Drains the pending commit-lane envelopes one at a time. A transient
1591
+ * failure, such as a network error, leaves the envelope at the head of the
1592
+ * lane in `pending` and stops; reconnect re-kicks it through
1593
+ * {@link flushOfflineQueue}. A permanent failure emits
1594
+ * `transaction:failed:<id>` and drops the envelope.
1191
1595
  */
1192
1596
  async processCommitLane() {
1193
- if (this.commitProcessing)
1597
+ if (this.commitProcessing || this.durableReplayBlock)
1194
1598
  return;
1195
1599
  this.commitProcessing = true;
1196
1600
  try {
@@ -1204,24 +1608,52 @@ export class TransactionQueue extends EventEmitter {
1204
1608
  }
1205
1609
  tx.status = 'executing';
1206
1610
  tx.attempts += 1;
1611
+ let dispatchStarted = false;
1207
1612
  try {
1208
- const result = await this.mutationExecutor.commit(tx.operations, {
1613
+ const durableEnvelope = tx.durableEnvelope ??
1614
+ (await this.sealDurableCommit({
1615
+ idempotencyKey: tx.id,
1616
+ origin: 'atomic_commit',
1617
+ operations: tx.operations,
1618
+ sourceMutationIds: tx.sourceMutationIds,
1619
+ commitOptions: {
1620
+ causedByTaskId: tx.causedByTaskId ?? null,
1621
+ ...(tx.reads ? { reads: tx.reads } : {}),
1622
+ },
1623
+ createdAt: tx.createdAt,
1624
+ sealedAt: tx.sealedAt,
1625
+ sequence: tx.sequence,
1626
+ }));
1627
+ tx.durableEnvelope = durableEnvelope;
1628
+ this.assertEnvelopeInsideReplayWindow(durableEnvelope);
1629
+ dispatchStarted = true;
1630
+ const result = await this.mutationExecutor.commit(durableEnvelope.operations, {
1209
1631
  idempotencyKey: tx.id,
1210
- causedByTaskId: tx.causedByTaskId ?? undefined,
1211
- ...(tx.reads ? { reads: tx.reads } : {}),
1632
+ causedByTaskId: durableEnvelope.commitOptions.causedByTaskId ?? undefined,
1633
+ ...(durableEnvelope.commitOptions.reads
1634
+ ? { reads: durableEnvelope.commitOptions.reads }
1635
+ : {}),
1212
1636
  });
1637
+ await this.removeDurableCommit(tx.id);
1213
1638
  tx.lastSyncId = result?.lastSyncId ?? 0;
1214
1639
  this.noteAck(tx.lastSyncId);
1215
1640
  tx.status = 'completed';
1216
1641
  this.commitLane.shift();
1217
- this.emit('transaction:completed', tx);
1218
- this.emit(`transaction:completed:${tx.id}`, tx);
1642
+ // Guarded: a throwing observer here would land in the catch below,
1643
+ // whose permanent branch shifts the lane a second time and rejects a
1644
+ // commit the server has already durably applied.
1645
+ this.emitCommitLifecycle('transaction:completed', tx);
1646
+ this.emitCommitLifecycle(`transaction:completed:${tx.id}`, tx);
1219
1647
  }
1220
1648
  catch (err) {
1221
1649
  const error = err instanceof Error ? err : new Error(String(err));
1650
+ if (dispatchStarted && this.isDefinitiveRejection(error)) {
1651
+ await this.removeDurableCommit(tx.id);
1652
+ }
1222
1653
  if (!this.isPermanentError(error)) {
1223
- // Transient leave at head, retry on next kick (reconnect or
1224
- // next enqueueCommit). Don't tight-loop while WS is down.
1654
+ // Transient: leave it at the head and retry on the next kick
1655
+ // (reconnect or the next enqueueCommit) rather than tight-looping
1656
+ // while the connection is down.
1225
1657
  tx.status = 'pending';
1226
1658
  getContext().logger.debug('[TransactionQueue] commit lane transient', {
1227
1659
  txId: tx.id.slice(0, 12),
@@ -1233,15 +1665,16 @@ export class TransactionQueue extends EventEmitter {
1233
1665
  tx.status = 'failed';
1234
1666
  tx.error = error;
1235
1667
  this.commitLane.shift();
1236
- // Internal bookkeeping the consumer-facing rejection is emitted via
1237
- // 'transaction:failed' and surfaced by the permanent-error headline → debug.
1668
+ // Internal bookkeeping; the consumer-facing rejection is emitted on
1669
+ // 'transaction:failed' and surfaced by the permanent-error headline,
1670
+ // so this line stays at debug.
1238
1671
  getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
1239
1672
  txId: tx.id.slice(0, 12),
1240
1673
  attempts: tx.attempts,
1241
1674
  message: error.message,
1242
1675
  });
1243
- this.emit('transaction:failed', { transaction: tx, error, permanent: true });
1244
- this.emit(`transaction:failed:${tx.id}`, { error });
1676
+ this.emitCommitLifecycle('transaction:failed', { transaction: tx, error, permanent: true });
1677
+ this.emitCommitLifecycle(`transaction:failed:${tx.id}`, { error });
1245
1678
  }
1246
1679
  }
1247
1680
  }
@@ -1250,10 +1683,10 @@ export class TransactionQueue extends EventEmitter {
1250
1683
  }
1251
1684
  }
1252
1685
  /**
1253
- * Promise-based confirmation for a commit-lane transaction. Resolves
1254
- * with the server-side `lastSyncId` once `mutation_result` lands;
1255
- * rejects on permanent failure. Backs the `wait: 'confirmed'` semantics
1256
- * of `ablo.commits.create()`.
1686
+ * Resolves once a commit-lane transaction is confirmed, returning the server's
1687
+ * `lastSyncId` and any stale-context notifications; rejects on permanent
1688
+ * failure. This backs the `wait: 'confirmed'` semantics of
1689
+ * `ablo.commits.create()`.
1257
1690
  */
1258
1691
  waitForCommitReceipt(clientTxId) {
1259
1692
  // Drain any stale-context notifications stamped for this tx on the ack.
@@ -1296,20 +1729,20 @@ export class TransactionQueue extends EventEmitter {
1296
1729
  return 'order' in data || 'orderKey' in data || 'position' in data;
1297
1730
  }
1298
1731
  /**
1299
- * Determine if an error is transient (retryable) vs permanent (non-retryable).
1300
- *
1301
- * IMPORTANT: Uses a BLOCKLIST approach for safety - only retry on known transient errors.
1302
- * Any unknown error type defaults to permanent (don't retry) to prevent infinite loops.
1732
+ * Classifies an error as transient (worth retrying) or permanent. The
1733
+ * approach is deliberately conservative: only known-transient errors are
1734
+ * retried, and anything unrecognized is treated as permanent so a failing
1735
+ * write cannot loop forever.
1303
1736
  *
1304
- * Transient errors (will retry):
1305
- * - Network failures, connection errors, timeouts
1306
- * - Server errors (5xx status codes)
1307
- * - Rate limiting (429)
1737
+ * Transient (retried):
1738
+ * - Network failures, connection errors, and timeouts.
1739
+ * - Server errors (HTTP 5xx).
1740
+ * - Rate limiting (HTTP 429).
1308
1741
  *
1309
- * Permanent errors (won't retry - includes but not limited to):
1310
- * - Validation errors, constraint violations
1311
- * - Not found, unauthorized, forbidden
1312
- * - Any other business logic error from the server
1742
+ * Permanent (not retried), among others:
1743
+ * - Validation errors and constraint violations.
1744
+ * - Not found, unauthorized, and forbidden.
1745
+ * - Any other business-logic error from the server.
1313
1746
  */
1314
1747
  isPermanentError(error) {
1315
1748
  // Typed connection error (e.g. ws_not_ready, transport timeout) is
@@ -1366,19 +1799,31 @@ export class TransactionQueue extends EventEmitter {
1366
1799
  // This is the safe default - better to fail fast than retry forever
1367
1800
  return true;
1368
1801
  }
1802
+ /** True only when the server definitively rejected before applying. */
1803
+ isDefinitiveRejection(error) {
1804
+ if (error instanceof AbloError && error.code) {
1805
+ const spec = errorCodeSpec(error.code);
1806
+ if (spec)
1807
+ return !spec.retryable;
1808
+ }
1809
+ const status = extractStatusCode(error);
1810
+ return status !== undefined && status >= 400 && status < 500 && status !== 429;
1811
+ }
1369
1812
  /**
1370
- * Handle transaction failure
1813
+ * Handles a failed transaction: retries transient failures with backoff and
1814
+ * rolls back permanent ones, settling the transaction's confirmation promise
1815
+ * either way.
1371
1816
  */
1372
1817
  async handleFailure(transaction, error) {
1373
1818
  transaction.attempts++;
1374
- // Check if this is a permanent error that should NOT be retried
1819
+ // Check whether this is a permanent error that should not be retried.
1375
1820
  if (this.isPermanentError(error)) {
1376
- // Elevated to warn permanent errors mean user writes were rejected
1377
- // by the server, so the user should be able to see WHY in the
1378
- // console (not just via Sentry). Include the typed AbloError fields
1379
- // so the cause is visible: `type`/`code`/`httpStatus` are what
1380
- // distinguish e.g. FK-violation (AbloValidationError) from auth
1381
- // expiry (AbloAuthenticationError).
1821
+ // Logged at warn: a permanent error means the server rejected the write,
1822
+ // so the developer should see the reason in the console. The typed
1823
+ // AbloError fields (`type`, `code`, `httpStatus`) are included so the
1824
+ // cause is visible for example a foreign-key violation
1825
+ // (AbloValidationError) versus expired authentication
1826
+ // (AbloAuthenticationError).
1382
1827
  try {
1383
1828
  const abloErr = error instanceof AbloError ? error : undefined;
1384
1829
  const details = {
@@ -1407,17 +1852,16 @@ export class TransactionQueue extends EventEmitter {
1407
1852
  const isRepeat = sig === this.lastPermanentErrorSig;
1408
1853
  this.lastPermanentErrorSig = sig;
1409
1854
  const logger = getContext().logger;
1410
- // Two registers, one call site, split by log level (the default
1411
- // consumer logger is gated at `warn`, so `debug` is invisible unless
1412
- // someone sets ABLO_LOG_LEVEL=debug to debug the engine itself):
1413
- // the default-visible line speaks the APP DEVELOPER's language —
1414
- // their verb (`update`), their model, the typed error's own human
1415
- // message, and the wire `code` for grep the way AI SDK / Next.js
1416
- // surface errors. No engine nouns ("TransactionQueue", "permanent",
1417
- // "rolling back") and no JSON dump on this line: those alarm and
1418
- // don't help someone who just installed @abloatai/ablo.
1419
- // the forensic `details` ride a companion `debug` line for whoever
1420
- // is debugging the engine internals.
1855
+ // Two registers from one call site, split by log level (the default
1856
+ // logger is gated at `warn`, so `debug` stays hidden unless
1857
+ // ABLO_LOG_LEVEL=debug is set to inspect the engine):
1858
+ // - the default-visible line speaks the application developer's
1859
+ // language: their verb (such as `update`), their model, the typed
1860
+ // error's own message, and the wire `code` for searching. It uses
1861
+ // no engine jargon and prints no JSON dump, which would alarm
1862
+ // without helping.
1863
+ // - the forensic `details` ride a companion `debug` line for anyone
1864
+ // debugging the engine internals.
1421
1865
  const revertNote = this.config.enableOptimistic
1422
1866
  ? ' The local change was reverted.'
1423
1867
  : '';
@@ -1453,15 +1897,11 @@ export class TransactionQueue extends EventEmitter {
1453
1897
  return;
1454
1898
  }
1455
1899
  if (transaction.attempts < this.config.maxRetries) {
1456
- // Exponential backoff with FULL jitter for EVERY transient retry
1457
- // (AWS-standard shape: `sleep = random(0, min(cap, base × 2^attempt))`
1458
- // see the Exponential Backoff And Jitter analysis). Throttling
1459
- // signals (429/503) get a longer base, mirroring the AWS SDK's
1460
- // transient-vs-throttle split. Previously only 429/503 backed off
1461
- // AND the sleep blocked the whole batch loop — every other failure
1462
- // retried at raw `batchDelay` cadence. The re-enqueue is scheduled
1463
- // (never awaited) so one backing-off transaction can't stall
1464
- // unrelated commits.
1900
+ // Exponential backoff with full jitter on every transient retry:
1901
+ // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
1902
+ // (429/503) use a longer base than other transient errors. The re-enqueue
1903
+ // is scheduled rather than awaited, so one backing-off transaction cannot
1904
+ // stall unrelated commits.
1465
1905
  const { baseMs, capMs } = this.config.retryBackoff;
1466
1906
  let base = baseMs;
1467
1907
  try {
@@ -1493,7 +1933,9 @@ export class TransactionQueue extends EventEmitter {
1493
1933
  }
1494
1934
  }
1495
1935
  /**
1496
- * Conflict resolution
1936
+ * Resolves a conflict against server data using the configured strategy:
1937
+ * last-write-wins rolls the local change back, merge and reject re-enqueue it,
1938
+ * and custom applies the caller's resolver.
1497
1939
  */
1498
1940
  async handleConflict(transaction, serverData) {
1499
1941
  const { strategy, resolver } = this.config.conflictResolution;
@@ -1523,8 +1965,8 @@ export class TransactionQueue extends EventEmitter {
1523
1965
  }
1524
1966
  }
1525
1967
  /**
1526
- * Optimistic updates apply/rollback rules live in `./optimistic.js`;
1527
- * these delegates bind the host-owned ledger + event emitter.
1968
+ * Optimistic updates. The apply and rollback rules live in `./optimisticApply.js`;
1969
+ * these methods bind them to the queue's own tracking map and event emitter.
1528
1970
  */
1529
1971
  applyOptimisticCreate(model, transaction) {
1530
1972
  applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
@@ -1539,25 +1981,9 @@ export class TransactionQueue extends EventEmitter {
1539
1981
  await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
1540
1982
  }
1541
1983
  /**
1542
- * Execute individual transaction via the unified commit path
1543
- */
1544
- async executeTransaction(transaction) {
1545
- const { type, modelName, modelId, data } = transaction;
1546
- const schemaName = stripModelSuffix(modelName);
1547
- const mutationType = TX_TYPE_TO_MUTATION_OP[type];
1548
- const model = normalizeModelKey(modelName);
1549
- const input = (type === 'create' || type === 'update') ? data : undefined;
1550
- try {
1551
- await this.mutationExecutor.commit([
1552
- applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
1553
- ]);
1554
- }
1555
- catch (error) {
1556
- handleMutationError(error, `${type}-mutation`, schemaName, modelId);
1557
- }
1558
- }
1559
- /**
1560
- * Persistence
1984
+ * Loads transactions persisted from a previous session and re-enqueues them,
1985
+ * so writes made while offline survive a restart. Does nothing when
1986
+ * persistence is disabled.
1561
1987
  */
1562
1988
  async loadPersistedTransactions(database) {
1563
1989
  if (!this.config.enablePersistence)
@@ -1580,11 +2006,102 @@ export class TransactionQueue extends EventEmitter {
1580
2006
  }
1581
2007
  }
1582
2008
  /**
1583
- * Validate + rehydrate one persisted row (the T1.8 IDB replay boundary).
1584
- * Rows written by other subsystems into the same store (`'queue'`,
1585
- * `'awaiting_delta'`) are skipped silently; rows that fail the
1586
- * persisted-transaction schema (older SDK versions, corruption) are
1587
- * DROPPED with an observability capture instead of replayed as commits.
2009
+ * Restore exact sealed requests after the local database is open. Sealed
2010
+ * envelopes replay through the atomic commit lane and are never re-projected
2011
+ * through model/schema state from the new process.
2012
+ */
2013
+ async restoreDurableCommits() {
2014
+ if (!this.config.enablePersistence)
2015
+ return new Set();
2016
+ const sourceMutationIds = new Set();
2017
+ try {
2018
+ if (!this.commitOutbox)
2019
+ return sourceMutationIds;
2020
+ const rows = await this.commitOutbox.list();
2021
+ const envelopes = [];
2022
+ for (const row of rows) {
2023
+ if (typeof row !== 'object' ||
2024
+ row === null ||
2025
+ row.type !== 'commit_envelope')
2026
+ continue;
2027
+ const parsed = durableCommitEnvelopeSchema.safeParse(row);
2028
+ if (parsed.success) {
2029
+ envelopes.push(parsed.data);
2030
+ }
2031
+ else {
2032
+ getContext().logger.warn('A saved local write is unreadable and was held for review.');
2033
+ getContext().observability.captureTransactionFailure({
2034
+ context: 'restore-commit-envelope',
2035
+ error: parsed.error,
2036
+ });
2037
+ throw new AbloValidationError('A saved commit envelope is unreadable; replay stopped before newer writes were sent.', { code: 'write_options_invalid', cause: parsed.error });
2038
+ }
2039
+ }
2040
+ envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
2041
+ (b.sequence ?? b.sealedAt * 1_000) ||
2042
+ a.id.localeCompare(b.id));
2043
+ for (const envelope of envelopes) {
2044
+ for (const mutationId of envelope.sourceMutationIds) {
2045
+ sourceMutationIds.add(mutationId);
2046
+ }
2047
+ if (Date.now() - envelope.sealedAt >=
2048
+ TransactionQueue.DURABLE_REPLAY_WINDOW_MS) {
2049
+ getContext().logger.warn('A saved local write is too old to retry safely and was held for review.');
2050
+ getContext().observability.captureTransactionFailure({
2051
+ context: 'quarantine-expired-commit-envelope',
2052
+ error: `Envelope ${envelope.idempotencyKey} is too old to replay safely`,
2053
+ });
2054
+ throw new AbloIdempotencyError('A saved commit is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
2055
+ }
2056
+ if (this.commitOutboxScope &&
2057
+ (!envelope.scope || // eslint-disable-line @typescript-eslint/prefer-optional-chain -- missing scope must quarantine
2058
+ envelope.scope.organizationId !== this.commitOutboxScope.organizationId ||
2059
+ envelope.scope.participantId !== this.commitOutboxScope.participantId ||
2060
+ envelope.scope.namespace !== this.commitOutboxScope.namespace)) {
2061
+ getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
2062
+ continue;
2063
+ }
2064
+ if (this.commitStore.has(envelope.idempotencyKey))
2065
+ continue;
2066
+ const transaction = {
2067
+ id: envelope.idempotencyKey,
2068
+ kind: 'commit',
2069
+ operations: envelope.operations.map((operation) => ({ ...operation })),
2070
+ causedByTaskId: envelope.commitOptions.causedByTaskId ?? null,
2071
+ ...(envelope.commitOptions.reads
2072
+ ? { reads: [...envelope.commitOptions.reads] }
2073
+ : {}),
2074
+ status: 'pending',
2075
+ createdAt: envelope.createdAt,
2076
+ sealedAt: envelope.sealedAt,
2077
+ sequence: envelope.sequence ?? envelope.sealedAt * 1_000,
2078
+ attempts: 0,
2079
+ sourceMutationIds: [...envelope.sourceMutationIds],
2080
+ durableEnvelope: envelope,
2081
+ };
2082
+ this.commitStore.set(transaction.id, transaction);
2083
+ this.commitLane.push(transaction);
2084
+ }
2085
+ if (this.commitLane.length > 0)
2086
+ void this.processCommitLane();
2087
+ }
2088
+ catch (error) {
2089
+ getContext().logger.debug('[TransactionQueue] Failed to restore commit outbox', {
2090
+ error: error instanceof Error ? error.message : String(error),
2091
+ });
2092
+ getContext().observability.captureTransactionFailure({
2093
+ context: 'restore-commit-envelopes',
2094
+ error: error instanceof Error ? error : String(error),
2095
+ });
2096
+ throw error;
2097
+ }
2098
+ return sourceMutationIds;
2099
+ }
2100
+ /**
2101
+ * Validates and rehydrates one persisted row. Rows written to the same store
2102
+ * by other subsystems are skipped, and rows that fail the persisted
2103
+ * transaction schema — from an older version or corruption — are dropped and
2104
+ * reported rather than replayed as commits.
1588
2105
  */
1589
2106
  deserializeTransaction(data) {
1590
2107
  if (isNonReplayablePersistedRow(data))
@@ -1606,7 +2123,9 @@ export class TransactionQueue extends EventEmitter {
1606
2123
  return transaction;
1607
2124
  }
1608
2125
  /**
1609
- * Cancel transactions for a specific model
2126
+ * Cancels every pending or executing transaction for a given model id,
2127
+ * optionally limited to one operation type, rolling back their optimistic
2128
+ * state. Returns the cancelled transactions.
1610
2129
  */
1611
2130
  cancelTransactionsForModel(modelId, transactionType) {
1612
2131
  const cancelledTransactions = [];
@@ -1634,16 +2153,14 @@ export class TransactionQueue extends EventEmitter {
1634
2153
  return cancelledTransactions;
1635
2154
  }
1636
2155
  /**
1637
- * LINEAR PATTERN: Cancel transactions for child entities by foreign key
2156
+ * Cancels pending transactions for child rows that reference a deleted parent,
2157
+ * used to cascade a parent deletion. The caller supplies the foreign-key
2158
+ * relationship; this method performs the cancellation.
1638
2159
  *
1639
- * Used by SyncedStore for cascade cancellation when a parent is deleted.
1640
- * This keeps FK relationship knowledge in ModelRegistry/SyncedStore,
1641
- * while TransactionQueue just handles the cancellation mechanics.
1642
- *
1643
- * @param childModelName - The child model type (e.g., 'SlideLayer')
1644
- * @param foreignKey - The FK property name (e.g., 'slideId')
1645
- * @param parentId - The deleted parent's ID
1646
- * @returns Number of transactions cancelled
2160
+ * @param childModelName - The child model type (for example 'SlideLayer').
2161
+ * @param foreignKey - The foreign-key property name (for example 'slideId').
2162
+ * @param parentId - The deleted parent's id.
2163
+ * @returns The number of transactions cancelled.
1647
2164
  */
1648
2165
  cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
1649
2166
  let cancelled = 0;
@@ -1677,14 +2194,12 @@ export class TransactionQueue extends EventEmitter {
1677
2194
  return cancelled;
1678
2195
  }
1679
2196
  /**
1680
- * Get count of outstanding transactions
2197
+ * Returns the number of transactions still pending or executing.
1681
2198
  */
1682
2199
  getOutstandingTransactionCount() {
1683
2200
  return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
1684
2201
  }
1685
- /**
1686
- * Utilities
1687
- */
2202
+ /** Generates a unique local transaction id. */
1688
2203
  generateId() {
1689
2204
  return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1690
2205
  }
@@ -1700,35 +2215,26 @@ export class TransactionQueue extends EventEmitter {
1700
2215
  extractUpdateData(model) {
1701
2216
  return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
1702
2217
  }
1703
- buildUpdateInput(modelName, changes) {
1704
- return projectCommitPayload(modelName, changes, { dropUndefined: true });
1705
- }
1706
2218
  // Derive previous values for changed fields to support accurate rollback.
1707
- //
1708
- // The previous Slide-specific branch reaching into `_data` was removed:
1709
- // the field name in the comment (`_localChanges`) didn't match the
1710
- // code (`_data`), no `Slide` class still defines either field, and
1711
- // hardcoded model-name checks don't belong in a generic queue. If a
1712
- // model ever needs to surface previous-state outside `modifiedProperties`,
1713
- // expose a typed `getPreviousData()` accessor on Model and call that.
2219
+ // Model-specific special cases do not belong here; a model that needs to
2220
+ // surface previous state beyond `modifiedProperties` should expose a typed
2221
+ // `getPreviousData()` accessor for this method to call.
1714
2222
  extractPreviousData(model, updateInput) {
1715
2223
  // When the update's written keys are known, capture a before-image for
1716
- // EXACTLY those keys so the recorded undo inverse can revert them and only
1717
- // them (a full-row inverse would clobber concurrent edits to unrelated
1718
- // fields). `fallbackToLive: false` makes `Model.capturePreviousValues` OMIT
1719
- // any key it can't resolve from `modifiedProperties.old` / the original
1720
- // snapshot `buildUndoOps` then drops an un-revertible inverse rather than
1721
- // inventing one. With no `updateInput` (full extract) fall back to every
1722
- // tracked field. `Model.capturePreviousValues` is the single before-image
1723
- // source shared with `RecordingTransaction.snapshotFields`.
2224
+ // exactly those keys, so the recorded undo inverse reverts them and nothing
2225
+ // else a full-row inverse would clobber concurrent edits to unrelated
2226
+ // fields. `fallbackToLive: false` makes `Model.capturePreviousValues` omit
2227
+ // any key it cannot resolve, and `buildUndoOps` then drops an un-revertible
2228
+ // inverse rather than inventing one. With no `updateInput` (a full extract)
2229
+ // it falls back to every tracked field. `Model.capturePreviousValues` is the
2230
+ // single before-image source, shared with
2231
+ // `RecordingTransaction.snapshotFields`.
1724
2232
  const keys = updateInput
1725
2233
  ? Object.keys(updateInput)
1726
2234
  : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
1727
2235
  return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
1728
2236
  }
1729
- /**
1730
- * Public API
1731
- */
2237
+ /** Returns a snapshot of queue counts and the current configuration. */
1732
2238
  getStats() {
1733
2239
  return {
1734
2240
  pending: this.store.getByStatus('pending').length,
@@ -1742,8 +2248,8 @@ export class TransactionQueue extends EventEmitter {
1742
2248
  };
1743
2249
  }
1744
2250
  /**
1745
- * Get detailed debug info for the sync debug page
1746
- * Exposes internal state that helps diagnose delta confirmation issues
2251
+ * Returns detailed internal state pending, executing, and awaiting-delta
2252
+ * transactions to help diagnose delta-confirmation issues.
1747
2253
  */
1748
2254
  getDebugInfo() {
1749
2255
  const awaitingDelta = this.store.getByStatus('awaiting_delta');
@@ -1773,18 +2279,17 @@ export class TransactionQueue extends EventEmitter {
1773
2279
  })),
1774
2280
  };
1775
2281
  }
1776
- /**
1777
- * Set configuration
1778
- */
2282
+ /** Merges the given options into the queue's configuration. */
1779
2283
  setConfig(config) {
1780
2284
  this.config = { ...this.config, ...config };
1781
2285
  }
1782
2286
  /**
1783
- * Handle incoming sync delta - simplified for permanent IDs
2287
+ * Re-emits an incoming sync delta on the `sync:delta` event for the store to
2288
+ * apply. Because rows use stable ids, no id reconciliation is needed here.
1784
2289
  */
1785
2290
  handleSyncDelta(delta) {
1786
- // With permanent IDs, no reconciliation needed!
1787
- // Just emit the delta for ObjectPool to handle directly
2291
+ // Row ids are stable, so no reconciliation is needed; re-emit the delta for
2292
+ // the store to apply directly.
1788
2293
  this.emit('sync:delta', {
1789
2294
  id: delta.id,
1790
2295
  modelName: delta.modelName,
@@ -1794,7 +2299,8 @@ export class TransactionQueue extends EventEmitter {
1794
2299
  return true;
1795
2300
  }
1796
2301
  /**
1797
- * Cleanup and dispose resources
2302
+ * Releases the queue's resources: rolls back outstanding optimistic updates,
2303
+ * clears all timers and stored transactions, and removes event listeners.
1798
2304
  */
1799
2305
  dispose() {
1800
2306
  // Cancel all active optimistic updates