@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.
- package/CHANGELOG.md +61 -0
- package/README.md +248 -124
- package/dist/BaseSyncedStore.d.ts +3 -3
- package/dist/BaseSyncedStore.js +3 -3
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +91 -93
- package/dist/client/Ablo.js +122 -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 +116 -90
- package/dist/client/createModelProxy.js +128 -128
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +5 -5
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +59 -14
- package/dist/errors.js +73 -12
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -12
- 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/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +14 -6
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +167 -0
- package/dist/schema/diff.js +280 -0
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +9 -3
- package/dist/schema/index.js +14 -2
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +10 -69
- package/dist/schema/schema.js +58 -24
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +96 -0
- package/dist/schema/serialize.js +231 -0
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- 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.d.ts +2 -1
- package/dist/sync/createIntentStream.js +89 -5
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +9 -18
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +90 -42
- package/docs/api-keys.md +44 -0
- package/docs/api.md +72 -173
- package/docs/audit.md +5 -5
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +42 -43
- package/docs/coordination.md +343 -0
- package/docs/data-sources.md +16 -16
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +38 -36
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +34 -56
- package/docs/identity.md +529 -0
- package/docs/index.md +18 -24
- package/docs/integration-guide.md +130 -144
- package/docs/interaction-model.md +32 -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 +74 -24
- package/docs/roadmap.md +17 -7
- package/llms.txt +34 -39
- package/package.json +8 -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/docs/client-behavior.md
CHANGED
|
@@ -9,9 +9,9 @@ import Ablo from '@abloatai/ablo';
|
|
|
9
9
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
10
10
|
|
|
11
11
|
const schema = defineSchema({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
status: z.enum(['
|
|
12
|
+
weatherReports: model({
|
|
13
|
+
location: z.string(),
|
|
14
|
+
status: z.enum(['pending', 'ready']),
|
|
15
15
|
}),
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -25,10 +25,10 @@ Common options:
|
|
|
25
25
|
|
|
26
26
|
| Option | Purpose |
|
|
27
27
|
|---|---|
|
|
28
|
-
| `schema` | Required for typed model
|
|
28
|
+
| `schema` | Required for typed model clients. |
|
|
29
29
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
30
30
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
31
|
-
| `persistence` | `volatile` by default. Use `indexeddb` for
|
|
31
|
+
| `persistence` | `volatile` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
32
32
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
33
33
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
34
34
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
@@ -40,17 +40,17 @@ endpoint.
|
|
|
40
40
|
|
|
41
41
|
## Model Methods
|
|
42
42
|
|
|
43
|
-
Each schema model becomes a typed
|
|
43
|
+
Each schema model becomes a typed model:
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
46
|
await ablo.ready();
|
|
47
47
|
|
|
48
|
-
const [
|
|
49
|
-
const local = ablo.
|
|
48
|
+
const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
|
|
49
|
+
const local = ablo.weatherReports.retrieve('report_stockholm');
|
|
50
50
|
|
|
51
|
-
await ablo.
|
|
52
|
-
await ablo.
|
|
53
|
-
await ablo.
|
|
51
|
+
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
52
|
+
await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
|
|
53
|
+
await ablo.weatherReports.delete('report_stockholm', { wait: 'confirmed' });
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
`load` is async hydration from local store and server. `retrieve`, `list`, and
|
|
@@ -63,15 +63,15 @@ rows.
|
|
|
63
63
|
|
|
64
64
|
## Multiplayer Behavior
|
|
65
65
|
|
|
66
|
-
Multiplayer works when every participant uses the same model
|
|
66
|
+
Multiplayer works when every participant uses the same model client path. A
|
|
67
67
|
human Server Action, a browser view, and an agent worker can all use
|
|
68
|
-
`ablo.
|
|
68
|
+
`ablo.weatherReports`:
|
|
69
69
|
|
|
70
70
|
```ts
|
|
71
|
-
const [
|
|
72
|
-
const snap = ablo.snapshot({
|
|
71
|
+
const [report] = await ablo.weatherReports.load({ where: { id } });
|
|
72
|
+
const snap = ablo.snapshot({ weatherReports: id });
|
|
73
73
|
|
|
74
|
-
await ablo.
|
|
74
|
+
await ablo.weatherReports.update(id, patch, {
|
|
75
75
|
readAt: snap.stamp,
|
|
76
76
|
onStale: 'reject',
|
|
77
77
|
wait: 'confirmed',
|
|
@@ -79,9 +79,9 @@ await ablo.tasks.update(id, patch, {
|
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
The confirmed write fans out over realtime subscriptions. React clients that use
|
|
82
|
-
`useAblo((ablo) => ablo.
|
|
83
|
-
such as `useAblo((ablo) => ablo.
|
|
84
|
-
receive active
|
|
82
|
+
`useAblo((ablo) => ablo.weatherReports.retrieve(id))` receive the new row, and selectors
|
|
83
|
+
such as `useAblo((ablo) => ablo.weatherReports.claimState(id))`
|
|
84
|
+
receive active claim state. There is
|
|
85
85
|
no extra multiplayer setup beyond routing shared state through Ablo.
|
|
86
86
|
|
|
87
87
|
If an app writes directly to its database, Ablo cannot coordinate that write
|
|
@@ -90,15 +90,14 @@ until the app reports it through Data Source events.
|
|
|
90
90
|
## Per-Write Options
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
|
-
await ablo.
|
|
94
|
-
'
|
|
95
|
-
{ status: '
|
|
93
|
+
await ablo.weatherReports.update(
|
|
94
|
+
'report_stockholm',
|
|
95
|
+
{ status: 'ready' },
|
|
96
96
|
{
|
|
97
97
|
wait: 'confirmed',
|
|
98
98
|
readAt: snap.stamp,
|
|
99
99
|
onStale: 'reject',
|
|
100
|
-
|
|
101
|
-
idempotencyKey: 'task_123:mark-done:v1',
|
|
100
|
+
idempotencyKey: 'report_stockholm:mark-ready:v1',
|
|
102
101
|
timeout: 20_000,
|
|
103
102
|
},
|
|
104
103
|
);
|
|
@@ -109,31 +108,31 @@ await ablo.tasks.update(
|
|
|
109
108
|
| `wait` | `queued` resolves after local queueing; `confirmed` waits for server acceptance. |
|
|
110
109
|
| `readAt` | State cursor the write was based on. |
|
|
111
110
|
| `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. |
|
|
112
|
-
| `intent` | Active work claim associated with this write. |
|
|
113
111
|
| `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
|
|
114
112
|
| `timeout` | Maximum time for the write call. |
|
|
115
113
|
|
|
116
|
-
##
|
|
114
|
+
## Claimed Behavior
|
|
117
115
|
|
|
118
116
|
```ts
|
|
119
|
-
const
|
|
117
|
+
const active = ablo.weatherReports.claimState('report_stockholm');
|
|
120
118
|
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
{ resource: 'tasks', id: 'task_123' },
|
|
124
|
-
{ timeout: 30_000 },
|
|
125
|
-
);
|
|
119
|
+
if (active) {
|
|
120
|
+
return { status: 'claimed', active };
|
|
126
121
|
}
|
|
122
|
+
|
|
123
|
+
await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
124
|
+
await ablo.weatherReports.update(report.id, { status: 'ready' });
|
|
125
|
+
});
|
|
127
126
|
```
|
|
128
127
|
|
|
129
|
-
Reads never silently block. For
|
|
128
|
+
Reads never silently block. For schema model calls, use `claimState(id)` to observe
|
|
129
|
+
current work and `claim(id, work)` to serialize a write across a slow step:
|
|
130
130
|
|
|
131
|
-
- `
|
|
132
|
-
- `wait`
|
|
133
|
-
- `
|
|
131
|
+
- default `claim` waits in the fair queue and re-reads before invoking `work`;
|
|
132
|
+
- `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
|
|
133
|
+
- `{ maxQueueDepth }` rejects if the wait line is already too deep.
|
|
134
134
|
|
|
135
|
-
Schema clients use the realtime stream for waits.
|
|
136
|
-
provide `busyPollInterval` when using `ifBusy: 'wait'`.
|
|
135
|
+
Schema clients use the realtime stream for waits.
|
|
137
136
|
|
|
138
137
|
## Errors
|
|
139
138
|
|
|
@@ -149,16 +148,16 @@ All SDK errors extend `AbloError` and carry a stable `type`.
|
|
|
149
148
|
| `AbloValidationError` | Invalid input or unsupported request shape. |
|
|
150
149
|
| `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
|
|
151
150
|
| `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
|
|
152
|
-
| `
|
|
151
|
+
| `AbloClaimedError` | An active claim conflicted with `{ wait: false }`, the queue was too deep, or a claim wait timed out. |
|
|
153
152
|
|
|
154
153
|
```ts
|
|
155
|
-
import {
|
|
154
|
+
import { AbloClaimedError } from '@abloatai/ablo';
|
|
156
155
|
|
|
157
156
|
try {
|
|
158
|
-
await ablo.
|
|
157
|
+
await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
|
|
159
158
|
} catch (error) {
|
|
160
|
-
if (error instanceof
|
|
161
|
-
return { status: '
|
|
159
|
+
if (error instanceof AbloClaimedError) {
|
|
160
|
+
return { status: 'claimed' };
|
|
162
161
|
}
|
|
163
162
|
throw error;
|
|
164
163
|
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# Coordination Reference
|
|
2
|
+
|
|
3
|
+
Coordinate long-running work on a row so humans and agents don't clobber each
|
|
4
|
+
other. Most writes need none of this — `ablo.<model>.update(id, …)` is optimistic
|
|
5
|
+
and the server rejects it if the row moved. Reach for `claim` only when you'll
|
|
6
|
+
**hold a row across a slow gap** (read → LLM call → write).
|
|
7
|
+
|
|
8
|
+
Claims are **fair**: on contention a second claimer joins a **server-side FIFO
|
|
9
|
+
queue** and blocks until promoted to the head of the line — it does not fail and
|
|
10
|
+
does not poll. Reads are open by default; reading a claimed row is allowed unless
|
|
11
|
+
the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
|
|
12
|
+
holder is auto-released and the queue advances.
|
|
13
|
+
|
|
14
|
+
This reference opens with [the model](#the-model--three-layers-one-decision) — the
|
|
15
|
+
one answer to "how do two agents not clobber each other" — then covers the
|
|
16
|
+
[claim state object](#the-claim-state-object), the SDK [methods](#methods)
|
|
17
|
+
(`claim` · `claimState` · `queue` · `release` · [writing under a
|
|
18
|
+
claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The model — three layers, one decision
|
|
23
|
+
|
|
24
|
+
Ablo has exactly **three** coordination layers. They are **not** three competing
|
|
25
|
+
answers to the same question — they stack, and only one of them is a decision you
|
|
26
|
+
make:
|
|
27
|
+
|
|
28
|
+
| layer | kind | what it does | enforces? |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| **Presence** (`claimState`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
|
|
31
|
+
| **Claim** (`claim`/`queue`/`release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
|
|
32
|
+
| **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
|
|
33
|
+
|
|
34
|
+
**The one decision: do you hold the row across a slow gap (read → LLM call →
|
|
35
|
+
write)?**
|
|
36
|
+
|
|
37
|
+
- **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
|
|
38
|
+
is optimistically guarded by stale-context already; it rejects with
|
|
39
|
+
`AbloStaleContextError` if the row moved under you. This is the default and
|
|
40
|
+
needs no ceremony.
|
|
41
|
+
- **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
|
|
42
|
+
excludes other participants for the duration, queues contenders fairly, and —
|
|
43
|
+
see below — your own writes under it stay stale-guarded too.
|
|
44
|
+
|
|
45
|
+
**How they compose (what wins):**
|
|
46
|
+
|
|
47
|
+
1. **Claim supersedes stale-context for *foreign* writers.** A non-holder writing
|
|
48
|
+
to a claimed row is rejected by the claim guard (`AbloClaimedError`,
|
|
49
|
+
`claim_conflict`/`entity_claimed`) *before* any watermark check — `readAt` is
|
|
50
|
+
irrelevant when you don't hold the lease. Pessimistic exclusion is the outer
|
|
51
|
+
gate.
|
|
52
|
+
2. **Stale-context is the always-on backstop for *unclaimed* writes.** No claim
|
|
53
|
+
held → the watermark check is the only protection, and it's automatic. This is
|
|
54
|
+
why the no-claim path is safe by default.
|
|
55
|
+
3. **Inside a claim, both apply.** A claim is not a license to clobber yourself:
|
|
56
|
+
writes under a held claim carry the claim's snapshot as `readAt` with
|
|
57
|
+
`onStale: 'reject'` (see [Writing under a claim](#writing-under-a-claim)), so a
|
|
58
|
+
`bypass` write or a row that moved between snapshot and write still rejects.
|
|
59
|
+
Claim = "no one else"; stale-context = "and not against a moved snapshot."
|
|
60
|
+
4. **Presence never decides.** It is the visualization of (1)–(3), not a fourth
|
|
61
|
+
gate. Never branch enforcement logic on `claimState` — read it to render, act
|
|
62
|
+
on the errors above.
|
|
63
|
+
|
|
64
|
+
Claims and stale-context are **orthogonal by construction**, not wired into each
|
|
65
|
+
other on the server: the claim guard runs pre-transaction; the watermark check
|
|
66
|
+
runs inside it. The SDK attaches `readAt`/`onStale` for you when writing under a
|
|
67
|
+
claim — that coupling lives in the SDK, deliberately, so the server's two checks
|
|
68
|
+
stay independent and individually testable.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## The claim state object
|
|
73
|
+
|
|
74
|
+
The claim state object is the live record that a participant is coordinating work on
|
|
75
|
+
a model row. It's what `claimState()` returns and what observers render.
|
|
76
|
+
|
|
77
|
+
| field | type | description |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
80
|
+
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
|
|
81
|
+
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
82
|
+
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
83
|
+
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
84
|
+
| `participantKind` | `'human' \| 'agent'` | Who's behind it. |
|
|
85
|
+
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
86
|
+
| `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
87
|
+
| `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
|
|
88
|
+
|
|
89
|
+
```jsonc
|
|
90
|
+
{
|
|
91
|
+
"id": "claim_8fJ2",
|
|
92
|
+
"status": "active",
|
|
93
|
+
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
94
|
+
"action": "editing",
|
|
95
|
+
"heldBy": "agent:forecaster",
|
|
96
|
+
"participantKind": "agent",
|
|
97
|
+
"createdAt": "1748160000000",
|
|
98
|
+
"expiresAt": "1748160030000"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Methods
|
|
105
|
+
|
|
106
|
+
Each method below follows one fixed shape: **signature · what it does ·
|
|
107
|
+
parameters · returns · example**.
|
|
108
|
+
|
|
109
|
+
### `claim`
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
ablo.<model>.claim(id, work, options?): Promise<R> // callback form
|
|
113
|
+
ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Claim a row so other writers serialize behind you until you're done; reads stay
|
|
117
|
+
open by default. The claim acquires through the server's fair FIFO queue: if the
|
|
118
|
+
target is free the lease is yours immediately, and if another participant holds
|
|
119
|
+
it your claim **waits in line** and resolves only once it reaches the head —
|
|
120
|
+
then re-reads so the claimed snapshot reflects what the previous holder
|
|
121
|
+
committed. There's no client-side poll and no TOCTOU gap: the server orders
|
|
122
|
+
contenders.
|
|
123
|
+
|
|
124
|
+
**Parameters**
|
|
125
|
+
|
|
126
|
+
| name | type | required | description |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
|
|
129
|
+
| `options.action` | `string` | no | Phase shown to observers (default `'editing'`). |
|
|
130
|
+
| `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
|
|
131
|
+
| `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
|
|
132
|
+
| `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
|
|
133
|
+
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
134
|
+
| `work` | `(row) => …` | no | Callback form: hold the claim for the callback, release when it returns. |
|
|
135
|
+
|
|
136
|
+
The high-level `claim` queues by default, so on contention you either get the row
|
|
137
|
+
when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
|
|
138
|
+
`grant_timeout`).
|
|
139
|
+
|
|
140
|
+
**Returns** — with the callback form, returns whatever `work` returns and
|
|
141
|
+
releases after the callback returns or throws. The manual form returns the
|
|
142
|
+
claimed row (`ClaimedRow<T> = T & AsyncDisposable`): the row data plus a
|
|
143
|
+
release hook for manual scopes.
|
|
144
|
+
|
|
145
|
+
**Example**
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
149
|
+
const weather = await weatherAgent.getWeather(report.location);
|
|
150
|
+
await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
151
|
+
return weather;
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The manual scoped form is still available for wider TS 5.2+ scopes, but ordinary
|
|
156
|
+
held work should use the callback form above.
|
|
157
|
+
|
|
158
|
+
### Claim-gated reads
|
|
159
|
+
|
|
160
|
+
`claimState(id)` always returns immediately. Model reads such as
|
|
161
|
+
`ablo.<model>.retrieve(id)` are local reads and stay available while a claim is
|
|
162
|
+
held. Server/model reads can choose a claimed policy:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await ablo.model('weatherReports').retrieve('report_stockholm', {
|
|
166
|
+
ifClaimed: 'wait',
|
|
167
|
+
claimedTimeout: 30_000,
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- `ifClaimed: 'return'` reads now and includes active work metadata.
|
|
172
|
+
- `ifClaimed: 'wait'` waits for the active claim to clear before reading.
|
|
173
|
+
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
174
|
+
|
|
175
|
+
### `claimState`
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
ablo.<model>.claimState(id)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
182
|
+
reactive (it reads the local coordination snapshot). Never blocks.
|
|
183
|
+
|
|
184
|
+
**Parameters**
|
|
185
|
+
|
|
186
|
+
| name | type | required | description |
|
|
187
|
+
|---|---|---|---|
|
|
188
|
+
| `id` | `string` | yes | The row id. |
|
|
189
|
+
|
|
190
|
+
**Returns** — the active [claim state object](#the-claim-state-object), or `null` when the row
|
|
191
|
+
is free.
|
|
192
|
+
|
|
193
|
+
**Example**
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const who = ablo.weatherReports.claimState('report_stockholm');
|
|
197
|
+
if (who) console.log(`${who.heldBy} is ${who.action}`);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Returns the active claim state when the row is held, or `null` when it's free:
|
|
201
|
+
|
|
202
|
+
```jsonc
|
|
203
|
+
{
|
|
204
|
+
"id": "claim_8fJ2",
|
|
205
|
+
"status": "active",
|
|
206
|
+
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
207
|
+
"action": "editing",
|
|
208
|
+
"heldBy": "agent:forecaster",
|
|
209
|
+
"participantKind": "agent",
|
|
210
|
+
"expiresAt": "1748160030000"
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### `queue`
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
ablo.<model>.queue(id)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Read the **wait line** behind a row — the FIFO of claims queued behind the
|
|
221
|
+
current holder, in promotion order. Like `claimState`, it's synchronous and
|
|
222
|
+
reactive (it reads the local coordination snapshot, kept current by the server's
|
|
223
|
+
queue-mutation frames), and reading never blocks. Where `claimState` answers "who
|
|
224
|
+
holds it," `queue` answers "who's lined up next" — render "3rd in line", or
|
|
225
|
+
decide the wait isn't worth it.
|
|
226
|
+
|
|
227
|
+
**Parameters**
|
|
228
|
+
|
|
229
|
+
| name | type | required | description |
|
|
230
|
+
|---|---|---|---|
|
|
231
|
+
| `id` | `string` | yes | The row id. |
|
|
232
|
+
|
|
233
|
+
**Returns** — a list envelope. `data` contains the queued
|
|
234
|
+
[claim state objects](#the-claim-state-object) in promotion order (head first), excluding
|
|
235
|
+
the active holder; `[]` when no one is waiting.
|
|
236
|
+
|
|
237
|
+
**Example**
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
|
|
241
|
+
console.log(`${waiting.length} ahead of you`);
|
|
242
|
+
console.log(waiting.map((i) => i.heldBy));
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `release`
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
ablo.<model>.release(id): Promise<void>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Release a claim you hold. Usually **implicit** — the callback returning releases
|
|
252
|
+
for you, and TTL cleans up a crashed holder.
|
|
253
|
+
Call this only to give a manually held claim back early (claimed, then decided
|
|
254
|
+
not to write).
|
|
255
|
+
Releasing **promotes the head of the queue**: the next waiter receives the claim.
|
|
256
|
+
|
|
257
|
+
**Parameters**
|
|
258
|
+
|
|
259
|
+
| name | type | required | description |
|
|
260
|
+
|---|---|---|---|
|
|
261
|
+
| `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
|
|
262
|
+
|
|
263
|
+
**Returns** — resolves once the claim is released.
|
|
264
|
+
|
|
265
|
+
**Example**
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
|
|
269
|
+
try {
|
|
270
|
+
const ok = await reviewExternally(report);
|
|
271
|
+
if (!ok) return; // abandon, no write
|
|
272
|
+
await ablo.weatherReports.update(report.id, { status: 'ready' });
|
|
273
|
+
} finally {
|
|
274
|
+
await ablo.weatherReports.release(report.id);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Writing under a claim
|
|
279
|
+
|
|
280
|
+
There is no separate "write" method on a claim — use the normal flat
|
|
281
|
+
`ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
|
|
282
|
+
automatically stale-guarded against the snapshot the claim took (`readAt` =
|
|
283
|
+
snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
|
|
284
|
+
it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
await ablo.weatherReports.claim(id, async (report) => {
|
|
288
|
+
await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
|
|
293
|
+
participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
|
|
294
|
+
'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
|
|
295
|
+
behind the current holder and re-reads once it's yours, so your `update` lands
|
|
296
|
+
on fresh data. You never conflict with your own claim, and reads are never gated.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
try {
|
|
300
|
+
await ablo.weatherReports.update(id, { status: 'ready' });
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err instanceof AbloClaimedError) {
|
|
303
|
+
// someone else holds it — claim the row and retry from fresh state
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Errors
|
|
311
|
+
|
|
312
|
+
All extend `AbloError` (`packages/sync-engine/src/errors.ts`). Catch by `type` or
|
|
313
|
+
inspect the `code`.
|
|
314
|
+
|
|
315
|
+
| error | `code` | thrown when | carries |
|
|
316
|
+
|---|---|---|---|
|
|
317
|
+
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | `claims?` |
|
|
318
|
+
| `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
|
|
319
|
+
| `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
|
|
320
|
+
| `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
|
|
321
|
+
| `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
|
|
322
|
+
| `AbloStaleContextError` | — | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot — your reasoning is stale. | `readAt`, `conflicts[]` |
|
|
323
|
+
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration wiring. | — |
|
|
324
|
+
| `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
|
|
325
|
+
|
|
326
|
+
`AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
|
|
327
|
+
that moved during your generation window — use it for selective regeneration
|
|
328
|
+
(re-think only the slides that changed, not the whole deck) and for metrics.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
try {
|
|
332
|
+
await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
333
|
+
const weather = await weatherAgent.getWeather(report.location); // slow gap
|
|
334
|
+
await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
|
|
338
|
+
// Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
|
|
339
|
+
} else if (err instanceof AbloStaleContextError) {
|
|
340
|
+
// The row moved under us — re-read and regenerate from the fresh snapshot.
|
|
341
|
+
} else throw err;
|
|
342
|
+
}
|
|
343
|
+
```
|
package/docs/data-sources.md
CHANGED
|
@@ -8,7 +8,7 @@ the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
|
|
|
8
8
|
`schema.prisma`.
|
|
9
9
|
|
|
10
10
|
By default, Ablo stores the rows for the models you declare. That makes Ablo the
|
|
11
|
-
managed state store for those
|
|
11
|
+
managed state store for those models, the same way Stripe stores `Customer`
|
|
12
12
|
and `PaymentIntent` objects that you create through Stripe's API.
|
|
13
13
|
|
|
14
14
|
If you already have application tables and want those tables to remain
|
|
@@ -24,7 +24,7 @@ Use the SDK with an API key:
|
|
|
24
24
|
|
|
25
25
|
```ts
|
|
26
26
|
import Ablo from '@abloatai/ablo';
|
|
27
|
-
import { schema } from './ablo
|
|
27
|
+
import { schema } from './ablo/schema';
|
|
28
28
|
|
|
29
29
|
export const ablo = Ablo({
|
|
30
30
|
schema,
|
|
@@ -54,9 +54,9 @@ ABLO_API_KEY=sk_live_...
|
|
|
54
54
|
The SDK call is the same in both modes:
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
|
-
await ablo.
|
|
58
|
-
await ablo.
|
|
59
|
-
const
|
|
57
|
+
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
58
|
+
await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
59
|
+
const report = ablo.weatherReports.retrieve('report_stockholm');
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Only the backing store changes.
|
|
@@ -100,7 +100,7 @@ The shape is the same as a production webhook integration:
|
|
|
100
100
|
```ts
|
|
101
101
|
// app/api/ablo/source/route.ts
|
|
102
102
|
import { dataSource } from '@abloatai/ablo';
|
|
103
|
-
import { schema } from '@/ablo
|
|
103
|
+
import { schema } from '@/ablo/schema';
|
|
104
104
|
import { db } from '@/db';
|
|
105
105
|
|
|
106
106
|
export const POST = dataSource({
|
|
@@ -120,13 +120,13 @@ export const POST = dataSource({
|
|
|
120
120
|
return { rows };
|
|
121
121
|
},
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
reports: {
|
|
124
124
|
async load({ id, context }) {
|
|
125
|
-
return context.auth.db.
|
|
125
|
+
return context.auth.db.report.findUnique({ where: { id } });
|
|
126
126
|
},
|
|
127
127
|
|
|
128
128
|
async list({ query, context }) {
|
|
129
|
-
return context.auth.db.
|
|
129
|
+
return context.auth.db.report.findMany({
|
|
130
130
|
take: query.limit ?? 100,
|
|
131
131
|
});
|
|
132
132
|
},
|
|
@@ -137,9 +137,9 @@ export const POST = dataSource({
|
|
|
137
137
|
Your app code still writes through the normal model API:
|
|
138
138
|
|
|
139
139
|
```ts
|
|
140
|
-
await ablo.
|
|
141
|
-
'
|
|
142
|
-
{ status: '
|
|
140
|
+
await ablo.weatherReports.update(
|
|
141
|
+
'report_stockholm',
|
|
142
|
+
{ status: 'ready' },
|
|
143
143
|
{ wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
|
|
144
144
|
);
|
|
145
145
|
```
|
|
@@ -155,9 +155,9 @@ When Ablo calls your Data Source, it sends a signed JSON request:
|
|
|
155
155
|
operations: [
|
|
156
156
|
{
|
|
157
157
|
type: 'UPDATE',
|
|
158
|
-
model: '
|
|
159
|
-
id: '
|
|
160
|
-
input: { status: '
|
|
158
|
+
model: 'weatherReports',
|
|
159
|
+
id: 'report_stockholm',
|
|
160
|
+
input: { status: 'ready' },
|
|
161
161
|
readAt: 1042,
|
|
162
162
|
onStale: 'reject',
|
|
163
163
|
},
|
|
@@ -177,7 +177,7 @@ Return canonical rows:
|
|
|
177
177
|
```ts
|
|
178
178
|
{
|
|
179
179
|
rows: [
|
|
180
|
-
{ id: '
|
|
180
|
+
{ id: 'report_stockholm', location: 'Stockholm', status: 'ready' },
|
|
181
181
|
],
|
|
182
182
|
}
|
|
183
183
|
```
|