@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
@@ -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>;
@@ -35,10 +35,12 @@ export interface ModelListOptions<T> {
35
35
  };
36
36
  limit?: number;
37
37
  offset?: number;
38
- /** Lifecycle scope. Defaults to live rows. */
39
- scope?: ModelListScope;
38
+ /** Lifecycle filter `live` (default), `archived`, or `all`. Named `state`
39
+ * (GitHub's open/closed/all precedent) so it doesn't collide with the
40
+ * sync-group `scope`. */
41
+ state?: ModelListScope;
40
42
  }
41
- export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'scope'>;
43
+ export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'state'>;
42
44
  export interface ModelLoadOptions<T> {
43
45
  /**
44
46
  * Filter for the lookup. Accepts:
@@ -74,12 +76,20 @@ export interface IntentLeaseHandle {
74
76
  export interface ModelCollaboration<T> {
75
77
  createIntent(options: {
76
78
  target: {
77
- resource: string;
79
+ model: string;
78
80
  id: string;
79
81
  field?: string;
80
82
  };
81
83
  action: string;
82
84
  ttl?: Duration;
85
+ /**
86
+ * Block on the server's fair FIFO queue when the target is held, rather
87
+ * than failing. Resolves only once the lease is genuinely ours (the head
88
+ * of the line). `takeClaim` sets this so writers serialize on contention.
89
+ */
90
+ queue?: boolean;
91
+ /** Reject (don't wait) if the queue is already this deep when we join. */
92
+ maxQueueDepth?: number;
83
93
  }): Promise<IntentLeaseHandle>;
84
94
  createSnapshot(modelKey: string, id: string): Snapshot;
85
95
  /**
@@ -90,16 +100,32 @@ export interface ModelCollaboration<T> {
90
100
  * hold it" from "someone else holds it").
91
101
  */
92
102
  observe(target: {
93
- resource: string;
103
+ model: string;
94
104
  id: string;
95
105
  }): Intent | null;
106
+ /**
107
+ * The reactive wait queue on a target — the FIFO line of queued intents
108
+ * behind the holder. Synchronous snapshot off the synced intent stream.
109
+ */
110
+ queue(target: {
111
+ model: string;
112
+ id: string;
113
+ }): readonly Intent[];
114
+ /**
115
+ * Re-rank the wait queue on a target (privileged — server-gated). `order` is
116
+ * the desired front-of-line ordering, taken from `queue(target)`.
117
+ */
118
+ reorder(target: {
119
+ model: string;
120
+ id: string;
121
+ }, order: readonly Intent[]): void;
96
122
  /**
97
123
  * Resolve once no participant holds an active intent on the target.
98
124
  * The contender's "wait until it's free" — delegates to the intent
99
125
  * stream's `waitFor`.
100
126
  */
101
127
  waitFor(target: {
102
- resource: string;
128
+ model: string;
103
129
  id: string;
104
130
  }, options?: IntentWaitOptions): Promise<void>;
105
131
  /**
@@ -108,80 +134,45 @@ export interface ModelCollaboration<T> {
108
134
  */
109
135
  readonly selfParticipantId: string;
110
136
  }
