@abloatai/ablo 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (622) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +71 -1929
  3. package/NOTICE +2 -2
  4. package/README.md +23 -532
  5. package/assets/banner.png +0 -0
  6. package/dist/auth.d.ts +2 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +2 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/client.d.ts +3 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +3 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/coordination.d.ts +2 -0
  15. package/dist/coordination.d.ts.map +1 -0
  16. package/dist/coordination.js +2 -0
  17. package/dist/coordination.js.map +1 -0
  18. package/dist/index.d.ts +3 -112
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -161
  21. package/dist/index.js.map +1 -0
  22. package/dist/react.d.ts +4 -0
  23. package/dist/react.d.ts.map +1 -0
  24. package/dist/react.js +3 -0
  25. package/dist/react.js.map +1 -0
  26. package/dist/schema.d.ts +2 -0
  27. package/dist/schema.d.ts.map +1 -0
  28. package/dist/schema.js +2 -0
  29. package/dist/schema.js.map +1 -0
  30. package/dist/server.d.ts +2 -0
  31. package/dist/server.d.ts.map +1 -0
  32. package/dist/server.js +2 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/source-conformance.d.ts +2 -0
  35. package/dist/source-conformance.d.ts.map +1 -0
  36. package/dist/source-conformance.js +2 -0
  37. package/dist/source-conformance.js.map +1 -0
  38. package/dist/source-drizzle.d.ts +2 -0
  39. package/dist/source-drizzle.d.ts.map +1 -0
  40. package/dist/source-drizzle.js +2 -0
  41. package/dist/source-drizzle.js.map +1 -0
  42. package/dist/source-kysely.d.ts +2 -0
  43. package/dist/source-kysely.d.ts.map +1 -0
  44. package/dist/source-kysely.js +2 -0
  45. package/dist/source-kysely.js.map +1 -0
  46. package/dist/source-next.d.ts +2 -0
  47. package/dist/source-next.d.ts.map +1 -0
  48. package/dist/source-next.js +2 -0
  49. package/dist/source-next.js.map +1 -0
  50. package/dist/source.d.ts +2 -0
  51. package/dist/source.d.ts.map +1 -0
  52. package/dist/source.js +2 -0
  53. package/dist/source.js.map +1 -0
  54. package/dist/wire.d.ts +2 -0
  55. package/dist/wire.d.ts.map +1 -0
  56. package/dist/wire.js +2 -0
  57. package/dist/wire.js.map +1 -0
  58. package/docs/agents.md +2 -2
  59. package/docs/api-keys.md +13 -12
  60. package/docs/api.md +15 -53
  61. package/docs/audit.md +4 -3
  62. package/docs/cli.md +11 -11
  63. package/docs/client-behavior.md +9 -9
  64. package/docs/concurrency-convention.md +28 -42
  65. package/docs/coordination.md +228 -86
  66. package/docs/data-sources.md +5 -5
  67. package/docs/debugging.md +34 -12
  68. package/docs/deployment.md +8 -8
  69. package/docs/examples/agent-human.md +4 -4
  70. package/docs/examples/ai-sdk-tool.md +1 -1
  71. package/docs/examples/existing-python-backend.md +15 -4
  72. package/docs/examples/nextjs.md +27 -6
  73. package/docs/examples/scoped-agent.md +3 -3
  74. package/docs/examples/server-agent.md +2 -2
  75. package/docs/groups.md +57 -3
  76. package/docs/guarantees.md +37 -10
  77. package/docs/how-it-works.md +32 -8
  78. package/docs/idempotency.md +6 -6
  79. package/docs/identity.md +24 -24
  80. package/docs/index.md +8 -8
  81. package/docs/integration-guide.md +38 -16
  82. package/docs/internal/README.md +18 -0
  83. package/docs/internal/agent-fleet-coordination-design.md +171 -0
  84. package/docs/internal/agent-orchestration.md +58 -0
  85. package/docs/internal/commit-identifiers.md +91 -0
  86. package/docs/internal/concurrency-open-decisions.md +37 -0
  87. package/docs/internal/data-source-reverse-channel.md +150 -0
  88. package/docs/internal/per-field-conflict-detection.md +165 -0
  89. package/docs/internal/postgres-replication.md +64 -0
  90. package/docs/internal/serializable-schema.md +119 -0
  91. package/docs/internal/structure.md +32 -0
  92. package/docs/mcp.md +9 -9
  93. package/docs/migration.md +37 -18
  94. package/docs/projects.md +1 -1
  95. package/docs/quickstart.md +2 -2
  96. package/docs/react.md +24 -13
  97. package/docs/schema-contract.md +3 -3
  98. package/docs/sessions.md +91 -37
  99. package/docs/webhooks.md +9 -9
  100. package/examples/README.md +2 -2
  101. package/examples/data-source/README.md +1 -1
  102. package/examples/data-source/ablo-driver.ts +1 -1
  103. package/examples/data-source/customer-server.ts +1 -1
  104. package/examples/data-source/run.ts +1 -1
  105. package/examples/data-source/schema.ts +1 -1
  106. package/examples/quickstart.ts +2 -2
  107. package/llms.txt +12 -12
  108. package/package.json +64 -174
  109. package/dist/BaseSyncedStore.d.ts +0 -823
  110. package/dist/BaseSyncedStore.js +0 -1955
  111. package/dist/Database.d.ts +0 -335
  112. package/dist/Database.js +0 -1500
  113. package/dist/InstanceCache.d.ts +0 -233
  114. package/dist/InstanceCache.js +0 -1164
  115. package/dist/LazyReferenceCollection.d.ts +0 -177
  116. package/dist/LazyReferenceCollection.js +0 -461
  117. package/dist/Model.d.ts +0 -444
  118. package/dist/Model.js +0 -909
  119. package/dist/ModelRegistry.d.ts +0 -221
  120. package/dist/ModelRegistry.js +0 -537
  121. package/dist/NetworkMonitor.d.ts +0 -26
  122. package/dist/NetworkMonitor.js +0 -77
  123. package/dist/RuntimeContext.d.ts +0 -52
  124. package/dist/RuntimeContext.js +0 -80
  125. package/dist/SyncClient.d.ts +0 -551
  126. package/dist/SyncClient.js +0 -2199
  127. package/dist/adapters/alwaysOnline.d.ts +0 -14
  128. package/dist/adapters/alwaysOnline.js +0 -17
  129. package/dist/adapters/inMemoryStorage.d.ts +0 -31
  130. package/dist/adapters/inMemoryStorage.js +0 -110
  131. package/dist/ai-sdk/coordinatedTool.d.ts +0 -120
  132. package/dist/ai-sdk/coordinatedTool.js +0 -134
  133. package/dist/ai-sdk/coordinationContext.d.ts +0 -46
  134. package/dist/ai-sdk/coordinationContext.js +0 -106
  135. package/dist/ai-sdk/index.d.ts +0 -121
  136. package/dist/ai-sdk/index.js +0 -121
  137. package/dist/ai-sdk/wrap.d.ts +0 -65
  138. package/dist/ai-sdk/wrap.js +0 -39
  139. package/dist/auth/index.d.ts +0 -1
  140. package/dist/auth/index.js +0 -8
  141. package/dist/batching/index.d.ts +0 -55
  142. package/dist/batching/index.js +0 -147
  143. package/dist/cli.cjs +0 -288600
  144. package/dist/client/Ablo.d.ts +0 -231
  145. package/dist/client/Ablo.js +0 -149
  146. package/dist/client/abloClient.d.ts +0 -309
  147. package/dist/client/abloClient.js +0 -13
  148. package/dist/client/clientPrelude.d.ts +0 -52
  149. package/dist/client/clientPrelude.js +0 -60
  150. package/dist/client/consoleLogger.d.ts +0 -35
  151. package/dist/client/consoleLogger.js +0 -44
  152. package/dist/client/coreClient.d.ts +0 -60
  153. package/dist/client/coreClient.js +0 -118
  154. package/dist/client/createInternalComponents.d.ts +0 -46
  155. package/dist/client/createInternalComponents.js +0 -92
  156. package/dist/client/createModelProxy.d.ts +0 -228
  157. package/dist/client/createModelProxy.js +0 -818
  158. package/dist/client/humans.d.ts +0 -48
  159. package/dist/client/humans.js +0 -52
  160. package/dist/client/modelRegistration.d.ts +0 -10
  161. package/dist/client/modelRegistration.js +0 -312
  162. package/dist/client/options.d.ts +0 -461
  163. package/dist/client/options.js +0 -7
  164. package/dist/client/reactiveEngine.d.ts +0 -48
  165. package/dist/client/reactiveEngine.js +0 -910
  166. package/dist/client/resourceTypes.d.ts +0 -12
  167. package/dist/client/resourceTypes.js +0 -10
  168. package/dist/client/schemaConfig.d.ts +0 -44
  169. package/dist/client/schemaConfig.js +0 -185
  170. package/dist/client/validateAbloOptions.d.ts +0 -42
  171. package/dist/client/validateAbloOptions.js +0 -43
  172. package/dist/client/wsMutationExecutor.d.ts +0 -27
  173. package/dist/client/wsMutationExecutor.js +0 -72
  174. package/dist/context.d.ts +0 -29
  175. package/dist/context.js +0 -58
  176. package/dist/coordination/ClaimLog.d.ts +0 -26
  177. package/dist/coordination/ClaimLog.js +0 -32
  178. package/dist/coordination/index.d.ts +0 -1
  179. package/dist/coordination/index.js +0 -8
  180. package/dist/core/DatabaseManager.d.ts +0 -105
  181. package/dist/core/DatabaseManager.js +0 -387
  182. package/dist/core/QueryProcessor.d.ts +0 -75
  183. package/dist/core/QueryProcessor.js +0 -255
  184. package/dist/core/QueryView.d.ts +0 -79
  185. package/dist/core/QueryView.js +0 -218
  186. package/dist/core/StoreManager.d.ts +0 -112
  187. package/dist/core/StoreManager.js +0 -302
  188. package/dist/core/ViewRegistry.d.ts +0 -20
  189. package/dist/core/ViewRegistry.js +0 -55
  190. package/dist/core/index.d.ts +0 -33
  191. package/dist/core/index.js +0 -48
  192. package/dist/core/openIDBWithTimeout.d.ts +0 -65
  193. package/dist/core/openIDBWithTimeout.js +0 -153
  194. package/dist/core/queryUtils.d.ts +0 -45
  195. package/dist/core/queryUtils.js +0 -69
  196. package/dist/core/storeContract.d.ts +0 -145
  197. package/dist/core/storeContract.js +0 -12
  198. package/dist/docs/catalog.d.ts +0 -72
  199. package/dist/docs/catalog.js +0 -227
  200. package/dist/docs/index.d.ts +0 -10
  201. package/dist/docs/index.js +0 -10
  202. package/dist/environment.d.ts +0 -1
  203. package/dist/environment.js +0 -8
  204. package/dist/interfaces/index.d.ts +0 -311
  205. package/dist/interfaces/index.js +0 -9
  206. package/dist/keys/index.d.ts +0 -1
  207. package/dist/keys/index.js +0 -8
  208. package/dist/mutators/RecordingMutation.d.ts +0 -36
  209. package/dist/mutators/RecordingMutation.js +0 -182
  210. package/dist/mutators/Transaction.d.ts +0 -40
  211. package/dist/mutators/Transaction.js +0 -58
  212. package/dist/mutators/UndoManager.d.ts +0 -258
  213. package/dist/mutators/UndoManager.js +0 -658
  214. package/dist/mutators/defineMutators.d.ts +0 -60
  215. package/dist/mutators/defineMutators.js +0 -18
  216. package/dist/mutators/inverseOp.d.ts +0 -126
  217. package/dist/mutators/inverseOp.js +0 -71
  218. package/dist/mutators/mutateActions.d.ts +0 -45
  219. package/dist/mutators/mutateActions.js +0 -105
  220. package/dist/mutators/readerActions.d.ts +0 -33
  221. package/dist/mutators/readerActions.js +0 -57
  222. package/dist/mutators/undoApply.d.ts +0 -51
  223. package/dist/mutators/undoApply.js +0 -117
  224. package/dist/policy/index.d.ts +0 -21
  225. package/dist/policy/index.js +0 -20
  226. package/dist/query/client.d.ts +0 -61
  227. package/dist/query/client.js +0 -137
  228. package/dist/query/types.d.ts +0 -85
  229. package/dist/query/types.js +0 -16
  230. package/dist/react/AbloProvider.d.ts +0 -230
  231. package/dist/react/AbloProvider.js +0 -455
  232. package/dist/react/ClientSideSuspense.d.ts +0 -36
  233. package/dist/react/ClientSideSuspense.js +0 -17
  234. package/dist/react/DefaultFallback.d.ts +0 -24
  235. package/dist/react/DefaultFallback.js +0 -43
  236. package/dist/react/context.d.ts +0 -55
  237. package/dist/react/context.js +0 -29
  238. package/dist/react/index.d.ts +0 -61
  239. package/dist/react/index.js +0 -66
  240. package/dist/react/internalContext.d.ts +0 -33
  241. package/dist/react/internalContext.js +0 -3
  242. package/dist/react/useAblo.d.ts +0 -75
  243. package/dist/react/useAblo.js +0 -102
  244. package/dist/react/useCurrentUserId.d.ts +0 -22
  245. package/dist/react/useCurrentUserId.js +0 -34
  246. package/dist/react/useErrorListener.d.ts +0 -20
  247. package/dist/react/useErrorListener.js +0 -38
  248. package/dist/react/useMutationFailureListener.d.ts +0 -26
  249. package/dist/react/useMutationFailureListener.js +0 -38
  250. package/dist/react/useMutators.d.ts +0 -56
  251. package/dist/react/useMutators.js +0 -84
  252. package/dist/react/useReactive.d.ts +0 -35
  253. package/dist/react/useReactive.js +0 -123
  254. package/dist/react/useSyncStatus.d.ts +0 -59
  255. package/dist/react/useSyncStatus.js +0 -76
  256. package/dist/react/useUndoScope.d.ts +0 -34
  257. package/dist/react/useUndoScope.js +0 -81
  258. package/dist/schema/coordination.d.ts +0 -112
  259. package/dist/schema/coordination.js +0 -129
  260. package/dist/schema/ddl.d.ts +0 -97
  261. package/dist/schema/ddl.js +0 -491
  262. package/dist/schema/ddlLock.d.ts +0 -35
  263. package/dist/schema/ddlLock.js +0 -46
  264. package/dist/schema/diff.d.ts +0 -225
  265. package/dist/schema/diff.js +0 -289
  266. package/dist/schema/generate.d.ts +0 -19
  267. package/dist/schema/generate.js +0 -86
  268. package/dist/schema/index.d.ts +0 -41
  269. package/dist/schema/index.js +0 -76
  270. package/dist/schema/queries.d.ts +0 -201
  271. package/dist/schema/queries.js +0 -144
  272. package/dist/schema/select.d.ts +0 -40
  273. package/dist/schema/select.js +0 -87
  274. package/dist/schema/serialize.d.ts +0 -115
  275. package/dist/schema/serialize.js +0 -262
  276. package/dist/schema/sugar.d.ts +0 -109
  277. package/dist/schema/sugar.js +0 -83
  278. package/dist/schema/syncDeltaRow.d.ts +0 -6
  279. package/dist/schema/syncDeltaRow.js +0 -6
  280. package/dist/server/adapter.d.ts +0 -173
  281. package/dist/server/adapter.js +0 -18
  282. package/dist/server/commit.d.ts +0 -107
  283. package/dist/server/commit.js +0 -1
  284. package/dist/server/index.d.ts +0 -14
  285. package/dist/server/index.js +0 -2
  286. package/dist/server/readConfig.d.ts +0 -80
  287. package/dist/server/readConfig.js +0 -8
  288. package/dist/server/storageMode.d.ts +0 -23
  289. package/dist/server/storageMode.js +0 -17
  290. package/dist/source/adapter.d.ts +0 -81
  291. package/dist/source/adapter.js +0 -22
  292. package/dist/source/adapters/drizzle.d.ts +0 -48
  293. package/dist/source/adapters/drizzle.js +0 -219
  294. package/dist/source/adapters/kysely.d.ts +0 -42
  295. package/dist/source/adapters/kysely.js +0 -205
  296. package/dist/source/adapters/kyselyMutationCore.d.ts +0 -76
  297. package/dist/source/adapters/kyselyMutationCore.js +0 -125
  298. package/dist/source/adapters/memory.d.ts +0 -13
  299. package/dist/source/adapters/memory.js +0 -130
  300. package/dist/source/adapters/prisma.d.ts +0 -63
  301. package/dist/source/adapters/prisma.js +0 -202
  302. package/dist/source/conformance.d.ts +0 -37
  303. package/dist/source/conformance.js +0 -215
  304. package/dist/source/connector.d.ts +0 -95
  305. package/dist/source/connector.js +0 -266
  306. package/dist/source/connectorProtocol.d.ts +0 -154
  307. package/dist/source/connectorProtocol.js +0 -163
  308. package/dist/source/contract.d.ts +0 -195
  309. package/dist/source/contract.js +0 -164
  310. package/dist/source/factory.d.ts +0 -92
  311. package/dist/source/factory.js +0 -286
  312. package/dist/source/footprint.d.ts +0 -111
  313. package/dist/source/footprint.js +0 -0
  314. package/dist/source/idempotency.d.ts +0 -61
  315. package/dist/source/idempotency.js +0 -144
  316. package/dist/source/index.d.ts +0 -23
  317. package/dist/source/index.js +0 -30
  318. package/dist/source/migrations.d.ts +0 -21
  319. package/dist/source/migrations.js +0 -103
  320. package/dist/source/next.d.ts +0 -32
  321. package/dist/source/next.js +0 -25
  322. package/dist/source/pushQueue.d.ts +0 -134
  323. package/dist/source/pushQueue.js +0 -256
  324. package/dist/source/signing.d.ts +0 -92
  325. package/dist/source/signing.js +0 -162
  326. package/dist/source/types.d.ts +0 -401
  327. package/dist/source/types.js +0 -59
  328. package/dist/stores/ObjectStore.d.ts +0 -115
  329. package/dist/stores/ObjectStore.js +0 -393
  330. package/dist/stores/ObjectStoreContract.d.ts +0 -38
  331. package/dist/stores/ObjectStoreContract.js +0 -1
  332. package/dist/stores/SyncActionStore.d.ts +0 -97
  333. package/dist/stores/SyncActionStore.js +0 -504
  334. package/dist/stores/syncAction.d.ts +0 -26
  335. package/dist/stores/syncAction.js +0 -16
  336. package/dist/surface.d.ts +0 -36
  337. package/dist/surface.js +0 -75
  338. package/dist/sync/BootstrapFetcher.d.ts +0 -280
  339. package/dist/sync/BootstrapFetcher.js +0 -962
  340. package/dist/sync/ConnectionManager.d.ts +0 -8
  341. package/dist/sync/ConnectionManager.js +0 -8
  342. package/dist/sync/OnDemandLoader.d.ts +0 -228
  343. package/dist/sync/OnDemandLoader.js +0 -742
  344. package/dist/sync/SubscriptionManager.d.ts +0 -159
  345. package/dist/sync/SubscriptionManager.js +0 -243
  346. package/dist/sync/SyncWebSocket.d.ts +0 -173
  347. package/dist/sync/SyncWebSocket.js +0 -438
  348. package/dist/sync/awaitClaimGrant.d.ts +0 -6
  349. package/dist/sync/awaitClaimGrant.js +0 -6
  350. package/dist/sync/bootstrapApply.d.ts +0 -70
  351. package/dist/sync/bootstrapApply.js +0 -73
  352. package/dist/sync/commitFrames.d.ts +0 -8
  353. package/dist/sync/commitFrames.js +0 -8
  354. package/dist/sync/contextPorts.d.ts +0 -18
  355. package/dist/sync/contextPorts.js +0 -31
  356. package/dist/sync/createClaimStream.d.ts +0 -7
  357. package/dist/sync/createClaimStream.js +0 -7
  358. package/dist/sync/createPresenceStream.d.ts +0 -69
  359. package/dist/sync/createPresenceStream.js +0 -200
  360. package/dist/sync/createSnapshot.d.ts +0 -29
  361. package/dist/sync/createSnapshot.js +0 -118
  362. package/dist/sync/credentialLifecycle.d.ts +0 -7
  363. package/dist/sync/credentialLifecycle.js +0 -7
  364. package/dist/sync/deltaPipeline.d.ts +0 -113
  365. package/dist/sync/deltaPipeline.js +0 -261
  366. package/dist/sync/groupChange.d.ts +0 -113
  367. package/dist/sync/groupChange.js +0 -242
  368. package/dist/sync/participants.d.ts +0 -115
  369. package/dist/sync/participants.js +0 -344
  370. package/dist/sync/persistedPrefix.d.ts +0 -12
  371. package/dist/sync/persistedPrefix.js +0 -22
  372. package/dist/sync/schemaDrift.d.ts +0 -55
  373. package/dist/sync/schemaDrift.js +0 -53
  374. package/dist/sync/schemas.d.ts +0 -70
  375. package/dist/sync/schemas.js +0 -94
  376. package/dist/sync/syncCursor.d.ts +0 -40
  377. package/dist/sync/syncCursor.js +0 -55
  378. package/dist/sync/syncPlan.d.ts +0 -54
  379. package/dist/sync/syncPlan.js +0 -50
  380. package/dist/sync/wsFrameHandlers.d.ts +0 -8
  381. package/dist/sync/wsFrameHandlers.js +0 -8
  382. package/dist/testing/fixtures/bootstrap.d.ts +0 -49
  383. package/dist/testing/fixtures/bootstrap.js +0 -59
  384. package/dist/testing/fixtures/deltas.d.ts +0 -83
  385. package/dist/testing/fixtures/deltas.js +0 -136
  386. package/dist/testing/fixtures/httpResponses.d.ts +0 -70
  387. package/dist/testing/fixtures/httpResponses.js +0 -90
  388. package/dist/testing/fixtures/models.d.ts +0 -83
  389. package/dist/testing/fixtures/models.js +0 -272
  390. package/dist/testing/helpers/reactWrapper.d.ts +0 -69
  391. package/dist/testing/helpers/reactWrapper.js +0 -67
  392. package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
  393. package/dist/testing/helpers/syncEngineHarness.js +0 -73
  394. package/dist/testing/helpers/wait.d.ts +0 -30
  395. package/dist/testing/helpers/wait.js +0 -49
  396. package/dist/testing/index.d.ts +0 -23
  397. package/dist/testing/index.js +0 -33
  398. package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
  399. package/dist/testing/mocks/FakeDatabase.js +0 -10
  400. package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
  401. package/dist/testing/mocks/MockMutationExecutor.js +0 -186
  402. package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
  403. package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
  404. package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
  405. package/dist/testing/mocks/MockSyncContext.js +0 -72
  406. package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
  407. package/dist/testing/mocks/MockSyncStore.js +0 -171
  408. package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
  409. package/dist/testing/mocks/MockWebSocket.js +0 -118
  410. package/dist/transaction/ablo.d.ts +0 -88
  411. package/dist/transaction/ablo.js +0 -33
  412. package/dist/transaction/auth/apiKey.d.ts +0 -152
  413. package/dist/transaction/auth/apiKey.js +0 -419
  414. package/dist/transaction/auth/bootstrapScope.d.ts +0 -15
  415. package/dist/transaction/auth/bootstrapScope.js +0 -1
  416. package/dist/transaction/auth/capability.d.ts +0 -177
  417. package/dist/transaction/auth/capability.js +0 -199
  418. package/dist/transaction/auth/credentialEndpoint.d.ts +0 -61
  419. package/dist/transaction/auth/credentialEndpoint.js +0 -86
  420. package/dist/transaction/auth/credentialPolicy.d.ts +0 -148
  421. package/dist/transaction/auth/credentialPolicy.js +0 -125
  422. package/dist/transaction/auth/credentialSource.d.ts +0 -30
  423. package/dist/transaction/auth/credentialSource.js +0 -55
  424. package/dist/transaction/auth/hostedEndpoints.d.ts +0 -21
  425. package/dist/transaction/auth/hostedEndpoints.js +0 -21
  426. package/dist/transaction/auth/identity.d.ts +0 -55
  427. package/dist/transaction/auth/identity.js +0 -210
  428. package/dist/transaction/auth/index.d.ts +0 -162
  429. package/dist/transaction/auth/index.js +0 -304
  430. package/dist/transaction/auth/schemas.d.ts +0 -59
  431. package/dist/transaction/auth/schemas.js +0 -85
  432. package/dist/transaction/auth/sessionMint.d.ts +0 -28
  433. package/dist/transaction/auth/sessionMint.js +0 -85
  434. package/dist/transaction/coordination/awaitClaimGrant.d.ts +0 -49
  435. package/dist/transaction/coordination/awaitClaimGrant.js +0 -112
  436. package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +0 -50
  437. package/dist/transaction/coordination/claimHeartbeatLoop.js +0 -88
  438. package/dist/transaction/coordination/claimMeta.d.ts +0 -49
  439. package/dist/transaction/coordination/claimMeta.js +0 -52
  440. package/dist/transaction/coordination/createClaimStream.d.ts +0 -64
  441. package/dist/transaction/coordination/createClaimStream.js +0 -475
  442. package/dist/transaction/coordination/events.d.ts +0 -74
  443. package/dist/transaction/coordination/events.js +0 -7
  444. package/dist/transaction/coordination/index.d.ts +0 -19
  445. package/dist/transaction/coordination/index.js +0 -44
  446. package/dist/transaction/coordination/locator.d.ts +0 -83
  447. package/dist/transaction/coordination/locator.js +0 -82
  448. package/dist/transaction/coordination/schema.d.ts +0 -1473
  449. package/dist/transaction/coordination/schema.js +0 -1013
  450. package/dist/transaction/coordination/targetConflict.d.ts +0 -2
  451. package/dist/transaction/coordination/targetConflict.js +0 -103
  452. package/dist/transaction/coordination/trace.d.ts +0 -78
  453. package/dist/transaction/coordination/trace.js +0 -138
  454. package/dist/transaction/durableWrites.d.ts +0 -62
  455. package/dist/transaction/durableWrites.js +0 -71
  456. package/dist/transaction/environment.d.ts +0 -105
  457. package/dist/transaction/environment.js +0 -108
  458. package/dist/transaction/errorCodes.d.ts +0 -403
  459. package/dist/transaction/errorCodes.js +0 -480
  460. package/dist/transaction/errors.d.ts +0 -428
  461. package/dist/transaction/errors.js +0 -686
  462. package/dist/transaction/index.d.ts +0 -20
  463. package/dist/transaction/index.js +0 -20
  464. package/dist/transaction/keys/index.d.ts +0 -87
  465. package/dist/transaction/keys/index.js +0 -207
  466. package/dist/transaction/log/syncDeltaRow.d.ts +0 -158
  467. package/dist/transaction/log/syncDeltaRow.js +0 -95
  468. package/dist/transaction/logPosition.d.ts +0 -97
  469. package/dist/transaction/logPosition.js +0 -125
  470. package/dist/transaction/logger.d.ts +0 -16
  471. package/dist/transaction/logger.js +0 -7
  472. package/dist/transaction/observability.d.ts +0 -53
  473. package/dist/transaction/observability.js +0 -19
  474. package/dist/transaction/persistence.d.ts +0 -12
  475. package/dist/transaction/persistence.js +0 -11
  476. package/dist/transaction/plugin.d.ts +0 -192
  477. package/dist/transaction/plugin.js +0 -87
  478. package/dist/transaction/policy/types.d.ts +0 -217
  479. package/dist/transaction/policy/types.js +0 -126
  480. package/dist/transaction/resources/functionalUpdate.d.ts +0 -79
  481. package/dist/transaction/resources/functionalUpdate.js +0 -87
  482. package/dist/transaction/resources/httpResources.d.ts +0 -266
  483. package/dist/transaction/resources/httpResources.js +0 -7
  484. package/dist/transaction/resources/modelOperations.d.ts +0 -319
  485. package/dist/transaction/resources/modelOperations.js +0 -12
  486. package/dist/transaction/resources/mutationOptions.d.ts +0 -66
  487. package/dist/transaction/resources/mutationOptions.js +0 -9
  488. package/dist/transaction/resources/where.d.ts +0 -85
  489. package/dist/transaction/resources/where.js +0 -70
  490. package/dist/transaction/resources/writeOptionsSchema.d.ts +0 -47
  491. package/dist/transaction/resources/writeOptionsSchema.js +0 -73
  492. package/dist/transaction/schema/field.d.ts +0 -126
  493. package/dist/transaction/schema/field.js +0 -265
  494. package/dist/transaction/schema/loadStrategy.d.ts +0 -45
  495. package/dist/transaction/schema/loadStrategy.js +0 -46
  496. package/dist/transaction/schema/model.d.ts +0 -379
  497. package/dist/transaction/schema/model.js +0 -123
  498. package/dist/transaction/schema/openapi.d.ts +0 -57
  499. package/dist/transaction/schema/openapi.js +0 -340
  500. package/dist/transaction/schema/relation.d.ts +0 -199
  501. package/dist/transaction/schema/relation.js +0 -104
  502. package/dist/transaction/schema/residency.d.ts +0 -29
  503. package/dist/transaction/schema/residency.js +0 -25
  504. package/dist/transaction/schema/roles.d.ts +0 -249
  505. package/dist/transaction/schema/roles.js +0 -230
  506. package/dist/transaction/schema/schema.d.ts +0 -324
  507. package/dist/transaction/schema/schema.js +0 -305
  508. package/dist/transaction/schema/tenancy.d.ts +0 -139
  509. package/dist/transaction/schema/tenancy.js +0 -190
  510. package/dist/transaction/transactionLayer.d.ts +0 -82
  511. package/dist/transaction/transactionLayer.js +0 -24
  512. package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +0 -143
  513. package/dist/transaction/transactions/settlement/commitEnvelope.js +0 -161
  514. package/dist/transaction/transactions/settlement/httpCommitEnvelope.d.ts +0 -53
  515. package/dist/transaction/transactions/settlement/httpCommitEnvelope.js +0 -207
  516. package/dist/transaction/transactions/settlement/idempotencyKey.d.ts +0 -10
  517. package/dist/transaction/transactions/settlement/idempotencyKey.js +0 -9
  518. package/dist/transaction/transactions/settlement/pendingWrite.d.ts +0 -112
  519. package/dist/transaction/transactions/settlement/pendingWrite.js +0 -20
  520. package/dist/transaction/transport/commitFrames.d.ts +0 -90
  521. package/dist/transaction/transport/commitFrames.js +0 -134
  522. package/dist/transaction/transport/connectionManager.d.ts +0 -215
  523. package/dist/transaction/transport/connectionManager.js +0 -673
  524. package/dist/transaction/transport/credentialLifecycle.d.ts +0 -177
  525. package/dist/transaction/transport/credentialLifecycle.js +0 -324
  526. package/dist/transaction/transport/heartbeat.d.ts +0 -65
  527. package/dist/transaction/transport/heartbeat.js +0 -93
  528. package/dist/transaction/transport/httpClient.d.ts +0 -123
  529. package/dist/transaction/transport/httpClient.js +0 -145
  530. package/dist/transaction/transport/httpOptions.d.ts +0 -33
  531. package/dist/transaction/transport/httpOptions.js +0 -12
  532. package/dist/transaction/transport/httpTransport.d.ts +0 -8
  533. package/dist/transaction/transport/httpTransport.js +0 -1276
  534. package/dist/transaction/transport/networkProbe.d.ts +0 -84
  535. package/dist/transaction/transport/networkProbe.js +0 -207
  536. package/dist/transaction/transport/wsFrameHandlers.d.ts +0 -128
  537. package/dist/transaction/transport/wsFrameHandlers.js +0 -429
  538. package/dist/transaction/transport/wsTransport.d.ts +0 -576
  539. package/dist/transaction/transport/wsTransport.js +0 -1017
  540. package/dist/transaction/types/assertExact.d.ts +0 -17
  541. package/dist/transaction/types/assertExact.js +0 -1
  542. package/dist/transaction/types/global.d.ts +0 -107
  543. package/dist/transaction/types/global.js +0 -40
  544. package/dist/transaction/types/index.d.ts +0 -205
  545. package/dist/transaction/types/index.js +0 -56
  546. package/dist/transaction/types/modelData.d.ts +0 -10
  547. package/dist/transaction/types/modelData.js +0 -9
  548. package/dist/transaction/types/participant.d.ts +0 -20
  549. package/dist/transaction/types/participant.js +0 -10
  550. package/dist/transaction/types/streams.d.ts +0 -540
  551. package/dist/transaction/types/streams.js +0 -11
  552. package/dist/transaction/utils/asyncIterator.d.ts +0 -34
  553. package/dist/transaction/utils/asyncIterator.js +0 -135
  554. package/dist/transaction/utils/duration.d.ts +0 -25
  555. package/dist/transaction/utils/duration.js +0 -45
  556. package/dist/transaction/utils/json.d.ts +0 -57
  557. package/dist/transaction/utils/json.js +0 -276
  558. package/dist/transaction/wire/accountResponses.d.ts +0 -351
  559. package/dist/transaction/wire/accountResponses.js +0 -255
  560. package/dist/transaction/wire/auth.d.ts +0 -49
  561. package/dist/transaction/wire/auth.js +0 -57
  562. package/dist/transaction/wire/bootstrapReason.d.ts +0 -9
  563. package/dist/transaction/wire/bootstrapReason.js +0 -8
  564. package/dist/transaction/wire/claimEvent.d.ts +0 -76
  565. package/dist/transaction/wire/claimEvent.js +0 -73
  566. package/dist/transaction/wire/claims.d.ts +0 -463
  567. package/dist/transaction/wire/claims.js +0 -229
  568. package/dist/transaction/wire/commit.d.ts +0 -603
  569. package/dist/transaction/wire/commit.js +0 -321
  570. package/dist/transaction/wire/delta.d.ts +0 -250
  571. package/dist/transaction/wire/delta.js +0 -147
  572. package/dist/transaction/wire/errorEnvelope.d.ts +0 -72
  573. package/dist/transaction/wire/errorEnvelope.js +0 -123
  574. package/dist/transaction/wire/feedCursor.d.ts +0 -60
  575. package/dist/transaction/wire/feedCursor.js +0 -82
  576. package/dist/transaction/wire/feedEvent.d.ts +0 -177
  577. package/dist/transaction/wire/feedEvent.js +0 -39
  578. package/dist/transaction/wire/frames.d.ts +0 -194
  579. package/dist/transaction/wire/frames.js +0 -50
  580. package/dist/transaction/wire/inboundFrames.d.ts +0 -552
  581. package/dist/transaction/wire/inboundFrames.js +0 -116
  582. package/dist/transaction/wire/index.d.ts +0 -50
  583. package/dist/transaction/wire/index.js +0 -74
  584. package/dist/transaction/wire/listEnvelope.d.ts +0 -37
  585. package/dist/transaction/wire/listEnvelope.js +0 -42
  586. package/dist/transaction/wire/modelResponses.d.ts +0 -85
  587. package/dist/transaction/wire/modelResponses.js +0 -43
  588. package/dist/transaction/wire/protocol.d.ts +0 -38
  589. package/dist/transaction/wire/protocol.js +0 -38
  590. package/dist/transaction/wire/protocolVersion.d.ts +0 -73
  591. package/dist/transaction/wire/protocolVersion.js +0 -83
  592. package/dist/transactions/mutations/MutationQueue.d.ts +0 -655
  593. package/dist/transactions/mutations/MutationQueue.js +0 -2797
  594. package/dist/transactions/mutations/MutationStore.d.ts +0 -20
  595. package/dist/transactions/mutations/MutationStore.js +0 -53
  596. package/dist/transactions/mutations/UnconfirmedWrites.d.ts +0 -82
  597. package/dist/transactions/mutations/UnconfirmedWrites.js +0 -104
  598. package/dist/transactions/mutations/coalesceRules.d.ts +0 -58
  599. package/dist/transactions/mutations/coalesceRules.js +0 -140
  600. package/dist/transactions/mutations/commitLatency.d.ts +0 -52
  601. package/dist/transactions/mutations/commitLatency.js +0 -130
  602. package/dist/transactions/mutations/commitOutboxStore.d.ts +0 -28
  603. package/dist/transactions/mutations/commitOutboxStore.js +0 -26
  604. package/dist/transactions/mutations/commitPayload.d.ts +0 -164
  605. package/dist/transactions/mutations/commitPayload.js +0 -152
  606. package/dist/transactions/mutations/deltaConfirmation.d.ts +0 -59
  607. package/dist/transactions/mutations/deltaConfirmation.js +0 -233
  608. package/dist/transactions/mutations/durableWriteStore.d.ts +0 -14
  609. package/dist/transactions/mutations/durableWriteStore.js +0 -12
  610. package/dist/transactions/mutations/optimisticApply.d.ts +0 -49
  611. package/dist/transactions/mutations/optimisticApply.js +0 -65
  612. package/dist/transactions/mutations/replayValidation.d.ts +0 -186
  613. package/dist/transactions/mutations/replayValidation.js +0 -163
  614. package/dist/utils/mobxSetup.d.ts +0 -53
  615. package/dist/utils/mobxSetup.js +0 -330
  616. package/dist/webhooks/events.d.ts +0 -43
  617. package/dist/webhooks/events.js +0 -42
  618. package/dist/webhooks/index.d.ts +0 -8
  619. package/dist/webhooks/index.js +0 -8
  620. package/dist/wire/index.d.ts +0 -1
  621. package/dist/wire/index.js +0 -8
  622. package/docs/interaction-model.md +0 -99
