@abloatai/ablo 0.10.1 → 0.11.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 (93) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -1
  3. package/dist/BaseSyncedStore.d.ts +75 -0
  4. package/dist/BaseSyncedStore.js +193 -8
  5. package/dist/Database.d.ts +10 -2
  6. package/dist/Database.js +15 -1
  7. package/dist/SyncClient.d.ts +12 -1
  8. package/dist/SyncClient.js +110 -26
  9. package/dist/agent/Agent.d.ts +9 -9
  10. package/dist/agent/Agent.js +16 -16
  11. package/dist/agent/index.d.ts +1 -1
  12. package/dist/agent/index.js +2 -2
  13. package/dist/agent/types.d.ts +1 -1
  14. package/dist/agent/types.js +1 -1
  15. package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
  16. package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
  17. package/dist/ai-sdk/coordination-context.d.ts +9 -9
  18. package/dist/ai-sdk/coordination-context.js +8 -8
  19. package/dist/ai-sdk/index.d.ts +1 -1
  20. package/dist/ai-sdk/index.js +1 -1
  21. package/dist/ai-sdk/wrap.d.ts +4 -4
  22. package/dist/ai-sdk/wrap.js +4 -4
  23. package/dist/api/index.d.ts +2 -2
  24. package/dist/cli.cjs +254 -48
  25. package/dist/client/Ablo.d.ts +30 -63
  26. package/dist/client/Ablo.js +108 -102
  27. package/dist/client/ApiClient.d.ts +6 -5
  28. package/dist/client/ApiClient.js +83 -62
  29. package/dist/client/createModelProxy.d.ts +16 -54
  30. package/dist/client/createModelProxy.js +44 -16
  31. package/dist/client/httpClient.d.ts +2 -0
  32. package/dist/client/httpClient.js +1 -1
  33. package/dist/client/index.d.ts +3 -3
  34. package/dist/client/writeOptionsSchema.d.ts +4 -4
  35. package/dist/client/writeOptionsSchema.js +4 -4
  36. package/dist/coordination/schema.d.ts +249 -38
  37. package/dist/coordination/schema.js +172 -39
  38. package/dist/core/index.d.ts +2 -2
  39. package/dist/core/index.js +4 -4
  40. package/dist/errorCodes.d.ts +9 -9
  41. package/dist/errorCodes.js +15 -15
  42. package/dist/errors.d.ts +51 -2
  43. package/dist/errors.js +94 -5
  44. package/dist/interfaces/index.d.ts +8 -4
  45. package/dist/policy/index.d.ts +1 -1
  46. package/dist/policy/types.d.ts +13 -13
  47. package/dist/policy/types.js +8 -8
  48. package/dist/react/AbloProvider.d.ts +51 -4
  49. package/dist/react/AbloProvider.js +95 -11
  50. package/dist/react/context.d.ts +26 -9
  51. package/dist/react/context.js +2 -2
  52. package/dist/react/index.d.ts +4 -4
  53. package/dist/react/index.js +4 -4
  54. package/dist/react/useAblo.js +5 -5
  55. package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
  56. package/dist/react/useClaim.js +42 -0
  57. package/dist/schema/index.js +1 -1
  58. package/dist/schema/sugar.d.ts +3 -3
  59. package/dist/schema/sugar.js +3 -3
  60. package/dist/schema/sync-delta-wire.d.ts +8 -8
  61. package/dist/server/commit.d.ts +2 -2
  62. package/dist/sync/AreaOfInterestManager.d.ts +162 -0
  63. package/dist/sync/AreaOfInterestManager.js +233 -0
  64. package/dist/sync/BootstrapHelper.d.ts +9 -1
  65. package/dist/sync/BootstrapHelper.js +15 -5
  66. package/dist/sync/NetworkProbe.d.ts +1 -1
  67. package/dist/sync/NetworkProbe.js +1 -1
  68. package/dist/sync/SyncWebSocket.d.ts +59 -25
  69. package/dist/sync/SyncWebSocket.js +123 -26
  70. package/dist/sync/awaitClaimGrant.d.ts +40 -0
  71. package/dist/sync/awaitClaimGrant.js +86 -0
  72. package/dist/sync/createClaimStream.d.ts +34 -0
  73. package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
  74. package/dist/sync/createPresenceStream.js +3 -2
  75. package/dist/sync/participants.d.ts +10 -10
  76. package/dist/sync/participants.js +17 -10
  77. package/dist/sync/schemas.d.ts +8 -8
  78. package/dist/transactions/TransactionQueue.d.ts +12 -0
  79. package/dist/transactions/TransactionQueue.js +126 -8
  80. package/dist/types/global.d.ts +10 -10
  81. package/dist/types/global.js +3 -3
  82. package/dist/types/index.d.ts +9 -7
  83. package/dist/types/index.js +2 -2
  84. package/dist/types/streams.d.ts +114 -98
  85. package/dist/types/streams.js +1 -1
  86. package/dist/utils/asyncIterator.d.ts +1 -1
  87. package/dist/utils/asyncIterator.js +1 -1
  88. package/dist/wire/frames.d.ts +2 -2
  89. package/package.json +3 -2
  90. package/dist/react/useIntent.js +0 -42
  91. package/dist/sync/awaitIntentGrant.d.ts +0 -40
  92. package/dist/sync/awaitIntentGrant.js +0 -62
  93. package/dist/sync/createIntentStream.d.ts +0 -34
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Canonical `claim` vocabulary, sync-group area-of-interest, and richer claim-rejection errors.
8
+ - **`intent` → `claim` everywhere.** The coordination primitive is now a `Claim` across the public surface: `useClaim` replaces `useIntent`, the `Ablo.Claim.*` namespace replaces `Ablo.Intent.*`, and module augmentation registers `Claims` instead of `Intents` on the `Register` interface. The underlying wire frames moved from `intent_*` to `claim_*` — clients and servers must run a `claim_*`-aware build together.
9
+ - **Sync-group area of interest.** A client's read interest is no longer frozen at connect: the new `update_subscription` frame drives live re-indexing, and `enterScope` / `leaveScope` / `pinScope` / `unpinScope` let a store narrow or widen what it streams. `AreaOfInterestManager` adds hysteresis (warm-TTL), claim-pinning, reconcile coalescing, and an LRU cap so narrowing the view never shrinks the write allowlist.
10
+ - **Richer claim-rejection errors.** Rejections (over WebSocket and HTTP) now carry `heldByClaim` and `policyReason`, and `AbloClaimedError` exposes a typed `claims` array so callers can see exactly who holds the contested rows.
11
+ - **Coordination vocabulary consolidation.** Participant identity is canonical `user` | `agent` | `system`; the server stamps `participantKind` on every presence emit and clients read it, so non-human peers surface correctly.
12
+
3
13
  ## 0.10.1
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -104,6 +104,7 @@ instead of guessing:
104
104
  ```ts
105
105
  import Ablo from '@abloatai/ablo';
106
106
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
107
+ ```
107
108
 
