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