@abloatai/ablo 0.58.0 → 0.59.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/AGENTS.md +4 -3
- package/CHANGELOG.md +70 -0
- package/README.md +4 -4
- package/docs/agents.md +20 -1
- package/docs/api.md +55 -5
- package/docs/basic-usage.md +84 -0
- package/docs/client-behavior.md +7 -15
- package/docs/comparison.md +63 -0
- package/docs/concurrency-convention.md +27 -0
- package/docs/context.md +20 -0
- package/docs/coordinate-existing-work.md +104 -0
- package/docs/coordination.md +68 -92
- package/docs/deployment.md +19 -1
- package/docs/examples/{existing-document-pipeline.md → evidence-backed-document-pipeline.md} +2 -2
- package/docs/faq.md +75 -0
- package/docs/guarantees.md +3 -2
- package/docs/idempotency.md +3 -0
- package/docs/implement.md +61 -0
- package/docs/implementation-index.md +20 -0
- package/docs/index.md +59 -178
- package/docs/installation.md +77 -0
- package/docs/instrumentation.md +52 -0
- package/docs/integrations/sandbox-runtime.md +10 -1
- package/docs/migration.md +12 -7
- package/docs/options.md +172 -0
- package/docs/quickstart.md +6 -1
- package/docs/security.md +64 -0
- package/examples/README.md +6 -0
- package/examples/stale-context-agent-turn.ts +106 -0
- package/llms.txt +1 -1
- package/package.json +4 -3
- package/docs/agent-integration-decision-guide.md +0 -123
package/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
Ablo lets AI agents and humans safely edit the same typed data without clobbering each other. When two of them touch the same row, a "claim" makes one wait for the other instead of overwriting it.
|
|
3
|
+
Ablo lets AI agents and humans safely edit the same typed data without clobbering each other. When two of them touch the same row, a "claim" makes one wait for the other instead of overwriting it. For an existing application, first preserve its named operation and persistence boundary; use the schema-backed row pattern below only when that route owns the write.
|
|
4
4
|
|
|
5
5
|
Claims don't lock. If another writer holds the row, `claim` waits for them and re-reads the fresh row before handing it to you — so two writers serialize instead of clobbering.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Claims don't lock. If another writer holds the row, `claim` waits for them and r
|
|
|
8
8
|
|
|
9
9
|
Before choosing among identifier claims, row claims, captured reads, atomic
|
|
10
10
|
commits, existing database writes, and Ablo-routed writes, use the
|
|
11
|
-
[
|
|
11
|
+
[coordinate existing work guide](./docs/coordinate-existing-work.md).
|
|
12
12
|
It routes existing applications to the smallest relevant example and names the
|
|
13
13
|
test layer that proves each guarantee.
|
|
14
14
|
|
|
@@ -44,7 +44,7 @@ Every model verb takes ONE options object. The common loop:
|
|
|
44
44
|
1. **Get or read** the row — `get({ id })` observes; `read({ id })` declares that a later mutation depends on this exact version. `list({ where })` is observational. In React render, use `local.get(id)`.
|
|
45
45
|
2. **See who's active** (optional) — `ablo.<model>.claim.state({ id })` (synchronous; never blocks).
|
|
46
46
|
3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, description?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
|
|
47
|
-
4. **Write** — pass `reads: [row]` when the decision used a row returned by `read`, or
|
|
47
|
+
4. **Write** — pass `reads: [row]` when the decision used a row returned by `read`, or pass `claim` when writing through a held claim. If the declared read moved or the claim was lost, the mutation does not land.
|
|
48
48
|
|
|
49
49
|
Keep coding assistants on this schema-backed path.
|
|
50
50
|
|
|
@@ -80,6 +80,7 @@ const claimed = claim.data;
|
|
|
80
80
|
await ablo.weatherReports.update({
|
|
81
81
|
id: claimed.id,
|
|
82
82
|
data: { status: 'ready', forecast: await getForecast(claimed.location) },
|
|
83
|
+
claim,
|
|
83
84
|
});
|
|
84
85
|
```
|
|
85
86
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.59.0
|
|
4
|
+
|
|
5
|
+
### Schema changes now have one ordered deployment plan
|
|
6
|
+
|
|
7
|
+
`ablo plan` compares the source schema, the active Ablo schema artifact, and the
|
|
8
|
+
connected PostgreSQL shape without changing any of them. It produces one
|
|
9
|
+
fingerprinted expand, dual-write, backfill, verify, switch, and contract
|
|
10
|
+
sequence, with explicit owners, blockers, and a rollback target:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx ablo plan
|
|
14
|
+
npx ablo plan --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`ablo check` is now the database-compatibility view of that same plan. `ablo
|
|
18
|
+
push` and `ablo migrate` consume it instead of maintaining separate migration
|
|
19
|
+
judgments, and `ablo rollback` plans or applies a reviewed reactivation of an
|
|
20
|
+
earlier schema artifact. The shared deployment contracts are available through
|
|
21
|
+
`@abloatai/ablo/schema` and `@abloatai/transaction/schema`.
|
|
22
|
+
|
|
23
|
+
Planning must observe all three states. `ablo plan`, `ablo check`, and `ablo
|
|
24
|
+
migrate` therefore require `ABLO_API_KEY` plus
|
|
25
|
+
`DATABASE_ADMIN_URL`/`DATABASE_URL`; a migration dry run is no longer a
|
|
26
|
+
source-only operation. `ablo push` now refuses a blocked plan and can accept an
|
|
27
|
+
explicit lifecycle manifest with `--manifest <path>`.
|
|
28
|
+
|
|
29
|
+
Runtime schema-drift warnings now name the affected fields and distinguish
|
|
30
|
+
client-only fields, active-only fields, and changes to type or optionality.
|
|
31
|
+
|
|
32
|
+
### Declared timestamps no longer recurse during local edits
|
|
33
|
+
|
|
34
|
+
Schemas can declare `createdAt` and `updatedAt` for typed reads and ordering
|
|
35
|
+
without turning Ablo's automatic timestamp bookkeeping into another model
|
|
36
|
+
edit. Updating an observable field now advances `updatedAt` once, keeps the
|
|
37
|
+
timestamp observable, and excludes system-managed timestamps from the
|
|
38
|
+
user-authored change payload.
|
|
39
|
+
|
|
40
|
+
### The public client configuration boundary is explicit
|
|
41
|
+
|
|
42
|
+
The supported `Ablo({ ... })` options are now machine-checked against the
|
|
43
|
+
published reference. The internal `onCommitReceipt` transport callback is no
|
|
44
|
+
longer accepted by the public factory type.
|
|
45
|
+
|
|
46
|
+
### Connection capacity is selectable from the public pricing model
|
|
47
|
+
|
|
48
|
+
The pricing API can now select the first tier that accommodates a requested
|
|
49
|
+
connection count. The published Pro allowance increases from 1,000 to 5,000
|
|
50
|
+
concurrent connections.
|
|
51
|
+
|
|
52
|
+
### CLI telemetry can reach authenticated ingestion
|
|
53
|
+
|
|
54
|
+
When an Ablo runtime key is available, the CLI uses it only in memory to
|
|
55
|
+
authenticate product-analytics delivery. The key is not written to the local
|
|
56
|
+
telemetry state, and existing telemetry opt-outs continue to apply.
|
|
57
|
+
|
|
58
|
+
### Version-matched integration guidance is easier to enter
|
|
59
|
+
|
|
60
|
+
The documentation bundled with `@abloatai/ablo` now starts from installation,
|
|
61
|
+
the operation being coordinated, and whether an existing write boundary must
|
|
62
|
+
be preserved. New focused pages cover basic usage, implementation choices,
|
|
63
|
+
existing-operation coordination, every client option, security,
|
|
64
|
+
instrumentation, comparisons, common questions, and GraphQL.js. `npx ablo
|
|
65
|
+
docs` continues to read this package-local documentation, so the guidance
|
|
66
|
+
matches the installed version.
|
|
67
|
+
|
|
68
|
+
A new stale-context agent-turn example shows the complete long-running policy:
|
|
69
|
+
abort cancellable work when an exact read moves, rebuild context for a bounded
|
|
70
|
+
retry, retain the authoritative write guard, and reconcile rather than blindly
|
|
71
|
+
replay an irreversible external side effect.
|
|
72
|
+
|
|
3
73
|
## 0.58.0
|
|
4
74
|
|
|
5
75
|
### Reads now distinguish observation from decision input
|
package/README.md
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<strong>
|
|
6
|
+
<strong>Coordination infrastructure for agents, applications, services, and people working on shared state.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://docs.abloatai.com">Docs</a> |
|
|
11
|
-
<a href="https://docs.abloatai.com/
|
|
11
|
+
<a href="https://docs.abloatai.com/installation">Installation</a> |
|
|
12
12
|
<a href="https://docs.abloatai.com/api">API</a> |
|
|
13
13
|
<a href="https://github.com/Abloatai/ablo">GitHub</a>
|
|
14
14
|
</p>
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
-
Ablo is
|
|
26
|
-
|
|
25
|
+
Ablo is coordination infrastructure for agents, applications, services, and
|
|
26
|
+
people working on shared state.
|
|
27
27
|
|
|
28
28
|
Every write goes through it, so authority, idempotency, conflicts, ordering,
|
|
29
29
|
and confirmation are enforced in one place. Your Postgres remains the source of
|
package/docs/agents.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
> The stateless participant: wake on a trigger, read, claim, commit, go idle.
|
|
4
4
|
|
|
5
|
+
## Stateless HTTP reads
|
|
6
|
+
|
|
7
|
+
Use `get` to read one task row by id and `list` to find matching rows. Agents
|
|
8
|
+
and other stateless workers use the HTTP client directly, without a
|
|
9
|
+
synchronization step or a `.data` wrapper.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const task = await ablo.tasks.get({ id: taskId });
|
|
13
|
+
if (!task) throw new Error('task not found');
|
|
14
|
+
console.log(task.title);
|
|
15
|
+
|
|
16
|
+
const matching = await ablo.tasks.list({ where: { title } });
|
|
17
|
+
if (!matching[0]) throw new Error('task not found');
|
|
18
|
+
console.log(matching[0].title);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
These are observational reads. Use `read({ id })` only when a later Ablo write
|
|
22
|
+
depends on that exact version and will pass it through `reads`.
|
|
23
|
+
|
|
5
24
|
An agent is a **reactive** participant: it wakes on something happening, reads
|
|
6
25
|
what it needs, writes a result, and goes idle. That's a request/response
|
|
7
26
|
workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The
|
|
@@ -16,7 +35,7 @@ other*.**
|
|
|
16
35
|
<Note>
|
|
17
36
|
Agents transact against your **pushed schema**, same as everyone — `ablo.records`
|
|
18
37
|
exists because you defined a `record` model and ran `ablo push`. The key
|
|
19
|
-
authenticates; the [schema](/
|
|
38
|
+
authenticates; the [schema](/installation) defines what you can call.
|
|
20
39
|
</Note>
|
|
21
40
|
|
|
22
41
|
## The agent client
|
package/docs/api.md
CHANGED
|
@@ -96,6 +96,49 @@ fallback removed — nothing to await, so they return a value.
|
|
|
96
96
|
through the server. The `local` reads work off the rows a session has already
|
|
97
97
|
synced, so a cheap re-read needs no round-trip.
|
|
98
98
|
|
|
99
|
+
## Atomic commits
|
|
100
|
+
|
|
101
|
+
Use one `ablo.commits.create` when several Ablo model writes must all land or
|
|
102
|
+
none may land. Put every operation in `operations` and every exact row returned
|
|
103
|
+
by `read` that influenced the batch in the top-level `reads` array.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { AbloStaleContextError } from '@abloatai/ablo';
|
|
107
|
+
|
|
108
|
+
const task = await ablo.tasks.read({ id: taskId });
|
|
109
|
+
if (!task) throw new Error('task not found');
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await ablo.commits.create({
|
|
113
|
+
operations: [
|
|
114
|
+
{
|
|
115
|
+
action: 'update', model: 'tasks',
|
|
116
|
+
id: task.id,
|
|
117
|
+
data: { status: 'done' },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
action: 'create', model: 'tasks',
|
|
121
|
+
id: markerId,
|
|
122
|
+
data: { title: 'atomic marker', status: 'done' },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
reads: [task],
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof AbloStaleContextError && error.code === 'stale_context') {
|
|
129
|
+
console.log(error.code);
|
|
130
|
+
} else {
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The server checks the premises and applies the operations in one transaction.
|
|
137
|
+
If any premise is stale or any operation fails, no operation lands. Independent
|
|
138
|
+
model calls are not an atomic batch. External effects and application-owned
|
|
139
|
+
Postgres writes cannot join this commit; keep those in their existing
|
|
140
|
+
transaction or outbox.
|
|
141
|
+
|
|
99
142
|
### Reading a whole collection
|
|
100
143
|
|
|
101
144
|
Prefer a filtered `listAll` when the application truly needs one complete
|
|
@@ -252,13 +295,18 @@ const handle = await ablo.weatherReports.claim({
|
|
|
252
295
|
description: 'editing',
|
|
253
296
|
ttl: '2m',
|
|
254
297
|
});
|
|
255
|
-
await ablo.weatherReports.update({
|
|
298
|
+
await ablo.weatherReports.update({
|
|
299
|
+
id: handle.data.id,
|
|
300
|
+
data: { status: 'ready' },
|
|
301
|
+
claim: handle,
|
|
302
|
+
});
|
|
256
303
|
await handle.release();
|
|
257
304
|
```
|
|
258
305
|
|
|
259
|
-
Writes go through the normal
|
|
260
|
-
|
|
261
|
-
|
|
306
|
+
Writes go through the normal model mutation and pass the held handle as `claim`.
|
|
307
|
+
That explicit handle carries commit-time fencing. If the row changed underneath
|
|
308
|
+
you since you took the claim, the update rejects with `AbloStaleContextError`,
|
|
309
|
+
so you re-read before retrying.
|
|
262
310
|
Call `handle.release()` (or `ablo.weatherReports.claim.release({ id })`) to release
|
|
263
311
|
the claim when your work is done.
|
|
264
312
|
|
|
@@ -307,7 +355,9 @@ rendered as agent tools.
|
|
|
307
355
|
|
|
308
356
|
## Errors
|
|
309
357
|
|
|
310
|
-
All SDK errors extend `AbloError`
|
|
358
|
+
All SDK errors extend `AbloError`. `type` is the class-name discriminator, such
|
|
359
|
+
as `AbloStaleContextError`; `code` is the wire condition, such as
|
|
360
|
+
`stale_context`. Use `instanceof` in-process and `type` after serialization.
|
|
311
361
|
|
|
312
362
|
| Error | Meaning |
|
|
313
363
|
|---|---|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Basic Usage
|
|
2
|
+
|
|
3
|
+
> Read one task row by id, list matching rows, create and update state, and coordinate work when needed.
|
|
4
|
+
|
|
5
|
+
Assume the configured client from [Installation](./installation.md) is exported
|
|
6
|
+
from `./ablo/client`.
|
|
7
|
+
|
|
8
|
+
## Read rows
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { ablo } from './ablo/client';
|
|
12
|
+
|
|
13
|
+
const task = await ablo.tasks.get({ id: taskId });
|
|
14
|
+
if (!task) throw new Error('task not found');
|
|
15
|
+
|
|
16
|
+
const open = await ablo.tasks.list({ where: { status: 'open' } });
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`get` observes one current row. Use `read` instead only when a later Ablo write
|
|
20
|
+
must be rejected if that exact premise changes.
|
|
21
|
+
|
|
22
|
+
## Write rows
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const created = await ablo.tasks.create({
|
|
26
|
+
data: { title: 'Review return', status: 'open' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await ablo.tasks.update({
|
|
30
|
+
id: created.id,
|
|
31
|
+
data: { status: 'done' },
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Writes return after Ablo confirms the authoritative result. Your PostgreSQL
|
|
36
|
+
constraints and schema remain in force.
|
|
37
|
+
|
|
38
|
+
## Coordinate slow work
|
|
39
|
+
|
|
40
|
+
When the target is an Ablo model row, claim it before the expensive step and
|
|
41
|
+
pass the claim to the final write.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
await using claim = await ablo.tasks.claim({ id: taskId });
|
|
45
|
+
|
|
46
|
+
const result = await performExpensiveWork(claim.data);
|
|
47
|
+
|
|
48
|
+
await ablo.tasks.update({
|
|
49
|
+
id: claim.data.id,
|
|
50
|
+
data: { title: result.title, status: 'done' },
|
|
51
|
+
claim,
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A contender follows the chosen wait, skip, or fail policy. Disposal releases
|
|
56
|
+
the claim, and expiry lets another participant recover when the owner
|
|
57
|
+
disappears.
|
|
58
|
+
|
|
59
|
+
## Preserve an existing write
|
|
60
|
+
|
|
61
|
+
The claimed target does not have to be an Ablo row. Claim a stable business id
|
|
62
|
+
and keep the final transaction in the application that already owns it.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await using lease = await ablo.taskRuns.claim(taskId, {
|
|
66
|
+
contention: { mode: 'skip' },
|
|
67
|
+
});
|
|
68
|
+
if (!lease) return;
|
|
69
|
+
|
|
70
|
+
const prepared = await performExpensiveWork(taskId);
|
|
71
|
+
return existingTaskService.commitPrepared(taskId, prepared);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That transaction must still re-read, validate, and commit authoritatively. An
|
|
75
|
+
Ablo claim does not join a transaction in another process.
|
|
76
|
+
|
|
77
|
+
## Add stronger guarantees when required
|
|
78
|
+
|
|
79
|
+
- [Concurrency Convention](./concurrency-convention.md): reject a write when an
|
|
80
|
+
earlier premise changed.
|
|
81
|
+
- [Atomic commits](./api.md#atomic-commits): apply several Ablo writes together.
|
|
82
|
+
- [Idempotency](./idempotency.md): make retrying the same Ablo mutation safe.
|
|
83
|
+
- [Agents](./agents.md): configure a stateless HTTP worker.
|
|
84
|
+
- [React](./react.md): add live state and presence for a human interface.
|
package/docs/client-behavior.md
CHANGED
|
@@ -28,20 +28,10 @@ const ablo = Ablo({
|
|
|
28
28
|
});
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
| `schema` | Required for typed model clients. |
|
|
36
|
-
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
37
|
-
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. An HTTPS origin, optionally with a path prefix; plain HTTP is accepted for localhost. Your key travels here, so a URL carrying its own credentials, a query, or a fragment is refused at construction. |
|
|
38
|
-
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
39
|
-
| `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
|
|
40
|
-
| `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and model `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but ordinary calls are HTTP round trips with no socket. Stateful-only model methods (`local`, model `onChange`, and `join`) are compile errors. A listener added through `context().onChange` holds one HTTP response open only until its context changes or its last listener stops. |
|
|
41
|
-
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
42
|
-
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
43
|
-
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
44
|
-
| `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
|
|
31
|
+
The package-root export is the stateless HTTP client for agents, workers, route
|
|
32
|
+
handlers, and other server operations. See [Options](./options.md) for its exact
|
|
33
|
+
constructor reference. Live state and local reads are added through the
|
|
34
|
+
[React client](./react.md).
|
|
45
35
|
|
|
46
36
|
Your database connects out of band — through logical replication (`npx ablo
|
|
47
37
|
connect`), or the signed [Data Source](./data-sources.md) endpoint as the
|
|
@@ -169,7 +159,9 @@ stream, so they never poll.
|
|
|
169
159
|
|
|
170
160
|
## Errors
|
|
171
161
|
|
|
172
|
-
All SDK errors extend `AbloError`
|
|
162
|
+
All SDK errors extend `AbloError`. `type` is the class-name discriminator, such
|
|
163
|
+
as `AbloStaleContextError`; `code` is the wire condition, such as
|
|
164
|
+
`stale_context`. Use `instanceof` in-process and `type` after serialization.
|
|
173
165
|
|
|
174
166
|
| Error | Typical cause |
|
|
175
167
|
|---|---|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Comparison
|
|
2
|
+
|
|
3
|
+
> How Ablo relates to the coordination tools an experienced team may already use.
|
|
4
|
+
|
|
5
|
+
Teams do not need Ablo to build a lock. Redis reservations and PostgreSQL locks
|
|
6
|
+
are proven, inexpensive primitives. Ablo is useful when the same team would
|
|
7
|
+
otherwise have to define ownership, expiry, recovery, waiting, participant
|
|
8
|
+
identity, stale-result handling, and visibility for each new workflow.
|
|
9
|
+
|
|
10
|
+
## Versus PostgreSQL locks
|
|
11
|
+
|
|
12
|
+
- **Keep the lock.** PostgreSQL should continue to protect the short,
|
|
13
|
+
authoritative database transaction.
|
|
14
|
+
- **Coordinate before the transaction.** An Ablo claim can cover the model call,
|
|
15
|
+
document search, browser session, or tool run that happens before commit.
|
|
16
|
+
- **Keep the connection short-lived.** The application does not have to hold one
|
|
17
|
+
database session while an agent waits on external work.
|
|
18
|
+
- **Revalidate at commit.** The existing service still applies authorization,
|
|
19
|
+
constraints, version checks, and business rules.
|
|
20
|
+
|
|
21
|
+
## Versus Redis reservations
|
|
22
|
+
|
|
23
|
+
- **The primitive is familiar.** Ablo uses expiring leases for live ownership
|
|
24
|
+
and waiting; it does not claim that temporary reservations are novel.
|
|
25
|
+
- **The lifecycle is defined.** Acquisition, skip, wait, heartbeat, release,
|
|
26
|
+
expiry, cancellation, and recovery share one client contract.
|
|
27
|
+
- **Ownership has identity.** A claim belongs to a scoped participant rather
|
|
28
|
+
than only an arbitrary worker string.
|
|
29
|
+
- **Correctness stays durable.** PostgreSQL and commit-time version checks remain
|
|
30
|
+
the backstop when an expired worker resumes late.
|
|
31
|
+
- **Contention is visible.** Owners, waiters, duration, and rejection reasons use
|
|
32
|
+
the same operational model across workflows.
|
|
33
|
+
|
|
34
|
+
## Versus queues and workflow engines
|
|
35
|
+
|
|
36
|
+
- Queues decide who receives a job; claims decide who may act on a contested
|
|
37
|
+
business resource.
|
|
38
|
+
- Redelivery still needs idempotency, and delivery does not prove the rows behind
|
|
39
|
+
a decision are unchanged.
|
|
40
|
+
- Workflow engines remain the right owner for durable steps, timers, and retry
|
|
41
|
+
history. Ablo coordinates those workflows with other agents, services, and
|
|
42
|
+
people touching the same state.
|
|
43
|
+
|
|
44
|
+
## Versus rolling your own
|
|
45
|
+
|
|
46
|
+
- Start without designing Redis key conventions, ownership tokens, renewal,
|
|
47
|
+
safe release, wait queues, and crash recovery for every call site.
|
|
48
|
+
- Reuse one participant and authorization model across workers and human
|
|
49
|
+
interfaces.
|
|
50
|
+
- Test against one documented failure contract instead of rebuilding delayed
|
|
51
|
+
worker, expiry, retry, and partial-failure tests per workflow.
|
|
52
|
+
- Add captured reads, guarded writes, and atomic Ablo commits only when the
|
|
53
|
+
operation needs them.
|
|
54
|
+
|
|
55
|
+
## When Ablo is not necessary
|
|
56
|
+
|
|
57
|
+
PostgreSQL or a small internal reservation can be enough when one team controls
|
|
58
|
+
every writer, work is short, contention is rare, and stale or duplicate work is
|
|
59
|
+
cheap. Ablo becomes more valuable as slow agent work, independent participants,
|
|
60
|
+
shared resources, recovery, authority, and operational explanation matter.
|
|
61
|
+
|
|
62
|
+
The adoption boundary is intentionally small: keep the architecture that
|
|
63
|
+
already works and [coordinate one existing operation](./coordinate-existing-work.md).
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> A write either declares what it read or deliberately does not.
|
|
4
4
|
|
|
5
|
+
Use a captured read when a write must be rejected because an earlier premise
|
|
6
|
+
changed.
|
|
7
|
+
|
|
8
|
+
Read the authoritative premise, carry
|
|
9
|
+
that exact evidence into the write, and handle the documented failure code.
|
|
10
|
+
|
|
5
11
|
Ablo does not put a configurable stale mode between your code and a
|
|
6
12
|
commit. The public choice is visible at the call site:
|
|
7
13
|
|
|
@@ -38,6 +44,22 @@ The server validates every declared premise inside the write transaction. If
|
|
|
38
44
|
one is stale, the entire mutation rejects before any write applies. Re-read,
|
|
39
45
|
recompute, and submit a new mutation when that is the behavior you want.
|
|
40
46
|
|
|
47
|
+
```ts
|
|
48
|
+
import { AbloStaleContextError } from '@abloatai/ablo';
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await submitGuardedWrite();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof AbloStaleContextError && error.code === 'stale_context') {
|
|
54
|
+
return rebuildFromFreshReads();
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`error.type` is the class-name discriminator (`AbloStaleContextError`);
|
|
61
|
+
`error.code` is the wire condition (`stale_context`).
|
|
62
|
+
|
|
41
63
|
## Unguarded writes
|
|
42
64
|
|
|
43
65
|
Use `get` or `list` when you only need to observe, and omit `reads` when the
|
|
@@ -71,6 +93,11 @@ A claim protects a target across a slower read → decide → write interval.
|
|
|
71
93
|
Foreign writers are rejected while the claim is active; contenders that ask
|
|
72
94
|
to queue wait in order. Ordinary reads stay open.
|
|
73
95
|
|
|
96
|
+
When the final effect remains in an existing application path—such as its API,
|
|
97
|
+
database transaction, filesystem, or Git merge—start with
|
|
98
|
+
[Coordinate existing work](./coordinate-existing-work.md). Use the
|
|
99
|
+
row-backed claim below when the target and final write belong to an Ablo model.
|
|
100
|
+
|
|
74
101
|
Claims and stale reads answer different questions:
|
|
75
102
|
|
|
76
103
|
| Mechanism | Lifetime | Question |
|
package/docs/context.md
CHANGED
|
@@ -127,6 +127,26 @@ delivery closes. The final create, update, or delete must still receive
|
|
|
127
127
|
`reads: ctx.reads`; that check remains authoritative if delivery races the
|
|
128
128
|
write or is disconnected.
|
|
129
129
|
|
|
130
|
+
## Retry stale agent work
|
|
131
|
+
|
|
132
|
+
Use this policy for a long-running turn:
|
|
133
|
+
|
|
134
|
+
1. Create one operation key before the retry loop.
|
|
135
|
+
2. Build a new context on every attempt.
|
|
136
|
+
3. Use `onChange` to abort the model and cancellable tools.
|
|
137
|
+
4. Still pass `reads: ctx.reads` to the final write.
|
|
138
|
+
5. Retry a stale attempt at most a small fixed number of times.
|
|
139
|
+
|
|
140
|
+
Automatic retry is safe only before the first external action that cannot be
|
|
141
|
+
canceled. After sending an email, charging a card, or receiving an uncertain
|
|
142
|
+
tool response, look up that action by the same operation key. Do not run it
|
|
143
|
+
again unless that tool explicitly guarantees the retry is safe.
|
|
144
|
+
|
|
145
|
+
[`examples/stale-context-agent-turn.ts`](../examples/stale-context-agent-turn.ts)
|
|
146
|
+
is the complete copyable loop. Put that function in the application operation
|
|
147
|
+
that owns the write; GraphQL resolvers and route handlers should call it once,
|
|
148
|
+
not add another retry loop.
|
|
149
|
+
|
|
130
150
|
## External context
|
|
131
151
|
|
|
132
152
|
Provider results pass through without an adapter or provider dependency. The
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Coordinate Existing Work
|
|
2
|
+
|
|
3
|
+
> Start here. Preserve the application, coordinate one operation, and read another page only when the routing table sends you there.
|
|
4
|
+
|
|
5
|
+
Use this guide to coordinate expensive work while its existing PostgreSQL
|
|
6
|
+
transaction remains authoritative.
|
|
7
|
+
|
|
8
|
+
Many production systems already reserve slow work in Redis and protect the
|
|
9
|
+
final write with a PostgreSQL transaction. That is a sound architecture. The
|
|
10
|
+
cost appears when every workflow must independently define ownership, expiry,
|
|
11
|
+
heartbeat, waiting, recovery, participant identity, and operational visibility.
|
|
12
|
+
|
|
13
|
+
Ablo standardizes that coordination lifecycle. It does not replace the
|
|
14
|
+
application's authoritative transaction.
|
|
15
|
+
|
|
16
|
+
## Existing backend: copy this shape
|
|
17
|
+
|
|
18
|
+
For an application that already owns its API and Postgres transaction:
|
|
19
|
+
|
|
20
|
+
1. Choose one named operation, such as `completeTask`.
|
|
21
|
+
2. Keep its API, authorization, validation, transaction, locks, and constraints.
|
|
22
|
+
3. Give each concurrent worker a distinct scoped credential.
|
|
23
|
+
4. Claim the operation's stable business identifier before expensive work.
|
|
24
|
+
5. While the claim is held, call the existing operation to commit.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const ablo = Ablo({
|
|
28
|
+
schema,
|
|
29
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
30
|
+
transport: 'http',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await using lease = await ablo.taskRuns.claim(taskId, {
|
|
34
|
+
contention: { mode: 'skip' },
|
|
35
|
+
ttl: '30s',
|
|
36
|
+
heartbeat: { every: '10s' },
|
|
37
|
+
});
|
|
38
|
+
if (!lease) return { outcome: 'skipped' };
|
|
39
|
+
|
|
40
|
+
const prepared = await performExpensiveWork(taskId);
|
|
41
|
+
return existingTaskService.commitPrepared(taskId, prepared);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The string passed to `claim` is an identifier-only lease. It does not require an
|
|
45
|
+
Ablo row and returns no row data. `commitPrepared` must still re-read and validate
|
|
46
|
+
inside the application's Postgres transaction. An Ablo lease does not join a
|
|
47
|
+
transaction in another process.
|
|
48
|
+
|
|
49
|
+
If the operation only needs to inspect an Ablo row before calling the existing
|
|
50
|
+
write path, keep it this small:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const task = await ablo.tasks.get({ id: taskId });
|
|
54
|
+
if (!task) throw new Error('task not found');
|
|
55
|
+
|
|
56
|
+
await completeTask({ id: task.id, expectedTitle: task.title });
|
|
57
|
+
return task.id;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`get({ id })` observes a row. Use `read({ id })` only when its captured evidence
|
|
61
|
+
will be passed to an Ablo write through `reads`.
|
|
62
|
+
|
|
63
|
+
## Change the shape only when required
|
|
64
|
+
|
|
65
|
+
| Your operation requires this | Use | Read |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| The claimed target is an Ablo model row and the final write goes through that model. | Row-backed claim; pass the returned `claim` to the write. | [Coordination](../coordination.md) |
|
|
68
|
+
| The result depends on an Ablo row that may change while work runs. | `read(...)` the premise and pass it through `reads`. | [Concurrency Convention](../concurrency-convention.md) |
|
|
69
|
+
| Several Ablo writes must all land or none may land. | One `commits.create(...)`. | [API Reference](../api.md) |
|
|
70
|
+
| A person needs live state, presence, or reactive local reads. | WebSocket client for that human interface. Workers stay on HTTP. | [React](../react.md) |
|
|
71
|
+
| The operation sends email, charges money, writes a file, or calls another provider. | That system's idempotency key or an application outbox. | [Idempotency](../idempotency.md) |
|
|
72
|
+
|
|
73
|
+
Do not add a mechanism unless its condition is true. In particular, do not
|
|
74
|
+
replace an existing database operation merely because Ablo coordinates it.
|
|
75
|
+
|
|
76
|
+
## Check these boundaries before editing
|
|
77
|
+
|
|
78
|
+
- What existing operation and public result must remain unchanged?
|
|
79
|
+
- What stable identifier represents the contested work?
|
|
80
|
+
- Which process performs expensive work, and which process commits?
|
|
81
|
+
- Does Postgres or Ablo own each final write?
|
|
82
|
+
- What does the caller receive on contention, failure, and retry?
|
|
83
|
+
|
|
84
|
+
If an answer is unknown, preserve the existing write path. Do not copy an
|
|
85
|
+
advanced example or expand the schema to hide the missing decision.
|
|
86
|
+
|
|
87
|
+
## Prove the implementation
|
|
88
|
+
|
|
89
|
+
For the adopted operation, test that:
|
|
90
|
+
|
|
91
|
+
1. The coordinated and existing paths return the same public result.
|
|
92
|
+
2. Two distinct participants do not both perform the expensive work.
|
|
93
|
+
3. A contender follows the chosen skip or wait behavior.
|
|
94
|
+
4. Failure or expiry allows a later attempt to proceed.
|
|
95
|
+
5. The existing authorization and database transaction still run.
|
|
96
|
+
|
|
97
|
+
Local tests prove application behavior. Run
|
|
98
|
+
[`examples/coordination-conformance`](../../../../examples/coordination-conformance/README.md)
|
|
99
|
+
against hosted Ablo to prove participant identity, heartbeat, exclusion, release,
|
|
100
|
+
and expiry. Run staging against the real database and authorization to prove the
|
|
101
|
+
production boundary.
|
|
102
|
+
|
|
103
|
+
For setup, read the [Integration Guide](../integration-guide.md). The complete
|
|
104
|
+
implementation on this page is the starter route for an existing backend.
|