@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
package/dist/Database.js DELETED
@@ -1,1500 +0,0 @@
1
- /**
2
- * The local persistence layer for synced models. It stores rows in the
3
- * browser's IndexedDB (or in-memory maps when run headlessly), applies inbound
4
- * deltas to that store, and fetches the bootstrap snapshot from your sync
5
- * server. {@link BaseSyncedStore} drives it, and {@link InstanceCache} holds the
6
- * in-memory mirror of what this class persists.
7
- */
8
- import { DatabaseManager } from './core/DatabaseManager.js';
9
- import { StoreManager } from './core/StoreManager.js';
10
- import { ModelRegistry } from './ModelRegistry.js';
11
- import { LoadStrategy } from './transaction/types/index.js';
12
- import { getContext } from './context.js';
13
- import { AbloConnectionError, AbloValidationError } from './transaction/errors.js';
14
- import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
15
- import { logPositionSchema } from './transaction/logPosition.js';
16
- import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
17
- /**
18
- * Request identity excludes local timing metadata for re-entrant seals: a
19
- * retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
20
- * those volatile fields would reject every legitimate same-request re-seal as
21
- * an idempotency conflict. Only the fields that define the wire request count.
22
- */
23
- function isSameOutboxRecord(existing, candidate) {
24
- if (existing.type === 'http_commit_envelope' &&
25
- candidate.type === 'http_commit_envelope') {
26
- const identity = (record) => ({
27
- id: record.id,
28
- type: record.type,
29
- storageVersion: record.storageVersion,
30
- idempotencyKey: record.idempotencyKey,
31
- // HTTP outbox rows written before protocol versioning are v1. Normalize
32
- // them so a same-request re-seal remains idempotent after an upgrade.
33
- protocolVersion: record.protocolVersion ?? 1,
34
- request: record.request,
35
- scopeNamespace: record.scopeNamespace,
36
- });
37
- if (existing.correlationId !== undefined &&
38
- candidate.correlationId !== undefined &&
39
- existing.correlationId !== candidate.correlationId) {
40
- return false;
41
- }
42
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
43
- }
44
- if (existing.type === 'commit_envelope' &&
45
- candidate.type === 'commit_envelope') {
46
- const identity = (record) => ({
47
- id: record.id,
48
- type: record.type,
49
- storageVersion: record.storageVersion,
50
- origin: record.origin,
51
- idempotencyKey: record.idempotencyKey,
52
- operations: record.operations,
53
- sourceMutationIds: record.sourceMutationIds,
54
- commitOptions: record.commitOptions,
55
- scope: record.scope,
56
- });
57
- if (existing.correlationId !== undefined &&
58
- candidate.correlationId !== undefined &&
59
- existing.correlationId !== candidate.correlationId) {
60
- return false;
61
- }
62
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
63
- }
64
- return JSON.stringify(existing) === JSON.stringify(candidate);
65
- }
66
- function isAcceptedOutboxPromotion(existing, candidate) {
67
- return (existing !== undefined &&
68
- (existing.type === 'commit_envelope' ||
69
- existing.type === 'http_commit_envelope') &&
70
- existing.type === candidate.type &&
71
- existing.acceptedAt === undefined &&
72
- candidate.acceptedAt !== undefined);
73
- }
74
- export class Database {
75
- // Core database components
76
- databaseManager;
77
- storeManager;
78
- // Injected dependencies
79
- modelRegistry;
80
- bootstrapHelper;
81
- /** The pre-configured query helper for lazy-loading data from the sync server. */
82
- get helper() {
83
- return this.bootstrapHelper;
84
- }
85
- /**
86
- * Fetch the current rows of the given sync groups as a side-effect-free
87
- * snapshot, used to hydrate a scope as the user enters it. Unlike
88
- * {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
89
- * change the connection's subscribed sync groups. The caller applies the
90
- * result to the pool through the scoped apply path.
91
- */
92
- async fetchScopedBootstrapData(syncGroups) {
93
- // No lastSyncId → a full snapshot of exactly these groups.
94
- return this.bootstrapHelper.fetchBootstrap(undefined, syncGroups);
95
- }
96
- // Database state
97
- currentDbInfo = null;
98
- workspaceDb = null;
99
- /**
100
- * Flag to track if database is closing/closed.
101
- * Used for graceful degradation when operations are attempted during shutdown.
102
- */
103
- isClosing = false;
104
- /**
105
- * When set, forces the next requiredBootstrap() call to return 'full' even if offline.
106
- * Used when a sync group change delta is received — we must re-bootstrap to purge
107
- * revoked data, even if the device is currently offline (it will bootstrap when online).
108
- */
109
- _forceFullBootstrap = false;
110
- /** Essential fields that must be preserved during partial UPDATE merges.
111
- * Sourced from SyncEngineConfig.essentialFields — consumers define their own. */
112
- get essentialFields() {
113
- return getContext().config.essentialFields;
114
- }
115
- /**
116
- * When true, all IndexedDB operations are replaced with in-memory Maps.
117
- * Enables the SDK to run headlessly in Node.js / agent workers / tests
118
- * without requiring a browser environment.
119
- *
120
- * Set via createSyncEngine({ storage: inMemoryStorage() }) or directly:
121
- * new Database(registry, bootstrap, { inMemory: true })
122
- */
123
- inMemory;
124
- /** In-memory stores used when inMemory=true. Keyed by model name. */
125
- inMemoryStores = new Map();
126
- /** In-memory workspace metadata when inMemory=true. */
127
- inMemoryMetadata = null;
128
- constructor(modelRegistry, bootstrapHelper, options) {
129
- this.databaseManager = new DatabaseManager();
130
- this.storeManager = new StoreManager(modelRegistry);
131
- this.modelRegistry = modelRegistry;
132
- this.bootstrapHelper = bootstrapHelper;
133
- this.inMemory = options?.inMemory ?? false;
134
- }
135
- /**
136
- * Get store for a model, or `undefined` if no store exists.
137
- *
138
- * Routes to `inMemoryStores` in inMemory mode and `storeManager`
139
- * otherwise. Both implementations satisfy `ObjectStoreContract`, so
140
- * callers don't branch on which one they got back.
141
- *
142
- * Pass `context` to emit an observability breadcrumb when the store
143
- * is missing — useful for hot paths (bootstrap, delta apply, hydrate)
144
- * where a missing store points to silent data loss. Callers that
145
- * already expect optional behavior (e.g. lazy lookups) can omit it.
146
- */
147
- getStore(modelName, context) {
148
- const store = this.inMemory
149
- ? this.inMemoryStores.get(modelName)
150
- : this.storeManager.getStore(modelName);
151
- if (!store && context) {
152
- getContext().observability.breadcrumb(`Store not found for model: ${modelName}`, 'sync.database', 'warning', { context });
153
- }
154
- return store;
155
- }
156
- /** Get store or throw if not found (for operations that require the store). */
157
- getRequiredStore(modelName) {
158
- const store = this.getStore(modelName);
159
- if (!store) {
160
- throw new AbloValidationError(`Store not found: ${modelName}`, {
161
- code: 'db_store_not_found',
162
- });
163
- }
164
- return store; // TypeScript narrows to non-undefined after the throw
165
- }
166
- /** Log preserved fields during partial UPDATE merge (debug helper) */
167
- logPreservedFields(modelName, modelId, existing, delta) {
168
- if (modelName === 'Activity')
169
- return;
170
- const requiredFields = this.essentialFields[modelName] ?? [];
171
- const preserved = requiredFields.filter((field) => existing[field] !== undefined && delta[field] === undefined);
172
- if (preserved.length > 0) {
173
- getContext().logger.debug('[Database] UPDATE merged - preserved fields', {
174
- modelName,
175
- modelId: modelId.slice(0, 12),
176
- deltaFields: Object.keys(delta),
177
- preservedFields: preserved,
178
- });
179
- }
180
- }
181
- async open(userId, organizationId, version = 1) {
182
- // Reset closing flag when opening (in case of reopen)
183
- this.isClosing = false;
184
- if (this.workspaceDb && this.currentDbInfo) {
185
- return;
186
- }
187
- // ── In-memory mode: skip IndexedDB entirely ──────────────────
188
- // Creates InMemoryObjectStore instances for all registered models.
189
- // Bootstrap via HTTP still works; only local persistence is skipped.
190
- if (this.inMemory) {
191
- getContext().logger.debug('Opening in-memory database (headless mode)');
192
- const allModels = this.modelRegistry.getRegisteredModelNames();
193
- for (const modelName of allModels) {
194
- const storeName = `store_${modelName.toLowerCase()}`;
195
- this.inMemoryStores.set(modelName, new InMemoryObjectStore(modelName, storeName));
196
- }
197
- // Create a __transactions store for the offline queue
198
- this.inMemoryStores.set('__transactions', new InMemoryObjectStore('__transactions', '__transactions'));
199
- getContext().logger.info(`In-memory database opened: ${this.inMemoryStores.size} stores`);
200
- return;
201
- }
202
- // ── Browser mode: IndexedDB (existing behavior, unchanged) ───
203
- getContext().logger.debug('Opening IndexedDB database');
204
- // Initialize meta database
205
- await this.databaseManager.initializeMetaDatabase();
206
- // Calculate database info
207
- this.currentDbInfo = await this.databaseManager.calculateDatabaseInfo(userId, organizationId, version);
208
- // Register database
209
- await this.databaseManager.registerDatabase(this.currentDbInfo);
210
- // Open workspace database
211
- this.workspaceDb = await this.databaseManager.openWorkspaceDatabase(this.currentDbInfo, async (db) => {
212
- await this.storeManager.createStores(db);
213
- });
214
- // Initialize stores
215
- await this.storeManager.initializeStores(this.workspaceDb);
216
- const readiness = await this.storeManager.checkReadinessOfStores();
217
- getContext().logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
218
- }
219
- /**
220
- * Shrink a record before persisting it. Drops `undefined` fields, empty
221
- * arrays, empty objects, and the redundant markers `__typename`, `__class`,
222
- * `clientId`, and `syncStatus`. Explicit `null` values are preserved, since
223
- * a null is a meaningful "clear this field" in a nullable column.
224
- *
225
- * By design this receives plain objects, never live observables: WebSocket
226
- * deltas arrive already parsed, optimistic updates come through `toJSON()`,
227
- * and bootstrap data is plain JSON from the server.
228
- */
229
- compactRecord(_modelName, data) {
230
- if (!data || typeof data !== 'object')
231
- return data;
232
- const out = {};
233
- for (const [key, value] of Object.entries(data)) {
234
- // Drop redundant or ephemeral markers
235
- if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
236
- continue;
237
- }
238
- // Skip only `undefined`; preserve explicit `null`, which is a
239
- // meaningful value for a nullable column.
240
- if (value === undefined) {
241
- continue;
242
- }
243
- if (Array.isArray(value)) {
244
- if (value.length === 0)
245
- continue;
246
- out[key] = value;
247
- continue;
248
- }
249
- if (typeof value === 'object') {
250
- // Preserve explicit null values
251
- if (value === null) {
252
- out[key] = null;
253
- continue;
254
- }
255
- // Preserve Date objects (IndexedDB can clone these)
256
- if (value instanceof Date) {
257
- out[key] = value;
258
- continue;
259
- }
260
- // For plain objects, drop if empty
261
- if (Object.keys(value).length === 0)
262
- continue;
263
- out[key] = value;
264
- continue;
265
- }
266
- out[key] = value;
267
- }
268
- // Always ensure id is present
269
- if (!out.id && data.id)
270
- out.id = data.id;
271
- return out;
272
- }
273
- /**
274
- * Mark that the next bootstrap must be a full bootstrap.
275
- * Called when a sync group change ("G" delta) is received — the client must
276
- * re-fetch all data from the server to purge models from revoked sync groups.
277
- */
278
- markRequiresFullBootstrap() {
279
- this._forceFullBootstrap = true;
280
- getContext().logger.info('[Database] Marked for forced full bootstrap (sync group change)');
281
- }
282
- /**
283
- * Smart bootstrap requirements based on data freshness
284
- */
285
- async requiredBootstrap() {
286
- // In-memory mode (server-side agents, headless workers): there's
287
- // no `workspaceDb` by design — `open()` returns early after
288
- // initializing `inMemoryStores`. Persistent data never exists
289
- // across sessions, so the right answer is always a full bootstrap
290
- // from the server. Mirrors the `inMemory` short-circuit in
291
- // `setModelPersisted` / `isModelPersisted` / `getMetadata`.
292
- if (this.inMemory) {
293
- const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
294
- const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
295
- return {
296
- type: 'full',
297
- modelsToLoad: [...instantModels, ...lazyModels],
298
- lastSyncId: 0,
299
- syncGroups: [],
300
- };
301
- }
302
- if (!this.workspaceDb) {
303
- throw new AbloConnectionError('Database not opened', {
304
- code: 'db_not_opened',
305
- });
306
- }
307
- // Sync group change requires full re-bootstrap to purge revoked data
308
- if (this._forceFullBootstrap) {
309
- this._forceFullBootstrap = false;
310
- const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
311
- const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
312
- getContext().logger.info('[Database.requiredBootstrap] Forced FULL bootstrap (sync group change)');
313
- return {
314
- type: 'full',
315
- modelsToLoad: [...instantModels, ...lazyModels],
316
- lastSyncId: 0,
317
- syncGroups: [],
318
- };
319
- }
320
- const readiness = await this.storeManager.checkReadinessOfStores();
321
- const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
322
- // Get models from registry
323
- const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
324
- const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
325
- const modelsToLoad = [...instantModels, ...lazyModels];
326
- // Gate the PERSISTED cursor through the sync-position schema field —
327
- // the one trust boundary for resume state. IDB can hand back anything
328
- // (a corrupted negative/float cursor would previously pass `|| 0`,
329
- // which only catches falsy, and get sent to the server as the resume
330
- // point). Invalid → 0 → full bootstrap, the safe degradation.
331
- const metadataLastSyncId = logPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
332
- const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
333
- // ── Cache-validity check ─────────────────────────────────────
334
- //
335
- // The cursor (lastSyncId) is only valid if the data it refers to
336
- // actually exists in the stores. If the local store was cleared (or
337
- // this is a fresh in-memory session), the metadata's lastSyncId is
338
- // stale — sending it to the server would trigger a partial bootstrap
339
- // that returns zero deltas because the gap is 0, leaving the client
340
- // with an empty InstanceCache.
341
- //
342
- // The fix is to sample the actual stores: if they hold no rows, the
343
- // cursor is meaningless regardless of what the metadata claims.
344
- const dataExists = this.inMemory
345
- ? false // In-memory mode: no persistent data across sessions
346
- : await this.storeManager.hasAnyData();
347
- // The effective lastSyncId: only trust the metadata cursor when
348
- // we've confirmed the data it refers to actually exists in the stores.
349
- const lastSyncId = dataExists ? metadataLastSyncId : 0;
350
- // Log the resolved database state for diagnostics.
351
- getContext().logger.debug('[Database.requiredBootstrap] State check', {
352
- readinessReady: readiness.ready,
353
- hasMetadata: !!metadata,
354
- metadataLastSyncId,
355
- effectiveLastSyncId: lastSyncId,
356
- dataExists,
357
- dataAge: metadata?.updatedAt ? Math.round(dataAge / 1000) + 's' : 'N/A',
358
- navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : 'N/A',
359
- });
360
- // Determine bootstrap type based on connectivity and data state
361
- const offline = typeof navigator !== 'undefined' && navigator && navigator.onLine === false;
362
- let type;
363
- // hasLocalData: stores actually have records AND we have a valid cursor
364
- const hasLocalData = readiness.ready && dataExists && lastSyncId > 0;
365
- if (offline && hasLocalData) {
366
- // Offline with data - use local bootstrap (only option when offline)
367
- type = 'local';
368
- getContext().logger.info('Offline detected with local data - using local bootstrap');
369
- }
370
- else {
371
- // The server is the source of truth: always use a full bootstrap
372
- // when online.
373
- type = 'full';
374
- getContext().logger.info('Full bootstrap - server is source of truth', {
375
- reason: offline ? 'offline_no_data' : 'server_authoritative',
376
- hasLocalData,
377
- lastSyncId,
378
- dataExists,
379
- });
380
- }
381
- return {
382
- type,
383
- modelsToLoad,
384
- lastSyncId,
385
- syncGroups: metadata?.syncGroups ?? [],
386
- };
387
- }
388
- /**
389
- * Fetch a bootstrap snapshot (or delta batch) from the sync server and load
390
- * it into the local store, then return a {@link BootstrapResult} the caller
391
- * applies to the {@link InstanceCache}.
392
- */
393
- async bootstrapFromServer(requirements,
394
- /** Full sync-group subscription list — what the WS subscribes to
395
- * AND what gets persisted as `subscribedSyncGroups` for the
396
- * shrinkage check. Caller supplies the complete list, not just
397
- * team-derived groups. */
398
- syncGroups, onProgress) {
399
- getContext().logger.debug('Starting bootstrap fetch', {
400
- type: requirements.type,
401
- lastSyncId: requirements.lastSyncId,
402
- modelsToLoad: requirements.modelsToLoad,
403
- });
404
- getContext().logger.info('Database: Starting bootstrap from Go server', {
405
- type: requirements.type,
406
- syncGroups,
407
- modelsToLoad: requirements.modelsToLoad,
408
- });
409
- try {
410
- // Fetch before any destructive operation, so a failed network
411
- // request can't leave the local store empty.
412
- const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
413
- getContext().logger.info('Fetching bootstrap data from server (before clearing local data)', {
414
- type: requirements.type,
415
- lastSyncId: requirements.lastSyncId,
416
- });
417
- const bootstrapData = await this.bootstrapHelper.fetchBootstrap(requirements.lastSyncId);
418
- getContext().logger.debug('Received bootstrap response', {
419
- type: bootstrapData.type,
420
- lastSyncId: bootstrapData.lastSyncId,
421
- hasModels: !!bootstrapData.models,
422
- hasDeltas: !!bootstrapData.deltas,
423
- deltaCount: bootstrapData.deltaCount ?? 0,
424
- });
425
- // Clear only after a successful fetch, for transactional safety.
426
- // Clear when the server says the response is a full snapshot,
427
- // regardless of what type was requested.
428
- if (bootstrapData.type === 'full') {
429
- await this.clear();
430
- }
431
- // Handle partial bootstrap (delta batch)
432
- if (bootstrapData.type === 'partial') {
433
- const deltas = bootstrapData.deltas ?? [];
434
- getContext().logger.info('Processing partial bootstrap with delta batch', {
435
- deltaCount: deltas.length,
436
- fromSyncId: requirements.lastSyncId,
437
- toSyncId: bootstrapData.lastSyncId,
438
- });
439
- // Apply deltas to IndexedDB using processDeltaBatch for better performance.
440
- // Capture the return value so the pool can be updated by the caller —
441
- // without this, partial-bootstrap DELETEs persist to IDB but don't
442
- // evict entities from the in-memory InstanceCache, leaving ghost rows
443
- // visible on the canvas until a full reload rebuilds the pool.
444
- let deltasApplied = 0;
445
- let deltaResults;
446
- if (deltas.length > 0) {
447
- // Narrow the wire delta to what processDelta reads. The field names
448
- // are the wire's own — the only change is `id` becoming `syncId`.
449
- // A group-change frame carries its payload as a JSON string, decoded
450
- // here exactly as the live delta path does in BaseSyncedStore.
451
- const formattedDeltas = deltas.map((delta) => ({
452
- syncId: delta.id,
453
- actionType: delta.actionType,
454
- modelName: delta.modelName,
455
- modelId: delta.modelId,
456
- data: typeof delta.data === 'string'
457
- ? JSON.parse(delta.data)
458
- : delta.data,
459
- }));
460
- // Use batch processing for better performance
461
- const batch = await this.processDeltaBatch(formattedDeltas);
462
- deltaResults = batch.results;
463
- deltasApplied = formattedDeltas.length;
464
- onProgress?.(deltasApplied);
465
- }
466
- // Update workspace metadata with new lastSyncId (critical even when 0 deltas)
467
- await this.updateWorkspaceMetadata({
468
- lastSyncId: bootstrapData.lastSyncId,
469
- schemaHash: this.modelRegistry.getSchemaHash(),
470
- syncGroups: [...syncGroups],
471
- updatedAt: new Date(),
472
- });
473
- const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
474
- getContext().logger.info(`Partial bootstrap complete in ${elapsed.toFixed(2)}ms`, {
475
- deltasApplied,
476
- lastSyncId: bootstrapData.lastSyncId,
477
- });
478
- return { modelsLoaded: 0, modelsStored: deltasApplied, bootstrapData, deltaResults };
479
- }
480
- // Full bootstrap: Process model data
481
- if (!bootstrapData.models) {
482
- throw new AbloValidationError('Full bootstrap response missing models data', {
483
- code: 'bootstrap_response_invalid',
484
- });
485
- }
486
- let modelsLoaded = 0;
487
- let modelsStored = 0;
488
- for (const [modelName, modelData] of Object.entries(bootstrapData.models)) {
489
- // Handle null, undefined, or non-array data
490
- if (!modelData) {
491
- getContext().observability.breadcrumb(`No data received for ${modelName}`, 'sync.bootstrap', 'warning');
492
- continue;
493
- }
494
- if (!Array.isArray(modelData)) {
495
- getContext().observability.breadcrumb(`Skipping non-array data for ${modelName}`, 'sync.bootstrap', 'warning');
496
- continue;
497
- }
498
- // Skip empty arrays silently (expected for some models)
499
- if (modelData.length === 0) {
500
- getContext().logger.debug(`No ${modelName} items to store (empty array)`);
501
- continue;
502
- }
503
- const store = this.getStore(modelName, 'bootstrap');
504
- if (!store) {
505
- getContext().logger.debug(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
506
- continue;
507
- }
508
- let writeErrors = 0;
509
- // Store all items to IndexedDB (compacted)
510
- for (const item of modelData) {
511
- try {
512
- const compacted = this.compactRecord(modelName, item);
513
- await store.put(compacted);
514
- modelsStored++;
515
- modelsLoaded++;
516
- // Report progress every 10 items
517
- if (modelsLoaded % 10 === 0) {
518
- onProgress?.(modelsLoaded);
519
- }
520
- }
521
- catch (error) {
522
- writeErrors++;
523
- getContext().observability.breadcrumb(`Failed to store ${modelName} item`, 'sync.database', 'error', {
524
- error: error instanceof Error ? error.message : String(error),
525
- });
526
- }
527
- }
528
- // The model is marked persisted below whether or not every item landed,
529
- // because a partial store is still what the next sync reconciles
530
- // against. Counted and surfaced here so a partial does not read as a
531
- // clean bootstrap.
532
- if (writeErrors > 0) {
533
- getContext().observability.breadcrumb(`Stored ${modelName} with ${writeErrors} of ${modelData.length} items dropped`, 'sync.database', 'warning');
534
- }
535
- // Mark model as persisted after successful write
536
- try {
537
- await this.setModelPersisted(modelName, true);
538
- }
539
- catch { }
540
- }
541
- // Update workspace metadata with bootstrap snapshot's lastSyncId
542
- // Note: This method is only called for 'full' bootstrap (not 'local')
543
- // For 'partial' bootstrap (future): would need intelligent merge logic here
544
- await this.updateWorkspaceMetadata({
545
- lastSyncId: bootstrapData.lastSyncId,
546
- schemaHash: this.modelRegistry.getSchemaHash(),
547
- syncGroups: [...syncGroups],
548
- updatedAt: new Date(),
549
- });
550
- const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startTime;
551
- getContext().logger.info(`Bootstrap complete: ${modelsLoaded} items loaded, ${modelsStored} stored to IndexedDB in ${elapsed.toFixed(2)}ms`);
552
- getContext().analytics?.capture('bootstrap_success', {
553
- responseTime: elapsed,
554
- modelsLoaded,
555
- });
556
- return { modelsLoaded, modelsStored, bootstrapData };
557
- }
558
- catch (error) {
559
- // Comprehensive error logging for bootstrap failures
560
- getContext().observability.captureBootstrapFailure(error, {
561
- type: requirements.type,
562
- navigatorOnline: typeof navigator !== 'undefined' ? navigator.onLine : undefined,
563
- });
564
- // Track bootstrap failure telemetry
565
- getContext().analytics?.capture('bootstrap_failed', {
566
- bootstrapType: requirements.type,
567
- lastSyncId: requirements.lastSyncId,
568
- errorMessage: error instanceof Error ? error.message : String(error),
569
- errorName: error instanceof Error ? error.name : 'UnknownError',
570
- });
571
- throw error;
572
- }
573
- }
574
- // bootstrapSpecificModels removed per request
575
- /**
576
- * Apply a single inbound delta from the WebSocket to the local store.
577
- *
578
- * This handles one delta at a time. To apply several, prefer
579
- * {@link processDeltaBatch}, which commits them in one IndexedDB transaction
580
- * rather than two transactions per delta.
581
- *
582
- * Update deltas carry only the changed fields, so they are merged onto the
583
- * existing record rather than replacing it. That preserves fields the delta
584
- * omits (such as reportId or title), and an explicit null is kept as a value,
585
- * clearing that field.
586
- */
587
- async processDelta(delta) {
588
- const { actionType, modelName, modelId, data, syncId } = delta;
589
- const store = this.getStore(modelName, 'processDelta');
590
- if (!store) {
591
- return { action: 'verify', modelName, modelId };
592
- }
593
- // Idempotency gate: ignore already-applied deltas by comparing with the persisted lastSyncId
594
- try {
595
- const lastApplied = await this.getLastSyncId();
596
- const incomingId = typeof syncId === 'number' ? syncId : undefined;
597
- if (typeof incomingId === 'number' && incomingId <= lastApplied) {
598
- return { action: 'verify', modelName, modelId };
599
- }
600
- }
601
- catch { }
602
- // Compact data before persistence; do not store redundant type markers.
603
- // Inject `id` from the envelope — server deltas frequently strip it
604
- // from the `data` payload, but IDB object stores use keyPath='id'
605
- // and require it on the record itself. See `processDeltaBatch` for
606
- // the same rationale on the batch path.
607
- const dataWithId = data && typeof data === 'object'
608
- ? { id: modelId, ...data }
609
- : data;
610
- const compacted = dataWithId && typeof dataWithId === 'object'
611
- ? this.compactRecord(modelName, dataWithId)
612
- : dataWithId;
613
- switch (actionType) {
614
- // 'C' (Covering) — client gained permission to see an existing entity.
615
- // End state in the local store is identical to an insert: the row is
616
- // present. The semantic difference is purely observability — it wasn't
617
- // newly created, it was newly visible. We fall through to the 'I' case
618
- // after a debug trace so the two can be disambiguated in logs.
619
- case 'C':
620
- getContext().observability.breadcrumb('Applying covering delta (gained permission)', 'sync.database', 'info', { modelName, modelId: modelId.slice(0, 12) });
621
- // falls through
622
- case 'I': {
623
- // Skip when the delta payload was empty/null. IDB rejects
624
- // non-record `put` arguments at runtime; the previous `any`
625
- // typing on `ObjectStore.put` was silently letting that
626
- // through. Real I-deltas always carry a row body.
627
- if (!compacted || typeof compacted !== 'object') {
628
- return { action: 'add', modelName, modelId, data: null };
629
- }
630
- // Insert synchronously for durable ack-after-apply semantics
631
- try {
632
- await store.put(compacted);
633
- if (typeof syncId === 'number') {
634
- await this.updateWorkspaceMetadata({ lastSyncId: syncId });
635
- }
636
- }
637
- catch (err) {
638
- getContext().observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
639
- error: err instanceof Error ? err.message : String(err),
640
- });
641
- throw err; // Re-throw to see the actual error
642
- }
643
- return { action: 'add', modelName, modelId, data: compacted };
644
- }
645
- case 'U': {
646
- // Update: merge onto the existing record (partial-delta pattern).
647
- // Read the existing record first.
648
- const existing = await store.get(modelId);
649
- // Skip the update when there's no existing record to merge with:
650
- // building a record from partial update data would corrupt it
651
- // (missing reportId, and so on).
652
- if (!existing) {
653
- getContext().observability.breadcrumb('Skipping UPDATE delta - no existing record to merge with', 'sync.database', 'warning', {
654
- modelName,
655
- modelId: modelId.slice(0, 12),
656
- });
657
- // Return verify action to signal no changes were made
658
- return { action: 'verify', modelName, modelId, data: null };
659
- }
660
- // Shallow merge: delta overrides existing fields (safe - existing is guaranteed)
661
- const merged = { ...existing, ...compacted };
662
- // Log preserved fields for debugging partial updates
663
- if (existing && compacted) {
664
- this.logPreservedFields(modelName, modelId, existing, compacted);
665
- }
666
- // Persist merged record
667
- try {
668
- await store.put(merged);
669
- if (typeof syncId === 'number') {
670
- await this.updateWorkspaceMetadata({ lastSyncId: syncId });
671
- }
672
- }
673
- catch (err) {
674
- getContext().observability.breadcrumb(`IndexedDB put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
675
- error: err instanceof Error ? err.message : String(err),
676
- });
677
- throw err;
678
- }
679
- // Return merged data (not just delta) to preserve essential fields like organizationId
680
- return { action: 'update', modelName, modelId, data: merged };
681
- }
682
- case 'D': {
683
- // Delete synchronously
684
- try {
685
- await store.delete(modelId);
686
- if (typeof syncId === 'number') {
687
- await this.updateWorkspaceMetadata({ lastSyncId: syncId });
688
- }
689
- }
690
- catch (err) {
691
- getContext().observability.breadcrumb(`IndexedDB delete failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
692
- error: err instanceof Error ? err.message : String(err),
693
- });
694
- // Surface failure so caller does not mutate InstanceCache inconsistently
695
- throw err;
696
- }
697
- return { action: 'remove', modelName, modelId };
698
- }
699
- case 'A': {
700
- // Archive
701
- const archivedData = this.compactRecord(modelName, { ...data, archivedAt: new Date() });
702
- try {
703
- await store.put(archivedData);
704
- if (typeof syncId === 'number') {
705
- await this.updateWorkspaceMetadata({ lastSyncId: syncId });
706
- }
707
- }
708
- catch (err) {
709
- getContext().observability.breadcrumb(`IndexedDB archive put failed for ${modelName}:${modelId}`, 'sync.database', 'error', {
710
- error: err instanceof Error ? err.message : String(err),
711
- });
712
- throw err;
713
- }
714
- return { action: 'archive', modelName, modelId, data: archivedData };
715
- }
716
- case 'V': // Verify
717
- return { action: 'verify', modelName, modelId, data };
718
- // 'G' (GroupAdded) and 'S' (GroupRemoved) are sync-group membership
719
- // signals, not entity mutations. They are routed upstream in
720
- // BaseSyncedStore.processDeltaWithBatching and should never reach
721
- // processDelta. If one slips through (e.g. replayed from the bootstrap
722
- // queue), we return a no-op verify rather than crashing the engine.
723
- case 'G':
724
- case 'S':
725
- getContext().observability.breadcrumb(`Group membership delta (${actionType}) reached processDelta — should be handled upstream`, 'sync.database', 'warning', { modelName, modelId: modelId.slice(0, 12), actionType });
726
- return { action: 'verify', modelName, modelId, data: null };
727
- default: {
728
- // The switch above is exhaustive over the declared action types, so
729
- // this branch is only reachable when a value escapes the type — hence
730
- // stringifying whatever actually arrived rather than the `never`.
731
- const _exhaustive = actionType;
732
- void _exhaustive;
733
- throw new AbloValidationError(`Unknown action type: ${JSON.stringify(actionType)}`, { code: 'db_unknown_action_type' });
734
- }
735
- }
736
- }
737
- /**
738
- * Apply many deltas to the local store in as few IndexedDB transactions as
739
- * possible. Deltas are grouped by store, and each store's writes commit in a
740
- * single transaction, so a batch of 186 deltas becomes roughly one
741
- * transaction per store instead of two per delta.
742
- *
743
- * The method reads the existing records for update deltas up front, then
744
- * merges each update onto its existing record so fields the delta omits are
745
- * preserved and an explicit null still clears its field. It advances the
746
- * persisted sync cursor once, to the highest committed sync id.
747
- *
748
- * Conflict resolution follows a delete-wins rule: it first indexes the
749
- * delete deltas by entity, then skips any insert or update whose sync id is
750
- * at or below a delete for the same entity. This avoids resurrecting a
751
- * deleted entity and avoids fetching one that no longer exists.
752
- */
753
- async processDeltaBatch(deltas) {
754
- if ((!this.workspaceDb && !this.inMemory) || this.isClosing || deltas.length === 0) {
755
- return { results: [], persistedSyncId: 0 };
756
- }
757
- // ── inMemory short-circuit ───────────────────────────────────────
758
- //
759
- // The batched IDB transaction path below assumes `this.storeManager`
760
- // and `workspaceDb`. In inMemory mode (agent-worker, tests) those
761
- // don't exist. Without this branch, every live delta arriving over
762
- // the WebSocket is silently dropped — the local pool never updates,
763
- // `subscribe()` autoruns never re-fire, lazy-model dispatchers
764
- // never claim incoming work.
765
- //
766
- // Fall through to the single-delta path (`processDelta`), which
767
- // uses `getStore` and is inMemory-compatible. Same return
768
- // shape, sequential apply per delta — fine since inMemory mode
769
- // doesn't need IDB transaction batching for performance.
770
- if (this.inMemory) {
771
- const inMemResults = [];
772
- let inMemPersistedSyncId = 0;
773
- for (const delta of deltas) {
774
- const single = await this.processDelta({
775
- syncId: delta.syncId,
776
- actionType: delta.actionType,
777
- modelName: delta.modelName,
778
- modelId: delta.modelId,
779
- data: delta.data,
780
- });
781
- inMemResults.push({ ...single, transactionId: delta.transactionId });
782
- // inMemory has no IDB tx that can fail — every non-'verify'
783
- // single result is durable in the in-memory store. Advance the
784
- // persisted-cursor watermark to the input delta's syncId so the
785
- // ack path can move forward.
786
- if (single.action !== 'verify' && typeof delta.syncId === 'number' && delta.syncId > inMemPersistedSyncId) {
787
- inMemPersistedSyncId = delta.syncId;
788
- }
789
- }
790
- return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
791
- }
792
- // Prepare results aligned with input order
793
- const results = new Array(deltas.length);
794
- // Build a delete index for conflict resolution. When a delete has a sync
795
- // id at or above a later insert or update for the same entity, that entity
796
- // is not (re)created — which drops stale updates for cascade-deleted
797
- // entities.
798
- const deleteSyncIds = new Map(); // key: "ModelName:modelId" -> delete syncId
799
- for (const delta of deltas) {
800
- if (delta.actionType === 'D' && delta.syncId) {
801
- const key = `${delta.modelName}:${delta.modelId}`;
802
- const existing = deleteSyncIds.get(key);
803
- // Normalize to number — postgres sends bigint as string on the wire.
804
- const n = typeof delta.syncId === 'string' ? Number(delta.syncId) : delta.syncId;
805
- if (typeof n === 'number' && !isNaN(n) && (!existing || n > existing)) {
806
- deleteSyncIds.set(key, n);
807
- }
808
- }
809
- }
810
- if (deleteSyncIds.size > 0) {
811
- getContext().logger.debug('[Database.processDeltaBatch] Built DELETE index for conflict resolution', {
812
- deleteCount: deleteSyncIds.size,
813
- totalDeltas: deltas.length,
814
- });
815
- }
816
- // Group deltas by store for efficient transaction management.
817
- //
818
- // The method tracks the total range seen plus the exact input indexes whose
819
- // store transaction committed. The cursor is derived from their ordered
820
- // prefix after every store finishes; a maximum alone is unsafe because a
821
- // later store can succeed after an earlier store failed.
822
- //
823
- // Without this split, a single store-level failure (a compacted record
824
- // missing a required field, a validation abort) would advance the cursor
825
- // past deltas that never wrote to IndexedDB. The next partial bootstrap
826
- // would ask "what's new since {advanced cursor}?", the skipped rows would
827
- // fall into the already-seen range forever, and the local store would stay
828
- // permanently behind the server with no way to recover on reload.
829
- const deltasByStore = new Map();
830
- let highestSyncId = 0;
831
- const persistedIndexes = new Set();
832
- let skippedDueToConflict = 0;
833
- deltas.forEach((delta, idx) => {
834
- // Normalize to number — postgres sends bigint syncIds as strings.
835
- const deltaSyncIdNum = typeof delta.syncId === 'string'
836
- ? Number(delta.syncId)
837
- : delta.syncId;
838
- if (typeof deltaSyncIdNum === 'number' && !isNaN(deltaSyncIdNum) && deltaSyncIdNum > highestSyncId) {
839
- highestSyncId = deltaSyncIdNum;
840
- }
841
- // Conflict check: skip an insert or update when a delete for the same
842
- // entity has an equal or higher sync id.
843
- if (delta.actionType === 'U' ||
844
- delta.actionType === 'I' ||
845
- delta.actionType === 'C') {
846
- const key = `${delta.modelName}:${delta.modelId}`;
847
- const deleteSyncId = deleteSyncIds.get(key);
848
- if (deleteSyncId !== undefined) {
849
- // DELETE exists for this entity
850
- const deltaSyncId = delta.syncId ?? 0;
851
- if (deleteSyncId >= deltaSyncId) {
852
- // DELETE has equal or higher syncId - skip this UPDATE/INSERT
853
- getContext().logger.debug('[Database.processDeltaBatch] Skipping stale delta (DELETE wins)', {
854
- modelName: delta.modelName,
855
- modelId: delta.modelId.slice(0, 12),
856
- actionType: delta.actionType,
857
- deltaSyncId,
858
- deleteSyncId,
859
- });
860
- results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
861
- // The later delete in this same ordered frame supersedes this
862
- // value, so the stale predecessor requires no separate write.
863
- persistedIndexes.add(idx);
864
- skippedDueToConflict++;
865
- return; // Skip this delta
866
- }
867
- }
868
- }
869
- const store = this.getStore(delta.modelName, 'processDeltaBatch');
870
- if (!store) {
871
- results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
872
- return;
873
- }
874
- if (!deltasByStore.has(delta.modelName)) {
875
- deltasByStore.set(delta.modelName, []);
876
- }
877
- deltasByStore.get(delta.modelName).push({ idx, delta });
878
- });
879
- if (skippedDueToConflict > 0) {
880
- getContext().logger.info('[Database.processDeltaBatch] Conflict resolution summary', {
881
- skippedDueToConflict,
882
- totalDeltas: deltas.length,
883
- deleteCount: deleteSyncIds.size,
884
- });
885
- }
886
- // Process each store's deltas in a single transaction
887
- for (const [modelName, storeDeltas] of deltasByStore.entries()) {
888
- const store = this.storeManager.getStore(modelName);
889
- if (!store)
890
- continue;
891
- try {
892
- // Batch read-modify-write.
893
- // Step 1: Identify which deltas need existing data (updates)
894
- const updateDeltas = storeDeltas.filter(({ delta }) => delta.actionType === 'U');
895
- const updateIds = updateDeltas.map(({ delta }) => delta.modelId);
896
- // Step 2: Batch read all existing records in a SINGLE IDB transaction
897
- // This replaces N sequential get() calls with 1 transaction containing N gets
898
- let existingRecords = new Map();
899
- const missingIds = new Set();
900
- if (updateIds.length > 0) {
901
- try {
902
- existingRecords = await store.getMany(updateIds);
903
- // Identify missing IDs for self-healing
904
- for (const id of updateIds) {
905
- if (!existingRecords.has(id)) {
906
- missingIds.add(id);
907
- }
908
- }
909
- }
910
- catch {
911
- getContext().observability.breadcrumb(`Batch read failed for ${modelName}, falling back to individual reads`, 'sync.database', 'warning');
912
- // Fallback: mark all as missing for self-healing
913
- for (const id of updateIds) {
914
- missingIds.add(id);
915
- }
916
- }
917
- }
918
- // Self-heal by fetching missing records for update deltas.
919
- // Track ids that failed to fetch (a 404 means the entity was deleted,
920
- // so its delta is skipped).
921
- const failedToFetch = new Set();
922
- if (missingIds.size > 0) {
923
- getContext().logger.info(`[Database.processDeltaBatch] Found ${missingIds.size} missing records for ${modelName}, fetching from server...`);
924
- // Fetch sequentially to avoid overwhelming server
925
- for (const id of missingIds) {
926
- try {
927
- const fetchedRecord = await this.bootstrapHelper.fetchEntity(modelName, id);
928
- if (fetchedRecord) {
929
- const compacted = this.compactRecord(modelName, fetchedRecord);
930
- existingRecords.set(id, compacted);
931
- getContext().logger.debug(`[Database.processDeltaBatch] Successfully fetched missing record: ${modelName}:${id}`);
932
- }
933
- else {
934
- // fetchEntity returns null for 404 — entity was deleted, skip the delta
935
- failedToFetch.add(id);
936
- getContext().logger.debug(`[Database.processDeltaBatch] Entity not found (deleted): ${modelName}:${id}`);
937
- }
938
- }
939
- catch (error) {
940
- // Unexpected error (5xx, network failure) — mark for skipping and report
941
- failedToFetch.add(id);
942
- getContext().observability.breadcrumb(`Failed to fetch missing record ${modelName}:${id}`, 'sync.database', 'warning', {
943
- error: error instanceof Error ? error.message : String(error),
944
- });
945
- }
946
- }
947
- if (failedToFetch.size > 0) {
948
- getContext().logger.info(`[Database.processDeltaBatch] Skipping ${failedToFetch.size} stale UPDATE deltas for deleted entities`, {
949
- modelName,
950
- failedCount: failedToFetch.size,
951
- totalMissing: missingIds.size,
952
- });
953
- }
954
- }
955
- // Re-check after entity fetch loop: close() may have run during network I/O
956
- if (!this.workspaceDb || this.isClosing) {
957
- for (const { idx, delta } of storeDeltas) {
958
- results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
959
- }
960
- continue;
961
- }
962
- // Step 3: Start a single readwrite transaction for this store
963
- const tx = this.workspaceDb.transaction([modelName], 'readwrite');
964
- const objectStore = tx.objectStore(modelName);
965
- // Stage results for this store; only commit to global results when tx completes successfully
966
- const stagedResults = [];
967
- // Step 4: Process all deltas synchronously within transaction (no await!)
968
- for (const { idx, delta } of storeDeltas) {
969
- const { actionType, modelId, data } = delta;
970
- // Server deltas carry `id` in the envelope (modelId) but often
971
- // strip it from the `data` payload as redundant. IDB object
972
- // stores use keyPath='id' on the record itself, so the record
973
- // MUST have `id` set. Inject it before `compactRecord` so the
974
- // record is self-describing.
975
- const dataWithId = data && typeof data === 'object'
976
- ? { id: modelId, ...data }
977
- : data;
978
- const compacted = dataWithId && typeof dataWithId === 'object'
979
- ? this.compactRecord(modelName, dataWithId)
980
- : dataWithId;
981
- switch (actionType) {
982
- case 'C': // Create
983
- case 'I': // Insert
984
- objectStore.put(compacted);
985
- stagedResults.push({
986
- action: 'add',
987
- modelName,
988
- modelId,
989
- data: compacted,
990
- idx,
991
- });
992
- break;
993
- case 'U': {
994
- // Update: merge the delta onto the existing record (already fetched).
995
- const existing = existingRecords.get(modelId);
996
- // Skip a stale update: if the entity is neither in the local
997
- // store nor fetchable from the server (a 404), it was deleted,
998
- // so skip it rather than create an incomplete record.
999
- if (!existing && failedToFetch.has(modelId)) {
1000
- getContext().logger.debug('[Database.processDeltaBatch] Skipping UPDATE for deleted entity', {
1001
- modelName,
1002
- modelId: modelId.slice(0, 12),
1003
- });
1004
- stagedResults.push({ action: 'verify', modelName, modelId, idx });
1005
- break; // Skip this delta
1006
- }
1007
- // Skip the update when there's no existing record to merge with:
1008
- // building a record from partial update data would corrupt it
1009
- // (missing reportId, and so on).
1010
- if (!existing) {
1011
- getContext().observability.breadcrumb('Batch: Skipping UPDATE delta - no existing record', 'sync.database', 'warning', {
1012
- modelName,
1013
- modelId: modelId.slice(0, 12),
1014
- });
1015
- stagedResults.push({ action: 'verify', modelName, modelId, idx });
1016
- break; // Skip this delta
1017
- }
1018
- // Safe to merge - existing record is guaranteed
1019
- const merged = { ...existing, ...compacted };
1020
- // Log preserved fields for debugging partial updates
1021
- if (existing && compacted) {
1022
- this.logPreservedFields(modelName, modelId, existing, compacted);
1023
- }
1024
- objectStore.put(merged);
1025
- stagedResults.push({
1026
- action: 'update',
1027
- modelName,
1028
- modelId,
1029
- data: merged, // Return merged data, not just delta
1030
- idx,
1031
- });
1032
- break;
1033
- }
1034
- case 'D': // Delete
1035
- objectStore.delete(modelId);
1036
- stagedResults.push({ action: 'remove', modelName, modelId, idx });
1037
- break;
1038
- case 'A': // Archive
1039
- const archivedData = this.compactRecord(modelName, {
1040
- ...data,
1041
- archivedAt: new Date(),
1042
- });
1043
- objectStore.put(archivedData);
1044
- stagedResults.push({
1045
- action: 'archive',
1046
- modelName,
1047
- modelId,
1048
- data: archivedData,
1049
- idx,
1050
- });
1051
- break;
1052
- case 'V': // Verify
1053
- stagedResults.push({ action: 'verify', modelName, modelId, data, idx });
1054
- break;
1055
- }
1056
- }
1057
- // Wait for transaction to complete
1058
- await new Promise((resolve, reject) => {
1059
- tx.oncomplete = () => { resolve(); };
1060
- tx.onerror = () => { reject(tx.error); };
1061
- });
1062
- // Only commit staged results to the global results if the transaction
1063
- // succeeded. Record input indexes rather than a maximum sync id; the
1064
- // durable cursor is the prefix through these indexes.
1065
- for (const r of stagedResults) {
1066
- // Resolve the originating delta so we can carry its
1067
- // transactionId through to the result. Echo detection in
1068
- // `SyncClient.applyDeltaBatchToPool` reads it.
1069
- const sourceDelta = deltas[r.idx];
1070
- results[r.idx] = {
1071
- action: r.action,
1072
- modelName: r.modelName,
1073
- modelId: r.modelId,
1074
- data: r.data,
1075
- transactionId: sourceDelta?.transactionId,
1076
- };
1077
- persistedIndexes.add(r.idx);
1078
- }
1079
- }
1080
- catch (err) {
1081
- // Surface the IDB error directly — `captureMutationFailure`
1082
- // routes to Sentry, but during interactive debugging the console
1083
- // needs to show the specific failure (e.g. `ConstraintError`,
1084
- // `DataError`, `AbortError`) so we can find what's wrong with
1085
- // the `compacted` payload shape or store schema.
1086
- const idbErr = err instanceof Error ? err : new Error(String(err));
1087
- getContext().logger.debug('[Database.processDeltaBatch] store tx FAILED', {
1088
- modelName,
1089
- storeDeltasCount: storeDeltas.length,
1090
- errorName: idbErr.name,
1091
- message: idbErr.message,
1092
- sampleDeltas: storeDeltas.slice(0, 3).map(({ delta }) => ({
1093
- action: delta.actionType,
1094
- id: delta.modelId.slice(0, 12),
1095
- dataKeys: delta.data && typeof delta.data === 'object'
1096
- ? Object.keys(delta.data).slice(0, 8)
1097
- : typeof delta.data,
1098
- })),
1099
- });
1100
- getContext().observability.captureMutationFailure({
1101
- context: 'batch-indexeddb-operation',
1102
- modelName,
1103
- error: idbErr,
1104
- });
1105
- // Mark all store deltas as verify in their original positions
1106
- for (const { idx, delta } of storeDeltas) {
1107
- results[idx] = { action: 'verify', modelName, modelId: delta.modelId };
1108
- }
1109
- }
1110
- }
1111
- // Advance only through the durable INPUT PREFIX. IDs need not be
1112
- // numerically contiguous because other tenants and filtered sync groups
1113
- // occupy gaps; the server-delivered order is the relevant sequence.
1114
- const highestPersistedSyncId = highestPersistedPrefixSyncId(deltas, persistedIndexes);
1115
- // Using `highestSyncId` (the range-seen max) would advance past an earlier
1116
- // failed store transaction and permanently skip its delta.
1117
- //
1118
- // If `highestPersistedSyncId === 0` (every store tx failed), we leave
1119
- // the metadata alone. Next partial bootstrap will re-deliver the
1120
- // deltas at the original cursor position.
1121
- if (highestPersistedSyncId > 0) {
1122
- try {
1123
- await this.updateWorkspaceMetadata({ lastSyncId: highestPersistedSyncId });
1124
- }
1125
- catch (err) {
1126
- getContext().observability.breadcrumb('Failed to update metadata after batch', 'sync.database', 'error', {
1127
- error: err instanceof Error ? err.message : String(err),
1128
- });
1129
- }
1130
- }
1131
- if (highestPersistedSyncId < highestSyncId) {
1132
- // Staging-visibility probe: makes the "some deltas seen but not
1133
- // persisted" signal loud when it actually happens. If this fires
1134
- // repeatedly on the same sync IDs, a specific row is un-writable
1135
- // (validation? compact issue?) and needs fixing at that layer.
1136
- getContext().logger.debug('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
1137
- seen: highestSyncId,
1138
- persisted: highestPersistedSyncId,
1139
- gap: highestSyncId - highestPersistedSyncId,
1140
- });
1141
- }
1142
- return { results, persistedSyncId: highestPersistedSyncId };
1143
- }
1144
- /** Get raw data for hydration */
1145
- async hydrateModels(modelName) {
1146
- const store = this.getStore(modelName, 'hydrate');
1147
- if (!store) {
1148
- return [];
1149
- }
1150
- return store.getAll();
1151
- }
1152
- /** Put a single record to IndexedDB (for self-healing corrupted records) */
1153
- async putRecord(modelName, id, data) {
1154
- const store = this.getStore(modelName, 'putRecord');
1155
- if (!store) {
1156
- getContext().observability.breadcrumb(`Store not found for putRecord: ${modelName}`, 'sync.database', 'warning');
1157
- return;
1158
- }
1159
- const compacted = this.compactRecord(modelName, data);
1160
- await store.put(compacted);
1161
- }
1162
- /** Get data by index. `value` is an IDB key — string, number, Date,
1163
- * BufferSource, or array thereof. */
1164
- async getDataByIndex(modelName, indexName, value) {
1165
- const store = this.getRequiredStore(modelName);
1166
- return await store.getAllFromIndex(indexName, value);
1167
- }
1168
- /** Read workspace metadata from IndexedDB. Returns null when the database is not open. */
1169
- async getWorkspaceMetadata() {
1170
- if (this.inMemory)
1171
- return this.inMemoryMetadata;
1172
- if (!this.workspaceDb)
1173
- return null;
1174
- return this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1175
- }
1176
- async getLastSyncId() {
1177
- if (!this.workspaceDb) {
1178
- return 0;
1179
- }
1180
- const metadata = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1181
- return metadata?.lastSyncId ?? 0;
1182
- }
1183
- async updateWorkspaceMetadata(metadata) {
1184
- // In-memory mode: store in local variable
1185
- if (this.inMemory) {
1186
- this.inMemoryMetadata = {
1187
- ...(this.inMemoryMetadata ?? {
1188
- lastSyncId: 0, firstSyncId: 0, backendDatabaseVersion: 0,
1189
- subscribedSyncGroups: [], updatedAt: new Date(),
1190
- }),
1191
- ...metadata,
1192
- updatedAt: new Date(),
1193
- };
1194
- return;
1195
- }
1196
- // Graceful degradation: skip if database is closing or not open
1197
- // This prevents "Database not opened" errors during React Strict Mode cleanup
1198
- if (!this.workspaceDb || this.isClosing) {
1199
- getContext().observability.breadcrumb('updateWorkspaceMetadata: Database not open or closing', 'sync.database', 'warning', {
1200
- hasDb: !!this.workspaceDb,
1201
- isClosing: this.isClosing,
1202
- });
1203
- return;
1204
- }
1205
- const current = await this.databaseManager.getWorkspaceMetadata(this.workspaceDb);
1206
- // Re-check after await: close() may have been called during getWorkspaceMetadata,
1207
- // or the browser may have closed the IDB connection (tab background, navigation).
1208
- // Without this, setWorkspaceMetadata would hit "The database connection is closing".
1209
- if (!this.workspaceDb || this.isClosing) {
1210
- return;
1211
- }
1212
- const updated = {
1213
- ...current,
1214
- ...metadata,
1215
- updatedAt: new Date(),
1216
- };
1217
- await this.databaseManager.setWorkspaceMetadata(this.workspaceDb, updated);
1218
- }
1219
- /** Transaction persistence for offline/retry support.
1220
- * Returns either the IDB-backed ObjectStore or its in-memory twin
1221
- * (`InMemoryObjectStore`) — both expose the same async put/get/
1222
- * delete/getAll/getAllFromIndex surface, so callers don't need to
1223
- * branch on which one they got back. */
1224
- get transactionStore() {
1225
- return this.getRequiredStore('__transactions');
1226
- }
1227
- async saveTransaction(transaction) {
1228
- await this.transactionStore.put(transaction);
1229
- }
1230
- /** Persist one burst of journal rows in a single strict durability group. */
1231
- async saveTransactions(transactions) {
1232
- if (transactions.length === 0)
1233
- return;
1234
- if (this.inMemory) {
1235
- await Promise.all(transactions.map((transaction) => this.transactionStore.put(transaction)));
1236
- return;
1237
- }
1238
- const db = this.workspaceDb;
1239
- if (!db || this.isClosing) {
1240
- throw new AbloConnectionError('Database not opened for mutation journal', {
1241
- code: 'db_not_opened',
1242
- });
1243
- }
1244
- await new Promise((resolve, reject) => {
1245
- try {
1246
- const tx = db.transaction(['__transactions'], 'readwrite', {
1247
- durability: 'strict',
1248
- });
1249
- const store = tx.objectStore('__transactions');
1250
- for (const transaction of transactions)
1251
- store.put(transaction);
1252
- tx.oncomplete = () => { resolve(); };
1253
- tx.onabort = () => {
1254
- reject(tx.error ?? new Error('Mutation journal transaction aborted'));
1255
- };
1256
- tx.onerror = () => {
1257
- // onabort owns rejection.
1258
- };
1259
- }
1260
- catch (error) {
1261
- reject(error instanceof Error ? error : new Error(String(error)));
1262
- }
1263
- });
1264
- }
1265
- async removeTransaction(id) {
1266
- await this.transactionStore.delete(id);
1267
- }
1268
- async getPersistedTransactions() {
1269
- const rows = await this.transactionStore.getAll();
1270
- // Storage layer returns the centralized `Record<string, unknown>`
1271
- // shape from `ObjectStoreContract`. PersistedTransaction adds an
1272
- // index signature so each row already structurally satisfies the
1273
- // narrower type — runtime invariant: only saveTransaction writes
1274
- // here, and it only accepts PersistedTransaction.
1275
- return rows;
1276
- }
1277
- async getPersistedTransaction(id) {
1278
- return (await this.transactionStore.get(id));
1279
- }
1280
- /**
1281
- * Atomically seal one exact commit request and consume the staged mutation
1282
- * records it replaces. The read, optional add, and deletes share one strict
1283
- * IndexedDB transaction, so a crash can expose the staged records or the
1284
- * sealed envelope, never a missing handoff. Returns the pre-existing record
1285
- * when the envelope id was already sealed (retry/re-entrant call).
1286
- */
1287
- async sealTransactionRecord(record, consumedRecordIds) {
1288
- const recordId = record.id;
1289
- if (!recordId) {
1290
- throw new AbloValidationError('A sealed transaction record must carry an id', {
1291
- code: 'invalid_body',
1292
- });
1293
- }
1294
- if (this.inMemory) {
1295
- const store = this.transactionStore;
1296
- const existing = (await store.get(recordId));
1297
- if (existing && !isSameOutboxRecord(existing, record)) {
1298
- throw new AbloValidationError('Pending-write key already identifies a different request', {
1299
- code: 'idempotency_conflict',
1300
- });
1301
- }
1302
- if (isAcceptedOutboxPromotion(existing, record)) {
1303
- await store.put(record);
1304
- }
1305
- if (!existing) {
1306
- const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
1307
- if (sources.some((source) => source === undefined)) {
1308
- throw new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
1309
- }
1310
- await store.add(record);
1311
- }
1312
- for (const id of consumedRecordIds) {
1313
- if (id !== recordId)
1314
- await store.delete(id);
1315
- }
1316
- return existing;
1317
- }
1318
- const db = this.workspaceDb;
1319
- if (!db || this.isClosing) {
1320
- throw new AbloConnectionError('Database not opened for durable writes', {
1321
- code: 'db_not_opened',
1322
- });
1323
- }
1324
- return new Promise((resolve, reject) => {
1325
- try {
1326
- const tx = db.transaction(['__transactions'], 'readwrite', {
1327
- durability: 'strict',
1328
- });
1329
- const store = tx.objectStore('__transactions');
1330
- const getRequest = store.get(recordId);
1331
- const sourceIds = [...new Set(consumedRecordIds)].filter((id) => id !== recordId);
1332
- const sourceRequests = sourceIds.map((id) => store.get(id));
1333
- const sourceExists = new Array(sourceRequests.length).fill(false);
1334
- let existing;
1335
- let collisionError;
1336
- let envelopeRead = false;
1337
- let sourcesRead = 0;
1338
- let promotionStarted = false;
1339
- const promote = () => {
1340
- if (promotionStarted ||
1341
- !envelopeRead ||
1342
- sourcesRead !== sourceRequests.length)
1343
- return;
1344
- promotionStarted = true;
1345
- if (existing && !isSameOutboxRecord(existing, record)) {
1346
- collisionError = new AbloValidationError('Pending-write key already identifies a different request', { code: 'idempotency_conflict' });
1347
- tx.abort();
1348
- return;
1349
- }
1350
- // A new envelope owns promotion only while every source row still
1351
- // exists. This is the fleet/tab execution claim: a second tab that
1352
- // restored the same journal entries under another key loses here and
1353
- // cannot dispatch. An identical existing envelope is an idempotent
1354
- // retry, so its already-consumed sources may be absent.
1355
- if (!existing && sourceExists.some((exists) => !exists)) {
1356
- collisionError = new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
1357
- tx.abort();
1358
- return;
1359
- }
1360
- if (!existing) {
1361
- store.add(record);
1362
- }
1363
- else if (isAcceptedOutboxPromotion(existing, record)) {
1364
- store.put(record);
1365
- }
1366
- for (const id of sourceIds)
1367
- store.delete(id);
1368
- };
1369
- getRequest.onsuccess = () => {
1370
- existing = getRequest.result;
1371
- envelopeRead = true;
1372
- promote();
1373
- };
1374
- getRequest.onerror = () => {
1375
- tx.abort();
1376
- };
1377
- sourceRequests.forEach((request, index) => {
1378
- request.onsuccess = () => {
1379
- sourceExists[index] = request.result !== undefined;
1380
- sourcesRead += 1;
1381
- promote();
1382
- };
1383
- request.onerror = () => {
1384
- tx.abort();
1385
- };
1386
- });
1387
- tx.oncomplete = () => { resolve(existing); };
1388
- tx.onabort = () => {
1389
- reject(collisionError ??
1390
- tx.error ??
1391
- getRequest.error ??
1392
- new Error('Durable-write transaction aborted'));
1393
- };
1394
- tx.onerror = () => {
1395
- // onabort owns rejection so the promise settles exactly once.
1396
- };
1397
- }
1398
- catch (error) {
1399
- reject(error instanceof Error ? error : new Error(String(error)));
1400
- }
1401
- });
1402
- }
1403
- async cleanupOldTransactions(maxAge) {
1404
- const store = this.transactionStore;
1405
- const rows = (await store.getAll());
1406
- const cutoff = Date.now() - maxAge;
1407
- let cleaned = 0;
1408
- for (const tx of rows) {
1409
- // Live write intent has no safe age-based expiry. In particular, server
1410
- // idempotency retention may already have elapsed, so silently deleting or
1411
- // blindly replaying an old envelope would both be unsafe. Restoration
1412
- // owns quarantine/reconciliation for these records.
1413
- if (tx.type === 'commit_envelope' ||
1414
- tx.type === 'http_commit_envelope' ||
1415
- tx.type === 'pending_mutation') {
1416
- continue;
1417
- }
1418
- if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
1419
- await store.delete(tx.id);
1420
- cleaned++;
1421
- }
1422
- }
1423
- return cleaned;
1424
- }
1425
- /**
1426
- * Store management
1427
- *
1428
- * `getStore(modelName, context?)` is defined near the top of this
1429
- * class — single accessor for both inMemory and IDB modes.
1430
- */
1431
- getAllStores() {
1432
- if (this.inMemory) {
1433
- return this.inMemoryStores;
1434
- }
1435
- return this.storeManager.getAllStores();
1436
- }
1437
- /**
1438
- * Model persistence tracking
1439
- */
1440
- async setModelPersisted(modelName, persisted) {
1441
- if (this.inMemory)
1442
- return; // No persistence tracking in memory mode
1443
- if (!this.workspaceDb) {
1444
- throw new AbloConnectionError('Database not opened', {
1445
- code: 'db_not_opened',
1446
- });
1447
- }
1448
- await this.databaseManager.setModelPersisted(this.workspaceDb, modelName, persisted);
1449
- }
1450
- async isModelPersisted(modelName) {
1451
- if (this.inMemory)
1452
- return false; // In-memory = nothing persisted
1453
- if (!this.workspaceDb) {
1454
- throw new AbloConnectionError('Database not opened', {
1455
- code: 'db_not_opened',
1456
- });
1457
- }
1458
- return await this.databaseManager.isModelPersisted(this.workspaceDb, modelName);
1459
- }
1460
- /**
1461
- * Statistics
1462
- */
1463
- async getStats() {
1464
- const storeStats = await this.storeManager.getComprehensiveStats();
1465
- return {
1466
- database: this.currentDbInfo,
1467
- stores: storeStats,
1468
- metadata: this.workspaceDb
1469
- ? await this.databaseManager.getWorkspaceMetadata(this.workspaceDb)
1470
- : null,
1471
- };
1472
- }
1473
- /**
1474
- * Lifecycle
1475
- */
1476
- isOpen() {
1477
- return this.workspaceDb !== null;
1478
- }
1479
- async close() {
1480
- // Mark database as closing FIRST to enable graceful degradation
1481
- // This allows in-flight operations to bail out gracefully
1482
- this.isClosing = true;
1483
- // Mark all stores as closing to prevent new operations
1484
- this.storeManager.markAllStoresAsClosing();
1485
- if (this.workspaceDb) {
1486
- this.workspaceDb.close();
1487
- this.workspaceDb = null;
1488
- }
1489
- await this.databaseManager.close();
1490
- this.currentDbInfo = null;
1491
- getContext().logger.debug('Database closed');
1492
- }
1493
- async clear(options = {}) {
1494
- await this.storeManager.clearAllStores();
1495
- if (options.includeWriteJournal) {
1496
- await this.transactionStore.clear();
1497
- }
1498
- getContext().logger.info('All stores cleared');
1499
- }
1500
- }