@abloatai/ablo 0.35.0 → 0.37.0

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