@abloatai/ablo 0.25.0 → 0.26.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 (244) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.d.ts +72 -195
  5. package/dist/BaseSyncedStore.js +240 -625
  6. package/dist/Database.d.ts +8 -9
  7. package/dist/Database.js +9 -14
  8. package/dist/LazyReferenceCollection.js +5 -0
  9. package/dist/Model.js +6 -8
  10. package/dist/ModelRegistry.d.ts +25 -6
  11. package/dist/ModelRegistry.js +12 -6
  12. package/dist/NetworkMonitor.js +1 -1
  13. package/dist/ObjectPool.js +9 -3
  14. package/dist/SyncClient.d.ts +15 -5
  15. package/dist/SyncClient.js +152 -66
  16. package/dist/SyncEngineContext.d.ts +1 -3
  17. package/dist/SyncEngineContext.js +1 -2
  18. package/dist/agent/Agent.d.ts +21 -8
  19. package/dist/agent/Agent.js +26 -13
  20. package/dist/ai-sdk/coordination-context.js +9 -3
  21. package/dist/ai-sdk/wrap.d.ts +1 -1
  22. package/dist/ai-sdk/wrap.js +1 -1
  23. package/dist/auth/credentialPolicy.d.ts +10 -2
  24. package/dist/auth/credentialSource.d.ts +1 -13
  25. package/dist/auth/credentialSource.js +6 -13
  26. package/dist/auth/index.js +3 -3
  27. package/dist/cli.cjs +328 -262
  28. package/dist/client/Ablo.d.ts +35 -764
  29. package/dist/client/Ablo.js +64 -688
  30. package/dist/client/ApiClient.d.ts +14 -1
  31. package/dist/client/ApiClient.js +63 -9
  32. package/dist/client/auth.d.ts +8 -17
  33. package/dist/client/auth.js +42 -6
  34. package/dist/client/consoleLogger.d.ts +36 -0
  35. package/dist/client/consoleLogger.js +45 -0
  36. package/dist/client/createInternalComponents.js +1 -1
  37. package/dist/client/createModelProxy.js +10 -6
  38. package/dist/client/credentialEndpoint.d.ts +63 -0
  39. package/dist/client/credentialEndpoint.js +87 -0
  40. package/dist/client/hostedEndpoints.d.ts +24 -0
  41. package/dist/client/hostedEndpoints.js +24 -0
  42. package/dist/client/httpClient.d.ts +1 -1
  43. package/dist/client/identity.js +2 -1
  44. package/dist/client/modelRegistration.d.ts +14 -0
  45. package/dist/client/modelRegistration.js +321 -0
  46. package/dist/client/options.d.ts +396 -0
  47. package/dist/client/options.js +10 -0
  48. package/dist/client/resourceTypes.d.ts +344 -0
  49. package/dist/client/resourceTypes.js +13 -0
  50. package/dist/client/schemaConfig.d.ts +56 -0
  51. package/dist/client/schemaConfig.js +188 -0
  52. package/dist/client/sessionMint.d.ts +1 -1
  53. package/dist/client/validateAbloOptions.js +1 -1
  54. package/dist/client/wsMutationExecutor.d.ts +33 -0
  55. package/dist/client/wsMutationExecutor.js +77 -0
  56. package/dist/context.js +0 -3
  57. package/dist/coordination/index.d.ts +8 -1
  58. package/dist/coordination/index.js +24 -1
  59. package/dist/coordination/trace.d.ts +0 -1
  60. package/dist/coordination/trace.js +0 -1
  61. package/dist/core/DatabaseManager.d.ts +0 -1
  62. package/dist/core/DatabaseManager.js +26 -24
  63. package/dist/core/QueryProcessor.js +7 -8
  64. package/dist/core/QueryView.d.ts +14 -2
  65. package/dist/core/QueryView.js +4 -5
  66. package/dist/core/StoreManager.d.ts +2 -2
  67. package/dist/core/StoreManager.js +5 -1
  68. package/dist/core/index.d.ts +1 -1
  69. package/dist/core/openIDBWithTimeout.js +15 -11
  70. package/dist/core/query-utils.js +1 -1
  71. package/dist/core/storeContract.d.ts +143 -0
  72. package/dist/core/storeContract.js +16 -0
  73. package/dist/errorCodes.d.ts +11 -2
  74. package/dist/errorCodes.js +155 -137
  75. package/dist/errors.d.ts +18 -7
  76. package/dist/errors.js +14 -1
  77. package/dist/index.js +5 -0
  78. package/dist/interfaces/index.d.ts +6 -20
  79. package/dist/keys/index.js +18 -9
  80. package/dist/mutators/UndoManager.js +6 -1
  81. package/dist/mutators/defineMutators.d.ts +1 -3
  82. package/dist/policy/types.d.ts +1 -1
  83. package/dist/query/client.d.ts +11 -0
  84. package/dist/query/client.js +81 -52
  85. package/dist/react/AbloProvider.d.ts +6 -10
  86. package/dist/react/AbloProvider.js +22 -15
  87. package/dist/react/context.d.ts +2 -118
  88. package/dist/react/useAblo.js +1 -1
  89. package/dist/react/useErrorListener.js +1 -1
  90. package/dist/react/useMutationFailureListener.js +1 -1
  91. package/dist/react/useMutators.js +7 -1
  92. package/dist/react/useReactive.js +0 -1
  93. package/dist/react/useUndoScope.js +2 -2
  94. package/dist/schema/ddlLock.d.ts +39 -0
  95. package/dist/schema/ddlLock.js +51 -0
  96. package/dist/schema/field.js +1 -1
  97. package/dist/schema/index.d.ts +4 -3
  98. package/dist/schema/index.js +15 -5
  99. package/dist/schema/model.d.ts +3 -3
  100. package/dist/schema/model.js +2 -2
  101. package/dist/schema/openapi.js +5 -1
  102. package/dist/schema/queries.d.ts +1 -1
  103. package/dist/schema/queries.js +1 -0
  104. package/dist/schema/residency.d.ts +35 -0
  105. package/dist/schema/residency.js +27 -0
  106. package/dist/schema/roles.d.ts +25 -0
  107. package/dist/schema/roles.js +37 -0
  108. package/dist/schema/schema.d.ts +3 -3
  109. package/dist/schema/schema.js +11 -7
  110. package/dist/schema/serialize.d.ts +8 -8
  111. package/dist/schema/sync-delta-row.d.ts +2 -2
  112. package/dist/schema/sync-delta-row.js +2 -2
  113. package/dist/server/storage-mode.d.ts +8 -1
  114. package/dist/server/storage-mode.js +14 -4
  115. package/dist/source/adapter.d.ts +1 -1
  116. package/dist/source/adapters/drizzle.js +4 -3
  117. package/dist/source/adapters/kysely.js +4 -3
  118. package/dist/source/adapters/memory.js +1 -1
  119. package/dist/source/adapters/prisma.js +5 -4
  120. package/dist/source/conformance.js +10 -8
  121. package/dist/source/connector.js +4 -2
  122. package/dist/source/factory.d.ts +106 -0
  123. package/dist/source/factory.js +273 -0
  124. package/dist/source/index.d.ts +11 -461
  125. package/dist/source/index.js +14 -416
  126. package/dist/source/next.d.ts +1 -1
  127. package/dist/source/next.js +1 -1
  128. package/dist/source/pushQueue.d.ts +1 -1
  129. package/dist/source/pushQueue.js +6 -3
  130. package/dist/source/signing.d.ts +59 -0
  131. package/dist/source/signing.js +142 -0
  132. package/dist/source/types.d.ts +334 -0
  133. package/dist/source/types.js +44 -0
  134. package/dist/stores/ObjectStore.d.ts +1 -1
  135. package/dist/stores/ObjectStore.js +23 -23
  136. package/dist/stores/SyncActionStore.d.ts +1 -1
  137. package/dist/stores/SyncActionStore.js +70 -35
  138. package/dist/surface.d.ts +1 -1
  139. package/dist/surface.js +1 -0
  140. package/dist/sync/AreaOfInterestManager.d.ts +2 -2
  141. package/dist/sync/AreaOfInterestManager.js +1 -1
  142. package/dist/sync/BootstrapHelper.d.ts +1 -3
  143. package/dist/sync/BootstrapHelper.js +29 -8
  144. package/dist/sync/ConnectionManager.d.ts +8 -0
  145. package/dist/sync/ConnectionManager.js +19 -4
  146. package/dist/sync/HydrationCoordinator.d.ts +12 -0
  147. package/dist/sync/HydrationCoordinator.js +25 -5
  148. package/dist/sync/NetworkProbe.d.ts +3 -2
  149. package/dist/sync/NetworkProbe.js +7 -6
  150. package/dist/sync/SyncWebSocket.d.ts +71 -120
  151. package/dist/sync/SyncWebSocket.js +324 -630
  152. package/dist/sync/awaitClaimGrant.js +27 -19
  153. package/dist/sync/bootstrapApply.d.ts +60 -0
  154. package/dist/sync/bootstrapApply.js +65 -0
  155. package/dist/sync/commitFrames.d.ts +43 -0
  156. package/dist/sync/commitFrames.js +94 -0
  157. package/dist/sync/createClaimStream.js +3 -2
  158. package/dist/sync/createSnapshot.d.ts +1 -3
  159. package/dist/sync/credentialLifecycle.d.ts +175 -0
  160. package/dist/sync/credentialLifecycle.js +329 -0
  161. package/dist/sync/deltaPipeline.d.ts +111 -0
  162. package/dist/sync/deltaPipeline.js +262 -0
  163. package/dist/sync/groupChange.d.ts +111 -0
  164. package/dist/sync/groupChange.js +246 -0
  165. package/dist/sync/heartbeat.d.ts +62 -0
  166. package/dist/sync/heartbeat.js +91 -0
  167. package/dist/sync/participants.d.ts +8 -8
  168. package/dist/sync/syncCursor.d.ts +44 -0
  169. package/dist/sync/syncCursor.js +59 -0
  170. package/dist/sync/syncPlan.d.ts +62 -0
  171. package/dist/sync/syncPlan.js +51 -0
  172. package/dist/sync/wsFrameHandlers.d.ts +117 -0
  173. package/dist/sync/wsFrameHandlers.js +378 -0
  174. package/dist/testing/fixtures/bootstrap.d.ts +9 -11
  175. package/dist/testing/fixtures/deltas.d.ts +1 -1
  176. package/dist/testing/fixtures/models.js +1 -0
  177. package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
  178. package/dist/testing/helpers/sync-engine-harness.js +2 -1
  179. package/dist/testing/index.d.ts +1 -1
  180. package/dist/testing/index.js +1 -1
  181. package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
  182. package/dist/testing/mocks/MockSyncContext.js +1 -32
  183. package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
  184. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  185. package/dist/transactions/TransactionQueue.d.ts +20 -56
  186. package/dist/transactions/TransactionQueue.js +101 -487
  187. package/dist/transactions/TransactionStore.d.ts +18 -0
  188. package/dist/transactions/TransactionStore.js +51 -0
  189. package/dist/transactions/coalesceRules.d.ts +34 -0
  190. package/dist/transactions/coalesceRules.js +117 -0
  191. package/dist/transactions/commitPayload.d.ts +134 -0
  192. package/dist/transactions/commitPayload.js +152 -0
  193. package/dist/transactions/deltaConfirmation.d.ts +60 -0
  194. package/dist/transactions/deltaConfirmation.js +223 -0
  195. package/dist/transactions/optimistic.d.ts +24 -0
  196. package/dist/transactions/optimistic.js +45 -0
  197. package/dist/transactions/persistedReplay.d.ts +93 -0
  198. package/dist/transactions/persistedReplay.js +105 -0
  199. package/dist/types/index.d.ts +3 -3
  200. package/dist/types/index.js +0 -5
  201. package/dist/types/modelData.d.ts +12 -0
  202. package/dist/types/modelData.js +11 -0
  203. package/dist/types/participant.d.ts +21 -0
  204. package/dist/types/participant.js +12 -0
  205. package/dist/types/streams.d.ts +8 -14
  206. package/dist/utils/mobx-setup.d.ts +1 -1
  207. package/dist/utils/mobx-setup.js +2 -2
  208. package/dist/wire/errorEnvelope.d.ts +11 -2
  209. package/dist/wire/errorEnvelope.js +14 -2
  210. package/dist/wire/frames.d.ts +90 -0
  211. package/dist/wire/frames.js +55 -1
  212. package/dist/wire/index.d.ts +4 -1
  213. package/dist/wire/index.js +20 -1
  214. package/dist/wire/protocol.d.ts +45 -0
  215. package/dist/wire/protocol.js +45 -0
  216. package/dist/wire/protocolVersion.d.ts +56 -0
  217. package/dist/wire/protocolVersion.js +63 -0
  218. package/docs/api-keys.md +4 -3
  219. package/docs/examples/existing-python-backend.md +3 -3
  220. package/docs/identity.md +4 -4
  221. package/docs/integration-guide.md +1 -1
  222. package/docs/react.md +1 -1
  223. package/docs/sessions.md +5 -7
  224. package/package.json +15 -13
  225. package/dist/client/index.d.ts +0 -36
  226. package/dist/client/index.js +0 -33
  227. package/dist/config/index.d.ts +0 -10
  228. package/dist/config/index.js +0 -12
  229. package/dist/interfaces/headless.d.ts +0 -95
  230. package/dist/interfaces/headless.js +0 -41
  231. package/dist/query/index.d.ts +0 -6
  232. package/dist/query/index.js +0 -5
  233. package/dist/realtime/index.d.ts +0 -10
  234. package/dist/realtime/index.js +0 -9
  235. package/dist/schema/plane.d.ts +0 -23
  236. package/dist/schema/plane.js +0 -19
  237. package/dist/server/next.d.ts +0 -51
  238. package/dist/server/next.js +0 -47
  239. package/dist/sync/OfflineFlush.d.ts +0 -9
  240. package/dist/sync/OfflineFlush.js +0 -22
  241. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  242. package/dist/sync/OfflineTransactionStore.js +0 -263
  243. package/dist/transactions/index.d.ts +0 -16
  244. package/dist/transactions/index.js +0 -7
