@abloatai/ablo 0.25.0 → 0.27.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 (425) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +104 -88
  4. package/dist/BaseSyncedStore.d.ts +140 -266
  5. package/dist/BaseSyncedStore.js +338 -739
  6. package/dist/Database.d.ts +62 -77
  7. package/dist/Database.js +106 -127
  8. package/dist/{ObjectPool.d.ts → InstanceCache.d.ts} +10 -13
  9. package/dist/{ObjectPool.js → InstanceCache.js} +91 -83
  10. package/dist/LazyReferenceCollection.d.ts +11 -15
  11. package/dist/LazyReferenceCollection.js +16 -15
  12. package/dist/Model.d.ts +37 -52
  13. package/dist/Model.js +52 -69
  14. package/dist/ModelRegistry.d.ts +46 -25
  15. package/dist/ModelRegistry.js +32 -30
  16. package/dist/NetworkMonitor.d.ts +5 -6
  17. package/dist/NetworkMonitor.js +6 -7
  18. package/dist/SyncClient.d.ts +119 -109
  19. package/dist/SyncClient.js +303 -224
  20. package/dist/SyncEngineContext.d.ts +1 -3
  21. package/dist/SyncEngineContext.js +1 -2
  22. package/dist/adapters/alwaysOnline.d.ts +6 -8
  23. package/dist/adapters/alwaysOnline.js +6 -8
  24. package/dist/adapters/inMemoryStorage.d.ts +9 -9
  25. package/dist/adapters/inMemoryStorage.js +9 -9
  26. package/dist/agent/Agent.d.ts +39 -31
  27. package/dist/agent/Agent.js +35 -23
  28. package/dist/agent/index.d.ts +4 -4
  29. package/dist/agent/index.js +5 -5
  30. package/dist/agent/session.d.ts +47 -44
  31. package/dist/agent/session.js +37 -48
  32. package/dist/agent/types.d.ts +26 -31
  33. package/dist/agent/types.js +6 -7
  34. package/dist/ai-sdk/coordinatedTool.d.ts +108 -0
  35. package/dist/ai-sdk/{coordinated-tool.js → coordinatedTool.js} +44 -38
  36. package/dist/ai-sdk/coordinationContext.d.ts +46 -0
  37. package/dist/ai-sdk/{coordination-context.js → coordinationContext.js} +30 -31
  38. package/dist/ai-sdk/index.d.ts +25 -22
  39. package/dist/ai-sdk/index.js +25 -22
  40. package/dist/ai-sdk/wrap.d.ts +7 -8
  41. package/dist/ai-sdk/wrap.js +2 -2
  42. package/dist/auth/credentialPolicy.d.ts +74 -71
  43. package/dist/auth/credentialPolicy.js +51 -56
  44. package/dist/auth/credentialSource.d.ts +7 -18
  45. package/dist/auth/credentialSource.js +10 -18
  46. package/dist/auth/index.d.ts +59 -58
  47. package/dist/auth/index.js +34 -40
  48. package/dist/auth/schemas.d.ts +5 -4
  49. package/dist/auth/schemas.js +5 -4
  50. package/dist/batching/index.d.ts +19 -21
  51. package/dist/batching/index.js +14 -17
  52. package/dist/cli.cjs +483 -369
  53. package/dist/client/Ablo.d.ts +107 -836
  54. package/dist/client/Ablo.js +174 -833
  55. package/dist/client/ApiClient.d.ts +44 -20
  56. package/dist/client/ApiClient.js +193 -44
  57. package/dist/client/auth.d.ts +51 -60
  58. package/dist/client/auth.js +137 -110
  59. package/dist/client/claimHeartbeatLoop.d.ts +50 -0
  60. package/dist/client/claimHeartbeatLoop.js +88 -0
  61. package/dist/client/consoleLogger.d.ts +35 -0
  62. package/dist/client/consoleLogger.js +44 -0
  63. package/dist/client/createInternalComponents.d.ts +14 -17
  64. package/dist/client/createInternalComponents.js +26 -31
  65. package/dist/client/createModelProxy.d.ts +130 -120
  66. package/dist/client/createModelProxy.js +158 -124
  67. package/dist/client/credentialEndpoint.d.ts +61 -0
  68. package/dist/client/credentialEndpoint.js +86 -0
  69. package/dist/client/functionalUpdate.d.ts +29 -27
  70. package/dist/client/functionalUpdate.js +21 -21
  71. package/dist/client/hostedEndpoints.d.ts +21 -0
  72. package/dist/client/hostedEndpoints.js +21 -0
  73. package/dist/client/httpClient.d.ts +58 -54
  74. package/dist/client/httpClient.js +29 -31
  75. package/dist/client/identity.d.ts +15 -20
  76. package/dist/client/identity.js +49 -59
  77. package/dist/client/modelRegistration.d.ts +10 -0
  78. package/dist/client/modelRegistration.js +301 -0
  79. package/dist/client/options.d.ts +373 -0
  80. package/dist/client/options.js +6 -0
  81. package/dist/client/registerDataSource.d.ts +9 -9
  82. package/dist/client/registerDataSource.js +15 -16
  83. package/dist/client/resourceTypes.d.ts +333 -0
  84. package/dist/client/resourceTypes.js +7 -0
  85. package/dist/client/schemaConfig.d.ts +44 -0
  86. package/dist/client/schemaConfig.js +176 -0
  87. package/dist/client/sessionMint.d.ts +17 -13
  88. package/dist/client/sessionMint.js +26 -31
  89. package/dist/client/validateAbloOptions.d.ts +12 -14
  90. package/dist/client/validateAbloOptions.js +9 -10
  91. package/dist/client/writeOptionsSchema.d.ts +18 -16
  92. package/dist/client/writeOptionsSchema.js +23 -20
  93. package/dist/client/wsMutationExecutor.d.ts +28 -0
  94. package/dist/client/wsMutationExecutor.js +71 -0
  95. package/dist/context.d.ts +6 -4
  96. package/dist/context.js +6 -7
  97. package/dist/coordination/index.d.ts +13 -4
  98. package/dist/coordination/index.js +29 -4
  99. package/dist/coordination/schema.d.ts +176 -128
  100. package/dist/coordination/schema.js +197 -133
  101. package/dist/coordination/trace.d.ts +9 -11
  102. package/dist/coordination/trace.js +13 -15
  103. package/dist/core/DatabaseManager.d.ts +5 -8
  104. package/dist/core/DatabaseManager.js +38 -40
  105. package/dist/core/QueryProcessor.d.ts +7 -9
  106. package/dist/core/QueryProcessor.js +27 -34
  107. package/dist/core/QueryView.d.ts +17 -5
  108. package/dist/core/QueryView.js +6 -7
  109. package/dist/core/StoreManager.d.ts +14 -16
  110. package/dist/core/StoreManager.js +26 -25
  111. package/dist/core/ViewRegistry.d.ts +5 -5
  112. package/dist/core/ViewRegistry.js +4 -4
  113. package/dist/core/index.d.ts +18 -13
  114. package/dist/core/index.js +32 -26
  115. package/dist/core/openIDBWithTimeout.d.ts +38 -36
  116. package/dist/core/openIDBWithTimeout.js +57 -54
  117. package/dist/core/queryUtils.d.ts +45 -0
  118. package/dist/core/queryUtils.js +69 -0
  119. package/dist/core/storeContract.d.ts +145 -0
  120. package/dist/core/storeContract.js +12 -0
  121. package/dist/environment.d.ts +28 -0
  122. package/dist/environment.js +21 -0
  123. package/dist/errorCodes.d.ts +118 -101
  124. package/dist/errorCodes.js +277 -260
  125. package/dist/errors.d.ts +170 -165
  126. package/dist/errors.js +161 -151
  127. package/dist/index.d.ts +30 -27
  128. package/dist/index.js +90 -82
  129. package/dist/interfaces/index.d.ts +108 -133
  130. package/dist/interfaces/index.js +5 -4
  131. package/dist/keys/index.d.ts +27 -29
  132. package/dist/keys/index.js +59 -49
  133. package/dist/mutators/RecordingTransaction.d.ts +16 -16
  134. package/dist/mutators/RecordingTransaction.js +31 -37
  135. package/dist/mutators/Transaction.d.ts +18 -26
  136. package/dist/mutators/Transaction.js +14 -20
  137. package/dist/mutators/UndoManager.d.ts +122 -131
  138. package/dist/mutators/UndoManager.js +149 -155
  139. package/dist/mutators/defineMutators.d.ts +24 -37
  140. package/dist/mutators/defineMutators.js +14 -20
  141. package/dist/mutators/inverseOp.d.ts +12 -15
  142. package/dist/mutators/inverseOp.js +12 -15
  143. package/dist/mutators/mutateActions.d.ts +10 -9
  144. package/dist/mutators/mutateActions.js +1 -1
  145. package/dist/mutators/readerActions.d.ts +9 -8
  146. package/dist/mutators/readerActions.js +2 -2
  147. package/dist/mutators/undoApply.d.ts +31 -27
  148. package/dist/mutators/undoApply.js +26 -24
  149. package/dist/policy/index.d.ts +5 -3
  150. package/dist/policy/index.js +5 -3
  151. package/dist/policy/types.d.ts +105 -101
  152. package/dist/policy/types.js +67 -66
  153. package/dist/query/client.d.ts +32 -16
  154. package/dist/query/client.js +103 -72
  155. package/dist/query/types.d.ts +37 -60
  156. package/dist/query/types.js +13 -33
  157. package/dist/react/AbloProvider.d.ts +7 -11
  158. package/dist/react/AbloProvider.js +24 -17
  159. package/dist/react/context.d.ts +27 -146
  160. package/dist/react/context.js +9 -10
  161. package/dist/react/index.d.ts +41 -42
  162. package/dist/react/index.js +37 -38
  163. package/dist/react/internalContext.d.ts +17 -19
  164. package/dist/react/useAblo.d.ts +23 -22
  165. package/dist/react/useAblo.js +17 -15
  166. package/dist/react/useCurrentUserId.d.ts +8 -7
  167. package/dist/react/useCurrentUserId.js +8 -7
  168. package/dist/react/useErrorListener.d.ts +7 -7
  169. package/dist/react/useErrorListener.js +11 -12
  170. package/dist/react/useMutationFailureListener.d.ts +8 -8
  171. package/dist/react/useMutationFailureListener.js +9 -9
  172. package/dist/react/useMutators.d.ts +11 -11
  173. package/dist/react/useMutators.js +10 -4
  174. package/dist/react/useReactive.js +2 -3
  175. package/dist/react/useSyncStatus.d.ts +4 -6
  176. package/dist/react/useUndoScope.d.ts +7 -9
  177. package/dist/react/useUndoScope.js +3 -3
  178. package/dist/schema/coordination.d.ts +21 -25
  179. package/dist/schema/coordination.js +21 -25
  180. package/dist/schema/ddl.d.ts +43 -39
  181. package/dist/schema/ddl.js +75 -68
  182. package/dist/schema/ddlLock.d.ts +35 -0
  183. package/dist/schema/ddlLock.js +46 -0
  184. package/dist/schema/diff.d.ts +99 -61
  185. package/dist/schema/diff.js +43 -34
  186. package/dist/schema/field.d.ts +37 -42
  187. package/dist/schema/field.js +36 -49
  188. package/dist/schema/generate.d.ts +12 -12
  189. package/dist/schema/generate.js +12 -12
  190. package/dist/schema/index.d.ts +5 -4
  191. package/dist/schema/index.js +29 -21
  192. package/dist/schema/model.d.ts +121 -146
  193. package/dist/schema/model.js +24 -35
  194. package/dist/schema/openapi.d.ts +10 -9
  195. package/dist/schema/openapi.js +7 -1
  196. package/dist/schema/queries.d.ts +30 -32
  197. package/dist/schema/queries.js +24 -25
  198. package/dist/schema/relation.d.ts +89 -99
  199. package/dist/schema/relation.js +13 -13
  200. package/dist/schema/residency.d.ts +38 -0
  201. package/dist/schema/residency.js +30 -0
  202. package/dist/schema/roles.d.ts +45 -27
  203. package/dist/schema/roles.js +52 -21
  204. package/dist/schema/schema.d.ts +36 -45
  205. package/dist/schema/schema.js +42 -39
  206. package/dist/schema/select.d.ts +13 -13
  207. package/dist/schema/select.js +13 -13
  208. package/dist/schema/serialize.d.ts +36 -39
  209. package/dist/schema/serialize.js +27 -31
  210. package/dist/schema/sugar.d.ts +17 -32
  211. package/dist/schema/sugar.js +14 -29
  212. package/dist/schema/{sync-delta-row.d.ts → syncDeltaRow.d.ts} +27 -50
  213. package/dist/schema/syncDeltaRow.js +89 -0
  214. package/dist/schema/tenancy.d.ts +44 -46
  215. package/dist/schema/tenancy.js +46 -48
  216. package/dist/server/adapter.d.ts +58 -58
  217. package/dist/server/adapter.js +13 -14
  218. package/dist/server/commit.d.ts +60 -64
  219. package/dist/server/index.d.ts +9 -10
  220. package/dist/server/index.js +1 -1
  221. package/dist/server/readConfig.d.ts +70 -0
  222. package/dist/server/readConfig.js +8 -0
  223. package/dist/server/storageMode.d.ts +23 -0
  224. package/dist/server/storageMode.js +17 -0
  225. package/dist/source/adapter.d.ts +31 -26
  226. package/dist/source/adapter.js +10 -10
  227. package/dist/source/adapters/drizzle.d.ts +28 -23
  228. package/dist/source/adapters/drizzle.js +34 -28
  229. package/dist/source/adapters/kysely.d.ts +27 -25
  230. package/dist/source/adapters/kysely.js +28 -26
  231. package/dist/source/adapters/memory.d.ts +8 -7
  232. package/dist/source/adapters/memory.js +10 -9
  233. package/dist/source/adapters/prisma.d.ts +13 -12
  234. package/dist/source/adapters/prisma.js +27 -29
  235. package/dist/source/conformance.d.ts +18 -11
  236. package/dist/source/conformance.js +27 -19
  237. package/dist/source/connector.d.ts +31 -32
  238. package/dist/source/connector.js +30 -28
  239. package/dist/source/connectorProtocol.d.ts +160 -0
  240. package/dist/source/connectorProtocol.js +162 -0
  241. package/dist/source/contract.d.ts +26 -27
  242. package/dist/source/contract.js +28 -29
  243. package/dist/source/factory.d.ts +94 -0
  244. package/dist/source/factory.js +268 -0
  245. package/dist/source/index.d.ts +10 -462
  246. package/dist/source/index.js +17 -421
  247. package/dist/source/migrations.d.ts +9 -9
  248. package/dist/source/migrations.js +9 -9
  249. package/dist/source/next.d.ts +10 -11
  250. package/dist/source/next.js +7 -8
  251. package/dist/source/pushQueue.d.ts +70 -48
  252. package/dist/source/pushQueue.js +36 -29
  253. package/dist/source/signing.d.ts +88 -0
  254. package/dist/source/signing.js +159 -0
  255. package/dist/source/types.d.ts +351 -0
  256. package/dist/source/types.js +43 -0
  257. package/dist/stores/ObjectStore.d.ts +11 -12
  258. package/dist/stores/ObjectStore.js +34 -35
  259. package/dist/stores/ObjectStoreContract.d.ts +12 -15
  260. package/dist/stores/SyncActionStore.d.ts +8 -12
  261. package/dist/stores/SyncActionStore.js +77 -46
  262. package/dist/surface.d.ts +28 -21
  263. package/dist/surface.js +28 -20
  264. package/dist/sync/{BootstrapHelper.d.ts → BootstrapFetcher.d.ts} +37 -45
  265. package/dist/sync/{BootstrapHelper.js → BootstrapFetcher.js} +101 -80
  266. package/dist/sync/ConnectionManager.d.ts +47 -50
  267. package/dist/sync/ConnectionManager.js +74 -70
  268. package/dist/sync/NetworkProbe.d.ts +27 -31
  269. package/dist/sync/NetworkProbe.js +67 -72
  270. package/dist/sync/{HydrationCoordinator.d.ts → OnDemandLoader.d.ts} +49 -36
  271. package/dist/sync/{HydrationCoordinator.js → OnDemandLoader.js} +79 -54
  272. package/dist/sync/{AreaOfInterestManager.d.ts → SubscriptionManager.d.ts} +45 -59
  273. package/dist/sync/{AreaOfInterestManager.js → SubscriptionManager.js} +44 -52
  274. package/dist/sync/SyncWebSocket.d.ts +175 -250
  275. package/dist/sync/SyncWebSocket.js +431 -769
  276. package/dist/sync/awaitClaimGrant.d.ts +18 -18
  277. package/dist/sync/awaitClaimGrant.js +38 -30
  278. package/dist/sync/bootstrapApply.d.ts +70 -0
  279. package/dist/sync/bootstrapApply.js +73 -0
  280. package/dist/sync/commitFrames.d.ts +44 -0
  281. package/dist/sync/commitFrames.js +94 -0
  282. package/dist/sync/createClaimStream.d.ts +23 -22
  283. package/dist/sync/createClaimStream.js +108 -25
  284. package/dist/sync/createPresenceStream.d.ts +19 -18
  285. package/dist/sync/createPresenceStream.js +25 -26
  286. package/dist/sync/createSnapshot.d.ts +13 -17
  287. package/dist/sync/createSnapshot.js +20 -26
  288. package/dist/sync/credentialLifecycle.d.ts +175 -0
  289. package/dist/sync/credentialLifecycle.js +322 -0
  290. package/dist/sync/deltaPipeline.d.ts +113 -0
  291. package/dist/sync/deltaPipeline.js +261 -0
  292. package/dist/sync/groupChange.d.ts +113 -0
  293. package/dist/sync/groupChange.js +242 -0
  294. package/dist/sync/heartbeat.d.ts +63 -0
  295. package/dist/sync/heartbeat.js +91 -0
  296. package/dist/sync/participants.d.ts +27 -27
  297. package/dist/sync/schemas.d.ts +3 -2
  298. package/dist/sync/schemas.js +14 -10
  299. package/dist/sync/syncCursor.d.ts +40 -0
  300. package/dist/sync/syncCursor.js +55 -0
  301. package/dist/sync/syncPlan.d.ts +54 -0
  302. package/dist/sync/syncPlan.js +50 -0
  303. package/dist/sync/syncPosition.d.ts +54 -49
  304. package/dist/sync/syncPosition.js +57 -52
  305. package/dist/sync/wsFrameHandlers.d.ts +116 -0
  306. package/dist/sync/wsFrameHandlers.js +374 -0
  307. package/dist/testing/fixtures/bootstrap.d.ts +21 -17
  308. package/dist/testing/fixtures/bootstrap.js +12 -6
  309. package/dist/testing/fixtures/deltas.d.ts +31 -34
  310. package/dist/testing/fixtures/deltas.js +30 -33
  311. package/dist/testing/fixtures/models.d.ts +11 -10
  312. package/dist/testing/fixtures/models.js +12 -10
  313. package/dist/testing/helpers/{react-wrapper.d.ts → reactWrapper.d.ts} +13 -10
  314. package/dist/testing/helpers/{react-wrapper.js → reactWrapper.js} +15 -12
  315. package/dist/testing/helpers/{sync-engine-harness.d.ts → syncEngineHarness.d.ts} +17 -18
  316. package/dist/testing/helpers/{sync-engine-harness.js → syncEngineHarness.js} +14 -11
  317. package/dist/testing/helpers/wait.d.ts +13 -8
  318. package/dist/testing/helpers/wait.js +13 -8
  319. package/dist/testing/index.d.ts +4 -4
  320. package/dist/testing/index.js +3 -3
  321. package/dist/testing/mocks/MockMutationExecutor.d.ts +18 -17
  322. package/dist/testing/mocks/MockMutationExecutor.js +15 -14
  323. package/dist/testing/mocks/MockNetworkMonitor.d.ts +8 -8
  324. package/dist/testing/mocks/MockNetworkMonitor.js +8 -8
  325. package/dist/testing/mocks/MockSyncContext.d.ts +21 -34
  326. package/dist/testing/mocks/MockSyncContext.js +16 -45
  327. package/dist/testing/mocks/MockSyncStore.d.ts +14 -14
  328. package/dist/testing/mocks/MockSyncStore.js +11 -11
  329. package/dist/testing/mocks/MockWebSocket.d.ts +28 -24
  330. package/dist/testing/mocks/MockWebSocket.js +22 -21
  331. package/dist/transactions/TransactionQueue.d.ts +190 -221
  332. package/dist/transactions/TransactionQueue.js +424 -822
  333. package/dist/transactions/TransactionStore.d.ts +20 -0
  334. package/dist/transactions/TransactionStore.js +53 -0
  335. package/dist/transactions/UnconfirmedWrites.d.ts +82 -0
  336. package/dist/transactions/UnconfirmedWrites.js +104 -0
  337. package/dist/transactions/coalesceRules.d.ts +58 -0
  338. package/dist/transactions/coalesceRules.js +140 -0
  339. package/dist/transactions/commitPayload.d.ts +130 -0
  340. package/dist/transactions/commitPayload.js +143 -0
  341. package/dist/transactions/deltaConfirmation.d.ts +58 -0
  342. package/dist/transactions/deltaConfirmation.js +215 -0
  343. package/dist/transactions/optimisticApply.d.ts +49 -0
  344. package/dist/transactions/optimisticApply.js +65 -0
  345. package/dist/transactions/replayValidation.d.ts +99 -0
  346. package/dist/transactions/replayValidation.js +111 -0
  347. package/dist/types/global.d.ts +46 -41
  348. package/dist/types/global.js +20 -19
  349. package/dist/types/index.d.ts +74 -80
  350. package/dist/types/index.js +22 -27
  351. package/dist/types/modelData.d.ts +10 -0
  352. package/dist/types/modelData.js +9 -0
  353. package/dist/types/participant.d.ts +20 -0
  354. package/dist/types/participant.js +10 -0
  355. package/dist/types/streams.d.ts +216 -209
  356. package/dist/types/streams.js +7 -7
  357. package/dist/utils/asyncIterator.d.ts +25 -32
  358. package/dist/utils/asyncIterator.js +25 -32
  359. package/dist/utils/duration.d.ts +12 -15
  360. package/dist/utils/duration.js +12 -15
  361. package/dist/utils/mobxSetup.d.ts +53 -0
  362. package/dist/utils/{mobx-setup.js → mobxSetup.js} +44 -100
  363. package/dist/webhooks/events.d.ts +21 -16
  364. package/dist/webhooks/events.js +10 -8
  365. package/dist/webhooks/index.d.ts +5 -7
  366. package/dist/webhooks/index.js +5 -7
  367. package/dist/{schema/sync-delta-wire.d.ts → wire/delta.d.ts} +58 -41
  368. package/dist/wire/delta.js +114 -0
  369. package/dist/wire/errorEnvelope.d.ts +35 -27
  370. package/dist/wire/errorEnvelope.js +38 -32
  371. package/dist/wire/frames.d.ts +150 -67
  372. package/dist/wire/frames.js +48 -1
  373. package/dist/wire/index.d.ts +18 -13
  374. package/dist/wire/index.js +36 -13
  375. package/dist/wire/listEnvelope.d.ts +16 -23
  376. package/dist/wire/listEnvelope.js +7 -6
  377. package/dist/wire/protocol.d.ts +38 -0
  378. package/dist/wire/protocol.js +38 -0
  379. package/dist/wire/protocolVersion.d.ts +60 -0
  380. package/dist/wire/protocolVersion.js +67 -0
  381. package/docs/api-keys.md +4 -3
  382. package/docs/coordination.md +59 -0
  383. package/docs/examples/existing-python-backend.md +3 -3
  384. package/docs/identity.md +4 -4
  385. package/docs/integration-guide.md +1 -1
  386. package/docs/react.md +1 -1
  387. package/docs/sessions.md +5 -7
  388. package/package.json +24 -21
  389. package/dist/ai-sdk/coordinated-tool.d.ts +0 -101
  390. package/dist/ai-sdk/coordination-context.d.ts +0 -52
  391. package/dist/client/index.d.ts +0 -36
  392. package/dist/client/index.js +0 -33
  393. package/dist/config/index.d.ts +0 -10
  394. package/dist/config/index.js +0 -12
  395. package/dist/core/query-utils.d.ts +0 -34
  396. package/dist/core/query-utils.js +0 -59
  397. package/dist/interfaces/headless.d.ts +0 -95
  398. package/dist/interfaces/headless.js +0 -41
  399. package/dist/query/index.d.ts +0 -6
  400. package/dist/query/index.js +0 -5
  401. package/dist/realtime/index.d.ts +0 -10
  402. package/dist/realtime/index.js +0 -9
  403. package/dist/schema/plane.d.ts +0 -23
  404. package/dist/schema/plane.js +0 -19
  405. package/dist/schema/sync-delta-row.js +0 -103
  406. package/dist/schema/sync-delta-wire.js +0 -102
  407. package/dist/server/next.d.ts +0 -51
  408. package/dist/server/next.js +0 -47
  409. package/dist/server/read-config.d.ts +0 -67
  410. package/dist/server/read-config.js +0 -8
  411. package/dist/server/storage-mode.d.ts +0 -1
  412. package/dist/server/storage-mode.js +0 -18
  413. package/dist/source/connector-protocol.d.ts +0 -159
  414. package/dist/source/connector-protocol.js +0 -161
  415. package/dist/sync/OfflineFlush.d.ts +0 -9
  416. package/dist/sync/OfflineFlush.js +0 -22
  417. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  418. package/dist/sync/OfflineTransactionStore.js +0 -263
  419. package/dist/transactions/OptimisticEchoTracker.d.ts +0 -82
  420. package/dist/transactions/OptimisticEchoTracker.js +0 -104
  421. package/dist/transactions/index.d.ts +0 -16
  422. package/dist/transactions/index.js +0 -7
  423. package/dist/transactions/mutation-error-handler.d.ts +0 -5
  424. package/dist/transactions/mutation-error-handler.js +0 -39
  425. package/dist/utils/mobx-setup.d.ts +0 -42