@@ -1,2199 +0,0 @@
1
- /**
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 MutationQueue} that delivers writes
8
- * over the network.
9
- */
10
- import { runInAction } from 'mobx';
11
- import { v4 as uuid } from 'uuid';
12
- import { InstanceCache, ModelScope } from './InstanceCache.js';
13
- import { Model } from './Model.js';
14
- import { snapshotJsonValue } from './transaction/utils/json.js';
15
- // ModelRegistry instance accessed via this.objectPool.registry
16
- import { LoadStrategy } from './transaction/types/index.js';
17
- import { getContext } from './context.js';
18
- import { AbloAuthenticationError, AbloError, AbloValidationError } from './transaction/errors.js';
19
- import { EventEmitter } from 'events';
20
- import { NetworkMonitor } from './NetworkMonitor.js';
21
- import { MutationQueue } from './transactions/mutations/MutationQueue.js';
22
- import { observeCommitLatency, } from './transactions/mutations/commitLatency.js';
23
- import { legacyPendingMutationRecordSchema, PENDING_MUTATION_REPLAY_WINDOW_MS, pendingMutationRecordId, pendingMutationRecordSchema, persistedMutationSchema, } from './transactions/mutations/replayValidation.js';
24
- import { UnconfirmedWrites, } from './transactions/mutations/UnconfirmedWrites.js';
25
- import { LogPosition } from './transaction/logPosition.js';
26
- import { DatabaseCommitOutboxStore, } from './transactions/mutations/commitOutboxStore.js';
27
- /**
28
- * Reports whether an incoming snapshot record is strictly newer than the
29
- * model already in the pool. The comparison uses the server-stamped
30
- * `updatedAt` timestamp, since rows carry no numeric version and the delta
31
- * pipeline resolves order by arrival (last write wins). An undefined incoming
32
- * timestamp counts as not newer, so a known row is never clobbered; an
33
- * undefined existing timestamp means the pooled row is unversioned, so the
34
- * incoming record wins. The scoped hydrate-on-enter path uses this to drop
35
- * snapshot rows that a live delta has already advanced past.
36
- */
37
- function rawRecordIsNewer(data, existing) {
38
- const raw = data.updatedAt;
39
- const inMs = raw instanceof Date
40
- ? raw.getTime()
41
- : typeof raw === 'string'
42
- ? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
43
- : typeof raw === 'number'
44
- ? raw
45
- : undefined;
46
- const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
47
- if (inMs === undefined)
48
- return false;
49
- if (exMs === undefined)
50
- return true;
51
- return inMs > exMs;
52
- }
53
- /**
54
- * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
55
- * or Date read off an untyped row — into epoch milliseconds for
56
- * last-write-wins comparison. Falsy or non-date values become 0, matching the
57
- * conflict resolver's rule that a missing timestamp sorts as the epoch.
58
- */
59
- function toEpochMs(value) {
60
- if (!value)
61
- return 0;
62
- if (value instanceof Date)
63
- return value.getTime();
64
- if (typeof value === 'string' || typeof value === 'number') {
65
- return new Date(value).getTime();
66
- }
67
- return 0;
68
- }
69
- export class SyncClient extends EventEmitter {
70
- objectPool;
71
- database;
72
- get mutationExecutor() { return getContext().mutationExecutor; }
73
- networkMonitor;
74
- /**
75
- * @internal — test seam, stripped from the published declarations by
76
- * `stripInternal`. Unit suites deliver queue lifecycle events directly.
77
- */
78
- mutationQueue;
79
- observers = new Set();
80
- // Authentication context
81
- userId = null;
82
- organizationId = null;
83
- // Pending mutations queue
84
- pendingMutations = [];
85
- stagedMutationIds = new Set();
86
- pendingJournalBatch = [];
87
- journalFlushScheduled = false;
88
- commitOutboxNamespace;
89
- /**
90
- * Tracks the ids of transactions the client has applied optimistically but
91
- * the server has not yet confirmed. When a delta arrives, the receive path
92
- * consults this set to recognize the echo of the client's own mutation and
93
- * skip the now-redundant pool update; the IndexedDB write still runs,
94
- * because the delta is the authoritative version of the row. Without this
95
- * discriminator, an optimistically applied delete followed by a
96
- * server-confirmed create echo would resurrect the row for the window
97
- * between the two confirmations.
98
- *
99
- * The set is bounded with first-in-first-out eviction, and
100
- * {@link SyncClient.getEchoMetrics} exposes its counters.
101
- */
102
- echoTracker = new UnconfirmedWrites();
103
- // Connection state
104
- connectionState = 'disconnected';
105
- // Configuration
106
- isDisposed = false;
107
- /**
108
- * The client's position in the global delta order, held as the single
109
- * canonical {@link LogPosition} instance. The store advances `applied` and
110
- * `persisted` as deltas land, the queue advances `acked` on commit
111
- * responses, and snapshots and claims read `readFloor`.
112
- */
113
- position = new LogPosition();
114
- constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
115
- super();
116
- this.objectPool = objectPool;
117
- this.database = database;
118
- this.commitOutboxNamespace = commitOutboxNamespace;
119
- this.networkMonitor = new NetworkMonitor();
120
- // Initialize MutationQueue with proper configuration
121
- this.mutationQueue = new MutationQueue({
122
- position: this.position,
123
- maxBatchSize: 50, // Larger batches keep the batch count low for bulk operations
124
- // A short delay keeps writes responsive; coalescing still groups them
125
- batchDelay: 150,
126
- maxRetries: 3,
127
- enableOptimistic: true,
128
- enablePersistence: true,
129
- conflictResolution: {
130
- strategy: 'last-write-wins',
131
- },
132
- });
133
- this.mutationQueue.setCommitOutbox(commitOutbox);
134
- this.mutationQueue.on('commit:envelope_persisted', (event) => {
135
- if (event.sourceMutationIds.length === 0)
136
- return;
137
- const consumed = new Set(event.sourceMutationIds);
138
- this.pendingMutations = this.pendingMutations.filter((mutation) => !consumed.has(mutation.mutationId));
139
- for (const mutationId of consumed)
140
- this.stagedMutationIds.delete(mutationId);
141
- if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
142
- this.scheduleSync();
143
- }
144
- });
145
- this.mutationQueue.on('transaction:completed', (transaction) => {
146
- const completed = new Set(transaction.sourceMutationIds ?? []);
147
- if (completed.size > 0) {
148
- this.pendingMutations = this.pendingMutations.filter((mutation) => !completed.has(mutation.mutationId));
149
- }
150
- for (const mutationId of transaction.sourceMutationIds ?? []) {
151
- this.stagedMutationIds.delete(mutationId);
152
- void this.database
153
- .removeTransaction(pendingMutationRecordId(mutationId))
154
- .catch(() => undefined);
155
- }
156
- if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
157
- this.scheduleSync();
158
- }
159
- });
160
- this.mutationQueue.on('transaction:failed', ({ transaction }) => {
161
- const failed = transaction.sourceMutationIds ?? [];
162
- if (failed.length === 0)
163
- return;
164
- const failedSet = new Set(failed);
165
- this.pendingMutations = this.pendingMutations.filter((mutation) => !failedSet.has(mutation.mutationId));
166
- for (const mutationId of failed) {
167
- this.stagedMutationIds.delete(mutationId);
168
- // The queue has already rolled the model back, so replaying the
169
- // journal row on the next boot would resurrect a rejected write.
170
- void this.database
171
- .removeTransaction(pendingMutationRecordId(mutationId))
172
- .catch(() => undefined);
173
- }
174
- // Without this drain, terminally failed ids stayed claimed forever and
175
- // the size guard in processPendingMutations stalled every later write.
176
- if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
177
- this.scheduleSync();
178
- }
179
- });
180
- // Provide connection state to MutationQueue - prevents rollbacks during disconnection
181
- this.mutationQueue.setConnectionChecker(() => this.connectionState === 'connected');
182
- // Restore object-pool state when a transaction is rolled back. If the
183
- // server rejects a write or it times out, the model's previous state is
184
- // put back. Because writes are no longer applied to IndexedDB
185
- // optimistically, that store already holds the correct state.
186
- this.setupTransactionRollbackHandling();
187
- // Forward reconciliation requests from the transaction queue. When delta
188
- // confirmation times out, the client cycles the WebSocket connection to
189
- // trigger a catch-up from the server rather than rolling the write back.
190
- this.setupReconciliationForwarding();
191
- // Persist unconfirmed transactions to IndexedDB. When delta retries are
192
- // exhausted, the write is cached so it survives a tab close.
193
- this.setupAwaitingTransactionPersistence();
194
- // Setup network monitoring
195
- this.setupNetworkMonitoring();
196
- }
197
- /**
198
- * Setup network monitoring handlers
199
- */
200
- setupNetworkMonitoring() {
201
- // Both handlers emit to external listeners (which can throw) before/around
202
- // their own try/catch — route rejections into observability rather than
203
- // losing a failed reconnect flush silently.
204
- this.networkMonitor.on('online', () => {
205
- void this.handleReconnection().catch((error) => {
206
- getContext().observability.captureMutationFailure({
207
- context: 'network-online-reconnection',
208
- error: error instanceof Error ? error : new Error(String(error)),
209
- });
210
- });
211
- });
212
- this.networkMonitor.on('offline', () => {
213
- void this.handleDisconnection().catch((error) => {
214
- getContext().observability.captureMutationFailure({
215
- context: 'network-offline-handler',
216
- error: error instanceof Error ? error : new Error(String(error)),
217
- });
218
- });
219
- });
220
- }
221
- /**
222
- * Handle transaction rollback. Two distinct shapes flow through this
223
- * event:
224
- *
225
- * 1. **Server-rejected rollback** (`reason === 'permanent_error'`,
226
- * `'max_retries_exhausted'`, `'conflict_server_wins'`) — the
227
- * optimistic state is wrong, the row exists, restore previous
228
- * state and notify the UI.
229
- *
230
- * 2. **Local-cancellation cleanup** (`reason === 'model_cancelled'`,
231
- * `'cascade_parent_deleted'`) — the user deleted this model (or
232
- * its parent), so a pending UPDATE on it gets cancelled. There's
233
- * nothing to restore (the model is doomed) and no UI notification
234
- * needed (the delete itself already triggered re-renders). Just
235
- * discard the optimistic state silently.
236
- *
237
- * Treating both paths the same caused the deletion-flicker bug: every
238
- * cancelled update on a multi-child record fired a per-model observer
239
- * event and a `[SyncClient.rollback]` warn, producing N renders and N
240
- * spam log lines for one user-initiated delete.
241
- */
242
- setupTransactionRollbackHandling() {
243
- this.mutationQueue.on('optimistic:rollback', (event) => {
244
- const { model, previousState, transaction, reason, error } = event;
245
- // Local cleanup path — discard quietly. The optimistic state was
246
- // applied to a model that's already disposed by the cascading
247
- // delete, and emitting per-model observer events here would
248
- // re-render N times for one user-initiated cascade.
249
- if (reason === 'model_cancelled' || reason === 'cascade_parent_deleted') {
250
- return;
251
- }
252
- // Surface the typed AbloError fields directly — `type`/`code`/
253
- // `httpStatus`/`requestId` are what tell us the rollback cause
254
- // (e.g. `AbloValidationError` with `code: 'schema_...'`,
255
- // `AbloServerError` with `httpStatus: 500`). Falling back to
256
- // generic message lets us still see unstructured errors.
257
- // Mechanic-level breadcrumb only. The authoritative, user-facing
258
- // reason is logged once at `warn` by `MutationQueue.handleFailure`
259
- // (`Permanent error - rolling back`). Logging the same typed cause
260
- // again here at `warn` is what produced three identical dumps per
261
- // rejected write — keep it at `debug` so the rollback mechanics are
262
- // available when debugging but don't double the console noise.
263
- const abloErr = error instanceof AbloError ? error : undefined;
264
- getContext().logger.debug('[SyncClient.rollback]', {
265
- txType: transaction.type,
266
- modelName: transaction.modelName,
267
- modelId: transaction.modelId.slice(0, 12),
268
- reason: reason ?? 'unknown',
269
- errorType: abloErr?.type ?? error?.name,
270
- errorCode: abloErr?.code,
271
- httpStatus: abloErr?.httpStatus,
272
- requestId: abloErr?.requestId,
273
- message: error?.message,
274
- });
275
- getContext().observability.captureRollback({
276
- transactionType: transaction.type,
277
- modelName: transaction.modelName,
278
- modelId: transaction.modelId,
279
- reason: reason ?? 'unknown',
280
- error: error?.message,
281
- connectionState: this.connectionState,
282
- });
283
- try {
284
- if (transaction.type === 'create') {
285
- // CREATE rollback: remove the optimistically created entity
286
- this.objectPool.remove(transaction.modelId);
287
- }
288
- else if (transaction.type === 'delete' &&
289
- reason === 'permanent_error' &&
290
- error?.message?.includes('not found')) {
291
- // DELETE "not found" rollback: the entity doesn't exist on the server.
292
- // Instead of restoring a ghost entity, remove it locally too.
293
- // Both sides agree: this entity should not exist.
294
- getContext().observability.breadcrumb('DELETE rolled back with "not found" - removing ghost entity', 'sync.conflict', 'info', {
295
- modelId: transaction.modelId,
296
- modelName: transaction.modelName,
297
- });
298
- this.objectPool.remove(transaction.modelId);
299
- }
300
- else if (model) {
301
- // For update/delete/archive: restore model (with previousState if available)
302
- // Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
303
- // cascade), don't re-add it — Object.assign cannot restore the private
304
- // isDisposed flag, so the model would be added in a broken state.
305
- if (model.disposed) {
306
- // Follow-on of an already-logged permanent error, not its own
307
- // problem: the tx that failed has already surfaced the cause in
308
- // MutationQueue. Restoring a disposed model is a no-op by
309
- // design (can't revive the private isDisposed flag), so keep this
310
- // at `debug` instead of emitting a second `warn` that reads as a
311
- // distinct failure in the console.
312
- getContext().logger.debug('[SyncClient] Rollback skipped restore (model already disposed)', {
313
- modelId: transaction.modelId,
314
- modelName: transaction.modelName,
315
- reason,
316
- });
317
- }
318
- else {
319
- if (previousState)
320
- Object.assign(model, previousState);
321
- this.objectPool.add(model, ModelScope.live);
322
- }
323
- }
324
- this.notifyObservers({
325
- type: 'rollback',
326
- modelType: transaction.modelName,
327
- modelId: transaction.modelId,
328
- transactionType: transaction.type,
329
- });
330
- // Emit event so SyncedStore can clear pendingDeletes on delete rollback
331
- this.emit('sync:rollback', {
332
- modelId: transaction.modelId,
333
- modelName: transaction.modelName,
334
- transactionType: transaction.type,
335
- reason,
336
- });
337
- }
338
- catch (error) {
339
- getContext().observability.captureMutationFailure({
340
- context: 'rollback-failed',
341
- transactionId: transaction.id,
342
- modelName: transaction.modelName,
343
- modelId: transaction.modelId,
344
- error: error instanceof Error ? error : new Error(String(error)),
345
- });
346
- }
347
- });
348
- }
349
- /**
350
- * Forward reconciliation requests from the {@link MutationQueue} to the
351
- * sync layer. When delta confirmation times out, the queue emits
352
- * `reconciliation:needed` instead of rolling back, so optimistic state the
353
- * server may already have committed is never destroyed.
354
- */
355
- setupReconciliationForwarding() {
356
- this.mutationQueue.on('reconciliation:needed', (event) => {
357
- getContext().observability.captureReconciliation({
358
- reason: event.reason,
359
- model: event.model,
360
- modelId: event.modelId,
361
- syncIdNeeded: event.syncIdNeeded,
362
- lastSeenSyncId: event.lastSeenSyncId,
363
- retryCount: event.retryCount,
364
- connectionState: this.connectionState,
365
- });
366
- // Forward to SyncedStore via event — it has access to the WebSocket
367
- this.emit('reconciliation:needed', event);
368
- });
369
- }
370
- /**
371
- * Persist unconfirmed transactions to IndexedDB. When delta-confirmation
372
- * retries are exhausted, the transaction is cached so it survives a tab
373
- * close. On the next session, a WebSocket reconnect and delta catch-up
374
- * deliver the missing deltas and confirm the transaction.
375
- */
376
- setupAwaitingTransactionPersistence() {
377
- this.mutationQueue.on('transaction:persist_awaiting', (event) => {
378
- // void is safe: the handler's body is fully try/catch'd.
379
- void this.persistAwaitingTransaction(event);
380
- });
381
- // Clean up persisted awaiting transactions when they're finally confirmed
382
- this.mutationQueue.on('transaction:completed', (tx) => {
383
- // void is safe: the handler's body is fully try/catch'd.
384
- void this.removeAwaitingTransaction(tx.id);
385
- });
386
- // Echo detection bridge. When the queue stages a transaction, the
387
- // client has already optimistically applied the change to the
388
- // pool — record the tx id so the matching server delta echo gets
389
- // recognized in `applyDeltaBatchToPool`. The set is drained when
390
- // the echo lands; if a transaction is rolled back before the
391
- // server processes it, we drain on rollback too so a stale id
392
- // doesn't permanently silence a foreign delta sharing the same id
393
- // (vanishingly unlikely for UUIDs, but cheap insurance).
394
- this.mutationQueue.on('transaction:created', (tx) => {
395
- if (!tx.localOnly)
396
- this.echoTracker.markPending(tx.id);
397
- });
398
- this.mutationQueue.on('optimistic:rollback', (event) => {
399
- this.echoTracker.drainOnRollback(event.transaction.id);
400
- });
401
- }
402
- /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
403
- async persistAwaitingTransaction(event) {
404
- if (!this.database)
405
- return;
406
- try {
407
- await this.database.saveTransaction({
408
- id: `awaiting_${event.txId}`,
409
- type: 'awaiting_delta',
410
- timestamp: Date.now(),
411
- awaitingDelta: {
412
- syncIdNeeded: event.syncIdNeeded ?? 0,
413
- modelName: event.model,
414
- modelId: event.modelId,
415
- operationType: event.operationType,
416
- },
417
- });
418
- getContext().observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
419
- txId: event.txId,
420
- model: event.model,
421
- modelId: event.modelId,
422
- });
423
- }
424
- catch (error) {
425
- getContext().observability.captureMutationFailure({
426
- context: 'persist-awaiting-transaction',
427
- modelName: event.model,
428
- modelId: event.modelId,
429
- error: error instanceof Error ? error : new Error(String(error)),
430
- });
431
- }
432
- }
433
- /** Drop the persisted awaiting-row once confirmed (never rejects). */
434
- async removeAwaitingTransaction(txId) {
435
- if (!this.database)
436
- return;
437
- try {
438
- await this.database.removeTransaction(`awaiting_${txId}`);
439
- }
440
- catch {
441
- // Ignore — might not have been persisted
442
- }
443
- }
444
- /**
445
- * Initialize sync client with authentication
446
- */
447
- async initialize(userId, organizationId) {
448
- this.userId = userId;
449
- this.organizationId = organizationId;
450
- getContext().observability.setContext(userId, organizationId);
451
- this.mutationQueue.setCommitOutboxScope({
452
- organizationId,
453
- participantId: userId,
454
- namespace: this.commitOutboxNamespace,
455
- });
456
- // Calls made during startup are allowed to queue before identity arrives,
457
- // but they cannot be serialized with a trustworthy scope until now. Flush
458
- // those already-created journal promises before any write can be staged.
459
- if (this.pendingJournalBatch.length > 0) {
460
- this.scheduleJournalFlush();
461
- await Promise.all(this.pendingMutations.map((mutation) => mutation.journaled));
462
- }
463
- // Restore exact, already-sealed requests first. The returned source ids
464
- // suppress any legacy queue entry left behind by an older non-atomic
465
- // handoff.
466
- const sealedMutationIds = await this.mutationQueue.restoreDurableCommits();
467
- await this.restoreMutationQueue(sealedMutationIds);
468
- // Read the initial network status from the injected OnlineStatusProvider.
469
- // In the browser this reflects the host's connectivity signal; in Node it
470
- // reports online by default. NetworkMonitor drives the ongoing
471
- // online/offline transitions below — this read is only the initial
472
- // snapshot taken when identity is set.
473
- if (getContext().onlineStatus.isOnline()) {
474
- this.setConnectionState('connected');
475
- }
476
- else {
477
- // Offline - start in offline mode
478
- this.setConnectionState('disconnected');
479
- this.emit('sync:offline');
480
- }
481
- if (this.pendingMutations.length > 0)
482
- this.scheduleSync();
483
- }
484
- /**
485
- * The organization this client writes under (set by `initialize`).
486
- * Read by the model proxy so `create()` defaults `organizationId` the
487
- * same way the mutator path does — `null` until identity is wired.
488
- */
489
- getOrganizationId() {
490
- return this.organizationId;
491
- }
492
- /**
493
- * Self-healing helper for individual model records.
494
- *
495
- * Two registry-driven repair passes run on every row hydrated from
496
- * IndexedDB or merged from a delta:
497
- *
498
- * 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
499
- * declares on this model, copy the corresponding identity value
500
- * (`organizationId` / `userId`) onto the row when it's missing.
501
- * Repairs rows from a past version that didn't write the field.
502
- *
503
- * 2. **Required-field gate** — if the row is missing any field listed
504
- * in the model's `requiredFields`, return `null` so the caller
505
- * skips this record. Used for FK columns whose absence renders the
506
- * row unrecoverable (e.g. a Block with no sectionId).
507
- *
508
- * The engine itself is product-neutral: model identity (which fields
509
- * to back-fill, which absences are fatal) lives entirely in the
510
- * consumer schema.
511
- */
512
- healModelRecord(modelType, data) {
513
- const meta = this.objectPool.registry.getMetadata(modelType);
514
- if (!meta)
515
- return { data, healed: false };
516
- const idPrefix = data.id?.slice(0, 8) ?? 'unknown';
517
- let result = data;
518
- let healed = false;
519
- if (meta.autoFill) {
520
- for (const rule of meta.autoFill) {
521
- if (result[rule.field])
522
- continue;
523
- const replacement = rule.from === 'organizationId' ? this.organizationId : this.userId;
524
- if (!replacement)
525
- continue;
526
- getContext().observability.captureSelfHealing({
527
- modelName: modelType,
528
- modelId: idPrefix,
529
- field: rule.field,
530
- action: `added missing ${rule.field}`,
531
- });
532
- result = { ...result, [rule.field]: replacement };
533
- healed = true;
534
- }
535
- }
536
- if (meta.requiredFields) {
537
- for (const field of meta.requiredFields) {
538
- if (result[field])
539
- continue;
540
- getContext().observability.captureSelfHealing({
541
- modelName: modelType,
542
- modelId: idPrefix,
543
- field,
544
- action: `skipped corrupted ${modelType} - missing ${field}`,
545
- });
546
- return null;
547
- }
548
- }
549
- return { data: result, healed };
550
- }
551
- /**
552
- * Hydrate InstanceCache with data from Database
553
- * Called after bootstrap is complete
554
- */
555
- async hydrateFromDatabase() {
556
- if (!this.database) {
557
- throw new AbloValidationError('Database not available for hydration', {
558
- code: 'sync_client_db_missing',
559
- });
560
- }
561
- // Get model types that should be hydrated on startup (skip lazy per LSE)
562
- const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
563
- const meta = this.objectPool.registry.getMetadata(name);
564
- return meta?.loadStrategy === LoadStrategy.instant;
565
- });
566
- const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
567
- // Phase 1: Fetch all data from IndexedDB and create model instances (async I/O).
568
- // We collect all models across ALL types before touching MobX, so that Phase 2
569
- // can add them in a single addBatch() call → ONE MobX action → ONE re-render.
570
- const allModelsToAdd = [];
571
- const perTypePerfLogs = [];
572
- for (const modelType of modelTypes) {
573
- const typeStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
574
- try {
575
- // Get raw data from Database (via StoreManager)
576
- const rawData = await this.database.hydrateModels(modelType);
577
- const afterFetch = typeof performance !== 'undefined' ? performance.now() : Date.now();
578
- // Create models in batch first, collect for deferred addBatch
579
- const modelsForType = [];
580
- const recordsToHeal = [];
581
- for (const data of rawData) {
582
- let withType = data && typeof data === 'object' && !data.__typename
583
- ? { __typename: modelType, ...data }
584
- : data;
585
- // Self-healing: Fix corrupted IndexedDB records missing essential fields
586
- const healResult = this.healModelRecord(modelType, withType);
587
- if (healResult === null) {
588
- continue; // Record is corrupted beyond repair — skip
589
- }
590
- withType = healResult.data;
591
- if (healResult.healed) {
592
- recordsToHeal.push({ id: healResult.data.id, data: healResult.data });
593
- }
594
- const model = this.objectPool.createFromData(withType);
595
- if (model) {
596
- modelsForType.push(model);
597
- }
598
- }
599
- // Collect models for the single batched addBatch call in Phase 2
600
- allModelsToAdd.push(...modelsForType);
601
- // Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
602
- if (recordsToHeal.length > 0 && this.database) {
603
- getContext().logger.info(`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`);
604
- // Use fire-and-forget to not block hydration.
605
- // void is safe: the handler's body is fully try/catch'd.
606
- void Promise.resolve().then(async () => {
607
- try {
608
- for (const { id, data } of recordsToHeal) {
609
- await this.database.putRecord(modelType, id, data);
610
- }
611
- getContext().logger.info(`[SyncClient.hydrate] Successfully healed ${recordsToHeal.length} ${modelType} records`);
612
- }
613
- catch (err) {
614
- getContext().observability.captureMutationFailure({
615
- context: 'persist-healed-records',
616
- modelName: modelType,
617
- error: err instanceof Error ? err : new Error(String(err)),
618
- });
619
- }
620
- });
621
- }
622
- const typeEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
623
- perTypePerfLogs.push({
624
- type: modelType,
625
- fetched: rawData.length,
626
- added: modelsForType.length,
627
- fetchMs: (afterFetch - typeStart).toFixed(2),
628
- createMs: (typeEnd - afterFetch).toFixed(2),
629
- });
630
- }
631
- catch (error) {
632
- getContext().observability.captureBootstrapFailure(error, { type: `hydrate-${modelType}` });
633
- }
634
- }
635
- // Phase 2: Single MobX action — add ALL models across all types at once.
636
- const addStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
637
- const totalAdded = this.objectPool.addBatch(allModelsToAdd, ModelScope.live);
638
- const addEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
639
- // Log per-type perf after the batched add (so logs still show per-type breakdown)
640
- for (const entry of perTypePerfLogs) {
641
- getContext().logger.debug('hydrate:type', parseFloat(entry.fetchMs) + parseFloat(entry.createMs), {
642
- type: entry.type,
643
- fetched: entry.fetched,
644
- added: entry.added,
645
- fetchMs: entry.fetchMs,
646
- createMs: entry.createMs,
647
- });
648
- }
649
- const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
650
- getContext().logger.debug('hydrate:total', totalEnd - totalStart, {
651
- totalModels: totalAdded,
652
- addBatchMs: (addEnd - addStart).toFixed(2),
653
- });
654
- // One-line startup summary: types pre-seeded and items per type
655
- try {
656
- const preseededTypes = this.objectPool.registry.getRegisteredModelNames();
657
- const stats = this.objectPool.getStats();
658
- getContext().logger.info('startup_summary', {
659
- typesPreseeded: preseededTypes.length,
660
- poolSize: stats.size,
661
- typeCounts: stats.typeCounts,
662
- });
663
- }
664
- catch { }
665
- }
666
- /**
667
- * Re-hydrate InstanceCache from IndexedDB when the pool already has data.
668
- *
669
- * Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
670
- * this method properly:
671
- * 1. Upserts models — updates existing models in-place, adds new ones
672
- * 2. Removes ghosts — deletes models from the pool that no longer exist in IndexedDB
673
- *
674
- * Used by background bootstrap, network recovery, and server-triggered re-bootstrap.
675
- */
676
- async rehydrateFromDatabase() {
677
- if (!this.database) {
678
- throw new AbloValidationError('Database not available for rehydration', {
679
- code: 'sync_client_db_missing',
680
- });
681
- }
682
- const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
683
- // Model types to rehydrate (same filter as hydrateFromDatabase)
684
- const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
685
- const meta = this.objectPool.registry.getMetadata(name);
686
- return meta?.loadStrategy === LoadStrategy.instant;
687
- });
688
- // ── Phase 1: Read from IndexedDB & create model instances (async I/O) ──
689
- const allModels = [];
690
- const idbIdsByType = new Map();
691
- let healedCount = 0;
692
- let skippedCount = 0;
693
- for (const modelType of modelTypes) {
694
- try {
695
- const rawData = await this.database.hydrateModels(modelType);
696
- const idsForType = new Set();
697
- idbIdsByType.set(modelType, idsForType);
698
- for (const data of rawData) {
699
- let withType = data && typeof data === 'object' && !data.__typename
700
- ? { __typename: modelType, ...data }
701
- : data;
702
- // Self-healing
703
- const healResult = this.healModelRecord(modelType, withType);
704
- if (healResult === null) {
705
- skippedCount++;
706
- continue;
707
- }
708
- withType = healResult.data;
709
- if (healResult.healed) {
710
- healedCount++;
711
- // Persist heal back to IndexedDB (fire-and-forget)
712
- if (this.database) {
713
- const id = healResult.data.id;
714
- const healedData = healResult.data;
715
- // void is safe: the handler's body is fully try/catch'd.
716
- void Promise.resolve().then(async () => {
717
- try {
718
- await this.database.putRecord(modelType, id, healedData);
719
- }
720
- catch {
721
- // Non-critical — will heal again next time
722
- }
723
- });
724
- }
725
- }
726
- // Register ID before createFromData — prevents ghost removal
727
- // if createFromData fails for a record that exists in IDB
728
- const recordId = withType.id;
729
- if (recordId) {
730
- idsForType.add(recordId);
731
- }
732
- try {
733
- const model = this.objectPool.createFromData(withType);
734
- if (model) {
735
- allModels.push(model);
736
- }
737
- }
738
- catch (error) {
739
- getContext().observability.breadcrumb('Model creation failed during rehydration', 'sync.bootstrap', 'warning', {
740
- modelType,
741
- modelId: recordId?.slice(0, 8) ?? 'unknown',
742
- error: error instanceof Error ? error.message : String(error),
743
- });
744
- skippedCount++;
745
- }
746
- }
747
- }
748
- catch (error) {
749
- getContext().observability.captureBootstrapFailure(error, { type: `rehydrate-${modelType}` });
750
- }
751
- }
752
- // ── Phase 2: Upsert batch (single MobX action) ──
753
- // createFromData already calls updateFromData() on existing models,
754
- // so existing models are up-to-date. Upsert adds the new ones and
755
- // updates scope for any that changed.
756
- const beforeSize = this.objectPool.size;
757
- this.objectPool.upsertBatch(allModels, ModelScope.live);
758
- const addedCount = this.objectPool.size - beforeSize;
759
- const updatedCount = allModels.length - addedCount;
760
- // ── Phase 3: Reconcile ghost deletions (single MobX action) ──
761
- // Only reconcile types that were rehydrated — never touch lazy-loaded types.
762
- const ghostIds = [];
763
- for (const modelType of modelTypes) {
764
- const idbIds = idbIdsByType.get(modelType);
765
- if (!idbIds)
766
- continue; // Type had an error during fetch — don't reconcile
767
- const poolIds = this.objectPool.getIdsByModelType(modelType);
768
- if (!poolIds)
769
- continue;
770
- for (const poolId of poolIds) {
771
- if (!idbIds.has(poolId)) {
772
- ghostIds.push(poolId);
773
- }
774
- }
775
- }
776
- const removedCount = this.objectPool.removeBatch(ghostIds);
777
- // ── Phase 4: Stats & logging ──
778
- const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
779
- const elapsedMs = Math.round(totalEnd - totalStart);
780
- const stats = {
781
- added: addedCount,
782
- updated: updatedCount,
783
- removed: removedCount,
784
- skipped: skippedCount,
785
- healed: healedCount,
786
- elapsedMs,
787
- };
788
- getContext().logger.info('[SyncClient.rehydrate] Complete', {
789
- ...stats,
790
- poolSize: this.objectPool.size,
791
- ghostIds: ghostIds.length > 0 ? ghostIds.slice(0, 5).map((id) => id.slice(0, 8)) : [],
792
- });
793
- getContext().observability.breadcrumb('Rehydration complete', 'sync.bootstrap', 'info', {
794
- added: stats.added,
795
- updated: stats.updated,
796
- removed: stats.removed,
797
- elapsedMs: stats.elapsedMs,
798
- });
799
- return stats;
800
- }
801
- /**
802
- * Apply a mutation to a model optimistically and queue it for server sync.
803
- * IndexedDB is updated only once the server confirms the change with a delta
804
- * packet.
805
- *
806
- * A model's changes are captured before the pool action runs, because a pool
807
- * operation such as an upsert can clear the model's local change set;
808
- * capturing first ensures those changes are never lost. The captured set is
809
- * frozen and handed to {@link queueMutation}.
810
- */
811
- mutate(type, model, poolAction, writeOptions) {
812
- // No-op UPDATE guard (O(1)). An update with no dirty fields would travel
813
- // to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
814
- // and — if it was the only op — come back as `lastSyncId: 0`. That trips
815
- // `captureCommitZeroSyncId` (false-positive Sentry anomaly) AND parks the
816
- // tx in `awaiting_delta` for a 30s reconciliation timeout on a write that
817
- // changed nothing. `Model.hasChanges` reads `modifiedProperties.size`, so
818
- // this costs O(1) with no allocation (vs. O(N) materializing getChanges()).
819
- //
820
- // Strict `=== false` is deliberate: `rowAsModel` only casts, so a non-Model
821
- // object can reach here with `hasChanges === undefined`. `undefined === false`
822
- // is false → we fall through to the normal path rather than risk dropping a
823
- // real write. Only a genuine Model with an empty dirty-set is skipped.
824
- if (type === 'update' && model.hasChanges === false) {
825
- return;
826
- }
827
- // Capture changes before the pool action runs. Pool operations —
828
- // upsert in particular — can clear the model's local changes, so
829
- // capturing first ensures they are never lost.
830
- const capturedChanges = type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
831
- poolAction();
832
- this.queueMutation({ type, model, timestamp: new Date(), capturedChanges, writeOptions });
833
- this.notifyObservers({
834
- type,
835
- modelType: model.getModelName(),
836
- model: type !== 'delete' ? model : undefined,
837
- modelId: model.id,
838
- });
839
- // QueryProcessor uses `models:changed` to invalidate caches. Coalesce
840
- // to one event per microtask: a paste of 100 rows should re-run
841
- // affected queries ONCE, not 100×.
842
- this.markModelChanged(model.getModelName());
843
- }
844
- pendingChangedTypes = null;
845
- markModelChanged(modelType) {
846
- if (!this.pendingChangedTypes) {
847
- this.pendingChangedTypes = new Set();
848
- const schedule = typeof queueMicrotask === 'function'
849
- ? queueMicrotask
850
- : (cb) => Promise.resolve().then(cb);
851
- schedule(() => {
852
- const types = this.pendingChangedTypes;
853
- this.pendingChangedTypes = null;
854
- if (types && types.size > 0)
855
- this.emit('models:changed', types);
856
- });
857
- }
858
- this.pendingChangedTypes.add(modelType);
859
- }
860
- /**
861
- * Capture model changes immutably BEFORE any pool operations
862
- * This prevents the fragile pattern of reading changes after state modification
863
- */
864
- captureModelChanges(model) {
865
- if (typeof model.getChanges !== 'function')
866
- return undefined;
867
- const changes = model.getChanges();
868
- // Return a frozen copy to prevent accidental modification
869
- return Object.keys(changes).length > 0 ? Object.freeze({ ...changes }) : undefined;
870
- }
871
- /** Add new model (CREATE) - works offline */
872
- add(model, options) {
873
- this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
874
- }
875
- /** Update existing model (UPDATE) - works offline */
876
- update(model, options) {
877
- this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
878
- }
879
- /**
880
- * Update existing model with pre-computed changes.
881
- * Used by saveManyOptimized when incoming models have empty change-tracking
882
- * (e.g. freshly constructed cell models from a bulk document decomposition).
883
- */
884
- updateWithChanges(model, changes) {
885
- getContext().logger.debug(`SyncClient.updateWithChanges`, {
886
- modelId: model.id,
887
- modelType: model.getModelName(),
888
- });
889
- // Use pre-computed changes if provided, otherwise fall back to model.getChanges()
890
- const capturedChanges = changes && Object.keys(changes).length > 0
891
- ? Object.freeze({ ...changes })
892
- : this.captureModelChanges(model);
893
- // No-op UPDATE guard: neither an explicit change set nor model dirty-fields.
894
- // `captureModelChanges` already returns undefined for an empty dirty-set, so
895
- // an undefined here means there is genuinely nothing to send — skip rather
896
- // than emit an empty-input update that the server coalesces to lastSyncId 0
897
- // (see the same guard in `mutate`).
898
- if (capturedChanges === undefined)
899
- return;
900
- this.objectPool.upsert(model, ModelScope.live);
901
- this.queueMutation({ type: 'update', model, timestamp: new Date(), capturedChanges });
902
- this.notifyObservers({
903
- type: 'update',
904
- modelType: model.getModelName(),
905
- model,
906
- modelId: model.id,
907
- });
908
- }
909
- /** Expose the GraphQL client for atomic mutations (e.g., createSectionWithBlocks).
910
- * Used by SyncedStore for operations that bypass the transaction queue
911
- * but still need optimistic pool updates at the sync layer. */
912
- get gql() {
913
- return this.mutationExecutor;
914
- }
915
- /** Delete model (DELETE) - works offline */
916
- delete(model, options) {
917
- // Clear pending mutations first to prevent "not found" errors on fast delete
918
- this.clearPendingMutationsForModel(model.id);
919
- this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
920
- }
921
- /**
922
- * Clear all pending mutations for a specific model
923
- * Called before deletion to prevent "record not found" errors on the server
924
- */
925
- clearPendingMutationsForModel(modelId) {
926
- const beforeCount = this.pendingMutations.length;
927
- const removed = this.pendingMutations.filter((mutation) => mutation.model.id === modelId);
928
- this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
929
- const afterCount = this.pendingMutations.length;
930
- if (beforeCount !== afterCount) {
931
- getContext().logger.debug('[SyncClient.clearPendingMutationsForModel] Cleared pending mutations', {
932
- modelId,
933
- clearedCount: beforeCount - afterCount,
934
- remainingCount: afterCount,
935
- });
936
- for (const mutation of removed) {
937
- // Once staged, MutationQueue owns cancellation and transfers this
938
- // source id into the superseding delete envelope. Deleting the journal
939
- // row here would make that valid atomic promotion look like a
940
- // multi-tab loser. Truly unstaged work can be canceled locally.
941
- if (this.stagedMutationIds.has(mutation.mutationId))
942
- continue;
943
- this.stagedMutationIds.delete(mutation.mutationId);
944
- void mutation.journaled
945
- .then(() => this.database.removeTransaction(pendingMutationRecordId(mutation.mutationId)))
946
- .catch(() => undefined);
947
- }
948
- }
949
- }
950
- /**
951
- * Upload a file and create its attachment record. The upload runs through
952
- * the {@link MutationQueue}, and a model is built from the server's
953
- * response and added to the pool.
954
- */
955
- async uploadFile(file, options) {
956
- if (!this.userId || !this.organizationId) {
957
- throw new AbloAuthenticationError('Authentication required for file uploads', {
958
- code: 'file_upload_auth_required',
959
- });
960
- }
961
- try {
962
- // Use MutationQueue to handle the upload mutation
963
- const result = await this.mutationQueue.uploadAttachment(file, {
964
- id: options.id,
965
- attachableType: options.attachableType,
966
- attachableId: options.attachableId,
967
- metadata: options.metadata,
968
- }, {
969
- userId: this.userId,
970
- organizationId: this.organizationId,
971
- });
972
- if (result) {
973
- // Create model from response using ModelRegistry (generic — no concrete class import)
974
- const model = this.objectPool.createFromData({
975
- id: options.id,
976
- ...result,
977
- });
978
- if (model) {
979
- this.objectPool.add(model, ModelScope.live);
980
- this.notifyObservers({
981
- type: 'create',
982
- modelType: model.getModelName(),
983
- model,
984
- });
985
- return model;
986
- }
987
- }
988
- return null;
989
- }
990
- catch (error) {
991
- getContext().observability.captureMutationFailure({
992
- context: 'file-upload',
993
- error: error instanceof Error ? error : new Error(String(error)),
994
- });
995
- throw error;
996
- }
997
- }
998
- /**
999
- * Batch upload files — single GraphQL call + parallel S3 PUTs.
1000
- *
1001
- * Returns the raw `Model[]` built by the object pool (typename is
1002
- * determined by the payload the server returns — currently always
1003
- * `Attachment`). The SDK has no knowledge of app-specific model classes,
1004
- * so it cannot honestly claim a narrower return type; consumers that
1005
- * need an `Attachment[]` project through their own typed accessor
1006
- * (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
1007
- * after the upload resolves.
1008
- */
1009
- async batchUploadFiles(files, options) {
1010
- if (!this.userId || !this.organizationId) {
1011
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1012
- code: 'file_upload_auth_required',
1013
- });
1014
- }
1015
- const items = options.ids.map((id) => ({
1016
- id,
1017
- attachableType: options.attachableType,
1018
- attachableId: options.attachableId,
1019
- metadata: options.metadata,
1020
- }));
1021
- const results = await this.mutationQueue.batchUploadAttachments(files, items, {
1022
- userId: this.userId,
1023
- organizationId: this.organizationId,
1024
- });
1025
- const models = [];
1026
- for (const result of results) {
1027
- const model = this.objectPool.createFromData({ ...result });
1028
- if (model) {
1029
- this.objectPool.add(model, ModelScope.live);
1030
- this.notifyObservers({
1031
- type: 'create',
1032
- modelType: model.getModelName(),
1033
- model,
1034
- });
1035
- models.push(model);
1036
- }
1037
- }
1038
- return models;
1039
- }
1040
- /** Archive model (ARCHIVE) - works offline */
1041
- archive(model) {
1042
- this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
1043
- }
1044
- /**
1045
- * Append a mutation to the pending queue and schedule its sync work.
1046
- *
1047
- * IndexedDB persistence and the server push are deferred to a microtask, so
1048
- * many pushes within the same tick collapse into a single serialization and
1049
- * a single process call. Without the deferral, queueing a hundred mutations
1050
- * at once — a large paste, a document import, bulk row creation — would
1051
- * reserialize the whole growing queue a hundred times, an O(N²) cost in
1052
- * `model.toJSON()`.
1053
- *
1054
- * @param mutation.capturedChanges - Pre-captured, frozen changes, used to
1055
- * avoid re-reading a model after pool operations that might clear them.
1056
- */
1057
- queueMutation(mutation) {
1058
- const mutationId = `mutation_${uuid()}`;
1059
- const modelData = mutation.model.toJSON
1060
- ? mutation.model.toJSON()
1061
- : { ...mutation.model };
1062
- let resolveJournal;
1063
- let rejectJournal;
1064
- const journaled = new Promise((resolve, reject) => {
1065
- resolveJournal = resolve;
1066
- rejectJournal = reject;
1067
- });
1068
- let resolveStaged;
1069
- let rejectStaged;
1070
- const staged = new Promise((resolve, reject) => {
1071
- resolveStaged = resolve;
1072
- rejectStaged = reject;
1073
- });
1074
- const pending = {
1075
- ...mutation,
1076
- mutationId,
1077
- modelData,
1078
- journaled,
1079
- resolveJournal,
1080
- rejectJournal,
1081
- staged,
1082
- resolveStaged,
1083
- rejectStaged,
1084
- };
1085
- this.pendingJournalBatch.push(pending);
1086
- this.scheduleJournalFlush();
1087
- // Offline drains may not await this until much later. Observe rejection
1088
- // immediately to avoid an unhandled-promise report while retaining the
1089
- // original rejecting promise for fail-closed dispatch.
1090
- void pending.journaled.catch(() => undefined);
1091
- void pending.staged.catch(() => undefined);
1092
- this.pendingMutations.push(pending);
1093
- this.scheduleSync();
1094
- }
1095
- scheduleJournalFlush() {
1096
- if (this.journalFlushScheduled)
1097
- return;
1098
- this.journalFlushScheduled = true;
1099
- const schedule = typeof queueMicrotask === 'function'
1100
- ? queueMicrotask
1101
- : (callback) => { void Promise.resolve().then(callback); };
1102
- schedule(() => {
1103
- this.journalFlushScheduled = false;
1104
- const batch = this.pendingJournalBatch;
1105
- this.pendingJournalBatch = [];
1106
- void this.flushPendingMutationJournal(batch);
1107
- });
1108
- }
1109
- async flushPendingMutationJournal(batch) {
1110
- if (batch.length === 0)
1111
- return;
1112
- if (!this.userId || !this.organizationId) {
1113
- // Startup writes remain behind their unresolved journal promise. Identity
1114
- // initialization re-kicks this batch once its durable scope is known.
1115
- this.pendingJournalBatch = [...batch, ...this.pendingJournalBatch];
1116
- return;
1117
- }
1118
- try {
1119
- // Build each durable row independently. A malformed mutation must not
1120
- // prevent unrelated writes from the same event-loop burst from being
1121
- // journaled and dispatched.
1122
- const entries = [];
1123
- for (const mutation of batch) {
1124
- try {
1125
- entries.push({
1126
- mutation,
1127
- record: this.pendingMutationRecord(mutation),
1128
- });
1129
- }
1130
- catch (error) {
1131
- mutation.rejectJournal?.(error);
1132
- }
1133
- }
1134
- if (entries.length === 0)
1135
- return;
1136
- const database = this.database;
1137
- const saveOne = async (record) => {
1138
- // Some adapters (including IndexedDB's put path) can throw before
1139
- // returning a Promise. The async wrapper turns that into an individual
1140
- // rejected outcome instead of aborting construction of the whole list.
1141
- await database.saveTransaction(record);
1142
- };
1143
- let outcomes;
1144
- if (database.saveTransactions) {
1145
- try {
1146
- await database.saveTransactions(entries.map(({ record }) => record));
1147
- outcomes = entries.map(() => ({
1148
- status: 'fulfilled',
1149
- value: undefined,
1150
- }));
1151
- }
1152
- catch {
1153
- // IndexedDB aborts a multi-row transaction when any value cannot be
1154
- // cloned. Retry one row per transaction to identify the offender and
1155
- // preserve every valid sibling. Re-putting a row is idempotent by id.
1156
- outcomes = await Promise.allSettled(entries.map(({ record }) => saveOne(record)));
1157
- }
1158
- }
1159
- else {
1160
- outcomes = await Promise.allSettled(entries.map(({ record }) => saveOne(record)));
1161
- }
1162
- for (const [index, entry] of entries.entries()) {
1163
- const outcome = outcomes[index];
1164
- if (outcome?.status === 'fulfilled') {
1165
- entry.mutation.resolveJournal?.();
1166
- }
1167
- else {
1168
- entry.mutation.rejectJournal?.(outcome?.reason ??
1169
- new Error('Mutation journal persistence failed'));
1170
- }
1171
- }
1172
- }
1173
- finally {
1174
- for (const mutation of batch) {
1175
- mutation.resolveJournal = undefined;
1176
- mutation.rejectJournal = undefined;
1177
- }
1178
- }
1179
- }
1180
- syncScheduled = false;
1181
- scheduleSync() {
1182
- if (this.syncScheduled)
1183
- return;
1184
- this.syncScheduled = true;
1185
- const schedule = typeof queueMicrotask === 'function'
1186
- ? queueMicrotask
1187
- : (cb) => Promise.resolve().then(cb);
1188
- schedule(() => {
1189
- this.syncScheduled = false;
1190
- if (getContext().onlineStatus.isOnline()) {
1191
- this.processPendingMutations().catch((err) => {
1192
- getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
1193
- });
1194
- }
1195
- });
1196
- }
1197
- pendingMutationRecord(mutation) {
1198
- if (!this.userId || !this.organizationId) {
1199
- throw new AbloValidationError('Cannot persist a mutation before participant scope is initialized', { code: 'write_options_invalid' });
1200
- }
1201
- const candidate = {
1202
- id: pendingMutationRecordId(mutation.mutationId),
1203
- type: 'pending_mutation',
1204
- storageVersion: 2,
1205
- mutation: {
1206
- mutationId: mutation.mutationId,
1207
- type: mutation.type,
1208
- modelData: mutation.modelData,
1209
- modelName: mutation.model.getModelName(),
1210
- timestamp: mutation.timestamp.toISOString(),
1211
- ...(mutation.capturedChanges !== undefined
1212
- ? { capturedChanges: mutation.capturedChanges }
1213
- : {}),
1214
- ...(mutation.writeOptions !== undefined
1215
- ? { writeOptions: mutation.writeOptions }
1216
- : {}),
1217
- },
1218
- scope: {
1219
- organizationId: this.organizationId,
1220
- participantId: this.userId,
1221
- namespace: this.commitOutboxNamespace,
1222
- },
1223
- timestamp: mutation.timestamp.getTime(),
1224
- };
1225
- // One framework-neutral boundary contract owns both Proxy unwrapping and
1226
- // rejection of values JSON would silently corrupt before IndexedDB sees it.
1227
- return pendingMutationRecordSchema.parse(snapshotJsonValue(candidate, '$.pendingMutation'));
1228
- }
1229
- async persistPendingMutation(mutation) {
1230
- await this.database.saveTransaction(this.pendingMutationRecord(mutation));
1231
- }
1232
- /**
1233
- * Restore the mutation queue from IndexedDB.
1234
- *
1235
- * The persisted record was written by an earlier session, possibly by an
1236
- * older build of the SDK, so each entry is validated as it is replayed:
1237
- * corrupt entries are dropped and logged at debug level, and a failure is
1238
- * never swallowed silently, because the survival of offline writes must be
1239
- * observable.
1240
- */
1241
- async restoreMutationQueue(sealedMutationIds = new Set()) {
1242
- if (!this.database || !this.userId)
1243
- return;
1244
- try {
1245
- const stored = await this.database.getPersistedTransactions();
1246
- const restoredMutationIds = new Set();
1247
- let heldForReview = 0;
1248
- const restore = async (mutation, migrateLegacy, legacyMutationId) => {
1249
- const parsed = persistedMutationSchema.safeParse(mutation);
1250
- if (!parsed.success) {
1251
- getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1252
- issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1253
- });
1254
- return;
1255
- }
1256
- // The window is anchored to when the write was made, because a
1257
- // record re-sealed on restore would otherwise reset its own expiry
1258
- // clock. An unparseable timestamp is held rather than replayed.
1259
- const writtenAt = Date.parse(parsed.data.timestamp);
1260
- const age = Date.now() - writtenAt;
1261
- if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
1262
- heldForReview += 1;
1263
- getContext().logger.warn('A saved local write is older than the server idempotency window and was held for review.');
1264
- return;
1265
- }
1266
- const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
1267
- if (sealedMutationIds.has(mutationId) ||
1268
- restoredMutationIds.has(mutationId))
1269
- return;
1270
- const model = this.objectPool.createFromData(parsed.data.modelData);
1271
- if (model) {
1272
- const pending = {
1273
- mutationId,
1274
- type: parsed.data.type,
1275
- model,
1276
- modelData: parsed.data.modelData,
1277
- timestamp: new Date(parsed.data.timestamp),
1278
- ...(parsed.data.capturedChanges !== undefined
1279
- ? { capturedChanges: parsed.data.capturedChanges }
1280
- : {}),
1281
- ...(parsed.data.writeOptions !== undefined
1282
- ? { writeOptions: parsed.data.writeOptions }
1283
- : {}),
1284
- journaled: Promise.resolve(),
1285
- // Restored mutations have no live `wait: 'confirmed'` caller, so
1286
- // their staging needs no waiter handshake.
1287
- staged: Promise.resolve(),
1288
- };
1289
- if (migrateLegacy) {
1290
- pending.journaled = this.persistPendingMutation(pending);
1291
- await pending.journaled;
1292
- }
1293
- this.pendingMutations.push(pending);
1294
- restoredMutationIds.add(mutationId);
1295
- }
1296
- };
1297
- for (const row of stored) {
1298
- if (row.type !== 'pending_mutation')
1299
- continue;
1300
- const parsed = pendingMutationRecordSchema.safeParse(row);
1301
- if (!parsed.success) {
1302
- const legacy = legacyPendingMutationRecordSchema.safeParse(row);
1303
- if (legacy.success) {
1304
- await restore(legacy.data.mutation, true);
1305
- continue;
1306
- }
1307
- getContext().logger.debug('[SyncClient] Dropping malformed pending mutation record', {
1308
- rowId: row.id,
1309
- });
1310
- continue;
1311
- }
1312
- if (parsed.data.scope.organizationId !== this.organizationId ||
1313
- parsed.data.scope.participantId !== this.userId ||
1314
- parsed.data.scope.namespace !== this.commitOutboxNamespace) {
1315
- getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
1316
- continue;
1317
- }
1318
- await restore(parsed.data.mutation, false);
1319
- }
1320
- const legacyQueue = stored.find((row) => row.id === 'mutation-queue');
1321
- if (legacyQueue?.mutations) {
1322
- const heldBefore = heldForReview;
1323
- for (const [index, mutation] of legacyQueue.mutations.entries()) {
1324
- await restore(mutation, true, `legacy_mutation_${index}`);
1325
- }
1326
- // Deleting the legacy row would discard any entry held for review, so
1327
- // it is only removed once every entry has migrated.
1328
- if (heldForReview === heldBefore) {
1329
- await this.database.removeTransaction('mutation-queue');
1330
- }
1331
- }
1332
- }
1333
- catch (error) {
1334
- // A restore failure means queued offline writes did NOT rehydrate.
1335
- // Self-healing is impossible here (the record may be unreadable), but
1336
- // the failure must be visible for diagnosis instead of silent loss.
1337
- getContext().logger.debug('[SyncClient] Failed to restore offline mutation queue', {
1338
- error: error instanceof Error ? error.message : String(error),
1339
- });
1340
- getContext().observability.captureMutationFailure({
1341
- context: 'restore-mutation-queue',
1342
- error: error instanceof Error ? error : String(error),
1343
- });
1344
- }
1345
- }
1346
- /**
1347
- * Process pending mutations - can be called by SyncedStore when online
1348
- *
1349
- * Best Practice: Only sync models that still exist locally (local-first principle)
1350
- * - If a model was deleted locally → skip any pending updates/creates for it
1351
- * - This prevents "record not found" errors from fast copy-paste-delete workflows
1352
- */
1353
- async processPendingMutations() {
1354
- if (this.pendingMutations.length === 0)
1355
- return;
1356
- // Identity guard. The early returns here used to be silent — the bug
1357
- // pattern was "every mutation from a logged-in user evaporates" when
1358
- // `SyncClient.initialize()` wasn't called (e.g., missing wiring in
1359
- // the consumer's `BaseSyncedStore.initialize` generator). Warn so
1360
- // this class of misconfiguration surfaces in dev instead of
1361
- // manifesting as "my drag doesn't save."
1362
- if (!this.userId || !this.organizationId) {
1363
- // Internal invariant, not a consumer-actionable error: identity (user +
1364
- // org) hasn't arrived yet. The mutations stay queued and retry once it
1365
- // does, so this is `debug` — a transient startup race is normal. If it
1366
- // never clears it means the host app finished sign-in without seeding
1367
- // identity, which surfaces downstream as "writes never confirm"; we do
1368
- // NOT name internal wiring (`SyncClient.initialize`) here because that
1369
- // method isn't part of the @abloatai/ablo surface a reader could act on.
1370
- getContext().logger.debug('[sync] writes waiting for identity (user/org not set yet) — queued, will retry', {
1371
- pending: this.pendingMutations.length,
1372
- userId: this.userId,
1373
- organizationId: this.organizationId,
1374
- });
1375
- return;
1376
- }
1377
- if (!getContext().onlineStatus.isOnline())
1378
- return; // Skip if offline
1379
- if (this.isDisposed)
1380
- return; // Skip if disposed
1381
- if (this.stagedMutationIds.size > 0)
1382
- return;
1383
- const mutations = this.pendingMutations.filter((mutation) => !this.stagedMutationIds.has(mutation.mutationId)).slice(0, 500);
1384
- if (mutations.length === 0)
1385
- return;
1386
- // Claim the batch BEFORE awaiting the journal. This method runs
1387
- // concurrently — the scheduleSync microtask and a direct syncNow() caller
1388
- // land in the same tick — and both would otherwise capture this same
1389
- // batch, suspend on the identical `journaled` promises, and stage every
1390
- // mutation twice (two transactions on the wire for one write). Claiming
1391
- // synchronously makes the second caller hit the guard above and return.
1392
- for (const mutation of mutations) {
1393
- this.stagedMutationIds.add(mutation.mutationId);
1394
- }
1395
- // A journal rejection is permanent for that mutation (fail-closed: it can
1396
- // never dispatch without its durable record), so drop it rather than
1397
- // leaving it queued to poison every later pass. Healthy batch members
1398
- // still stage.
1399
- // Settle each mutation into an object that keeps the mutation bound to its
1400
- // own outcome. Correlating by array index would force a `mutations[index]!`
1401
- // non-null assertion (the lookup is `T | undefined` under
1402
- // noUncheckedIndexedAccess); carrying the reference makes it unnecessary.
1403
- const journalOutcomes = await Promise.all(mutations.map(async (mutation) => {
1404
- try {
1405
- await mutation.journaled;
1406
- return { mutation, ok: true };
1407
- }
1408
- catch (reason) {
1409
- return { mutation, ok: false, reason };
1410
- }
1411
- }));
1412
- const journaledMutations = [];
1413
- for (const outcome of journalOutcomes) {
1414
- const { mutation } = outcome;
1415
- if (outcome.ok) {
1416
- journaledMutations.push(mutation);
1417
- continue;
1418
- }
1419
- this.stagedMutationIds.delete(mutation.mutationId);
1420
- this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
1421
- mutation.rejectStaged?.(outcome.reason);
1422
- getContext().observability.captureMutationFailure({
1423
- context: 'persist-pending-mutation',
1424
- error: outcome.reason instanceof Error
1425
- ? outcome.reason
1426
- : new Error(String(outcome.reason)),
1427
- });
1428
- }
1429
- // Stage every mutation synchronously within the same event-loop tick;
1430
- // the transaction queue's microtask batches and sends them together.
1431
- for (const mutation of journaledMutations) {
1432
- // Stage synchronously - MutationQueue handles batching, retry, and errors
1433
- this.stageMutation(mutation);
1434
- }
1435
- }
1436
- /**
1437
- * Stage mutation to MutationQueue - mutations in same tick are batched via microtask
1438
- *
1439
- * @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
1440
- */
1441
- stageMutation(mutation) {
1442
- if (!this.userId || !this.organizationId) {
1443
- // Nothing will stage this call; settle the waiter with the legacy
1444
- // "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
1445
- mutation.resolveStaged?.();
1446
- return;
1447
- }
1448
- const ctx = { userId: this.userId, organizationId: this.organizationId };
1449
- // Settlement is delivered via transaction.confirmation, not this promise —
1450
- // it only rejects when staging itself throws (change extraction, optimistic
1451
- // apply, store add). That means the write never entered the queue, so
1452
- // capture it instead of dropping it silently.
1453
- const captureStagingFailure = (error) => {
1454
- getContext().observability.captureMutationFailure({
1455
- context: `stage-mutation-${mutation.type}`,
1456
- modelName: mutation.model.getModelName(),
1457
- modelId: mutation.model.id,
1458
- error: error instanceof Error ? error : new Error(String(error)),
1459
- });
1460
- this.stagedMutationIds.delete(mutation.mutationId);
1461
- };
1462
- const staging = mutation.type === 'update'
1463
- ? this.mutationQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
1464
- : this.mutationQueue[mutation.type].bind(this.mutationQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
1465
- staging
1466
- .then(() => mutation.resolveStaged?.())
1467
- .catch((error) => {
1468
- captureStagingFailure(error);
1469
- mutation.rejectStaged?.(error);
1470
- });
1471
- }
1472
- /**
1473
- * Resolve a conflict between the local model and incoming server data,
1474
- * called while processing deltas from the WebSocket. Certain server states,
1475
- * such as deletions and deactivations, always take precedence even when the
1476
- * local model has unsynced changes, so the two sides stay consistent.
1477
- */
1478
- resolveConflicts(localModel, serverData) {
1479
- const hasLocalChanges = localModel.hasChanges;
1480
- // Safely get timestamp, handling both Date objects and strings
1481
- const localUpdatedAt = localModel.updatedAt
1482
- ? localModel.updatedAt instanceof Date
1483
- ? localModel.updatedAt.getTime()
1484
- : new Date(localModel.updatedAt).getTime()
1485
- : 0;
1486
- const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1487
- getContext().logger.debug('Conflict resolution', {
1488
- modelId: localModel.id,
1489
- modelType: localModel.getModelName(),
1490
- hasLocalChanges,
1491
- localUpdatedAt: localModel.updatedAt?.toString(),
1492
- serverUpdatedAt: serverData.updatedAt,
1493
- localChanges: localModel.getChanges(),
1494
- serverState: this.extractCriticalState(serverData),
1495
- });
1496
- // PRIORITY 1: Check for critical server states that must be respected
1497
- // These states override any local changes to maintain data consistency
1498
- const criticalServerStates = this.extractCriticalState(serverData);
1499
- const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
1500
- if (shouldForceAcceptServer) {
1501
- getContext().logger.debug('Accepting server update - critical state change detected', {
1502
- modelId: localModel.id,
1503
- criticalStates: criticalServerStates,
1504
- });
1505
- // Force accept server state for critical changes
1506
- localModel.updateFromData(serverData);
1507
- localModel.clearChanges();
1508
- localModel.markAsSynced();
1509
- return localModel;
1510
- }
1511
- // Local-first: if we have local dirty fields, merge by field.
1512
- // Keep locally changed fields; apply server for the rest.
1513
- if (hasLocalChanges) {
1514
- const localChanges = localModel.getChanges();
1515
- getContext().logger.debug('Merging server update with local dirty fields', {
1516
- modelId: localModel.id,
1517
- keptFields: Object.keys(localChanges || {}),
1518
- });
1519
- // Merge: server baseline + local dirty fields win
1520
- const merged = { ...serverData, ...(localChanges || {}) };
1521
- // Preserve the most recent updatedAt without clearing dirty flags
1522
- if (serverData.updatedAt || localModel.updatedAt) {
1523
- const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1524
- // updateFromData accepts Date or ISO string for dates
1525
- merged.updatedAt = mergedUpdatedAt;
1526
- }
1527
- localModel.updateFromData(merged);
1528
- // Intentionally DO NOT clearChanges here; pending tx will confirm and clear
1529
- return localModel;
1530
- }
1531
- // No local changes: fall back to LWW to converge
1532
- // Accept server regardless of timestamp equality to stay in sync
1533
- const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
1534
- getContext().logger.debug(`Accepting server update - ${acceptReason}`);
1535
- localModel.updateFromData(serverData);
1536
- localModel.clearChanges();
1537
- localModel.markAsSynced();
1538
- return localModel;
1539
- }
1540
- /**
1541
- * Extract the critical state fields from server data. These are the states
1542
- * that must be honored even when the local model has unsynced changes. The
1543
- * conflict resolver reads exactly these fields and no others.
1544
- */
1545
- extractCriticalState(serverData) {
1546
- const critical = {};
1547
- if (!serverData || typeof serverData !== 'object') {
1548
- return critical;
1549
- }
1550
- // Deletion/archival states - always critical
1551
- if (serverData.deletedAt !== undefined) {
1552
- critical.deletedAt = serverData.deletedAt;
1553
- }
1554
- if (serverData.archivedAt !== undefined) {
1555
- critical.archivedAt = serverData.archivedAt;
1556
- }
1557
- // Deactivation states - critical for assignments and similar entities
1558
- if (serverData.isActive !== undefined && serverData.isActive === false) {
1559
- critical.isActive = false;
1560
- }
1561
- if (serverData.unassignedAt !== undefined) {
1562
- critical.unassignedAt = serverData.unassignedAt;
1563
- }
1564
- return critical;
1565
- }
1566
- /**
1567
- * Check if critical state changes exist that require forcing server state
1568
- */
1569
- hasCriticalStateChange(criticalStates) {
1570
- // Any critical state present means we should force accept server
1571
- return (Object.keys(criticalStates).length > 0 &&
1572
- Object.values(criticalStates).some((v) => v !== null && v !== undefined));
1573
- }
1574
- /**
1575
- * Handle network reconnection
1576
- */
1577
- async handleReconnection() {
1578
- getContext().observability.breadcrumb('Network reconnected', 'sync.offline');
1579
- this.emit('sync:reconnecting');
1580
- try {
1581
- // Prefer a single batch flush for pending mutations (fast path)
1582
- try {
1583
- await this.mutationQueue.flushOfflineQueue();
1584
- }
1585
- catch { }
1586
- // Process all queued mutations
1587
- await this.processPendingMutations();
1588
- this.setConnectionState('connected');
1589
- this.emit('sync:reconnected');
1590
- }
1591
- catch (error) {
1592
- getContext().observability.captureMutationFailure({
1593
- context: 'reconnection-sync',
1594
- error: error instanceof Error ? error : new Error(String(error)),
1595
- });
1596
- this.emit('sync:error', error);
1597
- }
1598
- }
1599
- /**
1600
- * Handle network disconnection
1601
- */
1602
- async handleDisconnection() {
1603
- getContext().observability.breadcrumb('Network disconnected', 'sync.offline');
1604
- this.setConnectionState('disconnected');
1605
- this.emit('sync:offline');
1606
- }
1607
- /**
1608
- * Get current sync state
1609
- */
1610
- getState() {
1611
- return {
1612
- connectionState: this.connectionState,
1613
- pendingMutations: this.pendingMutations.length,
1614
- lastSyncAt: new Date(),
1615
- error: undefined,
1616
- };
1617
- }
1618
- /**
1619
- * Set connection state
1620
- */
1621
- setConnectionState(state) {
1622
- const oldState = this.connectionState;
1623
- this.connectionState = state;
1624
- if (oldState !== state) {
1625
- getContext().observability.setConnectionState(state);
1626
- getContext().observability.breadcrumb(`Connection: ${oldState} → ${state}`, 'sync.websocket');
1627
- if (state === 'connected') {
1628
- this.emit('connection:established');
1629
- this.mutationQueue.setConnectionState('connected');
1630
- }
1631
- else if (state === 'disconnected') {
1632
- this.emit('connection:disconnected');
1633
- this.mutationQueue.setConnectionState('disconnected');
1634
- }
1635
- }
1636
- }
1637
- /**
1638
- * Subscribe to events with disposer pattern
1639
- */
1640
- subscribe(event, handler) {
1641
- super.on(event, handler);
1642
- // Return disposer function
1643
- return () => {
1644
- this.off(event, handler);
1645
- };
1646
- }
1647
- /**
1648
- * Add observer for sync events
1649
- */
1650
- addObserver(observer) {
1651
- this.observers.add(observer);
1652
- }
1653
- /**
1654
- * Remove observer
1655
- */
1656
- removeObserver(observer) {
1657
- this.observers.delete(observer);
1658
- }
1659
- /**
1660
- * Notify all observers
1661
- */
1662
- notifyObservers(event) {
1663
- for (const observer of this.observers) {
1664
- if (observer.onSync) {
1665
- try {
1666
- observer.onSync(event);
1667
- }
1668
- catch (error) {
1669
- getContext().observability.breadcrumb('Observer error', 'sync.transaction', 'error', {
1670
- error: error instanceof Error ? error.message : String(error),
1671
- });
1672
- }
1673
- }
1674
- }
1675
- }
1676
- /**
1677
- * Disconnect from sync
1678
- */
1679
- disconnect() {
1680
- this.setConnectionState('disconnected');
1681
- }
1682
- /**
1683
- * Mark the sync client as connected
1684
- * Called when WebSocket successfully connects (can happen independently of browser online/offline)
1685
- */
1686
- markConnected() {
1687
- this.setConnectionState('connected');
1688
- // Browser online state may have marked the client connected before the
1689
- // WebSocket itself was ready. Always kick both durable lanes on the real
1690
- // socket event, even when the high-level state did not change.
1691
- void this.mutationQueue.flushOfflineQueue().catch((error) => {
1692
- getContext().observability.captureMutationFailure({
1693
- context: 'restore-commit-outbox',
1694
- error: error instanceof Error ? error : new Error(String(error)),
1695
- });
1696
- });
1697
- void this.processPendingMutations();
1698
- }
1699
- /**
1700
- * Dispose and cleanup
1701
- */
1702
- dispose() {
1703
- this.isDisposed = true;
1704
- this.disconnect();
1705
- this.networkMonitor.dispose();
1706
- this.observers.clear();
1707
- this.pendingMutations = [];
1708
- this.removeAllListeners();
1709
- }
1710
- /**
1711
- * Notify the {@link MutationQueue} of an incoming delta so it can confirm
1712
- * hosted writes by sync-id threshold and queued forwards by their echoed
1713
- * source-batch correlation id.
1714
- * @param syncId - The sync id of the received delta.
1715
- * @param transactionId - Optional server echo of the originating local write.
1716
- * @param correlationId - Opaque batch identity decoded from a source WAL echo.
1717
- */
1718
- onDeltaReceived(syncId, transactionId, correlationId) {
1719
- try {
1720
- this.mutationQueue.onDeltaReceived(syncId, transactionId, correlationId);
1721
- }
1722
- catch (e) {
1723
- getContext().observability.breadcrumb('Failed to notify delta received', 'sync.transaction', 'warning', {
1724
- syncId,
1725
- transactionId,
1726
- correlationId,
1727
- });
1728
- }
1729
- }
1730
- /**
1731
- * Cancel pending transactions for child entities orphaned by a parent's
1732
- * deletion. The store calls this when a delete delta arrives for a parent,
1733
- * cancelling any queued writes on children that reference it.
1734
- *
1735
- * @param childModelName - The child model type (for example, `Block`).
1736
- * @param foreignKey - The foreign-key property name (for example, `sectionId`).
1737
- * @param parentId - The id of the deleted parent.
1738
- * @returns The number of transactions cancelled.
1739
- */
1740
- cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
1741
- return this.mutationQueue.cancelTransactionsByForeignKey(childModelName, foreignKey, parentId);
1742
- }
1743
- /**
1744
- * Wait for a transaction to be confirmed by its delta echo. Delegates to the
1745
- * {@link MutationQueue}, which handles the confirmation timeout.
1746
- */
1747
- waitForDeltaConfirmation(transactionId) {
1748
- return this.mutationQueue.waitForConfirmation(transactionId);
1749
- }
1750
- /**
1751
- * Force sync now - process pending mutations
1752
- */
1753
- async syncNow() {
1754
- // Snapshot before draining: a concurrent drain may already have claimed
1755
- // this caller's write, in which case processPendingMutations returns
1756
- // without staging anything. `wait: 'confirmed'` resolves on finding no
1757
- // in-flight work, so it must not run until every write queued before this
1758
- // call has a real transaction in the queue or was definitively dropped.
1759
- const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
1760
- await this.processPendingMutations();
1761
- await Promise.allSettled(queuedBeforeCall);
1762
- }
1763
- /**
1764
- * Get sync statistics. Return type is inferred from the literal so
1765
- * the call site sees the actual shape — `connectionState` narrowed
1766
- * to its three states, `objectPoolStats` typed by `InstanceCache.getStats`.
1767
- */
1768
- getSyncStats() {
1769
- return {
1770
- connectionState: this.connectionState,
1771
- pendingMutations: this.pendingMutations.length,
1772
- objectPoolStats: this.objectPool.getStats(),
1773
- };
1774
- }
1775
- /**
1776
- * Get pending transaction count from MutationQueue
1777
- * Used by SyncedStore to compute hasUnsyncedChanges
1778
- */
1779
- getPendingTransactionCount() {
1780
- const stats = this.mutationQueue.getStats();
1781
- // Include pending and executing as "unsynced"
1782
- // awaiting_delta transactions are included in 'executing' until confirmed
1783
- // Completed and failed are "synced" (either done or gave up)
1784
- return stats.pending + stats.executing;
1785
- }
1786
- /**
1787
- * Subscribe to transaction events for sync status tracking
1788
- * Returns unsubscribe function
1789
- */
1790
- onTransactionEvent(event, callback) {
1791
- const eventName = `transaction:${event}`;
1792
- this.mutationQueue.on(eventName, callback);
1793
- return () => this.mutationQueue.off(eventName, callback);
1794
- }
1795
- /**
1796
- * Subscribe to mutation failures with the full payload. Mirrors the
1797
- * underlying MutationQueue 'transaction:failed' shape so consumers
1798
- * can render typed UI (toast keyed by `AbloError.type`, route-level
1799
- * "this entity reverted" boundaries, telemetry).
1800
- *
1801
- * Distinct from `onTransactionEvent('failed', cb)`, which serves the
1802
- * parameterless `pendingChanges` counter and intentionally drops the
1803
- * payload. The two coexist: the counter callback stays lightweight, while
1804
- * this typed listener drives user-visible surfaces.
1805
- */
1806
- onMutationFailure(listener) {
1807
- this.mutationQueue.on('transaction:failed', listener);
1808
- return () => this.mutationQueue.off('transaction:failed', listener);
1809
- }
1810
- /**
1811
- * Subscribe to commit round-trip latency, split into the local seal and the
1812
- * remote acknowledgement. Fires once per completed commit.
1813
- *
1814
- * Taps the {@link MutationQueue} emitter for the same reason
1815
- * {@link onMutationFailure} does: the commit lifecycle events originate
1816
- * there and the SyncClient's own emitter never rebroadcasts them.
1817
- */
1818
- onCommitLatency(listener) {
1819
- return observeCommitLatency(this.mutationQueue, listener);
1820
- }
1821
- /**
1822
- * Subscribe to local transaction creation with the full {@link QueuedMutation}
1823
- * payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
1824
- * the feed the store's local-mutation subscription taps for undo recording.
1825
- *
1826
- * It subscribes to the {@link MutationQueue}'s emitter directly, since
1827
- * that is the only emitter that fires `transaction:created`. The SyncClient's
1828
- * own emitter (reached through {@link subscribe}) never rebroadcasts that
1829
- * event, so routing undo through `subscribe('transaction:created')` would
1830
- * record nothing. {@link onMutationFailure} taps the queue for the same
1831
- * reason.
1832
- */
1833
- onLocalTransaction(listener) {
1834
- this.mutationQueue.on('transaction:created', listener);
1835
- const snapshotsByCommit = new Map();
1836
- const onCommitStaging = (payload) => {
1837
- snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
1838
- if (operation.type === 'CREATE')
1839
- return undefined;
1840
- const resident = this.objectPool.get(operation.id);
1841
- return resident?.toJSON();
1842
- }));
1843
- };
1844
- const onCommitSealFailed = (payload) => {
1845
- snapshotsByCommit.delete(payload.clientTxId);
1846
- };
1847
- // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1848
- // their own `commit:created` event: they have no optimistic pool apply,
1849
- // so they must not feed the echo tracker's `transaction:created` path.
1850
- // Enrich each operation with previous state captured from the pool HERE
1851
- // (the queue is pool-free) and hand the synthesized transaction to the
1852
- // same listener, so undo observes every write door — one stream.
1853
- const onCommitCreated = (payload) => {
1854
- const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
1855
- snapshotsByCommit.delete(payload.clientTxId);
1856
- const TYPE_BY_WIRE = {
1857
- CREATE: 'create',
1858
- UPDATE: 'update',
1859
- DELETE: 'delete',
1860
- ARCHIVE: 'archive',
1861
- UNARCHIVE: 'unarchive',
1862
- };
1863
- payload.operations.forEach((op, index) => {
1864
- const type = TYPE_BY_WIRE[op.type];
1865
- if (!type || !op.id)
1866
- return;
1867
- const snapshot = type === 'create'
1868
- ? undefined
1869
- : stagedSnapshots
1870
- ? stagedSnapshots[index]
1871
- : this.objectPool.get(op.id)?.toJSON();
1872
- // A DELETE of a row the local graph never saw is not invertible —
1873
- // recording it would make undo "restore" an empty husk. Skip it.
1874
- if (type === 'delete' && !snapshot)
1875
- return;
1876
- // UPDATE inverse must only revert the fields this op actually wrote;
1877
- // handing undo the FULL row would clobber concurrent edits to
1878
- // unrelated fields on revert.
1879
- const previousData = type === 'update' && snapshot && op.input
1880
- ? Object.fromEntries(Object.keys(op.input).map((key) => [key, snapshot[key]]))
1881
- : snapshot ?? null;
1882
- listener({
1883
- id: `${payload.clientTxId}_op${index}`,
1884
- type,
1885
- modelName: op.model,
1886
- modelId: op.id,
1887
- modelKey: op.model,
1888
- data: op.input ?? undefined,
1889
- previousData,
1890
- context: {
1891
- userId: this.userId ?? '',
1892
- organizationId: this.organizationId ?? '',
1893
- },
1894
- status: 'pending',
1895
- createdAt: Date.now(),
1896
- attempts: 0,
1897
- priority: 'normal',
1898
- priorityScore: 0,
1899
- });
1900
- });
1901
- };
1902
- this.mutationQueue.on('commit:staging', onCommitStaging);
1903
- this.mutationQueue.on('commit:seal_failed', onCommitSealFailed);
1904
- this.mutationQueue.on('commit:created', onCommitCreated);
1905
- return () => {
1906
- this.mutationQueue.off('transaction:created', listener);
1907
- this.mutationQueue.off('commit:staging', onCommitStaging);
1908
- this.mutationQueue.off('commit:seal_failed', onCommitSealFailed);
1909
- this.mutationQueue.off('commit:created', onCommitCreated);
1910
- snapshotsByCommit.clear();
1911
- };
1912
- }
1913
- /**
1914
- * Wait for the latest in-flight transaction for (modelName, modelId)
1915
- * to be confirmed by the server, or reject if it's rolled back.
1916
- * Resolves immediately when no transaction is in flight — see
1917
- * `MutationQueue.confirmationFor` for the lookup contract.
1918
- *
1919
- * Distinct from `waitForDeltaConfirmation(transactionId)` which keys
1920
- * off a known tx id; this variant is for call sites that hold a
1921
- * Model reference but never see the underlying transaction.
1922
- */
1923
- waitForConfirmation(modelName, modelId) {
1924
- return this.mutationQueue.confirmationFor(modelName, modelId);
1925
- }
1926
- /**
1927
- * Get detailed debug info for the sync debug page
1928
- */
1929
- getDebugInfo() {
1930
- return {
1931
- connectionState: this.connectionState,
1932
- pendingMutationsCount: this.pendingMutations.length,
1933
- mutationQueue: this.mutationQueue.getDebugInfo(),
1934
- };
1935
- }
1936
- // --- Best-practice assignment ops ---
1937
- async unassignEntity(entityType, entityId) {
1938
- // Call server-side unassign to avoid per-id races
1939
- await this.mutationExecutor.executeDelete('Assignment', entityId);
1940
- }
1941
- async reassignEntity(entityType, entityId, assigneeType, assigneeId, id) {
1942
- await this.mutationExecutor.executeCreate('Assignment', id || '', {
1943
- entityType,
1944
- entityId,
1945
- assigneeType,
1946
- assigneeId,
1947
- });
1948
- }
1949
- // ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
1950
- /**
1951
- * Apply a batch of delta results from Database to the InstanceCache.
1952
- * Owns: model creation, upsert, remove, archive, conflict resolution.
1953
- * Returns: nothing — InstanceCache is updated in place.
1954
- */
1955
- /**
1956
- * Mark a local transaction as optimistically applied. The matching
1957
- * server delta (when it arrives with the same `transactionId`) will
1958
- * be recognized as an echo and skip the pool mutation. Called
1959
- * automatically by `MutationQueue` when a transaction is staged;
1960
- * exposed publicly so tests can drive the API directly.
1961
- */
1962
- markTransactionPending(transactionId) {
1963
- this.echoTracker.markPending(transactionId);
1964
- }
1965
- /**
1966
- * Read echo-detection counters: hits, rollbacks, evictions, and the
1967
- * current pending-set size. Surfaced for production observability
1968
- * — a sustained `evictions > 0` rate or `rollbacks` spike is a
1969
- * health signal worth alerting on.
1970
- */
1971
- getEchoMetrics() {
1972
- return this.echoTracker.getMetrics();
1973
- }
1974
- /**
1975
- * Package-internal accessor for the {@link MutationQueue}. Used by
1976
- * `Ablo.commits.create()` to route raw multi-operation envelopes through the
1977
- * same retry-on-reconnect lane as the model proxy path, and by tests to
1978
- * exercise the queue's interaction with {@link markTransactionPending} on the
1979
- * real instance the SyncClient subscribes to. It is not re-exported to SDK
1980
- * consumers; `Ablo` is the public surface.
1981
- */
1982
- getMutationQueue() {
1983
- return this.mutationQueue;
1984
- }
1985
- applyDeltaBatchToPool(dbResults, enrichRelations) {
1986
- const modelsToAdd = [];
1987
- const modelsToUpsert = [];
1988
- const idsToRemove = [];
1989
- const idsToArchive = [];
1990
- // Pre-pass: collect every id slated for `remove` in this batch. The
1991
- // parent-delete flicker came from this exact pattern: a peer (or the
1992
- // user themself) deletes a parent with N children; the commit produces
1993
- // BOTH residual `update` deltas (from the optimistic edits that
1994
- // happened just before the delete) AND `remove` deltas. The
1995
- // `update` branch below would `createFromData` the row back into
1996
- // the pool when `existing` was already gone (optimistic remove
1997
- // happened), and the next loop iteration's `remove` would strip
1998
- // it again — net effect: pool transitions live → gone → live →
1999
- // gone in one tick, which the renderer catches mid-frame as a
2000
- // flicker. Filter ops on doomed ids before they touch the pool.
2001
- const idsBeingRemoved = new Set();
2002
- for (const r of dbResults) {
2003
- if (r.action === 'remove')
2004
- idsBeingRemoved.add(r.modelId);
2005
- }
2006
- for (const result of dbResults) {
2007
- const { modelName, modelId, action, transactionId } = result;
2008
- // Echo detection: if this delta carries a transaction id that matches
2009
- // one already applied optimistically, the pool already reflects the
2010
- // mutation, so the pool operation is skipped. The IndexedDB write in
2011
- // Database.processDeltaBatch still runs; only the in-memory pool update
2012
- // is suppressed. This prevents a resurrection flicker: a server-confirmed
2013
- // create arriving after the user has optimistically deleted the row would
2014
- // otherwise re-add it for the brief window before the matching delete
2015
- // confirmation lands.
2016
- if (this.echoTracker.consumeEcho(transactionId)) {
2017
- continue;
2018
- }
2019
- // If a later op in this batch will remove this id, skip earlier
2020
- // add/update ops on it. Server FK ordering can produce
2021
- // U(child)+D(child) when an optimistic edit and a delete both
2022
- // commit in the same window; only the final state matters.
2023
- if ((action === 'add' || action === 'update') && idsBeingRemoved.has(modelId)) {
2024
- continue;
2025
- }
2026
- switch (action) {
2027
- case 'add': {
2028
- const existing = this.objectPool.get(modelId);
2029
- if (existing) {
2030
- existing.markAsSynced();
2031
- }
2032
- else if (result.data) {
2033
- const data = enrichRelations(modelName, { ...result.data, __typename: modelName });
2034
- const model = this.objectPool.createFromData(data);
2035
- if (model)
2036
- modelsToAdd.push(model);
2037
- }
2038
- break;
2039
- }
2040
- case 'update': {
2041
- const existing = this.objectPool.get(modelId);
2042
- if (existing && !existing.disposed && result.data) {
2043
- enrichRelations(modelName, result.data);
2044
- const resolved = this.resolveConflicts(existing, result.data);
2045
- modelsToUpsert.push(resolved);
2046
- }
2047
- // Resurrection drop: if `existing` is gone (optimistic delete
2048
- // discarded it; the matching D delta is in-flight) we used
2049
- // to call `createFromData` here, which reintroduced the row
2050
- // for a frame before the D delta stripped it again — the
2051
- // chart-delete flicker. Trust the local state. If the server
2052
- // still considers the row alive, a subsequent bootstrap or
2053
- // resync will reconcile.
2054
- break;
2055
- }
2056
- case 'remove':
2057
- idsToRemove.push(modelId);
2058
- break;
2059
- case 'archive':
2060
- idsToArchive.push(modelId);
2061
- break;
2062
- case 'verify':
2063
- // `verify` is `Database.processDeltaBatch`'s signal for a delta
2064
- // whose IDB store transaction FAILED. Pool isn't updated for
2065
- // this delta — by design, since the persisted view doesn't
2066
- // reflect it either — and the persistence-gated cursor in
2067
- // `BaseSyncedStore.flushPendingDeltas` will NOT ack past it,
2068
- // so the next 30s catch-up poll (or reconnect handshake) will
2069
- // re-fetch and re-apply. Logged here so silent IDB failures
2070
- // are observable instead of disappearing into a default switch
2071
- // fall-through.
2072
- // Self-healing: the next catch-up poll / reconnect re-fetches and
2073
- // re-applies this delta, so it's forensic, not consumer-actionable → debug.
2074
- getContext().logger.debug('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
2075
- modelName,
2076
- modelId: modelId.slice(0, 12),
2077
- });
2078
- break;
2079
- }
2080
- }
2081
- // Reveal the whole frame in a single MobX action. `addBatch`,
2082
- // `upsertBatch`, `removeBatch`, and `updateScope` are each individually
2083
- // wrapped in an action, so calling them in sequence flushes reactions at
2084
- // every action boundary — a catch-up frame that adds, updates, and removes
2085
- // would fire every dependent reaction several times in a row, re-rendering
2086
- // and re-sorting on each. Wrapping them in one outer `runInAction` defers
2087
- // all reaction flushes to a single boundary, so dependents recompute
2088
- // exactly once regardless of how many models or operation kinds the frame
2089
- // touched. The app therefore never observes a partially applied frame.
2090
- runInAction(() => {
2091
- if (modelsToAdd.length > 0)
2092
- this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2093
- if (modelsToUpsert.length > 0)
2094
- this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2095
- if (idsToRemove.length > 0)
2096
- this.objectPool.removeBatch(idsToRemove);
2097
- for (const id of idsToArchive)
2098
- this.objectPool.updateScope(id, ModelScope.archived);
2099
- // Emit changed model types so QueryProcessor can auto-invalidate.
2100
- // Kept inside the action so any observable query-cache state it
2101
- // flips is part of the same atomic reveal.
2102
- const changedTypes = new Set(dbResults.map(r => r.modelName));
2103
- if (changedTypes.size > 0)
2104
- this.emit('models:changed', changedTypes);
2105
- });
2106
- }
2107
- /**
2108
- * Apply bootstrap data to the InstanceCache with ghost removal.
2109
- * Owns: model creation, batch upsert, ghost detection + removal.
2110
- */
2111
- applyBootstrapDataToPool(bootstrapData, protectedIds, options) {
2112
- if (!bootstrapData.models) {
2113
- return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
2114
- }
2115
- const allModels = [];
2116
- const serverIdsByType = new Map();
2117
- let healedCount = 0;
2118
- let skippedCount = 0;
2119
- const failedTypes = new Set(bootstrapData.failedModels ?? []);
2120
- for (const [modelType, records] of Object.entries(bootstrapData.models)) {
2121
- if (failedTypes.has(modelType))
2122
- continue;
2123
- const idsForType = new Set();
2124
- serverIdsByType.set(modelType, idsForType);
2125
- if (!Array.isArray(records) || records.length === 0)
2126
- continue;
2127
- for (const rawRecord of records) {
2128
- if (!rawRecord || typeof rawRecord !== 'object') {
2129
- skippedCount++;
2130
- continue;
2131
- }
2132
- let data = rawRecord;
2133
- if (!data.__typename)
2134
- data = { __typename: modelType, ...data };
2135
- const healResult = this.healModelRecord(modelType, data);
2136
- if (healResult === null) {
2137
- skippedCount++;
2138
- continue;
2139
- }
2140
- data = healResult.data;
2141
- if (healResult.healed)
2142
- healedCount++;
2143
- const recordId = data.id;
2144
- if (recordId)
2145
- idsForType.add(recordId);
2146
- // Scoped backfill for the hydrate-on-enter path: a subset snapshot is
2147
- // taken at a server watermark. If a concurrent live delta already
2148
- // advanced this row past the snapshot, skip it. `createFromData`
2149
- // mutates the pooled model in place to keep instances alive, so this
2150
- // version guard has to run before it; a guard at the upsert layer would
2151
- // be too late, because the row would already be clobbered.
2152
- if (options?.scoped && recordId) {
2153
- const existing = this.objectPool.get(recordId);
2154
- if (existing && !rawRecordIsNewer(data, existing)) {
2155
- skippedCount++;
2156
- continue;
2157
- }
2158
- }
2159
- try {
2160
- const model = this.objectPool.createFromData(data);
2161
- if (model)
2162
- allModels.push(model);
2163
- }
2164
- catch {
2165
- skippedCount++;
2166
- }
2167
- }
2168
- }
2169
- // Upsert. The scoped stale-skip above already guarded the version, so a
2170
- // plain upsert is correct here for both paths.
2171
- const beforeSize = this.objectPool.size;
2172
- this.objectPool.upsertBatch(allModels, ModelScope.live);
2173
- const addedCount = this.objectPool.size - beforeSize;
2174
- const updatedCount = allModels.length - addedCount;
2175
- // Ghost removal: drop pool entities absent from the server snapshot. This
2176
- // is valid only for a full bootstrap, where the snapshot is authoritative
2177
- // for each returned type. A scoped subset snapshot must not remove rows of
2178
- // the same type that belong to other, unhydrated groups.
2179
- let removedCount = 0;
2180
- if (!options?.scoped) {
2181
- const ghostIds = [];
2182
- for (const [modelType, serverIds] of serverIdsByType) {
2183
- const poolIds = this.objectPool.getIdsByModelType(modelType);
2184
- if (!poolIds)
2185
- continue;
2186
- for (const poolId of poolIds) {
2187
- if (!serverIds.has(poolId) && !protectedIds?.has(poolId))
2188
- ghostIds.push(poolId);
2189
- }
2190
- }
2191
- removedCount = this.objectPool.removeBatch(ghostIds);
2192
- }
2193
- // Emit changed model types so QueryProcessor can auto-invalidate
2194
- const changedTypes = new Set(Object.keys(bootstrapData.models));
2195
- if (changedTypes.size > 0)
2196
- this.emit('models:changed', changedTypes);
2197
- return { added: addedCount, updated: updatedCount, removed: removedCount, skipped: skippedCount, healed: healedCount };
2198
- }
2199
- }