@abloatai/ablo 0.5.1 → 0.7.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 (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tenancy — the single source of truth for how a model's rows are scoped to a
3
+ * tenant. This replaces three scattered mechanisms (a hardcoded
4
+ * `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
5
+ * one Zod discriminated union, resolved in one place and consumed everywhere
6
+ * (provision/RLS, introspection, runtime, CLI).
7
+ *
8
+ * Why a union: every consumer used to re-derive "how is this table scoped?" from
9
+ * a flag plus a literal — a missed branch was a silent cross-tenant scoping bug.
10
+ * A discriminated union makes the `switch` exhaustive, so the type system holds
11
+ * the isolation boundary, and the physical column name lives in exactly one
12
+ * place (the `column` variant) instead of being hardcoded across the codebase.
13
+ */
14
+ import { z } from 'zod';
15
+ /** Default physical tenancy column. The ONLY place this literal is canonical. */
16
+ export const DEFAULT_ORG_COLUMN = 'organization_id';
17
+ /**
18
+ * Scope a table's rows through a parent table (for rows that carry no tenancy
19
+ * column of their own — e.g. `slide_layers` → slide → deck → org).
20
+ */
21
+ export const scopedViaRefSchema = z.object({
22
+ /** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
23
+ localKey: z.string().min(1),
24
+ /** Parent table name (e.g. `'team'`). */
25
+ parentTable: z.string().min(1),
26
+ /** Column on the parent that `localKey` references. Default `'id'`. */
27
+ parentKey: z.string().min(1).optional(),
28
+ /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
29
+ parentOrgColumn: z.string().min(1).optional(),
30
+ });
31
+ /** How a model's rows are scoped to a tenant. */
32
+ export const tenancySchema = z.discriminatedUnion('kind', [
33
+ /** Row-local tenancy column (default name `organization_id`, overridable). */
34
+ z.object({ kind: z.literal('column'), column: z.string().min(1) }),
35
+ /** Scoped through a parent table's tenancy. */
36
+ z.object({ kind: z.literal('parent'), via: scopedViaRefSchema }),
37
+ /** Not tenant-scoped (global / reference data). */
38
+ z.object({ kind: z.literal('none') }),
39
+ ]);
40
+ /**
41
+ * Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
42
+ * at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
43
+ * `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
44
+ * column (default or `orgColumn`).
45
+ */
46
+ export function resolveTenancy(input) {
47
+ if (input.tenancy)
48
+ return input.tenancy;
49
+ if (input.scopedVia)
50
+ return { kind: 'parent', via: input.scopedVia };
51
+ if (input.orgScoped === false)
52
+ return { kind: 'none' };
53
+ return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
54
+ }
55
+ /** The physical tenancy column for a column-scoped model, else `null`. */
56
+ export function tenancyColumn(t) {
57
+ return t.kind === 'column' ? t.column : null;
58
+ }
@@ -113,6 +113,8 @@ export declare class HydrationCoordinator {
113
113
  private hydrateExpanded;
114
114
  private persistToIdb;
115
115
  private resolveTypename;
116
+ private columnizeField;
117
+ private columnizeClause;
116
118
  }
117
119
  /**
118
120
  * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
@@ -161,10 +161,10 @@ export class HydrationCoordinator {
161
161
  const firstOrder = orderEntries[0];
162
162
  const query = {
163
163
  model: typename,
164
- where: clauses.map((c) => columnizeClause(c)),
164
+ where: clauses.map((c) => this.columnizeClause(modelName, c)),
165
165
  ...(firstOrder
166
166
  ? {
167
- orderBy: columnize(firstOrder[0]),
167
+ orderBy: this.columnizeField(modelName, firstOrder[0]),
168
168
  order: firstOrder[1] ?? 'asc',
169
169
  }
170
170
  : {}),
@@ -256,6 +256,27 @@ export class HydrationCoordinator {
256
256
  .models?.[modelName];
257
257
  return def?.typename ?? modelName;
258
258
  }
259
+ columnizeField(modelName, field) {
260
+ const fields = this.opts.schema.models?.[modelName]?.fields;
261
+ if (fields) {
262
+ const direct = fields[field]?.column;
263
+ if (direct)
264
+ return direct;
265
+ for (const [fieldName, meta] of Object.entries(fields)) {
266
+ const conventional = columnize(fieldName);
267
+ if (field === fieldName || field === conventional || field === meta.column) {
268
+ return meta.column ?? conventional;
269
+ }
270
+ }
271
+ }
272
+ return /[A-Z]/.test(field) ? columnize(field) : field;
273
+ }
274
+ columnizeClause(modelName, clause) {
275
+ const finalCol = this.columnizeField(modelName, clause[0]);
276
+ if (clause.length === 2)
277
+ return [finalCol, clause[1]];
278
+ return [finalCol, clause[1], clause[2]];
279
+ }
259
280
  }
260
281
  // ── Helpers ────────────────────────────────────────────────────────────
261
282
  function stableKey(modelName, clauses, orderBy, limit) {
@@ -350,21 +371,6 @@ export function normalizeWhere(where) {
350
371
  }
351
372
  return [];
352
373
  }
353
- /**
354
- * Apply `columnize` to the column name of a wire-bound clause so the
355
- * server sees `slide_id` instead of `slideId`. Tuple-form clauses from
356
- * callers are passed through unchanged — they already supply the wire
357
- * column name (matches what existing `postQuery` consumers do).
358
- */
359
- function columnizeClause(clause) {
360
- const col = clause[0];
361
- // If the column already looks snake_case (no uppercase letters), assume
362
- // the caller is already using server-side naming. Otherwise camelize→snake.
363
- const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
364
- if (clause.length === 2)
365
- return [finalCol, clause[1]];
366
- return [finalCol, clause[1], clause[2]];
367
- }
368
374
  /** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
369
375
  function extractEqClauses(clauses) {
370
376
  const out = {};
@@ -261,6 +261,23 @@ export interface CoreSyncEventMap {
261
261
  * Payload mirrors the wire frame's `payload`.
262
262
  */
263
263
  intent_rejected: [Record<string, unknown>];
264
+ /**
265
+ * Fair-queue frames (opt-in `queue: true` on `intent_begin`). `intent_acquired`
266
+ * means the target was free and the lease is ours immediately; `intent_queued`
267
+ * means the claim is waiting in line (carries `position`); `intent_granted`
268
+ * means it reached the head and the lease is now ours; `intent_lost` means a
269
+ * held/granted claim was taken away (TTL lapse on disconnect, revoke).
270
+ */
271
+ /**
272
+ * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
273
+ * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
274
+ * queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
275
+ */
276
+ intent_queue: [Record<string, unknown>];
277
+ intent_acquired: [Record<string, unknown>];
278
+ intent_queued: [Record<string, unknown>];
279
+ intent_granted: [Record<string, unknown>];
280
+ intent_lost: [Record<string, unknown>];
264
281
  }
265
282
  /**
266
283
  * Collaboration event — app-specific real-time events (selection, cursors, etc.)
@@ -10,7 +10,7 @@
10
10
  import { EventEmitter } from 'events';
11
11
  import { getContext } from '../context.js';
12
12
  import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
- import { CapabilityError, SyncSessionError } from '../errors.js';
13
+ import { AbloClaimedError, CapabilityError, SyncSessionError, } from '../errors.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
16
  // Consumers pass their own event types as TCollaboration generic parameter.
@@ -368,6 +368,24 @@ export class SyncWebSocket extends EventEmitter {
368
368
  errorCode === 'capability_invalid') {
369
369
  pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
370
370
  }
371
+ else if (errorCode === 'intent_conflict' ||
372
+ errorCode === 'claim_conflict' ||
373
+ errorCode === 'entity_claimed') {
374
+ // Claim enforcement: another participant holds a live claim on
375
+ // a targeted entity. Two server layers reject this — the Hub's
376
+ // pre-commit lease check (`intent_conflict`, the code that
377
+ // reaches clients in practice) and `executeCommit`'s deeper
378
+ // guard (`entity_claimed`). Both mean "claimed", so both route
379
+ // through the typed AbloClaimedError, letting callers
380
+ // `instanceof AbloClaimedError` (or read `e.type` across worker
381
+ // boundaries) and wait/bypass — symmetric with the
382
+ // CapabilityError branch above, and with the HTTP commit path
383
+ // (`translateHttpError`).
384
+ pending.reject(new AbloClaimedError(errorMessage, {
385
+ code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
386
+ httpStatus: 409,
387
+ }));
388
+ }
371
389
  else {
372
390
  const rejection = new Error(errorMessage);
373
391
  if (errorCode)
@@ -448,6 +466,33 @@ export class SyncWebSocket extends EventEmitter {
448
466
  this.emit('intent_rejected', message.payload ?? {});
449
467
  break;
450
468
  }
469
+ case 'intent_acquired': {
470
+ // Opt-in fair queue: the target was free, so the lease is ours
471
+ // immediately (no waiting). Payload carries { intentId, target }.
472
+ this.emit('intent_acquired', message.payload ?? {});
473
+ break;
474
+ }
475
+ case 'intent_queue': {
476
+ // Per-entity wait-queue snapshot for reactive `queue(id)`.
477
+ this.emit('intent_queue', message.payload ?? {});
478
+ break;
479
+ }
480
+ case 'intent_queued': {
481
+ // Opt-in fair queue: our claim is waiting in line. Payload
482
+ // carries { intentId, target, position }.
483
+ this.emit('intent_queued', message.payload ?? {});
484
+ break;
485
+ }
486
+ case 'intent_granted': {
487
+ // Our queued claim reached the head — the lease is now ours.
488
+ this.emit('intent_granted', message.payload ?? {});
489
+ break;
490
+ }
491
+ case 'intent_lost': {
492
+ // A held/granted claim was taken from us (TTL lapse, revoke).
493
+ this.emit('intent_lost', message.payload ?? {});
494
+ break;
495
+ }
451
496
  case 'delta': {
452
497
  const p = message.payload;
453
498
  if (p?.actionType || p?.modelName) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `awaitIntentGrant` — the client side of the fair-queue handover.
3
+ *
4
+ * When a `claim` is contended, the server enqueues it and replies `queued`
5
+ * (HTTP 202 on `/v1/intents`, or `intent_queued` over WS). The grant is then
6
+ * PUSHED later over the WS as `intent_granted` when the claim reaches the head.
7
+ * This resolves once that frame arrives for our `intentId` — so the caller's
8
+ * `claim` promise stays pending (event-driven; no poll, no race) until it's
9
+ * actually our turn. Rejects on `intent_lost` (surfaced as `claim_lost`: the claim was taken away — TTL
10
+ * lapse on disconnect, revoke) or an optional timeout.
11
+ *
12
+ * Takes only a minimal `{ subscribe }` transport so it unit-tests against a
13
+ * fake; `SyncWebSocket` satisfies it structurally.
14
+ */
15
+ export interface GrantTransport {
16
+ subscribe(event: 'intent_acquired' | 'intent_granted' | 'intent_lost' | 'intent_queued', handler: (payload: Record<string, unknown>) => void): () => void;
17
+ }
18
+ export declare function awaitIntentGrant(transport: GrantTransport, intentId: string, options?: {
19
+ timeoutMs?: number;
20
+ /**
21
+ * Backpressure: reject instead of waiting if, when we join the line, the
22
+ * server reports `position >= maxQueueDepth` (i.e. that many claims are
23
+ * already ahead of us). Omit to wait however deep the queue is.
24
+ */
25
+ maxQueueDepth?: number;
26
+ }): Promise<void>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `awaitIntentGrant` — the client side of the fair-queue handover.
3
+ *
4
+ * When a `claim` is contended, the server enqueues it and replies `queued`
5
+ * (HTTP 202 on `/v1/intents`, or `intent_queued` over WS). The grant is then
6
+ * PUSHED later over the WS as `intent_granted` when the claim reaches the head.
7
+ * This resolves once that frame arrives for our `intentId` — so the caller's
8
+ * `claim` promise stays pending (event-driven; no poll, no race) until it's
9
+ * actually our turn. Rejects on `intent_lost` (surfaced as `claim_lost`: the claim was taken away — TTL
10
+ * lapse on disconnect, revoke) or an optional timeout.
11
+ *
12
+ * Takes only a minimal `{ subscribe }` transport so it unit-tests against a
13
+ * fake; `SyncWebSocket` satisfies it structurally.
14
+ */
15
+ import { AbloClaimedError } from '../errors.js';
16
+ export function awaitIntentGrant(transport, intentId, options) {
17
+ return new Promise((resolve, reject) => {
18
+ const unsubs = [];
19
+ let timer;
20
+ const settle = (fn) => {
21
+ if (timer)
22
+ clearTimeout(timer);
23
+ for (const u of unsubs)
24
+ u();
25
+ fn();
26
+ };
27
+ const onGrant = (p) => {
28
+ if (p?.intentId === intentId)
29
+ settle(resolve);
30
+ };
31
+ // The target was free → `intent_acquired` (immediate); it was contended,
32
+ // we waited in line, and reached the head → `intent_granted`. Either frame
33
+ // means the lease is now ours, so one await covers both grant paths.
34
+ unsubs.push(transport.subscribe('intent_acquired', onGrant));
35
+ unsubs.push(transport.subscribe('intent_granted', onGrant));
36
+ if (options?.maxQueueDepth !== undefined) {
37
+ const max = options.maxQueueDepth;
38
+ unsubs.push(transport.subscribe('intent_queued', (p) => {
39
+ if (p?.intentId !== intentId)
40
+ return;
41
+ const position = typeof p.position === 'number' ? p.position : 0;
42
+ if (position >= max) {
43
+ settle(() => reject(new AbloClaimedError(`Claim queue for ${intentId} is ${position} deep (max ${max}).`, { code: 'queue_too_deep' })));
44
+ }
45
+ }));
46
+ }
47
+ unsubs.push(transport.subscribe('intent_lost', (p) => {
48
+ if (p?.intentId === intentId) {
49
+ settle(() => reject(new AbloClaimedError(`Claim lost while queued for ${intentId}.`, {
50
+ code: 'claim_lost',
51
+ })));
52
+ }
53
+ }));
54
+ if (options?.timeoutMs && options.timeoutMs > 0) {
55
+ timer = setTimeout(() => {
56
+ settle(() => reject(new AbloClaimedError(`Timed out waiting for the queue grant on claim ${intentId}.`, { code: 'grant_timeout' })));
57
+ }, options.timeoutMs);
58
+ }
59
+ });
60
+ }
@@ -11,7 +11,8 @@
11
11
  * Wire contract (apps/sync-server/src/hub/types.ts):
12
12
  * • Outbound: `{ type: 'intent_begin', payload: { intentId,
13
13
  * entityType, entityId, action, field?, estimatedMs? } }`
14
- * • Outbound: `{ type: 'intent_abandon', payload: { intentId } }`
14
+ * • Outbound: `{ type: 'intent_abandon', payload: { intentId,
15
+ * entityType?, entityId? } }`
15
16
  * • Inbound (via presence): `event.activeIntents: IntentClaim[]`
16
17
  * stamped with `declaredAt`, `expiresAt`.
17
18
  * • Inbound: `intent_rejected` event with conflict metadata.
@@ -11,7 +11,8 @@
11
11
  * Wire contract (apps/sync-server/src/hub/types.ts):
12
12
  * • Outbound: `{ type: 'intent_begin', payload: { intentId,
13
13
  * entityType, entityId, action, field?, estimatedMs? } }`
14
- * • Outbound: `{ type: 'intent_abandon', payload: { intentId } }`
14
+ * • Outbound: `{ type: 'intent_abandon', payload: { intentId,
15
+ * entityType?, entityId? } }`
15
16
  * • Inbound (via presence): `event.activeIntents: IntentClaim[]`
16
17
  * stamped with `declaredAt`, `expiresAt`.
17
18
  * • Inbound: `intent_rejected` event with conflict metadata.
@@ -29,9 +30,16 @@ export function createIntentStream(config, transport = null) {
29
30
  let intentsSnapshot = Object.freeze([]);
30
31
  // ── State: our own open intents (for re-announce on reconnect) ───
31
32
  const ownIntents = new Map();
33
+ // ── State: per-entity wait queues, from `intent_queue` frames ────
34
+ // Keyed `type:id`; the value is the FIFO line of queued intents. Powers
35
+ // the reactive `queue(target)` read — who's waiting and what they intend.
36
+ const queueByEntity = new Map();
37
+ const entityKey = (type, id) => `${type}:${id}`;
38
+ const EMPTY_QUEUE = Object.freeze([]);
32
39
  // ── Subscribers ──────────────────────────────────────────────────
33
40
  const listeners = new Set();
34
41
  const rejectionListeners = new Set();
42
+ const lostListeners = new Set();
35
43
  const notifyListeners = () => {
36
44
  intentsSnapshot = Object.freeze(Array.from(activeByIntentId.values()));
37
45
  for (const l of listeners) {
@@ -125,6 +133,39 @@ export function createIntentStream(config, transport = null) {
125
133
  }
126
134
  }
127
135
  }));
