@abloatai/ablo 0.57.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 +12 -5
- package/CHANGELOG.md +269 -13
- package/README.md +6 -5
- package/dist/ai-sdk.d.ts +1 -1
- package/dist/ai-sdk.d.ts.map +1 -1
- package/dist/context/evidence.d.ts +6 -8
- package/dist/context/evidence.d.ts.map +1 -1
- package/dist/context/evidence.js +6 -20
- package/dist/context/evidence.js.map +1 -1
- package/dist/context/index.d.ts +23 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +26 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/onChange.d.ts +9 -0
- package/dist/context/onChange.d.ts.map +1 -0
- package/dist/context/onChange.js +37 -0
- package/dist/context/onChange.js.map +1 -0
- package/dist/source-conformance.d.ts +1 -1
- package/dist/source-conformance.d.ts.map +1 -1
- package/dist/source-conformance.js +1 -1
- package/dist/source-conformance.js.map +1 -1
- package/dist/source-drizzle.d.ts +1 -1
- package/dist/source-drizzle.d.ts.map +1 -1
- package/dist/source-drizzle.js +1 -1
- package/dist/source-drizzle.js.map +1 -1
- package/dist/source-kysely.d.ts +1 -1
- package/dist/source-kysely.d.ts.map +1 -1
- package/dist/source-kysely.js +1 -1
- package/dist/source-kysely.js.map +1 -1
- package/dist/source-next.d.ts +1 -1
- package/dist/source-next.d.ts.map +1 -1
- package/dist/source-next.js +1 -1
- package/dist/source-next.js.map +1 -1
- package/docs/agents.md +38 -15
- package/docs/api-keys.md +6 -6
- package/docs/api.md +107 -34
- package/docs/basic-usage.md +84 -0
- package/docs/branch-development.md +23 -4
- package/docs/cli.md +16 -9
- package/docs/client-behavior.md +27 -29
- package/docs/comparison.md +63 -0
- package/docs/concurrency-convention.md +92 -75
- package/docs/context.md +76 -31
- package/docs/coordinate-existing-work.md +104 -0
- package/docs/coordination.md +93 -38
- package/docs/data-sources.md +12 -6
- package/docs/debugging.md +1 -1
- package/docs/deployment.md +19 -1
- package/docs/examples/agent-human.md +6 -18
- package/docs/examples/coordination-conformance.md +69 -0
- package/docs/examples/evidence-backed-document-pipeline.md +488 -0
- package/docs/examples/existing-python-backend.md +10 -13
- package/docs/examples/nextjs.md +2 -2
- package/docs/examples/scoped-agent.md +18 -1
- package/docs/examples/server-agent.md +2 -2
- package/docs/faq.md +75 -0
- package/docs/groups.md +19 -139
- package/docs/guarantees.md +8 -8
- package/docs/idempotency.md +3 -0
- package/docs/identity.md +2 -1
- package/docs/implement.md +61 -0
- package/docs/implementation-index.md +20 -0
- package/docs/index.md +59 -173
- package/docs/installation.md +77 -0
- package/docs/instrumentation.md +52 -0
- package/docs/integration-guide.md +20 -19
- package/docs/integrations/sandbox-runtime.md +157 -0
- package/docs/integrations.md +9 -0
- package/docs/migration.md +12 -7
- package/docs/operating-on-your-database.md +7 -0
- package/docs/options.md +172 -0
- package/docs/quickstart.md +25 -14
- package/docs/react.md +9 -9
- package/docs/schema-contract.md +14 -13
- package/docs/security.md +64 -0
- package/docs/sessions.md +1 -1
- package/examples/README.md +8 -2
- package/examples/agent-turn.ts +1 -1
- package/examples/expensive-agent-turn.ts +1 -1
- package/examples/stale-context-agent-turn.ts +106 -0
- package/llms.txt +23 -12
- package/package.json +7 -6
- package/dist/context/sources.d.ts +0 -21
- package/dist/context/sources.d.ts.map +0 -1
- package/dist/context/sources.js +0 -36
- package/dist/context/sources.js.map +0 -1
- package/dist/context.d.ts +0 -22
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
package/docs/client-behavior.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Client Behavior
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Guarded writes, claim behavior, and which errors are safe to retry.
|
|
4
4
|
|
|
5
|
-
When several writers touch the same data at once — an agent worker, a Server
|
|
5
|
+
When several writers touch the same data at once — an agent worker, a Server
|
|
6
|
+
Action, a person in the browser — the SDK protects explicit read dependencies
|
|
7
|
+
and claims records across slow work. This page describes those guarantees and
|
|
8
|
+
which errors are safe to retry.
|
|
6
9
|
|
|
7
10
|
Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
|
|
8
11
|
|
|
@@ -25,20 +28,10 @@ const ablo = Ablo({
|
|
|
25
28
|
});
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
| `schema` | Required for typed model clients. |
|
|
33
|
-
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
34
|
-
| `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. |
|
|
35
|
-
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
36
|
-
| `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
|
|
37
|
-
| `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (the `local` reads, `onChange`, `join`) are compile errors rather than runtime gaps. |
|
|
38
|
-
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
39
|
-
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
40
|
-
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
41
|
-
| `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).
|
|
42
35
|
|
|
43
36
|
Your database connects out of band — through logical replication (`npx ablo
|
|
44
37
|
connect`), or the signed [Data Source](./data-sources.md) endpoint as the
|
|
@@ -53,7 +46,7 @@ Each schema model becomes a typed model:
|
|
|
53
46
|
```ts
|
|
54
47
|
await ablo.ready();
|
|
55
48
|
|
|
56
|
-
const report = await ablo.weatherReports.
|
|
49
|
+
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
|
|
57
50
|
const local = ablo.weatherReports.local.get('report_stockholm');
|
|
58
51
|
|
|
59
52
|
await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
|
|
@@ -65,7 +58,7 @@ On the reactive client, each model write changes local state optimistically
|
|
|
65
58
|
before the call returns. Its promise always waits for authoritative
|
|
66
59
|
confirmation, so `await update(...)` is the confirmation barrier.
|
|
67
60
|
|
|
68
|
-
Call `get`/`list`
|
|
61
|
+
Call `get`/`list` to observe, or `read` when a later mutation depends on the row.
|
|
69
62
|
After that, `local.get`/`local.list`/`local.count` read the already-synced data instantly with
|
|
70
63
|
no `await`, and stay reactive in render. Use the async pair to load, the sync trio
|
|
71
64
|
to read.
|
|
@@ -83,14 +76,13 @@ through the same model client path. A human Server Action, a browser view, and a
|
|
|
83
76
|
agent worker can all use `ablo.weatherReports`:
|
|
84
77
|
|
|
85
78
|
```ts
|
|
86
|
-
const report = await ablo.weatherReports.
|
|
87
|
-
|
|
79
|
+
const report = await ablo.weatherReports.read({ id });
|
|
80
|
+
if (!report) throw new Error('Row not found');
|
|
88
81
|
|
|
89
82
|
await ablo.weatherReports.update({
|
|
90
83
|
id,
|
|
91
84
|
data: patch,
|
|
92
|
-
|
|
93
|
-
onStale: 'reject',
|
|
85
|
+
reads: [report],
|
|
94
86
|
});
|
|
95
87
|
```
|
|
96
88
|
|
|
@@ -106,24 +98,28 @@ actor routing through Ablo is coordinated. The one write it can't coordinate is
|
|
|
106
98
|
one made directly against your database, around Ablo — the WAL echo still catches
|
|
107
99
|
it for reads, but it bypasses claims and ordering.
|
|
108
100
|
|
|
109
|
-
##
|
|
101
|
+
## Guarded Writes
|
|
110
102
|
|
|
111
103
|
```ts
|
|
104
|
+
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
|
|
105
|
+
if (!report) throw new Error('report not found');
|
|
106
|
+
|
|
112
107
|
await ablo.weatherReports.update({
|
|
113
|
-
id:
|
|
108
|
+
id: report.id,
|
|
114
109
|
data: { status: 'ready' },
|
|
115
|
-
|
|
116
|
-
onStale: 'reject',
|
|
110
|
+
reads: [report],
|
|
117
111
|
idempotencyKey: 'report_stockholm:mark-ready:v1',
|
|
118
112
|
});
|
|
119
113
|
```
|
|
120
114
|
|
|
121
115
|
| Option | Purpose |
|
|
122
116
|
|---|---|
|
|
123
|
-
| `
|
|
124
|
-
| `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. |
|
|
117
|
+
| `reads` | Exact rows returned by `read` that this mutation depends on. |
|
|
125
118
|
| `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
|
|
126
119
|
|
|
120
|
+
A stale premise always rejects with `AbloStaleContextError`. Omit `reads` only
|
|
121
|
+
when the assignment is intentionally unconditional.
|
|
122
|
+
|
|
127
123
|
## Claimed Behavior
|
|
128
124
|
|
|
129
125
|
If your update involves a slow step — an API call, an LLM round-trip — and someone
|
|
@@ -163,7 +159,9 @@ stream, so they never poll.
|
|
|
163
159
|
|
|
164
160
|
## Errors
|
|
165
161
|
|
|
166
|
-
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.
|
|
167
165
|
|
|
168
166
|
| Error | Typical cause |
|
|
169
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).
|
|
@@ -1,59 +1,81 @@
|
|
|
1
1
|
# Concurrency Convention
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> A write either declares what it read or deliberately does not.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
participant does when it hits a conflict, and it is the policy for that model.
|
|
8
|
-
A per-write `onStale` states the disposition for one write. Ablo enforces what
|
|
9
|
-
you declared and nothing else.
|
|
5
|
+
Use a captured read when a write must be rejected because an earlier premise
|
|
6
|
+
changed.
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
A plain write has no stale premise:
|
|
8
|
+
Read the authoritative premise, carry
|
|
9
|
+
that exact evidence into the write, and handle the documented failure code.
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```
|
|
11
|
+
Ablo does not put a configurable stale mode between your code and a
|
|
12
|
+
commit. The public choice is visible at the call site:
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
- `get` and `list` observe state. They do not create a write premise.
|
|
15
|
+
- `read` returns a row that can be passed to a mutation in `reads`.
|
|
16
|
+
- a mutation with `reads` rejects with `AbloStaleContextError` if any declared
|
|
17
|
+
premise changed;
|
|
18
|
+
- a mutation without `reads` is an unconditional, last-write-wins assignment
|
|
19
|
+
when no active claim applies.
|
|
23
20
|
|
|
24
21
|
## Guarded writes
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
Use `read` for every row that materially influenced a decision, then pass the
|
|
24
|
+
exact returned objects to the mutation:
|
|
27
25
|
|
|
28
26
|
```ts
|
|
29
|
-
const record = await ablo.records.
|
|
30
|
-
const
|
|
31
|
-
if (!record || !
|
|
27
|
+
const record = await ablo.records.read({ id });
|
|
28
|
+
const rules = await ablo.rules.read({ id: rulesId });
|
|
29
|
+
if (!record || !rules) throw new Error('required input is missing');
|
|
32
30
|
|
|
33
31
|
await ablo.records.update({
|
|
34
32
|
id: record.id,
|
|
35
|
-
data:
|
|
36
|
-
reads: [record,
|
|
33
|
+
data: decide(record, rules),
|
|
34
|
+
reads: [record, rules],
|
|
37
35
|
});
|
|
38
36
|
```
|
|
39
37
|
|
|
40
|
-
Ablo
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
Ablo records only the evidence needed for the check: model, id, and the
|
|
39
|
+
watermark at which the row was read. It does not retain the row's contents.
|
|
40
|
+
The exact object identity matters, so clones, fabricated rows, and rows from a
|
|
41
|
+
different client are rejected locally.
|
|
42
|
+
|
|
43
|
+
The server validates every declared premise inside the write transaction. If
|
|
44
|
+
one is stale, the entire mutation rejects before any write applies. Re-read,
|
|
45
|
+
recompute, and submit a new mutation when that is the behavior you want.
|
|
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
|
+
|
|
63
|
+
## Unguarded writes
|
|
64
|
+
|
|
65
|
+
Use `get` or `list` when you only need to observe, and omit `reads` when the
|
|
66
|
+
new value should win regardless of what was previously observed:
|
|
43
67
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
| `overwrite` | Apply the new value without enforcing the stale premise. |
|
|
68
|
+
```ts
|
|
69
|
+
const visible = await ablo.records.get({ id });
|
|
70
|
+
await ablo.records.update({ id, data: { status: 'done' } });
|
|
71
|
+
```
|
|
49
72
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`overwrite` only when the newer assignment should unconditionally win.
|
|
73
|
+
This is deliberately unconditional, not an implicit fallback. It is suitable
|
|
74
|
+
for independent assignments and inappropriate for read-modify-write decisions.
|
|
53
75
|
|
|
54
76
|
## Functional updates
|
|
55
77
|
|
|
56
|
-
For a pure
|
|
78
|
+
For a pure calculation based on one current row, use the functional form:
|
|
57
79
|
|
|
58
80
|
```ts
|
|
59
81
|
await ablo.counters.update(counterId, (current) => ({
|
|
@@ -61,59 +83,54 @@ await ablo.counters.update(counterId, (current) => ({
|
|
|
61
83
|
}));
|
|
62
84
|
```
|
|
63
85
|
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
The SDK reads, attempts a guarded write, and retries from fresh state within a
|
|
87
|
+
bounded budget. Because the updater may run more than once, do not perform
|
|
88
|
+
side effects inside it.
|
|
66
89
|
|
|
67
90
|
## Claims
|
|
68
91
|
|
|
69
|
-
A claim protects a target across a
|
|
70
|
-
|
|
71
|
-
wait
|
|
72
|
-
choose a different disposition for a participant kind.
|
|
92
|
+
A claim protects a target across a slower read → decide → write interval.
|
|
93
|
+
Foreign writers are rejected while the claim is active; contenders that ask
|
|
94
|
+
to queue wait in order. Ordinary reads stay open.
|
|
73
95
|
|
|
74
|
-
|
|
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.
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
- A stale guard proves that the state a write depended on has not changed.
|
|
78
|
-
- A write made under a claim is still rejected if its own claimed snapshot has
|
|
79
|
-
become stale.
|
|
101
|
+
Claims and stale reads answer different questions:
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
| Mechanism | Lifetime | Question |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `reads` | One mutation | Is every input to this decision still current? |
|
|
106
|
+
| claim | Slow work interval | Who may write this target while work is underway? |
|
|
107
|
+
| database transaction | One apply | Can this physical change commit atomically? |
|
|
108
|
+
| idempotency key | Retries | Has this same mutation already been applied? |
|
|
82
109
|
|
|
83
|
-
|
|
110
|
+
Claims do not hold a Postgres transaction open while an agent thinks. The
|
|
111
|
+
database transaction remains short and owns only validation plus apply.
|
|
84
112
|
|
|
85
|
-
|
|
86
|
-
write targets somewhere else. This protects decisions such as “update the record
|
|
87
|
-
only if the deal I inspected has not changed.” A stale batch premise applies to
|
|
88
|
-
the whole batch so atomicity is preserved.
|
|
89
|
-
|
|
90
|
-
Use the high-level model methods unless you are building a custom runtime. When
|
|
91
|
-
you do use batch premises, declare only the rows or groups that materially
|
|
92
|
-
influenced the decision; overly broad premises create unnecessary contention.
|
|
93
|
-
|
|
94
|
-
## Notifications
|
|
113
|
+
## Cross-row and batch premises
|
|
95
114
|
|
|
96
|
-
A
|
|
97
|
-
|
|
115
|
+
A write may depend on rows other than its target. Put every influential row in
|
|
116
|
+
`reads`; if any one changed, Ablo rejects the whole mutation so atomicity is
|
|
117
|
+
preserved. Declare only material dependencies, because broader premises create
|
|
118
|
+
more contention.
|
|
98
119
|
|
|
99
|
-
|
|
120
|
+
Low-level runtimes can also declare row or group watermarks directly. They have
|
|
121
|
+
the same fixed result: stale rejects, fresh applies.
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
2. Recompute the intended change.
|
|
103
|
-
3. Submit a new guarded write with a fresh premise.
|
|
123
|
+
## Live change delivery is separate
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
|
|
125
|
+
Model `onChange` is not a stale-write disposition. It streams committed changes
|
|
126
|
+
to a stateful WebSocket client. `context().onChange` has the narrower job of
|
|
127
|
+
calling once when one of that context's exact reads changes; HTTP delivers it
|
|
128
|
+
through a response held open for that listener. Neither replaces passing those
|
|
129
|
+
same `reads` to create, update, or delete.
|
|
107
130
|
|
|
108
131
|
## Boundaries
|
|
109
132
|
|
|
110
|
-
Concurrency control does not replace
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- idempotency for safely replaying the same request;
|
|
115
|
-
- claims for exclusivity across slow, side-effecting work.
|
|
116
|
-
|
|
117
|
-
The rule is simple: the model's `conflict` setting is the policy, and each write
|
|
118
|
-
declares what it read. Plain writes are last-write-wins because declaring
|
|
119
|
-
nothing is itself a decision, so make it deliberately.
|
|
133
|
+
Concurrency control does not replace authorization, database constraints,
|
|
134
|
+
transactions, or idempotency. The rule at the SDK boundary is intentionally
|
|
135
|
+
small: `read` declares a dependency, `reads` enforces it, and omission means an
|
|
136
|
+
unconditional write.
|
package/docs/context.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Context
|
|
2
2
|
|
|
3
3
|
> Assemble the current information for an action and carry its authoritative
|
|
4
|
-
> Ablo reads into the write that follows.
|
|
4
|
+
> Ablo reads into the model write or atomic commit that follows.
|
|
5
5
|
|
|
6
6
|
`context()` is a standalone SDK function. It does not run a model, keep a
|
|
7
7
|
conversation, search documents, or create memory. The application chooses the
|
|
8
8
|
values; Ablo awaits them and identifies the exact returned rows that can guard
|
|
9
|
-
a later write.
|
|
9
|
+
a later model write or atomic commit.
|
|
10
10
|
|
|
11
11
|
## Context, model, write
|
|
12
12
|
|
|
@@ -21,7 +21,7 @@ import { generateText } from 'ai';
|
|
|
21
21
|
const ctx = await context({
|
|
22
22
|
ablo,
|
|
23
23
|
data: {
|
|
24
|
-
record: ablo.records.
|
|
24
|
+
record: ablo.records.read({ id: recordId }),
|
|
25
25
|
records: ablo.records.list({ where: { recordId } }),
|
|
26
26
|
memory: loadMemories(recordId),
|
|
27
27
|
},
|
|
@@ -42,6 +42,25 @@ await ablo.records.update({
|
|
|
42
42
|
});
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
The same captured rows guard an atomic batch. There is no second read format
|
|
46
|
+
and no manual conversion step:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
await ablo.commits.create({
|
|
50
|
+
operations: [
|
|
51
|
+
{ action: 'update', model: 'records', id: recordId, data: update },
|
|
52
|
+
{ action: 'create', model: 'auditEvents', id: eventId, data: event },
|
|
53
|
+
],
|
|
54
|
+
reads: ctx.reads,
|
|
55
|
+
idempotencyKey: runId,
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both the stateless HTTP client and the reactive WebSocket client resolve these
|
|
60
|
+
captured rows into canonical `{ model, id, readAt }` dependencies before the
|
|
61
|
+
commit reaches the transport. A claim returned by a typed model resource can
|
|
62
|
+
also be passed directly as the batch `claim`.
|
|
63
|
+
|
|
45
64
|
If an authoritative row moves during the model call, the update rejects with
|
|
46
65
|
`AbloStaleContextError`. Rebuild the context before trying again. The model is
|
|
47
66
|
not called or retried by `context()`.
|
|
@@ -54,7 +73,7 @@ protection according to the work:
|
|
|
54
73
|
| Situation | Use | Why |
|
|
55
74
|
|---|---|---|
|
|
56
75
|
| Bring several current values into one model call | `context()` | Awaits the selected values and collects their evidence. |
|
|
57
|
-
| Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when
|
|
76
|
+
| Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when a model write or atomic commit reaches the server. |
|
|
58
77
|
| Avoid paying for a model call while another participant owns the row | `claim()` | Waits first, then supplies fresh state. |
|
|
59
78
|
| Compute a patch from one current row without external work | Functional `update()` | Re-reads and retries the pure calculation. |
|
|
60
79
|
|
|
@@ -64,39 +83,69 @@ See [Coordination](./coordination.md) for the full choice.
|
|
|
64
83
|
|
|
65
84
|
## Result
|
|
66
85
|
|
|
67
|
-
The result has
|
|
86
|
+
The result has three members:
|
|
68
87
|
|
|
69
88
|
| Member | Meaning |
|
|
70
89
|
|---|---|
|
|
71
90
|
| `data` | The selected values, with nested promises resolved. |
|
|
72
|
-
| `reads` | Exact Ablo rows accepted by a write's `reads` option. |
|
|
73
|
-
| `
|
|
74
|
-
| `sources` | One provenance summary for each top-level value. |
|
|
91
|
+
| `reads` | Exact Ablo rows accepted by a model write or atomic commit's `reads` option. |
|
|
92
|
+
| `onChange` | Calls a listener once if any exact row in `reads` changes. Returns a function that stops listening. |
|
|
75
93
|
|
|
76
|
-
If a row in `ctx.reads` moves before
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
If a row in `ctx.reads` moves before a model write or atomic commit, the server
|
|
95
|
+
rejects the operation as stale. Plain values remain in `ctx.data`, but only
|
|
96
|
+
exact Ablo reads appear in `ctx.reads` and gain that guarantee.
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
ctx.sources;
|
|
82
|
-
// [
|
|
83
|
-
// { key: 'record', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
|
|
84
|
-
// { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null },
|
|
85
|
-
// ]
|
|
86
|
-
```
|
|
98
|
+
## Stop work when context changes
|
|
87
99
|
|
|
88
|
-
|
|
89
|
-
its exact Ablo rows appear in `ctx.reads`:
|
|
100
|
+
`onChange` lets long-running work stop early without changing the write rule:
|
|
90
101
|
|
|
91
102
|
```ts
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const stop = ctx.onChange((error) => controller.abort(error));
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const result = await generateText({
|
|
108
|
+
model,
|
|
109
|
+
abortSignal: controller.signal,
|
|
110
|
+
messages: [contextMessage(ctx)],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await ablo.records.update({
|
|
114
|
+
id: ctx.data.record.id,
|
|
115
|
+
data: parseTaskUpdate(result.text),
|
|
116
|
+
reads: ctx.reads,
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
stop();
|
|
120
|
+
}
|
|
96
121
|
```
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
The first listener starts delivery and all listeners on that context share it.
|
|
124
|
+
The last returned `stop` closes it. A context with no reads opens nothing. The
|
|
125
|
+
first matching change calls every listener with `AbloStaleContextError`, then
|
|
126
|
+
delivery closes. The final create, update, or delete must still receive
|
|
127
|
+
`reads: ctx.reads`; that check remains authoritative if delivery races the
|
|
128
|
+
write or is disconnected.
|
|
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.
|
|
100
149
|
|
|
101
150
|
## External context
|
|
102
151
|
|
|
@@ -108,7 +157,7 @@ Reducto, or another system behind their own interfaces.
|
|
|
108
157
|
const ctx = await context({
|
|
109
158
|
ablo,
|
|
110
159
|
data: {
|
|
111
|
-
record: ablo.records.
|
|
160
|
+
record: ablo.records.read({ id: recordId }),
|
|
112
161
|
memory: loadMemories({ query, userId }),
|
|
113
162
|
related: findRelatedChunks({ projectId, query }),
|
|
114
163
|
evidence: extractEvidence({ recordId }),
|
|
@@ -157,14 +206,10 @@ The first version deliberately has no:
|
|
|
157
206
|
|
|
158
207
|
- search or memory API;
|
|
159
208
|
- provider registry or provider-specific adapter;
|
|
160
|
-
- `since` cursor or incremental `changes` result;
|
|
161
209
|
- context session, persistence, or sharing lifecycle;
|
|
162
210
|
- token counting, trimming, summarisation, or model call;
|
|
163
211
|
- guarantee that a person or model understood the included information.
|
|
164
212
|
|
|
165
|
-
Store `ctx.cursor` in application-owned state if it is useful. Incremental
|
|
166
|
-
context is not yet derived from it.
|
|
167
|
-
|
|
168
213
|
`context` remains available as a schema model name. The helper lives at
|
|
169
214
|
`@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member
|
|
170
215
|
of the schema-backed client.
|