108
109
  Register the schema once (init scaffolds this `ablo.d.ts`), and every type
109
110
  is one parameter away — no `typeof schema` re-stating, anywhere:
@@ -127,7 +128,7 @@ type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema
127
128
  TanStack-Router pattern: declare the source of truth once, everything
128
129
  infers from it.)
129
130
 
130
-
131
+ ```ts
131
132
  const schema = defineSchema({
132
133
  weatherReports: model({
133
134
  location: z.string(),
@@ -12,6 +12,8 @@
12
12
  * pull generic methods into this base class.
13
13
  */
14
14
  import { ConnectionManager } from './sync/ConnectionManager.js';
15
+ import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
16
+ import { type ParticipantScope } from './sync/participants.js';
15
17
  import type { SyncClient } from './SyncClient.js';
16
18
  import type { Database, BootstrapResult } from './Database.js';
17
19
  import type { ObjectPool } from './ObjectPool.js';
@@ -237,6 +239,20 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
237
239
  */
238
240
  protected readonly schema?: TSchema;
239
241
  protected syncWebSocket: SyncWebSocket<TCollaboration> | null;
242
+ /**
243
+ * Dynamic read interest (area-of-interest) over the connection's sync
244
+ * groups. Lives alongside `syncWebSocket` and is recreated with it; the
245
+ * stable `enterScope`/`leaveScope`/`pinScope`/`unpinScope` methods forward
246
+ * to whichever instance is current, so callers (the React participant
247
+ * hook) never hold a stale reference. Null until `setupWebSocketSync`.
248
+ */
249
+ protected areaOfInterest: AreaOfInterestManager | null;
250
+ /** Sync groups whose current state has been backfilled into the pool
251
+ * (hydrate-on-enter). Cleared when the pool is reset on (re)bootstrap. */
252
+ private readonly hydratedGroups;
253
+ /** In-flight scoped hydrations, keyed by group — single-flights concurrent
254
+ * enters of the same scope so they share one fetch. */
255
+ private readonly hydratingGroups;
240
256
  private _syncServerUrl?;
241
257
  /**
242
258
  * Public accessor for the underlying SyncWebSocket. Used by the
@@ -247,6 +263,32 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
247
263
  * `initialize()`.
248
264
  */
249
265
  getSyncWebSocket(): SyncWebSocket<TCollaboration> | null;
266
+ private scopeToGroups;
267
+ /**
268
+ * Bring a scope into view → subscribe to its groups. With
269
+ * `{ hydrate: true }`, ALSO backfill the groups' current state into the pool
270
+ * after the subscription is active (the game "spawn snapshot + delta stream"
271
+ * pattern): subscribe-first so no live delta is missed in the gap, then
272
+ * snapshot. Hydration is soft — a failed backfill never rejects `enterScope`
273
+ * and the live tail still flows.
274
+ */
275
+ enterScope(scope: ParticipantScope, opts?: {
276
+ hydrate?: boolean;
277
+ }): Promise<void>;
278
+ /**
279
+ * Backfill the current state of `syncGroups` into the pool via a PURE scoped
280
+ * snapshot fetch + the version-guarded, ghost-free scoped apply. Idempotent
281
+ * (skips groups already hydrated) and single-flight (concurrent enters of the
282
+ * same group share one fetch). Soft-fails: on error the groups are NOT marked
283
+ * hydrated, so a later re-enter retries.
284
+ */
285
+ protected hydrateGroups(syncGroups: readonly string[]): Promise<void>;
286
+ /** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
287
+ leaveScope(scope: ParticipantScope): Promise<void>;
288
+ /** Pin a scope (active claim / prominence) → never warms while pinned. */
289
+ pinScope(scope: ParticipantScope): Promise<void>;
290
+ /** Release a pin → the group transitions to warm rather than dropping. */
291
+ unpinScope(scope: ParticipantScope): Promise<void>;
250
292
  protected readonly queryProcessor: QueryProcessor;
251
293
  /**
252
294
  * Runtime behavior flags only — the three schema/config arrays
@@ -630,6 +672,39 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
630
672
  protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
631
673
  /** Process incoming delta with smart batching */
632
674
  protected processDeltaWithBatching(delta: SyncDelta): void;
675
+ /**
676
+ * Apply a complete, server-delivered delta frame atomically.
677
+ *
678
+ * A `delta_batch` WS event (reconnect/catch-up replay) already carries
679
+ * the FULL set of missed deltas. Routing it through the per-delta
680
+ * `processDeltaWithBatching` path re-chunks it via the live-traffic
681
+ * debounce timer + `maxBatchSize` force-flush, so a 300-delta catch-up
682
+ * fans out into ~6 separate `flushPendingDeltas` cycles — each its own
683
+ * IDB write, pool mutation, `models:changed` emit, and React re-render.
684
+ * The decks gallery visibly re-sorts and "pops in" once per chunk.
685
+ *
686
+ * Here we run the per-delta bookkeeping (dedup, ack, version vector,
687
+ * watermark, G/S routing, D cascade) for every delta WITHOUT scheduling
688
+ * a flush, then flush ONCE — collapsing the whole frame into a single
689
+ * IDB write + pool mutation + `models:changed` + re-render. Same code
690
+ * for the post-bootstrap replay of deltas queued during bootstrap.
691
+ *
692
+ * (Named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
693
+ * with `Database.processDeltaBatch` — the lower-level IDB write this
694
+ * eventually drives through `flushPendingDeltas`.)
695
+ */
696
+ protected applyDeltaFrame(deltas: SyncDelta[]): void;
697
+ /**
698
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
699
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
700
+ * subsequent flush must drain), `false` when it was skipped (dedup),
701
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
702
+ * sync-group mutations). Does NOT schedule a flush — callers decide
703
+ * whether to debounce (live) or flush atomically (catch-up frame).
704
+ */
705
+ protected enqueueDelta(delta: SyncDelta): boolean;
706
+ /** Debounce a flush for live single-delta traffic. */
707
+ protected scheduleDeltaFlush(): void;
633
708
  /**
634
709
  * Cancel pending transactions for child entities when a parent is deleted.
635
710
  *
@@ -14,6 +14,8 @@
14
14
  import { makeObservable, observable, computed, runInAction } from 'mobx';
15
15
  import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
16
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
+ import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
18
+ import { resolveParticipantSyncGroups, } from './sync/participants.js';
17
19
  import { PropertyType } from './types/index.js';
18
20
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
19
21
  import { QueryProcessor } from './core/QueryProcessor.js';
@@ -131,6 +133,20 @@ export class BaseSyncedStore {
131
133
  schema;
132
134
  // ── Real-time sync ──
133
135
  syncWebSocket = null;
136
+ /**
137
+ * Dynamic read interest (area-of-interest) over the connection's sync
138
+ * groups. Lives alongside `syncWebSocket` and is recreated with it; the
139
+ * stable `enterScope`/`leaveScope`/`pinScope`/`unpinScope` methods forward
140
+ * to whichever instance is current, so callers (the React participant
141
+ * hook) never hold a stale reference. Null until `setupWebSocketSync`.
142
+ */
143
+ areaOfInterest = null;
144
+ /** Sync groups whose current state has been backfilled into the pool
145
+ * (hydrate-on-enter). Cleared when the pool is reset on (re)bootstrap. */
146
+ hydratedGroups = new Set();
147
+ /** In-flight scoped hydrations, keyed by group — single-flights concurrent
148
+ * enters of the same scope so they share one fetch. */
149
+ hydratingGroups = new Map();
134
150
  _syncServerUrl;
135
151
  /**
136
152
  * Public accessor for the underlying SyncWebSocket. Used by the
@@ -143,6 +159,98 @@ export class BaseSyncedStore {
143
159
  getSyncWebSocket() {
144
160
  return this.syncWebSocket;
145
161
  }
162
+ // ── Area-of-interest (dynamic read subscription) ─────────────────
163
+ //
164
+ // `enterScope`/`leaveScope` move the connection's read interest as the
165
+ // user navigates (open/close a deck, sheet, doc); `pinScope`/`unpinScope`
166
+ // express prominence (an active claim keeps a group subscribed). All four
167
+ // resolve the scope to sync-group strings through the SAME resolver the
168
+ // 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`).
172
+ scopeToGroups(scope) {
173
+ return resolveParticipantSyncGroups(scope, this.schema);
174
+ }
175
+ /**
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.
182
+ */
183
+ enterScope(scope, opts) {
184
+ const mgr = this.areaOfInterest;
185
+ if (!mgr)
186
+ return Promise.resolve();
187
+ const groups = this.scopeToGroups(scope);
188
+ const subscribed = Promise.all(groups.map((g) => mgr.enter(g))).then(() => undefined);
189
+ if (!opts?.hydrate)
190
+ return subscribed;
191
+ return subscribed.then(() => this.hydrateGroups(groups));
192
+ }
193
+ /**
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.
199
+ */
200
+ async hydrateGroups(syncGroups) {
201
+ const need = syncGroups.filter((g) => !this.hydratedGroups.has(g) && !this.hydratingGroups.has(g));
202
+ if (need.length === 0) {
203
+ // Nothing new to fetch, but await any in-flight hydration for the
204
+ // requested groups so callers can sequence on completion.
205
+ await Promise.all(syncGroups
206
+ .map((g) => this.hydratingGroups.get(g))
207
+ .filter((p) => p !== undefined));
208
+ return;
209
+ }
210
+ const work = (async () => {
211
+ try {
212
+ const data = await this.database.fetchScopedBootstrapData(need);
213
+ this.syncClient.applyBootstrapDataToPool(data, undefined, { scoped: true });
214
+ for (const g of need)
215
+ this.hydratedGroups.add(g);
216
+ }
217
+ catch (err) {
218
+ getContext().logger.warn('[BaseSyncedStore] scoped hydrate failed', {
219
+ syncGroups: need,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ // Soft-fail — leave `need` un-hydrated so a re-enter retries.
223
+ }
224
+ finally {
225
+ for (const g of need)
226
+ this.hydratingGroups.delete(g);
227
+ }
228
+ })();
229
+ for (const g of need)
230
+ this.hydratingGroups.set(g, work);
231
+ await work;
232
+ }
233
+ /** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
234
+ leaveScope(scope) {
235
+ const mgr = this.areaOfInterest;
236
+ if (!mgr)
237
+ return Promise.resolve();
238
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.leave(g))).then(() => undefined);
239
+ }
240
+ /** Pin a scope (active claim / prominence) → never warms while pinned. */
241
+ pinScope(scope) {
242
+ const mgr = this.areaOfInterest;
243
+ if (!mgr)
244
+ return Promise.resolve();
245
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.pin(g))).then(() => undefined);
246
+ }
247
+ /** Release a pin → the group transitions to warm rather than dropping. */
248
+ unpinScope(scope) {
249
+ const mgr = this.areaOfInterest;
250
+ if (!mgr)
251
+ return Promise.resolve();
252
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.unpin(g))).then(() => undefined);
253
+ }
146
254
  // ── Internal helpers ──
