@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,14 +1,15 @@
1
1
  /**
2
- * SyncClient - Mutation and offline queue manager
3
- *
4
- * Responsibilities:
5
- * - Handle model mutations (create, update, delete, archive)
6
- * - Manage offline mutation queue with persistence
7
- * - Send mutations to server via API client
8
- * - Handle conflict resolution for local changes
2
+ * Applies model mutations and manages the offline write queue. The
3
+ * SyncClient turns local create, update, delete, and archive calls into
4
+ * optimistic changes, holds them while the client is offline, sends them to
5
+ * the server when connectivity returns, and resolves conflicts when the
6
+ * server's version of a row disagrees with the local one. It sits between the
7
+ * reactive object pool and the {@link TransactionQueue} that delivers writes
8
+ * over the network.
9
9
  */
10
10
  import { runInAction } from 'mobx';
11
- import { ObjectPool, ModelScope } from './ObjectPool.js';
11
+ import { v4 as uuid } from 'uuid';
12
+ import { InstanceCache, ModelScope } from './InstanceCache.js';
12
13
  import { Model } from './Model.js';
13
14
  // ModelRegistry instance accessed via this.objectPool.registry
14
15
  import { LoadStrategy } from './types/index.js';
@@ -17,17 +18,19 @@ import { AbloAuthenticationError, AbloError, AbloValidationError } from './error
17
18
  import { EventEmitter } from 'events';
18
19
  import { NetworkMonitor } from './NetworkMonitor.js';
19
20
  import { TransactionQueue } from './transactions/TransactionQueue.js';
20
- import { persistedMutationSchema } from './transactions/persistedReplay.js';
21
- import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
21
+ import { legacyPendingMutationRecordSchema, PENDING_MUTATION_REPLAY_WINDOW_MS, pendingMutationRecordId, pendingMutationRecordSchema, persistedMutationSchema, } from './transactions/replayValidation.js';
22
+ import { UnconfirmedWrites, } from './transactions/UnconfirmedWrites.js';
22
23
  import { SyncPosition } from './sync/syncPosition.js';
24
+ import { DatabaseCommitOutboxStore, } from './transactions/commitOutboxStore.js';
23
25
  /**
24
- * Is the raw snapshot record strictly newer than the pooled model? Compares
25
- * server-stamped `updatedAt` (the engine has no numeric row version; the delta
26
- * pipeline is arrival-ordered last-write-wins). An undefined incoming time is
27
- * treated as NOT newer (don't clobber a known row); an undefined existing time
28
- * means the pool row is unversioned, so the incoming record wins. Used by the
29
- * scoped hydrate-on-enter apply to drop snapshot rows a live delta already
30
- * advanced past the snapshot watermark.
26
+ * Reports whether an incoming snapshot record is strictly newer than the
27
+ * model already in the pool. The comparison uses the server-stamped
28
+ * `updatedAt` timestamp, since rows carry no numeric version and the delta
29
+ * pipeline resolves order by arrival (last write wins). An undefined incoming
30
+ * timestamp counts as not newer, so a known row is never clobbered; an
31
+ * undefined existing timestamp means the pooled row is unversioned, so the
32
+ * incoming record wins. The scoped hydrate-on-enter path uses this to drop
33
+ * snapshot rows that a live delta has already advanced past.
31
34
  */
32
35
  function rawRecordIsNewer(data, existing) {
33
36
  const raw = data.updatedAt;
@@ -46,10 +49,10 @@ function rawRecordIsNewer(data, existing) {
46
49
  return inMs > exMs;
47
50
  }
48
51
  /**
49
- * Narrow an untyped server `updatedAt` (ISO string / epoch / Date, off a
50
- * `Record<string, unknown>` row) to epoch ms for LWW comparison. Falsy /
51
- * non-date values 0, matching the conflict resolver's historical
52
- * "no timestamp = epoch" behavior.
52
+ * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
53
+ * or Date read off an untyped row into epoch milliseconds for
54
+ * last-write-wins comparison. Falsy or non-date values become 0, matching the
55
+ * conflict resolver's rule that a missing timestamp sorts as the epoch.
53
56
  */
54
57
  function toEpochMs(value) {
55
58
  if (!value)
@@ -66,6 +69,10 @@ export class SyncClient extends EventEmitter {
66
69
  database;
67
70
  get mutationExecutor() { return getContext().mutationExecutor; }
68
71
  networkMonitor;
72
+ /**
73
+ * @internal — test seam, stripped from the published declarations by
74
+ * `stripInternal`. Unit suites deliver queue lifecycle events directly.
75
+ */
69
76
  transactionQueue;
70
77
  observers = new Set();
71
78
  // Authentication context
@@ -73,45 +80,46 @@ export class SyncClient extends EventEmitter {
73
80
  organizationId = null;
74
81
  // Pending mutations queue
75
82
  pendingMutations = [];
83
+ stagedMutationIds = new Set();
84
+ pendingJournalBatch = [];
85
+ journalFlushScheduled = false;
86
+ commitOutboxNamespace;
76
87
  /**
77
- * Tracks transaction ids the client has optimistically applied but
78
- * the server has not yet confirmed. The receive path consults it
79
- * to recognize delta echoes of own mutations and suppress the
80
- * (otherwise-redundant) pool mutation the IDB write still runs
81
- * because the delta is the authoritative version of the row.
88
+ * Tracks the ids of transactions the client has applied optimistically but
89
+ * the server has not yet confirmed. When a delta arrives, the receive path
90
+ * consults this set to recognize the echo of the client's own mutation and
91
+ * skip the now-redundant pool update; the IndexedDB write still runs,
92
+ * because the delta is the authoritative version of the row. Without this
93
+ * discriminator, an optimistically applied delete followed by a
94
+ * server-confirmed create echo would resurrect the row for the window
95
+ * between the two confirmations.
82
96
  *
83
- * The receive-layer discriminator named in
84
- * `apps/sync-server/docs/OPTIMISTIC_RECONCILIATION.md`. Without
85
- * it, an optimistically-applied DELETE followed by a
86
- * server-confirming CREATE echo resurrects the row for the window
87
- * between the two confirmations (the chart-delete flicker).
88
- *
89
- * Bounded with FIFO eviction; observability via `getEchoMetrics()`.
97
+ * The set is bounded with first-in-first-out eviction, and
98
+ * {@link SyncClient.getEchoMetrics} exposes its counters.
90
99
  */
91
- echoTracker = new OptimisticEchoTracker();
100
+ echoTracker = new UnconfirmedWrites();
92
101
  // Connection state
93
102
  connectionState = 'disconnected';
94
- offlineSince;
95
103
  // Configuration
96
- maxRetries = 3;
97
104
  isDisposed = false;
98
105
  /**
99
- * THE client's place in the global delta order the one canonical
100
- * instance (see `sync/syncPosition.ts`). The store advances
101
- * `applied`/`persisted` as deltas land; the queue advances `acked` on
102
- * commit responses; snapshots/claims read `readFloor`.
106
+ * The client's position in the global delta order, held as the single
107
+ * canonical {@link SyncPosition} instance. The store advances `applied` and
108
+ * `persisted` as deltas land, the queue advances `acked` on commit
109
+ * responses, and snapshots and claims read `readFloor`.
103
110
  */
104
111
  position = new SyncPosition();
105
- constructor(objectPool, database) {
112
+ constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
106
113
  super();
107
114
  this.objectPool = objectPool;
108
115
  this.database = database;
116
+ this.commitOutboxNamespace = commitOutboxNamespace;
109
117
  this.networkMonitor = new NetworkMonitor();
110
118
  // Initialize TransactionQueue with proper configuration
111
119
  this.transactionQueue = new TransactionQueue({
112
120
  position: this.position,
113
- maxBatchSize: 50, // Increased from 10 to reduce batch count for large operations
114
- // Lower delay for snappier dev UX; batching still happens via coalescing
121
+ maxBatchSize: 50, // Larger batches keep the batch count low for bulk operations
122
+ // A short delay keeps writes responsive; coalescing still groups them
115
123
  batchDelay: 150,
116
124
  maxRetries: 3,
117
125
  enableOptimistic: true,
@@ -120,18 +128,66 @@ export class SyncClient extends EventEmitter {
120
128
  strategy: 'last-write-wins',
121
129
  },
122
130
  });
131
+ this.transactionQueue.setCommitOutbox(commitOutbox);
132
+ this.transactionQueue.on('commit:envelope_persisted', (event) => {
133
+ if (event.sourceMutationIds.length === 0)
134
+ return;
135
+ const consumed = new Set(event.sourceMutationIds);
136
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !consumed.has(mutation.mutationId));
137
+ for (const mutationId of consumed)
138
+ this.stagedMutationIds.delete(mutationId);
139
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
140
+ this.scheduleSync();
141
+ }
142
+ });
143
+ this.transactionQueue.on('transaction:completed', (transaction) => {
144
+ const completed = new Set(transaction.sourceMutationIds ?? []);
145
+ if (completed.size > 0) {
146
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !completed.has(mutation.mutationId));
147
+ }
148
+ for (const mutationId of transaction.sourceMutationIds ?? []) {
149
+ this.stagedMutationIds.delete(mutationId);
150
+ void this.database
151
+ .removeTransaction(pendingMutationRecordId(mutationId))
152
+ .catch(() => undefined);
153
+ }
154
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
155
+ this.scheduleSync();
156
+ }
157
+ });
158
+ this.transactionQueue.on('transaction:failed', ({ transaction }) => {
159
+ const failed = transaction.sourceMutationIds ?? [];
160
+ if (failed.length === 0)
161
+ return;
162
+ const failedSet = new Set(failed);
163
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !failedSet.has(mutation.mutationId));
164
+ for (const mutationId of failed) {
165
+ this.stagedMutationIds.delete(mutationId);
166
+ // The queue has already rolled the model back, so replaying the
167
+ // journal row on the next boot would resurrect a rejected write.
168
+ void this.database
169
+ .removeTransaction(pendingMutationRecordId(mutationId))
170
+ .catch(() => undefined);
171
+ }
172
+ // Without this drain, terminally failed ids stayed claimed forever and
173
+ // the size guard in processPendingMutations stalled every later write.
174
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
175
+ this.scheduleSync();
176
+ }
177
+ });
123
178
  // Provide connection state to TransactionQueue - prevents rollbacks during disconnection