@@ -11,19 +11,25 @@
11
11
  * implementation stays in the app's SyncedStore.ts until we incrementally
12
12
  * pull generic methods into this base class.
13
13
  */
14
- import { makeObservable, observable, computed, runInAction } from 'mobx';
14
+ import { makeObservable, observable, action, computed, runInAction } from 'mobx';
15
15
  import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
16
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
17
  import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
18
18
  import { resolveParticipantSyncGroups, } from './sync/participants.js';
19
+ import { ModelRegistry } from './ModelRegistry.js';
19
20
  import { PropertyType } from './types/index.js';
20
21
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
21
22
  import { QueryProcessor } from './core/QueryProcessor.js';
22
23
  import { Model, rowAsModel } from './Model.js';
23
24
  import { getContext } from './context.js';
24
- import { SyncSessionError } from './errors.js';
25
+ import { SyncSessionError, isAccessCredentialExpiryCloseReason } from './errors.js';
25
26
  import { ModelScope } from './ObjectPool.js';
26
27
  import { LazyReferenceCollection } from './LazyReferenceCollection.js';
28
+ import { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
29
+ import { CredentialLifecycle } from './sync/credentialLifecycle.js';
30
+ import * as groupChange from './sync/groupChange.js';
31
+ import * as bootstrapApply from './sync/bootstrapApply.js';
32
+ import * as deltaPipeline from './sync/deltaPipeline.js';
27
33
  /** Bootstrap timeout configuration */
28
34
  export const BOOTSTRAP_CONFIG = {
29
35
  OVERALL_TIMEOUT_MS: 15_000,
@@ -32,6 +38,9 @@ export const BOOTSTRAP_CONFIG = {
32
38
  };
33
39
  // Re-export for clean API
34
40
  export { ModelScope };
41
+ // deriveSyncPlanFromSchema (pure schema → sync-plan derivation) moved to
42
+ // sync/syncPlan.ts; re-exported so importers are unchanged.
43
+ export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
35
44
  // ── Base class ──────────────────────────────────────────────────────────────
36
45
  /**
37
46
  * BaseSyncedStore — abstract base for app-specific sync stores.
@@ -61,57 +70,6 @@ export { ModelScope };
61
70
  * }
62
71
  * }
63
72
  */
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
73
  export class BaseSyncedStore {
116
74
  // ── Observable sync status for UI ──
117
75
  syncStatus = {
@@ -254,11 +212,10 @@ export class BaseSyncedStore {
254
212
  // ── Internal helpers ──
255
213
  queryProcessor;
256
214
  /**
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`.
215
+ * Runtime behavior flags only — the schema/config arrays
216
+ * (`enrichmentPlan`, `foreignKeyIndexes`) are consumed at construction
217
+ * time and stored on the instance as `enrichmentPlan` and
218
+ * pool-registered indexes. They don't need to persist on `this.config`.
262
219
  */
263
220
  config;
264
221
  disposers = [];
@@ -276,7 +233,6 @@ export class BaseSyncedStore {
276
233
  // right place to surface it.
277
234
  userContext = null;
278
235
  // ── Smart sync ──
279
- versionVector;
280
236
  /**
281
237
  * Declarative enrichment plan: "for model X, when a delta arrives,
282
238
  * read data[foreignKey] and attach the matching parent from the pool
@@ -318,11 +274,11 @@ export class BaseSyncedStore {
318
274
  Model.setStore(this);
319
275
  // ── Schema-derived sync plan (Phase 2) ─────────────────────────────
320
276
  //
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.
277
+ // When a schema is provided, derive FK indexes and the enrichment
278
+ // plan from declarative annotations on the schema's `belongsTo`
279
+ // relations. Explicit config fields layer on top, so subclasses
280
+ // (like Ablo's SyncedStore) can pass hardcoded arrays without
281
+ // needing a full schema.generated.ts.
326
282
  //
327
283
  // Order matters: schema-derived first, config second, so that in a
328
284
  // future where Ablo passes both (schema AND explicit config), the
@@ -330,7 +286,7 @@ export class BaseSyncedStore {
330
286
  // accidentally shadowed by schema derivation.
331
287
  const derived = dependencies.schema
332
288
  ? deriveSyncPlanFromSchema(dependencies.schema)
333
- : { versionVectorKeys: [], enrichmentPlan: [], foreignKeyIndexes: [] };
289
+ : { enrichmentPlan: [], foreignKeyIndexes: [] };
334
290
  const mergedForeignKeyIndexes = [
335
291
  ...derived.foreignKeyIndexes,
336
292
  ...(config.foreignKeyIndexes ?? []),
@@ -361,14 +317,6 @@ export class BaseSyncedStore {
361
317
  batchingDelay: 100,
362
318
  maxBatchSize: 50,
363
319
  };
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
320
  // Create internal helpers
373
321
  this.queryProcessor = new QueryProcessor({
374
322
  enableCache: this.config.enableCache,
@@ -465,23 +413,21 @@ export class BaseSyncedStore {
465
413
  */
466
414
  connectionManager = null;
467
415
  /**
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).
416
+ * Access-credential re-mint + proactive pre-roll extracted to
417
+ * sync/credentialLifecycle.ts. Owns the refresher hook, the single-flight
418
+ * guard, and the browser-only refresh timer / wake listener; talks back
419
+ * through three lazily-resolved callbacks (the ConnectionManager doesn't
420
+ * exist until `setupWebSocketSync`). The `setCredentialRefresher` /
421
+ * `performCredentialRefresh` / `startCredentialLifecycle` methods below
422
+ * are thin delegates so the store's public surface is unchanged.
476
423
  */
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;
424
+ credentialLifecycle = new CredentialLifecycle({
425
+ setAuthToken: (token) => { this.auth?.setAuthToken(token); },
426
+ nudgeReconnect: () => { this.nudgeReconnect(); },
427
+ reportSessionExpired: () => {
428
+ this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
429
+ },
430
+ });
485
431
  /**
486
432
  * Listeners registered via `subscribeSessionError()`. Fired when the
487
433
  * WebSocket closes with a session-invalid code (1008/4001/4003) or a
@@ -525,7 +471,7 @@ export class BaseSyncedStore {
525
471
  }
526
472
  /**
527
473
  * Observe the LOCAL mutation stream for undo recording (see
528
- * {@link import('./react/context.js').LocalMutation}). Taps the
474
+ * {@link import('./core/storeContract.js').LocalMutation}). Taps the
529
475
  * TransactionQueue's `transaction:created` event — fired once per local
530
476
  * create/update/delete/archive with `previousData` already captured.
531
477
  * Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
@@ -538,7 +484,7 @@ export class BaseSyncedStore {
538
484
  // the queue's emitter does) — so undo recorded nothing. See
539
485
  // `SyncClient.onLocalTransaction` for the full rationale.
540
486
  return this.syncClient.onLocalTransaction((tx) => {
541
- if (!tx || !tx.type || !tx.modelName || !tx.modelId)
487
+ if (!tx?.type || !tx.modelName || !tx.modelId)
542
488
  return;
543
489
  handler({
544
490
  type: tx.type,
@@ -695,75 +641,29 @@ export class BaseSyncedStore {
695
641
  /**
696
642
  * Register the access-credential re-mint hook. Called by the React provider
697
643
  * with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
698
- * See {@link credentialRefresher}.
644
+ * See {@link CredentialLifecycle.setRefresher}.
699
645
  */
700
646
  setCredentialRefresher(refresher) {
701
- this.credentialRefresher = refresher;
647
+ this.credentialLifecycle.setRefresher(refresher);
702
648
  }
703
649
  /**
704
650
  * Re-mint the short-lived access credential and push it into the credential
705
651
  * 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.
652
+ * its FSM. Single-flight; no refresher wired ⇒ `'refreshed'` (a no-op
653
+ * re-probe). Full contract on {@link CredentialLifecycle.refresh}.
720
654
  */
721
655
  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
- }
656
+ return this.credentialLifecycle.refresh();
657
+ }
658
+ /**
659
+ * THE auth-recovery backbone for HTTP transports (lazy query lane etc.):
660
+ * classify-driven single-flight re-mint with the same FSM outcome routing
661
+ * the WS probe and proactive pre-roll use. `'retry'` ⇒ a fresh credential
662
+ * is in the credential source, replay the request ONCE. Full contract on
663
+ * {@link CredentialLifecycle.recoverFromAuthRejection}.
664
+ */
665
+ async recoverFromAuthRejection(recovery) {
666
+ return this.credentialLifecycle.recoverFromAuthRejection(recovery);
767
667
  }
768
668
  /**
769
669
  * Nudge the connection FSM to re-probe with the current credential. Idempotent
@@ -776,227 +676,70 @@ export class BaseSyncedStore {
776
676
  this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
777
677
  }
778
678
  /**
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}.
679
+ * Install the access-credential lifecycle the CLIENT owns: register
680
+ * `getToken` as the reactive re-mint hook AND arm the browser-only
681
+ * proactive pre-roll (refresh timer + OS-wake re-mint). Idempotent
682
+ * (a second call replaces the first); torn down on {@link disconnect}.
683
+ * Full rationale on {@link CredentialLifecycle.start}.
793
684
  */
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
- };
685
+ startCredentialLifecycle(getToken, opts) {
686
+ this.credentialLifecycle.start(getToken, opts);
861
687
  }
862
688
  /** Tear down the proactive credential lifecycle (idempotent). */
863
689
  stopCredentialLifecycle() {
864
- this.credentialLifecycleTeardown?.();
865
- this.credentialLifecycleTeardown = null;
690
+ this.credentialLifecycle.stop();
866
691
  }
867
692
  // ── Sync Group Management ────────────────────────────────────────────────
693
+ //
694
+ // Implementation extracted to sync/groupChange.ts. The methods below stay
695
+ // as thin protected delegates with unchanged signatures — subclass
696
+ // override points remain overridable, and the leaf routes cross-handler
697
+ // calls back through `groupChangeContext()` so dynamic dispatch holds.
698
+ /** Narrow context the group-change leaf talks back through. */
699
+ groupChangeContext() {
700
+ return {
701
+ database: this.database,
702
+ objectPool: this.objectPool,
703
+ getSubscribedSyncGroups: () => this.syncWebSocket?.getSyncGroups() ?? [],
704
+ getCurrentSyncGroups: () => this.userContext ? this.resolveSyncGroups(this.userContext) : null,
705
+ getBootstrapMode: () => this.userContext?.bootstrapMode,
706
+ disconnectWebSocket: () => { this.syncWebSocket?.disconnect(); },
707
+ emitConnectionEvent: (event) => { this.onConnectionEvent?.(event); },
708
+ handleGroupAdded: (payload, syncId) => this.handleGroupAdded(payload, syncId),
709
+ computeUpdatedSyncGroups: (payload) => this.computeUpdatedSyncGroups(payload),
710
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
711
+ };
712
+ }
868
713
  /**
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.
714
+ * Handle an actionType 'G' delta — incremental `{ group, userId }` or
715
+ * legacy `{ addedGroups, removedGroups }` payloads. Full pathway doc on
716
+ * {@link groupChange.handleSyncGroupChange}.
883
717
  */
884
718
  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();
719
+ return groupChange.handleSyncGroupChange(this.groupChangeContext(), delta);
919
720
  }
920
721
  /**
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.
722
+ * Handle an incremental GroupAdded delta — metadata only, no re-bootstrap
723
+ * (covering deltas bring the entities). See {@link groupChange.handleGroupAdded}.
926
724
  */
927
725
  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.
726
+ return groupChange.handleGroupAdded(this.groupChangeContext(), payload, syncId);
936
727
  }
937
728
  /**
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.
729
+ * Handle an actionType 'S' (GroupRemoved) delta — SECURITY clear of local
730
+ * state + full re-bootstrap. See {@link groupChange.handleGroupRemoved}.
948
731
  */
949
732
  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();
733
+ return groupChange.handleGroupRemoved(this.groupChangeContext(), delta);
974
734
  }