136
+ // (2a) Server-side LOSS frames — you held it, then lost it (preempted /
137
+ // expired). Distinct from a rejection (a claim the server refused).
138
+ unsubs.push(t.subscribe('intent_lost', (payload) => {
139
+ const lost = payload;
140
+ if (!lost.intentId)
141
+ return;
142
+ // Drop the lost own-claim so reconnect doesn't re-announce a lease we
143
+ // no longer hold.
144
+ ownIntents.delete(lost.intentId);
145
+ for (const l of lostListeners) {
146
+ try {
147
+ l(lost);
148
+ }
149
+ catch {
150
+ /* isolate */
151
+ }
152
+ }
153
+ }));
154
+ // (2b) Per-entity wait-queue snapshots. The server fans the full line
155
+ // out on every queue mutation; we replace our cached line for that
156
+ // entity and notify so `queue(target)` reads reactively.
157
+ unsubs.push(t.subscribe('intent_queue', (payload) => {
158
+ const p = payload;
159
+ if (!p.target?.type || !p.target.id)
160
+ return;
161
+ const key = entityKey(p.target.type, p.target.id);
162
+ const line = Array.isArray(p.queue) ? p.queue : [];
163
+ if (line.length === 0)
164
+ queueByEntity.delete(key);
165
+ else
166
+ queueByEntity.set(key, Object.freeze([...line]));
167
+ notifyListeners();
168
+ }));
128
169
  // (3) On reconnect, re-announce every open self-claim — the
