@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,1991 @@
1
+ /**
2
+ * The base class that application-specific sync stores extend. It supplies the
3
+ * shared orchestration for reads, writes, delta processing, and bootstrap, and
4
+ * exports the core types those stores build on.
5
+ *
6
+ * A subclass adds its own domain behavior — lazy-loaded relations,
7
+ * collaboration events, and model enrichment — by overriding the protected
8
+ * extension points defined here. The heavy lifting is delegated to injected
9
+ * collaborators: {@link SyncClient} owns pool writes and the transaction
10
+ * queue, {@link Database} owns local persistence, {@link InstanceCache} holds the
11
+ * in-memory models, and {@link ModelRegistry} holds their metadata.
12
+ */
13
+
14
+ import { makeObservable, observable, action, computed, runInAction } from 'mobx';
15
+ import { AbloConnectionError, AbloValidationError, toAbloError } from '@abloatai/transaction/errors';
16
+ import type { RecoveryClass } from '@abloatai/transaction/errorCodes';
17
+ import { ConnectionManager } from './sync/ConnectionManager.js';
18
+ import { contextLogger, contextSocketObservability } from './sync/contextPorts.js';
19
+ import { SubscriptionManager } from './sync/SubscriptionManager.js';
20
+ import {
21
+ resolveParticipantSyncGroups,
22
+ type ParticipantScope,
23
+ } from './sync/participants.js';
24
+ import type { SyncClient } from './SyncClient.js';
25
+ import type { Database, BootstrapResult, BootstrapRequirements } from './Database.js';
26
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
27
+ import type { InstanceCache } from './InstanceCache.js';
28
+ import { ModelRegistry } from './ModelRegistry.js';
29
+ import { PropertyType } from '@abloatai/transaction/types';
30
+ import {
31
+ SyncWebSocket,
32
+ type SyncDelta,
33
+ type SyncGroupChangePayload,
34
+ type GroupAddedPayload,
35
+ type GroupRemovedPayload,
36
+ type BootstrapHint,
37
+ type BootstrapDataEvent,
38
+ type PresenceUpdate,
39
+ type EventMap,
40
+ type DefaultCollaborationEvents,
41
+ type SyncWebSocketEventMap,
42
+ } from './sync/SyncWebSocket.js';
43
+ import { QueryProcessor } from './query/QueryProcessor.js';
44
+ import { Model, rowAsModel } from './Model.js';
45
+ import { globalRuntime } from './context.js';
46
+ import type { RuntimeContext } from './RuntimeContext.js';
47
+ import type { AbloPlugin, AppliedChange } from '../plugin.js';
48
+ import { AbloSessionError, isAccessCredentialExpiryCloseReason } from '@abloatai/transaction/errors';
49
+ import { ModelScope } from './InstanceCache.js';
50
+ import { LazyReferenceCollection } from './LazyReferenceCollection.js';
51
+ import type { Schema } from '@abloatai/transaction/schema/schema';
52
+ // The store contract types (SyncStoreContract, LocalMutation, SyncStatus)
53
+ // live in a React-free core module and are re-exported for React consumers.
54
+ import type { SyncStatus, SyncStoreContract, LocalMutation } from './storeContract.js';
55
+ import type { AuthCredentialSource } from '@abloatai/transaction/auth/credentialSource';
56
+ import type { ModelData } from '@abloatai/transaction/types/modelData';
57
+ import { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
58
+ import type { EnrichmentPlanEntry, ForeignKeyIndexSpec } from './sync/syncPlan.js';
59
+ import { CredentialLifecycle, type CredentialRefresher } from './sync/credentialLifecycle.js';
60
+ import { TerminalSessionLifecycle } from './sync/terminalSessionLifecycle.js';
61
+ import { wireSocketEvents } from './sync/socketEventWiring.js';
62
+ import { performReconnect as runReconnect } from './sync/reconnect.js';
63
+ import { initialize as runInitialize } from './sync/initialize.js';
64
+ import {
65
+ createConnectionManager as runCreateConnectionManager,
66
+ startConnectionManager as runStartConnectionManager,
67
+ waitForWebSocketConnected as runWaitForWebSocketConnected,
68
+ } from './sync/connectionManagerLifecycle.js';
69
+ import * as groupChange from './sync/groupChange.js';
70
+ import type { GroupChangeContext } from './sync/groupChange.js';
71
+ import * as bootstrapApply from './sync/bootstrapApply.js';
72
+ import type { PoolContext, RehydrationStats } from './sync/bootstrapApply.js';
73
+ import * as deltaPipeline from './sync/deltaPipeline.js';
74
+ import type { DeltaPipelineContext } from './sync/deltaPipeline.js';
75
+ import type { ParticipantKind } from '@abloatai/transaction/types/participant';
76
+ import { queryByClass as runQueryByClass, countModels } from './store/queryApi.js';
77
+ import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
78
+ import type { CommitLatencySample } from './transactions/mutations/commitLatency.js';
79
+
80
+ // ── Exported types ──────────────────────────────────────────────────────────
81
+
82
+ /** Constructor type for Model subclasses (accepts abstract classes) */
83
+ export type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
84
+
85
+ /** Concrete constructor type for instantiation */
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Constructor args vary per model (PrismaTask, Record<string, unknown>, etc.)
87
+ export type ConcreteModelConstructor<T extends Model> = new (data?: any) => T;
88
+
89
+ // ModelData is defined in a separate module to break the type cycle between
90
+ // BaseSyncedStore and SyncClient, and is re-exported here.
91
+ export type { ModelData } from '@abloatai/transaction/types/modelData';
92
+
93
+ /** Query result interface */
94
+ export interface QueryResult<T extends Model> {
95
+ data: T[];
96
+ total: number;
97
+ hasMore: boolean;
98
+ fromCache?: boolean;
99
+ }
100
+ // ForeignKeyIndexSpec and EnrichmentPlanEntry are defined alongside
101
+ // deriveSyncPlanFromSchema and re-exported here.
102
+ export type { ForeignKeyIndexSpec, EnrichmentPlanEntry } from './sync/syncPlan.js';
103
+
104
+ /** Configuration for SyncedStore behavior */
105
+ export interface SyncedStoreConfig {
106
+ enableOffline?: boolean;
107
+ enableCache?: boolean;
108
+ enableTelemetry?: boolean;
109
+
110
+ /**
111
+ * Wire message types to surface as collaboration events, e.g.
112
+ * `['document:selection', 'document:cursor']`.
113
+ *
114
+ * The vocabulary belongs to the application, not the SDK — these name the
115
+ * application's own concepts, and a schema with no documents should never see
116
+ * them. Defaults to none, so an application opts in by naming the events it
117
+ * actually broadcasts.
118
+ */
119
+ collaborationEvents?: readonly string[];
120
+
121
+ /**
122
+ * Declarative enrichment plan consumed by `enrichRelations`. Replaces
123
+ * the subclass override of `enrichRelations` for per-model parent
124
+ * attachment. Merged with schema-derived entries (relations marked
125
+ * `{ enrich: true }` on `belongsTo`).
126
+ */
127
+ enrichmentPlan?: readonly EnrichmentPlanEntry[];
128
+
129
+ /**
130
+ * Foreign-key indexes to register on the InstanceCache at construction
131
+ * time. Replaces the subclass override of `registerForeignKeys` for
132
+ * per-model FK registration. Merged with schema-derived entries
133
+ * (relations marked `{ index: true }` on `belongsTo`). Both sets
134
+ * are registered before the legacy `registerForeignKeys()` hook
135
+ * fires, so subclasses can still add more on top.
136
+ */
137
+ foreignKeyIndexes?: readonly ForeignKeyIndexSpec[];
138
+ }
139
+ // SyncStatus is defined in the React-free store-contract module, next to
140
+ // SyncStoreContract which embeds it, and is re-exported here.
141
+ export type { SyncStatus } from './storeContract.js';
142
+
143
+ /** User context for initialization */
144
+ export interface UserContext {
145
+ userId: string;
146
+ organizationId: string;
147
+ /** Authenticated data-plane coordinates used to isolate local persistence. */
148
+ projectId?: string | null;
149
+ environment?: 'sandbox' | 'production' | null;
150
+ sandboxId?: string | null;
151
+ role?: string;
152
+ teamIds?: string[];
153
+ /** Participant kind on the wire. Default 'user' for browser
154
+ * sessions; 'agent' for headless bots / worker processes. The
155
+ * store routes this to SyncWebSocket so the WS URL carries
156
+ * `kind=agent` and the server applies capability-token auth. */
157
+ kind?: ParticipantKind;
158
+ /** Restricted (`rk_`) API key for `kind: 'agent'` — the agent's
159
+ * bearer credential. Sent in the `ablo.bearer.<token>` WebSocket
160
+ * subprotocol, never in the URL. */
161
+ capabilityToken?: string;
162
+ /** Server-authoritative sync groups, supplied by auth/capability
163
+ * exchange. The SDK does not invent org/user/default groups; app
164
+ * structure comes from schema-declared scopes and server-issued
165
+ * authorization. */
166
+ syncGroups?: readonly string[];
167
+ /**
168
+ * How aggressively this participant should pull baseline state at
169
+ * startup.
170
+ *
171
+ * - `'full'` (default): pull every delta in scope before `ready()`
172
+ * resolves. The standard browser/user replica behavior.
173
+ * - `'none'`: open the WebSocket and process live deltas only.
174
+ * Reads go through `model.get()` / filtered subscriptions
175
+ * backfilled by `Covering` deltas. Suitable for transactional
176
+ * participants — agent-worker, video-pipeline, routine runners —
177
+ * that don't need a local replica of the org's tenant plane.
178
+ */
179
+ bootstrapMode?: 'full' | 'none';
180
+ }
181
+
182
+ /** Smart sync options */
183
+ export interface SmartSyncOptions {
184
+ maxDeltasBeforeBootstrap?: number;
185
+ maxBootstrapSize?: number;
186
+ batchingDelay?: number;
187
+ maxBatchSize?: number;
188
+ }
189
+
190
+ // RehydrationStats is defined alongside the bootstrap-apply path and is
191
+ // re-exported here.
192
+ export type { RehydrationStats } from './sync/bootstrapApply.js';
193
+
194
+ /**
195
+ * Bootstrap retry configuration.
196
+ *
197
+ * There is deliberately no overall timeout here. How long one attempt may run
198
+ * is not a policy this layer gets to invent — it is a property of the fetcher's
199
+ * watchdogs, read from `BootstrapFetcher.budgetMs`. A second number kept here
200
+ * would only be able to disagree with them, which is exactly what it used to do.
201
+ */
202
+ export const BOOTSTRAP_CONFIG = {
203
+ MAX_RETRY_ATTEMPTS: 3,
204
+ RETRY_DELAY_MS: 500,
205
+ } as const;
206
+
207
+ // Re-export for clean API
208
+ export { ModelScope };
209
+
210
+ // Re-export sync types consumers need
211
+ export type {
212
+ SyncDelta,
213
+ SyncGroupChangePayload,
214
+ GroupAddedPayload,
215
+ GroupRemovedPayload,
216
+ BootstrapHint,
217
+ BootstrapDataEvent,
218
+ PresenceUpdate,
219
+ };
220
+
221
+ // deriveSyncPlanFromSchema derives a sync plan from a schema and is
222
+ // re-exported here.
223
+ export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
224
+
225
+ // ── Base class ──────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * The abstract base class that application-specific sync stores extend. It
229
+ * carries the injected collaborators, the observable sync status, and the
230
+ * orchestration for initialization, delta processing, bootstrap, and the
231
+ * read and write API. A subclass supplies its own domain behavior by
232
+ * overriding the protected extension points defined here and by typing its
233
+ * collaboration events through the generic parameter.
234
+ *
235
+ * A subclass must call `super(dependencies, config)` and then set up its own
236
+ * MobX observables.
237
+ *
238
+ * Generic over `TCollaboration` — an app-defined event map for real-time
239
+ * collaboration events (cursors, selections, presence beyond the core set).
240
+ * Subclasses pass their own event map to get typed `subscribe()` calls on
241
+ * the underlying SyncWebSocket without casts:
242
+ *
243
+ * @example
244
+ * interface EditorEvents {
245
+ * 'document:selection': [SelectionEvent];
246
+ * 'document:cursor': [CursorEvent];
247
+ * }
248
+ * class EditorStore extends BaseSyncedStore<EditorEvents> {
249
+ * subscribeToCursor(handler: (e: CursorEvent) => void) {
250
+ * return this.syncWebSocket.subscribe('document:cursor', handler);
251
+ * }
252
+ * }
253
+ */
254
+ export class BaseSyncedStore<
255
+ // The collaboration event map. Each key maps to a handler args tuple.
256
+ // `EventMap<T>` (defined in sync/SyncWebSocket.ts) is a homomorphic mapped
257
+ // type that says "every value is unknown[]" — it accepts both closed
258
+ // interfaces (like Ablo's `AbloCollaborationEvents`) AND open Record types,
259
+ // which `Record<string, unknown[]>` does not (interfaces lack the implicit
260
+ // string index signature that `Record<string, ...>` requires). The default
261
+ // is `DefaultCollaborationEvents` (= `Record<string, never>`), which
262
+ // trivially satisfies `EventMap<T>` because `keyof` is `never`.
263
+ TCollaboration extends EventMap<TCollaboration> = DefaultCollaborationEvents,
264
+ // The app's schema, so `query.<modelKey>` + `create(key, data)` return
265
+ // precisely-typed entities. Defaulting to the erased `Schema` shape lets
266
+ // callers that don't know their schema continue to compile; app
267
+ // subclasses parameterize with `typeof schema` to get real inference.
268
+ TSchema extends Schema = Schema
269
+ > {
270
+ // ── Observable sync status for UI ──
271
+ syncStatus: SyncStatus = {
272
+ state: 'idle',
273
+ progress: 0,
274
+ pendingChanges: 0,
275
+ isSessionError: false,
276
+ };
277
+
278
+ // ── Injected dependencies ──
279
+ /** The owning client's runtime; the module-global bridge when constructed directly. */
280
+ protected readonly runtime: RuntimeContext;
281
+ /** The installed plugins the delta pipeline dispatches stage handlers to. */
282
+ protected readonly stagePlugins: readonly AbloPlugin[];
283
+ protected readonly syncClient: SyncClient;
284
+ protected readonly database: Database;
285
+ protected readonly objectPool: InstanceCache;
286
+ protected readonly modelRegistry: ModelRegistry;
287
+ protected readonly auth?: AuthCredentialSource;
288
+ /**
289
+ * Schema the store was constructed with. Used by the schema-typed
290
+ * `create(key, data)` factory and model self-healing.
291
+ */
292
+ protected readonly schema?: TSchema;
293
+
294
+
295
+ // ── Real-time sync ──
296
+ /**
297
+ * The connection, owned by whoever built this store (ADR 0016 follow-up
298
+ * 3b): the host constructs it and hands it in, the store seeds its late
299
+ * values during `initialize()` and owns the lifecycle from there. One
300
+ * instance for the store's whole lifetime — reconnects replace the socket
301
+ * inside it, never the object.
302
+ */
303
+ protected readonly syncWebSocket: SyncWebSocket<TCollaboration>;
304
+ /**
305
+ * Dynamic read interest (area-of-interest) over the connection's sync
306
+ * groups. Constructed with the connection; the permanent base scopes are
307
+ * seeded in `setupWebSocketSync` once identity resolves.
308
+ */
309
+ protected readonly areaOfInterest: SubscriptionManager;
310
+ /** Sync groups whose current state has been backfilled into the pool
311
+ * (hydrate-on-enter). Cleared when the pool is reset on (re)bootstrap. */
312
+ private readonly hydratedGroups = new Set<string>();
313
+ /** In-flight scoped hydrations, keyed by group — single-flights concurrent
314
+ * enters of the same scope so they share one fetch. */
315
+ private readonly hydratingGroups = new Map<string, Promise<void>>();
316
+ private _syncServerUrl?: string;
317
+ /** Application-declared collaboration event types; empty unless configured. */
318
+ private _collaborationEvents: readonly string[] = [];
319
+
320
+ /**
321
+ * Public accessor for the underlying SyncWebSocket. Used by the
322
+ * factory in `createSyncEngine` to wire the default mutation
323
+ * executor — the executor needs the WS handle to send commit
324
+ * frames, and the factory can't reach `protected` state through
325
+ * normal typing.
326
+ */
327
+ getSyncWebSocket(): SyncWebSocket<TCollaboration> {
328
+ return this.syncWebSocket;
329
+ }
330
+
331
+ /**
332
+ * Subscribe to pushed frames — deltas, presence updates, claim grants and
333
+ * losses, connection changes, and this store's collaboration events.
334
+ * Durable by construction: the connection object exists for the store's
335
+ * whole lifetime (reconnects replace only the socket inside it), so a
336
+ * subscription made before the first connect starts delivering when the
337
+ * socket opens and keeps delivering across every reconnect. Returns the
338
+ * unsubscribe function.
339
+ */
340
+ subscribe<K extends keyof SyncWebSocketEventMap<TCollaboration>>(
341
+ event: K,
342
+ handler: (...args: SyncWebSocketEventMap<TCollaboration>[K]) => void,
343
+ ): () => void {
344
+ return this.syncWebSocket.subscribe(event, handler);
345
+ }
346
+
347
+ /**
348
+ * Send a collaboration event (an app-specific real-time message from this
349
+ * store's `TCollaboration` map). A no-op while the connection is down —
350
+ * presence-grade traffic is not queued.
351
+ */
352
+ sendCollaborationEvent<K extends string & keyof TCollaboration>(
353
+ messageType: K,
354
+ payload: TCollaboration[K] extends [infer P]
355
+ ? Omit<P & Record<string, unknown>, 'timestamp'>
356
+ : never,
357
+ ): void {
358
+ this.syncWebSocket.sendCollaborationEvent(messageType, payload);
359
+ }
360
+
361
+ // ── Area-of-interest (dynamic read subscription) ─────────────────
362
+ //
363
+ // `enterScope`/`leaveScope` move the connection's read interest as the
364
+ // user navigates (open or close a record); `pinScope`/`unpinScope`
365
+ // express prominence (an active claim keeps a group subscribed). All four
366
+ // resolve the scope to sync-group strings through the same resolver the
367
+ // claim path uses (`resolveParticipantSyncGroups`), so read interest and
368
+ // write claims always agree on the string for a given entity. Before the
369
+ // connection opens they record interest without a wire send, and they
370
+ // never reject when the transport is offline (see
371
+ // {@link SubscriptionManager.reconcile}); the on-connect `resync` pushes
372
+ // whatever interest accumulated.
373
+
374
+ private scopeToGroups(scope: ParticipantScope): string[] {
375
+ return resolveParticipantSyncGroups(scope, this.schema);
376
+ }
377
+
378
+ /**
379
+ * Bring a scope into view and subscribe to its sync groups. With
380
+ * `{ hydrate: true }`, also backfill the groups' current state into the pool
381
+ * once the subscription is active. The order matters: subscribing first
382
+ * guarantees no live delta is missed in the gap before the snapshot lands.
383
+ * Hydration is best-effort — a failed backfill never rejects `enterScope`,
384
+ * and the live delta stream keeps flowing regardless.
385
+ */
386
+ enterScope(scope: ParticipantScope, opts?: { hydrate?: boolean }): Promise<void> {
387
+ const groups = this.scopeToGroups(scope);
388
+ const subscribed = Promise.all(groups.map((g) => this.areaOfInterest.enter(g))).then(
389
+ () => undefined,
390
+ );
391
+ if (!opts?.hydrate) return subscribed;
392
+ return subscribed.then(() => this.hydrateGroups(groups));
393
+ }
394
+
395
+ /**
396
+ * Backfill the current state of `syncGroups` into the pool with a side-effect-free
397
+ * scoped snapshot fetch followed by the version-guarded scoped apply. The call
398
+ * is idempotent (it skips groups already hydrated) and single-flight (concurrent
399
+ * enters of the same group share one fetch). On error the groups are left
400
+ * unmarked, so a later re-enter retries.
401
+ */
402
+ protected async hydrateGroups(syncGroups: readonly string[]): Promise<void> {
403
+ const need = syncGroups.filter(
404
+ (g) => !this.hydratedGroups.has(g) && !this.hydratingGroups.has(g),
405
+ );
406
+ if (need.length === 0) {
407
+ // Nothing new to fetch, but await any in-flight hydration for the
408
+ // requested groups so callers can sequence on completion.
409
+ await Promise.all(
410
+ syncGroups
411
+ .map((g) => this.hydratingGroups.get(g))
412
+ .filter((p): p is Promise<void> => p !== undefined),
413
+ );
414
+ return;
415
+ }
416
+ const work = (async () => {
417
+ try {
418
+ const data = await this.database.fetchScopedBootstrapData(need);
419
+ this.syncClient.applyBootstrapDataToPool(data, undefined, { scoped: true });
420
+ for (const g of need) this.hydratedGroups.add(g);
421
+ } catch (err) {
422
+ this.runtime.logger.debug('[BaseSyncedStore] scoped hydrate failed', {
423
+ syncGroups: need,
424
+ error: err instanceof Error ? err.message : String(err),
425
+ });
426
+ // Soft-fail — leave `need` un-hydrated so a re-enter retries.
427
+ } finally {
428
+ for (const g of need) this.hydratingGroups.delete(g);
429
+ }
430
+ })();
431
+ for (const g of need) this.hydratingGroups.set(g, work);
432
+ await work;
433
+ }
434
+
435
+ /** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
436
+ leaveScope(scope: ParticipantScope): Promise<void> {
437
+ return Promise.all(
438
+ this.scopeToGroups(scope).map((g) => this.areaOfInterest.leave(g)),
439
+ ).then(() => undefined);
440
+ }
441
+
442
+ /** Pin a scope (active claim / prominence) → never warms while pinned. */
443
+ pinScope(scope: ParticipantScope): Promise<void> {
444
+ return Promise.all(
445
+ this.scopeToGroups(scope).map((g) => this.areaOfInterest.pin(g)),
446
+ ).then(() => undefined);
447
+ }
448
+
449
+ /** Release a pin → the group transitions to warm rather than dropping. */
450
+ unpinScope(scope: ParticipantScope): Promise<void> {
451
+ return Promise.all(
452
+ this.scopeToGroups(scope).map((g) => this.areaOfInterest.unpin(g)),
453
+ ).then(() => undefined);
454
+ }
455
+
456
+ // ── Internal helpers ──
457
+ protected readonly queryProcessor: QueryProcessor;
458
+ /**
459
+ * Runtime behavior flags only — the schema/config arrays
460
+ * (`enrichmentPlan`, `foreignKeyIndexes`) are consumed at construction
461
+ * time and stored on the instance as `enrichmentPlan` and
462
+ * pool-registered indexes. They don't need to persist on `this.config`.
463
+ */
464
+ protected readonly config: Required<
465
+ Pick<SyncedStoreConfig, 'enableOffline' | 'enableCache' | 'enableTelemetry'>
466
+ >;
467
+ protected disposers: (() => void)[] = [];
468
+ protected initialized = false;
469
+ protected dataReady = false;
470
+
471
+ // ── User context ──
472
+ // The identity the consumer supplied to `initialize()`: user id,
473
+ // organization id, and optional team ids. Reads are scoped to this
474
+ // identity, and the sync-group subscription is derived from it.
475
+ protected userContext: UserContext | null = null;
476
+
477
+ // ── Smart sync ──
478
+ /**
479
+ * Declarative enrichment plan: "for model X, when a delta arrives,
480
+ * read data[foreignKey] and attach the matching parent from the pool
481
+ * as data[relationKey]." Merged from schema-derived + config at
482
+ * construction time. Replaces the `enrichRelations` subclass override
483
+ * pattern.
484
+ */
485
+ protected enrichmentPlan: readonly EnrichmentPlanEntry[] = [];
486
+ protected smartSyncOptions: Required<SmartSyncOptions>;
487
+ protected pendingDeltas: SyncDelta[] = [];
488
+ protected batchTimer: ReturnType<typeof setTimeout> | null = null;
489
+ protected syncPromise: Promise<void> | null = null;
490
+ /** Resume/ack cursor — delegates to the shared LogPosition (see
491
+ * logPosition.ts). Advances only after IDB persistence. */
492
+ protected get lastAckedId(): number {
493
+ return this.syncClient.position.persisted;
494
+ }
495
+ /** Pool-applied cursor — delegates to the shared LogPosition. */
496
+ protected get highestProcessedSyncId(): number {
497
+ return this.syncClient.position.applied;
498
+ }
499
+
500
+ // ── Delta queuing during bootstrap ──
501
+ protected bootstrapDeltaQueue: SyncDelta[] | null = null;
502
+ protected activeBootstrapCount = 0;
503
+ /** The live deadline for the bootstrap attempt in flight, if any. */
504
+ private bootstrapDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
505
+
506
+ // ── Delete tracking ──
507
+ protected pendingDeletes = new Set<string>();
508
+
509
+ // ── Model type hydration ──
510
+ protected modelTypesHydrated = new Set<string>();
511
+ protected modelTypeHydrationInFlight = new Map<string, Promise<void>>();
512
+
513
+ constructor(
514
+ dependencies: {
515
+ syncClient: SyncClient;
516
+ database: Database;
517
+ objectPool: InstanceCache;
518
+ modelRegistry: ModelRegistry;
519
+ /**
520
+ * The connection, built by the host. When omitted, the store constructs
521
+ * its own from `url` and the collaboration-event config — the
522
+ * self-contained path subclasses and tests use. Either way the store
523
+ * owns the lifecycle from here: it seeds the late values (identity,
524
+ * read scope, resume cursor) during `initialize()` and releases the
525
+ * first connect.
526
+ */
527
+ syncWebSocket?: SyncWebSocket<TCollaboration>;
528
+ /**
529
+ * Optional schema. When provided, {@link deriveSyncPlanFromSchema} walks
530
+ * the schema's models and relations to auto-populate foreign-key indexes
531
+ * and the enrichment plan from their declarative annotations. Subclasses
532
+ * that register model classes directly can instead pass explicit
533
+ * `config.foreignKeyIndexes` / `config.enrichmentPlan`.
534
+ */
535
+ schema?: TSchema;
536
+ /** Sync server URL for WebSocket connection. Converted to wss:// automatically. */
537
+ url?: string;
538
+ /** Shared bearer credential source for every auth-aware transport. */
539
+ auth?: AuthCredentialSource;
540
+ /** The owning client's runtime. Defaults to the module-global bridge. */
541
+ runtime?: RuntimeContext;
542
+ /**
543
+ * The installed plugins, whose declared stage handlers the delta
544
+ * pipeline dispatches. Empty on direct construction — the store's own
545
+ * apply is then the whole pipeline.
546
+ */
547
+ stagePlugins?: readonly AbloPlugin[];
548
+ },
549
+ config: SyncedStoreConfig = {}
550
+ ) {
551
+ this.runtime = dependencies.runtime ?? globalRuntime;
552
+ this.stagePlugins = dependencies.stagePlugins ?? [];
553
+ this.syncClient = dependencies.syncClient;
554
+ this.database = dependencies.database;
555
+ this.objectPool = dependencies.objectPool;
556
+ this.modelRegistry = dependencies.modelRegistry;
557
+ this.auth = dependencies.auth;
558
+ this.schema = dependencies.schema;
559
+ this.terminalSessionLifecycle = new TerminalSessionLifecycle({
560
+ runtime: this.runtime,
561
+ listeners: this.sessionErrorListeners,
562
+ purgeAuthenticatedState: () => this.purge(),
563
+ updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
564
+ });
565
+ this._syncServerUrl = dependencies.url;
566
+ this._collaborationEvents = config.collaborationEvents ?? [];
567
+
568
+ // The connection exists from construction (ADR 0016 follow-up 3b): the
569
+ // host hands one in, or the store builds its own. `deferConnect` holds
570
+ // it closed until `initialize()` has seeded identity and read scope, so
571
+ // nothing can open an unscoped connection in between.
572
+ this.syncWebSocket =
573
+ dependencies.syncWebSocket ??
574
+ new SyncWebSocket<TCollaboration>({
575
+ baseUrl: this._syncServerUrl,
576
+ collaborationEvents: [...this._collaborationEvents],
577
+ getAuthToken: this.auth?.getAuthToken,
578
+ deferConnect: true,
579
+ capabilities: {
580
+ partialBootstrap: true,
581
+ compressedDeltas: true,
582
+ streamingBootstrap: true,
583
+ batchedDeltas: true,
584
+ },
585
+ });
586
+ this.areaOfInterest = new SubscriptionManager({ transport: this.syncWebSocket });
587
+ this.wireSocketEvents();
588
+
589
+ // QueuedMutation events for pendingChanges tracking — connection-
590
+ // independent, wired once for the store's lifetime.
591
+ this.disposers.push(
592
+ this.syncClient.onTransactionEvent('created', () => { this.incrementPendingChanges(); }),
593
+ this.syncClient.onTransactionEvent('completed', () => { this.decrementPendingChanges(); }),
594
+ this.syncClient.onTransactionEvent('failed', () => { this.decrementPendingChanges(); }),
595
+ );
596
+
597
+ // Set this store as the global Model store
598
+ Model.setStore(this as Parameters<typeof Model.setStore>[0]);
599
+
600
+ // ── Schema-derived sync plan ───────────────────────────────────────
601
+ //
602
+ // When a schema is provided, derive foreign-key indexes and the
603
+ // enrichment plan from the declarative annotations on its `belongsTo`
604
+ // relations. Explicit config fields layer on top, so a subclass can
605
+ // pass hardcoded arrays without supplying a full schema.
606
+ //
607
+ // Order matters: schema-derived entries are registered first and
608
+ // config entries second, so that when a caller supplies both, the
609
+ // explicit config entries win and are never shadowed by derivation.
610
+ const derived = dependencies.schema
611
+ ? deriveSyncPlanFromSchema(dependencies.schema)
612
+ : { enrichmentPlan: [], foreignKeyIndexes: [] };
613
+
614
+ const mergedForeignKeyIndexes: ForeignKeyIndexSpec[] = [
615
+ ...derived.foreignKeyIndexes,
616
+ ...(config.foreignKeyIndexes ?? []),
617
+ ];
618
+ for (const { modelName, fieldName } of mergedForeignKeyIndexes) {
619
+ this.objectPool.registerForeignKey(modelName, fieldName);
620
+ }
621
+
622
+ // Override hook — called after schema-driven registration so a subclass
623
+ // can add more foreign keys on top of the declarative set.
624
+ this.registerForeignKeys();
625
+
626
+ this.enrichmentPlan = [
627
+ ...derived.enrichmentPlan,
628
+ ...(config.enrichmentPlan ?? []),
629
+ ];
630
+
631
+ // Set dependencies for LazyReferenceCollection
632
+ LazyReferenceCollection.setDependencies(this.database, this.objectPool);
633
+
634
+ // Apply config defaults
635
+ this.config = {
636
+ enableOffline: config.enableOffline ?? true,
637
+ enableCache: config.enableCache ?? true,
638
+ enableTelemetry: config.enableTelemetry ?? false,
639
+ };
640
+
641
+ // Smart sync options
642
+ this.smartSyncOptions = {
643
+ maxDeltasBeforeBootstrap: 1000,
644
+ maxBootstrapSize: 10 * 1024 * 1024,
645
+ batchingDelay: 100,
646
+ maxBatchSize: 50,
647
+ };
648
+
649
+ // Create internal helpers
650
+ this.queryProcessor = new QueryProcessor({
651
+ enableCache: this.config.enableCache,
652
+ });
653
+
654
+ // Auto-invalidate query cache when SyncClient modifies the pool.
655
+ // Replaces all manual queryProcessor.invalidateCache() calls.
656
+ this.syncClient.on('models:changed', (modelNames: Set<string>) => {
657
+ for (const name of modelNames) {
658
+ this.queryProcessor.invalidateCache(`.*${name}.*`);
659
+ }
660
+ });
661
+
662
+ // Make the sync-status fields observable so consumer code can do
663
+ // reaction(() => store.isReady, ...)
664
+ // observer(() => store.isOffline)
665
+ // and actually receive notifications. Without these annotations,
666
+ // `syncStatus` and `dataReady` are plain properties, and the derived
667
+ // getters (isReady, isSyncing, isOffline, and the rest) never emit
668
+ // change signals — so a `reaction` on `store.isReady` would never fire.
669
+ makeObservable<this, 'dataReady'>(this, {
670
+ syncStatus: observable,
671
+ dataReady: observable,
672
+ isReady: computed,
673
+ isSyncing: computed,
674
+ isOffline: computed,
675
+ isReconnecting: computed,
676
+ isError: computed,
677
+ hasUnsyncedChanges: computed,
678
+ });
679
+ }
680
+
681
+ // ── Protected extension points ────────────────────────────────────────────
682
+
683
+ /**
684
+ * Register foreign-key indexes for constant-time lookups.
685
+ *
686
+ * This is an override hook. The preferred way to declare a foreign-key
687
+ * index is `config.foreignKeyIndexes` at construction time, or marking the
688
+ * `belongsTo` relation with `{ index: true }` in the schema. The hook fires
689
+ * after the schema-derived and config registrations, so a subclass can
690
+ * layer additional indexes on top.
691
+ */
692
+ protected registerForeignKeys(): void {}
693
+
694
+ /**
695
+ * Enrich delta data with related models from the InstanceCache.
696
+ *
697
+ * Base implementation walks `this.enrichmentPlan` — entries populated
698
+ * from the schema's `{ enrich: true }` relations and from
699
+ * `config.enrichmentPlan`. Subclasses can still override for bespoke
700
+ * logic, calling `super.enrichRelations(modelName, data)` first to
701
+ * apply the declarative plan before layering on custom work.
702
+ *
703
+ * Enrichment is best-effort: if the parent isn't yet in the pool
704
+ * (e.g., a child delta arrives before its parent in a bootstrap
705
+ * batch), the entry is silently skipped and the data passes through
706
+ * untouched. The next delta for the same child will re-enrich.
707
+ */
708
+ protected enrichRelations(modelName: string, data: ModelData): ModelData {
709
+ for (const entry of this.enrichmentPlan) {
710
+ if (entry.modelName !== modelName) continue;
711
+ const fkValue = data[entry.foreignKey];
712
+ if (typeof fkValue !== 'string') continue;
713
+ const parent = this.objectPool.get(fkValue);
714
+ if (parent) {
715
+ data[entry.relationKey] = parent;
716
+ }
717
+ }
718
+ return data;
719
+ }
720
+
721
+ /** Check if a model name represents a custom/dynamic entity type. */
722
+ protected isCustomEntity(modelName: string): boolean {
723
+ return !this.objectPool.registry.getModelByName(modelName);
724
+ }
725
+
726
+ /** Create a custom entity instance from delta data. Override for domain-specific custom entities. */
727
+ protected createCustomEntity(_modelName: string, _modelId: string, _data: Record<string, unknown>): Model | null {
728
+ return null;
729
+ }
730
+
731
+ /** Called before save for domain-specific validation/self-healing. */
732
+ protected beforeSave(_model: Model): void {}
733
+
734
+ /** Connection lifecycle event callback — set by subclass to wire connection state machine. */
735
+ protected onConnectionEvent?: (event: string) => void;
736
+
737
+ /**
738
+ * Internal connection FSM. Owns network probe + backoff + reconnect
739
+ * orchestration for the default path. Constructed lazily once we
740
+ * have a user context + a WebSocket (see `wireWebSocketEvents`);
741
+ * driven by the `onConnectionEvent` hook AND browser online/offline
742
+ * events it sets up itself.
743
+ *
744
+ * Every consumer gets production-grade offline-to-online recovery
745
+ * out of the box. Subclasses that want their own lifecycle owner
746
+ * can disable this by overriding `createConnectionManager()` to
747
+ * return null.
748
+ */
749
+ protected connectionManager: import('./sync/ConnectionManager.js').ConnectionManager | null = null;
750
+
751
+ /**
752
+ * Access-credential re-mint + proactive pre-roll — extracted to
753
+ * sync/credentialLifecycle.ts. Owns the refresher hook, the single-flight
754
+ * guard, and the browser-only refresh timer / wake listener; talks back
755
+ * through three lazily-resolved callbacks (the ConnectionManager doesn't
756
+ * exist until `setupWebSocketSync`). The `setCredentialRefresher` /
757
+ * `performCredentialRefresh` / `startCredentialLifecycle` methods below
758
+ * are thin delegates so the store's public surface is unchanged.
759
+ */
760
+ private readonly credentialLifecycle = new CredentialLifecycle(
761
+ {
762
+ setAuthToken: (token) => { this.auth?.setAuthToken(token); },
763
+ nudgeReconnect: () => { this.nudgeReconnect(); },
764
+ reportSessionExpired: () => {
765
+ this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
766
+ },
767
+ },
768
+ contextLogger,
769
+ );
770
+
771
+ /**
772
+ * Listeners registered via `subscribeSessionError()`. Fired when the
773
+ * WebSocket closes with a session-invalid code (1008/4001/4003) or a
774
+ * session-error event is received. Separate from `onConnectionEvent`
775
+ * (which exists for the ConnectionStore FSM) so multiple consumers —
776
+ * typically `<AbloProvider>` and a connection-lifecycle owner — can
777
+ * both react without racing on the single-callback slot.
778
+ */
779
+ protected sessionErrorListeners = new Set<(error: Error) => void>();
780
+ private readonly terminalSessionLifecycle: TerminalSessionLifecycle;
781
+
782
+ /**
783
+ * Subscribe to session-error events. The returned function removes
784
+ * the listener. Safe to call multiple times from different consumers
785
+ * (each gets its own slot in the listener set).
786
+ */
787
+ subscribeSessionError(listener: (error: Error) => void): () => void {
788
+ this.sessionErrorListeners.add(listener);
789
+ return () => { this.sessionErrorListeners.delete(listener); };
790
+ }
791
+
792
+ /**
793
+ * Subscribe to per-mutation failure payloads. Forwarded from the
794
+ * underlying `SyncClient.mutationQueue` so consumers (toast layer,
795
+ * route-level reverted boundaries, telemetry) can react without
796
+ * reaching across the store. Returns an unsubscribe function.
797
+ *
798
+ * Why this lives on the base store rather than SyncClient: the React
799
+ * `<AbloProvider>` binds against this surface, so adding it here
800
+ * keeps the engine's internal wiring private while still giving the
801
+ * SDK a single hook to expose. Mirrors `subscribeSessionError` —
802
+ * same shape, same lifecycle.
803
+ */
804
+ subscribeMutationFailure(
805
+ listener: (payload: {
806
+ transaction: QueuedMutation;
807
+ error: Error;
808
+ permanent?: boolean;
809
+ }) => void,
810
+ ): () => void {
811
+ return this.syncClient.onMutationFailure(listener);
812
+ }
813
+
814
+ /**
815
+ * Subscribe to commit round-trip latency. Forwarded from the underlying
816
+ * `SyncClient` for the same reason as `subscribeMutationFailure` — the
817
+ * React provider binds against this surface, so the engine's wiring stays
818
+ * private while the SDK keeps one hook to expose.
819
+ */
820
+ subscribeCommitLatency(
821
+ listener: (
822
+ sample: CommitLatencySample,
823
+ ) => void,
824
+ ): () => void {
825
+ return this.syncClient.onCommitLatency(listener);
826
+ }
827
+
828
+ /**
829
+ * Wait for the in-flight transaction for (modelName, modelId) to be
830
+ * confirmed by the server. See `SyncClient.waitForConfirmation` for the
831
+ * lookup contract; resolves immediately if nothing is in flight.
832
+ */
833
+ waitForConfirmation(modelName: string, modelId: string): Promise<void> {
834
+ return this.syncClient.waitForConfirmation(modelName, modelId);
835
+ }
836
+
837
+ /**
838
+ * Observe the LOCAL mutation stream for undo recording (see
839
+ * {@link import('./storeContract.js').LocalMutation}). Taps the
840
+ * MutationQueue's `transaction:created` event — fired once per local
841
+ * create/update/delete/archive with `previousData` already captured.
842
+ * Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
843
+ * emit here, so undo is naturally local-only (you can't undo a teammate).
844
+ */
845
+ subscribeLocalMutations(handler: (mutation: LocalMutation) => void): () => void {
846
+ // Tap the MutationQueue directly via `onLocalTransaction`. The previous
847
+ // `syncClient.subscribe('transaction:created', …)` route registered the
848
+ // handler on SyncClient's OWN emitter, which never fires that event (only
849
+ // the queue's emitter does) — so undo recorded nothing. See
850
+ // `SyncClient.onLocalTransaction` for the full rationale.
851
+ return this.syncClient.onLocalTransaction((tx) => {
852
+ if (!tx.modelName || !tx.modelId) return;
853
+ handler({
854
+ type: tx.type,
855
+ modelName: tx.modelName,
856
+ modelId: tx.modelId,
857
+ data: tx.data ?? null,
858
+ previousData: tx.previousData ?? null,
859
+ });
860
+ });
861
+ }
862
+
863
+ // ── Bootstrap + Retry ────────────────────────────────────────────────────
864
+
865
+ /**
866
+ * Execute a bootstrap function with timeout protection and automatic retry.
867
+ * Prevents the common issue where bootstrap hangs on startup.
868
+ */
869
+ protected async executeBootstrapWithTimeout<T>(
870
+ bootstrapFn: () => Promise<T>,
871
+ _context: UserContext,
872
+ signal?: AbortSignal
873
+ ): Promise<T> {
874
+ let lastError: Error | null = null;
875
+
876
+ // An aborted initialize has to stop the transfer, not just stop waiting for
877
+ // it. Without this the caller returns while a cold start keeps downloading,
878
+ // and those chunks are still in flight when the next initialize begins.
879
+ const onCallerAbort = (): void => { this.database.helper.abort(); };
880
+ signal?.addEventListener('abort', onCallerAbort, { once: true });
881
+
882
+ try {
883
+ for (let attempt = 1; attempt <= BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS; attempt++) {
884
+ if (signal?.aborted) {
885
+ throw new DOMException('Initialization aborted', 'AbortError');
886
+ }
887
+
888
+ // `navigator.onLine === false` is the MDN-reliable "definitely
889
+ // offline" signal. Don't use `!navigator.onLine`: Node 22+ exposes
890
+ // `globalThis.navigator` with `onLine === undefined`, so the
891
+ // negation false-positives every server-side bootstrap (e.g. the
892
+ // server-side agent.run dispatch path through `connectAgent`).
893
+ const navigatorOnline: unknown =
894
+ typeof navigator === 'undefined' ? undefined : navigator.onLine;
895
+ if (navigatorOnline === false) {
896
+ this.runtime.observability.breadcrumb(
897
+ `Bootstrap attempt ${attempt} skipped - offline`,
898
+ 'sync.bootstrap',
899
+ 'warning'
900
+ );
901
+ throw new AbloConnectionError('Bootstrap skipped - device is offline', {
902
+ code: 'bootstrap_offline',
903
+ });
904
+ }
905
+
906
+ try {
907
+ this.runtime.logger.info(
908
+ `[BaseSyncedStore] Bootstrap attempt ${attempt}/${BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS}`
909
+ );
910
+
911
+ const result = (await Promise.race([
912
+ bootstrapFn(),
913
+ this.createBootstrapTimeout(attempt),
914
+ ])) as T;
915
+
916
+ this.runtime.logger.info('[BaseSyncedStore] Bootstrap completed successfully', { attempt });
917
+ return result;
918
+ } catch (error) {
919
+ lastError = error as Error;
920
+ const isTimeout = error instanceof Error && error.message.includes('timed out');
921
+ const isAbort = error instanceof DOMException && error.name === 'AbortError';
922
+ const isNetworkError = error instanceof TypeError && error.message.includes('fetch');
923
+
924
+ if (isAbort) throw error;
925
+ if (AbloSessionError.isSessionError(error)) throw error;
926
+
927
+ const navigatorOnline: unknown =
928
+ typeof navigator === 'undefined' ? undefined : navigator.onLine;
929
+ if (isNetworkError && navigatorOnline === false) {
930
+ this.runtime.observability.captureBootstrapFailure(error, { type: 'network-offline' });
931
+ throw error;
932
+ }
933
+
934
+ this.runtime.observability.breadcrumb(
935
+ `Bootstrap attempt ${attempt} failed`,
936
+ 'sync.bootstrap',
937
+ 'warning',
938
+ { isTimeout, isNetworkError, willRetry: attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS }
939
+ );
940
+
941
+ if (isTimeout && attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS) {
942
+ this.runtime.logger.info('[BaseSyncedStore] Resetting state before bootstrap retry');
943
+ this.resetBootstrapState();
944
+ await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_CONFIG.RETRY_DELAY_MS));
945
+ } else if (!isTimeout && attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS) {
946
+ await new Promise((resolve) => setTimeout(resolve, 1000));
947
+ }
948
+ } finally {
949
+ // Disarm this attempt's deadline the moment it settles — a live timer
950
+ // would abort whatever the next attempt puts in flight.
951
+ this.clearBootstrapDeadline();
952
+ }
953
+ }
954
+
955
+ throw lastError
956
+ ? toAbloError(lastError)
957
+ : new AbloConnectionError('Bootstrap failed after all retry attempts', {
958
+ code: 'bootstrap_fetch_timeout',
959
+ });
960
+ } finally {
961
+ signal?.removeEventListener('abort', onCallerAbort);
962
+ this.clearBootstrapDeadline();
963
+ }
964
+ }
965
+
966
+ /**
967
+ * The outer deadline for one bootstrap attempt.
968
+ *
969
+ * The length is DERIVED from the fetcher's own watchdog budget, not chosen. A
970
+ * chosen number is what broke this: the previous fixed 15s was shorter than a
971
+ * single model chunk's allowance — 20s waiting for response headers plus 15s
972
+ * of stall grace — so on any workspace with one slow model the deadline fired
973
+ * before the watchdogs it was meant to backstop, and every attempt timed out
974
+ * by construction. The watchdogs below are progress-based and already
975
+ * guarantee termination; this deadline exists only for a hang somewhere other
976
+ * than the network, so it must sit above them, and it can only do that
977
+ * reliably by asking them how long they take.
978
+ *
979
+ * Reaching it aborts the work in flight. `Promise.race` merely stops waiting:
980
+ * without the abort the losing bootstrap keeps running, keeps its sockets, and
981
+ * races the retry that replaced it — which is how one page load turned into
982
+ * dozens of overlapping requests.
983
+ */
984
+ protected createBootstrapTimeout(attempt: number): Promise<never> {
985
+ const timeoutMs = this.database.helper.budgetMs;
986
+ return new Promise((_, reject) => {
987
+ this.clearBootstrapDeadline();
988
+ this.bootstrapDeadlineTimer = setTimeout(() => {
989
+ this.database.helper.abort();
990
+ reject(
991
+ new AbloConnectionError(
992
+ `Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`,
993
+ { code: 'bootstrap_fetch_timeout' },
994
+ ),
995
+ );
996
+ }, timeoutMs);
997
+ });
998
+ }
999
+
1000
+ /** Disarm the deadline once its attempt has settled. Load-bearing now that
1001
+ * firing it aborts real work: a leftover timer would cancel a later,
1002
+ * unrelated bootstrap. */
1003
+ private clearBootstrapDeadline(): void {
1004
+ if (this.bootstrapDeadlineTimer !== null) {
1005
+ clearTimeout(this.bootstrapDeadlineTimer);
1006
+ this.bootstrapDeadlineTimer = null;
1007
+ }
1008
+ }
1009
+
1010
+ /** Reset bootstrap-related state for a clean retry */
1011
+ protected resetBootstrapState(): void {
1012
+ try {
1013
+ this.objectPool.clear({ preserveObserved: true });
1014
+ this.queryProcessor.clearCache();
1015
+ runInAction(() => { this.dataReady = false; });
1016
+ this.modelTypesHydrated.clear();
1017
+ this.modelTypeHydrationInFlight.clear();
1018
+ // The pool is being wiped + re-bootstrapped, so the scoped-hydrate ledger
1019
+ // is stale — clear it so re-entered groups backfill again.
1020
+ this.hydratedGroups.clear();
1021
+ this.hydratingGroups.clear();
1022
+ this.runtime.logger.info('[BaseSyncedStore] Bootstrap state reset complete');
1023
+ } catch {
1024
+ this.runtime.observability.breadcrumb('Error resetting bootstrap state', 'sync.bootstrap', 'warning');
1025
+ }
1026
+ }
1027
+
1028
+ // ── Reconnection ─────────────────────────────────────────────────────────
1029
+
1030
+ /** Perform reconnect: bootstrap + WS reconnect. Returns outcome for state machine. */
1031
+ async performReconnect(): Promise<'success' | 'session_error' | 'network_error'> {
1032
+ const thisStore = this;
1033
+ return runReconnect({
1034
+ get userContext() { return thisStore.userContext; },
1035
+ database: this.database,
1036
+ syncClient: this.syncClient,
1037
+ objectPool: this.objectPool,
1038
+ syncWebSocket: this.syncWebSocket,
1039
+ runtime: this.runtime,
1040
+ get dataReady() { return thisStore.dataReady; },
1041
+ set dataReady(value: boolean) { thisStore.dataReady = value; },
1042
+ checkSyncGroupShrinkage: () => this.checkSyncGroupShrinkage(),
1043
+ resolveSyncGroups: (context) => this.resolveSyncGroups(context),
1044
+ applyBootstrapToPool: (result) => this.applyBootstrapToPool(result),
1045
+ updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1046
+ });
1047
+ }
1048
+ /**
1049
+ * Register the access-credential re-mint hook. Called by the React provider
1050
+ * with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
1051
+ * See {@link CredentialLifecycle.setRefresher}.
1052
+ */
1053
+ setCredentialRefresher(refresher: CredentialRefresher | null): void {
1054
+ this.credentialLifecycle.setRefresher(refresher);
1055
+ }
1056
+
1057
+ /**
1058
+ * Re-mint the short-lived access credential and push it into the credential
1059
+ * source, reporting a tri-state outcome the {@link ConnectionManager} maps to
1060
+ * its FSM. Single-flight; no refresher wired ⇒ `'refreshed'` (a no-op
1061
+ * re-probe). Full contract on {@link CredentialLifecycle.refresh}.
1062
+ */
1063
+ async performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'> {
1064
+ return this.credentialLifecycle.refresh();
1065
+ }
1066
+
1067
+ /**
1068
+ * The authentication-recovery path for HTTP transports, such as the lazy
1069
+ * query lane. It runs a single-flight credential re-mint driven by the
1070
+ * rejection's recovery class, routing outcomes through the same state
1071
+ * machine the WebSocket probe uses. `'retry'` means a fresh credential is
1072
+ * now in the credential source and the request should be replayed once.
1073
+ * Full contract on {@link CredentialLifecycle.recoverFromAuthRejection}.
1074
+ */
1075
+ async recoverFromAuthRejection(recovery: RecoveryClass): Promise<'retry' | 'stop'> {
1076
+ return this.credentialLifecycle.recoverFromAuthRejection(recovery);
1077
+ }
1078
+
1079
+ /**
1080
+ * Nudge the connection FSM to re-probe with the current credential. Idempotent
1081
+ * and safe in any state (ignored while `connected`). Call after pushing a
1082
+ * freshly-minted token via `setAuthToken`, or on an OS-wake signal, so a
1083
+ * connection parked in `offline` / `backoff` / `auth_blocked` picks the new
1084
+ * credential up immediately instead of waiting for the 30s watchdog.
1085
+ */
1086
+ nudgeReconnect(): void {
1087
+ this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
1088
+ }
1089
+
1090
+ /**
1091
+ * Install the client-owned access-credential lifecycle: register `getToken`
1092
+ * as the reactive re-mint hook and arm the browser-only proactive refresh
1093
+ * (a refresh timer plus an OS-wake re-mint). Idempotent — a second call
1094
+ * replaces the first — and torn down on {@link disconnect}. Full rationale
1095
+ * on {@link CredentialLifecycle.start}.
1096
+ */
1097
+ startCredentialLifecycle(
1098
+ getToken: CredentialRefresher,
1099
+ opts?: { proactiveInNode?: boolean },
1100
+ ): void {
1101
+ this.credentialLifecycle.start(getToken, opts);
1102
+ }
1103
+
1104
+ /** Tear down the proactive credential lifecycle (idempotent). */
1105
+ private stopCredentialLifecycle(): void {
1106
+ this.credentialLifecycle.stop();
1107
+ }
1108
+
1109
+ // ── Sync group management ────────────────────────────────────────────────
1110
+ //
1111
+ // The implementation lives in the sync/groupChange module. The methods
1112
+ // below are thin protected delegates that keep their signatures, so
1113
+ // subclass override points still work; the module routes cross-handler
1114
+ // calls back through `groupChangeContext()` to preserve dynamic dispatch.
1115
+
1116
+ /** Narrow context the group-change leaf talks back through. */
1117
+ private groupChangeContext(): GroupChangeContext {
1118
+ return {
1119
+ runtime: this.runtime,
1120
+ database: this.database,
1121
+ objectPool: this.objectPool,
1122
+ getSubscribedSyncGroups: () => this.syncWebSocket.getSyncGroups(),
1123
+ getCurrentSyncGroups: () =>
1124
+ this.userContext ? this.resolveSyncGroups(this.userContext) : null,
1125
+ getBootstrapMode: () => this.userContext?.bootstrapMode,
1126
+ disconnectWebSocket: () => { this.syncWebSocket.disconnect(); },
1127
+ emitConnectionEvent: (event) => { this.onConnectionEvent?.(event); },
1128
+ handleGroupAdded: (payload, syncId) => this.handleGroupAdded(payload, syncId),
1129
+ computeUpdatedSyncGroups: (payload) => this.computeUpdatedSyncGroups(payload),
1130
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
1131
+ };
1132
+ }
1133
+
1134
+ /**
1135
+ * Handle an actionType 'G' delta — incremental `{ group, userId }` or
1136
+ * legacy `{ addedGroups, removedGroups }` payloads. Full pathway doc on
1137
+ * {@link groupChange.handleSyncGroupChange}.
1138
+ */
1139
+ protected async handleSyncGroupChange(delta: SyncDelta): Promise<void> {
1140
+ return groupChange.handleSyncGroupChange(this.groupChangeContext(), delta);
1141
+ }
1142
+
1143
+ /**
1144
+ * Handle an incremental GroupAdded delta — metadata only, no re-bootstrap
1145
+ * (covering deltas bring the entities). See {@link groupChange.handleGroupAdded}.
1146
+ */
1147
+ protected async handleGroupAdded(payload: GroupAddedPayload, syncId: number): Promise<void> {
1148
+ return groupChange.handleGroupAdded(this.groupChangeContext(), payload, syncId);
1149
+ }
1150
+
1151
+ /**
1152
+ * Handle an actionType 'S' (GroupRemoved) delta: for safety, clear the
1153
+ * revoked local state and trigger a full re-bootstrap. See
1154
+ * {@link groupChange.handleGroupRemoved}.
1155
+ */
1156
+ protected async handleGroupRemoved(delta: SyncDelta): Promise<void> {
1157
+ return groupChange.handleGroupRemoved(this.groupChangeContext(), delta);
1158
+ }
1159
+
1160
+ /** Compute new sync groups after applying additions and removals */
1161
+ protected computeUpdatedSyncGroups(payload: SyncGroupChangePayload): string[] {
1162
+ return groupChange.computeUpdatedSyncGroups(this.groupChangeContext(), payload);
1163
+ }
1164
+
1165
+ /** Force a full re-bootstrap via connection lifecycle event (no-op for
1166
+ * `bootstrapMode: 'none'` participants — see {@link groupChange.forceFullRebootstrap}). */
1167
+ protected forceFullRebootstrap(): void {
1168
+ groupChange.forceFullRebootstrap(this.groupChangeContext());
1169
+ }
1170
+
1171
+ /**
1172
+ * Single source of truth for the sync-group list this session is
1173
+ * subscribed to. Server-issued (`context.syncGroups`) is authoritative.
1174
+ * When absent, the SDK subscribes to no explicit groups. Both
1175
+ * `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
1176
+ * here so the WS subscription and the security-critical shrinkage
1177
+ * check can never disagree.
1178
+ */
1179
+ protected resolveSyncGroups(context: UserContext): readonly string[] {
1180
+ return groupChange.resolveSyncGroups(context);
1181
+ }
1182
+
1183
+ /** Check if sync groups shrank since last session — force full bootstrap if so */
1184
+ protected async checkSyncGroupShrinkage(): Promise<void> {
1185
+ return groupChange.checkSyncGroupShrinkage(this.groupChangeContext());
1186
+ }
1187
+
1188
+ // ── Bootstrap apply ──────────────────────────────────────────────────────
1189
+ //
1190
+ // The implementation lives in the sync/bootstrapApply module. The protected
1191
+ // delegates below keep their signatures and subclass overridability; the
1192
+ // module talks back through `poolContext()`, with enrichment pre-bound to
1193
+ // `this.enrichRelations` so that override point still applies.
1194
+
1195
+ /** Narrow context the bootstrap-apply leaf talks back through. */
1196
+ private poolContext(): PoolContext {
1197
+ const store = this;
1198
+ return {
1199
+ runtime: this.runtime,
1200
+ applyDeltaBatchToPool: (results) =>
1201
+ { this.syncClient.applyDeltaBatchToPool(
1202
+ results,
1203
+ (name, data) => this.enrichRelations(name, data),
1204
+ ); },
1205
+ applyBootstrapDataToPool: (bootstrapData, protectedIds) =>
1206
+ this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds),
1207
+ getPoolSize: () => this.objectPool.size,
1208
+ getAllPoolIds: () => this.objectPool.getAllIds(),
1209
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
1210
+ set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
1211
+ applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1212
+ };
1213
+ }
1214
+
1215
+ /** Apply bootstrap data to the {@link InstanceCache}, removing entities that are no longer present (ghost removal). Pool writes are delegated to {@link SyncClient}. */
1216
+ protected applyBootstrapToPool(
1217
+ bootstrapResult: BootstrapResult,
1218
+ protectedIds?: ReadonlySet<string>
1219
+ ): RehydrationStats {
1220
+ return bootstrapApply.applyBootstrapToPool(this.poolContext(), bootstrapResult, protectedIds);
1221
+ }
1222
+
1223
+ // ── Initialize + Lifecycle ───────────────────────────────────────────────
1224
+
1225
+ /**
1226
+ * Initialize the sync engine with user context.
1227
+ * Offline-first: hydrate from IDB → show UI → bootstrap from server in background.
1228
+ */
1229
+ *initialize(
1230
+ context: UserContext,
1231
+ signal?: AbortSignal,
1232
+ ): Generator<Promise<void | number | boolean | BootstrapRequirements>, { success: boolean; error?: Error }, void | number | boolean | BootstrapRequirements> {
1233
+ const thisStore = this;
1234
+ return yield* runInitialize<TCollaboration>({
1235
+ get initialized() { return thisStore.initialized; },
1236
+ set initialized(value: boolean) { thisStore.initialized = value; },
1237
+ get userContext() {
1238
+ const value = thisStore.userContext;
1239
+ if (!value) throw new Error('User context is unavailable during initialization');
1240
+ return value;
1241
+ },
1242
+ set userContext(value: UserContext) { thisStore.userContext = value; },
1243
+ get dataReady() { return thisStore.dataReady; },
1244
+ set dataReady(value: boolean) { thisStore.dataReady = value; },
1245
+ runtime: this.runtime,
1246
+ database: this.database,
1247
+ syncClient: this.syncClient,
1248
+ objectPool: this.objectPool,
1249
+ syncWebSocket: this.syncWebSocket,
1250
+ updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1251
+ setupWebSocketSync: (nextContext, lastSyncId) => { this.setupWebSocketSync(nextContext, lastSyncId); },
1252
+ waitForWebSocketConnected: (timeoutMs) => this.waitForWebSocketConnected(timeoutMs),
1253
+ performBackgroundBootstrap: (requirements, nextContext, nextSignal) =>
1254
+ this.performBackgroundBootstrap(requirements, nextContext, nextSignal),
1255
+ executeBootstrapWithTimeout: (fn, nextContext, nextSignal) =>
1256
+ this.executeBootstrapWithTimeout(fn, nextContext, nextSignal),
1257
+ resolveSyncGroups: (nextContext) => this.resolveSyncGroups(nextContext),
1258
+ }, context, signal);
1259
+ }
1260
+ /** Background bootstrap — non-blocking, user sees cached data while this runs */
1261
+ protected async performBackgroundBootstrap(
1262
+ requirements: Awaited<ReturnType<typeof this.database.requiredBootstrap>>,
1263
+ context: UserContext,
1264
+ signal?: AbortSignal
1265
+ ): Promise<void> {
1266
+ await this.withDeltaQueuing(async () => {
1267
+ try {
1268
+ const preBootstrapIds = new Set(this.objectPool.getAllIds());
1269
+ const bootstrapResult = await this.database.bootstrapFromServer(
1270
+ requirements,
1271
+ this.resolveSyncGroups(context),
1272
+ );
1273
+ const deltaProtectedIds = this.collectDeltaProtectedIds(preBootstrapIds);
1274
+ this.applyBootstrapToPool(bootstrapResult, deltaProtectedIds);
1275
+ this.updateSyncStatus({ state: 'idle', progress: 100 });
1276
+ } catch (error) {
1277
+ this.runtime.logger.debug('[sync-engine] Background bootstrap failed', {
1278
+ error: error instanceof Error ? error.message : String(error),
1279
+ cause: error,
1280
+ });
1281
+ this.runtime.observability.captureBootstrapFailure(error, { type: 'background' });
1282
+ if (AbloSessionError.isSessionError(error)) {
1283
+ this.syncWebSocket.setSessionErrorDetected();
1284
+ this.syncWebSocket.disconnect();
1285
+ this.updateSyncStatus({ state: 'error', error: error });
1286
+ } else if (!this.syncWebSocket.isConnected()) {
1287
+ this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
1288
+ }
1289
+ }
1290
+ });
1291
+ }
1292
+
1293
+ /** Run bootstrap with delta queuing to prevent race conditions */
1294
+ protected async withDeltaQueuing<T>(fn: () => Promise<T>): Promise<T> {
1295
+ this.activeBootstrapCount++;
1296
+ if (this.bootstrapDeltaQueue === null) this.bootstrapDeltaQueue = [];
1297
+ try {
1298
+ return await fn();
1299
+ } finally {
1300
+ this.activeBootstrapCount--;
1301
+ if (this.activeBootstrapCount === 0) this.replayQueuedDeltas();
1302
+ }
1303
+ }
1304
+
1305
+ /** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
1306
+ protected collectDeltaProtectedIds(preBootstrapIds: ReadonlySet<string>): Set<string> {
1307
+ return bootstrapApply.collectDeltaProtectedIds(this.poolContext(), preBootstrapIds);
1308
+ }
1309
+
1310
+ /** Replay deltas queued during bootstrap (atomically, via `applyDeltaFrame`). */
1311
+ protected replayQueuedDeltas(): void {
1312
+ bootstrapApply.replayQueuedDeltas(this.poolContext());
1313
+ }
1314
+
1315
+ protected createConnectionManager(kind?: ParticipantKind): ConnectionManager | null {
1316
+ return runCreateConnectionManager<TCollaboration>({
1317
+ syncServerUrl: this._syncServerUrl,
1318
+ auth: this.auth,
1319
+ syncWebSocket: this.syncWebSocket,
1320
+ }, kind);
1321
+ }
1322
+
1323
+ /**
1324
+ * Disconnect and clean up all resources. Terminal: this means "the client
1325
+ * is finished", not "close and reopen later" — the connection object stays
1326
+ * assigned but closed, the event wiring is torn down, and nothing
1327
+ * re-initializes a disconnected store. (Mid-session closes during recovery
1328
+ * go through the connection FSM's `onDisconnectWebSocket`, which closes
1329
+ * the transport without touching the store.)
1330
+ */
1331
+ async disconnect(): Promise<void> {
1332
+ this.stopCredentialLifecycle();
1333
+ if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; }
1334
+ this.pendingDeltas = [];
1335
+
1336
+ for (const dispose of this.disposers) dispose();
1337
+ this.disposers = [];
1338
+
1339
+ if (this.connectionManager) {
1340
+ this.connectionManager.dispose();
1341
+ this.connectionManager = null;
1342
+ }
1343
+
1344
+ try {
1345
+ const last = this.syncWebSocket.getLastSyncId();
1346
+ if (last > 0) await this.database.updateWorkspaceMetadata({ lastSyncId: last });
1347
+ } catch {}
1348
+
1349
+ this.syncWebSocket.disconnect();
1350
+ this.syncClient.disconnect();
1351
+ this.queryProcessor.clearCache();
1352
+ // Stop the pool's GC interval — the one timer the pool arms itself.
1353
+ // Without this a discarded store retains its whole pool via the interval
1354
+ // closure (and a Node process without `unref` support can't exit).
1355
+ this.objectPool.stopGC();
1356
+ this.updateSyncStatus({ state: 'offline' });
1357
+ }
1358
+
1359
+ /** Stop access and await deletion of this identity's local state. */
1360
+ async purge(): Promise<void> {
1361
+ // Clear the bearer first so no request started during teardown can carry
1362
+ // the terminal credential.
1363
+ this.auth?.setAuthToken(null);
1364
+ await this.disconnect();
1365
+ this.objectPool.clear();
1366
+ this.queryProcessor.clearCache();
1367
+ await this.database.purgePersistence();
1368
+ }
1369
+
1370
+ // ── WebSocket Setup ───────────────────────────────────────────────────────
1371
+
1372
+ /**
1373
+ * Create WebSocket connection and wire all event handlers.
1374
+ * Handles: deltas, batches, presence, bootstrap_required, errors, reconnection.
1375
+ */
1376
+ /**
1377
+ * Block until the WebSocket reports a `connected` event, or until
1378
+ * `timeoutMs` elapses (returns false on timeout, true on connect).
1379
+ * Used by `initialize()` for `bootstrapMode: 'none'` consumers to
1380
+ * honor `ready()`'s "WS is connected when this resolves" contract
1381
+ * — `setupWebSocketSync` is fire-and-forget on the upgrade, and
1382
+ * without an explicit wait the next mutation can race the open.
1383
+ *
1384
+ * Resolves immediately if the WS is already connected (e.g., warm
1385
+ * reconnect after redeploy). Resolves false on timeout rather than
1386
+ * throwing so initialize() can complete and let the caller's first
1387
+ * mutation attempt surface a clearer error.
1388
+ */
1389
+ protected async waitForWebSocketConnected(timeoutMs: number): Promise<boolean> {
1390
+ return runWaitForWebSocketConnected<TCollaboration>({
1391
+ syncWebSocket: this.syncWebSocket,
1392
+ runtime: this.runtime,
1393
+ }, timeoutMs);
1394
+ }
1395
+
1396
+ /**
1397
+ * Seed the connection's late values and open it. The socket itself exists
1398
+ * from construction; what identity resolution supplies — the participant
1399
+ * kind, the credential, the read scope, and the resume cursor — is seeded
1400
+ * here, and only then is the held first connect released. A retried
1401
+ * `initialize()` after a failed `ready()` re-runs this against the same
1402
+ * connection object: the reconnect counter is reset for a clean slate,
1403
+ * while the session-error latch deliberately survives (only the
1404
+ * credential-expiry recovery clears it).
1405
+ */
1406
+ protected setupWebSocketSync(context: UserContext, lastSyncId: number): void {
1407
+ if (!context.userId || !context.organizationId) {
1408
+ this.runtime.observability.breadcrumb(
1409
+ 'Cannot setup WebSocket sync without user context',
1410
+ 'sync.websocket',
1411
+ 'warning'
1412
+ );
1413
+ return;
1414
+ }
1415
+
1416
+ if (context.kind) this.syncWebSocket.setKind(context.kind);
1417
+ if (context.capabilityToken) {
1418
+ this.syncWebSocket.setCapabilityToken(context.capabilityToken);
1419
+ }
1420
+ const syncGroups = this.resolveSyncGroups(context);
1421
+ this.syncWebSocket.setSyncGroups(syncGroups);
1422
+ this.syncWebSocket.setLastSyncId(lastSyncId || 0);
1423
+ // The permanent base scopes for read interest — same set the connection
1424
+ // subscribes to at upgrade, so the two can never disagree.
1425
+ this.areaOfInterest.setBaseGroups(syncGroups);
1426
+
1427
+ // ── Connection FSM ────────────────────────────────────────────
1428
+ // Instantiate + start the SDK's ConnectionManager so every consumer
1429
+ // gets correct online/offline recovery. Guarded: a retried
1430
+ // `initialize()` reuses the manager it already started.
1431
+ if (!this.connectionManager) this.startConnectionManager(context.kind);
1432
+
1433
+ this.syncWebSocket.resetReconnectAttempts();
1434
+ this.syncWebSocket.allowConnect();
1435
+ this.syncWebSocket.connect();
1436
+ }
1437
+
1438
+ /**
1439
+ * Wire the store's handlers onto the connection. Runs once, at
1440
+ * construction — the connection object is stable for the store's
1441
+ * lifetime, so the wiring is too.
1442
+ */
1443
+ protected wireSocketEvents(): void {
1444
+ const thisStore = this;
1445
+ wireSocketEvents({
1446
+ syncWebSocket: this.syncWebSocket,
1447
+ syncClient: this.syncClient,
1448
+ database: this.database,
1449
+ objectPool: this.objectPool,
1450
+ areaOfInterest: this.areaOfInterest,
1451
+ runtime: this.runtime,
1452
+ get dataReady() { return thisStore.dataReady; },
1453
+ connectionManager: this.connectionManager,
1454
+ disposers: this.disposers,
1455
+ onConnectionEvent: this.onConnectionEvent,
1456
+ updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1457
+ processDeltaWithBatching: (delta) => { this.processDeltaWithBatching(delta); },
1458
+ applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1459
+ handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
1460
+ handleBootstrapData: (data) => { this.handleBootstrapData(data); },
1461
+ handlePresenceUpdate: (data) => { this.handlePresenceUpdate(data); },
1462
+ performCredentialRefresh: () => this.performCredentialRefresh(),
1463
+ handleTerminalSessionError: (error) => { this.terminalSessionLifecycle.start(error); },
1464
+ nudgeReconnect: () => { this.nudgeReconnect(); },
1465
+ });
1466
+ }
1467
+
1468
+ /*
1469
+ * Kept as a distinct method so subclasses retain the original override
1470
+ * point; transport wiring itself lives in sync/socketEventWiring.ts.
1471
+ */
1472
+ /**
1473
+ * Build and start the connection FSM. The `onConnectionEvent` hook is the
1474
+ * bridge — WS events fire the hook, the hook forwards into the FSM. Called
1475
+ * from `setupWebSocketSync` because the FSM's shape depends on the resolved
1476
+ * participant kind (agents get none — see {@link createConnectionManager}).
1477
+ */
1478
+ private startConnectionManager(kind?: ParticipantKind): void {
1479
+ const thisStore = this;
1480
+ runStartConnectionManager<TCollaboration>({
1481
+ get connectionManager() { return thisStore.connectionManager; },
1482
+ set connectionManager(value) { thisStore.connectionManager = value; },
1483
+ get onConnectionEvent() { return thisStore.onConnectionEvent; },
1484
+ set onConnectionEvent(value) { thisStore.onConnectionEvent = value; },
1485
+ syncWebSocket: this.syncWebSocket,
1486
+ get syncStatus() { return thisStore.syncStatus; },
1487
+ createConnectionManager: (nextKind) => this.createConnectionManager(nextKind),
1488
+ performReconnect: () => this.performReconnect(),
1489
+ performCredentialRefresh: () => this.performCredentialRefresh(),
1490
+ handleTerminalSessionError: (error) => { this.terminalSessionLifecycle.start(error); },
1491
+ updateSyncStatus: (updates) => { this.updateSyncStatus(updates); },
1492
+ runtime: this.runtime,
1493
+ }, kind);
1494
+ }
1495
+
1496
+ // ── Delta processing pipeline ─────────────────────────────────────────────
1497
+ //
1498
+ // The implementation lives in the sync/deltaPipeline module (deduplication,
1499
+ // enqueue bookkeeping, debounce, flush). The methods below are thin protected
1500
+ // delegates with unchanged signatures, and the module routes every call to a
1501
+ // protected override point back through `deltaPipelineContext`, so subclass
1502
+ // dynamic dispatch is preserved. `applyDeltaFrame`, the authoritative-apply
1503
+ // correctness point, deliberately stays here.
1504
+
1505
+ /** Memoized pipeline context — `enqueueDelta` runs once per delta, so the
1506
+ * accessor object is built once and reused (the get/set accessors always
1507
+ * read the live host fields). */
1508
+ private _deltaPipelineContext: DeltaPipelineContext | null = null;
1509
+
1510
+ private get deltaPipelineContext(): DeltaPipelineContext {
1511
+ if (this._deltaPipelineContext) return this._deltaPipelineContext;
1512
+ const store = this;
1513
+ this._deltaPipelineContext = {
1514
+ runtime: this.runtime,
1515
+ stagePlugins: this.stagePlugins,
1516
+ // Shared pipeline state, backed by the host fields.
1517
+ get pendingDeltas() { return store.pendingDeltas; },
1518
+ set pendingDeltas(deltas) { store.pendingDeltas = deltas; },
1519
+ get batchTimer() { return store.batchTimer; },
1520
+ set batchTimer(timer) { store.batchTimer = timer; },
1521
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
1522
+ get smartSyncOptions() { return store.smartSyncOptions; },
1523
+ get highestProcessedSyncId() { return store.highestProcessedSyncId; },
1524
+ get lastAckedId() { return store.lastAckedId; },
1525
+ // SyncClient position/transaction bookkeeping.
1526
+ onDeltaReceived: (syncId, transactionId, correlationId) => {
1527
+ this.syncClient.onDeltaReceived(syncId, transactionId, correlationId);
1528
+ },
1529
+ advanceApplied: (syncId) => { this.syncClient.position.advanceApplied(syncId); },
1530
+ advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
1531
+ // Persistence + pool writes.
1532
+ processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
1533
+ applyDeltaBatchToPool: (results) => { this.applyChangesToPool(results); },
1534
+ acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
1535
+ get objectPool() { return store.objectPool; },
1536
+ // Dynamic-dispatch hooks — protected override points on this class.
1537
+ getStateFields: (modelName) => this.getStateFields(modelName),
1538
+ isCustomEntity: (modelName) => this.isCustomEntity(modelName),
1539
+ createCustomEntity: (modelName, modelId, data) =>
1540
+ this.createCustomEntity(modelName, modelId, data),
1541
+ deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
1542
+ flushPendingDeltas: () => this.flushPendingDeltas(),
1543
+ handleFlushError: (error) => { this.handleFlushError(error); },
1544
+ handleSyncGroupChange: (delta) => this.handleSyncGroupChange(delta),
1545
+ handleGroupRemoved: (delta) => this.handleGroupRemoved(delta),
1546
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
1547
+ cascadeCancelTransactionsForDeletedParent: (parentModelName, parentId) => {
1548
+ this.cascadeCancelTransactionsForDeletedParent(parentModelName, parentId);
1549
+ },
1550
+ };
1551
+ return this._deltaPipelineContext;
1552
+ }
1553
+
1554
+ /**
1555
+ * Lands persisted changes in the in-memory pool, with this store's
1556
+ * relation enrichment bound. The one apply path: the pipeline's bridge
1557
+ * (no plugins installed) and the `humans()` apply handler both call it.
1558
+ */
1559
+ applyChangesToPool(changes: readonly AppliedChange[]): void {
1560
+ this.syncClient.applyDeltaBatchToPool(
1561
+ changes,
1562
+ (name, data) => this.enrichRelations(name, data),
1563
+ );
1564
+ }
1565
+
1566
+ /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
1567
+ protected getStateFields(_modelName: string): string[] {
1568
+ return ['status', 'state', 'isActive'];
1569
+ }
1570
+
1571
+ /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
1572
+ protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
1573
+ return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
1574
+ }
1575
+
1576
+ /** Process incoming delta with smart batching */
1577
+ protected processDeltaWithBatching(delta: SyncDelta): void {
1578
+ if (!this.enqueueDelta(delta)) return;
1579
+ this.scheduleDeltaFlush();
1580
+ }
1581
+
1582
+ /**
1583
+ * Apply a complete, server-delivered delta frame atomically.
1584
+ *
1585
+ * A `delta_batch` WebSocket event (a reconnect or catch-up replay) already
1586
+ * carries the full set of missed deltas. Routing it through the per-delta
1587
+ * `processDeltaWithBatching` path would re-chunk it via the live-traffic
1588
+ * debounce timer and `maxBatchSize` force-flush, so a 300-delta catch-up
1589
+ * would fan out into several separate `flushPendingDeltas` cycles — each its
1590
+ * own local write, pool mutation, `models:changed` emit, and re-render, so
1591
+ * the UI visibly repaints once per chunk.
1592
+ *
1593
+ * Instead, this runs the per-delta bookkeeping (deduplication, ack, version
1594
+ * vector, watermark, group-change routing, delete cascade) for every delta
1595
+ * without scheduling a flush, then flushes once — collapsing the whole frame
1596
+ * into a single local write, pool mutation, `models:changed` emit, and
1597
+ * re-render. The post-bootstrap replay of deltas queued during bootstrap
1598
+ * uses the same path.
1599
+ *
1600
+ * It is named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
1601
+ * with {@link Database.processDeltaBatch} — the lower-level local write this
1602
+ * eventually drives through `flushPendingDeltas`.
1603
+ */
1604
+ protected applyDeltaFrame(deltas: SyncDelta[]): void {
1605
+ deltaPipeline.applyDeltaFrame(this.deltaPipelineContext, deltas);
1606
+ }
1607
+ /**
1608
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
1609
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
1610
+ * subsequent flush must drain), `false` when it was skipped (dedup),
1611
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
1612
+ * sync-group mutations). Does NOT schedule a flush — callers decide
1613
+ * whether to debounce (live) or flush atomically (catch-up frame).
1614
+ */
1615
+ protected enqueueDelta(
1616
+ delta: SyncDelta,
1617
+ options: { authoritative?: boolean } = {},
1618
+ ): boolean {
1619
+ return deltaPipeline.enqueueDelta(this.deltaPipelineContext, delta, options);
1620
+ }
1621
+
1622
+ /** Debounce a flush for live single-delta traffic. */
1623
+ protected scheduleDeltaFlush(): void {
1624
+ deltaPipeline.scheduleDeltaFlush(this.deltaPipelineContext);
1625
+ }
1626
+
1627
+ /**
1628
+ * Cancel pending transactions for child entities when a parent is deleted.
1629
+ *
1630
+ * Uses `pool.getByForeignKey` (O(1) via the FK index registered at
1631
+ * schema build time) to find children. The previous implementation did
1632
+ * `getByType(ctor).filter(e => e.toJSON()[foreignKey] === parentId)` —
1633
+ * a full pool scan per child model + a `toJSON()` allocation per
1634
+ * candidate. For a report delete with 10K blocks in the pool, that was
1635
+ * 10K toJSON allocations per cascade level. The FK-indexed lookup
1636
+ * skips both the scan AND the allocation.
1637
+ */
1638
+ protected cascadeCancelTransactionsForDeletedParent(parentModelName: string, parentId: string): void {
1639
+ const reg = this.objectPool.registry;
1640
+ const childModels = reg.getChildModels(parentModelName);
1641
+ if (childModels.length === 0) return;
1642
+
1643
+ let totalCancelled = 0;
1644
+
1645
+ for (const { childModel, foreignKey } of childModels) {
1646
+ const cancelled = this.syncClient.cancelTransactionsByForeignKey(childModel, foreignKey, parentId);
1647
+ totalCancelled += cancelled;
1648
+
1649
+ // O(1) FK-index lookup — skips the prior `getByType().filter(toJSON)` scan.
1650
+ const children = this.objectPool.getByForeignKey(childModel, foreignKey, parentId);
1651
+ for (const child of children) {
1652
+ this.cascadeCancelTransactionsForDeletedParent(childModel, child.id);
1653
+ }
1654
+ }
1655
+
1656
+ if (totalCancelled > 0) {
1657
+ this.runtime.logger.info('[BaseSyncedStore] Cascade cancelled orphaned transactions', {
1658
+ parentModel: parentModelName,
1659
+ parentId: parentId.slice(0, 12),
1660
+ totalCancelled,
1661
+ });
1662
+ }
1663
+ }
1664
+
1665
+ /** Flush pending deltas with deduplication. Pool writes are delegated to {@link SyncClient}. */
1666
+ protected async flushPendingDeltas(): Promise<void> {
1667
+ return deltaPipeline.flushPendingDeltas(this.deltaPipelineContext);
1668
+ }
1669
+
1670
+ // ── Core mutations (thin delegation to SyncClient) ────────────────────────
1671
+ //
1672
+ // This class orchestrates; it does not implement the writes. {@link SyncClient}
1673
+ // owns the object-pool operations, the transaction queue, and local writes.
1674
+ // This class owns validation, lifecycle hooks, and pending-delete tracking.
1675
+
1676
+ /** Check if a model type is local-only (no sync). Override for domain-specific models. */
1677
+ protected isLocalOnlyModel(_modelName: string): boolean {
1678
+ return false;
1679
+ }
1680
+
1681
+ /** Validate model against schema before save */
1682
+ protected validateModel(model: Model): void {
1683
+ const modelName = model.getModelName();
1684
+ const properties = this.modelRegistry.getPropertiesForModel(modelName);
1685
+ const modelData = model.toJSON() as Record<string, unknown>;
1686
+
1687
+ for (const [propName, metadata] of properties) {
1688
+ if (metadata.type === PropertyType.referenceModel) continue;
1689
+ if (metadata.type === PropertyType.ephemeralProperty) continue;
1690
+
1691
+ if (!metadata.optional && (modelData[propName] === null || modelData[propName] === undefined)) {
1692
+ throw new AbloValidationError(
1693
+ `Required field ${propName} is missing on ${modelName}`,
1694
+ { code: 'model_required_field_missing' },
1695
+ );
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ /**
1701
+ * Save a model (create or update).
1702
+ *
1703
+ * Accepts any entity shape with `{ id: string }` so consumers can pass the
1704
+ * Zod-inferred model types from `Model<Schema, K>` without knowing
1705
+ * about the internal `Model` base class. At runtime, every entity reaching
1706
+ * this method came through the object pool (via `store.create`, a query
1707
+ * accessor, or an optimistic insert) and IS a `Model` instance — the one
1708
+ * cast below preserves that invariant inside the SDK.
1709
+ */
1710
+ async save<T extends { id: string; createdAt?: Date; updatedAt?: Date }>(
1711
+ entity: T,
1712
+ options?: { skipValidation?: boolean }
1713
+ ): Promise<void> {
1714
+ const model = rowAsModel(entity);
1715
+ this.beforeSave(model);
1716
+ if (!options?.skipValidation) this.validateModel(model);
1717
+
1718
+ if (!model.createdAt) model.createdAt = new Date();
1719
+
1720
+ // SyncClient.add/update handles: optimistic pool add, transaction queue, IDB write
1721
+ const isCreate = !this.objectPool.get(model.id);
1722
+ if (isCreate) {
1723
+ model.updatedAt = new Date();
1724
+ this.syncClient.add(model);
1725
+ } else {
1726
+ this.syncClient.update(model);
1727
+ }
1728
+ }
1729
+
1730
+ /** Save with an atomic server mutation (e.g., createSectionWithBlocks) */
1731
+ async saveWithAtomicMutation(
1732
+ model: Model,
1733
+ mutation: (gql: unknown) => Promise<unknown>
1734
+ ): Promise<void> {
1735
+ this.objectPool.add(model, ModelScope.live);
1736
+ await mutation(this.syncClient.gql);
1737
+ }
1738
+
1739
+ /** Delete a model. Accepts schema-inferred entity shapes (see `save`). */
1740
+ async delete<T extends { id: string }>(entity: T): Promise<void> {
1741
+ const model = rowAsModel(entity);
1742
+ this.pendingDeletes.add(model.id);
1743
+ // SyncClient.delete handles: pool remove, transaction queue
1744
+ this.syncClient.delete(model);
1745
+ }
1746
+
1747
+ /** Archive a model. Accepts schema-inferred entity shapes (see `save`). */
1748
+ async archive<T extends { id: string; archivedAt?: Date | null }>(entity: T): Promise<void> {
1749
+ const model = rowAsModel(entity);
1750
+ model.archivedAt = new Date();
1751
+ this.syncClient.archive(model);
1752
+ }
1753
+
1754
+ /** Unarchive a model. Accepts schema-inferred entity shapes (see `save`). */
1755
+ async unarchive<T extends { id: string; archivedAt?: Date | null }>(entity: T): Promise<void> {
1756
+ const model = rowAsModel(entity);
1757
+ model.archivedAt = null;
1758
+ this.syncClient.update(model);
1759
+ }
1760
+
1761
+
1762
+ // ── Query API ────────────────────────────────────────────────────────────
1763
+ // `ablo.<model>.local.get` / `.local.list` is the read surface for
1764
+ // application code. Custom mutators read transactionally through
1765
+ // `tx.<model>`, backed by `createReaderActions`.
1766
+
1767
+ /** Retrieve a single entity by id. Synchronous pool read. */
1768
+ retrieve(_modelClass: ModelConstructor<Model>, id: string): Model | undefined {
1769
+ return this.objectPool.get(id);
1770
+ }
1771
+
1772
+ /** Find any entity by ID regardless of type */
1773
+ findAnyById(id: string): Model | undefined {
1774
+ return this.objectPool.get(id);
1775
+ }
1776
+
1777
+ /**
1778
+ * Lookup a model by ID alone. Matches the `SyncStoreRef.getById` contract
1779
+ * that schema-defined computeds use when they need to resolve a related
1780
+ * entity without holding onto its constructor.
1781
+ */
1782
+ getById(id: string): Model | undefined {
1783
+ return this.objectPool.get(id);
1784
+ }
1785
+
1786
+ /**
1787
+ * Create a model instance locally, typed via the schema.
1788
+ *
1789
+ * ```ts
1790
+ * const ledger = store.create('ledgers', { name, reportId });
1791
+ * // ledger: Ledger | null — no cast needed
1792
+ * ```
1793
+ *
1794
+ * The `typename` arg is the schema key (camelCase plural, e.g.
1795
+ * `'ledgers'`); the returned instance has the
1796
+ * `Model<Schema, K>` shape including computeds + relation accessors.
1797
+ * Wraps `pool.create(...)` — the underlying runtime is unchanged, just
1798
+ * type-narrowed.
1799
+ */
1800
+ create<K extends keyof TSchema['models'] & string>(
1801
+ typename: K,
1802
+ data: Record<string, unknown>,
1803
+ ): import('@abloatai/transaction/schema/schema').Model<TSchema, K> | null {
1804
+ if (!this.schema) {
1805
+ throw new AbloValidationError(
1806
+ 'store.create requires a schema to be passed to the BaseSyncedStore constructor.',
1807
+ { code: 'store_create_schema_missing' },
1808
+ );
1809
+ }
1810
+ const modelDef = this.schema.models[typename];
1811
+ const wireTypename =
1812
+ (modelDef as { typename?: string } | undefined)?.typename ?? typename;
1813
+ // Same boundary-cast idiom used by `createReaderActions.findById` — the
1814
+ // runtime instance IS the schema-typed shape (the dynamic class was
1815
+ // built from the same Zod shape), TypeScript just can't unify the SDK's
1816
+ // static `Model` class with the schema's object-literal type.
1817
+ return this.objectPool.create(wireTypename, data) as
1818
+ | import('@abloatai/transaction/schema/schema').Model<TSchema, K>
1819
+ | null;
1820
+ }
1821
+
1822
+ /**
1823
+ * Query entry point for callers that hold a {@link Model} constructor and an
1824
+ * options object. It filters, orders, and paginates the matching models from
1825
+ * the pool. Prefer the schema-typed read surface (`ablo.<model>.list`) where
1826
+ * you can, since it infers concrete row types without a class value or cast.
1827
+ */
1828
+ queryByClass(
1829
+ modelClass: ModelConstructor<Model>,
1830
+ options?: {
1831
+ predicate?: (model: Model) => boolean;
1832
+ state?: ModelScope;
1833
+ orderBy?: keyof Model;
1834
+ order?: 'asc' | 'desc';
1835
+ limit?: number;
1836
+ offset?: number;
1837
+ }
1838
+ ): QueryResult<Model> {
1839
+ return runQueryByClass(this.objectPool, this.pendingDeletes, modelClass, options);
1840
+ }
1841
+
1842
+ /**
1843
+ * Get all models of a type. Returns Model[] honestly — callers that need
1844
+ * narrow types should use `useAblo((ablo) => ablo.<model>.list(...))`
1845
+ * which does proper inference via `Model<S, K>`.
1846
+ */
1847
+ allModelsOfType(modelClass: ModelConstructor<Model>, scope?: ModelScope): Model[] {
1848
+ return this.objectPool.getByType(modelClass, scope ?? ModelScope.live);
1849
+ }
1850
+
1851
+ /** Error handler for fire-and-forget flushPendingDeltas calls */
1852
+ protected handleFlushError = (error: unknown): void => {
1853
+ this.runtime.observability.captureMutationFailure({
1854
+ context: 'flush-pending-deltas',
1855
+ modelName: 'batch',
1856
+ modelId: 'batch',
1857
+ error: error instanceof Error ? error : new Error(String(error)),
1858
+ });
1859
+ this.runtime.logger.debug('[BaseSyncedStore] Delta flush error', {
1860
+ error: error instanceof Error ? error.message : String(error),
1861
+ });
1862
+ };
1863
+
1864
+ /** Process a single delta (used for immediate DELETE processing). Override for domain-specific handling. */
1865
+ protected async processDelta(delta: SyncDelta): Promise<void> {
1866
+ const dbResult = await this.database.processDelta({
1867
+ syncId: delta.id,
1868
+ actionType: delta.actionType,
1869
+ modelName: delta.modelName,
1870
+ modelId: delta.modelId,
1871
+ data: typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data,
1872
+ });
1873
+
1874
+ // Track pending deletes for query filtering
1875
+ if (dbResult.action === 'remove') {
1876
+ this.pendingDeletes.add(dbResult.modelId);
1877
+ }
1878
+
1879
+ // Delegate pool writes to SyncClient (auto-invalidates cache via 'models:changed' event)
1880
+ this.syncClient.applyDeltaBatchToPool(
1881
+ [dbResult],
1882
+ (name, data) => this.enrichRelations(name, data),
1883
+ );
1884
+
1885
+ // This path runs after the delta was written to IDB — advance both
1886
+ // cursors through the shared position.
1887
+ this.syncClient.position.advancePersisted(delta.id);
1888
+ }
1889
+
1890
+ /** Handle bootstrap_required event */
1891
+ protected handleBootstrapRequired(_hint: BootstrapHint): void {
1892
+ // Subclass implements — triggers background bootstrap
1893
+ }
1894
+
1895
+ /** Handle bootstrap_data event. Override in subclass. */
1896
+ protected handleBootstrapData(_data: BootstrapDataEvent): void {
1897
+ this.updateSyncStatus({ state: 'syncing' });
1898
+ }
1899
+
1900
+ /** Handle presence_update event. Override in subclass. */
1901
+ protected handlePresenceUpdate(_data: PresenceUpdate): void {}
1902
+
1903
+ // ── Pending changes tracking ─────────────────────────────────────────────
1904
+
1905
+ protected incrementPendingChanges(): void {
1906
+ runInAction(() => { this.syncStatus.pendingChanges++; });
1907
+ }
1908
+
1909
+ protected decrementPendingChanges(): void {
1910
+ runInAction(() => {
1911
+ if (this.syncStatus.pendingChanges > 0) this.syncStatus.pendingChanges--;
1912
+ });
1913
+ }
1914
+
1915
+ // ── Status helpers ───────────────────────────────────────────────────────
1916
+
1917
+ protected updateSyncStatus(updates: Partial<SyncStatus>): void {
1918
+ runInAction(() => {
1919
+ Object.assign(this.syncStatus, updates);
1920
+ });
1921
+ }
1922
+
1923
+ // ── Accessors ─────────────────────────────────────────────────────────────
1924
+
1925
+ get pool(): InstanceCache {
1926
+ return this.objectPool;
1927
+ }
1928
+
1929
+ get lastSyncId(): number {
1930
+ return this.lastAckedId;
1931
+ }
1932
+
1933
+ // ── Status convenience getters ──────────────────────────────────────────
1934
+ // Thin wrappers over `syncStatus` for consumer ergonomics.
1935
+
1936
+ get isReady(): boolean {
1937
+ // Ready if: fully synced (idle + 100%) OR local data loaded (dataReady + syncing in background)
1938
+ return (this.syncStatus.state === 'idle' && this.syncStatus.progress >= 100)
1939
+ || (this.dataReady && this.syncStatus.state === 'syncing');
1940
+ }
1941
+
1942
+ get isSyncing(): boolean {
1943
+ return this.syncStatus.state === 'syncing';
1944
+ }
1945
+
1946
+ get isOffline(): boolean {
1947
+ return this.syncStatus.state === 'offline';
1948
+ }
1949
+
1950
+ get isReconnecting(): boolean {
1951
+ return this.syncStatus.state === 'reconnecting';
1952
+ }
1953
+
1954
+ get isError(): boolean {
1955
+ return this.syncStatus.state === 'error';
1956
+ }
1957
+
1958
+ get hasUnsyncedChanges(): boolean {
1959
+ return this.syncStatus.pendingChanges > 0;
1960
+ }
1961
+
1962
+ /** The SyncWebSocket handle — for collaboration events. */
1963
+ get ws(): SyncWebSocket<TCollaboration> | null {
1964
+ return this.syncWebSocket;
1965
+ }
1966
+
1967
+ /** The Database instance — for demand loaders and direct IDB operations. */
1968
+ get db(): Database {
1969
+ return this.database;
1970
+ }
1971
+
1972
+ /** The SyncClient instance — for assignment operations and other direct sync actions. */
1973
+ get sc(): SyncClient {
1974
+ return this.syncClient;
1975
+ }
1976
+
1977
+ /** The current organization ID — from the last initialize() call. */
1978
+ get orgId(): string | undefined {
1979
+ return this.userContext?.organizationId;
1980
+ }
1981
+
1982
+ /** Count models matching a predicate. */
1983
+ count(modelClass: ModelConstructor<Model>, predicate?: (m: Model) => boolean): number {
1984
+ return countModels(this.objectPool, modelClass, this.pendingDeletes, predicate);
1985
+ }
1986
+
1987
+ /** Get entities by foreign key (used by Model subclasses via Model.store) */
1988
+ getByForeignKey(modelName: string, foreignKey: string, id: string): Model[] {
1989
+ return this.objectPool.getByForeignKey(modelName, foreignKey, id);
1990
+ }
1991
+ }