@abloatai/ablo 0.7.0 → 0.9.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 +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
package/docs/coordination.md
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
# Coordination Reference
|
|
2
2
|
|
|
3
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,
|
|
4
|
+
other. Most writes need none of this — `ablo.<model>.update({ id, data })` is optimistic
|
|
5
5
|
and the server rejects it if the row moved. Reach for `claim` only when you'll
|
|
6
6
|
**hold a row across a slow gap** (read → LLM call → write).
|
|
7
7
|
|
|
8
|
-
Claims
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
the
|
|
12
|
-
|
|
8
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
9
|
+
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
10
|
+
of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
|
|
11
|
+
until promoted to the head of the line — it does not fail and does not poll.
|
|
12
|
+
Reads stay open: reading a claimed row is allowed unless the caller explicitly
|
|
13
|
+
asks for claimed gating. A claim carries a TTL so a crashed holder is
|
|
14
|
+
auto-released and the queue advances.
|
|
13
15
|
|
|
14
16
|
This reference opens with [the model](#the-model--three-layers-one-decision) — the
|
|
15
17
|
one answer to "how do two agents not clobber each other" — then covers the
|
|
16
18
|
[claim state object](#the-claim-state-object), the SDK [methods](#methods)
|
|
17
|
-
(`claim` · `
|
|
19
|
+
(`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
|
|
18
20
|
claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
19
21
|
|
|
20
22
|
---
|
|
@@ -27,8 +29,8 @@ make:
|
|
|
27
29
|
|
|
28
30
|
| layer | kind | what it does | enforces? |
|
|
29
31
|
|---|---|---|---|
|
|
30
|
-
| **Presence** (`
|
|
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
|
+
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
|
|
33
|
+
| **Claim** (`claim`/`claim.queue`/`claim.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
34
|
| **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
35
|
|
|
34
36
|
**The one decision: do you hold the row across a slow gap (read → LLM call →
|
|
@@ -42,42 +44,29 @@ write)?**
|
|
|
42
44
|
excludes other participants for the duration, queues contenders fairly, and —
|
|
43
45
|
see below — your own writes under it stay stale-guarded too.
|
|
44
46
|
|
|
45
|
-
**How they compose (what wins):**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
47
|
+
**How they compose (what wins):** If you don't hold the row, claims win — a
|
|
48
|
+
non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
|
|
49
|
+
`readAt`. If you do hold it, your own writes are still stale-checked — a row that
|
|
50
|
+
moved between your snapshot and your write still rejects with
|
|
51
|
+
`AbloStaleContextError`. With no claim held, the stale check is the only
|
|
52
|
+
protection, and it's automatic, which is why the no-claim path is safe by default.
|
|
53
|
+
Presence (`claim.state`) never decides anything — read it to render, act on the
|
|
54
|
+
errors. The two checks are independent: one rejects writes from people who don't
|
|
55
|
+
hold the claim, the other rejects writes based on a stale snapshot, and the SDK
|
|
56
|
+
adds the stale-check for you when you write under a claim, so you don't pass
|
|
57
|
+
anything extra.
|
|
69
58
|
|
|
70
59
|
---
|
|
71
60
|
|
|
72
61
|
## The claim state object
|
|
73
62
|
|
|
74
63
|
The claim state object is the live record that a participant is coordinating work on
|
|
75
|
-
a model row. It's what `
|
|
64
|
+
a model row. It's what `claim.state()` returns and what observers render.
|
|
76
65
|
|
|
77
66
|
| field | type | description |
|
|
78
67
|
|---|---|---|
|
|
79
68
|
| `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. |
|
|
69
|
+
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
|
|
81
70
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
82
71
|
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
83
72
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
@@ -109,8 +98,7 @@ parameters · returns · example**.
|
|
|
109
98
|
### `claim`
|
|
110
99
|
|
|
111
100
|
```ts
|
|
112
|
-
ablo.<model>.claim(id,
|
|
113
|
-
ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
|
|
101
|
+
ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>> // handle; AsyncDisposable, auto-releases with `await using`
|
|
114
102
|
```
|
|
115
103
|
|
|
116
104
|
Claim a row so other writers serialize behind you until you're done; reads stay
|
|
@@ -118,8 +106,8 @@ open by default. The claim acquires through the server's fair FIFO queue: if the
|
|
|
118
106
|
target is free the lease is yours immediately, and if another participant holds
|
|
119
107
|
it your claim **waits in line** and resolves only once it reaches the head —
|
|
120
108
|
then re-reads so the claimed snapshot reflects what the previous holder
|
|
121
|
-
committed. There's no
|
|
122
|
-
|
|
109
|
+
committed. There's no polling and no race window — the server decides the order,
|
|
110
|
+
so two claimers can't both think they won.
|
|
123
111
|
|
|
124
112
|
**Parameters**
|
|
125
113
|
|
|
@@ -131,38 +119,36 @@ contenders.
|
|
|
131
119
|
| `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
120
|
| `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
121
|
| `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
122
|
|
|
136
123
|
The high-level `claim` queues by default, so on contention you either get the row
|
|
137
124
|
when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
|
|
138
125
|
`grant_timeout`).
|
|
139
126
|
|
|
140
|
-
**Returns** —
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
127
|
+
**Returns** — a `ClaimHandle<T>` (an `AsyncDisposable`): `handle.data` is the
|
|
128
|
+
fresh row snapshot taken once the lease is yours, and `handle.release()` gives
|
|
129
|
+
the claim back. Bind it with `await using` so the claim auto-releases when the
|
|
130
|
+
scope exits.
|
|
144
131
|
|
|
145
132
|
**Example**
|
|
146
133
|
|
|
147
134
|
```ts
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
135
|
+
await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
|
|
136
|
+
const report = claim.data;
|
|
137
|
+
const weather = await weatherAgent.getWeather(report.location);
|
|
138
|
+
await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
|
|
153
139
|
```
|
|
154
140
|
|
|
155
|
-
The
|
|
156
|
-
held work should use the callback form above.
|
|
141
|
+
The claim releases when the `await using` scope exits — on return or throw.
|
|
157
142
|
|
|
158
143
|
### Claim-gated reads
|
|
159
144
|
|
|
160
|
-
`
|
|
161
|
-
`ablo.<model>.
|
|
145
|
+
`claim.state({ id })` always returns immediately. Model reads such as
|
|
146
|
+
`ablo.<model>.get(id)` are local reads and stay available while a claim is
|
|
162
147
|
held. Server/model reads can choose a claimed policy:
|
|
163
148
|
|
|
164
149
|
```ts
|
|
165
|
-
await ablo.
|
|
150
|
+
await ablo.weatherReports.retrieve({
|
|
151
|
+
id: 'report_stockholm',
|
|
166
152
|
ifClaimed: 'wait',
|
|
167
153
|
claimedTimeout: 30_000,
|
|
168
154
|
});
|
|
@@ -172,10 +158,10 @@ await ablo.model('weatherReports').retrieve('report_stockholm', {
|
|
|
172
158
|
- `ifClaimed: 'wait'` waits for the active claim to clear before reading.
|
|
173
159
|
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
174
160
|
|
|
175
|
-
### `
|
|
161
|
+
### `claim.state`
|
|
176
162
|
|
|
177
163
|
```ts
|
|
178
|
-
ablo.<model>.
|
|
164
|
+
ablo.<model>.claim.state({ id })
|
|
179
165
|
```
|
|
180
166
|
|
|
181
167
|
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
@@ -193,7 +179,7 @@ is free.
|
|
|
193
179
|
**Example**
|
|
194
180
|
|
|
195
181
|
```ts
|
|
196
|
-
const who = ablo.weatherReports.
|
|
182
|
+
const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
197
183
|
if (who) console.log(`${who.heldBy} is ${who.action}`);
|
|
198
184
|
```
|
|
199
185
|
|
|
@@ -211,17 +197,17 @@ Returns the active claim state when the row is held, or `null` when it's free:
|
|
|
211
197
|
}
|
|
212
198
|
```
|
|
213
199
|
|
|
214
|
-
### `queue`
|
|
200
|
+
### `claim.queue`
|
|
215
201
|
|
|
216
202
|
```ts
|
|
217
|
-
ablo.<model>.queue(id)
|
|
203
|
+
ablo.<model>.claim.queue({ id })
|
|
218
204
|
```
|
|
219
205
|
|
|
220
206
|
Read the **wait line** behind a row — the FIFO of claims queued behind the
|
|
221
|
-
current holder, in promotion order. Like `
|
|
207
|
+
current holder, in promotion order. Like `claim.state`, it's synchronous and
|
|
222
208
|
reactive (it reads the local coordination snapshot, kept current by the server's
|
|
223
|
-
queue-mutation frames), and reading never blocks. Where `
|
|
224
|
-
holds it," `queue` answers "who's lined up next" — render "3rd in line", or
|
|
209
|
+
queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
|
|
210
|
+
holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
|
|
225
211
|
decide the wait isn't worth it.
|
|
226
212
|
|
|
227
213
|
**Parameters**
|
|
@@ -237,19 +223,19 @@ the active holder; `[]` when no one is waiting.
|
|
|
237
223
|
**Example**
|
|
238
224
|
|
|
239
225
|
```ts
|
|
240
|
-
const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
|
|
226
|
+
const { data: waiting } = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
|
|
241
227
|
console.log(`${waiting.length} ahead of you`);
|
|
242
228
|
console.log(waiting.map((i) => i.heldBy));
|
|
243
229
|
```
|
|
244
230
|
|
|
245
|
-
### `release`
|
|
231
|
+
### `claim.release`
|
|
246
232
|
|
|
247
233
|
```ts
|
|
248
|
-
ablo.<model>.release(id): Promise<void>
|
|
234
|
+
ablo.<model>.claim.release({ id }): Promise<void>
|
|
249
235
|
```
|
|
250
236
|
|
|
251
|
-
Release a claim you hold. Usually **implicit** — the
|
|
252
|
-
for you, and TTL cleans up a crashed holder.
|
|
237
|
+
Release a claim you hold. Usually **implicit** — the `await using` scope exiting
|
|
238
|
+
releases for you, and TTL cleans up a crashed holder.
|
|
253
239
|
Call this only to give a manually held claim back early (claimed, then decided
|
|
254
240
|
not to write).
|
|
255
241
|
Releasing **promotes the head of the queue**: the next waiter receives the claim.
|
|
@@ -265,28 +251,28 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
|
|
|
265
251
|
**Example**
|
|
266
252
|
|
|
267
253
|
```ts
|
|
268
|
-
const
|
|
254
|
+
const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
|
|
255
|
+
const report = claim.data;
|
|
269
256
|
try {
|
|
270
257
|
const ok = await reviewExternally(report);
|
|
271
258
|
if (!ok) return; // abandon, no write
|
|
272
|
-
await ablo.weatherReports.update(report.id, { status: 'ready' });
|
|
259
|
+
await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
|
|
273
260
|
} finally {
|
|
274
|
-
await ablo.weatherReports.release(report.id);
|
|
261
|
+
await ablo.weatherReports.claim.release({ id: report.id });
|
|
275
262
|
}
|
|
276
263
|
```
|
|
277
264
|
|
|
278
265
|
### Writing under a claim
|
|
279
266
|
|
|
280
|
-
There is no separate "write" method on a claim — use the normal
|
|
281
|
-
`ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
|
|
267
|
+
There is no separate "write" method on a claim — use the normal
|
|
268
|
+
`ablo.<model>.update({ id, data })`. While you hold a claim on `id`, that `update` is
|
|
282
269
|
automatically stale-guarded against the snapshot the claim took (`readAt` =
|
|
283
270
|
snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
|
|
284
271
|
it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
|
|
285
272
|
|
|
286
273
|
```ts
|
|
287
|
-
await ablo.weatherReports.claim(id
|
|
288
|
-
|
|
289
|
-
});
|
|
274
|
+
await using claim = await ablo.weatherReports.claim({ id });
|
|
275
|
+
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
|
|
290
276
|
```
|
|
291
277
|
|
|
292
278
|
Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
|
|
@@ -297,7 +283,7 @@ on fresh data. You never conflict with your own claim, and reads are never gated
|
|
|
297
283
|
|
|
298
284
|
```ts
|
|
299
285
|
try {
|
|
300
|
-
await ablo.weatherReports.update(id, { status: 'ready' });
|
|
286
|
+
await ablo.weatherReports.update({ id, data: { status: 'ready' } });
|
|
301
287
|
} catch (err) {
|
|
302
288
|
if (err instanceof AbloClaimedError) {
|
|
303
289
|
// someone else holds it — claim the row and retry from fresh state
|
|
@@ -329,10 +315,10 @@ that moved during your generation window — use it for selective regeneration
|
|
|
329
315
|
|
|
330
316
|
```ts
|
|
331
317
|
try {
|
|
332
|
-
await ablo.weatherReports.claim('report_stockholm'
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
});
|
|
318
|
+
await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
|
|
319
|
+
const report = claim.data;
|
|
320
|
+
const weather = await weatherAgent.getWeather(report.location); // slow gap
|
|
321
|
+
await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
|
|
336
322
|
} catch (err) {
|
|
337
323
|
if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
|
|
338
324
|
// Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
|
package/docs/data-sources.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
By default, Ablo stores the rows for the models you define, so you don't need a
|
|
4
|
+
database to get started. But if you already have your own application database
|
|
5
|
+
and want it to stay the source of truth, you can attach it as a Data Source —
|
|
6
|
+
then Ablo coordinates each write and calls your app to commit it, instead of
|
|
7
|
+
storing the data itself.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`schema.prisma`.
|
|
9
|
+
That default makes Ablo the managed state store for your models, the same way
|
|
10
|
+
Stripe stores `Customer` and `PaymentIntent` objects that you create through
|
|
11
|
+
Stripe's API.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
and
|
|
13
|
-
|
|
14
|
-
If you already have application tables and want those tables to remain
|
|
15
|
-
canonical, attach a Data Source. Then Ablo coordinates the write and calls your
|
|
16
|
-
app to commit it.
|
|
13
|
+
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod —
|
|
14
|
+
the same way a Prisma project starts with a `schema.prisma`. Your schema
|
|
15
|
+
describes your data once, and everything else (the SDK, agents, and your
|
|
16
|
+
database connection) relies on that one definition.
|
|
17
17
|
|
|
18
18
|
Your app can keep using its own `DATABASE_URL`. Store that value in your app or
|
|
19
19
|
backend environment, not in Ablo. The integration boundary is the HTTPS
|
|
@@ -54,17 +54,20 @@ ABLO_API_KEY=sk_live_...
|
|
|
54
54
|
The SDK call is the same in both modes:
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
|
-
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
58
|
-
await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
59
|
-
const report = ablo.weatherReports.
|
|
57
|
+
await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
|
|
58
|
+
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
|
|
59
|
+
const report = ablo.weatherReports.get('report_stockholm');
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Only the backing store changes.
|
|
63
63
|
|
|
64
64
|
Multiplayer behavior is the same in both modes. Writes made through
|
|
65
65
|
`ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
|
|
66
|
-
fan out to subscribers.
|
|
67
|
-
|
|
66
|
+
fan out to subscribers. If something writes to your database without going
|
|
67
|
+
through Ablo (a cron job, an admin tool), Ablo can't know about it
|
|
68
|
+
automatically. To keep everyone's screen up to date, your app reports those
|
|
69
|
+
outside changes back through an events feed — shown below in
|
|
70
|
+
[External Writes](#external-writes).
|
|
68
71
|
|
|
69
72
|
## When To Use A Data Source
|
|
70
73
|
|
|
@@ -93,13 +96,13 @@ The shape is the same as a production webhook integration:
|
|
|
93
96
|
2. Store `ABLO_API_KEY` in your app.
|
|
94
97
|
3. Verify signed HTTP calls before opening a database transaction.
|
|
95
98
|
4. Keep your database credentials in your app.
|
|
96
|
-
5. Write an outbox row
|
|
99
|
+
5. Write an outbox row in the same transaction as every app-row change.
|
|
97
100
|
|
|
98
101
|
## Route
|
|
99
102
|
|
|
100
103
|
```ts
|
|
101
104
|
// app/api/ablo/source/route.ts
|
|
102
|
-
import { dataSource } from '@abloatai/ablo';
|
|
105
|
+
import { dataSource, sourceEventForOperation } from '@abloatai/ablo';
|
|
103
106
|
import { schema } from '@/ablo/schema';
|
|
104
107
|
import { db } from '@/db';
|
|
105
108
|
|
|
@@ -114,7 +117,23 @@ export const POST = dataSource({
|
|
|
114
117
|
async commit({ operations, clientTxId, context }) {
|
|
115
118
|
const rows = await context.auth.db.transaction(async (tx) => {
|
|
116
119
|
await tx.idempotency.upsert({ key: clientTxId, operations });
|
|
117
|
-
|
|
120
|
+
const changes = await applyOperations(tx, operations);
|
|
121
|
+
await tx.outbox.createMany({
|
|
122
|
+
data: changes.map(({ eventId, operation, entityId, data }) =>
|
|
123
|
+
sourceEventForOperation({
|
|
124
|
+
eventId,
|
|
125
|
+
operation,
|
|
126
|
+
entityId,
|
|
127
|
+
data,
|
|
128
|
+
...(clientTxId ? { clientTxId } : {}),
|
|
129
|
+
...(context.scope?.organizationId
|
|
130
|
+
? { organizationId: context.scope.organizationId }
|
|
131
|
+
: {}),
|
|
132
|
+
occurredAt: Date.now(),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
});
|
|
136
|
+
return changes.map(({ row }) => row);
|
|
118
137
|
});
|
|
119
138
|
|
|
120
139
|
return { rows };
|
|
@@ -137,11 +156,13 @@ export const POST = dataSource({
|
|
|
137
156
|
Your app code still writes through the normal model API:
|
|
138
157
|
|
|
139
158
|
```ts
|
|
140
|
-
await ablo.weatherReports.update(
|
|
141
|
-
'report_stockholm',
|
|
142
|
-
{ status: 'ready' },
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
await ablo.weatherReports.update({
|
|
160
|
+
id: 'report_stockholm',
|
|
161
|
+
data: { status: 'ready' },
|
|
162
|
+
wait: 'confirmed',
|
|
163
|
+
readAt: snap.stamp,
|
|
164
|
+
onStale: 'reject',
|
|
165
|
+
});
|
|
145
166
|
```
|
|
146
167
|
|
|
147
168
|
## Commit Request
|
|
@@ -185,10 +206,12 @@ Return canonical rows:
|
|
|
185
206
|
Use explicit `deltas` only when your source already computes canonical change
|
|
186
207
|
events.
|
|
187
208
|
|
|
188
|
-
##
|
|
209
|
+
## Outbox Events
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
Return your outbox feed from an `events` handler so connected humans and agents
|
|
212
|
+
stay current. Include SDK-origin events too. If Ablo already appended the commit
|
|
213
|
+
directly, `clientTxId` lets Ablo filter the echo; if the direct append failed,
|
|
214
|
+
the same outbox row repairs it on the next poll or push.
|
|
192
215
|
|
|
193
216
|
```ts
|
|
194
217
|
export const POST = dataSource({
|
|
@@ -215,7 +238,6 @@ export const POST = dataSource({
|
|
|
215
238
|
});
|
|
216
239
|
```
|
|
217
240
|
|
|
218
|
-
`clientTxId` lets Ablo drop SDK echoes that already produced a realtime update.
|
|
219
241
|
Events without `clientTxId` are treated as external writes.
|
|
220
242
|
|
|
221
243
|
## Production Checklist
|
|
@@ -227,14 +249,14 @@ Before using a customer-owned database in production:
|
|
|
227
249
|
- Verify signatures before opening a database transaction.
|
|
228
250
|
- Store `clientTxId` in an idempotency table before applying writes.
|
|
229
251
|
- Return canonical rows after each commit.
|
|
230
|
-
- Write outbox events in the same transaction as
|
|
252
|
+
- Write outbox events in the same transaction as every app-row write, including
|
|
253
|
+
Data Source `commit` writes.
|
|
231
254
|
- Dedupe outbox events by event `id`.
|
|
232
255
|
- Monitor last success, last error, retry count, event lag, and cursor.
|
|
233
256
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
data-processing terms.
|
|
257
|
+
Don't give Ablo your database URL for this integration — Ablo never connects to
|
|
258
|
+
your database directly. (Direct database access would be a separate product with
|
|
259
|
+
its own security model.)
|
|
238
260
|
|
|
239
261
|
## Security
|
|
240
262
|
|
|
@@ -4,21 +4,30 @@ A report-writing agent that yields when a human is editing the same report.
|
|
|
4
4
|
|
|
5
5
|
## Scenario
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
collide:
|
|
7
|
+
The same reports are edited by both humans and agents. They must not collide:
|
|
9
8
|
|
|
10
|
-
- If
|
|
11
|
-
-
|
|
12
|
-
- If the report changes mid-run, the commit
|
|
13
|
-
|
|
9
|
+
- If a human already holds the row, the agent yields instead of fighting for it.
|
|
10
|
+
- While the agent is updating, the UI can show who is active.
|
|
11
|
+
- If the report changes mid-run, the commit is rejected instead of overwriting
|
|
12
|
+
the human's newer edit.
|
|
13
|
+
|
|
14
|
+
A **claim** does both jobs. Claims don't lock — if another writer holds the row,
|
|
15
|
+
`claim` waits for them, re-reads the fresh row, then hands it back to you on
|
|
16
|
+
`claim.data`, so two writers serialize instead of clobbering. The handle is an
|
|
17
|
+
`AsyncDisposable`: hold it with `await using` and it releases on scope exit. And
|
|
18
|
+
once you hold a claim, any `update` you make while it's held is stale-checked for
|
|
19
|
+
free: the SDK records the row version you were handed and rejects the write with
|
|
20
|
+
a typed error if the row moved underneath you while the agent was busy.
|
|
14
21
|
|
|
15
22
|
## Schema-Backed Worker
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
row, and writes through
|
|
24
|
+
The worker uses the same schema client the app uses. It reads the report from
|
|
25
|
+
the server with `retrieve({ id })`, claims the row, and writes through
|
|
26
|
+
`ablo.weatherReports.update(...)` with a stale-check so a human's concurrent edit
|
|
27
|
+
can't be overwritten.
|
|
19
28
|
|
|
20
29
|
```ts
|
|
21
|
-
import Ablo from '@abloatai/ablo';
|
|
30
|
+
import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
|
|
22
31
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
23
32
|
|
|
24
33
|
const schema = defineSchema({
|
|
@@ -33,21 +42,53 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
|
33
42
|
export async function markReady(reportId: string) {
|
|
34
43
|
await ablo.ready();
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
// retrieve({ id }) is an async server read — await it.
|
|
46
|
+
const report = await ablo.weatherReports.retrieve({ id: reportId });
|
|
37
47
|
if (!report) return { status: 'not_found' };
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
try {
|
|
50
|
+
// wait: false → don't queue behind a current holder. If a human already
|
|
51
|
+
// holds the row, claim rejects with AbloClaimedError (caught below), so the
|
|
52
|
+
// agent yields instead of waiting. Omit it, or pass wait: true, to queue
|
|
53
|
+
// behind them. action → the label observers see while we work.
|
|
54
|
+
await using claim = await ablo.weatherReports.claim({
|
|
55
|
+
id: reportId,
|
|
56
|
+
wait: false,
|
|
57
|
+
action: 'marking_ready',
|
|
58
|
+
});
|
|
59
|
+
const claimed = claim.data;
|
|
60
|
+
|
|
61
|
+
// Inside an active claim, `update` is stale-checked automatically: the SDK
|
|
62
|
+
// attaches the claim's snapshot version as `readAt` and sets
|
|
63
|
+
// `onStale: 'reject'`. The write below is therefore equivalent to passing
|
|
64
|
+
// those options yourself:
|
|
65
|
+
//
|
|
66
|
+
// ablo.weatherReports.update({
|
|
67
|
+
// id: claimed.id,
|
|
68
|
+
// data: { status: 'ready' },
|
|
69
|
+
// wait: 'confirmed',
|
|
70
|
+
// readAt: <claim snapshot version>,
|
|
71
|
+
// onStale: 'reject',
|
|
72
|
+
// });
|
|
73
|
+
//
|
|
74
|
+
// If a human saved a newer version mid-run, the row no longer matches
|
|
75
|
+
// `readAt`, so the server rejects this commit with AbloStaleContextError
|
|
76
|
+
// (caught below) instead of clobbering their edit.
|
|
77
|
+
const updated = await ablo.weatherReports.update({
|
|
78
|
+
id: claimed.id,
|
|
79
|
+
data: { status: 'ready' },
|
|
80
|
+
wait: 'confirmed',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { status: 'ready', report: updated };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// A human already holds the row — yield this run and let them finish.
|
|
86
|
+
if (err instanceof AbloClaimedError) return { status: 'yielded' };
|
|
87
|
+
// A human saved a newer version while we held the claim. The stale-check
|
|
88
|
+
// rejected our commit, so nothing was overwritten — re-run on fresh data.
|
|
89
|
+
if (err instanceof AbloStaleContextError) return { status: 'stale' };
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
51
92
|
}
|
|
52
93
|
```
|
|
53
94
|
|
|
@@ -61,8 +102,8 @@ Keep workers on the same schema-backed client as the app.
|
|
|
61
102
|
import { useAblo } from '@abloatai/ablo/react';
|
|
62
103
|
|
|
63
104
|
export function ReportRow({ report: serverReport }: Props) {
|
|
64
|
-
const data = useAblo((ablo) => ablo.weatherReports.
|
|
65
|
-
const active = useAblo((ablo) => ablo.weatherReports.
|
|
105
|
+
const data = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
|
|
106
|
+
const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
|
|
66
107
|
const agentActive = active?.participantKind === 'agent';
|
|
67
108
|
|
|
68
109
|
return (
|
|
@@ -76,7 +117,12 @@ export function ReportRow({ report: serverReport }: Props) {
|
|
|
76
117
|
|
|
77
118
|
## Why It Works
|
|
78
119
|
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
- `
|
|
82
|
-
|
|
120
|
+
- The claim is visible to everyone: the UI reads it synchronously with
|
|
121
|
+
`claim.state({ id })`, and it also arrives over the live stream.
|
|
122
|
+
- `claim({ id })` makes writers take turns instead of racing — with
|
|
123
|
+
`wait: false`, the agent simply yields when a human already holds the row.
|
|
124
|
+
- The `update` made while the claim is held is stale-checked automatically, so a human's
|
|
125
|
+
edit landing mid-run rejects the agent's write with a typed
|
|
126
|
+
`AbloStaleContextError` instead of overwriting it.
|
|
127
|
+
- That same write carries the claim, so each accepted change is attributed to
|
|
128
|
+
the run that made it.
|