129
170
  // server's intent state is in-memory and is lost across
130
171
  // restarts. Without this, peers would see our claims vanish
@@ -153,13 +194,38 @@ export function createIntentStream(config, transport = null) {
153
194
  field: intent.field,
154
195
  meta: intent.meta,
155
196
  estimatedMs: intent.estimatedMs,
197
+ queue: intent.queue,
156
198
  },
157
199
  });
158
200
  }
159
- function sendAbandon(intentId) {
201
+ function sendReorder(entityType, entityId, order) {
160
202
  if (!attached?.isConnected())
161
203
  return;
162
- attached.send({ type: 'intent_abandon', payload: { intentId } });
204
+ attached.send({
205
+ type: 'intent_reorder',
206
+ payload: {
207
+ entityType,
208
+ entityId,
209
+ // The wire shape identifies a waiter by heldBy + intentId; map the
210
+ // ergonomic `Intent[]` (what `queueFor` returns) down to that.
211
+ order: order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })),
212
+ },
213
+ });
214
+ }
215
+ function sendAbandon(intentId, intent) {
216
+ if (!attached?.isConnected())
217
+ return;
218
+ // Carry the target so the server can dequeue us if we were only *waiting*
219
+ // (a queued claim isn't in the holder set it would otherwise scan). Held
220
+ // claims are found by intentId regardless; the target is harmless there.
221
+ attached.send({
222
+ type: 'intent_abandon',
223
+ payload: {
224
+ intentId,
225
+ entityType: intent?.entityType,
226
+ entityId: intent?.entityId,
227
+ },
228
+ });
163
229
  }