124
179
  this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
125
- // LINEAR PATTERN: Subscribe to rollback events to restore ObjectPool state
126
- // When a transaction fails (server rejects or timeout), we need to restore the model
127
- // Since we no longer write to IndexedDB optimistically, IndexedDB already has correct state
180
+ // Restore object-pool state when a transaction is rolled back. If the
181
+ // server rejects a write or it times out, the model's previous state is
182
+ // put back. Because writes are no longer applied to IndexedDB
183
+ // optimistically, that store already holds the correct state.
128
184
  this.setupTransactionRollbackHandling();
129
- // REPLICACHE PATTERN: Forward reconciliation requests from TransactionQueue
130
- // When delta confirmation times out, instead of rolling back we request the sync layer
131
- // to cycle the WebSocket connection, triggering a delta catch-up from the server
185
+ // Forward reconciliation requests from the transaction queue. When delta
186
+ // confirmation times out, the client cycles the WebSocket connection to
187
+ // trigger a catch-up from the server rather than rolling the write back.
132
188
  this.setupReconciliationForwarding();
133
- // LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB
134
- // When delta retries exhaust, cache in IDB so they survive tab close
189
+ // Persist unconfirmed transactions to IndexedDB. When delta retries are
190
+ // exhausted, the write is cached so it survives a tab close.
135
191
  this.setupAwaitingTransactionPersistence();
136
192
  // Setup network monitoring
137
193
  this.setupNetworkMonitoring();
@@ -289,10 +345,10 @@ export class SyncClient extends EventEmitter {
289
345
  });
290
346
  }
291
347
  /**
292
- * Forward reconciliation requests from TransactionQueue to the sync layer.
293
- * When delta confirmation times out, TransactionQueue emits 'reconciliation:needed'
294
- * instead of rolling back following the Replicache/PowerSync pattern of never
295
- * destroying optimistic state that the server may have committed.
348
+ * Forward reconciliation requests from the {@link TransactionQueue} to the
349
+ * sync layer. When delta confirmation times out, the queue emits
350
+ * `reconciliation:needed` instead of rolling back, so optimistic state the
351
+ * server may already have committed is never destroyed.
296
352
  */