975
735
  /** Compute new sync groups after applying additions and removals */
976
736
  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
- */
737
+ return groupChange.computeUpdatedSyncGroups(this.groupChangeContext(), payload);
738
+ }
739
+ /** Force a full re-bootstrap via connection lifecycle event (no-op for
740
+ * `bootstrapMode: 'none'` participants see {@link groupChange.forceFullRebootstrap}). */
992
741
  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');
742
+ groupChange.forceFullRebootstrap(this.groupChangeContext());
1000
743
  }
1001
744
  /**
1002
745
  * Single source of truth for the sync-group list this session is
@@ -1007,71 +750,37 @@ export class BaseSyncedStore {
1007
750
  * check can never disagree.
1008
751
  */
1009
752
  resolveSyncGroups(context) {
1010
- if (context.syncGroups && context.syncGroups.length > 0) {
1011
- return context.syncGroups;
1012
- }
1013
- return [];
753
+ return groupChange.resolveSyncGroups(context);
1014
754
  }
1015
755
  /** Check if sync groups shrank since last session — force full bootstrap if so */
1016
756
  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
- }
757
+ return groupChange.checkSyncGroupShrinkage(this.groupChangeContext());
758
+ }
759
+ // ── Bootstrap apply ──────────────────────────────────────────────────────
760
+ //
761
+ // Implementation extracted to sync/bootstrapApply.ts. Thin protected
762
+ // delegates below keep the signatures (and subclass overridability)
763
+ // unchanged; the leaf talks back through `poolContext()` — enrichment
764
+ // stays pre-bound to `this.enrichRelations` so that override point holds.
765
+ /** Narrow context the bootstrap-apply leaf talks back through. */
766
+ poolContext() {
767
+ const store = this;
768
+ return {
769
+ applyDeltaBatchToPool: (results) => {
770
+ this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
771
+ },
772
+ applyBootstrapDataToPool: (bootstrapData, protectedIds) => this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds),
773
+ getPoolSize: () => this.objectPool.size,
774
+ getAllPoolIds: () => this.objectPool.getAllIds(),
775
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
776
+ set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
777
+ applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
778
+ };
1047
779
  }