@@ -1,29 +1,34 @@
1
1
  /**
2
- * BaseSyncedStore Generic sync store base class for the SDK.
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.
3
5
  *
4
- * Exports the core types, interfaces, and a base class that app-specific
5
- * stores extend. The base class provides query/mutation/delta/bootstrap
6
- * orchestration. Subclasses add domain-specific lazy-loading, collaboration
7
- * events, and model enrichment.
8
- *
9
- * Design: The app's SyncedStore extends this and adds its own methods.
10
- * This file only contains types and the abstract contract — the actual
11
- * implementation stays in the app's SyncedStore.ts until we incrementally
12
- * pull generic methods into this base class.
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.
13
12
  */
14
- import { makeObservable, observable, computed, runInAction } from 'mobx';
13
+ import { makeObservable, observable, action, computed, runInAction } from 'mobx';
15
14
  import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
15
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
- import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
16
+ import { SubscriptionManager } from './sync/SubscriptionManager.js';
18
17
  import { resolveParticipantSyncGroups, } from './sync/participants.js';
18
+ import { ModelRegistry } from './ModelRegistry.js';
19
19
  import { PropertyType } from './types/index.js';
20
20
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
21
21
  import { QueryProcessor } from './core/QueryProcessor.js';
22
22
  import { Model, rowAsModel } from './Model.js';
