@abloatai/ablo 0.5.1 → 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 +16 -0
  2. package/README.md +217 -122
  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
@@ -2,10 +2,10 @@
2
2
  * Stateless API client for `Ablo({ apiKey })`.
3
3
  *
4
4
  * This is the hosted-API product surface: no schema, no object pool, no
5
- * IndexedDB, no WebSocket. It maps the public Resource / Intent / Commit
5
+ * IndexedDB, no WebSocket. It maps the public Model / Claim / Commit
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
- import { AbloBusyError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, } from '../errors.js';
8
+ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, } from '../errors.js';
9
9
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, } from './auth.js';
10
10
  import { toSeconds } from '../utils/duration.js';
11
11
  const DEFAULT_AGENT_LEASE = '10m';
@@ -87,7 +87,7 @@ export function createProtocolClient(options) {
87
87
  ? `int_${crypto.randomUUID()}`
88
88
  : `int_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
89
89
  }
90
- function createResourceId() {
90
+ function createModelId() {
91
91
  return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
92
92
  ? crypto.randomUUID()
93
93
  : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
@@ -163,19 +163,25 @@ export function createProtocolClient(options) {
163
163
  return {
164
164
  task,
165
165
  ablo: agentClient,
166
- resource(name) {
167
- return createAgentResourceClient(agentClient, name);
166
+ model(name) {
167
+ return createAgentModelClient(agentClient, name);
168
168
  },
169
169
  };
170
170
  }
171
- function createAgentResourceClient(agentClient, name) {
172
- const base = agentClient.resource(name);
171
+ function createAgentModelClient(agentClient, name) {
172
+ const base = agentClient.model(name);
173
173
  return {
174
174
  retrieve(id, options) {
175
- return base.retrieve(id, withAgentBusyDefault(options));
175
+ // Reads are never blocked by a claim (coordination.md): a claim
176
+ // serializes WRITERS, not readers. So — unlike the create/update/
177
+ // delete paths below — retrieve does NOT apply the agent claimed
178
+ // default; options pass through and the read path's `'return'`
179
+ // default keeps a claimed row readable. A caller can still opt into
180
+ // gating with an explicit `ifClaimed` (developer's choice).
181
+ return base.retrieve(id, options);
176
182
  },
177
183
  create(data, mutationOptions) {
178
- const id = mutationOptions?.id ?? createResourceId();
184
+ const id = mutationOptions?.id ?? createModelId();
179
185
  return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.create(data, {
180
186
  ...stripAgentRuntimeOptions(mutationOptions),
181
187
  id,
@@ -196,20 +202,20 @@ export function createProtocolClient(options) {
196
202
  },
197
203
  };
198
204
  }
199
- async function withAgentIntent(agentClient, resourceName, id, mutationOptions, commit) {
205
+ async function withAgentIntent(agentClient, modelName, id, mutationOptions, commit) {
200
206
  const intentInput = mutationOptions?.intent;
201
207
  const targetOverride = intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput)
202
208
  ? intentInput.target ?? {}
203
209
  : {};
204
210
  const target = {
205
211
  ...targetOverride,
206
- resource: targetOverride.resource ?? resourceName,
212
+ model: targetOverride.model ?? modelName,
207
213
  id: targetOverride.id ?? id,
208
214
  ...(intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput) && intentInput.field
209
215
  ? { field: intentInput.field }
210
216
  : {}),
211
217
  };
212
- await applyBusyPolicy(target, withAgentBusyDefault(mutationOptions), 'wait');
218
+ await applyClaimedPolicy(target, withAgentClaimedDefault(mutationOptions), 'wait');
213
219
  if (intentInput == null || isIntentHandleRef(intentInput)) {
214
220
  return commit(intentInput);
215
221
  }
@@ -229,14 +235,14 @@ export function createProtocolClient(options) {
229
235
  }
230
236
  }
231
237
  function normalizeCommitOperation(op, defaults) {
232
- const resource = op.resource ?? op.target?.resource;
233
- if (!resource) {
234
- throw new AbloValidationError('Commit operation requires `resource` or `target.resource`.', { code: 'commit_operation_resource_required' });
238
+ const model = op.model ?? op.target?.model;
239
+ if (!model) {
240
+ throw new AbloValidationError('Commit operation requires `model` or `target.model`.', { code: 'commit_operation_model_required' });
235
241
  }
236
242
  const id = op.id ?? op.target?.id ?? null;
237
243
  return {
238
244
  action: op.action,
239
- resource,
245
+ model,
240
246
  id,
241
247
  data: op.data ?? null,
242
248
  transactionId: op.transactionId ?? null,
@@ -257,24 +263,31 @@ export function createProtocolClient(options) {
257
263
  return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
258
264
  }
259
265
  async function listIntents(target) {
266
+ const state = await listClaimState(target);
267
+ return state.active;
268
+ }
269
+ async function listClaimState(target) {
260
270
  const params = new URLSearchParams();
261
- if (target?.resource)
262
- params.set('resource', target.resource);
271
+ if (target?.model)
272
+ params.set('model', target.model);
263
273
  if (target?.id)
264
274
  params.set('id', target.id);
265
275
  if (target?.field)
266
276
  params.set('field', target.field);
267
277
  const suffix = params.toString();
268
278
  const body = await requestJson(`/v1/intents${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
269
- return body.intents ?? [];
279
+ return {
280
+ active: body.intents ?? [],
281
+ queue: body.queue ?? [],
282
+ };
270
283
  }
271
- function busyError(target, intents, code) {
272
- const label = [target.resource, target.id, target.field].filter(Boolean).join('/');
273
- const holder = intents[0];
284
+ function claimedError(target, claims, code) {
285
+ const label = [target.model, target.id, target.field].filter(Boolean).join('/');
286
+ const holder = claims[0];
274
287
  const suffix = holder
275
288
  ? ` held by ${holder.actor} (${holder.action})`
276
289
  : ' held by another participant';
277
- return new AbloBusyError(`Resource is busy: ${label || 'target'}${suffix}.`, { code, intents });
290
+ return new AbloClaimedError(`Model row is claimed: ${label || 'target'}${suffix}.`, { code, claims });
278
291
  }
279
292
  function delay(ms, signal) {
280
293
  if (signal?.aborted) {
@@ -311,12 +324,12 @@ export function createProtocolClient(options) {
311
324
  if (intents.length === 0)
312
325
  return;
313
326
  if (pollInterval == null) {
314
- throw new AbloValidationError('Cannot wait for intents over the schema-less HTTP client without `pollInterval`. ' +
315
- 'Use the schema client for event-driven intent waits, pass `ifBusy: "return"`, ' +
327
+ throw new AbloValidationError('Cannot wait for claims over the HTTP client without `pollInterval`. ' +
328
+ 'Use the schema client for event-driven claim waits, pass `ifClaimed: "return"`, ' +
316
329
  'or provide an explicit poll interval for this runtime.', { code: 'intent_wait_poll_interval_required' });
317
330
  }
318
331
  if (options?.timeout != null && Date.now() - startedAt >= options.timeout) {
319
- throw busyError(target, intents, 'resource_busy_timeout');
332
+ throw claimedError(target, intents, 'model_claimed_timeout');
320
333
  }
321
334
  const remaining = options?.timeout == null
322
335
  ? pollInterval
@@ -324,18 +337,23 @@ export function createProtocolClient(options) {
324
337
  await delay(remaining, options?.signal);
325
338
  }
326
339
  }
327
- async function applyBusyPolicy(target, options, defaultPolicy = 'return') {
328
- const policy = options?.ifBusy ?? defaultPolicy;
340
+ async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
341
+ const policy = options?.ifClaimed ?? defaultPolicy;
329
342
  if (policy === 'return')
330
343
  return;
331
- const intents = await listIntents(target);
332
- if (intents.length === 0)
344
+ const state = await listClaimState(target);
345
+ if (state.active.length === 0)
333
346
  return;
334
- if (policy === 'fail')
335
- throw busyError(target, intents, 'resource_busy');
347
+ if (policy === 'fail') {
348
+ throw claimedError(target, state.active, 'model_claimed');
349
+ }
350
+ if (options?.maxQueueDepth !== undefined &&
351
+ state.queue.length >= options.maxQueueDepth) {
352
+ throw claimedError(target, state.active, 'queue_too_deep');
353
+ }
336
354
  await waitForNoIntents(target, {
337
- timeout: options?.busyTimeout,
338
- pollInterval: options?.busyPollInterval,
355
+ timeout: options?.claimedTimeout,
356
+ pollInterval: options?.claimedPollInterval,
339
357
  });
340
358
  }
341
359
  const commits = {
@@ -480,8 +498,19 @@ export function createProtocolClient(options) {
480
498
  target: intentOptions.target,
481
499
  action: intentOptions.action,
482
500
  ttl: intentOptions.ttl,
501
+ queue: intentOptions.queue,
483
502
  }),
484
503
  });
504
+ // The fair-queue grant is PUSHED over a WebSocket (`intent_granted`),
505
+ // which this stateless HTTP client doesn't hold. Returning a handle here
506
+ // would be a phantom holder — a lease we can't confirm is ours. So a
507
+ // queued response is surfaced as a typed claimed signal; callers that need
508
+ // to *wait* in line use the realtime (WS-backed) `ablo.<model>.claim`.
509
+ if (body.status === 'queued') {
510
+ throw new AbloClaimedError(`Target ${intentOptions.target.model}/${intentOptions.target.id} is held; ` +
511
+ `queued at position ${body.position ?? 0}. The HTTP client can't await ` +
512
+ `the grant (no socket) — use the realtime client to wait in line.`, { code: 'intent_queued' });
513
+ }
485
514
  const id = body.intent?.id ?? intentId;
486
515
  let released = false;
487
516
  const release = async () => {
@@ -504,40 +533,39 @@ export function createProtocolClient(options) {
504
533
  return waitForNoIntents(target, options);
505
534
  },
506
535
  };
507
- async function retrieveResource(resourceName, id, options) {
508
- await applyBusyPolicy({ resource: resourceName, id }, options);
509
- const query = await requestJson(`/v1/resources/${encodeURIComponent(resourceName)}/${encodeURIComponent(id)}`, {
536
+ async function retrieveModel(modelName, id, options) {
537
+ await applyClaimedPolicy({ model: modelName, id }, options);
538
+ const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(id)}`, {
510
539
  method: 'GET',
511
540
  });
512
541
  const data = query.data;
513
542
  if (!data) {
514
- throw new AbloValidationError(`Resource not found: ${resourceName}/${id}`, { code: 'resource_not_found' });
543
+ throw new AbloValidationError(`Model row not found: ${modelName}/${id}`, { code: 'model_not_found' });
515
544
  }
516
545
  return {
517
546
  data,
518
547
  stamp: query.stamp ?? 0,
519
- intents: query.intents ?? [],
548
+ claims: query.claims ?? [],
520
549
  };
521
550
  }
522
- function resource(name) {
551
+ function model(name) {
523
552
  return {
524
553
  retrieve(id, options) {
525
- return retrieveResource(name, id, options);
554
+ return retrieveModel(name, id, options);
526
555
  },
527
556
  async create(data, mutationOptions) {
528
- const id = mutationOptions?.id ?? createResourceId();
529
- await applyBusyPolicy({ resource: name, id }, mutationOptions);
557
+ const id = mutationOptions?.id ?? createModelId();
558
+ await applyClaimedPolicy({ model: name, id }, mutationOptions);
530
559
  return commits.create({
531
560
  intent: mutationOptions?.intent,
532
561
  idempotencyKey: mutationOptions?.idempotencyKey,
533
562
  readAt: mutationOptions?.readAt,
534
563
  onStale: mutationOptions?.onStale,
535
564
  wait: mutationOptions?.wait,
536
- timeout: mutationOptions?.timeout,
537
565
  operations: [
538
566
  {
539
567
  action: 'create',
540
- resource: name,
568
+ model: name,
541
569
  id,
542
570
  data,
543
571
  },
@@ -545,18 +573,17 @@ export function createProtocolClient(options) {
545
573
  });
546
574
  },
547
575
  async update(id, data, mutationOptions) {
548
- await applyBusyPolicy({ resource: name, id }, mutationOptions);
576
+ await applyClaimedPolicy({ model: name, id }, mutationOptions);
549
577
  return commits.create({
550
578
  intent: mutationOptions?.intent,
551
579
  idempotencyKey: mutationOptions?.idempotencyKey,
552
580
  readAt: mutationOptions?.readAt,
553
581
  onStale: mutationOptions?.onStale,
554
582
  wait: mutationOptions?.wait,
555
- timeout: mutationOptions?.timeout,
556
583
  operations: [
557
584
  {
558
585
  action: 'update',
559
- resource: name,
586
+ model: name,
560
587
  id,
561
588
  data,
562
589
  },
@@ -564,18 +591,17 @@ export function createProtocolClient(options) {
564
591
  });
565
592
  },
566
593
  async delete(id, mutationOptions) {
567
- await applyBusyPolicy({ resource: name, id }, mutationOptions);
594
+ await applyClaimedPolicy({ model: name, id }, mutationOptions);
568
595
  return commits.create({
569
596
  intent: mutationOptions?.intent,
570
597
  idempotencyKey: mutationOptions?.idempotencyKey,
571
598
  readAt: mutationOptions?.readAt,
572
599
  onStale: mutationOptions?.onStale,
573
600
  wait: mutationOptions?.wait,
574
- timeout: mutationOptions?.timeout,
575
601
  operations: [
576
602
  {
577
603
  action: 'delete',
578
- resource: name,
604
+ model: name,
579
605
  id,
580
606
  },
581
607
  ],
@@ -592,7 +618,7 @@ export function createProtocolClient(options) {
592
618
  tasks,
593
619
  intents,
594
620
  commits,
595
- resource,
621
+ model,
596
622
  agent: createAgent,
597
623
  async beginTurn(turnOptions) {
598
624
  const task = await tasks.create(turnOptions);
@@ -620,16 +646,16 @@ function normalizeIntentId(intent) {
620
646
  return intent;
621
647
  return intent?.id;
622
648
  }
623
- function withAgentBusyDefault(options) {
649
+ function withAgentClaimedDefault(options) {
624
650
  return {
625
- ifBusy: 'fail',
651
+ ifClaimed: 'fail',
626
652
  ...(options ?? {}),
627
653
  };
628
654
  }
629
655
  function stripAgentRuntimeOptions(options) {
630
656
  if (!options)
631
657
  return undefined;
632
- const { intent: _intent, ifBusy: _ifBusy, busyTimeout: _busyTimeout, busyPollInterval: _busyPollInterval, ...rest } = options;
658
+ const { intent: _intent, ifClaimed: _ifClaimed, claimedTimeout: _claimedTimeout, claimedPollInterval: _claimedPollInterval, maxQueueDepth: _maxQueueDepth, ...rest } = options;
633
659
  return rest;
634
660
  }
635
661
  function isIntentHandleRef(input) {
@@ -7,9 +7,8 @@
7
7
  * the previous one, so the construction order matters; isolating it
8
8
  * here means `Ablo.ts` doesn't need to know the dependency order.
9
9
  *
10
- * Mirrors the pattern Anthropic uses: their client constructor
11
- * imports each `Resource` class and wires it. Ours wires the
12
- * sync-engine components instead.
10
+ * Mirrors the pattern Anthropic uses: their client constructor wires
11
+ * endpoint modules. Ours wires the sync-engine components instead.
13
12
  */
14
13
  import { Database } from '../Database.js';
15
14
  import { ModelRegistry } from '../ModelRegistry.js';
@@ -7,9 +7,8 @@
7
7
  * the previous one, so the construction order matters; isolating it
8
8
  * here means `Ablo.ts` doesn't need to know the dependency order.
9
9
  *
10
- * Mirrors the pattern Anthropic uses: their client constructor
11
- * imports each `Resource` class and wires it. Ours wires the
12
- * sync-engine components instead.
10
+ * Mirrors the pattern Anthropic uses: their client constructor wires
11
+ * endpoint modules. Ours wires the sync-engine components instead.
13
12
  */
14
13
  import { Database } from '../Database.js';
15
14
  import { ModelRegistry, setActiveRegistry } from '../ModelRegistry.js';
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Per-model resource factory.
2
+ * Per-model client factory.
3
3
  *
4
- * Mirrors Anthropic SDK's `resources/messages.ts` / `resources/models.ts`
5
- * pattern: each resource has its own file, the client just instantiates
4
+ * Mirrors Anthropic SDK's per-endpoint module pattern: each model client
5
+ * has its own file, and the root client just instantiates
6
6
  * one per model. Extracted from `Ablo.ts` so the proxy logic is
7
7
  * testable in isolation and the constructor doesn't carry it.
8
8
  *
9
9
  * Each schema model gets one `ModelOperations<T, CreateInput>` —
10
10
  * exposes `retrieve`, `list`, `count`, `create`, `update`, `delete`,
11
- * `intent` (the coordination handle), `subscribe`, and `load`. The
12
- * factory returns a plain object; the client assembles the
11
+ * `claim`, `claimState`, `queue`, `release`, `subscribe`, and `load`.
12
+ * The factory returns a plain object; the client assembles the
13
13
  * `ablo.<model>` lookup table from these.
14
14
  */
15
15
  import type { MutationOptions } from '../interfaces/index.js';
@@ -19,12 +19,12 @@ import type { SyncClient } from '../SyncClient.js';
19
19
  import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
20
20
  import type { LoadWhere } from '../query/types.js';
21
21
  import { ModelScope } from '../types/index.js';
22
- import type { Duration, Intent, IntentStatus, IntentWaitOptions, Snapshot } from '../types/streams.js';
23
- export interface ModelResourceMeta {
22
+ import type { Duration, Intent, IntentWaitOptions, Snapshot } from '../types/streams.js';
23
+ export interface ModelClientMeta {
24
24
  readonly key: string;
25
25
  readonly typename: string;
26
26
  }
27
- export declare function getModelResourceMeta(resource: unknown): ModelResourceMeta | undefined;
27
+ export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
28
28
  export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
29
29
  export interface ModelListOptions<T> {
30
30
  where?: Partial<T>;
@@ -74,12 +74,20 @@ export interface IntentLeaseHandle {
74
74
  export interface ModelCollaboration<T> {
75
75
  createIntent(options: {
76
76
  target: {
77
- resource: string;
77
+ model: string;
78
78
  id: string;
79
79
  field?: string;
80
80
  };
81
81
  action: string;
82
82
  ttl?: Duration;
83
+ /**
84
+ * Block on the server's fair FIFO queue when the target is held, rather
85
+ * than failing. Resolves only once the lease is genuinely ours (the head
86
+ * of the line). `takeClaim` sets this so writers serialize on contention.
87
+ */
88
+ queue?: boolean;
89
+ /** Reject (don't wait) if the queue is already this deep when we join. */
90
+ maxQueueDepth?: number;
83
91
  }): Promise<IntentLeaseHandle>;
84
92
  createSnapshot(modelKey: string, id: string): Snapshot;
85
93
  /**
@@ -90,16 +98,24 @@ export interface ModelCollaboration<T> {
90
98
  * hold it" from "someone else holds it").
91
99
  */
92
100
  observe(target: {
93
- resource: string;
101
+ model: string;
94
102
  id: string;
95
103
  }): Intent | null;
104
+ /**
105
+ * The reactive wait queue on a target — the FIFO line of queued intents
106
+ * behind the holder. Synchronous snapshot off the synced intent stream.
107
+ */
108
+ queue(target: {
109
+ model: string;
110
+ id: string;
111
+ }): readonly Intent[];
96
112
  /**
97
113
  * Resolve once no participant holds an active intent on the target.
98
114
  * The contender's "wait until it's free" — delegates to the intent
99
115
  * stream's `waitFor`.
100
116
  */
101
117
  waitFor(target: {
102
- resource: string;
118
+ model: string;
103
119
  id: string;
104
120
  }, options?: IntentWaitOptions): Promise<void>;
105
121
  /**
@@ -108,80 +124,45 @@ export interface ModelCollaboration<T> {
108
124
  */
109
125
  readonly selfParticipantId: string;
110
126
  }
111
- /** Options for acquiring a per-model coordination intent. */
112
- export interface ModelIntentAcquireOptions {
113
- /** Phase shown to others while held. Defaults to `'editing'`. */
127
+ /** Options for `claim(id, …)`. */
128
+ export interface ClaimOptions {
129
+ /** Phase shown to observers while held. Defaults to `'editing'`. */
114
130
  action?: string;
115
- /** Field-level target for busy badges. */
131
+ /** Field-level target, for fine-grained claimed-state badges. */
116
132
  field?: string;
117
- /** Lease duration; runtime death is cleaned up by TTL. */
133
+ /** Crash-cleanup TTL the claim auto-releases if the holder dies. */
118
134
  ttl?: Duration;
119
- /** Default wait mode for `handle.update(...)`. Defaults to `confirmed`. */
120
- wait?: MutationOptions['wait'];
135
+ /**
136
+ * On contention: `true` (default) queues behind the current holder and
137
+ * resolves once it's yours (claim-or-wait). `false` is fail-fast — if
138
+ * another participant already holds the row, reject immediately with
139
+ * `AbloClaimedError` instead of waiting (claim-or-skip). Use `false` for
140
+ * work-distribution dedup ("if someone else has this job, skip it") where
141
+ * waiting would mean double-processing.
142
+ */
143
+ wait?: boolean;
144
+ /**
145
+ * Backpressure: willing to queue, but not behind too many. If the server
146
+ * reports `position >= maxQueueDepth` when we join the line, reject with
147
+ * `AbloClaimedError('queue_too_deep')` instead of waiting. Omit to wait
148
+ * however deep the queue is.
149
+ */
150
+ maxQueueDepth?: number;
121
151
  }
122
152
  /**
123
- * Per-entity coordination handle, returned synchronously by
124
- * `ablo.<model>.intent(id)`. It lets humans and agents claim a row before
125
- * they work on it, so two of them don't edit the same thing at once.
126
- *
127
- * The lifecycle reads like a sentence:
153
+ * A claimed row: the entity's data plus an async-dispose hook, so
128
154
  *
129
155
  * ```ts
130
- * const report = ablo.weatherReports.intent('weather_stockholm');
131
- *
132
- * if (report.current) await report.whenFree(); // someone's on it — wait
133
- * await report.claim({ action: 'checking_weather' }); // it's mine now
134
- * await report.update({ status: 'ready' }); // write, then auto-finish
156
+ * await ablo.weatherReports.claim('report_stockholm', async (report) => {
157
+ * await ablo.weatherReports.update(report.id, { status: 'ready' });
158
+ * });
135
159
  * ```
136
160
  *
137
- * `current` is the live `Intent` (or `null` if free). `claim()` announces
138
- * you're working so others yield. `whenFree()` waits for whoever holds it.
139
- * `claimOrWait()` does both claim, or wait your turn then claim — which
140
- * is what you bind to an agent's write tool so it never reasons about
141
- * coordination itself. `finish()`/`cancel()` give the claim back.
161
+ * releases the claim when the callback returns or throws. Read it like any row
162
+ * (`report.location`); write it through the flat `ablo.<model>.update(report.id, …)`
163
+ * verb there is no method chaining on the claim.
142
164
  */
143
- export interface ModelIntentHandle<T> extends AsyncDisposable {
144
- /** The target entity id this handle coordinates. */
145
- readonly id: string;
146
- /**
147
- * Live coordination state on this target — `null` when free, otherwise
148
- * the holder's `Intent` (who, what phase, until when). Reactive
149
- * snapshot; pair with the model's `subscribe` for change notifications.
150
- */
151
- readonly current: Intent | null;
152
- /** Convenience: `current?.status ?? 'idle'`. */
153
- readonly status: IntentStatus | 'idle';
154
- /**
155
- * Claim this row so other participants yield while you work. Resolves
156
- * once the claim is announced. Throws if someone else already holds it
157
- * — call `whenFree()` first, or use `claimOrWait()` to do both.
158
- */
159
- claim(options?: ModelIntentAcquireOptions): Promise<void>;
160
- /**
161
- * Claim the row, or — if someone else holds it — wait for them to
162
- * finish, re-read the (now-changed) row, then claim. The caller never
163
- * branches on who holds it; it just gets the row safely. A claim you
164
- * already hold is treated as yours and taken without waiting. Bind this
165
- * to an agent's write-tool boundary.
166
- */
167
- claimOrWait(options?: ModelIntentAcquireOptions): Promise<void>;
168
- /**
169
- * Optimistic update guarded by the claim this handle holds. Rejects
170
- * with `AbloStaleContextError` if the row changed under you, then
171
- * auto-finishes. Call `claim()` first.
172
- */
173
- update(data: Partial<T>, options?: MutationOptions): Promise<T>;
174
- /** Finish: give back a claim you hold once the work is committed. */
175
- finish(): Promise<void>;
176
- /**
177
- * Wait until the row is free, then resolve. On resolution your cached
178
- * copy may be stale — re-read before writing (the stale-context guard
179
- * enforces this if you go through `claim()` + `update()`).
180
- */
181
- whenFree(options?: IntentWaitOptions): Promise<void>;
182
- /** Cancel: drop a claim you hold without committing any work. */
183
- cancel(): void;
184
- }
165
+ export type ClaimedRow<T> = T & AsyncDisposable;
185
166
  export interface ModelOperations<T, CreateInput> {
186
167
  /**
187
168
  * Retrieve a single entity by id from the local pool. Synchronous.
@@ -211,22 +192,44 @@ export interface ModelOperations<T, CreateInput> {
211
192
  /** Delete an entity by id — optimistic, offline-first (see `create`). */
212
193
  delete(id: string, options?: MutationOptions): Promise<void>;
213
194
  /**
214
- * Coordination accessor for one entity the same `ablo.<model>(id)`
215
- * shape as `create`/`update`/`retrieve`, but on the coordination plane
216
- * (ephemeral, TTL'd, never persisted). Returns a handle synchronously:
217
- * read `.current` to see who's editing, `claim()` to take it, `update()`
218
- * to write under the claim, `whenFree()` to wait for a holder to finish.
195
+ * Claim a row so other writers wait or are rejected until you're done.
196
+ * Reads stay open by default. Prefer the callback form for ordinary held
197
+ * work; it releases when the callback returns or throws. The `await using`
198
+ * form is also available for wider lexical scopes.
199
+ *
200
+ * ```ts
201
+ * await ablo.weatherReports.claim('report_stockholm', async (report) => {
202
+ * const weather = await getWeather(report.location);
203
+ * await ablo.weatherReports.update(report.id, { forecast: weather });
204
+ * });
205
+ * ```
206
+ */
207
+ claim(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
208
+ claim<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
209
+ /**
210
+ * Read who's coordinating on a row — the current holder (who, phase,
211
+ * until when), or `null` when free. Synchronous and reactive; for
212
+ * observers/UI. Never blocks.
213
+ */
214
+ claimState(id: string): Intent | null;
215
+ /**
216
+ * The wait queue on a row — who's lined up behind the holder and what each
217
+ * intends. Reactive snapshot (synced from the server, like `activity`);
218
+ * returns a Stripe-style list envelope, FIFO order, empty when no one waits.
219
219
  *
220
220
  * ```ts
221
- * const lock = ablo.slide.intent(slideId);
222
- * if (lock.current) await lock.whenFree(); // someone's editing wait
223
- * await lock.claim({ action: 'editing' });
224
- * await lock.update({ title: 'New' }); // auto-finishes
221
+ * const { data } = ablo.decks.queue('deck_1');
222
+ * // [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
225
223
  * ```
226
224
  */
227
- intent(id: string): ModelIntentHandle<T>;
228
- /** Subscribe to changes (callback called on every change). */
229
- subscribe(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
225
+ queue(id: string): {
226
+ readonly object: 'list';
227
+ readonly data: readonly Intent[];
228
+ };
229
+ /** Release a claim you hold early. Usually implicit (scope exit). */
230
+ release(id: string): Promise<void>;
231
+ /** Listen for changes (callback called on every change). */
232
+ onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
230
233
  /**
231
234
  * Load matching rows into the local graph if they are not already
232
235
  * present. Single-flight: concurrent calls with the same args share