147
255
  queryProcessor;
148
256
  /**
@@ -519,6 +627,10 @@ export class BaseSyncedStore {
519
627
  runInAction(() => { this.dataReady = false; });
520
628
  this.modelTypesHydrated.clear();
521
629
  this.modelTypeHydrationInFlight.clear();
630
+ // The pool is being wiped + re-bootstrapped, so the scoped-hydrate ledger
631
+ // is stale — clear it so re-entered groups backfill again.
632
+ this.hydratedGroups.clear();
633
+ this.hydratingGroups.clear();
522
634
  getContext().logger.info('[BaseSyncedStore] Bootstrap state reset complete');
523
635
  }
524
636
  catch {
@@ -1127,8 +1239,10 @@ export class BaseSyncedStore {
1127
1239
  this.bootstrapDeltaQueue = null;
1128
1240
  if (!queue || queue.length === 0)
1129
1241
  return;
1130
- for (const delta of queue)
1131
- this.processDeltaWithBatching(delta);
1242
+ // Deltas that landed during bootstrap are a complete frame — apply
1243
+ // them atomically (one flush, one re-render) rather than dribbling
1244
+ // each back through the live debounce path.
1245
+ this.applyDeltaFrame(queue);
1132
1246
  }
1133
1247
  /**
1134
1248
  * Factory for the internal `ConnectionManager`. Override to return
@@ -1300,6 +1414,14 @@ export class BaseSyncedStore {
1300
1414
  batchedDeltas: true,
1301
1415
  },
1302
1416
  });
1417
+ // Area-of-interest manager — owns dynamic read-subscription over this
1418
+ // connection. baseGroups (the org/user scopes) are always subscribed;
1419
+ // enterScope/leaveScope move per-entity interest. Recreated with the
1420
+ // socket; torn down via the disposer pushed below.
1421
+ this.areaOfInterest = new AreaOfInterestManager({
1422
+ transport: this.syncWebSocket,
1423
+ baseGroups: this.resolveSyncGroups(context),
1424
+ });
1303
1425
  // Connection events → forward to connection lifecycle callback
1304
1426
  const onConnected = this.syncWebSocket.subscribe('connected', () => {
1305
1427
  this.syncClient.markConnected();
@@ -1310,6 +1432,12 @@ export class BaseSyncedStore {
1310
1432
  else {
1311
1433
  this.updateSyncStatus({ offlineSince: undefined });
1312
1434
  }
1435
+ // Re-assert read interest on every (re)connect. After a transient
1436
+ // reconnect the socket re-sends its URL groups, but interest may have
1437
+ // changed while offline; after a full reconnect the new socket's URL
1438
+ // carries only base groups. `resync` re-pushes the current desired set
1439
+ // so the server-side index matches what the user is actually viewing.
1440
+ void this.areaOfInterest?.resync();
1313
1441
  });
1314
1442
  const onDisconnected = this.syncWebSocket.subscribe('disconnected', () => {
1315
1443
  this.syncClient.disconnect();
@@ -1325,7 +1453,10 @@ export class BaseSyncedStore {
1325
1453
  this.processDeltaWithBatching(delta);
1326
1454
  });
1327
1455
  const onDeltaBatch = this.syncWebSocket.subscribe('delta_batch', (deltas) => {
1328
- deltas.forEach((delta) => this.processDeltaWithBatching(delta));
1456
+ // A catch-up/reconnect frame is already complete — apply it as ONE
1457
+ // atomic flush so the gallery re-renders once, not once per 50-delta
1458
+ // chunk. See `applyDeltaFrame`.
1459
+ this.applyDeltaFrame(deltas);
1329
1460
  });
1330
1461
  // Bootstrap events
1331
1462
  const onBootstrapRequired = this.syncWebSocket.subscribe('bootstrap_required', (hint) => { this.handleBootstrapRequired(hint); });
@@ -1374,7 +1505,7 @@ export class BaseSyncedStore {
1374
1505
  getContext().logger.warn('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
1375
1506
  this.updateSyncStatus({ state: 'reconnecting' });
1376
1507
  });
1377
- this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed);
1508
+ this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { this.areaOfInterest?.dispose(); this.areaOfInterest = null; });
1378
1509
  // ── Connection FSM ────────────────────────────────────────────
1379
1510
  // Instantiate + start the SDK's ConnectionManager so every
1380
1511
  // consumer gets correct online/offline recovery. Previously this
@@ -1547,9 +1678,59 @@ export class BaseSyncedStore {
1547
1678
  }
1548
1679
  /** Process incoming delta with smart batching */