23
23
  import { getContext } from './context.js';
24
- import { SyncSessionError } from './errors.js';
25
- import { ModelScope } from './ObjectPool.js';
24
+ import { SyncSessionError, isAccessCredentialExpiryCloseReason } from './errors.js';
25
+ import { ModelScope } from './InstanceCache.js';
26
26
  import { LazyReferenceCollection } from './LazyReferenceCollection.js';
27
+ import { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
28
+ import { CredentialLifecycle } from './sync/credentialLifecycle.js';
29
+ import * as groupChange from './sync/groupChange.js';
30
+ import * as bootstrapApply from './sync/bootstrapApply.js';
31
+ import * as deltaPipeline from './sync/deltaPipeline.js';
27
32
  /** Bootstrap timeout configuration */
28
33
  export const BOOTSTRAP_CONFIG = {
29
34
  OVERALL_TIMEOUT_MS: 15_000,
@@ -32,18 +37,20 @@ export const BOOTSTRAP_CONFIG = {
32
37
  };
33
38
  // Re-export for clean API
34
39
  export { ModelScope };
40
+ // deriveSyncPlanFromSchema derives a sync plan from a schema and is
41
+ // re-exported here.
42
+ export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
35
43
  // ── Base class ──────────────────────────────────────────────────────────────
36
44
  /**
37
- * BaseSyncedStore abstract base for app-specific sync stores.
38
- *
39
- * Provides the dependency structure, observable status, and protected
40
- * accessors that subclasses use. The actual sync orchestration (initialize,
41
- * delta processing, bootstrap, query, save, delete, etc.) lives in the
42
- * app's concrete subclass for now — methods will be pulled up into this
43
- * base class incrementally as they are genericized.
45
+ * The abstract base class that application-specific sync stores extend. It
46
+ * carries the injected collaborators, the observable sync status, and the
47
+ * orchestration for initialization, delta processing, bootstrap, and the
48
+ * read and write API. A subclass supplies its own domain behavior by
49
+ * overriding the protected extension points defined here and by typing its
50
+ * collaboration events through the generic parameter.
44
51
  *
45
- * Subclasses MUST call `super(dependencies, config)` and then set up
46
- * their own MobX observables.
52
+ * A subclass must call `super(dependencies, config)` and then set up its own
53
+ * MobX observables.
47
54
  *
48
55
  * Generic over `TCollaboration` — an app-defined event map for real-time
49
56
  * collaboration events (cursors, selections, presence beyond the core set).
@@ -61,57 +68,6 @@ export { ModelScope };
61
68
  * }
62
69
  * }
63
70
  */
64
- /**
65
- * Walk a schema and derive the three sync-plan arrays consumed by
66
- * `BaseSyncedStore`'s constructor: version-vector keys, FK indexes to
67
- * register on the pool, and the enrichment plan.
68
- *
69
- * Version vector keys are derived from each model's `typename` (lowercased
70
- * to match the server's event-type convention — `'Task'` → `'task'`,
71
- * `'SlideLayer'` → `'slidelayer'`). A fallback to the schema key applies
72
- * when `typename` is unset, though `defineSchema()` now always resolves
73
- * it during assembly so the fallback is defensive-only.
74
- *
75
- * FK indexes and enrichment entries are pulled from each `belongsTo`
76
- * relation where `options.index` / `options.enrich` is set. Relations
77
- * without those options are skipped — this is an opt-in mechanism so
78
- * adding a `belongsTo` never silently changes delta or lookup semantics.
79
- *
80
- * Pure function: takes a Schema, returns three arrays. No side effects,
81
- * no class state. Called once at construction time from `BaseSyncedStore`.
82
- */
83
- export function deriveSyncPlanFromSchema(schema) {
84
- const versionVectorKeys = [];
85
- const enrichmentPlan = [];
86
- const foreignKeyIndexes = [];
87
- for (const [modelName, def] of Object.entries(schema.models)) {
88
- const typename = def.typename ?? modelName;
89
- versionVectorKeys.push(typename.toLowerCase());
90
- for (const [relationKey, rel] of Object.entries(def.relations)) {
91
- if (rel.type === 'belongsTo') {
92
- if (rel.options?.index) {
93
- foreignKeyIndexes.push({ modelName: typename, fieldName: rel.foreignKey });
94
- }
95
- if (rel.options?.enrich) {
96
- enrichmentPlan.push({
97
- modelName: typename,
98
- foreignKey: rel.foreignKey,
99
- relationKey,
100
- });
101
- }
102
- }
103
- else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
104
- // hasMany/hasOne: the FK lives on the TARGET model, not the current model.
105
- // Register the FK index on the target so getByForeignKey works.
106
- // Target typename is resolved at registration time from the schema.
107
- const targetDef = schema.models[rel.target];
108
- const targetTypename = targetDef?.typename ?? rel.target;
109
- foreignKeyIndexes.push({ modelName: targetTypename, fieldName: rel.foreignKey });
110
- }
111
- }
112
- }
113
- return { versionVectorKeys, enrichmentPlan, foreignKeyIndexes };
114
- }
115
71
  export class BaseSyncedStore {
116
72
  // ── Observable sync status for UI ──
117
73
  syncStatus = {
@@ -162,23 +118,23 @@ export class BaseSyncedStore {
162
118
  // ── Area-of-interest (dynamic read subscription) ─────────────────
163
119
  //
164
120
  // `enterScope`/`leaveScope` move the connection's read interest as the
165
- // user navigates (open/close a deck, sheet, doc); `pinScope`/`unpinScope`
121
+ // user navigates (open or close a deck, sheet, or doc); `pinScope`/`unpinScope`
166
122
  // express prominence (an active claim keeps a group subscribed). All four
167
- // resolve the scope to sync-group strings through the SAME resolver the
123
+ // resolve the scope to sync-group strings through the same resolver the
168
124
  // claim path uses (`resolveParticipantSyncGroups`), so read interest and
169
- // write claims always agree on the string for a given entity. No-ops
170
- // before the socket exists. Soft state — they never reject for an offline
171
- // transport (see `AreaOfInterestManager.reconcile`).
125
+ // write claims always agree on the string for a given entity. They are
126
+ // no-ops before the socket exists, and they never reject when the transport
127
+ // is offline (see {@link SubscriptionManager.reconcile}).
172
128
  scopeToGroups(scope) {
173
129
  return resolveParticipantSyncGroups(scope, this.schema);
174
130
  }
175
131
  /**
176
- * Bring a scope into view subscribe to its groups. With
177
- * `{ hydrate: true }`, ALSO backfill the groups' current state into the pool
178
- * after the subscription is active (the game "spawn snapshot + delta stream"
179
- * pattern): subscribe-first so no live delta is missed in the gap, then
180
- * snapshot. Hydration is soft — a failed backfill never rejects `enterScope`
181
- * and the live tail still flows.
132
+ * Bring a scope into view and subscribe to its sync groups. With
133
+ * `{ hydrate: true }`, also backfill the groups' current state into the pool
134
+ * once the subscription is active. The order matters: subscribing first
135
+ * guarantees no live delta is missed in the gap before the snapshot lands.
136
+ * Hydration is best-effort — a failed backfill never rejects `enterScope`,
137
+ * and the live delta stream keeps flowing regardless.
182
138
  */
183
139
  enterScope(scope, opts) {
184
140
  const mgr = this.areaOfInterest;
@@ -191,11 +147,11 @@ export class BaseSyncedStore {
191
147
  return subscribed.then(() => this.hydrateGroups(groups));
192
148
  }
193
149
  /**
194
- * Backfill the current state of `syncGroups` into the pool via a PURE scoped
195
- * snapshot fetch + the version-guarded, ghost-free scoped apply. Idempotent
196
- * (skips groups already hydrated) and single-flight (concurrent enters of the
197
- * same group share one fetch). Soft-fails: on error the groups are NOT marked
198
- * hydrated, so a later re-enter retries.
150
+ * Backfill the current state of `syncGroups` into the pool with a side-effect-free
151
+ * scoped snapshot fetch followed by the version-guarded scoped apply. The call
152
+ * is idempotent (it skips groups already hydrated) and single-flight (concurrent
153
+ * enters of the same group share one fetch). On error the groups are left
154
+ * unmarked, so a later re-enter retries.
199
155
  */
200
156
  async hydrateGroups(syncGroups) {
201
157
  const need = syncGroups.filter((g) => !this.hydratedGroups.has(g) && !this.hydratingGroups.has(g));
@@ -254,29 +210,21 @@ export class BaseSyncedStore {
254
210
  // ── Internal helpers ──
255
211
  queryProcessor;
256
212
  /**
257
- * Runtime behavior flags only — the three schema/config arrays
258
- * (`versionVectorKeys`, `enrichmentPlan`, `foreignKeyIndexes`) are
259
- * consumed at construction time and stored on the instance as
260
- * `versionVector`, `enrichmentPlan`, and pool-registered indexes.
261
- * They don't need to persist on `this.config`.
213
+ * Runtime behavior flags only — the schema/config arrays
214
+ * (`enrichmentPlan`, `foreignKeyIndexes`) are consumed at construction
215
+ * time and stored on the instance as `enrichmentPlan` and
216
+ * pool-registered indexes. They don't need to persist on `this.config`.
262
217
  */
263
218
  config;
264
219
  disposers = [];
265
220
  initialized = false;
266
221
  dataReady = false;
267
222
  // ── User context ──
268
- // Identity context the consumer wired in at construction. The shape
269
- // (`{userId, organizationId, teamIds}`) is currently a fixed contract
270
- // because the Go-era bootstrap protocol embedded those keys in scope
271
- // tokens; the SDK should eventually expose this as an opaque
272
- // `principal` blob so consumers with different identity models
273
- // aren't forced into user/org. See the architectural note in the
274
- // README — "currentUserId" is a domain concept, not an SDK
275
- // primitive, and the host (apps/web/SyncEngineProvider) is the
276
- // right place to surface it.
223
+ // The identity the consumer supplied to `initialize()`: user id,
224
+ // organization id, and optional team ids. Reads are scoped to this
225
+ // identity, and the sync-group subscription is derived from it.
277
226
  userContext = null;
278
227
  // ── Smart sync ──
279
- versionVector;
280
228
  /**
281
229
  * Declarative enrichment plan: "for model X, when a delta arrives,
282
230
  * read data[foreignKey] and attach the matching parent from the pool
@@ -316,21 +264,19 @@ export class BaseSyncedStore {
316
264
  this._syncServerUrl = dependencies.url;
317
265
  // Set this store as the global Model store
318
266
  Model.setStore(this);
319
- // ── Schema-derived sync plan (Phase 2) ─────────────────────────────
267
+ // ── Schema-derived sync plan ───────────────────────────────────────
320
268
  //
321
- // When a schema is provided, derive version vector keys, FK indexes,
322
- // and the enrichment plan from declarative annotations on the schema's
323
- // `belongsTo` relations. Explicit config fields layer on top, so
324
- // subclasses (like Ablo's SyncedStore) can pass hardcoded arrays
325
- // without needing a full schema.generated.ts.
269
+ // When a schema is provided, derive foreign-key indexes and the
270
+ // enrichment plan from the declarative annotations on its `belongsTo`
271
+ // relations. Explicit config fields layer on top, so a subclass can
272
+ // pass hardcoded arrays without supplying a full schema.
326
273
  //
327
- // Order matters: schema-derived first, config second, so that in a
328
- // future where Ablo passes both (schema AND explicit config), the
329
- // explicit config entries are registered last and can't be
330
- // accidentally shadowed by schema derivation.
274
+ // Order matters: schema-derived entries are registered first and
275
+ // config entries second, so that when a caller supplies both, the
276
+ // explicit config entries win and are never shadowed by derivation.
331
277
  const derived = dependencies.schema
332
278
  ? deriveSyncPlanFromSchema(dependencies.schema)
333
- : { versionVectorKeys: [], enrichmentPlan: [], foreignKeyIndexes: [] };
279
+ : { enrichmentPlan: [], foreignKeyIndexes: [] };
334
280
  const mergedForeignKeyIndexes = [
335
281
  ...derived.foreignKeyIndexes,
336
282
  ...(config.foreignKeyIndexes ?? []),
@@ -338,9 +284,8 @@ export class BaseSyncedStore {
338
284
  for (const { modelName, fieldName } of mergedForeignKeyIndexes) {
339
285
  this.objectPool.registerForeignKey(modelName, fieldName);
340
286
  }
341
- // Legacy override hook — still called AFTER schema-driven registration
342
- // so subclasses can add more FKs on top of the declarative set.
343
- // Kept for backwards compat; subclasses migrate to config at leisure.
287
+ // Override hook — called after schema-driven registration so a subclass
288
+ // can add more foreign keys on top of the declarative set.
344
289
  this.registerForeignKeys();
345
290
  this.enrichmentPlan = [
346
291
  ...derived.enrichmentPlan,
@@ -361,14 +306,6 @@ export class BaseSyncedStore {
361
306
  batchingDelay: 100,
362
307
  maxBatchSize: 50,
363
308
  };
364
- // Version vector: union of schema-derived keys + explicit config keys,
365
- // each seeded to 0. Empty when neither source supplies keys (unchanged
366
- // behavior from pre-Phase-2 defaults).
367
- const mergedVvKeys = [
368
- ...derived.versionVectorKeys,
369
- ...(config.versionVectorKeys ?? []),
370
- ];
371
- this.versionVector = Object.fromEntries(mergedVvKeys.map((k) => [k, 0]));
372
309
  // Create internal helpers
373
310
  this.queryProcessor = new QueryProcessor({
374
311
  enableCache: this.config.enableCache,
@@ -380,15 +317,13 @@ export class BaseSyncedStore {
380
317
  this.queryProcessor.invalidateCache(`.*${name}.*`);
381
318
  }
382
319
  });
383
- // Make sync status fields observable so consumer code can do
320
+ // Make the sync-status fields observable so consumer code can do
384
321
  // reaction(() => store.isReady, ...)
385
322
  // observer(() => store.isOffline)
386
323
  // and actually receive notifications. Without these annotations,
387
- // `syncStatus` / `dataReady` are plain properties and the derived
388
- // getters (isReady, isSyncing, isOffline, ...) never emit change
389
- // signals — a trap that has burned multiple downstream apps
390
- // (one stuck forever on the loading skeleton because `reaction`
391
- // to `store.isReady` never fired). Explicit > accidental.
324
+ // `syncStatus` and `dataReady` are plain properties, and the derived
325
+ // getters (isReady, isSyncing, isOffline, and the rest) never emit
326
+ // change signals — so a `reaction` on `store.isReady` would never fire.
392
327
  makeObservable(this, {
393
328
  syncStatus: observable,
394
329
  dataReady: observable,
@@ -402,17 +337,17 @@ export class BaseSyncedStore {
402
337
  }
403
338
  // ── Protected extension points ────────────────────────────────────────────
404
339
  /**
405
- * Register foreign key indexes for O(1) lookups.
340
+ * Register foreign-key indexes for constant-time lookups.
406
341
  *
407
- * Legacy override hook in Phase 2 the preferred way to declare FK
408
- * indexes is via `config.foreignKeyIndexes` at construction time, or
409
- * by marking the `belongsTo` relation with `{ index: true }` in the
410
- * schema. This hook still fires AFTER the schema-derived + config
411
- * registrations, so subclasses can layer additional FKs on top.
342
+ * This is an override hook. The preferred way to declare a foreign-key
343
+ * index is `config.foreignKeyIndexes` at construction time, or marking the
344
+ * `belongsTo` relation with `{ index: true }` in the schema. The hook fires
345
+ * after the schema-derived and config registrations, so a subclass can
346
+ * layer additional indexes on top.
412
347
  */
413
348
  registerForeignKeys() { }
414
349
  /**
415
- * Enrich delta data with related models from the ObjectPool.
350
+ * Enrich delta data with related models from the InstanceCache.
416
351
  *
417
352
  * Base implementation walks `this.enrichmentPlan` — entries populated
418
353
  * from the schema's `{ enrich: true }` relations and from
@@ -465,23 +400,21 @@ export class BaseSyncedStore {
465
400
  */
466
401
  connectionManager = null;
467
402
  /**
468
- * Re-mint hook for the short-lived access credential (the Stripe-style
469
- * `ek_`/`rk_`). Wired by the React provider from its `getToken`/`authEndpoint`
470
- * the engine owns WHEN to refresh (a stale-credential probe / an external
471
- * nudge), the integrator owns HOW to mint. Mirrors the `getToken` contract:
472
- * resolves a token string on success, `null` when the long-lived login is
473
- * gone (terminal), and THROWS on a transient/offline failure. Used by
474
- * {@link performCredentialRefresh}. Absent no silent re-mint (e.g. a static
475
- * `apiKey` deployment whose credential source refreshes out-of-band).
403
+ * Access-credential re-mint + proactive pre-roll extracted to
404
+ * sync/credentialLifecycle.ts. Owns the refresher hook, the single-flight
405
+ * guard, and the browser-only refresh timer / wake listener; talks back
406
+ * through three lazily-resolved callbacks (the ConnectionManager doesn't
407
+ * exist until `setupWebSocketSync`). The `setCredentialRefresher` /
408
+ * `performCredentialRefresh` / `startCredentialLifecycle` methods below
409
+ * are thin delegates so the store's public surface is unchanged.
476
410
  */
477
- credentialRefresher = null;
478
- /** Single-flight guard so a wake nudge + an in-flight request + a probe don't
479
- * all mint at once (the classic "token thrash → random logout" bug). */
480
- inFlightCredentialRefresh = null;
481
- /** Teardown for the proactive credential lifecycle (refresh timer + wake/
482
- * online/focus listeners) installed by {@link startCredentialLifecycle};
483
- * cleared on {@link disconnect}. Null when no resolver is wired. */
484
- credentialLifecycleTeardown = null;
411
+ credentialLifecycle = new CredentialLifecycle({
412
+ setAuthToken: (token) => { this.auth?.setAuthToken(token); },
413
+ nudgeReconnect: () => { this.nudgeReconnect(); },
414
+ reportSessionExpired: () => {
415
+ this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
416
+ },
417
+ });
485
418
  /**
486
419
  * Listeners registered via `subscribeSessionError()`. Fired when the
487
420
  * WebSocket closes with a session-invalid code (1008/4001/4003) or a
@@ -525,7 +458,7 @@ export class BaseSyncedStore {
525
458
  }
526
459
  /**
527
460
  * Observe the LOCAL mutation stream for undo recording (see
528
- * {@link import('./react/context.js').LocalMutation}). Taps the
461
+ * {@link import('./core/storeContract.js').LocalMutation}). Taps the
529
462
  * TransactionQueue's `transaction:created` event — fired once per local
530
463
  * create/update/delete/archive with `previousData` already captured.
531
464
  * Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
@@ -538,7 +471,7 @@ export class BaseSyncedStore {
538
471
  // the queue's emitter does) — so undo recorded nothing. See
539
472
  // `SyncClient.onLocalTransaction` for the full rationale.
540
473
  return this.syncClient.onLocalTransaction((tx) => {
541
- if (!tx || !tx.type || !tx.modelName || !tx.modelId)
474
+ if (!tx?.type || !tx.modelName || !tx.modelId)
542
475
  return;
543
476
  handler({
544
477
  type: tx.type,
@@ -695,75 +628,30 @@ export class BaseSyncedStore {
695
628
  /**
696
629
  * Register the access-credential re-mint hook. Called by the React provider
697
630
  * with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
698
- * See {@link credentialRefresher}.
631
+ * See {@link CredentialLifecycle.setRefresher}.
699
632
  */
700
633
  setCredentialRefresher(refresher) {
701
- this.credentialRefresher = refresher;
634
+ this.credentialLifecycle.setRefresher(refresher);
702
635
  }
703
636
  /**
704
637
  * Re-mint the short-lived access credential and push it into the credential
705
638
  * source, reporting a tri-state outcome the {@link ConnectionManager} maps to
706
- * its FSM. The contract mirrors `getToken` (and PowerSync's `fetchCredentials`
707
- * / Liveblocks' `authEndpoint`, but made explicit instead of overloading
708
- * return/throw):
709
- * - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
710
- * - `null` → `'session_error'` (login itself is gone → terminal, sign out)
711
- * - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
712
- *
713
- * SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
714
- * probe) share one in-flight promise so we never double-mint — the canonical
715
- * fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
716
- *
717
- * No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
718
- * deployment has no session to re-mint from; its credential source refreshes
719
- * out-of-band, so we just re-probe with whatever it currently holds.
639
+ * its FSM. Single-flight; no refresher wired ⇒ `'refreshed'` (a no-op
640
+ * re-probe). Full contract on {@link CredentialLifecycle.refresh}.
720
641
  */
721
642
  async performCredentialRefresh() {
722
- const refresher = this.credentialRefresher;
723
- if (!refresher)
724
- return 'refreshed';
725
- if (this.inFlightCredentialRefresh)
726
- return this.inFlightCredentialRefresh;
727
- const run = (async () => {
728
- try {
729
- const token = await refresher();
730
- if (!token) {
731
- // null = the long-lived login is gone (mint endpoint answered 401/403).
732
- // Terminal — the FSM routes this to sign-out.
733
- return 'session_error';
734
- }
735
- this.auth?.setAuthToken(token);
736
- return 'refreshed';
737
- }
738
- catch (error) {
739
- // A throw = transient (offline / mint endpoint unreachable / 5xx). The
740
- // login may be perfectly valid; never sign out for this — back off and
741
- // retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
742
- const message = error?.message ?? String(error);
743
- // A relative-URL resolver invoked server-side (Node fetch has no origin
744
- // to resolve against) emits the opaque "Failed to parse URL" / "Only
745
- // absolute URLs are supported". Translate it into something actionable
746
- // instead of a mystery transient blip — the proactive refresh is now
747
- // browser-only, so hitting this means the resolver fired from SSR/RSC or
748
- // a server route.
749
- if (typeof window === 'undefined' && /parse URL|absolute URLs?/i.test(message)) {
750
- getContext().logger.warn('credential resolver ran on the server with a relative URL — Node fetch needs an absolute URL. ' +
751
- 'Refresh the Ablo client in the browser, or build an absolute URL server-side ' +
752
- "(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
753
- }
754
- else {
755
- getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
756
- }
757
- return 'network_error';
758
- }
759
- })();
760
- this.inFlightCredentialRefresh = run;
761
- try {
762
- return await run;
763
- }
764
- finally {
765
- this.inFlightCredentialRefresh = null;
766
- }
643
+ return this.credentialLifecycle.refresh();
644
+ }
645
+ /**
646
+ * The authentication-recovery path for HTTP transports, such as the lazy
647
+ * query lane. It runs a single-flight credential re-mint driven by the
648
+ * rejection's recovery class, routing outcomes through the same state
649
+ * machine the WebSocket probe uses. `'retry'` means a fresh credential is
650
+ * now in the credential source and the request should be replayed once.
651
+ * Full contract on {@link CredentialLifecycle.recoverFromAuthRejection}.
652
+ */
653
+ async recoverFromAuthRejection(recovery) {
654
+ return this.credentialLifecycle.recoverFromAuthRejection(recovery);
767
655
  }
768
656
  /**
769
657
  * Nudge the connection FSM to re-probe with the current credential. Idempotent
@@ -776,227 +664,71 @@ export class BaseSyncedStore {
776
664
  this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
777
665
  }
778
666
  /**
779
- * Install the access-credential lifecycle the CLIENT owns (this used to live
780
- * in the React provider wrong layer). Two parts:
781
- * 1. REACTIVE register `getToken` as the re-mint hook the FSM calls when a
782
- * probe finds the key stale (`credential_stale`) or on a nudge.
783
- * 2. PROACTIVE keep the short-lived key fresh ahead of trouble: a refresh
784
- * timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
785
- * block is browser-gated (`typeof window`): server/SSR has no socket to
786
- * keep warm and the resolver is browser-oriented, so arming it in Node
787
- * would fire a relative-URL fetch and throw. (Agents pass a static
788
- * `apiKey` with no resolver, so this method is never called for them.)
789
- *
790
- * Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
791
- * never call a refresh method. Idempotent (a second call replaces the first);
792
- * torn down on {@link disconnect}.
667
+ * Install the client-owned access-credential lifecycle: register `getToken`
668
+ * as the reactive re-mint hook and arm the browser-only proactive refresh
669
+ * (a refresh timer plus an OS-wake re-mint). Idempotent a second call
670
+ * replaces the first and torn down on {@link disconnect}. Full rationale
671
+ * on {@link CredentialLifecycle.start}.
793
672
  */
794
- startCredentialLifecycle(getToken) {
795
- this.stopCredentialLifecycle();
796
- this.setCredentialRefresher(getToken);
797
- // Re-mint through the SAME single-flight path the FSM's reactive probe uses
798
- // (`performCredentialRefresh`) rather than calling `getToken()` directly. Two
799
- // wins over the old direct call:
800
- // - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
801
- // roll share one in-flight promise — no double-mint thrash.
802
- // - The tri-state is HONOURED. The old code did `if (token) {…}` and
803
- // dropped a `null` on the floor — a zombie session that re-minted on
804
- // every tab focus and logged "signing out" forever without ever signing
805
- // out. `session_error` now drives the FSM to actually expire.
806
- const refresh = async () => {
807
- const outcome = await this.performCredentialRefresh();
808
- if (outcome === 'refreshed') {
809
- // Fresh key already pushed into the credential source by
810
- // `performCredentialRefresh`; nudge a parked connection to re-probe.
811
- this.nudgeReconnect();
812
- }
813
- else if (outcome === 'session_error') {
814
- // The long-lived login is gone (mint answered 401/403). Surface it —
815
- // the proactive path's job is to report this, not hide it. A no-op in
816
- // FSM states that don't accept the event (the probe converges on
817
- // sign-out there anyway); `session_expired`'s onEnter owns the log.
818
- this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
819
- }
820
- // 'network_error' → transient (offline / mint hiccup); the next timer tick
821
- // or the FSM's own probe retries. Never sign out for it.
822
- };
823
- const teardowns = [];
824
- // The ENTIRE proactive pre-roll is BROWSER-ONLY. On the server (Next.js
825
- // SSR/RSC evaluating the `providers` module) there is no live socket to keep
826
- // warm AND the scaffolded credential resolver is browser-oriented (a
827
- // relative-URL `fetch('/api/ablo-session')`). Arming the timer server-side
828
- // fires that resolver in Node, where fetch has no origin to resolve a
829
- // relative URL against → "Failed to parse URL" on every tick. Browser-only
830
- // refresh is the unanimous vendor model (Supabase `autoRefreshToken: isBrowser()`,
831
- // Clerk/Ably/Stripe refresh client-side). The reactive re-mint hook
832
- // (`setCredentialRefresher` above) stays UNCONDITIONAL: it only fires on a
833
- // real connection probe, which can't happen during a bare SSR module eval.
834
- if (typeof window !== 'undefined') {
835
- // Comfortably inside the 15m `ek_` TTL; a missed (background-throttled)
836
- // tick is recovered by the next, or by the reactive probe. The timer is
837
- // the sole proactive PRE-ROLL — it keeps the key warm ahead of expiry even
838
- // while the socket sits healthy-`connected` (a state the FSM never probes).
839
- const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
840
- const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
841
- teardowns.push(() => clearInterval(timer));
842
- // OS-wake (desktop only): the Electron shell bridges `powerMonitor`
843
- // 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
844
- // still owns, because `visibilitychange` does NOT fire on wake-from-sleep
845
- // and — unlike `online`/`visibilitychange` — the ConnectionManager's own
846
- // browser listeners (`setupBrowserListeners`) don't cover wake.
847
- //
848
- // The `online` and `visibilitychange` listeners that used to live here
849
- // were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
850
- // through this exact credential path, so registering them here too only
851
- // fired a second, null-swallowing mint per focus — the "session-key
852
- // POSTed on every tab focus" spam in the console.
853
- const onWake = () => void refresh();
854
- window.addEventListener('ablo:wake', onWake);
855
- teardowns.push(() => window.removeEventListener('ablo:wake', onWake));
856
- }
857
- this.credentialLifecycleTeardown = () => {
858
- for (const t of teardowns)
859
- t();
860
- };
673
+ startCredentialLifecycle(getToken, opts) {
674
+ this.credentialLifecycle.start(getToken, opts);
861
675
  }
862
676
  /** Tear down the proactive credential lifecycle (idempotent). */
863
677
  stopCredentialLifecycle() {
864
- this.credentialLifecycleTeardown?.();
865
- this.credentialLifecycleTeardown = null;
678
+ this.credentialLifecycle.stop();
679
+ }
680
+ // ── Sync group management ────────────────────────────────────────────────
681
+ //
682
+ // The implementation lives in the sync/groupChange module. The methods
683
+ // below are thin protected delegates that keep their signatures, so
684
+ // subclass override points still work; the module routes cross-handler
685
+ // calls back through `groupChangeContext()` to preserve dynamic dispatch.
686
+ /** Narrow context the group-change leaf talks back through. */
687
+ groupChangeContext() {
688
+ return {
689
+ database: this.database,
690
+ objectPool: this.objectPool,
691
+ getSubscribedSyncGroups: () => this.syncWebSocket?.getSyncGroups() ?? [],
692
+ getCurrentSyncGroups: () => this.userContext ? this.resolveSyncGroups(this.userContext) : null,
693
+ getBootstrapMode: () => this.userContext?.bootstrapMode,
694
+ disconnectWebSocket: () => { this.syncWebSocket?.disconnect(); },
695
+ emitConnectionEvent: (event) => { this.onConnectionEvent?.(event); },
696
+ handleGroupAdded: (payload, syncId) => this.handleGroupAdded(payload, syncId),
697
+ computeUpdatedSyncGroups: (payload) => this.computeUpdatedSyncGroups(payload),
698
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
699
+ };
866
700
  }
867
- // ── Sync Group Management ────────────────────────────────────────────────
868
701
  /**
869
- * Handle an actionType 'G' delta.
870
- *
871
- * The server emits 'G' via two distinct pathways, distinguished by payload
872
- * shape:
873
- *
874
- * Incremental (EmitGroupAdded): { group, userId }
875
- * - The recipient was added to a single sync group.
876
- * - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
877
- * - No re-bootstrap — entities arrive via the normal insert path.
878
- *
879
- * Legacy (EmitGroupChange): { addedGroups, removedGroups }
880
- * - Single delta carrying the full group membership diff.
881
- * - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
882
- * - Deprecated on the server; kept here for wire-level backward compat.
702
+ * Handle an actionType 'G' delta — incremental `{ group, userId }` or
703
+ * legacy `{ addedGroups, removedGroups }` payloads. Full pathway doc on
704
+ * {@link groupChange.handleSyncGroupChange}.
883
705
  */
884
706
  async handleSyncGroupChange(delta) {
885
- const raw = typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data;
886
- const rawObj = (raw ?? {});
887
- // Detect incremental payload shape: { group, userId }
888
- if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
889
- const incremental = {
890
- group: rawObj.group,
891
- userId: rawObj.userId,
892
- };
893
- await this.handleGroupAdded(incremental, delta.id);
894
- return;
895
- }
896
- // Legacy payload: { addedGroups, removedGroups }
897
- const payload = {
898
- removedGroups: rawObj.removedGroups ?? [],
899
- addedGroups: rawObj.addedGroups ?? [],
900
- };
901
- getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
902
- removedGroups: payload.removedGroups,
903
- addedGroups: payload.addedGroups,
904
- syncId: delta.id,
905
- });
906
- // SECURITY: If groups were removed, clear cached data immediately.
907
- // This prevents revoked data from persisting if the device goes offline
908
- // before the full re-bootstrap completes.
909
- if (payload.removedGroups.length > 0) {
910
- await this.database.clear();
911
- this.objectPool.clear();
912
- getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
913
- removedGroups: payload.removedGroups,
914
- });
915
- }
916
- const updatedGroups = this.computeUpdatedSyncGroups(payload);
917
- await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
918
- this.forceFullRebootstrap();
707
+ return groupChange.handleSyncGroupChange(this.groupChangeContext(), delta);
919
708
  }
920
709
  /**
921
- * Handle an incremental GroupAdded delta.
922
- *
923
- * Adds the new group to the subscription metadata without triggering a
924
- * re-bootstrap. The server will follow up with 'C' (Covering) deltas for
925
- * each newly-visible entity, which flow through the normal insert path.
710
+ * Handle an incremental GroupAdded delta — metadata only, no re-bootstrap
711
+ * (covering deltas bring the entities). See {@link groupChange.handleGroupAdded}.
926
712
  */
927
713
  async handleGroupAdded(payload, syncId) {
928
- getContext().logger.info('[BaseSyncedStore] Group added (incremental)', {
929
- group: payload.group,
930
- syncId,
931
- });
932
- const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
933
- current.add(payload.group);
934
- await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
935
- // Note: no forceFullRebootstrap() — covering deltas will bring the entities.
714
+ return groupChange.handleGroupAdded(this.groupChangeContext(), payload, syncId);
936
715
  }
937
716
  /**
938
- * Handle an actionType 'S' (GroupRemoved) delta.
939
- *
940
- * Signals that the recipient has lost access to a sync group. Because
941
- * the client does not track per-entity group membership, we can't
942
- * selectively purge entities belonging to that group. The safe fallback
943
- * is the legacy behavior: clear local state and force a re-bootstrap
944
- * with the updated group list.
945
- *
946
- * Future optimization: track group membership in the ObjectPool so 'S'
947
- * can do a targeted purge instead of a full re-bootstrap.
717
+ * Handle an actionType 'S' (GroupRemoved) delta: for safety, clear the
718
+ * revoked local state and trigger a full re-bootstrap. See
719
+ * {@link groupChange.handleGroupRemoved}.
948
720
  */
949
721
  async handleGroupRemoved(delta) {
950
- const raw = typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data;
951
- const rawObj = (raw ?? {});
952
- const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
953
- if (!groupKey) {
954
- getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
955
- syncId: delta.id,
956
- });
957
- return;
958
- }
959
- getContext().logger.info('[BaseSyncedStore] Group removed', {
960
- group: groupKey,
961
- syncId: delta.id,
962
- });
963
- // SECURITY: Clear cached data before re-bootstrap. This prevents
964
- // revoked-group data from persisting if the device goes offline
965
- // between receiving 'S' and completing the re-bootstrap.
966
- await this.database.clear();
967
- this.objectPool.clear();
968
- // Update subscription metadata so the re-bootstrap fetches the
969
- // correct set of groups.
970
- const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
971
- current.delete(groupKey);
972
- await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
973
- this.forceFullRebootstrap();
722
+ return groupChange.handleGroupRemoved(this.groupChangeContext(), delta);
974
723
  }
975
724
  /** Compute new sync groups after applying additions and removals */
976
725
  computeUpdatedSyncGroups(payload) {
977
- const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
978
- for (const g of payload.removedGroups)
979
- current.delete(g);
980
- for (const g of payload.addedGroups)
981
- current.add(g);
982
- return Array.from(current);
983
- }
984
- /** Force a full re-bootstrap via connection lifecycle event.
985
- *
986
- * No-op for `bootstrapMode: 'none'` participants — they never pull
987
- * baseline state, so a "force re-bootstrap" trigger (sync-group
988
- * shrink, scope revocation) instead just flushes the local pool and
989
- * relies on covering deltas to repopulate the data they actually
990
- * subscribe to.
991
- */
726
+ return groupChange.computeUpdatedSyncGroups(this.groupChangeContext(), payload);
727
+ }
728
+ /** Force a full re-bootstrap via connection lifecycle event (no-op for
729
+ * `bootstrapMode: 'none'` participants see {@link groupChange.forceFullRebootstrap}). */
992
730
  forceFullRebootstrap() {
993
- if (this.userContext?.bootstrapMode === 'none') {
994
- getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
995
- return;
996
- }
997
- this.database.markRequiresFullBootstrap();
998
- this.syncWebSocket?.disconnect();
999
- this.onConnectionEvent?.('WS_DISCONNECTED');
731
+ groupChange.forceFullRebootstrap(this.groupChangeContext());
1000
732
  }
1001
733
  /**
1002
734
  * Single source of truth for the sync-group list this session is
@@ -1007,71 +739,36 @@ export class BaseSyncedStore {
1007
739
  * check can never disagree.
1008
740
  */
1009
741
  resolveSyncGroups(context) {
1010
- if (context.syncGroups && context.syncGroups.length > 0) {
1011
- return context.syncGroups;
1012
- }
1013
- return [];
742
+ return groupChange.resolveSyncGroups(context);
1014
743
  }
1015
744
  /** Check if sync groups shrank since last session — force full bootstrap if so */
1016
745
  async checkSyncGroupShrinkage() {
1017
- if (!this.userContext)
1018
- return;
1019
- try {
1020
- const metadata = await this.database.getWorkspaceMetadata();
1021
- const stored = metadata?.subscribedSyncGroups ?? [];
1022
- if (stored.length === 0)
1023
- return;
1024
- const currentGroups = new Set(this.resolveSyncGroups(this.userContext));
1025
- const removedGroups = stored.filter((g) => !currentGroups.has(g));
1026
- if (removedGroups.length > 0) {
1027
- getContext().logger.info('[BaseSyncedStore] Sync groups shrank — forcing full bootstrap', {
1028
- removedGroups,
1029
- storedCount: stored.length,
1030
- currentCount: currentGroups.size,
1031
- });
1032
- // SECURITY: Clear cached data before re-bootstrap to prevent
1033
- // revoked-group data from persisting if device goes offline
1034
- await this.database.clear();
1035
- this.objectPool.clear();
1036
- this.database.markRequiresFullBootstrap();
1037
- }
1038
- await this.database.updateWorkspaceMetadata({
1039
- subscribedSyncGroups: Array.from(currentGroups),
1040
- });
1041
- }
1042
- catch (error) {
1043
- getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
1044
- error: error instanceof Error ? error.message : String(error),
1045
- });
1046
- }
746
+ return groupChange.checkSyncGroupShrinkage(this.groupChangeContext());
1047
747
  }
