@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/dist/errors.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { z } from 'zod';
22
22
  import { errorCodeSpec, classifyRecovery } from './errorCodes.js';
23
+ import { wireClaimSummarySchema, descriptionFromMeta, } from './coordination/schema.js';
23
24
  export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errorCodes.js';
24
25
  // ── AbloError hierarchy — the typed error surface ────────────────────
25
26
  /** Common shape for all errors thrown by this SDK. */
@@ -160,6 +161,49 @@ export class AbloStaleContextError extends AbloError {
160
161
  this.conflicts = options.conflicts;
161
162
  }
162
163
  }
164
+ function claimAction(claim) {
165
+ return claim?.action;
166
+ }
167
+ function claimDescription(claim) {
168
+ if (!claim)
169
+ return undefined;
170
+ if ('description' in claim && typeof claim.description === 'string') {
171
+ return claim.description;
172
+ }
173
+ const meta = 'target' in claim ? claim.target?.meta ?? claim.meta : claim.meta;
174
+ return descriptionFromMeta(meta);
175
+ }
176
+ function claimExpiresAt(claim) {
177
+ return claim?.expiresAt;
178
+ }
179
+ function claimActor(claim, fallback) {
180
+ if (claim && 'actor' in claim && typeof claim.actor === 'string') {
181
+ return claim.actor;
182
+ }
183
+ return fallback;
184
+ }
185
+ function secondsUntil(ms, now = Date.now()) {
186
+ if (ms === undefined || !Number.isFinite(ms))
187
+ return undefined;
188
+ return Math.max(0, Math.ceil((ms - now) / 1000));
189
+ }
190
+ export function formatClaimedErrorMessage(args) {
191
+ const holder = claimActor(args.claim, args.heldBy);
192
+ const action = claimAction(args.claim);
193
+ const description = claimDescription(args.claim);
194
+ const expiresIn = secondsUntil(claimExpiresAt(args.claim));
195
+ if (!holder && !action && !description) {
196
+ return args.fallback ?? `Model row is claimed: ${args.targetLabel}.`;
197
+ }
198
+ const actor = holder ?? 'another participant';
199
+ const actionPart = action ? ` (${action})` : '';
200
+ const descriptionPart = description ? `: ${description}` : '';
201
+ const expiresPart = expiresIn !== undefined ? ` - expires in ${expiresIn}s` : '';
202
+ const policyPart = args.policyReason
203
+ ? ` Policy reason: ${args.policyReason}.`
204
+ : '';
205
+ return `Claimed by ${actor}${actionPart}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
206
+ }
163
207
  /**
164
208
  * The target entity is currently claimed by another participant and the caller
165
209
  * asked the SDK not to read/write through that claim.
@@ -176,6 +220,30 @@ export class AbloClaimedError extends AbloError {
176
220
  this.claims = options.claims;
177
221
  }
178
222
  }
223
+ /**
224
+ * The `/`-joined human label for a claim target — `model/id/field`, dropping
225
+ * absent parts, falling back to `'target'`. The one place this join lived in
226
+ * three copies (client `Ablo`, HTTP `ApiClient`, `awaitClaimGrant`).
227
+ */
228
+ export function claimTargetLabel(target) {
229
+ return [target.model, target.id, target.field].filter(Boolean).join('/') || 'target';
230
+ }
231
+ /**
232
+ * Build the {@link AbloClaimedError} for a contended `ablo.<model>` write — the
233
+ * single factory shared by the realtime client (`Ablo`) and the HTTP client
234
+ * (`ApiClient`), which carried byte-identical copies. The first claim is the
235
+ * holder whose metadata shapes the message.
236
+ */
237
+ export function claimedError(target, claims, code) {
238
+ const label = claimTargetLabel(target);
239
+ const holder = claims[0];
240
+ return new AbloClaimedError(formatClaimedErrorMessage({
241
+ targetLabel: label,
242
+ heldBy: holder?.actor,
243
+ claim: holder,
244
+ fallback: `Model row is claimed: ${label} held by another participant.`,
245
+ }), { code, claims });
246
+ }
179
247
  /**
180
248
  * A scoped credential was denied — either the key is unknown / revoked /
181
249
  * expired (`capability_invalid`), or the connection's resolved scope
@@ -282,6 +350,10 @@ const NestedErrorShapeSchema = z
282
350
  message: OptionalWireStringSchema,
283
351
  field: OptionalWireStringSchema,
284
352
  requiredCapability: RequiredCapabilityWireSchema.optional().catch(undefined),
353
+ heldBy: OptionalWireStringSchema,
354
+ policyReason: OptionalWireStringSchema,
355
+ heldByClaim: wireClaimSummarySchema.optional().catch(undefined),
356
+ claims: z.array(wireClaimSummarySchema).optional().catch(undefined),
285
357
  })
286
358
  .passthrough();
287
359
  const ErrorFieldSchema = z
@@ -298,6 +370,10 @@ const ErrorBodyShapeSchema = z
298
370
  reason: OptionalWireStringSchema,
299
371
  message: OptionalWireStringSchema,
300
372
  requiredCapability: RequiredCapabilityWireSchema.optional().catch(undefined),
373
+ heldBy: OptionalWireStringSchema,
374
+ policyReason: OptionalWireStringSchema,
375
+ heldByClaim: wireClaimSummarySchema.optional().catch(undefined),
376
+ claims: z.array(wireClaimSummarySchema).optional().catch(undefined),
301
377
  })
302
378
  .passthrough();
303
379
  function parseErrorBodyShape(body) {
@@ -341,7 +417,7 @@ export function toAbloError(err) {
341
417
  * hierarchy and loses `code`/`httpStatus`/retryability.
342
418
  */
343
419
  export function errorFromWire(message, opts = {}) {
344
- const { code, requestId, requiredCapability } = opts;
420
+ const { code, requestId, requiredCapability, claims } = opts;
345
421
  // Effective status: an explicit HTTP status wins; otherwise fall back to
346
422
  // the code's canonical status from the registry (undefined for unknown /
347
423
  // forward-compat codes, which then map to the base AbloError).
@@ -349,7 +425,7 @@ export function errorFromWire(message, opts = {}) {
349
425
  // Wire boundary: an incoming code is an arbitrary string (a newer server
350
426
  // may send a code this SDK predates). Cast to ErrorCode here — the one
351
427
  // sanctioned crossing — so internal producers stay statically checked.
352
- const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
428
+ const publicCode = (code === 'claim_conflict' ? 'claim_conflict' : code);
353
429
  const baseOpts = { code: publicCode, httpStatus, requestId };
354
430
  // ── Code-first specials (transport-independent) ──────────────────────
355
431
  // A scoped credential was denied — route through CapabilityError so callers
@@ -360,8 +436,8 @@ export function errorFromWire(message, opts = {}) {
360
436
  // Claim enforcement (rides 409): the target entity is held by another
361
437
  // participant. Discriminate on code BEFORE the generic 409→idempotency
362
438
  // mapping so a claim rejection surfaces as AbloClaimedError.
363
- if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
364
- return new AbloClaimedError(message, baseOpts);
439
+ if (code === 'claim_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
440
+ return new AbloClaimedError(message, { ...baseOpts, claims });
365
441
  }
366
442
  // A write whose `readAt` watermark went stale — callers re-read and retry.
367
443
  if (code === 'stale_context') {
@@ -404,7 +480,20 @@ export function translateHttpError(status, body, requestId) {
404
480
  flatError ??
405
481
  (typeof body === 'string' ? body : `HTTP ${status}`);
406
482
  const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
407
- return errorFromWire(message, { code, httpStatus: status, requestId, requiredCapability });
483
+ const claims = parsed.claims ??
484
+ nested?.claims ??
485
+ (parsed.heldByClaim
486
+ ? [parsed.heldByClaim]
487
+ : nested?.heldByClaim
488
+ ? [nested.heldByClaim]
489
+ : undefined);
490
+ return errorFromWire(message, {
491
+ code,
492
+ httpStatus: status,
493
+ requestId,
494
+ requiredCapability,
495
+ claims,
496
+ });
408
497
  }
409
498
  /**
410
499
  * Whether an HTTP error body carries a code {@link translateHttpError} can read
@@ -160,13 +160,17 @@ export interface MutationOptions {
160
160
  wait?: 'queued' | 'confirmed';
161
161
  readAt?: number | null;
162
162
  onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
163
- intent?: string | {
163
+ /** Claim-pin attribution: the id (or `{ id }`) of the claim this write
164
+ * belongs to. Distinct from the `claim` HANDLE on the model write params —
165
+ * this is the low-level reference the commit carries to bypass the holder's
166
+ * own pin. (Was `intent` before the claim-vocabulary unification.) */
167
+ claimRef?: string | {
164
168
  readonly id: string;
165
169
  } | null;
166
170
  /**
167
171
  * Dormant agent-task lineage field, forwarded as the wire-level
168
172
  * `causedByTaskId`. Turns/tasks were removed from the SDK; nothing
169
- * populates this anymore (write attribution rides on the claim/intent
173
+ * populates this anymore (write attribution rides on the claim
170
174
  * id). Kept optional for wire-compat; always `null` from the client.
171
175
  */
172
176
  causedByTaskId?: string | null;
@@ -175,9 +179,9 @@ export interface MutationOptions {
175
179
  * The `MutationOptions` subset carried per-write through the offline
176
180
  * transaction lane (SyncClient → TransactionQueue → wire operation).
177
181
  * ONE shared type so the proxy's public params, the queue, and the wire
178
- * can never narrow each other silently again — `wait` and `intent` are
182
+ * can never narrow each other silently again — `wait` and `claim` are
179
183
  * deliberately absent because they resolve client-side before staging
180
- * (`wait` at the proxy's confirmation await, `intent` server-side via
184
+ * (`wait` at the proxy's confirmation await, `claim` server-side via
181
185
  * the active lease on the entity).
182
186
  */
183
187
  export type WriteOptions = Pick<MutationOptions, 'readAt' | 'onStale' | 'idempotencyKey' | 'label'>;
@@ -15,5 +15,5 @@
15
15
  * };
16
16
  * ```
17
17
  */
18
- export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, IntentHeldConflict, } from './types.js';
18
+ export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from './types.js';
19
19
  export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