1048
780
  /** Apply bootstrap data to the ObjectPool with ghost removal */
1049
781
  /** Apply bootstrap data to the ObjectPool. Delegates pool writes to SyncClient. */
1050
782
  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 };
783
+ return bootstrapApply.applyBootstrapToPool(this.poolContext(), bootstrapResult, protectedIds);
1075
784
  }
1076
785
  // ── Initialize + Lifecycle ───────────────────────────────────────────────
1077
786
  /**
@@ -1108,12 +817,6 @@ export class BaseSyncedStore {
1108
817
  // Get sync baseline for WebSocket
1109
818
  const lastSyncId = (yield this.database.getLastSyncId());
1110
819
  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
820
  // If local data available, show UI immediately
1118
821
  if (hasLocalData) {
1119
822
  this.dataReady = true;
@@ -1154,8 +857,15 @@ export class BaseSyncedStore {
1154
857
  }
1155
858
  else if (requirements.type !== 'local') {
1156
859
  if (hasLocalData) {
1157
- // Background bootstrap — don't block UI
1158
- this.performBackgroundBootstrap(requirements, context, signal);
860
+ // Background bootstrap — don't block UI. The method captures its own
861
+ // operational failures; this backstop covers escapes from the
862
+ // delta-queue replay in withDeltaQueuing's finally (and the error
863
+ // handler itself), which would otherwise vanish unhandled.
864
+ void this.performBackgroundBootstrap(requirements, context, signal).catch((error) => {
865
+ getContext().observability.captureBootstrapFailure(error, {
866
+ type: 'background-orchestration',
867
+ });
868
+ });
1159
869
  }
1160
870
  else {
1161
871
  // First load — must wait for server data
@@ -1253,27 +963,11 @@ export class BaseSyncedStore {
1253
963
  }
1254
964
  /** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
1255
965
  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;
966
+ return bootstrapApply.collectDeltaProtectedIds(this.poolContext(), preBootstrapIds);
1266
967
  }
1267
- /** Replay deltas queued during bootstrap */
968
+ /** Replay deltas queued during bootstrap (atomically, via `applyDeltaFrame`). */
1268
969
  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);
