@abloatai/ablo 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +242 -135
  3. package/dist/BaseSyncedStore.d.ts +2 -2
  4. package/dist/BaseSyncedStore.js +2 -2
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +90 -93
  8. package/dist/client/Ablo.js +121 -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 +90 -87
  14. package/dist/client/createModelProxy.js +124 -127
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +3 -3
  18. package/dist/core/index.d.ts +2 -0
  19. package/dist/core/index.js +7 -0
  20. package/dist/errors.d.ts +8 -8
  21. package/dist/errors.js +18 -10
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +7 -11
  24. package/dist/interfaces/index.d.ts +2 -10
  25. package/dist/mutators/Transaction.d.ts +2 -2
  26. package/dist/mutators/Transaction.js +2 -2
  27. package/dist/mutators/mutateActions.d.ts +44 -0
  28. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  29. package/dist/mutators/readerActions.d.ts +32 -0
  30. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  31. package/dist/query/types.d.ts +1 -1
  32. package/dist/react/AbloProvider.d.ts +1 -1
  33. package/dist/react/AbloProvider.js +3 -3
  34. package/dist/react/context.d.ts +4 -4
  35. package/dist/react/index.d.ts +4 -5
  36. package/dist/react/index.js +3 -7
  37. package/dist/react/useAblo.d.ts +14 -14
  38. package/dist/react/useAblo.js +26 -26
  39. package/dist/react/useIntent.d.ts +2 -2
  40. package/dist/react/useIntent.js +2 -2
  41. package/dist/react/useMutators.d.ts +1 -1
  42. package/dist/react/usePresence.d.ts +3 -3
  43. package/dist/react/usePresence.js +4 -4
  44. package/dist/react/useUndoScope.d.ts +1 -1
  45. package/dist/schema/diff.d.ts +161 -0
  46. package/dist/schema/diff.js +262 -0
  47. package/dist/schema/generate.d.ts +19 -0
  48. package/dist/schema/generate.js +87 -0
  49. package/dist/schema/index.d.ts +4 -1
  50. package/dist/schema/index.js +7 -1
  51. package/dist/schema/schema.d.ts +83 -32
  52. package/dist/schema/schema.js +58 -12
  53. package/dist/schema/serialize.d.ts +92 -0
  54. package/dist/schema/serialize.js +227 -0
  55. package/dist/sync/SyncWebSocket.d.ts +17 -0
  56. package/dist/sync/SyncWebSocket.js +46 -1
  57. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  58. package/dist/sync/awaitIntentGrant.js +60 -0
  59. package/dist/sync/createIntentStream.js +43 -4
  60. package/dist/sync/createPresenceStream.js +1 -1
  61. package/dist/sync/participants.d.ts +2 -2
  62. package/dist/sync/participants.js +4 -4
  63. package/dist/types/global.d.ts +43 -52
  64. package/dist/types/global.js +16 -18
  65. package/dist/types/streams.d.ts +37 -9
  66. package/docs/api.md +68 -158
  67. package/docs/audit.md +5 -5
  68. package/docs/client-behavior.md +41 -42
  69. package/docs/coordination.md +294 -0
  70. package/docs/data-sources.md +14 -14
  71. package/docs/examples/agent-human.md +30 -32
  72. package/docs/examples/ai-sdk-tool.md +32 -33
  73. package/docs/examples/existing-python-backend.md +35 -33
  74. package/docs/examples/nextjs.md +24 -25
  75. package/docs/examples/server-agent.md +20 -61
  76. package/docs/guarantees.md +30 -55
  77. package/docs/identity.md +458 -0
  78. package/docs/index.md +12 -24
  79. package/docs/integration-guide.md +106 -116
  80. package/docs/interaction-model.md +29 -95
  81. package/docs/mcp/claude-code.md +3 -3
  82. package/docs/mcp/cursor.md +1 -1
  83. package/docs/mcp/windsurf.md +1 -1
  84. package/docs/mcp.md +11 -26
  85. package/docs/quickstart.md +43 -49
  86. package/docs/react.md +73 -23
  87. package/docs/roadmap.md +5 -7
  88. package/llms.txt +34 -39
  89. package/package.json +1 -1
  90. package/dist/react/useMutate.d.ts +0 -83
  91. package/dist/react/useQuery.d.ts +0 -123
  92. package/dist/react/useQuery.js +0 -145
  93. package/dist/react/useReader.d.ts +0 -69
  94. package/docs/capabilities.md +0 -163