111
- /** Options for acquiring a per-model coordination intent. */
112
- export interface ModelIntentAcquireOptions {
113
- /** Phase shown to others while held. Defaults to `'editing'`. */
137
+ /** Options for `claim(id, …)`. */
138
+ export interface ClaimOptions {
139
+ /** Phase shown to observers while held. Defaults to `'editing'`. */
114
140
  action?: string;
115
- /** Field-level target for busy badges. */
141
+ /** Field-level target, for fine-grained claimed-state badges. */
116
142
  field?: string;
117
- /** Lease duration; runtime death is cleaned up by TTL. */
143
+ /** Crash-cleanup TTL the claim auto-releases if the holder dies. */
118
144
  ttl?: Duration;
119
- /** Default wait mode for `handle.update(...)`. Defaults to `confirmed`. */
120
- wait?: MutationOptions['wait'];
145
+ /**
146
+ * On contention: `true` (default) queues behind the current holder and
147
+ * resolves once it's yours (claim-or-wait). `false` is fail-fast — if
148
+ * another participant already holds the row, reject immediately with
149
+ * `AbloClaimedError` instead of waiting (claim-or-skip). Use `false` for
150
+ * work-distribution dedup ("if someone else has this job, skip it") where
151
+ * waiting would mean double-processing.
152
+ */
153
+ wait?: boolean;
154
+ /**
155
+ * Backpressure: willing to queue, but not behind too many. If the server
156
+ * reports `position >= maxQueueDepth` when we join the line, reject with
157
+ * `AbloClaimedError('queue_too_deep')` instead of waiting. Omit to wait
158
+ * however deep the queue is.
159
+ */
160
+ maxQueueDepth?: number;
121
161
  }
122
162
  /**
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:
163
+ * A claimed row: the entity's data plus an async-dispose hook, so
128
164
  *
129
165
  * ```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
166
+ * await ablo.weatherReports.claim('report_stockholm', async (report) => {
167
+ * await ablo.weatherReports.update(report.id, { status: 'ready' });
168
+ * });
135
169
  * ```
136
170
  *
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.
171
+ * releases the claim when the callback returns or throws. Read it like any row
172
+ * (`report.location`); write it through the flat `ablo.<model>.update(report.id, …)`
173
+ * verb there is no method chaining on the claim.
142
174
  */
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
- }
175
+ export type ClaimedRow<T> = T & AsyncDisposable;
185
176
  export interface ModelOperations<T, CreateInput> {
186
177
  /**
187
178
  * Retrieve a single entity by id from the local pool. Synchronous.
@@ -211,22 +202,57 @@ export interface ModelOperations<T, CreateInput> {
211
202
  /** Delete an entity by id — optimistic, offline-first (see `create`). */
212
203
  delete(id: string, options?: MutationOptions): Promise<void>;
213
204
  /**
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.
205
+ * Claim a row so other writers wait or are rejected until you're done.
206
+ * Reads stay open by default. Prefer the callback form for ordinary held
207
+ * work; it releases when the callback returns or throws. The `await using`
208
+ * form is also available for wider lexical scopes.
209
+ *
210
+ * ```ts
211
+ * await ablo.weatherReports.claim('report_stockholm', async (report) => {
212
+ * const weather = await getWeather(report.location);
213
+ * await ablo.weatherReports.update(report.id, { forecast: weather });
214
+ * });
215
+ * ```
216
+ */
217
+ claim(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
218
+ claim<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
219
+ /**
220
+ * Read who's coordinating on a row — the current holder (who, phase,
221
+ * until when), or `null` when free. Synchronous and reactive; for
222
+ * observers/UI. Never blocks.
223
+ */
224
+ claimState(id: string): Intent | null;
225
+ /**
226
+ * The wait queue on a row — who's lined up behind the holder and what each
227
+ * intends. Reactive snapshot (synced from the server, like `activity`);
228
+ * returns a Stripe-style list envelope, FIFO order, empty when no one waits.
229
+ *
230
+ * ```ts
231
+ * const { data } = ablo.decks.queue('deck_1');
232
+ * // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
233
+ * ```
234
+ */
235
+ queue(id: string): {
236
+ readonly object: 'list';
237
+ readonly data: readonly Intent[];
238
+ };
239
+ /**
240
+ * Re-rank the wait queue on a record — move waiters to the front in the
241
+ * given order. Pass the `Intent[]` from `queue(id).data`, reordered. A
242
+ * privileged operation: the server gates it (the caller needs the
243
+ * `intent.reorder` capability), so it's fire-and-forget — the new order
244
+ * arrives reactively through `queue(id)`.
219
245
  *
220
246
  * ```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
247
+ * const { data } = ablo.decks.queue('deck_1');
248
+ * ablo.decks.reorder('deck_1', [data[2], data[0], data[1]]); // promote #2
225
249
  * ```
226
250
  */
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;
251
+ reorder(id: string, order: readonly Intent[]): void;
252
+ /** Release a claim you hold early. Usually implicit (scope exit). */
253
+ release(id: string): Promise<void>;
254
+ /** Listen for changes (callback called on every change). */
255
+ onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
230
256
  /**
231
257
  * Load matching rows into the local graph if they are not already
232
258
  * present. Single-flight: concurrent calls with the same args share