@abloatai/ablo 0.10.0 → 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 (94) hide show
  1. package/CHANGELOG.md +16 -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/docs/migration.md +52 -0
  90. package/package.json +3 -2
  91. package/dist/react/useIntent.js +0 -42
  92. package/dist/sync/awaitIntentGrant.d.ts +0 -40
  93. package/dist/sync/awaitIntentGrant.js +0 -62
  94. package/dist/sync/createIntentStream.d.ts +0 -34
@@ -0,0 +1,233 @@
1
+ /**
2
+ * AreaOfInterestManager — client-side hysteresis + prominence policy over
3
+ * the `update_subscription` read primitive.
4
+ *
5
+ * Game netcode never thrashes its area-of-interest on a boundary: a cell
6
+ * you walk out of stays subscribed for a margin before it's dropped
7
+ * (hysteresis), and "important" entities stay relevant from farther away
8
+ * (prominence). This manager applies both to Ablo sync groups:
9
+ *
10
+ * - `enter(group)` / `leave(group)` move read interest as the user opens
11
+ * and closes entities (decks, sheets, docs). A `leave` does NOT
12
+ * immediately unsubscribe — the group goes WARM with a TTL and stays
13
+ * in the effective set. Re-entering within the window is a no-op
14
+ * (already subscribed → no bootstrap), and only when the warm TTL
15
+ * lapses does the group actually drop. This is the boundary hysteresis
16
+ * that turns deck-tab flipping from a re-bootstrap storm into a
17
+ * cache hit.
18
+ *
19
+ * - `pin(group)` / `unpin(group)` express prominence: a group that holds
20
+ * an active claim (write-claim) is pinned and never goes warm or
21
+ * expires while pinned. The claim machinery is the prominence oracle —
22
+ * the row two agents are fighting over stays subscribed regardless of
23
+ * navigation.
24
+ *
25
+ * - `baseGroups` are permanent infrastructure scopes (e.g. `org:<id>`,
26
+ * `user:<id>`) that are always in the effective set.
27
+ *
28
+ * The effective set is recomputed and diffed against what was last sent;
29
+ * the transport's `update_subscription` is only called when it actually
30
+ * changes, so hysteresis genuinely suppresses network churn rather than
31
+ * just deferring it.
32
+ *
33
+ * Transport-agnostic: it depends only on {@link SubscriptionTransport},
34
+ * which `SyncWebSocket` satisfies structurally. `now` and the sweep timer
35
+ * are injectable so the policy is deterministic under test.
36
+ */
37
+ function setsEqual(a, b) {
38
+ if (a.size !== b.size)
39
+ return false;
40
+ for (const v of a)
41
+ if (!b.has(v))
42
+ return false;
43
+ return true;
44
+ }
45
+ export class AreaOfInterestManager {
46
+ transport;
47
+ baseGroups;
48
+ warmTtlMs;
49
+ maxWarm;
50
+ now;
51
+ /** Groups currently in view (open entities). */
52
+ active = new Set();
53
+ /** Claim-pinned groups — prominence; never warm/expire while pinned. */
54
+ pinned = new Set();
55
+ /** Left-but-warm groups → epoch-ms at which they drop. */
56
+ warm = new Map();
57
+ /** Last set the transport confirmed — the diff baseline. */
58
+ lastSent = new Set();
59
+ /** Coalescing state so concurrent mutations collapse into one in-flight call. */
60
+ inFlight = null;
61
+ dirty = false;
62
+ cancelSweep;
63
+ constructor(options) {
64
+ this.transport = options.transport;
65
+ this.baseGroups = new Set(options.baseGroups ?? []);
66
+ this.warmTtlMs = options.warmTtlMs ?? 30_000;
67
+ this.maxWarm = options.maxWarm ?? 16;
68
+ this.now = options.now ?? (() => Date.now());
69
+ const sweepInterval = options.sweepIntervalMs ?? this.warmTtlMs;
70
+ if (sweepInterval > 0) {
71
+ const schedule = options.scheduler ??
72
+ ((fn, ms) => {
73
+ const handle = setInterval(fn, ms);
74
+ return () => clearInterval(handle);
75
+ });
76
+ this.cancelSweep = schedule(() => {
77
+ void this.sweep();
78
+ }, sweepInterval);
79
+ }
80
+ else {
81
+ this.cancelSweep = null;
82
+ }
83
+ }
84
+ /**
85
+ * Move a group into the warm set with a fresh TTL, maintaining LRU order
86
+ * and the `maxWarm` cap. JS `Map` preserves insertion order, so deleting
87
+ * then re-setting moves the group to the most-recently-warmed position;
88
+ * eviction then drops from the front (oldest). Base/pinned groups never
89
+ * warm — callers guard before calling this.
90
+ */
91
+ warmGroup(group) {
92
+ this.warm.delete(group);
93
+ this.warm.set(group, this.now() + this.warmTtlMs);
94
+ while (this.warm.size > this.maxWarm) {
95
+ const oldest = this.warm.keys().next().value;
96
+ if (oldest === undefined)
97
+ break;
98
+ this.warm.delete(oldest);
99
+ }
100
+ }
101
+ /** The effective read set: base ∪ active ∪ pinned ∪ (warm not yet expired). */
102
+ desiredGroups() {
103
+ const now = this.now();
104
+ const desired = new Set(this.baseGroups);
105
+ for (const g of this.active)
106
+ desired.add(g);
107
+ for (const g of this.pinned)
108
+ desired.add(g);
109
+ for (const [g, expiry] of this.warm) {
110
+ if (expiry > now)
111
+ desired.add(g);
112
+ }
113
+ return desired;
114
+ }
115
+ /** Bring a group into view. Cancels any warm timer for it. Idempotent. */
116
+ enter(group) {
117
+ this.warm.delete(group);
118
+ this.active.add(group);
119
+ return this.reconcile();
120
+ }
121
+ /**
122
+ * Leave a group. It does not drop immediately — it goes warm for
123
+ * `warmTtlMs` (unless pinned, in which case it stays via the pin).
124
+ * Re-entering within the window is free.
125
+ */
126
+ leave(group) {
127
+ this.active.delete(group);
128
+ if (!this.pinned.has(group) && !this.baseGroups.has(group)) {
129
+ this.warmGroup(group);
130
+ }
131
+ return this.reconcile();
132
+ }
133
+ /** Pin a group (active claim / prominence). Never warm or expires while pinned. */
134
+ pin(group) {
135
+ this.warm.delete(group);
136
+ this.pinned.add(group);
137
+ return this.reconcile();
138
+ }
139
+ /**
140
+ * Unpin a group. If it's not currently in view, it transitions to warm
141
+ * (so dropping a claim gets the same hysteresis as closing a tab) rather
142
+ * than dropping instantly.
143
+ */
144
+ unpin(group) {
145
+ this.pinned.delete(group);
146
+ if (!this.active.has(group) && !this.baseGroups.has(group)) {
147
+ this.warmGroup(group);
148
+ }
149
+ return this.reconcile();
150
+ }
151
+ /**
152
+ * Drop warm groups whose TTL has lapsed and reconcile. Auto-invoked on
153
+ * the sweep timer; call manually (with an injected `now`) in tests.
154
+ */
155
+ sweep() {
156
+ const now = this.now();
157
+ for (const [g, expiry] of this.warm) {
158
+ if (expiry <= now)
159
+ this.warm.delete(g);
160
+ }
161
+ return this.reconcile();
162
+ }
163
+ /** The set the manager believes is subscribed (post-confirmation). */
164
+ effectiveGroups() {
165
+ return [...this.lastSent];
166
+ }
167
+ /**
168
+ * Re-assert the full desired set against the transport, forgetting what
169
+ * was previously confirmed. Call after a reconnect: a fresh
170
+ * `SyncWebSocket` instance starts from the connect-time URL groups, so
171
+ * the manager's `lastSent` diff baseline is stale. Clearing it forces
172
+ * one `update_subscription` that re-establishes the live interest on the
173
+ * new socket.
174
+ *
175
+ * Resetting `lastSent` makes the next reconcile unconditionally re-push
176
+ * the current desired set (one `update_subscription` frame) so the fresh
177
+ * socket's server-side index matches local interest, even if warm/pinned
178
+ * groups drifted across the disconnect window. The connect-time URL
179
+ * already carries the last-acked set, so this is a correction frame, not
180
+ * the primary mechanism.
181
+ */
182
+ resync() {
183
+ this.lastSent = new Set();
184
+ return this.reconcile();
185
+ }
186
+ /** Stop the sweep timer. The connection is unaffected. */
187
+ dispose() {
188
+ this.cancelSweep?.();
189
+ }
190
+ /**
191
+ * Push the desired set to the transport iff it differs from the last
192
+ * confirmed set. Coalesces concurrent mutations: if a call is already in
193
+ * flight, mark dirty and let the in-flight loop pick up the newest state
194
+ * — so a burst of enter/leave collapses into the minimum number of
195
+ * `update_subscription` round-trips.
196
+ */
197
+ reconcile() {
198
+ if (this.inFlight) {
199
+ this.dirty = true;
200
+ return this.inFlight;
201
+ }
202
+ if (setsEqual(this.desiredGroups(), this.lastSent)) {
203
+ return Promise.resolve();
204
+ }
205
+ this.inFlight = (async () => {
206
+ try {
207
+ do {
208
+ this.dirty = false;
209
+ const target = this.desiredGroups();
210
+ if (setsEqual(target, this.lastSent))
211
+ break;
212
+ try {
213
+ const result = await this.transport.updateSubscription([...target]);
214
+ this.lastSent = new Set(result.syncGroups);
215
+ }
216
+ catch {
217
+ // Transport unavailable (offline / socket not open) or the
218
+ // server rejected the set. Interest is SOFT state — never throw
219
+ // out of enter/leave/sweep for an expected transient. Leave
220
+ // `lastSent` unchanged so the diff persists; `resync()` on the
221
+ // next `connected` re-pushes the then-current desired set,
222
+ // which is what recovers "interest changed while offline."
223
+ break;
224
+ }
225
+ } while (this.dirty);
226
+ }
227
+ finally {
228
+ this.inFlight = null;
229
+ }
230
+ })();
231
+ return this.inFlight;
232
+ }
233
+ }
@@ -100,7 +100,15 @@ export declare class BootstrapHelper {
100
100
  * @param lastSyncId - Optional: client's current lastSyncId for partial bootstrap
101
101
  * @returns Bootstrap data (either full snapshot or delta batch)
102
102
  */
103
- fetchBootstrap(lastSyncId?: number): Promise<BootstrapData>;
103
+ fetchBootstrap(lastSyncId?: number,
104
+ /**
105
+ * Per-call sync-group override for SCOPED hydrate-on-enter. When provided,
106
+ * the request uses THESE groups instead of `this.options.syncGroups`,
107
+ * WITHOUT mutating the shared options (so a concurrent full bootstrap is
108
+ * unaffected). Also bypasses the offline full-snapshot cache below, which
109
+ * holds the connection's full bootstrap and would be wrong for a subset.
110
+ */
111
+ syncGroupsOverride?: readonly string[]): Promise<BootstrapData>;
104
112
  /**
105
113
  * Fetch bootstrap with ETag, returning 304 hints
106
114
  */
@@ -83,7 +83,15 @@ export class BootstrapHelper {
83
83
  * @param lastSyncId - Optional: client's current lastSyncId for partial bootstrap
84
84
  * @returns Bootstrap data (either full snapshot or delta batch)
85
85
  */
86
- async fetchBootstrap(lastSyncId) {
86
+ async fetchBootstrap(lastSyncId,
87
+ /**
88
+ * Per-call sync-group override for SCOPED hydrate-on-enter. When provided,
89
+ * the request uses THESE groups instead of `this.options.syncGroups`,
90
+ * WITHOUT mutating the shared options (so a concurrent full bootstrap is
91
+ * unaffected). Also bypasses the offline full-snapshot cache below, which
92
+ * holds the connection's full bootstrap and would be wrong for a subset.
93
+ */
94
+ syncGroupsOverride) {
87
95
  // organizationId omitted — server reads it from auth identity.
88
96
  // See `fetchBootstrapWithETag` for the full rationale.
89
97
  const params = new URLSearchParams();
@@ -91,8 +99,8 @@ export class BootstrapHelper {
91
99
  if (lastSyncId !== undefined && lastSyncId > 0) {
92
100
  params.append('lastSyncId', lastSyncId.toString());
93
101
  }
94
- // Add sync groups
95
- this.options.syncGroups.forEach((group) => {
102
+ // Add sync groups (per-call override wins over the configured set).
103
+ (syncGroupsOverride ?? this.options.syncGroups).forEach((group) => {
96
104
  params.append('syncGroups', group);
97
105
  });
98
106
  // Selective bootstrap: only request instant-strategy models.
@@ -102,8 +110,10 @@ export class BootstrapHelper {
102
110
  params.append('models', this.options.instantModels.join(','));
103
111
  }
104
112
  const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
105
- // If offline, try cached bootstrap
106
- if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
113
+ // If offline, try cached bootstrap. Skipped for a scoped override — the
114
+ // cache holds the FULL snapshot, which is not a valid answer to a subset
115
+ // request; a scoped hydrate just soft-fails offline and retries on re-enter.
116
+ if (!syncGroupsOverride && typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
107
117
  const cached = this.options.cacheScope
108
118
  ? this.loadCachedBootstrap(this.options.cacheScope)
109
119
  : null;
@@ -29,7 +29,7 @@ import { type AuthTokenGetter } from '../auth/credentialSource.js';
29
29
  /**
30
30
  * The closed set of probe outcomes — one value carrying both reachability and
31
31
  * credential disposition, so the {@link ConnectionManager} branches on a single
32
- * exhaustive discriminant instead of reconstructing intent from a trio of
32
+ * exhaustive discriminant instead of reconstructing claim from a trio of
33
33
  * booleans. Mirrors the {@link RecoveryClass} taxonomy at the connectivity tier.
34
34
  */
35
35
  export declare const PROBE_OUTCOMES: readonly ["reachable", "unreachable", "session_expired", "credential_stale", "auth_blocked"];
@@ -31,7 +31,7 @@ import { withAuthHeaders } from '../auth/credentialSource.js';
31
31
  /**
32
32
  * The closed set of probe outcomes — one value carrying both reachability and
33
33
  * credential disposition, so the {@link ConnectionManager} branches on a single
34
- * exhaustive discriminant instead of reconstructing intent from a trio of
34
+ * exhaustive discriminant instead of reconstructing claim from a trio of
35
35
  * booleans. Mirrors the {@link RecoveryClass} taxonomy at the connectivity tier.
36
36
  */
37
37
  export const PROBE_OUTCOMES = [
@@ -10,6 +10,7 @@
10
10
  import { EventEmitter } from 'events';
11
11
  import type { MutationOperation } from '../interfaces/index.js';
12
12
  import type { ClientSyncDelta } from '../schema/sync-delta-wire.js';
13
+ import type { ClaimError, ClaimRejection } from '../coordination/schema.js';
13
14
  import { type AuthTokenGetter } from '../auth/credentialSource.js';
14
15
  /**
15
16
  * The wire delta the client receives. Derived from the canonical
@@ -160,12 +161,19 @@ export interface PresenceUpdateEvent {
160
161
  /** Server-derived from the connection's userId prefix. Clients must
161
162
  * not self-declare — server is the source of truth. */
162
163
  isAgent?: boolean;
164
+ /**
165
+ * Server-stamped canonical kind (`'user' | 'agent' | 'system'`). Additive:
166
+ * older servers omit it and readers fall back to the lossy `isAgent`
167
+ * boolean (which cannot express `'system'`). Typed `string` because it is
168
+ * raw wire input — normalize via `participantKindFromWire`.
169
+ */
170
+ participantKind?: string;
163
171
  timestamp?: number;
164
172
  /** Server stamps every presence frame with this participant's open
165
- * intent claims so peers see them without a separate channel. Wire
166
- * shape mirrors `apps/sync-server/src/hub/types.ts IntentClaim`. */
167
- activeIntents?: Array<{
168
- intentId: string;
173
+ * claim claims so peers see them without a separate channel. Wire
174
+ * shape mirrors `apps/sync-server/src/hub/types.ts Claim`. */
175
+ activeClaims?: Array<{
176
+ claimId: string;
169
177
  entityType: string;
170
178
  entityId: string;
171
179
  path?: string;
@@ -187,13 +195,7 @@ export interface PresenceUpdateEvent {
187
195
  * learn *how* it resolved before it drops from the active set.
188
196
  */
189
197
  status?: 'active' | 'committed' | 'expired' | 'canceled';
190
- error?: {
191
- code: string;
192
- message?: string;
193
- heldBy?: string;
194
- heldByIntentId?: string;
195
- heldByExpiresAt?: number;
196
- };
198
+ error?: ClaimError;
197
199
  }>;
198
200
  localTime?: string;
199
201
  type?: string;
@@ -240,30 +242,30 @@ export interface CoreSyncEventMap {
240
242
  claimId: string;
241
243
  }];
242
244
  /**
243
- * Server rejected an `intent_begin` because another participant
245
+ * Server rejected an `claim_begin` because another participant
244
246
  * already holds an open claim on the same target (cooperative
245
247
  * mutex enforced server-side). Surfaces to the participant-level
246
- * IntentStream so the caller knows their announce was denied.
248
+ * ClaimStream so the caller knows their announce was denied.
247
249
  * Payload mirrors the wire frame's `payload`.
248
250
  */
249
- intent_rejected: [Record<string, unknown>];
251
+ claim_rejected: [ClaimRejection];
250
252
  /**
251
- * Fair-queue frames (opt-in `queue: true` on `intent_begin`). `intent_acquired`
252
- * means the target was free and the lease is ours immediately; `intent_queued`
253
- * means the claim is waiting in line (carries `position`); `intent_granted`
254
- * means it reached the head and the lease is now ours; `intent_lost` means a
253
+ * Fair-queue frames (opt-in `queue: true` on `claim_begin`). `claim_acquired`
254
+ * means the target was free and the lease is ours immediately; `claim_queued`
255
+ * means the claim is waiting in line (carries `position`); `claim_granted`
256
+ * means it reached the head and the lease is now ours; `claim_lost` means a
255
257
  * held/granted claim was taken away (TTL lapse on disconnect, revoke).
256
258
  */
257
259
  /**
258
- * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
260
+ * Per-entity wait-queue snapshot: `{ target, queue: Claim[] }` with each
259
261
  * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
260
262
  * queue mutation — powers the reactive `ablo.<model>.claim.queue({ id })` read.
261
263
  */
262
- intent_queue: [Record<string, unknown>];
263
- intent_acquired: [Record<string, unknown>];
264
- intent_queued: [Record<string, unknown>];
265
- intent_granted: [Record<string, unknown>];
266
- intent_lost: [Record<string, unknown>];
264
+ claim_queue: [Record<string, unknown>];
265
+ claim_acquired: [Record<string, unknown>];
266
+ claim_queued: [Record<string, unknown>];
267
+ claim_granted: [Record<string, unknown>];
268
+ claim_lost: [Record<string, unknown>];
267
269
  }
268
270
  /**
269
271
  * Collaboration event — app-specific real-time events (selection, cursors, etc.)
@@ -370,6 +372,15 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
370
372
  * over a multiplexed connection.
371
373
  */
372
374
  private pendingClaims;
375
+ /**
376
+ * In-flight `update_subscription` frames awaiting `subscription_ack`.
377
+ * A FIFO queue rather than a keyed Map because the wire ack carries no
378
+ * correlation id — the server applies subscription updates in receive
379
+ * order and acks in the same order, so `shift()` on ack matches the
380
+ * oldest pending request. (Read-interest changes are infrequent and
381
+ * usually settle before the next one, so depth is ~1 in practice.)
382
+ */
383
+ private pendingSubscriptions;
373
384
  constructor(options: SyncWebSocketOptions);
374
385
  /**
375
386
  * Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
@@ -497,6 +508,29 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
497
508
  * we reject it locally — the user explicitly chose to release.
498
509
  */
499
510
  sendRelease(claimId: string): void;
511
+ /**
512
+ * Move this connection's READ interest — replace the connection-level
513
+ * sync groups mid-session as the user opens/closes entities. This is the
514
+ * area-of-interest (AOI) navigation primitive: the server fans out
515
+ * deltas only for groups currently in view, instead of the frozen set
516
+ * chosen at connect.
517
+ *
518
+ * Full-set replace semantics — pass the complete new group list, not a
519
+ * delta. Resolves with the server's effective set once `subscription_ack`
520
+ * arrives; rejects (typed) on a scope denial (a restricted `rk_` key
521
+ * requesting a group outside its allowlist), timeout, or disconnect. On
522
+ * success the new set is recorded as `options.syncGroups` so a later
523
+ * reconnect re-subscribes to current interest, not the connect-time set.
524
+ *
525
+ * Distinct from {@link sendClaim} (write-claim, per-op, TTL'd) — this is
526
+ * the read side and carries no capability token of its own; it's bounded
527
+ * by the connection credential's grant.
528
+ */
529
+ updateSubscription(syncGroups: ReadonlyArray<string>, options?: {
530
+ timeoutMs?: number;
531
+ }): Promise<{
532
+ syncGroups: string[];
533
+ }>;
500
534
  /**
501
535
  * Compatibility setter for direct SyncWebSocket users. The SDK-owned
502
536
  * `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
@@ -676,7 +710,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
676
710
  *
677
711
  * Wire frame (apps/sync-server/src/hub/types.ts PresenceUpdateMessage):
678
712
  * { type: 'presence_update', payload: { kind, userId, status,
679
- * syncGroups, activity, isAgent, timestamp, activeIntents } }
713
+ * syncGroups, activity, isAgent, timestamp, activeClaims } }
680
714
  */
681
715
  private handlePresenceUpdate;
682
716
  }