@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.
- package/CHANGELOG.md +22 -0
- package/README.md +242 -135
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
package/dist/client/ApiClient.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
167
|
-
return
|
|
166
|
+
model(name) {
|
|
167
|
+
return createAgentModelClient(agentClient, name);
|
|
168
168
|
},
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
|
-
function
|
|
172
|
-
const base = agentClient.
|
|
171
|
+
function createAgentModelClient(agentClient, name) {
|
|
172
|
+
const base = agentClient.model(name);
|
|
173
173
|
return {
|
|
174
174
|
retrieve(id, options) {
|
|
175
|
-
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
233
|
-
if (!
|
|
234
|
-
throw new AbloValidationError('Commit operation requires `
|
|
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
|
-
|
|
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?.
|
|
262
|
-
params.set('
|
|
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
|
|
279
|
+
return {
|
|
280
|
+
active: body.intents ?? [],
|
|
281
|
+
queue: body.queue ?? [],
|
|
282
|
+
};
|
|
270
283
|
}
|
|
271
|
-
function
|
|
272
|
-
const label = [target.
|
|
273
|
-
const holder =
|
|
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
|
|
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
|
|
315
|
-
'Use the schema client for event-driven
|
|
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
|
|
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
|
|
328
|
-
const policy = options?.
|
|
340
|
+
async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
|
|
341
|
+
const policy = options?.ifClaimed ?? defaultPolicy;
|
|
329
342
|
if (policy === 'return')
|
|
330
343
|
return;
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
344
|
+
const state = await listClaimState(target);
|
|
345
|
+
if (state.active.length === 0)
|
|
333
346
|
return;
|
|
334
|
-
if (policy === 'fail')
|
|
335
|
-
throw
|
|
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?.
|
|
338
|
-
pollInterval: options?.
|
|
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
|
|
508
|
-
await
|
|
509
|
-
const query = await requestJson(`/v1/
|
|
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(`
|
|
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
|
-
|
|
548
|
+
claims: query.claims ?? [],
|
|
520
549
|
};
|
|
521
550
|
}
|
|
522
|
-
function
|
|
551
|
+
function model(name) {
|
|
523
552
|
return {
|
|
524
553
|
retrieve(id, options) {
|
|
525
|
-
return
|
|
554
|
+
return retrieveModel(name, id, options);
|
|
526
555
|
},
|
|
527
556
|
async create(data, mutationOptions) {
|
|
528
|
-
const id = mutationOptions?.id ??
|
|
529
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
649
|
+
function withAgentClaimedDefault(options) {
|
|
624
650
|
return {
|
|
625
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
2
|
+
* Per-model client factory.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors Anthropic SDK's
|
|
5
|
-
*
|
|
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
|
-
* `
|
|
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,
|
|
23
|
-
export interface
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
export interface
|
|
113
|
-
/** Phase shown to
|
|
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
|
|
131
|
+
/** Field-level target, for fine-grained claimed-state badges. */
|
|
116
132
|
field?: string;
|
|
117
|
-
/**
|
|
133
|
+
/** Crash-cleanup TTL — the claim auto-releases if the holder dies. */
|
|
118
134
|
ttl?: Duration;
|
|
119
|
-
/**
|
|
120
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
222
|
-
*
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|