@@ -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
+ }
@@ -29,6 +29,12 @@ export function createIntentStream(config, transport = null) {
29
29
  let intentsSnapshot = Object.freeze([]);
30
30
  // ── State: our own open intents (for re-announce on reconnect) ───
31
31
  const ownIntents = new Map();
32
+ // ── State: per-entity wait queues, from `intent_queue` frames ────
33
+ // Keyed `type:id`; the value is the FIFO line of queued intents. Powers
34
+ // the reactive `queue(target)` read — who's waiting and what they intend.
35
+ const queueByEntity = new Map();
36
+ const entityKey = (type, id) => `${type}:${id}`;
37
+ const EMPTY_QUEUE = Object.freeze([]);
32
38
  // ── Subscribers ──────────────────────────────────────────────────
33
39
  const listeners = new Set();
34
40
  const rejectionListeners = new Set();
@@ -125,6 +131,21 @@ export function createIntentStream(config, transport = null) {
125
131
  }
126
132
  }
127
133
  }));
134
+ // (2b) Per-entity wait-queue snapshots. The server fans the full line
135
+ // out on every queue mutation; we replace our cached line for that
136
+ // entity and notify so `queue(target)` reads reactively.
137
+ unsubs.push(t.subscribe('intent_queue', (payload) => {
138
+ const p = payload;
139
+ if (!p.target?.type || !p.target.id)
140
+ return;
141
+ const key = entityKey(p.target.type, p.target.id);
142
+ const line = Array.isArray(p.queue) ? p.queue : [];
143
+ if (line.length === 0)
144
+ queueByEntity.delete(key);
145
+ else
146
+ queueByEntity.set(key, Object.freeze([...line]));
147
+ notifyListeners();
148
+ }));
128
149
  // (3) On reconnect, re-announce every open self-claim — the
129
150
  // server's intent state is in-memory and is lost across
130
151
  // restarts. Without this, peers would see our claims vanish
@@ -153,13 +174,24 @@ export function createIntentStream(config, transport = null) {
153
174
  field: intent.field,
154
175
  meta: intent.meta,
155
176
  estimatedMs: intent.estimatedMs,
177
+ queue: intent.queue,
156
178
  },
157
179
  });
158
180
  }