297
353
  setupReconciliationForwarding() {
298
354
  this.transactionQueue.on('reconciliation:needed', (event) => {
@@ -310,10 +366,10 @@ export class SyncClient extends EventEmitter {
310
366
  });
311
367
  }
312
368
  /**
313
- * LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB.
314
- * When delta confirmation retries exhaust, the transaction data is cached in IDB
315
- * so it survives tab close. On next session, WebSocket reconnect + delta catch-up
316
- * will deliver the missing deltas and naturally confirm the transaction.
369
+ * Persist unconfirmed transactions to IndexedDB. When delta-confirmation
370
+ * retries are exhausted, the transaction is cached so it survives a tab
371
+ * close. On the next session, a WebSocket reconnect and delta catch-up
372
+ * deliver the missing deltas and confirm the transaction.
317
373
  */
318
374
  setupAwaitingTransactionPersistence() {
319
375
  this.transactionQueue.on('transaction:persist_awaiting', (event) => {
@@ -341,7 +397,7 @@ export class SyncClient extends EventEmitter {
341
397
  this.echoTracker.drainOnRollback(event.transaction.id);
342
398
  });
343
399
  }
344
- /** Persist an unconfirmed transaction to IDB (never rejects — failures are captured). */
400
+ /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
345
401
  async persistAwaitingTransaction(event) {
346
402
  if (!this.database)
347
403
  return;
@@ -390,23 +446,38 @@ export class SyncClient extends EventEmitter {
390
446
  this.userId = userId;
391
447
  this.organizationId = organizationId;
392
448
  getContext().observability.setContext(userId, organizationId);
393
- // Restore queued mutations from previous session
394
- await this.restoreMutationQueue();
395
- // Check network status via the DI'd OnlineStatusProvider (see interfaces.ts:192).
396
- // In the browser this is wired to the service worker's connectivity signal via
397
- // abloOnlineStatus in ablo-sync-adapters.ts; in Node it returns true (assume
398
- // online) via the browserOnlineStatus fallback. NetworkMonitor still drives
399
- // event-based online/offline transitions below; this read is just the initial
400
- // status snapshot at registerUser() time.
449
+ this.transactionQueue.setCommitOutboxScope({
450
+ organizationId,
451
+ participantId: userId,
452
+ namespace: this.commitOutboxNamespace,
453
+ });
454
+ // Calls made during startup are allowed to queue before identity arrives,
455
+ // but they cannot be serialized with a trustworthy scope until now. Flush
456
+ // those already-created journal promises before any write can be staged.
457
+ if (this.pendingJournalBatch.length > 0) {
458
+ this.scheduleJournalFlush();
459
+ await Promise.all(this.pendingMutations.map((mutation) => mutation.journaled));
460
+ }
461
+ // Restore exact, already-sealed requests first. The returned source ids
462
+ // suppress any legacy queue entry left behind by an older non-atomic
463
+ // handoff.
464
+ const sealedMutationIds = await this.transactionQueue.restoreDurableCommits();
465
+ await this.restoreMutationQueue(sealedMutationIds);
466
+ // Read the initial network status from the injected OnlineStatusProvider.
467
+ // In the browser this reflects the host's connectivity signal; in Node it
468
+ // reports online by default. NetworkMonitor drives the ongoing
469
+ // online/offline transitions below — this read is only the initial
470
+ // snapshot taken when identity is set.
401
471
  if (getContext().onlineStatus.isOnline()) {
402
472
  this.setConnectionState('connected');
403
473
  }
404
474
  else {
405
475
  // Offline - start in offline mode
406
476
  this.setConnectionState('disconnected');
407
- this.offlineSince = new Date();
408
477
  this.emit('sync:offline');
409
478
  }
479
+ if (this.pendingMutations.length > 0)
480
+ this.scheduleSync();
410
481
  }
411
482
  /**
412
483
  * The organization this client writes under (set by `initialize`).
@@ -420,7 +491,7 @@ export class SyncClient extends EventEmitter {
420
491
  * Self-healing helper for individual model records.
421
492
  *
422
493
  * Two registry-driven repair passes run on every row hydrated from
423
- * IDB or merged from a delta:
494
+ * IndexedDB or merged from a delta:
424
495
  *
425
496
  * 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
426
497
  * declares on this model, copy the corresponding identity value
@@ -476,7 +547,7 @@ export class SyncClient extends EventEmitter {
476
547
  return { data: result, healed };
477
548
  }
478
549
  /**
479
- * Hydrate ObjectPool with data from Database
550
+ * Hydrate InstanceCache with data from Database
480
551
  * Called after bootstrap is complete
481
552
  */
482
553
  async hydrateFromDatabase() {
@@ -591,7 +662,7 @@ export class SyncClient extends EventEmitter {
591
662
  catch { }
592
663
  }
593
664
  /**
594
- * Re-hydrate ObjectPool from IndexedDB when the pool already has data.
665
+ * Re-hydrate InstanceCache from IndexedDB when the pool already has data.
595
666
  *
596
667
  * Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
597
668
  * this method properly:
@@ -726,13 +797,14 @@ export class SyncClient extends EventEmitter {
726
797
  return stats;
727
798
  }
728
799
  /**
729
- * Mutate model optimistically and queue for server sync.
730
- * IndexedDB is only updated when server confirms via delta packet.
800
+ * Apply a mutation to a model optimistically and queue it for server sync.
801
+ * IndexedDB is updated only once the server confirms the change with a delta
802
+ * packet.
731
803
  *
732
- * CRITICAL: Changes are captured BEFORE poolAction to prevent data loss.
733
- * The captured changes are frozen and passed to queueMutation.
734
- *
735
- * @see src/sync-engine/types/TrackableModel.ts for change capture pattern
804
+ * A model's changes are captured before the pool action runs, because a pool
805
+ * operation such as an upsert can clear the model's local change set;
806
+ * capturing first ensures those changes are never lost. The captured set is
807
+ * frozen and handed to {@link queueMutation}.
736
808
  */
737
809
  mutate(type, model, poolAction, writeOptions) {
738
810
  // No-op UPDATE guard (O(1)). An update with no dirty fields would travel
@@ -749,9 +821,9 @@ export class SyncClient extends EventEmitter {
749
821
  // real write. Only a genuine Model with an empty dirty-set is skipped.
750
822
  if (type === 'update' && model.hasChanges === false)
751
823
  return;
752
- // CRITICAL FIX: Capture changes BEFORE pool action
753
- // Pool operations (especially upsert) can clear _local changes
754
- // By capturing first, we ensure changes are never lost
824
+ // Capture changes before the pool action runs. Pool operations —
825
+ // upsert in particular can clear the model's local changes, so
826
+ // capturing first ensures they are never lost.
755
827
  const capturedChanges = type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
756
828
  poolAction();
757
829
  this.queueMutation({ type, model, timestamp: new Date(), capturedChanges, writeOptions });
@@ -849,6 +921,7 @@ export class SyncClient extends EventEmitter {
849
921
  */
850
922
  clearPendingMutationsForModel(modelId) {
851
923
  const beforeCount = this.pendingMutations.length;
924
+ const removed = this.pendingMutations.filter((mutation) => mutation.model.id === modelId);
852
925
  this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
853
926
  const afterCount = this.pendingMutations.length;
854
927
  if (beforeCount !== afterCount) {
@@ -857,13 +930,24 @@ export class SyncClient extends EventEmitter {
857
930
  clearedCount: beforeCount - afterCount,
858
931
  remainingCount: afterCount,
859
932
  });
860
- // Persist updated queue immediately
861
- void this.persistMutationQueue();
933
+ for (const mutation of removed) {
934
+ // Once staged, TransactionQueue owns cancellation and transfers this
935
+ // source id into the superseding delete envelope. Deleting the journal
936
+ // row here would make that valid atomic promotion look like a
937
+ // multi-tab loser. Truly unstaged work can be canceled locally.
938
+ if (this.stagedMutationIds.has(mutation.mutationId))
939
+ continue;
940
+ this.stagedMutationIds.delete(mutation.mutationId);
941
+ void mutation.journaled
942
+ .then(() => this.database.removeTransaction(pendingMutationRecordId(mutation.mutationId)))
943
+ .catch(() => undefined);
944
+ }
862
945
  }
863
946
  }
864
947
  /**
865
- * Upload file and create attachment (UPLOAD operation)
866
- * Uses Linear-style pattern with immediate URL generation
948
+ * Upload a file and create its attachment record. The upload runs through
949
+ * the {@link TransactionQueue}, and a model is built from the server's
950
+ * response and added to the pool.
867
951
  */
868
952
  async uploadFile(file, options) {
869
953
  if (!this.userId || !this.organizationId) {
@@ -955,21 +1039,102 @@ export class SyncClient extends EventEmitter {
955
1039
  this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
956
1040
  }
957
1041
  /**
958
- * Append a mutation and schedule its sync work.
1042
+ * Append a mutation to the pending queue and schedule its sync work.
959
1043
  *
960
- * IDB persistence and the server push are deferred to a microtask so N
961
- * pushes inside the same tick collapse into ONE IDB serialization + ONE
962
- * process call. Without the deferral, queueing 100 mutations (paste,
963
- * PPTX import, AI sandbox layer creation) reserializes the entire
964
- * growing queue 100× O(N²) `model.toJSON()`.
1044
+ * IndexedDB persistence and the server push are deferred to a microtask, so
1045
+ * many pushes within the same tick collapse into a single serialization and
1046
+ * a single process call. Without the deferral, queueing a hundred mutations
1047
+ * at once — a large paste, a document import, bulk layer creation would
1048
+ * reserialize the whole growing queue a hundred times, an O(N²) cost in
1049
+ * `model.toJSON()`.
965
1050
  *
966
- * @param mutation.capturedChanges - Pre-captured changes (frozen), used
967
- * to avoid re-reading changes after pool ops that might clear them.
1051
+ * @param mutation.capturedChanges - Pre-captured, frozen changes, used to
1052
+ * avoid re-reading a model after pool operations that might clear them.
968
1053
  */
969
1054
  queueMutation(mutation) {
970
- this.pendingMutations.push(mutation);
1055
+ const mutationId = `mutation_${uuid()}`;
1056
+ const modelData = mutation.model.toJSON
1057
+ ? mutation.model.toJSON()
1058
+ : { ...mutation.model };
1059
+ let resolveJournal;
1060
+ let rejectJournal;
1061
+ const journaled = new Promise((resolve, reject) => {
1062
+ resolveJournal = resolve;
1063
+ rejectJournal = reject;
1064
+ });
1065
+ let resolveStaged;
1066
+ let rejectStaged;
1067
+ const staged = new Promise((resolve, reject) => {
1068
+ resolveStaged = resolve;
1069
+ rejectStaged = reject;
1070
+ });
1071
+ const pending = {
1072
+ ...mutation,
1073
+ mutationId,
1074
+ modelData,
1075
+ journaled,
1076
+ resolveJournal,
1077
+ rejectJournal,
1078
+ staged,
1079
+ resolveStaged,
1080
+ rejectStaged,
1081
+ };
1082
+ this.pendingJournalBatch.push(pending);
1083
+ this.scheduleJournalFlush();
1084
+ // Offline drains may not await this until much later. Observe rejection
1085
+ // immediately to avoid an unhandled-promise report while retaining the
1086
+ // original rejecting promise for fail-closed dispatch.
1087
+ void pending.journaled.catch(() => undefined);
1088
+ void pending.staged.catch(() => undefined);
1089
+ this.pendingMutations.push(pending);
971
1090
  this.scheduleSync();
972
1091
  }
1092
+ scheduleJournalFlush() {
1093
+ if (this.journalFlushScheduled)
1094
+ return;
1095
+ this.journalFlushScheduled = true;
1096
+ const schedule = typeof queueMicrotask === 'function'
1097
+ ? queueMicrotask
1098
+ : (callback) => { void Promise.resolve().then(callback); };
1099
+ schedule(() => {
1100
+ this.journalFlushScheduled = false;
1101
+ const batch = this.pendingJournalBatch;
1102
+ this.pendingJournalBatch = [];
1103
+ void this.flushPendingMutationJournal(batch);
1104
+ });
1105
+ }
1106
+ async flushPendingMutationJournal(batch) {
1107
+ if (batch.length === 0)
1108
+ return;
1109
+ if (!this.userId || !this.organizationId) {
1110
+ // Startup writes remain behind their unresolved journal promise. Identity
1111
+ // initialization re-kicks this batch once its durable scope is known.
1112
+ this.pendingJournalBatch = [...batch, ...this.pendingJournalBatch];
1113
+ return;
1114
+ }
1115
+ try {
1116
+ const records = batch.map((mutation) => this.pendingMutationRecord(mutation));
1117
+ const database = this.database;
1118
+ if (database.saveTransactions) {
1119
+ await database.saveTransactions(records);
1120
+ }
1121
+ else {
1122
+ await Promise.all(records.map((record) => database.saveTransaction(record)));
1123
+ }
1124
+ for (const mutation of batch)
1125
+ mutation.resolveJournal?.();
1126
+ }
1127
+ catch (error) {
1128
+ for (const mutation of batch)
1129
+ mutation.rejectJournal?.(error);
1130
+ }
1131
+ finally {
1132
+ for (const mutation of batch) {
1133
+ mutation.resolveJournal = undefined;
1134
+ mutation.rejectJournal = undefined;
1135
+ }
1136
+ }
1137
+ }
973
1138
  syncScheduled = false;
974
1139
  scheduleSync() {
975
1140
  if (this.syncScheduled)
@@ -980,7 +1145,6 @@ export class SyncClient extends EventEmitter {
980
1145
  : (cb) => Promise.resolve().then(cb);
981
1146
  schedule(() => {
982
1147
  this.syncScheduled = false;
983
- void this.persistMutationQueue();
984
1148
  if (getContext().onlineStatus.isOnline()) {
985
1149
  this.processPendingMutations().catch((err) => {
986
1150
  getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
@@ -988,69 +1152,136 @@ export class SyncClient extends EventEmitter {
988
1152
  }
989
1153
  });
990
1154
  }
991
- /**
992
- * Persist mutation queue to IndexedDB
993
- */
994
- async persistMutationQueue() {
995
- if (!this.database || !this.userId)
996
- return;
997
- try {
998
- const serializedMutations = this.pendingMutations.map((m) => ({
999
- type: m.type,
1000
- modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
1001
- modelName: m.model.getModelName(),
1002
- timestamp: m.timestamp.toISOString(),
1003
- writeOptions: m.writeOptions,
1004
- }));
1005
- await this.database.saveTransaction({
1006
- id: 'mutation-queue',
1007
- type: 'queue',
1008
- mutations: serializedMutations,
1009
- timestamp: Date.now(),
1010
- });
1011
- }
1012
- catch (error) {
1013
- // Best-effort persistence — the in-memory queue still processes; only
1014
- // a tab close before reconnect loses these. Forensic → debug.
1015
- getContext().logger.debug('[SyncClient] Failed to persist offline mutation queue', {
1016
- error: error instanceof Error ? error.message : String(error),
1017
- });
1155
+ pendingMutationRecord(mutation) {
1156
+ if (!this.userId || !this.organizationId) {
1157
+ throw new AbloValidationError('Cannot persist a mutation before participant scope is initialized', { code: 'write_options_invalid' });
1018
1158
  }
1159
+ return pendingMutationRecordSchema.parse({
1160
+ id: pendingMutationRecordId(mutation.mutationId),
1161
+ type: 'pending_mutation',
1162
+ storageVersion: 2,
1163
+ mutation: {
1164
+ mutationId: mutation.mutationId,
1165
+ type: mutation.type,
1166
+ modelData: mutation.modelData,
1167
+ modelName: mutation.model.getModelName(),
1168
+ timestamp: mutation.timestamp.toISOString(),
1169
+ ...(mutation.capturedChanges !== undefined
1170
+ ? { capturedChanges: mutation.capturedChanges }
1171
+ : {}),
1172
+ ...(mutation.writeOptions !== undefined
1173
+ ? { writeOptions: mutation.writeOptions }
1174
+ : {}),
1175
+ },
1176
+ scope: {
1177
+ organizationId: this.organizationId,
1178
+ participantId: this.userId,
1179
+ namespace: this.commitOutboxNamespace,
1180
+ },
1181
+ timestamp: mutation.timestamp.getTime(),
1182
+ });
1183
+ }
1184
+ async persistPendingMutation(mutation) {
1185
+ await this.database.saveTransaction(this.pendingMutationRecord(mutation));
1019
1186
  }
1020
1187
  /**
1021
- * Restore mutation queue from IndexedDB.
1188
+ * Restore the mutation queue from IndexedDB.
1022
1189
  *
1023
- * The persisted record was written by a PREVIOUS session (possibly an older
1024
- * SDK build), so each entry is validated at this replay boundary (T1.8):
1025
- * corrupt entries are dropped + logged at debug, and a failure never
1026
- * vanishes into an empty catch offline write survival must be observable.
1190
+ * The persisted record was written by an earlier session, possibly by an
1191
+ * older build of the SDK, so each entry is validated as it is replayed:
1192
+ * corrupt entries are dropped and logged at debug level, and a failure is
1193
+ * never swallowed silently, because the survival of offline writes must be
1194
+ * observable.
1027
1195
  */
1028
- async restoreMutationQueue() {
1196
+ async restoreMutationQueue(sealedMutationIds = new Set()) {
1029
1197
  if (!this.database || !this.userId)
1030
1198
  return;
1031
1199
  try {
1032
1200
  const stored = await this.database.getPersistedTransactions();
1033
- const queue = stored.find((t) => t.id === 'mutation-queue');
1034
- if (queue?.mutations) {
1035
- for (const mutation of queue.mutations) {
1036
- const parsed = persistedMutationSchema.safeParse(mutation);
1037
- if (!parsed.success) {
1038
- getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1039
- issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1040
- });
1041
- continue;
1201
+ const restoredMutationIds = new Set();
1202
+ let heldForReview = 0;
1203
+ const restore = async (mutation, migrateLegacy, legacyMutationId) => {
1204
+ const parsed = persistedMutationSchema.safeParse(mutation);
1205
+ if (!parsed.success) {
1206
+ getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1207
+ issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1208
+ });
1209
+ return;
1210
+ }
1211
+ // The window is anchored to when the write was made, because a
1212
+ // record re-sealed on restore would otherwise reset its own expiry
1213
+ // clock. An unparseable timestamp is held rather than replayed.
1214
+ const writtenAt = Date.parse(parsed.data.timestamp);
1215
+ const age = Date.now() - writtenAt;
1216
+ if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
1217
+ heldForReview += 1;
1218
+ getContext().logger.warn('A saved local write is older than the server idempotency window and was held for review.');
1219
+ return;
1220
+ }
1221
+ const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
1222
+ if (sealedMutationIds.has(mutationId) ||
1223
+ restoredMutationIds.has(mutationId))
1224
+ return;
1225
+ const model = this.objectPool.createFromData(parsed.data.modelData);
1226
+ if (model) {
1227
+ const pending = {
1228
+ mutationId,
1229
+ type: parsed.data.type,
1230
+ model,
1231
+ modelData: parsed.data.modelData,
1232
+ timestamp: new Date(parsed.data.timestamp),
1233
+ ...(parsed.data.capturedChanges !== undefined
1234
+ ? { capturedChanges: parsed.data.capturedChanges }
1235
+ : {}),
1236
+ ...(parsed.data.writeOptions !== undefined
1237
+ ? { writeOptions: parsed.data.writeOptions }
1238
+ : {}),
1239
+ journaled: Promise.resolve(),
1240
+ // Restored mutations have no live `wait: 'confirmed'` caller, so
1241
+ // their staging needs no waiter handshake.
1242
+ staged: Promise.resolve(),
1243
+ };
1244
+ if (migrateLegacy) {
1245
+ pending.journaled = this.persistPendingMutation(pending);
1246
+ await pending.journaled;
1042
1247
  }
1043
- const model = this.objectPool.createFromData(parsed.data.modelData);
1044
- if (model) {
1045
- this.pendingMutations.push({
1046
- type: parsed.data.type,
1047
- model,
1048
- timestamp: new Date(parsed.data.timestamp),
1049
- ...(parsed.data.writeOptions !== undefined
1050
- ? { writeOptions: parsed.data.writeOptions }
1051
- : {}),
1052
- });
1248
+ this.pendingMutations.push(pending);
1249
+ restoredMutationIds.add(mutationId);
1250
+ }
1251
+ };
1252
+ for (const row of stored) {
1253
+ if (row.type !== 'pending_mutation')
1254
+ continue;
1255
+ const parsed = pendingMutationRecordSchema.safeParse(row);
1256
+ if (!parsed.success) {
1257
+ const legacy = legacyPendingMutationRecordSchema.safeParse(row);
1258
+ if (legacy.success) {
1259
+ await restore(legacy.data.mutation, true);
1260
+ continue;
1053
1261
  }
1262
+ getContext().logger.debug('[SyncClient] Dropping malformed pending mutation record', {
1263
+ rowId: row.id,
1264
+ });
1265
+ continue;
1266
+ }
1267
+ if (parsed.data.scope.organizationId !== this.organizationId ||
1268
+ parsed.data.scope.participantId !== this.userId ||
1269
+ parsed.data.scope.namespace !== this.commitOutboxNamespace) {
1270
+ getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
1271
+ continue;
1272
+ }
1273
+ await restore(parsed.data.mutation, false);
1274
+ }
1275
+ const legacyQueue = stored.find((row) => row.id === 'mutation-queue');
1276
+ if (legacyQueue?.mutations) {
1277
+ const heldBefore = heldForReview;
1278
+ for (const [index, mutation] of legacyQueue.mutations.entries()) {
1279
+ await restore(mutation, true, `legacy_mutation_${index}`);
1280
+ }
1281
+ // Deleting the legacy row would discard any entry held for review, so
1282
+ // it is only removed once every entry has migrated.
1283
+ if (heldForReview === heldBefore) {
1284
+ await this.database.removeTransaction('mutation-queue');
1054
1285
  }
1055
1286
  }
1056
1287
  }
@@ -1102,17 +1333,45 @@ export class SyncClient extends EventEmitter {
1102
1333
  return; // Skip if offline
1103
1334
  if (this.isDisposed)
1104
1335
  return; // Skip if disposed
1105
- const mutations = this.pendingMutations;
1106
- this.pendingMutations = [];
1107
- // Clear persisted queue before processing
1108
- await this.persistMutationQueue();
1109
- // LINEAR PATTERN: Stage all mutations synchronously in same event loop tick
1110
- // TransactionQueue's microtask will batch and send them together
1336
+ if (this.stagedMutationIds.size > 0)
1337
+ return;
1338
+ const mutations = this.pendingMutations.filter((mutation) => !this.stagedMutationIds.has(mutation.mutationId)).slice(0, 500);
1339
+ if (mutations.length === 0)
1340
+ return;
1341
+ // Claim the batch BEFORE awaiting the journal. This method runs
1342
+ // concurrently — the scheduleSync microtask and a direct syncNow() caller
1343
+ // land in the same tick — and both would otherwise capture this same
1344
+ // batch, suspend on the identical `journaled` promises, and stage every
1345
+ // mutation twice (two transactions on the wire for one write). Claiming
1346
+ // synchronously makes the second caller hit the guard above and return.
1111
1347
  for (const mutation of mutations) {
1112
- // Skip mutations for deleted models (prevents "not found" errors)
1113
- if (mutation.type !== 'delete' && !this.objectPool.get(mutation.model.id)) {
1114
- continue;
1348
+ this.stagedMutationIds.add(mutation.mutationId);
1349
+ }
1350
+ // A journal rejection is permanent for that mutation (fail-closed: it can
1351
+ // never dispatch without its durable record), so drop it rather than
1352
+ // leaving it queued to poison every later pass. Healthy batch members
1353
+ // still stage.
1354
+ const journalResults = await Promise.allSettled(mutations.map((mutation) => mutation.journaled));
1355
+ const journaledMutations = [];
1356
+ journalResults.forEach((result, index) => {
1357
+ const mutation = mutations[index];
1358
+ if (result.status === 'fulfilled') {
1359
+ journaledMutations.push(mutation);
1360
+ return;
1115
1361
  }
1362
+ this.stagedMutationIds.delete(mutation.mutationId);
1363
+ this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
1364
+ mutation.rejectStaged?.(result.reason);
1365
+ getContext().observability.captureTransactionFailure({
1366
+ context: 'persist-pending-mutation',
1367
+ error: result.reason instanceof Error
1368
+ ? result.reason
1369
+ : new Error(String(result.reason)),
1370
+ });
1371
+ });
1372
+ // Stage every mutation synchronously within the same event-loop tick;
1373
+ // the transaction queue's microtask batches and sends them together.
1374
+ for (const mutation of journaledMutations) {
1116
1375
  // Stage synchronously - TransactionQueue handles batching, retry, and errors
1117
1376
  this.stageMutation(mutation);
1118
1377
  }
@@ -1123,8 +1382,12 @@ export class SyncClient extends EventEmitter {
1123
1382
  * @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
1124
1383
  */
1125
1384
  stageMutation(mutation) {
1126
- if (!this.userId || !this.organizationId)
1385
+ if (!this.userId || !this.organizationId) {
1386
+ // Nothing will stage this call; settle the waiter with the legacy
1387
+ // "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
1388
+ mutation.resolveStaged?.();
1127
1389
  return;
1390
+ }
1128
1391
  const ctx = { userId: this.userId, organizationId: this.organizationId };
1129
1392
  // Settlement is delivered via transaction.confirmation, not this promise —
1130
1393
  // it only rejects when staging itself throws (change extraction, optimistic
@@ -1137,23 +1400,23 @@ export class SyncClient extends EventEmitter {
1137
1400
  modelId: mutation.model.id,
1138
1401
  error: error instanceof Error ? error : new Error(String(error)),
1139
1402
  });
1403
+ this.stagedMutationIds.delete(mutation.mutationId);
1140
1404
  };
1141
- if (mutation.type === 'update') {
1142
- this.transactionQueue
1143
- .update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions)
1144
- .catch(captureStagingFailure);
1145
- }
1146
- else {
1147
- const handler = this.transactionQueue[mutation.type].bind(this.transactionQueue);
1148
- handler(mutation.model, ctx, mutation.writeOptions).catch(captureStagingFailure);
1149
- }
1405
+ const staging = mutation.type === 'update'
1406
+ ? this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
1407
+ : this.transactionQueue[mutation.type].bind(this.transactionQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
1408
+ staging
1409
+ .then(() => mutation.resolveStaged?.())
1410
+ .catch((error) => {
1411
+ captureStagingFailure(error);
1412
+ mutation.rejectStaged?.(error);
1413
+ });
1150
1414
  }
1151
1415
  /**
1152
- * Resolve conflicts between local and server data
1153
- * Used when processing deltas from WebSocket
1154
- *
1155
- * CRITICAL: Always respects certain server states (deletes, deactivations)
1156
- * even when there are local changes, to maintain data consistency.
1416
+ * Resolve a conflict between the local model and incoming server data,
1417
+ * called while processing deltas from the WebSocket. Certain server states,
1418
+ * such as deletions and deactivations, always take precedence even when the
1419
+ * local model has unsynced changes, so the two sides stay consistent.
1157
1420
  */
1158
1421
  resolveConflicts(localModel, serverData) {
1159
1422
  const hasLocalChanges = localModel.hasChanges;
@@ -1218,9 +1481,9 @@ export class SyncClient extends EventEmitter {
1218
1481
  return localModel;
1219
1482
  }
1220
1483
  /**
1221
- * Extract critical state fields from server data
1222
- * These are states that must always be respected, even with local changes.
1223
- * The conflict brain reads exactly these known fields nothing else.
1484
+ * Extract the critical state fields from server data. These are the states
1485
+ * that must be honored even when the local model has unsynced changes. The
1486
+ * conflict resolver reads exactly these fields and no others.
1224
1487
  */
1225
1488
  extractCriticalState(serverData) {
1226
1489
  const critical = {};
@@ -1267,8 +1530,6 @@ export class SyncClient extends EventEmitter {
1267
1530
  await this.processPendingMutations();
1268
1531
  this.setConnectionState('connected');
1269
1532
  this.emit('sync:reconnected');
1270
- // Clear offline timestamp
1271
- this.offlineSince = undefined;
1272
1533
  }
1273
1534
  catch (error) {
1274
1535
  getContext().observability.captureTransactionFailure({
@@ -1284,7 +1545,6 @@ export class SyncClient extends EventEmitter {
1284
1545
  async handleDisconnection() {
1285
1546
  getContext().observability.breadcrumb('Network disconnected', 'sync.offline');
1286
1547
  this.setConnectionState('disconnected');
1287
- this.offlineSince = new Date();
1288
1548
  this.emit('sync:offline');
1289
1549
  }
1290
1550
  /**
@@ -1368,6 +1628,16 @@ export class SyncClient extends EventEmitter {
1368
1628
  */
1369
1629
  markConnected() {
1370
1630
  this.setConnectionState('connected');
1631
+ // Browser online state may have marked the client connected before the
1632
+ // WebSocket itself was ready. Always kick both durable lanes on the real
1633
+ // socket event, even when the high-level state did not change.
1634
+ void this.transactionQueue.flushOfflineQueue().catch((error) => {
1635
+ getContext().observability.captureTransactionFailure({
1636
+ context: 'restore-commit-outbox',
1637
+ error: error instanceof Error ? error : new Error(String(error)),
1638
+ });
1639
+ });
1640
+ void this.processPendingMutations();
1371
1641
  }
1372
1642
  /**
1373
1643
  * Dispose and cleanup
@@ -1381,9 +1651,10 @@ export class SyncClient extends EventEmitter {
1381
1651
  this.removeAllListeners();
1382
1652
  }
1383
1653
  /**
1384
- * LINEAR PATTERN: Notify TransactionQueue of incoming delta for sync ID threshold confirmation.
1385
- * Transactions are confirmed when any delta with id >= their lastSyncId threshold arrives.
1386
- * @param syncId - The sync ID of the received delta
1654
+ * Notify the {@link TransactionQueue} of an incoming delta so it can confirm
1655
+ * transactions by sync-id threshold. A transaction is confirmed once any
1656
+ * delta with an id at or beyond its `lastSyncId` threshold arrives.
1657
+ * @param syncId - The sync id of the received delta.
1387
1658
  */
1388
1659
  onDeltaReceived(syncId) {
1389
1660
  try {
@@ -1396,22 +1667,21 @@ export class SyncClient extends EventEmitter {
1396
1667
  }
1397
1668
  }
1398
1669
  /**
1399
- * LINEAR PATTERN: Cancel transactions for orphaned child entities
1400
- *
1401
- * Called by SyncedStore when a DELETE delta arrives for a parent entity.
1402
- * Cancels pending transactions for children that reference the deleted parent.
1670
+ * Cancel pending transactions for child entities orphaned by a parent's
1671
+ * deletion. The store calls this when a delete delta arrives for a parent,
1672
+ * cancelling any queued writes on children that reference it.
1403
1673
  *
1404
- * @param childModelName - The child model type (e.g., 'SlideLayer')
1405
- * @param foreignKey - The FK property name (e.g., 'slideId')
1406
- * @param parentId - The deleted parent's ID
1407
- * @returns Number of transactions cancelled
1674
+ * @param childModelName - The child model type (for example, `SlideLayer`).
1675
+ * @param foreignKey - The foreign-key property name (for example, `slideId`).
1676
+ * @param parentId - The id of the deleted parent.
1677
+ * @returns The number of transactions cancelled.
1408
1678
  */
1409
1679
  cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
1410
1680
  return this.transactionQueue.cancelTransactionsByForeignKey(childModelName, foreignKey, parentId);
1411
1681
  }
1412
1682
  /**
1413
- * Wait for a transaction to be confirmed via delta echo (Linear pattern)
1414
- * Delegates to TransactionQueue which already handles timeouts
1683
+ * Wait for a transaction to be confirmed by its delta echo. Delegates to the
1684
+ * {@link TransactionQueue}, which handles the confirmation timeout.
1415
1685
  */
1416
1686
  waitForDeltaConfirmation(transactionId) {
1417
1687
  return this.transactionQueue.waitForConfirmation(transactionId);
@@ -1420,12 +1690,19 @@ export class SyncClient extends EventEmitter {
1420
1690
  * Force sync now - process pending mutations
1421
1691
  */
1422
1692
  async syncNow() {
1693
+ // Snapshot before draining: a concurrent drain may already have claimed
1694
+ // this caller's write, in which case processPendingMutations returns
1695
+ // without staging anything. `wait: 'confirmed'` resolves on finding no
1696
+ // in-flight work, so it must not run until every write queued before this
1697
+ // call has a real transaction in the queue or was definitively dropped.
1698
+ const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
1423
1699
  await this.processPendingMutations();
1700
+ await Promise.allSettled(queuedBeforeCall);
1424
1701
  }
1425
1702
  /**
1426
1703
  * Get sync statistics. Return type is inferred from the literal so
1427
1704
  * the call site sees the actual shape — `connectionState` narrowed
1428
- * to its three states, `objectPoolStats` typed by `ObjectPool.getStats`.
1705
+ * to its three states, `objectPoolStats` typed by `InstanceCache.getStats`.
1429
1706
  */
1430
1707
  getSyncStats() {
1431
1708
  return {
@@ -1460,28 +1737,41 @@ export class SyncClient extends EventEmitter {
1460
1737
  * can render typed UI (toast keyed by `AbloError.type`, route-level
1461
1738
  * "this entity reverted" boundaries, telemetry).
1462
1739
  *
1463
- * Distinct from `onTransactionEvent('failed', cb)`, which exists only
1464
- * for the legacy parameterless `pendingChanges` counter and intentionally
1465
- * drops the payload. The two coexist — keep the counter callback fast
1466
- * and the typed listener for user-visible surfaces.
1740
+ * Distinct from `onTransactionEvent('failed', cb)`, which serves the
1741
+ * parameterless `pendingChanges` counter and intentionally drops the
1742
+ * payload. The two coexist: the counter callback stays lightweight, while
1743
+ * this typed listener drives user-visible surfaces.
1467
1744
  */
1468
1745
  onMutationFailure(listener) {
1469
1746
  this.transactionQueue.on('transaction:failed', listener);
1470
1747
  return () => this.transactionQueue.off('transaction:failed', listener);
1471
1748
  }
1472
1749
  /**
1473
- * Subscribe to LOCAL transaction creation with the full {@link Transaction}
1750
+ * Subscribe to local transaction creation with the full {@link Transaction}
1474
1751
  * payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
1475
- * the feed `BaseSyncedStore.subscribeLocalMutations` taps for undo recording.
1752
+ * the feed the store's local-mutation subscription taps for undo recording.
1476
1753
  *
1477
- * MUST subscribe to the TransactionQueue's emitter directly — that is the
1478
- * ONLY emitter that fires `transaction:created`. SyncClient's own emitter
1479
- * (reached via `subscribe()`) never re-broadcasts it, so routing undo through
1480
- * `subscribe('transaction:created')` silently records nothing. Mirrors
1481
- * `onMutationFailure`, which taps the queue for the same reason.
1754
+ * It subscribes to the {@link TransactionQueue}'s emitter directly, since
1755
+ * that is the only emitter that fires `transaction:created`. The SyncClient's
1756
+ * own emitter (reached through {@link subscribe}) never rebroadcasts that
1757
+ * event, so routing undo through `subscribe('transaction:created')` would
1758
+ * record nothing. {@link onMutationFailure} taps the queue for the same
1759
+ * reason.
1482
1760
  */
1483
1761
  onLocalTransaction(listener) {
1484
1762
  this.transactionQueue.on('transaction:created', listener);
1763
+ const snapshotsByCommit = new Map();
1764
+ const onCommitStaging = (payload) => {
1765
+ snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
1766
+ if (operation.type === 'CREATE')
1767
+ return undefined;
1768
+ const resident = this.objectPool.get(operation.id);
1769
+ return resident?.toJSON();
1770
+ }));
1771
+ };
1772
+ const onCommitSealFailed = (payload) => {
1773
+ snapshotsByCommit.delete(payload.clientTxId);
1774
+ };
1485
1775
  // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1486
1776
  // their own `commit:created` event: they have no optimistic pool apply,
1487
1777
  // so they must not feed the echo tracker's `transaction:created` path.
@@ -1489,6 +1779,8 @@ export class SyncClient extends EventEmitter {
1489
1779
  // (the queue is pool-free) and hand the synthesized transaction to the
1490
1780
  // same listener, so undo observes every write door — one stream.
1491
1781
  const onCommitCreated = (payload) => {
1782
+ const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
1783
+ snapshotsByCommit.delete(payload.clientTxId);
1492
1784
  const TYPE_BY_WIRE = {
1493
1785
  CREATE: 'create',
1494
1786
  UPDATE: 'update',
@@ -1500,8 +1792,11 @@ export class SyncClient extends EventEmitter {
1500
1792
  const type = TYPE_BY_WIRE[op.type];
1501
1793
  if (!type || !op.id)
1502
1794
  return;
1503
- const resident = this.objectPool.get(op.id);
1504
- const snapshot = type === 'create' ? undefined : resident?.toJSON();
1795
+ const snapshot = type === 'create'
1796
+ ? undefined
1797
+ : stagedSnapshots
1798
+ ? stagedSnapshots[index]
1799
+ : this.objectPool.get(op.id)?.toJSON();
1505
1800
  // A DELETE of a row the local graph never saw is not invertible —
1506
1801
  // recording it would make undo "restore" an empty husk. Skip it.
1507
1802
  if (type === 'delete' && !snapshot)
@@ -1532,10 +1827,15 @@ export class SyncClient extends EventEmitter {
1532
1827
  });
1533
1828
  });
1534
1829
  };
1830
+ this.transactionQueue.on('commit:staging', onCommitStaging);
1831
+ this.transactionQueue.on('commit:seal_failed', onCommitSealFailed);
1535
1832
  this.transactionQueue.on('commit:created', onCommitCreated);
1536
1833
  return () => {
1537
1834
  this.transactionQueue.off('transaction:created', listener);
1835
+ this.transactionQueue.off('commit:staging', onCommitStaging);
1836
+ this.transactionQueue.off('commit:seal_failed', onCommitSealFailed);
1538
1837
  this.transactionQueue.off('commit:created', onCommitCreated);
1838
+ snapshotsByCommit.clear();
1539
1839
  };
1540
1840
  }
1541
1841
  /**
@@ -1574,11 +1874,11 @@ export class SyncClient extends EventEmitter {
1574
1874
  assigneeId,
1575
1875
  });
1576
1876
  }
1577
- // ── Delta + Bootstrap application (owns ObjectPool writes) ──────────────
1877
+ // ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
1578
1878
  /**
1579
- * Apply a batch of delta results from Database to the ObjectPool.
1879
+ * Apply a batch of delta results from Database to the InstanceCache.
1580
1880
  * Owns: model creation, upsert, remove, archive, conflict resolution.
1581
- * Returns: nothing — ObjectPool is updated in place.
1881
+ * Returns: nothing — InstanceCache is updated in place.
1582
1882
  */
1583
1883
  /**
1584
1884
  * Mark a local transaction as optimistically applied. The matching
@@ -1600,12 +1900,12 @@ export class SyncClient extends EventEmitter {
1600
1900
  return this.echoTracker.getMetrics();
1601
1901
  }
1602
1902
  /**
1603
- * Package-internal accessor for the TransactionQueue. Used by
1604
- * `Ablo.commits.create()` to route raw multi-op envelopes through the
1605
- * same retry-on-reconnect lane as the Model proxy path, and by tests
1606
- * to exercise the queue markTransactionPending wiring on the real
1607
- * instance the SyncClient subscribes to. NOT re-exported to SDK
1608
- * consumers `Ablo` itself is the public surface.
1903
+ * Package-internal accessor for the {@link TransactionQueue}. Used by
1904
+ * `Ablo.commits.create()` to route raw multi-operation envelopes through the
1905
+ * same retry-on-reconnect lane as the model proxy path, and by tests to
1906
+ * exercise the queue's interaction with {@link markTransactionPending} on the
1907
+ * real instance the SyncClient subscribes to. It is not re-exported to SDK
1908
+ * consumers; `Ablo` is the public surface.
1609
1909
  */
1610
1910
  getTransactionQueue() {
1611
1911
  return this.transactionQueue;
@@ -1633,16 +1933,14 @@ export class SyncClient extends EventEmitter {
1633
1933
  }
1634
1934
  for (const result of dbResults) {
1635
1935
  const { modelName, modelId, action, transactionId } = result;
1636
- // ECHO DETECTION. If this delta carries a transaction id that
1637
- // matches one we've optimistically applied locally, the pool
1638
- // already reflects this mutation skip the pool op. The
1639
- // upstream IDB write (in `Database.processDeltaBatch`) still
1640
- // runs; only the in-memory pool mutation is suppressed. This is
1641
- // the architectural fix for the chart-delete flicker: a
1642
- // server-confirmed CREATE arriving AFTER the user has
1643
- // optimistically deleted the row would otherwise re-add the row
1644
- // for the ~2s window before the matching DELETE confirmation
1645
- // lands. See `OPTIMISTIC_RECONCILIATION.md` for the framing.
1936
+ // Echo detection: if this delta carries a transaction id that matches
1937
+ // one already applied optimistically, the pool already reflects the
1938
+ // mutation, so the pool operation is skipped. The IndexedDB write in
1939
+ // Database.processDeltaBatch still runs; only the in-memory pool update
1940
+ // is suppressed. This prevents a resurrection flicker: a server-confirmed
1941
+ // create arriving after the user has optimistically deleted the row would
1942
+ // otherwise re-add it for the brief window before the matching delete
1943
+ // confirmation lands.
1646
1944
  if (this.echoTracker.consumeEcho(transactionId)) {
1647
1945
  continue;
1648
1946
  }
@@ -1708,17 +2006,15 @@ export class SyncClient extends EventEmitter {
1708
2006
  break;
1709
2007
  }
1710
2008
  }
1711
- // Reveal the whole frame in ONE MobX action. `addBatch`/`upsertBatch`/
1712
- // `removeBatch`/`updateScope` are each individually `action`-wrapped,
1713
- // so calling them sequentially flushes reactions at every action
1714
- // boundary — a catch-up frame that adds + updates + removes would fire
1715
- // every dependent reaction (the decks gallery, each open editor) 3-
1716
- // in a row, re-rendering and re-sorting on each. Wrapping them in a
1717
- // single outer `runInAction` defers all reaction flushes to ONE
1718
- // boundary: dependents recompute exactly once regardless of how many
1719
- // models or how many op-kinds the frame touched. This is the MobX
1720
- // equivalent of Replicache's "atomically reveal the new state" — the
1721
- // app never observes a partially-applied frame.
2009
+ // Reveal the whole frame in a single MobX action. `addBatch`,
2010
+ // `upsertBatch`, `removeBatch`, and `updateScope` are each individually
2011
+ // wrapped in an action, so calling them in sequence flushes reactions at
2012
+ // every action boundary — a catch-up frame that adds, updates, and removes
2013
+ // would fire every dependent reaction several times in a row, re-rendering
2014
+ // and re-sorting on each. Wrapping them in one outer `runInAction` defers
2015
+ // all reaction flushes to a single boundary, so dependents recompute
2016
+ // exactly once regardless of how many models or operation kinds the frame
2017
+ // touched. The app therefore never observes a partially applied frame.
1722
2018
  runInAction(() => {
1723
2019
  if (modelsToAdd.length > 0)
1724
2020
  this.objectPool.addBatch(modelsToAdd, ModelScope.live);
@@ -1737,7 +2033,7 @@ export class SyncClient extends EventEmitter {
1737
2033
  });
1738
2034
  }
1739
2035
  /**
1740
- * Apply bootstrap data to the ObjectPool with ghost removal.
2036
+ * Apply bootstrap data to the InstanceCache with ghost removal.
1741
2037
  * Owns: model creation, batch upsert, ghost detection + removal.
1742
2038
  */
1743
2039
  applyBootstrapDataToPool(bootstrapData, protectedIds, options) {
@@ -1775,12 +2071,12 @@ export class SyncClient extends EventEmitter {
1775
2071
  const recordId = data.id;
1776
2072
  if (recordId)
1777
2073
  idsForType.add(recordId);
1778
- // Scoped backfill (P4 hydrate-on-enter): a subset snapshot is taken at
1779
- // a server watermark. If a concurrent live delta already advanced this
1780
- // row past the snapshot, skip it `createFromData` mutates the pooled
1781
- // model IN PLACE (the "keep instances alive" Linear pattern), so this
1782
- // version guard MUST run BEFORE it; an upsert-layer guard would be too
1783
- // late, the row would already be clobbered.
2074
+ // Scoped backfill for the hydrate-on-enter path: a subset snapshot is
2075
+ // taken at a server watermark. If a concurrent live delta already
2076
+ // advanced this row past the snapshot, skip it. `createFromData`
2077
+ // mutates the pooled model in place to keep instances alive, so this
2078
+ // version guard has to run before it; a guard at the upsert layer would
2079
+ // be too late, because the row would already be clobbered.
1784
2080
  if (options?.scoped && recordId) {
1785
2081
  const existing = this.objectPool.get(recordId);
1786
2082
  if (existing && !rawRecordIsNewer(data, existing)) {
@@ -1804,10 +2100,10 @@ export class SyncClient extends EventEmitter {
1804
2100
  this.objectPool.upsertBatch(allModels, ModelScope.live);
1805
2101
  const addedCount = this.objectPool.size - beforeSize;
1806
2102
  const updatedCount = allModels.length - addedCount;
1807
- // Ghost removal remove pool entities not in the server snapshot. Only
1808
- // valid for a FULL bootstrap, where the snapshot is authoritative for each
1809
- // returned type. A SCOPED subset snapshot must NOT remove rows of the same
1810
- // type that belong to other (unhydrated) groups.
2103
+ // Ghost removal: drop pool entities absent from the server snapshot. This
2104
+ // is valid only for a full bootstrap, where the snapshot is authoritative
2105
+ // for each returned type. A scoped subset snapshot must not remove rows of
2106
+ // the same type that belong to other, unhydrated groups.
1811
2107
  let removedCount = 0;
1812
2108
  if (!options?.scoped) {
1813
2109
  const ghostIds = [];