1048
- /** Apply bootstrap data to the ObjectPool with ghost removal */
1049
- /** Apply bootstrap data to the ObjectPool. Delegates pool writes to SyncClient. */
748
+ // ── Bootstrap apply ──────────────────────────────────────────────────────
749
+ //
750
+ // The implementation lives in the sync/bootstrapApply module. The protected
751
+ // delegates below keep their signatures and subclass overridability; the
752
+ // module talks back through `poolContext()`, with enrichment pre-bound to
753
+ // `this.enrichRelations` so that override point still applies.
754
+ /** Narrow context the bootstrap-apply leaf talks back through. */
755
+ poolContext() {
756
+ const store = this;
757
+ return {
758
+ applyDeltaBatchToPool: (results) => {
759
+ this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
760
+ },
761
+ applyBootstrapDataToPool: (bootstrapData, protectedIds) => this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds),
762
+ getPoolSize: () => this.objectPool.size,
763
+ getAllPoolIds: () => this.objectPool.getAllIds(),
764
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
765
+ set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
766
+ applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
767
+ };
768
+ }
769
+ /** Apply bootstrap data to the {@link InstanceCache}, removing entities that are no longer present (ghost removal). Pool writes are delegated to {@link SyncClient}. */
1050
770
  applyBootstrapToPool(bootstrapResult, protectedIds) {
1051
- const { bootstrapData } = bootstrapResult;
1052
- // Partial bootstrap: Database.processDeltaBatch already wrote the deltas
1053
- // to IDB. Route the same results through the delta-apply path so the
1054
- // in-memory pool evicts deleted entities (and updates modified ones).
1055
- // Without this, reconnect DELETEs persist to IDB but the canvas keeps
1056
- // showing ghost layers until a full reload.
1057
- if (bootstrapData.type === 'partial') {
1058
- const deltaResults = bootstrapResult.deltaResults;
1059
- if (deltaResults && deltaResults.length > 0) {
1060
- this.syncClient.applyDeltaBatchToPool(deltaResults, (name, data) => this.enrichRelations(name, data));
1061
- }
1062
- return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
1063
- }
1064
- if (!bootstrapData.models) {
1065
- return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
1066
- }
1067
- const start = typeof performance !== 'undefined' ? performance.now() : Date.now();
1068
- // SyncClient owns: model creation, healing, pool upsert, ghost removal
1069
- const stats = this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds);
1070
- const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - start);
1071
- getContext().logger.info('[BaseSyncedStore] Bootstrap applied', {
1072
- ...stats, elapsedMs, poolSize: this.objectPool.size,
1073
- });
1074
- return { ...stats, elapsedMs };
771
+ return bootstrapApply.applyBootstrapToPool(this.poolContext(), bootstrapResult, protectedIds);
1075
772
  }