159
- function sendAbandon(intentId) {
181
+ function sendAbandon(intentId, intent) {
160
182
  if (!attached?.isConnected())
161
183
  return;
162
- attached.send({ type: 'intent_abandon', payload: { intentId } });
184
+ // Carry the target so the server can dequeue us if we were only *waiting*
185
+ // (a queued claim isn't in the holder set it would otherwise scan). Held
186
+ // claims are found by intentId regardless; the target is harmless there.
187
+ attached.send({
188
+ type: 'intent_abandon',
189
+ payload: {
190
+ intentId,
191
+ entityType: intent?.entityType,
192
+ entityId: intent?.entityId,
193
+ },
194
+ });
163
195
  }
164
196
  function mintHandle(args) {
165
197
  const intentId = crypto.randomUUID();
@@ -173,6 +205,7 @@ export function createIntentStream(config, transport = null) {
173
205
  meta: args.meta,
174
206
  action: args.action,
175
207
  estimatedMs,
208
+ queue: args.queue,
176
209
  };
177
210
  ownIntents.set(intentId, intent);
178
211
  sendBegin(intentId, intent);
@@ -182,7 +215,7 @@ export function createIntentStream(config, transport = null) {
182
215
  return;
183
216
  revoked = true;
184
217
  ownIntents.delete(intentId);
185
- sendAbandon(intentId);
218
+ sendAbandon(intentId, intent);
186
219
  };
187
220
  return {
188
221
  id: intentId,
@@ -209,12 +242,17 @@ export function createIntentStream(config, transport = null) {
209
242
  meta: resolved.meta,
210
243
  action: opts?.reason ?? 'editing',
211
244
  ttl: opts?.ttl,
245
+ queue: opts?.queue,
212
246
  });
213
247
  },
214
248
  get others() {
215
249
  return intentsSnapshot;
216
250
  },
217
- subscribe: (listener) => {
251
+ queueFor(target) {
252
+ const ref = resolveTarget(target);
253
+ return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
254
+ },
255
+ onChange: (listener) => {
218
256
  listeners.add(listener);
219
257
  return () => {
220
258
  listeners.delete(listener);
@@ -243,6 +281,7 @@ export function createIntentStream(config, transport = null) {
243
281
  rejectionListeners.clear();
244
282
  activeByIntentId.clear();
245
283
  ownIntents.clear();
284
+ queueByEntity.clear();
246
285
  intentsSnapshot = Object.freeze([]);
247
286
  attached = null;
248
287
  },
@@ -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;
@@ -218,8 +218,8 @@ function createJoinedParticipant(args) {
218
218
  idle() {
219
219
  args.presence.idle();
220
220
  },
221
- subscribe(listener) {
222
- return args.presence.subscribe(listener);
221
+ onChange(listener) {
222
+ return args.presence.onChange(listener);
223
223
  },
224
224
  };
225
225
  const track = (handle) => {
@@ -252,8 +252,8 @@ function createJoinedParticipant(args) {
252
252
  onRejected(listener) {
253
253
  return args.intents.onRejected(listener);
254
254
  },
255
- subscribe(listener) {
256
- return args.intents.subscribe(listener);
255
+ onChange(listener) {
256
+ return args.intents.onChange(listener);
257
257
  },
258
258
  };
259
259
  const leave = () => {
@@ -1,27 +1,25 @@
1
1
  /**
2
- * Typed-global augmentation point for SDK consumers.
2
+ * Type registration point for SDK consumers.
3
3
  *
4
- * Consumers declare their Schema, Presence, Intents, and UserMeta ONCE in a
5
- * `.d.ts` file and every SDK hook — `useQuery`, `useOne`, `useMutate`,
6
- * `usePresence`, `useIntent` — reads its types from the resolved global.
7
- * No generics at call sites. No `schema` runtime arg passed per hook call.
4
+ * A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
5
+ * augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
6
+ * `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
7
+ * resolved registration. No generics at call sites, no `schema` arg per call.
8
8
  *
9
- * This is the same canonical TypeScript declaration-merging pattern that
10
- * Next.js uses for `process.env` / `NodeJS.ProcessEnv`, that CSS Modules
11
- * use for `declare module '*.module.css'`, and that Liveblocks uses for
12
- * `interface Liveblocks`. It's a language feature, not a library trick
13
- * any file in the compilation can augment the global `AbloSync` interface
14
- * and every consumer of the resolved types below picks up the augmentation
15
- * automatically.
9
+ * Registration is done via **module augmentation** of `@abloatai/ablo`
10
+ * the same pattern TanStack Router uses for its `Register` interface. The brand
11
+ * lives in the module specifier, so the interface is just `Register` (not a
12
+ * global, not prefixed). It's a language feature, not a library trick: any file
13
+ * in the compilation can augment it and every resolver below picks it up.
16
14
  *
17
15
  * Consumer example:
18
16
  *
19
17
  * ```ts
20
- * // apps/your-app/src/ablo-sync.d.ts
18
+ * // apps/your-app/src/ablo.d.ts
21
19
  * import type { schema } from './your-schema';
22
20
  *
23
- * declare global {
24
- * interface AbloSync {
21
+ * declare module '@abloatai/ablo' {
22
+ * interface Register {
25
23
  * Schema: typeof schema;
26
24
  * Presence: { cursor: { x: number; y: number } | null };
27
25
  * Intents: { editLayer: { layerId: string } };
@@ -31,17 +29,15 @@
31
29
  * export {};
32
30
  * ```
33
31
  *
34
- * If `AbloSync` is never declared, every resolver falls back to the
35
- * `DefaultSyncShape` — a loose `Record<string, unknown>` shape that keeps
36
- * SDK consumers compiling without typed benefits until they opt in.
32
+ * If `Register` is never augmented, every resolver falls back to
33
+ * {@link DefaultSyncShape} — a loose shape that keeps consumers compiling
34
+ * without typed benefits until they opt in.
37
35
  */
38
36
  /**
39
- * Default fallback shapes used when the consumer hasn't declared
40
- * `interface AbloSync`. `DefaultSyncShape.Schema` is intentionally
41
- * structural — it carries `{ models: Record<string, unknown> }` so hooks
42
- * can still validate the model key argument against *something*, just
43
- * without producing a typed entity shape. Once the consumer augments the
44
- * global, every resolver below picks up the augmented types automatically.
37
+ * Default fallback shapes used when the consumer hasn't augmented
38
+ * {@link Register}. `DefaultSyncShape.Schema` is intentionally structural — it
39
+ * carries `{ models: Record<string, unknown> }` so hooks can still validate the
40
+ * model key argument against *something*, just without a typed entity shape.
45
41
  */
46
42
  export interface DefaultSyncShape {
47
43
  readonly Schema: {
@@ -53,54 +49,49 @@ export interface DefaultSyncShape {
53
49
  readonly id: string;
54
50
  };
55
51
  }
56
- declare global {
57
- /**
58
- * Global augmentation target. Consumers augment this via
59
- * `declare global { interface AbloSync { Schema: ...; Presence: ...; ... } }`.
60
- * Empty by default every SDK resolver falls back to {@link DefaultSyncShape}
61
- * when an expected key is absent.
62
- */
63
- interface AbloSync {
64
- }
52
+ /**
53
+ * The registration interface. Consumers augment it via
54
+ * `declare module '@abloatai/ablo' { interface Register { Schema: ...; … } }`.
55
+ * Empty by default every SDK resolver falls back to {@link DefaultSyncShape}
56
+ * when an expected key is absent. Exported from the package root so the module
57
+ * augmentation merges into this declaration.
58
+ */
59
+ export interface Register {
65
60
  }
66
61
  /**
67
- * The consumer's schema, or the default shape if undeclared. Hooks use
68
- * this to type their model-key argument and to infer the entity type
69
- * returned from queries/mutations.
62
+ * The consumer's schema, or the default shape if unregistered. Hooks use this
63
+ * to type their model-key argument and infer the entity type returned.
70
64
  */
71
- export type ResolveSchema = AbloSync extends {
65
+ export type ResolveSchema = Register extends {
72
66
  Schema: infer S;
73
67
  } ? S extends {
74
68
  models: Record<string, unknown>;
75
69
  } ? S : DefaultSyncShape['Schema'] : DefaultSyncShape['Schema'];
76
70
  /**
77
- * The consumer's presence shape, or the default shape if undeclared.
78
- * Used by `usePresence`. The shape is free-form — any serializable JSON
79
- * the consumer wants to broadcast per session.
71
+ * The consumer's presence shape, or the default if unregistered. Used by
72
+ * `usePresence`. Free-form — any serializable JSON broadcast per session.
80
73
  */
81
- export type ResolvePresence = AbloSync extends {
74
+ export type ResolvePresence = Register extends {
82
75
  Presence: infer P;
83
76
  } ? P : DefaultSyncShape['Presence'];
84
77
  /**
85
- * The consumer's intent vocabulary, or the default if undeclared. Keys
86
- * are intent names; values are the claim payload for each intent. Used
87
- * by `useIntent(intentName)`.
78
+ * The consumer's intent vocabulary, or the default if unregistered. Keys are
79
+ * intent names; values are the claim payload for each intent. Used by
80
+ * `useIntent(intentName)`.
88
81
  */
89
- export type ResolveIntents = AbloSync extends {
82
+ export type ResolveIntents = Register extends {
90
83
  Intents: infer I;
91
84
  } ? I : DefaultSyncShape['Intents'];
92
85
  /**
93
- * The consumer's user-metadata shape, or the default if undeclared.
94
- * Carries identity info the consumer trusts from their auth layer —
95
- * the SDK doesn't validate this.
86
+ * The consumer's user-metadata shape, or the default if unregistered. Carries
87
+ * identity info the consumer trusts from their auth layer — not SDK-validated.
96
88
  */
97
- export type ResolveUserMeta = AbloSync extends {
89
+ export type ResolveUserMeta = Register extends {
98
90
  UserMeta: infer U;
99
91
  } ? U : DefaultSyncShape['UserMeta'];
100
92
  /**
101
- * The keys of the consumer's schema models. `useQuery(modelKey)` narrows
102
- * its first argument to this union, so unknown key literals fail at
103
- * compile time.
93
+ * The keys of the consumer's schema models. `useQuery(modelKey)` narrows its
94
+ * first argument to this union, so unknown key literals fail at compile time.
104
95
  */
105
96
  export type ResolveModelKey = ResolveSchema extends {
106
97
  models: infer M;
@@ -1,27 +1,25 @@
1
1
  /**
2
- * Typed-global augmentation point for SDK consumers.
2
+ * Type registration point for SDK consumers.
3
3
  *
4
- * Consumers declare their Schema, Presence, Intents, and UserMeta ONCE in a
5
- * `.d.ts` file and every SDK hook — `useQuery`, `useOne`, `useMutate`,
6
- * `usePresence`, `useIntent` — reads its types from the resolved global.
7
- * No generics at call sites. No `schema` runtime arg passed per hook call.
4
+ * A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
5
+ * augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
6
+ * `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
7
+ * resolved registration. No generics at call sites, no `schema` arg per call.
8
8
  *
9
- * This is the same canonical TypeScript declaration-merging pattern that
10
- * Next.js uses for `process.env` / `NodeJS.ProcessEnv`, that CSS Modules
11
- * use for `declare module '*.module.css'`, and that Liveblocks uses for
12
- * `interface Liveblocks`. It's a language feature, not a library trick
13
- * any file in the compilation can augment the global `AbloSync` interface
14
- * and every consumer of the resolved types below picks up the augmentation
15
- * automatically.
9
+ * Registration is done via **module augmentation** of `@abloatai/ablo`
10
+ * the same pattern TanStack Router uses for its `Register` interface. The brand
11
+ * lives in the module specifier, so the interface is just `Register` (not a
12
+ * global, not prefixed). It's a language feature, not a library trick: any file
13
+ * in the compilation can augment it and every resolver below picks it up.
16
14
  *
17
15
  * Consumer example:
18
16
  *
19
17
  * ```ts
20
- * // apps/your-app/src/ablo-sync.d.ts
18
+ * // apps/your-app/src/ablo.d.ts
21
19
  * import type { schema } from './your-schema';
22
20
  *
23
- * declare global {
24
- * interface AbloSync {
21
+ * declare module '@abloatai/ablo' {
22
+ * interface Register {
25
23
  * Schema: typeof schema;
26
24
  * Presence: { cursor: { x: number; y: number } | null };
27
25
  * Intents: { editLayer: { layerId: string } };
@@ -31,8 +29,8 @@
31
29
  * export {};
32
30
  * ```
33
31
  *
34
- * If `AbloSync` is never declared, every resolver falls back to the
35
- * `DefaultSyncShape` — a loose `Record<string, unknown>` shape that keeps
36
- * SDK consumers compiling without typed benefits until they opt in.
32
+ * If `Register` is never augmented, every resolver falls back to
33
+ * {@link DefaultSyncShape} — a loose shape that keeps consumers compiling
34
+ * without typed benefits until they opt in.
37
35
  */
38
36
  export {};
@@ -175,7 +175,7 @@ export interface PresenceStream {
175
175
  /**
176
176
  * Reactive view of every OTHER participant's current activity on
177
177
  * this participant's sync groups. Reads return the current snapshot;
178
- * pair with `subscribe(listener)` below to get notified on changes.
178
+ * pair with `onChange(listener)` below to get notified on changes.
179
179
  *
180
180
  * An LLM pipeline can include `presence.others` in its system prompt
181
181
  * so the model literally reasons with knowledge of what other
@@ -209,7 +209,7 @@ export interface PresenceStream {
209
209
  * });
210
210
  * ```
211
211
  */
212
- subscribe(listener: () => void): () => void;
212
+ onChange(listener: () => void): () => void;
213
213
  /**
214
214
  * Async-iterable view of the peer roster. Each iteration yields the
215
215
  * current `others` snapshot on every mutation — so the consumer
@@ -371,6 +371,15 @@ export interface ClaimOptions extends IntentOptions {
371
371
  * app-specific phases.
372
372
  */
373
373
  readonly reason?: string;
374
+ /**
375
+ * Join the server's fair FIFO queue on contention instead of being
376
+ * rejected. The grant arrives asynchronously (`intent_acquired` if the
377
+ * target was free, `intent_granted` once promoted to the head of the line).
378
+ * The low-level `claim` returns its handle immediately regardless; callers
379
+ * that need to *wait* for the grant use the awaiting wrappers
380
+ * (`ablo.<model>.claim`), which pair this flag with `awaitIntentGrant`.
381
+ */
382
+ readonly queue?: boolean;
374
383
  }
375
384
  export interface IntentStream {
376
385
  /**
@@ -394,6 +403,14 @@ export interface IntentStream {
394
403
  * below to get notified on change.
395
404
  */
396
405
  readonly others: ReadonlyArray<ActiveIntent>;
406
+ /**
407
+ * Reactive view of the wait queue on one target — the FIFO line of
408
+ * `status: 'queued'` intents behind the current holder, each with its
409
+ * `action`, `heldBy`, and `position`. Synced from the server's per-entity
410
+ * `intent_queue` frame; empty when no one's waiting. Pair with
411
+ * `subscribe(...)` for change notifications.
412
+ */
413
+ queueFor(target: PresenceTarget): readonly Intent[];
397
414
  /**
398
415
  * Framework-agnostic reactivity. Same contract as
399
416
  * `PresenceStream.subscribe` — register a listener fired on every
@@ -401,7 +418,7 @@ export interface IntentStream {
401
418
  * returns an unsubscribe fn. Use `useSyncExternalStore` in React or
402
419
  * `autorun` in MobX.
403
420
  */
404
- subscribe(listener: () => void): () => void;
421
+ onChange(listener: () => void): () => void;
405
422
  /**
406
423
  * Observe server-side intent rejections. Fires when the server
407
424
  * rejects an `intents.writing(...)` / `announce(...)` call because
@@ -494,8 +511,13 @@ export interface ActiveIntent extends IntentDeclaration {
494
511
  readonly announcedAt: string;
495
512
  readonly expiresAt: string;
496
513
  }
497
- /** Every lifecycle state of a coordination intent, in one enum. */
498
- export type IntentStatus = 'active' | 'committed' | 'expired' | 'canceled';
514
+ /**
515
+ * Every lifecycle state of a coordination intent, in one enum.
516
+ * `active` = the current holder (the lock). `queued` = waiting in the FIFO
517
+ * line behind the holder (carries `position`). The terminal states drop the
518
+ * intent from the synced set.
519
+ */
520
+ export type IntentStatus = 'active' | 'queued' | 'committed' | 'expired' | 'canceled';
499
521
  /** Options for waiting on a target to become free. */
500
522
  export interface IntentWaitOptions {
501
523
  readonly timeout?: number;
@@ -509,10 +531,11 @@ export interface IntentWaitOptions {
509
531
  *
510
532
  * Deliberately omits a Stripe-style `next_action`: a contender's only
511
533
  * response is "wait until free, then re-read", and the runtime performs
512
- * that uniformly at the tool boundary (`IntentHandle.whenFree()` + the
513
- * stale-context guard that forces a re-read). Encoding a constant
514
- * instruction the engine always takes would be the kind of ceremony this
515
- * object exists to remove.
534
+ * that uniformly `claim` serializes behind the holder via the server
535
+ * FIFO queue (or low-level `intents.waitFor` to wait without claiming), and the
536
+ * stale-context guard forces the re-read. Encoding a constant instruction
537
+ * the engine always takes would be the kind of ceremony this object exists
538
+ * to remove.
516
539
  */
517
540
  export interface Intent {
518
541
  readonly object: 'intent';
@@ -532,4 +555,9 @@ export interface Intent {
532
555
  readonly createdAt?: string;
533
556
  /** Ms-epoch the server auto-expires it if the holder doesn't finish. */
534
557
  readonly expiresAt: string;
558
+ /**
559
+ * 0-based place in the FIFO line — present only when `status: 'queued'`
560
+ * (`0` = next in line behind the holder). Absent for the active holder.
561
+ */
562
+ readonly position?: number;
535
563
  }