@@ -2,12 +2,12 @@
2
2
  * Conflict policy — the engine detects, the policy decides.
3
3
  *
4
4
  * Two conflict shapes today: `stale_context` (a write whose `readAt`
5
- * is older than the latest delta on the target) and `intent_held`
5
+ * is older than the latest delta on the target) and `claim_held`
6
6
  * (a participant claims a target someone else is already claiming).
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  import type { ParticipantRef } from '../types/streams.js';
10
- export type ConflictKind = 'stale_context' | 'intent_held';
10
+ export type ConflictKind = 'stale_context' | 'claim_held';
11
11
  /** Fields shared by every conflict shape. */
12
12
  interface ConflictBase {
13
13
  readonly committer: ParticipantRef;
@@ -40,19 +40,19 @@ export interface StaleContextConflict extends ConflictBase {
40
40
  */
41
41
  readonly conflictingFields?: readonly string[];
42
42
  }
43
- export interface IntentHeldConflict extends ConflictBase {
44
- readonly kind: 'intent_held';
43
+ export interface ClaimHeldConflict extends ConflictBase {
44
+ readonly kind: 'claim_held';
45
45
  readonly heldBy: ParticipantRef;
46
- readonly intentId: string;
46
+ readonly claimId: string;
47
47
  readonly entityType: string;
48
48
  readonly entityId: string;
49
- /** Holder's intent expiry (ms since epoch). */
49
+ /** Holder's claim expiry (ms since epoch). */
50
50
  readonly expiresAt: number;
51
51
  /**
52
52
  * The committer's granted capability operations (the key's allowlist). A
53
53
  * policy is a pure function of the conflict value, so it can only authorize
54
54
  * on what's carried here — this is what lets a policy express "preempt iff
55
- * the committer holds `intent.preempt`" (see `capabilityPreemptPolicy`).
55
+ * the committer holds `claim.preempt`" (see `capabilityPreemptPolicy`).
56
56
  * Empty for a human session with no allowlist.
57
57
  */
58
58
  readonly committerOperations: readonly string[];
@@ -61,7 +61,7 @@ export interface IntentHeldConflict extends ConflictBase {
61
61
  * The discriminated union the policy receives. Switch on `.kind` to
62
62
  * narrow to the variant.
63
63
  */
64
- export type Conflict = StaleContextConflict | IntentHeldConflict;
64
+ export type Conflict = StaleContextConflict | ClaimHeldConflict;
65
65
  /** What the policy returns. */
66
66
  export type ConflictDecision = {
67
67
  readonly action: 'reject';
@@ -72,8 +72,8 @@ export type ConflictDecision = {
72
72
  }
73
73
  /**
74
74
  * Evict the current holder and grant the target to the committer. Only
75
- * meaningful for an `intent_held` conflict at claim time (`intent_begin`):
76
- * the holder receives an `intent_lost` (reason `'preempted'`) and the
75
+ * meaningful for an `claim_held` conflict at claim time (`claim_begin`):
76
+ * the holder receives an `claim_lost` (reason `'preempted'`) and the
77
77
  * preemptor takes the lease, jumping ahead of any FIFO waiters. This is the
78
78
  * authorization seam for preemption — a policy returns `preempt` only for a
79
79
  * committer it deems higher-priority (e.g. a supervisor over its sub-agents,
@@ -100,12 +100,12 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
100
100
  /**
101
101
  * Default: reject every conflict. Safe fallback when no custom policy
102
102
  * is wired — the engine never silently allows a stale or
103
- * intent-conflicting write through.
103
+ * claim-conflicting write through.
104
104
  */
105
105
  export declare const defaultPolicy: ConflictPolicy;
106
106
  /**
107
- * Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
108
- * committer holds the `intent.preempt` operation in its capability allowlist
107
+ * Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
108
+ * committer holds the `claim.preempt` operation in its capability allowlist
109
109
  * (the holder is evicted, the committer takes the lease); everything else falls
110
110
  * back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
111
111
  * global to let a privileged identity jump a held entity without a bespoke
@@ -2,31 +2,31 @@
2
2
  * Conflict policy — the engine detects, the policy decides.
3
3
  *
4
4
  * Two conflict shapes today: `stale_context` (a write whose `readAt`
5
- * is older than the latest delta on the target) and `intent_held`
5
+ * is older than the latest delta on the target) and `claim_held`
6
6
  * (a participant claims a target someone else is already claiming).
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  /**
10
10
  * Default: reject every conflict. Safe fallback when no custom policy
11
11
  * is wired — the engine never silently allows a stale or
12
- * intent-conflicting write through.
12
+ * claim-conflicting write through.
13
13
  */
14
14
  export const defaultPolicy = (conflict) => ({
15
15
  action: 'reject',
16
- reason: conflict.kind === 'stale_context' ? 'stale_context' : 'intent_conflict',
16
+ reason: conflict.kind === 'stale_context' ? 'stale_context' : 'claim_conflict',
17
17
  });
18
18
  /**
19
- * Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
20
- * committer holds the `intent.preempt` operation in its capability allowlist
19
+ * Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
20
+ * committer holds the `claim.preempt` operation in its capability allowlist
21
21
  * (the holder is evicted, the committer takes the lease); everything else falls
22
22
  * back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
23
23
  * global to let a privileged identity jump a held entity without a bespoke
24
24
  * policy. The authorization is the capability, not an identity string.
25
25
  */
26
26
  export const capabilityPreemptPolicy = (conflict) => {
27
- if (conflict.kind === 'intent_held' &&
28
- conflict.committerOperations.includes('intent.preempt')) {
29
- return { action: 'preempt', reason: 'capability:intent.preempt' };
27
+ if (conflict.kind === 'claim_held' &&
28
+ conflict.committerOperations.includes('claim.preempt')) {
29
+ return { action: 'preempt', reason: 'capability:claim.preempt' };
30
30
  }
31
31
  return defaultPolicy(conflict);
32
32
  };
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { SchemaRecord } from '../schema/schema.js';
3
3
  import { Ablo } from '../client/Ablo.js';
4
- import type { ActiveIntent, Peer } from '../types/streams.js';
4
+ import type { ActiveClaim, Peer } from '../types/streams.js';
5
5
  import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
7
  /**
@@ -128,6 +128,29 @@ export interface UseParticipantOptions {
128
128
  readonly autoRefreshThresholdSeconds?: number | null;
129
129
  /** Tear down + don't re-join while true. */
130
130
  readonly paused?: boolean;
131
+ /**
132
+ * Acquire a write-claim CLAIM on the scope, in addition to read interest.
133
+ *
134
+ * Default `false`: opening a scope subscribes the connection to its deltas
135
+ * (read interest, via `update_subscription`) but does NOT claim it — a
136
+ * viewer is not a claimant. Set `true` when the participant intends to
137
+ * WRITE (editing a deck, an agent staking work): the claim is sent so peers
138
+ * observe it, and the scope is pinned so it stays subscribed (never warms)
139
+ * for as long as the claim is held.
140
+ */
141
+ readonly claim?: boolean;
142
+ /**
143
+ * Backfill the scope's CURRENT state into the pool on enter, in addition to
144
+ * tailing live changes.
145
+ *
146
+ * Default `false`: entering a scope subscribes to its FUTURE deltas only — if
147
+ * the scope's rows aren't already loaded, the view is empty until something
148
+ * changes. Set `true` when opening an entity that may not be loaded yet (a
149
+ * deep-linked deck, a never-opened sheet) so its current rows are fetched and
150
+ * injected once, then kept fresh by the live tail. The fetch is single-flight
151
+ * and runs once per group; a failure soft-fails (the live tail still flows).
152
+ */
153
+ readonly hydrate?: boolean;
131
154
  }
132
155
  /** @deprecated Use `ParticipantStatus`. */
133
156
  export type MeshParticipantStatus = ParticipantStatus;
@@ -135,8 +158,8 @@ export interface UseParticipantReturn {
135
158
  readonly participant: EngineParticipant | null;
136
159
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
137
160
  readonly peers: ReadonlyArray<Peer>;
138
- /** Active intent claims by peers (`participant.intents.others`), bridged to React. */
139
- readonly claims: ReadonlyArray<ActiveIntent>;
161
+ /** Active claim claims by peers (`participant.claims.others`), bridged to React. */
162
+ readonly claims: ReadonlyArray<ActiveClaim>;
140
163
  readonly status: ParticipantStatus;
141
164
  readonly error: Error | null;
142
165
  }
@@ -146,11 +169,35 @@ export interface UseParticipantReturn {
146
169
  * flips to true.
147
170
  *
148
171
  * The returned `participant` is an `EngineParticipant` — `.presence`
149
- * + `.intents` only — backed by the engine's existing socket. For
172
+ * + `.claims` only — backed by the engine's existing socket. For
150
173
  * headless-bot patterns (a separate identity in the same browser
151
174
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
152
175
  */
153
176
  export declare function useParticipant(opts: UseParticipantOptions): UseParticipantReturn;
177
+ /**
178
+ * Read-only presence: the OTHER participants currently visible to this
179
+ * connection, bridged to React. Unlike {@link useParticipant}, this does
180
+ * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
181
+ * it is a pure reader of the engine's already-flowing presence stream.
182
+ *
183
+ * Pass `scope` to narrow to the peers on that scope's sync group(s); omit
184
+ * it to get everyone on the engine's groups. Membership is driven entirely
185
+ * by the presence channel (set server-side on connect, independent of any
186
+ * cursor/collaboration traffic), so reading it never affects what the
187
+ * connection is subscribed to and can't deadlock against a gated channel.
188
+ *
189
+ * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
190
+ * broadcasts while alone — when some OTHER mount already owns the scope's
191
+ * read interest (scope `leave` is not reference-counted, so a second
192
+ * `useParticipant` on the same scope would warm-drop the owner's
193
+ * subscription on unmount).
194
+ *
195
+ * ```ts
196
+ * const peers = usePeers({ slideDecks: deckId });
197
+ * const alone = !peers.some((p) => p.participantKind === 'user');
198
+ * ```
199
+ */
200
+ export declare function usePeers(scope?: ParticipantScope): ReadonlyArray<Peer>;
154
201
  /**
155
202
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
156
203
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
@@ -205,7 +205,7 @@ const EMPTY_INTENTS = Object.freeze([]);
205
205
  * flips to true.
206
206
  *
207
207
  * The returned `participant` is an `EngineParticipant` — `.presence`
208
- * + `.intents` only — backed by the engine's existing socket. For
208
+ * + `.claims` only — backed by the engine's existing socket. For
209
209
  * headless-bot patterns (a separate identity in the same browser
210
210
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
211
211
  */
@@ -224,18 +224,21 @@ export function useParticipant(opts) {
224
224
  // Reference-stable participant facade — same socket as entity sync,
225
225
  // so there is no `connect()` / `disconnect()` lifecycle here. The
226
226
  // engine manages the connection; the hook is a thin window onto its
227
- // already-attached presence + intent streams.
227
+ // already-attached presence + claim streams.
228
228
  const participant = useMemo(() => {
229
229
  if (!engine)
230
230
  return null;
231
- return { presence: engine.presence, intents: engine.intents };
231
+ return { presence: engine.presence, claims: engine.claims };
232
232
  }, [engine]);
233
233
  // Status maps to the engine's sync state. `connecting` while the
234
234
  // engine bootstraps; `connected` once `engine.ready()` resolves and
235
235
  // any scoped participant claim has acked; `error` if the claim
236
236
  // fails; `disconnected` while paused or before the engine exists.
237
237
  const syncStatus = useSyncStatus();
238
- const needsClaim = scopedSyncGroups.length > 0;
238
+ // Only a write-claim participant waits on a claim ack. A pure reader
239
+ // (the default) is `connected` as soon as the engine is — its read
240
+ // interest is fire-and-forget `update_subscription`, not a claim.
241
+ const needsClaim = !!opts.claim && scopedSyncGroups.length > 0;
239
242
  const status = paused || !engine
240
243
  ? 'disconnected'
241
244
  : claimError
@@ -248,16 +251,45 @@ export function useParticipant(opts) {
248
251
  ? 'disconnected'
249
252
  : 'connecting';
250
253
  const error = claimError;
254
+ // ── Read interest (always) ───────────────────────────────────────
255
+ // Subscribe the connection to the scope's sync groups while mounted +
256
+ // connected — the area-of-interest navigation primitive. No claim, no
257
+ // TTL: a viewer just receives the scope's deltas. Hysteresis (warm TTL)
258
+ // lives in the store's AreaOfInterestManager, so a quick unmount/remount
259
+ // (tab flip) doesn't re-bootstrap.
260
+ useEffect(() => {
261
+ const scope = opts.scope;
262
+ if (paused || !engine || !scope || scopedSyncGroups.length === 0)
263
+ return;
264
+ if (syncStatus.name !== 'connected')
265
+ return;
266
+ const store = engine._store;
267
+ // `hydrate` backfills the scope's current state after subscribing
268
+ // (store handles subscribe-first ordering + single-flight). leaveScope
269
+ // only moves read interest; the hydrated rows stay in the pool.
270
+ void store.enterScope?.(scope, { hydrate: opts.hydrate });
271
+ return () => {
272
+ void store.leaveScope?.(scope);
273
+ };
274
+ // scopeKey is the stable proxy for the resolved groups; same idiom as
275
+ // the claim effect below.
276
+ }, [engine, paused, scopeKey, syncStatus.name, opts.hydrate]);
277
+ // ── Write claim (opt-in: `claim: true`) ─────────────────────────
278
+ // A claim is the write-claim primitive — distinct from read interest
279
+ // above. Only sent when the caller opts in; it makes peers observe the
280
+ // claim and pins the scope so it never warms while held.
251
281
  useEffect(() => {
252
282
  setClaimError(null);
253
283
  setClaimConnected(false);
254
- if (paused || !engine || scopedSyncGroups.length === 0)
284
+ const scope = opts.scope;
285
+ if (paused || !engine || !opts.claim || !scope || scopedSyncGroups.length === 0)
255
286
  return;
256
287
  if (syncStatus.name !== 'connected')
257
288
  return;
258
289
  const ws = engine._ws;
259
290
  if (!ws)
260
291
  return;
292
+ const store = engine._store;
261
293
  let cancelled = false;
262
294
  const claimId = createParticipantClaimId();
263
295
  ws.sendClaim(claimId, scopedSyncGroups, {
@@ -272,12 +304,15 @@ export function useParticipant(opts) {
272
304
  setClaimError(err instanceof Error ? err : new Error(String(err)));
273
305
  }
274
306
  });
307
+ // Prominence: hold the scope subscribed for as long as the claim lives.
308
+ void store.pinScope?.(scope);
275
309
  return () => {
276
310
  cancelled = true;
277
311
  ws.sendRelease(claimId);
312
+ void store.unpinScope?.(scope);
278
313
  };
279
- }, [engine, paused, scopeKey, syncStatus.name, opts.ttlSeconds]);
280
- // Bridge the engine's presence + intents streams into React state.
314
+ }, [engine, paused, scopeKey, syncStatus.name, opts.ttlSeconds, opts.claim]);
315
+ // Bridge the engine's presence + claims streams into React state.
281
316
  // Plain useState + useEffect is sufficient — mid-frame tearing on a
282
317
  // peer list is harmless (users won't notice one frame of stale
283
318
  // presence). Queries and sync status use useSyncExternalStore
@@ -291,16 +326,16 @@ export function useParticipant(opts) {
291
326
  return;
292
327
  }
293
328
  setPeers(participant.presence.others);
294
- setClaims(participant.intents.others);
329
+ setClaims(participant.claims.others);
295
330
  const unsubPresence = participant.presence.onChange(() => {
296
331
  setPeers(participant.presence.others);
297
332
  });
298
- const unsubIntents = participant.intents.onChange(() => {
299
- setClaims(participant.intents.others);
333
+ const unsubClaims = participant.claims.onChange(() => {
334
+ setClaims(participant.claims.others);
300
335
  });
301
336
  return () => {
302
337
  unsubPresence();
303
- unsubIntents();
338
+ unsubClaims();
304
339
  };
305
340
  }, [participant, paused]);
306
341
  // `opts.as`, `opts.agent`, `opts.idempotencyKey`, and
@@ -309,6 +344,55 @@ export function useParticipant(opts) {
309
344
  // active: it opens a multiplexed claim on the engine WebSocket.
310
345
  return { participant, peers, claims, status, error };
311
346
  }
347
+ /**
348
+ * Read-only presence: the OTHER participants currently visible to this
349
+ * connection, bridged to React. Unlike {@link useParticipant}, this does
350
+ * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
351
+ * it is a pure reader of the engine's already-flowing presence stream.
352
+ *
353
+ * Pass `scope` to narrow to the peers on that scope's sync group(s); omit
354
+ * it to get everyone on the engine's groups. Membership is driven entirely
355
+ * by the presence channel (set server-side on connect, independent of any
356
+ * cursor/collaboration traffic), so reading it never affects what the
357
+ * connection is subscribed to and can't deadlock against a gated channel.
358
+ *
359
+ * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
360
+ * broadcasts while alone — when some OTHER mount already owns the scope's
361
+ * read interest (scope `leave` is not reference-counted, so a second
362
+ * `useParticipant` on the same scope would warm-drop the owner's
363
+ * subscription on unmount).
364
+ *
365
+ * ```ts
366
+ * const peers = usePeers({ slideDecks: deckId });
367
+ * const alone = !peers.some((p) => p.participantKind === 'user');
368
+ * ```
369
+ */
370
+ export function usePeers(scope) {
371
+ const ctx = useContext(AbloInternalContext);
372
+ const engine = ctx?.engine ?? null;
373
+ // Resolve scope → groups through the schema (same idiom as useParticipant).
374
+ // The stringified, sorted key is the stable effect dependency.
375
+ const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
376
+ const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
377
+ const [peers, setPeers] = useState(EMPTY_PRESENCE);
378
+ useEffect(() => {
379
+ if (!engine) {
380
+ setPeers(EMPTY_PRESENCE);
381
+ return;
382
+ }
383
+ const presence = engine.presence;
384
+ const compute = () => groups.length === 0
385
+ ? presence.others
386
+ : presence.others.filter((p) => p.syncGroups.some((g) => groups.includes(g)));
387
+ // Plain useState + onChange — presence changes on join/leave/activity
388
+ // only (never on cursor traffic, a separate channel), so this fires
389
+ // rarely; a frame of stale presence is harmless (same rationale as
390
+ // useParticipant's peers bridge).
391
+ setPeers(compute());
392
+ return presence.onChange(() => setPeers(compute()));
393
+ }, [engine, scopeKey]);
394
+ return peers;
395
+ }
312
396
  // ── Escape-hatches: raw engine/store access ──────────────────────────
313
397
  /**
314
398
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
@@ -5,6 +5,7 @@ import type { QueryView, QueryViewOptions } from '../core/QueryView.js';
5
5
  import type { ViewRegistry } from '../core/ViewRegistry.js';
6
6
  import type { Schema } from '../schema/schema.js';
7
7
  import type { SyncStatus } from '../BaseSyncedStore.js';
8
+ import type { ParticipantScope } from '../sync/participants.js';
8
9
  /**
9
10
  * A single LOCAL mutation as observed off the commit stream — the substrate
10
11
  * the undo system records from. One is emitted per local create/update/
@@ -93,6 +94,22 @@ export interface SyncStoreContract {
93
94
  readonly isReconnecting: boolean;
94
95
  readonly isError: boolean;
95
96
  readonly hasUnsyncedChanges: boolean;
97
+ /**
98
+ * Area-of-interest (dynamic read subscription). `enterScope`/`leaveScope`
99
+ * move the connection's read interest as the user navigates (open/close a
100
+ * deck, sheet, doc); `pinScope`/`unpinScope` express prominence (an active
101
+ * claim keeps a group subscribed). Each resolves the scope through the same
102
+ * resolver the claim path uses, so read interest and write claims agree on
103
+ * the sync-group string. Optional so minimal test doubles can omit them;
104
+ * no-ops before the socket exists. The concrete store (`BaseSyncedStore`)
105
+ * forwards to its `AreaOfInterestManager`.
106
+ */
107
+ enterScope?(scope: ParticipantScope, opts?: {
108
+ hydrate?: boolean;
109
+ }): Promise<void>;
110
+ leaveScope?(scope: ParticipantScope): Promise<void>;
111
+ pinScope?(scope: ParticipantScope): Promise<void>;
112
+ unpinScope?(scope: ParticipantScope): Promise<void>;
96
113
  /**
97
114
  * Raw MobX-observable `SyncStatus` record. `useSyncStatus()` reads
98
115
  * `state`, `progress`, `pendingChanges`, `isSessionError`, `error`
@@ -131,13 +148,13 @@ export interface SyncReactContext {
131
148
  */
132
149
  presence?: unknown;
133
150
  /**
134
- * Optional intent initiator. Same pattern as presence — consumers
135
- * plug a function that turns an intent claim into a handle they
151
+ * Optional claim initiator. Same pattern as presence — consumers
152
+ * plug a function that turns an claim claim into a handle they
136
153
  * control (WebSocket send, optimistic local update, whatever).
137
- * `useIntent(name)` returns a typed invoker for the named intent
138
- * from `interface Register { Intents: ... }`.
154
+ * `useClaim(name)` returns a typed invoker for the named claim
155
+ * from `interface Register { Claims: ... }`.
139
156
  */
140
- beginIntent?: (intentName: string, claim: unknown) => unknown;
157
+ beginClaim?: (claimName: string, claim: unknown) => unknown;
141
158
  }
142
159
  export declare const SyncContext: import("react").Context<SyncReactContext | null>;
143
160
  /**
@@ -168,10 +185,10 @@ export interface SyncProviderProps {
168
185
  */
169
186
  presence?: unknown;
170
187
  /**
171
- * Optional intent initiator for `useIntent()`. See
172
- * {@link SyncReactContext.beginIntent}.
188
+ * Optional claim initiator for `useClaim()`. See
189
+ * {@link SyncReactContext.beginClaim}.
173
190
  */
174
- beginIntent?: (intentName: string, claim: unknown) => unknown;
191
+ beginClaim?: (claimName: string, claim: unknown) => unknown;
175
192
  children?: ReactNode;
176
193
  }
177
194
  /**
@@ -189,4 +206,4 @@ export interface SyncProviderProps {
189
206
  * );
190
207
  * }
191
208
  */
192
- export declare function SyncProvider({ store, organizationId, schema, presence, beginIntent, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
209
+ export declare function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
@@ -30,6 +30,6 @@ export function useSyncContext() {
30
30
  * );
31
31
  * }
32
32
  */
33
- export function SyncProvider({ store, organizationId, schema, presence, beginIntent, children, }) {
34
- return createElement(SyncContext.Provider, { value: { store, organizationId, schema, presence, beginIntent } }, children);
33
+ export function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }) {
34
+ return createElement(SyncContext.Provider, { value: { store, organizationId, schema, presence, beginClaim } }, children);
35
35
  }