1076
773
  // ── Initialize + Lifecycle ───────────────────────────────────────────────
1077
774
  /**
@@ -1082,14 +779,11 @@ export class BaseSyncedStore {
1082
779
  if (this.initialized)
1083
780
  return { success: true };
1084
781
  this.userContext = context;
1085
- // Propagate identity to SyncClient. Without this, every mutation
1086
- // silently drops in `processPendingMutations` / `stageMutation` with
1087
- // `userId=null, organizationId=null`. Previously the SDK assumed
1088
- // callers would call `syncClient.initialize()` themselves as a
1089
- // separate step that never happened from createSyncEngine, and
1090
- // the drop was invisible because both guard sites just early-return
1091
- // rather than throw. The right fix is to do it here where the store
1092
- // receives the context, so identity is one source of truth.
782
+ // Propagate identity to the SyncClient. Without this, every mutation
783
+ // silently drops in the mutation-staging path with a null userId and
784
+ // organizationId, because those guards early-return rather than throw.
785
+ // Setting it here, where the store receives the context, keeps identity
786
+ // in a single place.
1093
787
  yield this.syncClient.initialize(context.userId, context.organizationId);
1094
788
  try {
1095
789
  this.updateSyncStatus({ state: 'syncing', progress: 0 });
@@ -1108,12 +802,6 @@ export class BaseSyncedStore {
1108
802
  // Get sync baseline for WebSocket
1109
803
  const lastSyncId = (yield this.database.getLastSyncId());
1110
804
  this.syncClient.position.advancePersisted(lastSyncId || 0);
1111
- try {
1112
- const versions = (yield this.database.getVersionVector());
1113
- if (versions && typeof versions === 'object')
1114
- Object.assign(this.versionVector, versions);
1115
- }
1116
- catch { }
1117
805
  // If local data available, show UI immediately
1118
806
  if (hasLocalData) {
1119
807
  this.dataReady = true;
@@ -1154,8 +842,15 @@ export class BaseSyncedStore {
1154
842
  }
1155
843
  else if (requirements.type !== 'local') {
1156
844
  if (hasLocalData) {
1157
- // Background bootstrap — don't block UI
1158
- this.performBackgroundBootstrap(requirements, context, signal);
845
+ // Background bootstrap — don't block UI. The method captures its own
846
+ // operational failures; this backstop covers escapes from the
847
+ // delta-queue replay in withDeltaQueuing's finally (and the error
848
+ // handler itself), which would otherwise vanish unhandled.
849
+ void this.performBackgroundBootstrap(requirements, context, signal).catch((error) => {
850
+ getContext().observability.captureBootstrapFailure(error, {
851
+ type: 'background-orchestration',
852
+ });
853
+ });
1159
854
  }
1160
855
  else {
1161
856
  // First load — must wait for server data
@@ -1253,27 +948,11 @@ export class BaseSyncedStore {
1253
948
  }
1254
949
  /** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
1255
950
  collectDeltaProtectedIds(preBootstrapIds) {
1256
- const protectedIds = new Set();
1257
- for (const id of this.objectPool.getAllIds()) {
1258
- if (!preBootstrapIds.has(id))
1259
- protectedIds.add(id);
1260
- }
1261
- for (const delta of this.bootstrapDeltaQueue ?? []) {
1262
- if (delta.actionType !== 'D' && delta.modelId)
1263
- protectedIds.add(delta.modelId);
1264
- }
1265
- return protectedIds;
951
+ return bootstrapApply.collectDeltaProtectedIds(this.poolContext(), preBootstrapIds);
1266
952
  }
1267
- /** Replay deltas queued during bootstrap */
953
+ /** Replay deltas queued during bootstrap (atomically, via `applyDeltaFrame`). */
1268
954
  replayQueuedDeltas() {
1269
- const queue = this.bootstrapDeltaQueue;
1270
- this.bootstrapDeltaQueue = null;
1271
- if (!queue || queue.length === 0)
1272
- return;
1273
- // Deltas that landed during bootstrap are a complete frame — apply
1274
- // them atomically (one flush, one re-render) rather than dribbling
1275
- // each back through the live debounce path.
1276
- this.applyDeltaFrame(queue);
955
+ bootstrapApply.replayQueuedDeltas(this.poolContext());
1277
956
  }