1549
1680
  processDeltaWithBatching(delta) {
1681
+ if (!this.enqueueDelta(delta))
1682
+ return;
1683
+ this.scheduleDeltaFlush();
1684
+ }
1685
+ /**
1686
+ * Apply a complete, server-delivered delta frame atomically.
1687
+ *
1688
+ * A `delta_batch` WS event (reconnect/catch-up replay) already carries
1689
+ * the FULL set of missed deltas. Routing it through the per-delta
1690
+ * `processDeltaWithBatching` path re-chunks it via the live-traffic
1691
+ * debounce timer + `maxBatchSize` force-flush, so a 300-delta catch-up
1692
+ * fans out into ~6 separate `flushPendingDeltas` cycles — each its own
1693
+ * IDB write, pool mutation, `models:changed` emit, and React re-render.
1694
+ * The decks gallery visibly re-sorts and "pops in" once per chunk.
1695
+ *
1696
+ * Here we run the per-delta bookkeeping (dedup, ack, version vector,
1697
+ * watermark, G/S routing, D cascade) for every delta WITHOUT scheduling
1698
+ * a flush, then flush ONCE — collapsing the whole frame into a single
1699
+ * IDB write + pool mutation + `models:changed` + re-render. Same code
1700
+ * for the post-bootstrap replay of deltas queued during bootstrap.
1701
+ *
1702
+ * (Named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
1703
+ * with `Database.processDeltaBatch` — the lower-level IDB write this
1704
+ * eventually drives through `flushPendingDeltas`.)
1705
+ */
1706
+ applyDeltaFrame(deltas) {
1707
+ let enqueuedAny = false;
1708
+ for (const delta of deltas) {
1709
+ if (this.enqueueDelta(delta))
1710
+ enqueuedAny = true;
1711
+ }
1712
+ if (!enqueuedAny)
1713
+ return;
1714
+ // Cancel any pending live-traffic timer — the frame is complete, so
1715
+ // there is nothing to wait for. Flush everything in one pass.
1716
+ if (this.batchTimer) {
1717
+ clearTimeout(this.batchTimer);
1718
+ this.batchTimer = null;
1719
+ }
1720
+ void this.flushPendingDeltas().catch(this.handleFlushError);
1721
+ }
1722
+ /**
1723
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
1724
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
1725
+ * subsequent flush must drain), `false` when it was skipped (dedup),
1726
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
1727
+ * sync-group mutations). Does NOT schedule a flush — callers decide
1728
+ * whether to debounce (live) or flush atomically (catch-up frame).
1729
+ */
1730
+ enqueueDelta(delta) {
1550
1731
  // Dedup guard — skip already-processed deltas
1551
1732
  if (delta.id > 0 && delta.id <= this.highestProcessedSyncId)
1552
- return;
1733
+ return false;
1553
1734
  // Confirm awaiting transactions via sync ID threshold (before batching)
1554
1735
  this.syncClient.onDeltaReceived(delta.id);
1555
1736
  // Update version vector
@@ -1560,7 +1741,7 @@ export class BaseSyncedStore {
1560
1741
  // Queue during active bootstrap
1561
1742
  if (this.bootstrapDeltaQueue !== null) {
1562
1743
  this.bootstrapDeltaQueue.push(delta);
1563
- return;
1744
+ return false;
1564
1745
  }
1565
1746
  // Advance watermark
1566
1747
  this.syncClient.position.advanceApplied(delta.id);
@@ -1568,13 +1749,13 @@ export class BaseSyncedStore {
1568
1749
  // (addedGroups/removedGroups) and incremental (group/userId) payloads.
1569
1750
  if (delta.actionType === 'G') {
1570
1751
  void this.handleSyncGroupChange(delta);
1571
- return;
1752
+ return false;
1572
1753
  }
1573
1754
  // Sync group removed — handle immediately. Clears affected local state
1574
1755
  // and forces re-bootstrap with the updated group list.
1575
1756
  if (delta.actionType === 'S') {
1576
1757
  void this.handleGroupRemoved(delta);
1577
- return;
1758
+ return false;
1578
1759
  }
1579
1760
  // DELETE — fire the cascade cancel immediately (O(1) via FK index;
1580
1761
  // must run BEFORE any subsequent update on the same model lands so
@@ -1590,6 +1771,10 @@ export class BaseSyncedStore {
1590
1771
  this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
1591
1772
  }
1592
1773
  this.pendingDeltas.push(delta);
1774
+ return true;
1775
+ }
1776
+ /** Debounce a flush for live single-delta traffic. */
1777
+ scheduleDeltaFlush() {
1593
1778
  if (this.batchTimer)
1594
1779
  clearTimeout(this.batchTimer);
1595
1780
  if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
@@ -91,6 +91,14 @@ export declare class Database {
91
91
  private bootstrapHelper;
92
92
  /** The pre-configured query helper for lazy-loading data from the sync server. */
93
93
  get helper(): BootstrapHelper;
94
+ /**
95
+ * PURE scoped snapshot fetch for hydrate-on-enter (P4). Returns the FULL
96
+ * current rows of the given sync groups, with NO side effects — unlike
97
+ * {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
98
+ * touch the connection's `subscribedSyncGroups` (which the shrinkage check
99
+ * owns). The caller applies the result to the pool via the SCOPED apply path.
100
+ */
101
+ fetchScopedBootstrapData(syncGroups: readonly string[]): Promise<BootstrapData>;
94
102
  private currentDbInfo;
95
103
  private workspaceDb;
96
104
  /**
@@ -195,7 +203,7 @@ export declare class Database {
195
203
  * but the switch returns a no-op verify if one slips through (e.g.
196
204
  * replayed from the bootstrap queue) rather than crashing the engine.
197
205
  */
198
- actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
206
+ actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S' | 'M';
199
207
  modelName: string;
200
208
  modelId: string;
201
209
  data: ModelData | null;
@@ -235,7 +243,7 @@ export declare class Database {
235
243
  * shouldn't reach batch processing, but the switch inside returns
236
244
  * no-op verify for them if one slips through.
237
245
  */
238
- actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
246
+ actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S' | 'M';
239
247
  modelName: string;
240
248
  modelId: string;
241
249
  data: ModelData | null;
package/dist/Database.js CHANGED
@@ -20,6 +20,17 @@ export class Database {
20
20
  get helper() {
21
21
  return this.bootstrapHelper;
22
22
  }
23
+ /**
24
+ * PURE scoped snapshot fetch for hydrate-on-enter (P4). Returns the FULL
25
+ * current rows of the given sync groups, with NO side effects — unlike
26
+ * {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
27
+ * touch the connection's `subscribedSyncGroups` (which the shrinkage check
28
+ * owns). The caller applies the result to the pool via the SCOPED apply path.
29
+ */
30
+ async fetchScopedBootstrapData(syncGroups) {
31
+ // No lastSyncId → a full snapshot of exactly these groups.
32
+ return this.bootstrapHelper.fetchBootstrap(undefined, syncGroups);
33
+ }
23
34
  // Database state
24
35
  currentDbInfo = null;
25
36
  workspaceDb = null;
@@ -764,7 +775,10 @@ export class Database {
764
775
  // ========================================================================
765
776
  // CONFLICT CHECK: Skip UPDATE/INSERT if DELETE exists with higher syncId
766
777
  // ========================================================================
767
- if (delta.actionType === 'U' || delta.actionType === 'I' || delta.actionType === 'C') {
778
+ if (delta.actionType === 'U' ||
779
+ delta.actionType === 'I' ||
780
+ delta.actionType === 'C' ||
781
+ delta.actionType === 'M') {
768
782
  const key = `${delta.modelName}:${delta.modelId}`;
769
783
  const deleteSyncId = deleteSyncIds.get(key);
770
784
  if (deleteSyncId !== undefined) {
@@ -503,7 +503,18 @@ export declare class SyncClient extends EventEmitter {
503
503
  applyBootstrapDataToPool(bootstrapData: {
504
504
  models?: Record<string, unknown[]>;
505
505
  failedModels?: string[];
506
- }, protectedIds?: ReadonlySet<string>): {
506
+ }, protectedIds?: ReadonlySet<string>, options?: {
507
+ /**
508
+ * SCOPED backfill (P4 hydrate-on-enter): the snapshot covers only the
509
+ * groups just entered, NOT the whole type. Two behaviors change to keep
510
+ * it from corrupting the pool:
511
+ * - upsert is version-guarded ({@link ObjectPool.upsertIfNewer}) so a
512
+ * concurrent live delta isn't clobbered back to the snapshot version;
513
+ * - ghost removal is SKIPPED — a subset snapshot must never evict rows
514
+ * of the same type that belong to other (unhydrated) groups.
515
+ */
516
+ scoped?: boolean;
517
+ }): {
507
518
  added: number;
508
519
  updated: number;
509
520
  removed: number;