970
+ bootstrapApply.replayQueuedDeltas(this.poolContext());
1277
971
  }
1278
972
  /**
1279
973
  * Factory for the internal `ConnectionManager`. Override to return
@@ -1336,6 +1030,10 @@ export class BaseSyncedStore {
1336
1030
  }
1337
1031
  this.syncClient.disconnect();
1338
1032
  this.queryProcessor.clearCache();
1033
+ // Stop the pool's GC interval — the one timer the pool arms itself.
1034
+ // Without this a discarded store retains its whole pool via the interval
1035
+ // closure (and a Node process without `unref` support can't exit).
1036
+ this.objectPool.stopGC();
1339
1037
  this.updateSyncStatus({ state: 'offline' });
1340
1038
  }
1341
1039
  /**
@@ -1434,7 +1132,6 @@ export class BaseSyncedStore {
1434
1132
  organizationId: context.organizationId,
1435
1133
  syncGroups: [...this.resolveSyncGroups(context)],
1436
1134
  lastSyncId,
1437
- versions: this.versionVector,
1438
1135
  kind: context.kind,
1439
1136
  capabilityToken: context.capabilityToken,
1440
1137
  getAuthToken: this.auth?.getAuthToken,
@@ -1506,7 +1203,9 @@ export class BaseSyncedStore {
1506
1203
  this.updateSyncStatus({ state: 'error', error });
1507
1204
  }
1508
1205
  });
1509
- const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
1206
+ // Terminal session loss (revocation / the login itself is gone): notify,
1207
+ // route the FSM to its terminal state, and clear local data.
1208
+ const handleTerminalSessionError = (error) => {
1510
1209
  getContext().observability.captureWebSocketError({ context: 'session-error', error: error.message });
1511
1210
  this.onConnectionEvent?.('WS_SESSION_ERROR');
1512
1211
  for (const listener of this.sessionErrorListeners) {
@@ -1524,6 +1223,54 @@ export class BaseSyncedStore {
1524
1223
  getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
1525
1224
  });
1526
1225
  this.objectPool.clear();
1226
+ };
1227
+ const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
1228
+ // WS analog of HTTP's `apikey_expired` (see SyncSessionError.
1229
+ // isSessionErrorResponse): the hub's keepalive reaper closes sockets
1230
+ // whose SHORT-LIVED access credential (`ek_`/`rk_`) passed its expiry
1231
+ // with `4001 credential_expired`. That is re-mintable from the
1232
+ // still-valid login — recover silently (un-latch, single-flight
1233
+ // re-mint, reconnect) instead of signing out and clearing local data.
1234
+ // Only a mint that answers `null` (the login itself is gone) falls
1235
+ // through to the terminal path. Without this branch, every credential
1236
+ // TTL elapse wedged the socket behind the write-once session latch.
1237
+ if (SyncSessionError.isSessionError(error) && isAccessCredentialExpiryCloseReason(error.message)) {
1238
+ getContext().observability.breadcrumb('WebSocket closed for expired access credential — re-minting', 'sync.websocket', 'warning');
1239
+ // Un-latch BEFORE the async mint so the FSM's own recovery
1240
+ // (probe → refreshing_credential → reconnect) is never blocked on
1241
+ // our .then() ordering.
1242
+ this.syncWebSocket?.clearSessionError();
1243
+ void this.performCredentialRefresh().then((outcome) => {
1244
+ if (outcome === 'refreshed') {
1245
+ if (this.connectionManager) {
1246
+ // Kick a parked FSM; a concurrent probe joins the same
1247
+ // single-flight mint, so this never double-mints.
1248
+ this.nudgeReconnect();
1249
+ }
1250
+ else {
1251
+ // Agent/system clients have no connection FSM
1252
+ // (createConnectionManager returns null for kind 'agent') —
1253
+ // reconnect the socket directly; connect() reads the
1254
+ // freshly-minted credential from the credential source.
1255
+ this.syncWebSocket?.resetReconnectAttempts();
1256
+ this.syncWebSocket?.connect();
1257
+ }
1258
+ return;
1259
+ }
1260
+ if (outcome === 'session_error') {
1261
+ // The mint endpoint rejected: the long-lived login is gone.
1262
+ // Re-latch so writes reject with the permanent session type
1263
+ // (see SyncWebSocket.notConnectedError) instead of parking.
1264
+ this.syncWebSocket?.setSessionErrorDetected();
1265
+ handleTerminalSessionError(error);
1266
+ }
1267
+ // 'network_error' → transient mint failure. The WS_DISCONNECTED
1268
+ // that follows this event already put the FSM on its probe/backoff
1269
+ // loop, which retries through the same single-flight refresh.
1270
+ });
1271
+ return;
1272
+ }
1273
+ handleTerminalSessionError(error);
1527
1274
  });
1528
1275
  // Handshake failed: WS close before open. The HTTP status is hidden
1529
1276
  // behind close code 1006, so we can't tell whether the server rejected
@@ -1648,68 +1395,65 @@ export class BaseSyncedStore {
1648
1395
  this.syncWebSocket.connect();
1649
1396
  }
1650
1397
  // ── 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,
1398
+ //
1399
+ // Implementation extracted to sync/deltaPipeline.ts (dedup, enqueue
1400
+ // bookkeeping, debounce, flush). The methods below stay as thin protected
1401
+ // delegates with unchanged signatures, and the leaf routes every call to
1402
+ // a protected override point back through `deltaPipelineContext` so
1403
+ // subclass dynamic dispatch is preserved. `applyDeltaFrame` — the
1404
+ // authoritative-apply correctness seam — deliberately stays here.
1405
+ /** Memoized pipeline context`enqueueDelta` runs once per delta, so the
1406
+ * accessor object is built once and reused (the get/set accessors always
1407
+ * read the live host fields). */
1408
+ _deltaPipelineContext = null;
1409
+ get deltaPipelineContext() {
1410
+ if (this._deltaPipelineContext)
1411
+ return this._deltaPipelineContext;
1412
+ const store = this;
1413
+ this._deltaPipelineContext = {
1414
+ // Shared pipeline state, backed by the host fields.
1415
+ get pendingDeltas() { return store.pendingDeltas; },
1416
+ set pendingDeltas(deltas) { store.pendingDeltas = deltas; },
1417
+ get batchTimer() { return store.batchTimer; },
1418
+ set batchTimer(timer) { store.batchTimer = timer; },
1419
+ get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
1420
+ get smartSyncOptions() { return store.smartSyncOptions; },
1421
+ get highestProcessedSyncId() { return store.highestProcessedSyncId; },
1422
+ get lastAckedId() { return store.lastAckedId; },
1423
+ // SyncClient position/transaction bookkeeping.
1424
+ onDeltaReceived: (syncId) => { this.syncClient.onDeltaReceived(syncId); },
1425
+ advanceApplied: (syncId) => { this.syncClient.position.advanceApplied(syncId); },
1426
+ advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
1427
+ // Persistence + pool writes.
1428
+ processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
1429
+ applyDeltaBatchToPool: (results) => {
1430
+ this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
1431
+ },
1432
+ acknowledge: (syncId) => { this.syncWebSocket?.acknowledge?.(syncId); },
1433
+ get objectPool() { return store.objectPool; },
1434
+ // Dynamic-dispatch hooks — protected override points on this class.
1435
+ getStateFields: (modelName) => this.getStateFields(modelName),
1436
+ isCustomEntity: (modelName) => this.isCustomEntity(modelName),
1437
+ createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
1438
+ deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
1439
+ flushPendingDeltas: () => this.flushPendingDeltas(),
1440
+ handleFlushError: (error) => { this.handleFlushError(error); },
1441
+ handleSyncGroupChange: (delta) => this.handleSyncGroupChange(delta),
1442
+ handleGroupRemoved: (delta) => this.handleGroupRemoved(delta),
1443
+ forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
1444
+ cascadeCancelTransactionsForDeletedParent: (parentModelName, parentId) => {
1445
+ this.cascadeCancelTransactionsForDeletedParent(parentModelName, parentId);
1446
+ },
1663
1447
  };
1664
- for (const field of fieldsToCheck) {
1665
- if (field in data)
1666
- signature[field] = data[field];
1667
- }
1668
- return signature;
1448
+ return this._deltaPipelineContext;
1669
1449
  }