1278
957
  /**
1279
958
  * Factory for the internal `ConnectionManager`. Override to return
@@ -1336,6 +1015,10 @@ export class BaseSyncedStore {
1336
1015
  }
1337
1016
  this.syncClient.disconnect();
1338
1017
  this.queryProcessor.clearCache();
1018
+ // Stop the pool's GC interval — the one timer the pool arms itself.
1019
+ // Without this a discarded store retains its whole pool via the interval
1020
+ // closure (and a Node process without `unref` support can't exit).
1021
+ this.objectPool.stopGC();
1339
1022
  this.updateSyncStatus({ state: 'offline' });
1340
1023
  }
1341
1024
  /**
@@ -1434,7 +1117,6 @@ export class BaseSyncedStore {
1434
1117
  organizationId: context.organizationId,
1435
1118
  syncGroups: [...this.resolveSyncGroups(context)],
1436
1119
  lastSyncId,
1437
- versions: this.versionVector,
1438
1120
  kind: context.kind,
1439
1121
  capabilityToken: context.capabilityToken,
1440
1122
  getAuthToken: this.auth?.getAuthToken,
@@ -1449,7 +1131,7 @@ export class BaseSyncedStore {
1449
1131
  // connection. baseGroups (the org/user scopes) are always subscribed;
1450
1132
  // enterScope/leaveScope move per-entity interest. Recreated with the
1451
1133
  // socket; torn down via the disposer pushed below.
1452
- this.areaOfInterest = new AreaOfInterestManager({
1134
+ this.areaOfInterest = new SubscriptionManager({
1453
1135
  transport: this.syncWebSocket,
1454
1136
  baseGroups: this.resolveSyncGroups(context),
1455
1137
  });
@@ -1506,7 +1188,9 @@ export class BaseSyncedStore {
1506
1188
  this.updateSyncStatus({ state: 'error', error });
1507
1189
  }
1508
1190
  });
1509
- const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
1191
+ // Terminal session loss (revocation / the login itself is gone): notify,
1192
+ // route the FSM to its terminal state, and clear local data.
1193
+ const handleTerminalSessionError = (error) => {
1510
1194
  getContext().observability.captureWebSocketError({ context: 'session-error', error: error.message });
1511
1195
  this.onConnectionEvent?.('WS_SESSION_ERROR');
1512
1196
  for (const listener of this.sessionErrorListeners) {
@@ -1524,6 +1208,54 @@ export class BaseSyncedStore {
1524
1208
  getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
1525
1209
  });
1526
1210
  this.objectPool.clear();
1211
+ };
1212
+ const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
1213
+ // WS analog of HTTP's `apikey_expired` (see SyncSessionError.
1214
+ // isSessionErrorResponse): the hub's keepalive reaper closes sockets
1215
+ // whose SHORT-LIVED access credential (`ek_`/`rk_`) passed its expiry
1216
+ // with `4001 credential_expired`. That is re-mintable from the
1217
+ // still-valid login — recover silently (un-latch, single-flight
1218
+ // re-mint, reconnect) instead of signing out and clearing local data.
1219
+ // Only a mint that answers `null` (the login itself is gone) falls
1220
+ // through to the terminal path. Without this branch, every credential
1221
+ // TTL elapse wedged the socket behind the write-once session latch.
1222
+ if (SyncSessionError.isSessionError(error) && isAccessCredentialExpiryCloseReason(error.message)) {
1223
+ getContext().observability.breadcrumb('WebSocket closed for expired access credential — re-minting', 'sync.websocket', 'warning');
1224
+ // Un-latch BEFORE the async mint so the FSM's own recovery
1225
+ // (probe → refreshing_credential → reconnect) is never blocked on
1226
+ // our .then() ordering.
1227
+ this.syncWebSocket?.clearSessionError();
1228
+ void this.performCredentialRefresh().then((outcome) => {
1229
+ if (outcome === 'refreshed') {
1230
+ if (this.connectionManager) {
1231
+ // Kick a parked FSM; a concurrent probe joins the same
1232
+ // single-flight mint, so this never double-mints.
1233
+ this.nudgeReconnect();
1234
+ }
1235
+ else {
1236
+ // Agent/system clients have no connection FSM
1237
+ // (createConnectionManager returns null for kind 'agent') —
1238
+ // reconnect the socket directly; connect() reads the
1239
+ // freshly-minted credential from the credential source.
1240
+ this.syncWebSocket?.resetReconnectAttempts();
1241
+ this.syncWebSocket?.connect();
1242
+ }
1243
+ return;
1244
+ }
1245
+ if (outcome === 'session_error') {
1246
+ // The mint endpoint rejected: the long-lived login is gone.
1247
+ // Re-latch so writes reject with the permanent session type
1248
+ // (see SyncWebSocket.notConnectedError) instead of parking.
1249
+ this.syncWebSocket?.setSessionErrorDetected();
1250
+ handleTerminalSessionError(error);
1251
+ }
1252
+ // 'network_error' → transient mint failure. The WS_DISCONNECTED
1253
+ // that follows this event already put the FSM on its probe/backoff
1254
+ // loop, which retries through the same single-flight refresh.
1255
+ });
1256
+ return;
1257
+ }
1258
+ handleTerminalSessionError(error);
1527
1259
  });
1528
1260
  // Handshake failed: WS close before open. The HTTP status is hidden
1529
1261
  // behind close code 1006, so we can't tell whether the server rejected
@@ -1647,69 +1379,66 @@ export class BaseSyncedStore {
1647
1379
  this.disposers.push(unsubCreated, unsubCompleted, unsubFailed);
1648
1380
  this.syncWebSocket.connect();
1649
1381
  }
1650
- // ── Delta Processing Pipeline ─────────────────────────────────────────────
1651
- /** State signature for delta deduplication */
1652
- extractStateSignature(delta) {
1653
- if (!delta.data || typeof delta.data !== 'object')
1654
- return null;
1655
- const data = typeof delta.data === 'string'
1656
- ? JSON.parse(delta.data)
1657
- : delta.data;
1658
- // Generic state fieldssubclasses can override getStateFields() for model-specific fields
1659
- const fieldsToCheck = this.getStateFields(delta.modelName);
1660
- const signature = {
1661
- actionType: delta.actionType,
1662
- modelName: delta.modelName,
1382
+ // ── Delta processing pipeline ─────────────────────────────────────────────
1383
+ //
1384
+ // The implementation lives in the sync/deltaPipeline module (deduplication,
1385
+ // enqueue bookkeeping, debounce, flush). The methods below are thin protected
1386
+ // delegates with unchanged signatures, and the module routes every call to a
1387
+ // protected override point back through `deltaPipelineContext`, so subclass
1388
+ // dynamic dispatch is preserved. `applyDeltaFrame`, the authoritative-apply
1389
+ // correctness point, deliberately stays here.
1390
+ /** Memoized pipeline context`enqueueDelta` runs once per delta, so the
1391
+ * accessor object is built once and reused (the get/set accessors always
1392
+ * read the live host fields). */
1393
+ _deltaPipelineContext = null;
1394
+ get deltaPipelineContext() {
1395
+ if (this._deltaPipelineContext)
1396
+ return this._deltaPipelineContext;
1397
+ const store = this;
1398
+ this._deltaPipelineContext = {
1399
+ // Shared pipeline state, backed by the host fields.
1400
+ get pendingDeltas() { return store.pendingDeltas; },
1401
+ set pendingDeltas(deltas) { store.pendingDeltas = deltas; },
1402
+ get batchTimer() { return store.batchTimer; },
1403
+ set batchTimer(timer) { store.batchTimer = timer; },
1404
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
1405
+ get smartSyncOptions() { return store.smartSyncOptions; },
1406
+ get highestProcessedSyncId() { return store.highestProcessedSyncId; },
1407
+ get lastAckedId() { return store.lastAckedId; },
1408
+ // SyncClient position/transaction bookkeeping.
1409
+ onDeltaReceived: (syncId) => { this.syncClient.onDeltaReceived(syncId); },
1410
+ advanceApplied: (syncId) => { this.syncClient.position.advanceApplied(syncId); },
1411
+ advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
1412
+ // Persistence + pool writes.
1413
+ processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
1414
+ applyDeltaBatchToPool: (results) => {
1415
+ this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
1416
+ },
1417
+ acknowledge: (syncId) => { this.syncWebSocket?.acknowledge?.(syncId); },
1418
+ get objectPool() { return store.objectPool; },
1419
+ // Dynamic-dispatch hooks — protected override points on this class.
1420
+ getStateFields: (modelName) => this.getStateFields(modelName),
1421
+ isCustomEntity: (modelName) => this.isCustomEntity(modelName),
1422
+ createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
1423
+ deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
1424
+ flushPendingDeltas: () => this.flushPendingDeltas(),
1425
+ handleFlushError: (error) => { this.handleFlushError(error); },
1426
+ handleSyncGroupChange: (delta) => this.handleSyncGroupChange(delta),
1427
+ handleGroupRemoved: (delta) => this.handleGroupRemoved(delta),
1428
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
1429
+ cascadeCancelTransactionsForDeletedParent: (parentModelName, parentId) => {
1430
+ this.cascadeCancelTransactionsForDeletedParent(parentModelName, parentId);
1431
+ },
1663
1432
  };
1664
- for (const field of fieldsToCheck) {
1665
- if (field in data)
1666
- signature[field] = data[field];
1667
- }
1668
- return signature;
1433
+ return this._deltaPipelineContext;
1669
1434
  }
1670
1435
  /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
1671
1436
  getStateFields(_modelName) {
1672
1437
  return ['status', 'state', 'isActive'];
1673
1438
  }
1674
- isSameState(a, b) {
1675
- if (!a || !b)
1676
- return false;
1677
- const keys = Object.keys(a);
1678
- if (keys.length !== Object.keys(b).length)
1679
- return false;
1680
- return keys.every((k) => a[k] === b[k]);
1681
- }
1682
1439
  /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
1683
1440
  deduplicateDeltas(deltas) {
1684
- const byEntity = new Map();
1685
- for (const d of deltas) {
1686
- const key = `${d.modelName}:${d.modelId}`;
1687
- if (!byEntity.has(key))
1688
- byEntity.set(key, []);
1689
- byEntity.get(key).push(d);
1690
- }
1691
- const result = [];
1692
- for (const entityDeltas of byEntity.values()) {
1693
- const sorted = entityDeltas.sort((a, b) => a.id - b.id);
1694
- // DELETE wins — it's the final state
1695
- const del = sorted.find((d) => d.actionType === 'D');
1696
- if (del) {
1697
- result.push(del);
1698
- continue;
1699
- }
1700
- // Keep deltas that represent different states
1701
- const unique = [];
1702
- let prev = null;
1703
- for (const d of sorted) {
1704
- const sig = this.extractStateSignature(d);
1705
- if (!this.isSameState(prev, sig)) {
1706
- unique.push(d);
1707
- prev = sig;
1708
- }
1709
- }
1710
- result.push(...(unique.length > 0 ? unique : [sorted[sorted.length - 1]]));
1711
- }
1712
- return result.sort((a, b) => a.id - b.id);
1441
+ return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
1713
1442
  }
1714
1443
  /** Process incoming delta with smart batching */
1715
1444
  processDeltaWithBatching(delta) {
@@ -1720,35 +1449,36 @@ export class BaseSyncedStore {
1720
1449
  /**
1721
1450
  * Apply a complete, server-delivered delta frame atomically.
1722
1451
  *
1723
- * A `delta_batch` WS event (reconnect/catch-up replay) already carries
1724
- * the FULL set of missed deltas. Routing it through the per-delta
1725
- * `processDeltaWithBatching` path re-chunks it via the live-traffic
1726
- * debounce timer + `maxBatchSize` force-flush, so a 300-delta catch-up
1727
- * fans out into ~6 separate `flushPendingDeltas` cycles — each its own
1728
- * IDB write, pool mutation, `models:changed` emit, and React re-render.
1729
- * The decks gallery visibly re-sorts and "pops in" once per chunk.
1452
+ * A `delta_batch` WebSocket event (a reconnect or catch-up replay) already
1453
+ * carries the full set of missed deltas. Routing it through the per-delta
1454
+ * `processDeltaWithBatching` path would re-chunk it via the live-traffic
1455
+ * debounce timer and `maxBatchSize` force-flush, so a 300-delta catch-up
1456
+ * would fan out into several separate `flushPendingDeltas` cycles — each its
1457
+ * own local write, pool mutation, `models:changed` emit, and re-render, so
1458
+ * the UI visibly repaints once per chunk.
1730
1459
  *
1731
- * Here we run the per-delta bookkeeping (dedup, ack, version vector,
1732
- * watermark, G/S routing, D cascade) for every delta WITHOUT scheduling
1733
- * a flush, then flush ONCE — collapsing the whole frame into a single
1734
- * IDB write + pool mutation + `models:changed` + re-render. Same code
1735
- * for the post-bootstrap replay of deltas queued during bootstrap.
1460
+ * Instead, this runs the per-delta bookkeeping (deduplication, ack, version
1461
+ * vector, watermark, group-change routing, delete cascade) for every delta
1462
+ * without scheduling a flush, then flushes once — collapsing the whole frame
1463
+ * into a single local write, pool mutation, `models:changed` emit, and
1464
+ * re-render. The post-bootstrap replay of deltas queued during bootstrap
1465
+ * uses the same path.
1736
1466
  *
1737
- * (Named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
1738
- * with `Database.processDeltaBatch` — the lower-level IDB write this
1739
- * eventually drives through `flushPendingDeltas`.)
1467
+ * It is named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
1468
+ * with {@link Database.processDeltaBatch} — the lower-level local write this
1469
+ * eventually drives through `flushPendingDeltas`.
1740
1470
  */
1741
1471
  applyDeltaFrame(deltas) {
1742
1472
  let enqueuedAny = false;
1743
1473
  for (const delta of deltas) {
1744
- // A delta_batch frame is the server's AUTHORITATIVE, ordered answer to
1474
+ // A delta_batch frame is the server's authoritative, ordered answer to
1745
1475
  // "everything in my stream after cursor C" (reconnect/catch-up replay or
1746
- // post-bootstrap drain). Apply every delta it carries do NOT subject it
1476
+ // post-bootstrap drain). Apply every delta it carries; do not subject it
1747
1477
  // to the live-traffic watermark dedup (`id <= applied`).
1748
1478
  //
1749
1479
  // That watermark is only valid under in-order delivery, and reconnect
1750
- // breaks the assumption: an in-flight LIVE broadcast for a gap delta can
1751
- // land out of order BEFORE the catch-up fills the ids below it (e.g. the
1480
+ // breaks the assumption: an in-flight live broadcast for a gap delta can
1481
+ // land out of order before the catch-up fills the ids below it (e.g. the
1752
1482
  // server acks a write, then the test/client reconnects, then that write's
1753
1483
  // pending broadcast arrives on the fresh socket — id 4 live before the
1754
1484
  // catch-up's [2,3,4]). Applying id 4 advances `applied` to 4, and the
@@ -1781,68 +1511,11 @@ export class BaseSyncedStore {
1781
1511
  * whether to debounce (live) or flush atomically (catch-up frame).
1782
1512
  */
1783
1513
  enqueueDelta(delta, options = {}) {
1784
- // Dedup guard — skip already-processed deltas. The `applied` watermark is a
1785
- // valid skip threshold ONLY for in-order live traffic; an authoritative
1786
- // catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
1787
- // live delta that advanced the watermark can't cause the frame's lower ids
1788
- // to be silently dropped.
1789
- if (!options.authoritative && delta.id > 0 && delta.id <= this.highestProcessedSyncId) {
1790
- return false;
1791
- }
1792
- // Confirm awaiting transactions via sync ID threshold (before batching)
1793
- this.syncClient.onDeltaReceived(delta.id);
1794
- // Update version vector
1795
- const entityType = delta.modelName.toLowerCase();
1796
- if (this.versionVector[entityType] !== undefined) {
1797
- this.versionVector[entityType] = Math.max(this.versionVector[entityType], delta.id);
1798
- }
1799
- // Queue during active bootstrap
1800
- if (this.bootstrapDeltaQueue !== null) {
1801
- this.bootstrapDeltaQueue.push(delta);
1802
- return false;
1803
- }
1804
- // Advance watermark
1805
- this.syncClient.position.advanceApplied(delta.id);
1806
- // Sync group added — handle immediately. Supports both legacy
1807
- // (addedGroups/removedGroups) and incremental (group/userId) payloads.
1808
- if (delta.actionType === 'G') {
1809
- void this.handleSyncGroupChange(delta);
1810
- return false;
1811
- }
1812
- // Sync group removed — handle immediately. Clears affected local state
1813
- // and forces re-bootstrap with the updated group list.
1814
- if (delta.actionType === 'S') {
1815
- void this.handleGroupRemoved(delta);
1816
- return false;
1817
- }
1818
- // DELETE — fire the cascade cancel immediately (O(1) via FK index;
1819
- // must run BEFORE any subsequent update on the same model lands so
1820
- // pending update transactions for soon-deleted children don't race
1821
- // their parent's delete) but route the IDB+pool write through the
1822
- // same batched path as UPDATEs. The previous immediate-flush path
1823
- // produced N IDB writes + N pool mutations + N `models:changed`
1824
- // events when a peer deleted a chart with N layers; the batched
1825
- // path produces one of each per microtask flush. Dedup in
1826
- // `flushPendingDeltas` handles the U-then-D-on-same-model case
1827
- // correctly via arrival-order replay through `processDeltaBatch`.
1828
- if (delta.actionType === 'D') {
1829
- this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
1830
- }
1831
- this.pendingDeltas.push(delta);
1832
- return true;
1514
+ return deltaPipeline.enqueueDelta(this.deltaPipelineContext, delta, options);
1833
1515
  }
1834
1516
  /** Debounce a flush for live single-delta traffic. */
1835
1517
  scheduleDeltaFlush() {
1836
- if (this.batchTimer)
1837
- clearTimeout(this.batchTimer);
1838
- if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
1839
- void this.flushPendingDeltas().catch(this.handleFlushError);
1840
- }
1841
- else {
1842
- this.batchTimer = setTimeout(() => {
1843
- void this.flushPendingDeltas().catch(this.handleFlushError);
1844
- }, this.smartSyncOptions.batchingDelay);
1845
- }
1518
+ deltaPipeline.scheduleDeltaFlush(this.deltaPipelineContext);
1846
1519
  }
1847
1520
  /**
1848
1521
  * Cancel pending transactions for child entities when a parent is deleted.
@@ -1878,88 +1551,15 @@ export class BaseSyncedStore {
1878
1551
  });
1879
1552
  }
1880
1553
  }
1881
- /** Flush pending deltas with deduplication and batched ObjectPool mutations */
1882
- /** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
1554
+ /** Flush pending deltas with deduplication. Pool writes are delegated to {@link SyncClient}. */
1883
1555
  async flushPendingDeltas() {
1884
- if (this.pendingDeltas.length === 0)
1885
- return;
1886
- const deduplicatedDeltas = this.deduplicateDeltas(this.pendingDeltas);
1887
- // Custom entities → apply directly to ObjectPool (skip IDB)
1888
- const customDeltas = deduplicatedDeltas.filter((d) => this.isCustomEntity(d.modelName));
1889
- if (customDeltas.length > 0) {
1890
- runInAction(() => {
1891
- for (const delta of customDeltas) {
1892
- const data = typeof delta.data === 'string'
1893
- ? JSON.parse(delta.data)
1894
- : delta.data;
1895
- // 'C' (Covering) is treated identically to 'I' here — the client
1896
- // gained permission to see the entity, so we insert it into the
1897
- // pool as if newly created.
1898
- if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
1899
- const existing = this.objectPool.get(delta.modelId);
1900
- if (existing) {
1901
- existing.updateFromData(data);
1902
- }
1903
- else {
1904
- const model = this.createCustomEntity(delta.modelName, delta.modelId, data);
1905
- if (model) {
1906
- model.markAsPersisted();
1907
- this.objectPool.add(model, ModelScope.live);
1908
- }
1909
- }
1910
- }
1911
- else if (delta.actionType === 'D') {
1912
- this.objectPool.remove(delta.modelId);
1913
- }
1914
- }
1915
- });
1916
- }
1917
- // Regular deltas → IDB then ObjectPool via SyncClient.
1918
- // 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
1919
- // handleGroupRemoved) and never reach flushPendingDeltas, but the
1920
- // Database.processDelta signature accepts them defensively.
1921
- const regularDeltas = deduplicatedDeltas.filter((d) => !this.isCustomEntity(d.modelName));
1922
- const batch = await this.database.processDeltaBatch(regularDeltas.map((d) => ({
1923
- syncId: d.id,
1924
- actionType: d.actionType,
1925
- modelName: d.modelName,
1926
- modelId: d.modelId,
1927
- data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
1928
- // Thread `transactionId` through so the receive layer can
1929
- // recognize echoes of locally-applied transactions and skip
1930
- // the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
1931
- transactionId: d.transactionId,
1932
- })));
1933
- const dbResults = batch.results;
1934
- // Delegate ObjectPool writes to SyncClient (owns pool operations)
1935
- this.syncClient.applyDeltaBatchToPool(dbResults, (name, data) => this.enrichRelations(name, data));
1936
- // Acknowledge + advance sync cursor — gated on IDB persistence.
1937
- //
1938
- // We MUST ack `persistedSyncId` (the high-water mark of deltas whose
1939
- // store transaction actually committed), NOT the input batch's last
1940
- // delta id. Acking by input range advances the server's view past
1941
- // deltas that never wrote to IDB; the next catch-up request would
1942
- // then send the advanced cursor and the server replies "you're up
1943
- // to date" — losing the un-persisted delta forever. This is the
1944
- // Replicache "same-transaction" invariant: the cursor and the
1945
- // persisted view must be consistent.
1946
- const persistedSyncId = batch.persistedSyncId;
1947
- if (persistedSyncId > this.lastAckedId) {
1948
- this.syncWebSocket?.acknowledge?.(persistedSyncId);
1949
- this.syncClient.position.advancePersisted(persistedSyncId);
1950
- }
1951
- // Cache invalidation is automatic via SyncClient 'models:changed' event
1952
- this.pendingDeltas = [];
1953
- if (this.batchTimer) {
1954
- clearTimeout(this.batchTimer);
1955
- this.batchTimer = null;
1956
- }
1556
+ return deltaPipeline.flushPendingDeltas(this.deltaPipelineContext);
1957
1557
  }
1958
- // ── Core Mutations (thin delegation to SyncClient) ────────────────────────
1558
+ // ── Core mutations (thin delegation to SyncClient) ────────────────────────
1959
1559
  //
1960
- // BaseSyncedStore is an orchestrator, not an implementor.
1961
- // SyncClient owns: ObjectPool operations, TransactionQueue, IDB writes.
1962
- // BaseSyncedStore owns: validation, hooks, pending delete tracking.
1560
+ // This class orchestrates; it does not implement the writes. {@link SyncClient}
1561
+ // owns the object-pool operations, the transaction queue, and local writes.
1562
+ // This class owns validation, lifecycle hooks, and pending-delete tracking.
1963
1563
  /** Check if a model type is local-only (no sync). Override for domain-specific models. */
1964
1564
  isLocalOnlyModel(_modelName) {
1965
1565
  return false;
@@ -2031,9 +1631,9 @@ export class BaseSyncedStore {
2031
1631
  this.syncClient.update(model);
2032
1632
  }
2033
1633
  // ── Query API ────────────────────────────────────────────────────────────
2034
- // `store.query.<model>.*` was DELETED — `ablo.<model>.get/getAll` is the
2035
- // one read surface. Custom mutators still read transactionally through
2036
- // `tx.<model>` (mutators/Transaction.ts), which owns `createReaderActions`.
1634
+ // `ablo.<model>.get` / `ablo.<model>.getAll` is the read surface for
1635
+ // application code. Custom mutators read transactionally through
1636
+ // `tx.<model>`, backed by `createReaderActions`.
2037
1637
  /** Retrieve a single entity by id. Synchronous pool read. */
2038
1638
  retrieve(_modelClass, id) {
2039
1639
  return this.objectPool.get(id);
@@ -2077,10 +1677,10 @@ export class BaseSyncedStore {
2077
1677
  return this.objectPool.create(wireTypename, data);
2078
1678
  }
2079
1679
  /**
2080
- * Legacy class-based query entry point — kept for callers that still pass
2081
- * a Model constructor + options object. New code should use the typed
2082
- * `store.query.<modelKey>` namespace instead, which returns properly
2083
- * inferred schema types without needing a class value or cast.
1680
+ * Query entry point for callers that hold a {@link Model} constructor and an
1681
+ * options object. It filters, orders, and paginates the matching models from
1682
+ * the pool. Prefer the schema-typed read surface (`ablo.<model>.list`) where
1683
+ * you can, since it infers concrete row types without a class value or cast.
2084
1684
  */
2085
1685
  queryByClass(modelClass, options) {
2086
1686
  const modelName = this.objectPool.registry.getModelNameFromConstructor(modelClass);
@@ -2189,8 +1789,7 @@ export class BaseSyncedStore {
2189
1789
  return this.lastAckedId;
2190
1790
  }
2191
1791
  // ── Status convenience getters ──────────────────────────────────────────
2192
- // Thin wrappers over syncStatus for consumer ergonomics. Previously on
2193
- // SyncedStore; moved here so createSyncEngine consumers get them too.
1792
+ // Thin wrappers over `syncStatus` for consumer ergonomics.
2194
1793
  get isReady() {
2195
1794
  // Ready if: fully synced (idle + 100%) OR local data loaded (dataReady + syncing in background)
2196
1795
  return (this.syncStatus.state === 'idle' && this.syncStatus.progress >= 100)