@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
@@ -30,7 +30,7 @@
30
30
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
- * useIntent(name) — typed intent dispatcher
33
+ * useClaim(name) — typed claim dispatcher
34
34
  *
35
35
  * ── Breaking changes from v0.2.x ───────────────────────────────────
36
36
  * Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
@@ -43,8 +43,8 @@
43
43
  * Changed: useSyncStatus() now returns a discriminated union. See the
44
44
  * migration notes in CHANGELOG.md.
45
45
  */
46
- export type { DefaultSyncShape, ResolveSchema, ResolvePresence, ResolveIntents, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
47
- export { AbloProvider, useParticipant, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseParticipantOptions, type UseParticipantReturn, type MeshParticipantStatus, } from './AbloProvider.js';
46
+ export type { DefaultSyncShape, ResolveSchema, ResolvePresence, ResolveClaims, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
47
+ export { AbloProvider, useParticipant, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseParticipantOptions, type UseParticipantReturn, type MeshParticipantStatus, } from './AbloProvider.js';
48
48
  export { SyncGroupProvider, useSyncGroup, type SyncGroupProviderProps, } from './SyncGroupProvider.js';
49
49
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './ClientSideSuspense.js';
50
50
  export { DefaultFallback } from './DefaultFallback.js';
@@ -60,5 +60,5 @@ export { useMutators, type MutatorInvokers, type InvokerFor, type UseMutatorsOpt
60
60
  export { useUndoScope, type UseUndoScopeResult } from './useUndoScope.js';
61
61
  export { useAblo, type UseAbloHydratedModelResult, type UseAbloModelOptions, type UseAbloModelResult, } from './useAblo.js';
62
62
  export { usePresence } from './usePresence.js';
63
- export { useIntent } from './useIntent.js';
63
+ export { useClaim } from './useClaim.js';
64
64
  export { ModelScope } from '../types/index.js';
@@ -30,7 +30,7 @@
30
30
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
- * useIntent(name) — typed intent dispatcher
33
+ * useClaim(name) — typed claim dispatcher
34
34
  *
35
35
  * ── Breaking changes from v0.2.x ───────────────────────────────────
36
36
  * Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
@@ -44,7 +44,7 @@
44
44
  * migration notes in CHANGELOG.md.
45
45
  */
46
46
  // ── Umbrella provider + lifecycle hooks ────────────────────────────
47
- export { AbloProvider, useParticipant, useSync, useSyncStore, } from './AbloProvider.js';
47
+ export { AbloProvider, useParticipant, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
48
48
  export { SyncGroupProvider, useSyncGroup, } from './SyncGroupProvider.js';
49
49
  export { ClientSideSuspense, } from './ClientSideSuspense.js';
50
50
  export { DefaultFallback } from './DefaultFallback.js';
@@ -63,8 +63,8 @@ export { useReactive } from './useReactive.js';
63
63
  export { useMutators, } from './useMutators.js';
64
64
  export { useUndoScope } from './useUndoScope.js';
65
65
  export { useAblo, } from './useAblo.js';
66
- // ── Presence + intent (typed via Register module augmentation) ─────
66
+ // ── Presence + claim (typed via Register module augmentation) ─────
67
67
  export { usePresence } from './usePresence.js';
68
- export { useIntent } from './useIntent.js';
68
+ export { useClaim } from './useClaim.js';
69
69
  // ── ModelScope re-export ───────────────────────────────────────────
70
70
  export { ModelScope } from '../types/index.js';
@@ -12,7 +12,7 @@ function readModelResult(engine, modelClient, id, initial) {
12
12
  const data = snapshotValue(modelClient.get(id) ?? initial);
13
13
  const meta = getModelClientMeta(modelClient);
14
14
  const claims = meta && engine
15
- ? engine.intents.list({ model: meta.key, id })
15
+ ? engine.claims.list({ model: meta.key, id })
16
16
  : EMPTY_CLAIMS;
17
17
  return { data, claims, claimed: claims.length > 0 };
18
18
  }
@@ -37,18 +37,18 @@ export function useAblo(modelOrSelect, id, options) {
37
37
  : typeof modelOrSelect === 'function'
38
38
  ? undefined
39
39
  : modelOrSelect;
40
- // Claims live on a non-MobX event emitter (engine.intents), so the useReactive
40
+ // Claims live on a non-MobX event emitter (engine.claims), so the useReactive
41
41
  // reactions below cannot track them — we bridge changes through a setState bump.
42
42
  // ONLY the model-row form (`id !== undefined`) actually reads claims, so gate the
43
43
  // subscription on `id`. The selector-only form (`useAblo((a) => a.x.get/getAll)`)
44
- // never reads claims; subscribing it to the workspace-global intent stream would
45
- // re-render + double-compute it on every intent/presence delta anywhere (a real
44
+ // never reads claims; subscribing it to the workspace-global claim stream would
45
+ // re-render + double-compute it on every claim/presence delta anywhere (a real
46
46
  // storm during AI editing / live collaboration) for a value that can't change.
47
47
  const [claimVersion, setClaimVersion] = useState(0);
48
48
  useEffect(() => {
49
49
  if (!engine || id === undefined)
50
50
  return;
51
- return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
51
+ return engine.claims.onChange(() => setClaimVersion((version) => version + 1));
52
52
  }, [engine, id]);
53
53
  const selected = useReactive(() => {
54
54
  if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
@@ -1,13 +1,13 @@
1
- import type { ResolveIntents } from '../types/global.js';
1
+ import type { ResolveClaims } from '../types/global.js';
2
2
  /**
3
- * Named-intent invoker, typed via `ResolveIntents[IntentName]`.
3
+ * Named-claim invoker, typed via `ResolveClaims[ClaimName]`.
4
4
  *
5
- * The consumer declares their intent vocabulary in the global:
5
+ * The consumer declares their claim vocabulary in the global:
6
6
  *
7
7
  * ```ts
8
8
  * declare module '@abloatai/ablo' {
9
9
  * interface Register {
10
- * Intents: {
10
+ * Claims: {
11
11
  * editLayer: { slideId: string; layerId: string };
12
12
  * generateWithAI: { entityId: string; tool: string };
13
13
  * };
@@ -15,15 +15,15 @@ import type { ResolveIntents } from '../types/global.js';
15
15
  * }
16
16
  * ```
17
17
  *
18
- * Then `useIntent('editLayer')` returns a function whose sole argument
18
+ * Then `useClaim('editLayer')` returns a function whose sole argument
19
19
  * is the `editLayer` claim shape — no runtime checks, purely compile-
20
20
  * time narrowing.
21
21
  *
22
- * The SDK doesn't own what happens next: the `beginIntent` function on
23
- * the React context (supplied via `SyncProvider`) is where the intent
22
+ * The SDK doesn't own what happens next: the `beginClaim` function on
23
+ * the React context (supplied via `SyncProvider`) is where the claim
24
24
  * claim turns into a network effect. A Node-backed consumer wires it
25
- * through `SyncAgent.beginIntent`; a browser-backed consumer may
25
+ * through `SyncAgent.beginClaim`; a browser-backed consumer may
26
26
  * broadcast it through their own WebSocket. This hook is pure sugar
27
27
  * that adds the typed name + claim narrowing.
28
28
  */
29
- export declare function useIntent<Name extends keyof ResolveIntents & string>(intentName: Name): (claim: ResolveIntents[Name]) => unknown;
29
+ export declare function useClaim<Name extends keyof ResolveClaims & string>(claimName: Name): (claim: ResolveClaims[Name]) => unknown;
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+ import { useCallback } from 'react';
3
+ import { useSyncContext } from './context.js';
4
+ import { AbloValidationError } from '../errors.js';
5
+ /**
6
+ * Named-claim invoker, typed via `ResolveClaims[ClaimName]`.
7
+ *
8
+ * The consumer declares their claim vocabulary in the global:
9
+ *
10
+ * ```ts
11
+ * declare module '@abloatai/ablo' {
12
+ * interface Register {
13
+ * Claims: {
14
+ * editLayer: { slideId: string; layerId: string };
15
+ * generateWithAI: { entityId: string; tool: string };
16
+ * };
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * Then `useClaim('editLayer')` returns a function whose sole argument
22
+ * is the `editLayer` claim shape — no runtime checks, purely compile-
23
+ * time narrowing.
24
+ *
25
+ * The SDK doesn't own what happens next: the `beginClaim` function on
26
+ * the React context (supplied via `SyncProvider`) is where the claim
27
+ * claim turns into a network effect. A Node-backed consumer wires it
28
+ * through `SyncAgent.beginClaim`; a browser-backed consumer may
29
+ * broadcast it through their own WebSocket. This hook is pure sugar
30
+ * that adds the typed name + claim narrowing.
31
+ */
32
+ export function useClaim(claimName) {
33
+ const { beginClaim } = useSyncContext();
34
+ return useCallback((claim) => {
35
+ if (!beginClaim) {
36
+ throw new AbloValidationError(`useClaim: no \`beginClaim\` wired into SyncProvider. Pass ` +
37
+ `a \`beginClaim\` prop (typically bound to your transport) ` +
38
+ `to enable claim invocations.`, { code: 'claim_not_wired' });
39
+ }
40
+ return beginClaim(claimName, claim);
41
+ }, [beginClaim, claimName]);
42
+ }
@@ -41,7 +41,7 @@ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syn
41
41
  export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, } from './sync-delta-wire.js';
42
42
  // Model builder
43
43
  export { model, scopeKindOf, } from './model.js';
44
- // Intent-first shorthand: `mutable.lazy({...})` and friends. Read the
44
+ // Claim-first shorthand: `mutable.lazy({...})` and friends. Read the
45
45
  // safety posture and load shape off the verb tokens; everything else
46
46
  // falls back to sensible defaults. See sugar.ts for the full pattern.
47
47
  export { mutable, readOnly } from './sugar.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Intent-first shorthand for `model(...)` — the Modal-inspired DX layer.
2
+ * Claim-first shorthand for `model(...)` — the Modal-inspired DX layer.
3
3
  *
4
4
  * The factory verbs encode the two orthogonal axes that matter for
5
5
  * safety and bootstrap behavior:
@@ -12,7 +12,7 @@
12
12
  * `.lazy` loads on first access, `.manual` requires explicit
13
13
  * queries.
14
14
  *
15
- * The two-token form (`mutable.lazy({...})`) reads the safety intent
15
+ * The two-token form (`mutable.lazy({...})`) reads the safety claim
16
16
  * in the first token and the load shape in the second — you know both
17
17
  * key facts about the entity before scanning its fields.
18
18
  *
@@ -28,7 +28,7 @@
28
28
  * load: 'lazy', lazyObservable: true, computed: tasksComputed,
29
29
  * }),
30
30
  *
31
- * // After — intent reads off the verb; options carry only the
31
+ * // After — claim reads off the verb; options carry only the
32
32
  * // fields that actually diverge from defaults
33
33
  * tasks: mutable.lazy({ title: z.string() }, {
34
34
  * typename: 'Task', tableName: 'tasks',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Intent-first shorthand for `model(...)` — the Modal-inspired DX layer.
2
+ * Claim-first shorthand for `model(...)` — the Modal-inspired DX layer.
3
3
  *
4
4
  * The factory verbs encode the two orthogonal axes that matter for
5
5
  * safety and bootstrap behavior:
@@ -12,7 +12,7 @@
12
12
  * `.lazy` loads on first access, `.manual` requires explicit
13
13
  * queries.
14
14
  *
15
- * The two-token form (`mutable.lazy({...})`) reads the safety intent
15
+ * The two-token form (`mutable.lazy({...})`) reads the safety claim
16
16
  * in the first token and the load shape in the second — you know both
17
17
  * key facts about the entity before scanning its fields.
18
18
  *
@@ -28,7 +28,7 @@
28
28
  * load: 'lazy', lazyObservable: true, computed: tasksComputed,
29
29
  * }),
30
30
  *
31
- * // After — intent reads off the verb; options carry only the
31
+ * // After — claim reads off the verb; options carry only the
32
32
  * // fields that actually diverge from defaults
33
33
  * tasks: mutable.lazy({ title: z.string() }, {
34
34
  * typename: 'Task', tableName: 'tasks',
@@ -32,14 +32,14 @@ import { z } from 'zod';
32
32
  * `S` groupRemoved.
33
33
  */
34
34
  export declare const syncDeltaActionSchema: z.ZodEnum<{
35
- A: "A";
36
35
  I: "I";
37
36
  U: "U";
38
37
  D: "D";
38
+ A: "A";
39
+ V: "V";
39
40
  C: "C";
40
41
  G: "G";
41
42
  S: "S";
42
- V: "V";
43
43
  }>;
44
44
  export type SyncDeltaAction = z.infer<typeof syncDeltaActionSchema>;
45
45
  /**
@@ -73,14 +73,14 @@ export type ParticipantRef = z.infer<typeof participantRefSchema>;
73
73
  export declare const syncDeltaWireCoreSchema: z.ZodObject<{
74
74
  id: z.ZodNumber;
75
75
  actionType: z.ZodEnum<{
76
- A: "A";
77
76
  I: "I";
78
77
  U: "U";
79
78
  D: "D";
79
+ A: "A";
80
+ V: "V";
80
81
  C: "C";
81
82
  G: "G";
82
83
  S: "S";
83
- V: "V";
84
84
  }>;
85
85
  modelName: z.ZodString;
86
86
  modelId: z.ZodString;
@@ -98,14 +98,14 @@ export type SyncDeltaWireCore = z.infer<typeof syncDeltaWireCoreSchema>;
98
98
  export declare const clientSyncDeltaSchema: z.ZodObject<{
99
99
  id: z.ZodNumber;
100
100
  actionType: z.ZodEnum<{
101
- A: "A";
102
101
  I: "I";
103
102
  U: "U";
104
103
  D: "D";
104
+ A: "A";
105
+ V: "V";
105
106
  C: "C";
106
107
  G: "G";
107
108
  S: "S";
108
- V: "V";
109
109
  }>;
110
110
  modelName: z.ZodString;
111
111
  modelId: z.ZodString;
@@ -127,14 +127,14 @@ export type ClientSyncDelta = z.infer<typeof clientSyncDeltaSchema>;
127
127
  export declare const serverSyncDeltaSchema: z.ZodObject<{
128
128
  id: z.ZodNumber;
129
129
  actionType: z.ZodEnum<{
130
- A: "A";
131
130
  I: "I";
132
131
  U: "U";
133
132
  D: "D";
133
+ A: "A";
134
+ V: "V";
134
135
  C: "C";
135
136
  G: "G";
136
137
  S: "S";
137
- V: "V";
138
138
  }>;
139
139
  modelName: z.ZodString;
140
140
  modelId: z.ZodString;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@abloatai/ablo/server` — the COMMIT contract types.
3
3
  *
4
- * `CommitContext` is the attribution/intent envelope the host's commit executor
4
+ * `CommitContext` is the attribution/claim envelope the host's commit executor
5
5
  * stamps onto every delta a batch produces; `CommitResult` is the receipt. Both
6
6
  * are PURE descriptors (no `postgres`, no SQL, no functions), which is why they
7
7
  * live in the portable package while the SQL engine that consumes them
@@ -76,7 +76,7 @@ export interface CommitContext {
76
76
  confirmationState?: ConfirmationState;
77
77
  /**
78
78
  * Dormant FK to the agent-task id (`agent_tasks.id`). The SDK no longer
79
- * sets it (turns/tasks removed; attribution rides on the claim/intent id
79
+ * sets it (turns/tasks removed; attribution rides on the claim/claim id
80
80
  * + server-stamped actor/capability). Still validated + written onto
81
81
  * `caused_by_task_id` when present, but client writes leave it `null`.
82
82
  */
@@ -0,0 +1,162 @@
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
+ /** The single capability this manager needs from the connection. */
38
+ export interface SubscriptionTransport {
39
+ /**
40
+ * Replace the connection's read interest with the COMPLETE group set.
41
+ * Resolves with the server's effective set (which the manager treats as
42
+ * authoritative for its next diff).
43
+ */
44
+ updateSubscription(syncGroups: ReadonlyArray<string>): Promise<{
45
+ syncGroups: string[];
46
+ }>;
47
+ }
48
+ export interface AreaOfInterestOptions {
49
+ /** Connection to drive. `SyncWebSocket` satisfies this structurally. */
50
+ transport: SubscriptionTransport;
51
+ /**
52
+ * Groups always present in the effective set (e.g. `org:<id>`,
53
+ * `user:<id>`). Never warm, never expired.
54
+ */
55
+ baseGroups?: ReadonlyArray<string>;
56
+ /**
57
+ * How long a `leave`-ed group stays subscribed before it actually drops.
58
+ * This is the hysteresis margin. Default 30s.
59
+ */
60
+ warmTtlMs?: number;
61
+ /**
62
+ * Maximum number of warm (left-but-still-subscribed) groups. Under heavy
63
+ * navigation — opening and closing many entities quickly — warm groups
64
+ * would otherwise pile up until each TTL lapses, inflating the connection's
65
+ * subscription set. When the cap is exceeded, the LEAST-recently-warmed
66
+ * group is evicted immediately (dropped) instead of waiting for its TTL.
67
+ * This is the bounded relevant-set discipline from game netcode. Default 16.
68
+ */
69
+ maxWarm?: number;
70
+ /**
71
+ * Auto-run the warm-expiry sweep on this cadence. Set `0` to disable and
72
+ * drive {@link AreaOfInterestManager.sweep} yourself (tests do this).
73
+ * Default = `warmTtlMs` (checks about once per margin).
74
+ */
75
+ sweepIntervalMs?: number;
76
+ /** Clock injection point for deterministic tests. Default `Date.now`. */
77
+ now?: () => number;
78
+ /**
79
+ * Schedule a periodic callback. Default wraps `setInterval`/
80
+ * `clearInterval`. Injected so tests avoid real timers.
81
+ */
82
+ scheduler?: (fn: () => void, intervalMs: number) => () => void;
83
+ }
84
+ export declare class AreaOfInterestManager {
85
+ private readonly transport;
86
+ private readonly baseGroups;
87
+ private readonly warmTtlMs;
88
+ private readonly maxWarm;
89
+ private readonly now;
90
+ /** Groups currently in view (open entities). */
91
+ private readonly active;
92
+ /** Claim-pinned groups — prominence; never warm/expire while pinned. */
93
+ private readonly pinned;
94
+ /** Left-but-warm groups → epoch-ms at which they drop. */
95
+ private readonly warm;
96
+ /** Last set the transport confirmed — the diff baseline. */
97
+ private lastSent;
98
+ /** Coalescing state so concurrent mutations collapse into one in-flight call. */
99
+ private inFlight;
100
+ private dirty;
101
+ private readonly cancelSweep;
102
+ constructor(options: AreaOfInterestOptions);
103
+ /**
104
+ * Move a group into the warm set with a fresh TTL, maintaining LRU order
105
+ * and the `maxWarm` cap. JS `Map` preserves insertion order, so deleting
106
+ * then re-setting moves the group to the most-recently-warmed position;
107
+ * eviction then drops from the front (oldest). Base/pinned groups never
108
+ * warm — callers guard before calling this.
109
+ */
110
+ private warmGroup;
111
+ /** The effective read set: base ∪ active ∪ pinned ∪ (warm not yet expired). */
112
+ private desiredGroups;
113
+ /** Bring a group into view. Cancels any warm timer for it. Idempotent. */
114
+ enter(group: string): Promise<void>;
115
+ /**
116
+ * Leave a group. It does not drop immediately — it goes warm for
117
+ * `warmTtlMs` (unless pinned, in which case it stays via the pin).
118
+ * Re-entering within the window is free.
119
+ */
120
+ leave(group: string): Promise<void>;
121
+ /** Pin a group (active claim / prominence). Never warm or expires while pinned. */
122
+ pin(group: string): Promise<void>;
123
+ /**
124
+ * Unpin a group. If it's not currently in view, it transitions to warm
125
+ * (so dropping a claim gets the same hysteresis as closing a tab) rather
126
+ * than dropping instantly.
127
+ */
128
+ unpin(group: string): Promise<void>;
129
+ /**
130
+ * Drop warm groups whose TTL has lapsed and reconcile. Auto-invoked on
131
+ * the sweep timer; call manually (with an injected `now`) in tests.
132
+ */
133
+ sweep(): Promise<void>;
134
+ /** The set the manager believes is subscribed (post-confirmation). */
135
+ effectiveGroups(): string[];
136
+ /**
137
+ * Re-assert the full desired set against the transport, forgetting what
138
+ * was previously confirmed. Call after a reconnect: a fresh
139
+ * `SyncWebSocket` instance starts from the connect-time URL groups, so
140
+ * the manager's `lastSent` diff baseline is stale. Clearing it forces
141
+ * one `update_subscription` that re-establishes the live interest on the
142
+ * new socket.
143
+ *
144
+ * Resetting `lastSent` makes the next reconcile unconditionally re-push
145
+ * the current desired set (one `update_subscription` frame) so the fresh
146
+ * socket's server-side index matches local interest, even if warm/pinned
147
+ * groups drifted across the disconnect window. The connect-time URL
148
+ * already carries the last-acked set, so this is a correction frame, not
149
+ * the primary mechanism.
150
+ */
151
+ resync(): Promise<void>;
152
+ /** Stop the sweep timer. The connection is unaffected. */
153
+ dispose(): void;
154
+ /**
155
+ * Push the desired set to the transport iff it differs from the last
156
+ * confirmed set. Coalesces concurrent mutations: if a call is already in
157
+ * flight, mark dirty and let the in-flight loop pick up the newest state
158
+ * — so a burst of enter/leave collapses into the minimum number of
159
+ * `update_subscription` round-trips.
160
+ */
161
+ private reconcile;
162
+ }