1670
1450
  /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
1671
1451
  getStateFields(_modelName) {
1672
1452
  return ['status', 'state', 'isActive'];
1673
1453
  }
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
1454
  /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
1683
1455
  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);
1456
+ return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
1713
1457
  }
1714
1458
  /** Process incoming delta with smart batching */
1715
1459
  processDeltaWithBatching(delta) {
@@ -1781,68 +1525,11 @@ export class BaseSyncedStore {
1781
1525
  * whether to debounce (live) or flush atomically (catch-up frame).
1782
1526
  */
1783
1527
  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;
1528
+ return deltaPipeline.enqueueDelta(this.deltaPipelineContext, delta, options);
1833
1529
  }
1834
1530
  /** Debounce a flush for live single-delta traffic. */
1835
1531
  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
- }
1532
+ deltaPipeline.scheduleDeltaFlush(this.deltaPipelineContext);
1846
1533
  }
1847
1534
  /**
1848
1535
  * Cancel pending transactions for child entities when a parent is deleted.
@@ -1881,79 +1568,7 @@ export class BaseSyncedStore {
1881
1568
  /** Flush pending deltas with deduplication and batched ObjectPool mutations */
1882
1569
  /** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
1883
1570
  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
- }
1571
+ return deltaPipeline.flushPendingDeltas(this.deltaPipelineContext);
1957
1572
  }
1958
1573
  // ── Core Mutations (thin delegation to SyncClient) ────────────────────────
1959
1574
  //