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