164
230
  function mintHandle(args) {
165
231
  const intentId = crypto.randomUUID();
@@ -173,6 +239,7 @@ export function createIntentStream(config, transport = null) {
173
239
  meta: args.meta,
174
240
  action: args.action,
175
241
  estimatedMs,
242
+ queue: args.queue,
176
243
  };
177
244
  ownIntents.set(intentId, intent);
178
245
  sendBegin(intentId, intent);
@@ -182,7 +249,7 @@ export function createIntentStream(config, transport = null) {
182
249
  return;
183
250
  revoked = true;
184
251
  ownIntents.delete(intentId);
185
- sendAbandon(intentId);
252
+ sendAbandon(intentId, intent);
186
253
  };
187
254
  return {
188
255
  id: intentId,
@@ -209,12 +276,21 @@ export function createIntentStream(config, transport = null) {
209
276
  meta: resolved.meta,
210
277
  action: opts?.reason ?? 'editing',
211
278
  ttl: opts?.ttl,
279
+ queue: opts?.queue,
212
280
  });
213
281
  },
214
282
  get others() {
215
283
  return intentsSnapshot;
216
284
  },
217
- subscribe: (listener) => {
285
+ queueFor(target) {
286
+ const ref = resolveTarget(target);
287
+ return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
288
+ },
289
+ reorder(target, order) {
290
+ const ref = resolveTarget(target);
291
+ sendReorder(ref.type, ref.id, order);
292
+ },
293
+ onChange: (listener) => {
218
294
  listeners.add(listener);
219
295
  return () => {
220
296
  listeners.delete(listener);
@@ -226,6 +302,12 @@ export function createIntentStream(config, transport = null) {
226
302
  rejectionListeners.delete(listener);
227
303
  };
228
304
  },
305
+ onLost: (listener) => {
306
+ lostListeners.add(listener);
307
+ return () => {
308
+ lostListeners.delete(listener);
309
+ };
310
+ },
229
311
  [Symbol.asyncIterator]() {
230
312
  return asyncIteratorFrom((onChange) => {
231
313
  listeners.add(onChange);
@@ -241,8 +323,10 @@ export function createIntentStream(config, transport = null) {
241
323
  unsubs.length = 0;
242
324
  listeners.clear();
243
325
  rejectionListeners.clear();
326
+ lostListeners.clear();
244
327
  activeByIntentId.clear();
245
328
  ownIntents.clear();
329
+ queueByEntity.clear();
246
330
  intentsSnapshot = Object.freeze([]);
247
331
  attached = null;
248
332
  },
@@ -164,7 +164,7 @@ export function createPresenceStream(config, transport = null) {
164
164
  return othersSnapshot;
165
165
  },
166
166
  othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
167
- subscribe: (listener) => {
167
+ onChange: (listener) => {
168
168
  listeners.add(listener);
169
169
  return () => {
170
170
  listeners.delete(listener);
@@ -54,7 +54,7 @@ export interface ScopedPresence {
54
54
  editing(detail?: string): void;
55
55
  editing(target: PresenceTarget, detail?: string): void;
56
56
  idle(): void;
57
- subscribe(listener: () => void): () => void;
57
+ onChange(listener: () => void): () => void;
58
58
  }
59
59
  export interface ScopedClaimOptions {
60
60
  /** Override the participant's focus target for this one claim. */
@@ -76,7 +76,7 @@ export interface ScopedIntents {
76
76
  */
77
77
  claim(opts?: ScopedClaimOptions): Claim;
78
78
  onRejected(listener: Parameters<IntentStream['onRejected']>[0]): () => void;
79
- subscribe(listener: () => void): () => void;
79
+ onChange(listener: () => void): () => void;
80
80
  }
81
81
  export interface ParticipantFocusOptions {
82
82
  readonly activity?: 'reading' | 'viewing' | 'editing' | false;
@@ -1,3 +1,4 @@
1
+ import { scopeKindOf } from '../schema/model.js';
1
2
  export function createParticipantManager(config) {
2
3
  return {
3
4
  async join(input, overrides) {
@@ -74,17 +75,13 @@ export function resolveParticipantSyncGroups(scope, schema) {
74
75
  }
75
76
  export function syncGroupFromEntityRef(ref, schema) {
76
77
  const match = findModelForEntityRef(ref, schema);
77
- if (match?.def.syncGroupFormat) {
78
- return renderSyncGroupFormat(match.def.syncGroupFormat, { id: ref.id });
79
- }
80
- return `${ref.type.toLowerCase()}:${ref.id}`;
78
+ const kind = match ? scopeKindOf(match.def, match.key) : undefined;
79
+ return `${kind ?? ref.type.toLowerCase()}:${ref.id}`;
81
80
  }
82
81
  function syncGroupFromSchemaKey(schemaKey, id, schema) {
83
82
  const def = schema?.models?.[schemaKey];
84
- if (def?.syncGroupFormat) {
85
- return renderSyncGroupFormat(def.syncGroupFormat, { id });
86
- }
87
- return `${schemaKey}:${id}`;
83
+ const kind = def ? scopeKindOf(def, schemaKey) : undefined;
84
+ return `${kind ?? schemaKey}:${id}`;
88
85
  }
89
86
  function findModelForEntityRef(ref, schema) {
90
87
  if (!schema?.models)
@@ -98,12 +95,6 @@ function findModelForEntityRef(ref, schema) {
98
95
  }
99
96
  return null;
100
97
  }
101
- function renderSyncGroupFormat(format, values) {
102
- return format.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, key) => {
103
- const value = values[key];
104
- return value === undefined ? `{${key}}` : value;
105
- });
106
- }
107
98
  export function parseParticipantTtlSeconds(value) {
108
99
  if (typeof value === 'number' && Number.isFinite(value))
109
100
  return value;
@@ -218,8 +209,8 @@ function createJoinedParticipant(args) {
218
209
  idle() {
219
210
  args.presence.idle();
220
211
  },
221
- subscribe(listener) {
222
- return args.presence.subscribe(listener);
212
+ onChange(listener) {
213
+ return args.presence.onChange(listener);
223
214
  },
224
215
  };
225
216
  const track = (handle) => {
@@ -252,8 +243,8 @@ function createJoinedParticipant(args) {
252
243
  onRejected(listener) {
253
244
  return args.intents.onRejected(listener);
254
245
  },
255
- subscribe(listener) {
256
- return args.intents.subscribe(listener);
246
+ onChange(listener) {
247
+ return args.intents.onChange(listener);
257
248
  },
258
249
  };
259
250
  const leave = () => {