@abloatai/ablo 0.31.0 → 0.33.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 +3 -3
- package/CHANGELOG.md +50 -0
- package/README.md +25 -13
- package/dist/cli.cjs +1686 -1219
- package/dist/coordination/schema.d.ts +4 -2
- package/dist/coordination/schema.js +23 -9
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.js +2 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +55 -1
- package/dist/schema/schema.js +47 -0
- package/dist/schema/select.js +1 -0
- package/dist/schema/serialize.d.ts +3 -1
- package/dist/schema/serialize.js +9 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -4
- package/dist/source/adapters/prisma.js +3 -3
- package/dist/source/idempotency.d.ts +19 -4
- package/dist/source/idempotency.js +19 -4
- package/dist/source/migrations.d.ts +4 -1
- package/dist/source/migrations.js +36 -5
- package/docs/agent-messaging.md +1 -2
- package/docs/api.md +4 -4
- package/docs/audit.md +35 -23
- package/docs/client-behavior.md +7 -4
- package/docs/concurrency-convention.md +3 -3
- package/docs/coordination.md +13 -1
- package/docs/data-sources.md +34 -32
- package/docs/examples/agent-human.md +2 -2
- package/docs/examples/ai-sdk-tool.md +1 -1
- package/docs/examples/nextjs.md +1 -1
- package/docs/examples/server-agent.md +2 -2
- package/docs/groups.md +160 -0
- package/docs/guarantees.md +9 -7
- package/docs/how-it-works.md +110 -0
- package/docs/integration-guide.md +2 -2
- package/docs/interaction-model.md +3 -3
- package/docs/quickstart.md +6 -6
- package/docs/webhooks.md +23 -22
- package/llms.txt +10 -4
- package/package.json +1 -1
package/docs/audit.md
CHANGED
|
@@ -2,33 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
The audit log records who changed what in your org, and when — including
|
|
4
4
|
changes an AI agent made on a person's behalf. Every change is one row, and the
|
|
5
|
-
rows are
|
|
6
|
-
You can filter it, page through it, and export it.
|
|
7
|
-
|
|
8
|
-
Every commit becomes one row.
|
|
5
|
+
rows are chained with a keyed hash (HMAC-SHA256) so you can later prove the
|
|
6
|
+
history wasn't altered. You can filter it, page through it, and export it.
|
|
9
7
|
|
|
10
8
|
## Row shape
|
|
11
9
|
|
|
10
|
+
Each stored row carries both the attribution fields — who acted, on whose behalf,
|
|
11
|
+
with which key — and the chain columns that make the log tamper-evident:
|
|
12
|
+
|
|
12
13
|
```ts
|
|
13
14
|
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
15
|
+
id: string,
|
|
16
|
+
occurredAt: '2026-05-14T14:22:01.034Z',
|
|
17
|
+
actorKind: 'user' | 'agent' | 'system',
|
|
18
|
+
actorId: string,
|
|
19
|
+
onBehalfOfKind: 'user' | 'agent' | 'system' | null,
|
|
20
|
+
onBehalfOfId: string | null,
|
|
21
|
+
capabilityId: string | null, // the API key/capability used for the write
|
|
22
|
+
capabilityLabel: string | null, // its human-readable name, for scanning the log
|
|
23
|
+
delegationChainRootUserId: string | null, // always points at a human
|
|
24
|
+
actionType: string, // e.g. 'weatherReport.update'
|
|
25
|
+
modelName: string, // e.g. 'claude-opus-4-8'
|
|
26
|
+
confirmationState: 'auto' | 'previewed' | 'approved' | 'required_human_approval' | 'auto_historical',
|
|
27
|
+
diffSummary: unknown,
|
|
28
|
+
// chain columns — carried on every stored row, checked by verify (below)
|
|
29
|
+
chainSeq: number,
|
|
30
|
+
prevHash: string,
|
|
31
|
+
rowHash: string,
|
|
29
32
|
}
|
|
30
33
|
```
|
|
31
34
|
|
|
35
|
+
`confirmationState` records whether an agent's write ran on its own (`auto`), was
|
|
36
|
+
shown first (`previewed`), was signed off (`approved`), or is still waiting on a
|
|
37
|
+
person (`required_human_approval`) — it's also a filter on the list endpoint.
|
|
38
|
+
|
|
32
39
|
## Delegation chain
|
|
33
40
|
|
|
34
41
|
Every action traces back to a human. Even when an agent makes the change,
|
|
@@ -53,9 +60,14 @@ or, on tamper:
|
|
|
53
60
|
|
|
54
61
|
```json
|
|
55
62
|
{ "ok": false, "brokenAtSeq": 8419, "reason": "hash_mismatch",
|
|
56
|
-
"expectedHash": "
|
|
63
|
+
"expectedHash": "a3f1c9…", "foundHash": "b7e04d…" }
|
|
57
64
|
```
|
|
58
65
|
|
|
66
|
+
Hashes come back as plain hex. `expectedHash`/`foundHash` accompany a
|
|
67
|
+
`hash_mismatch` or `prev_hash_mismatch`; the other reasons (`sequence_gap`,
|
|
68
|
+
`missing_root`, `no_rows`) stand on their own. Recomputing a row's hash needs the
|
|
69
|
+
org's HMAC key, so verification runs where that secret is available.
|
|
70
|
+
|
|
59
71
|
## Filter and paginate
|
|
60
72
|
|
|
61
73
|
The dashboard at `/[orgSlug]/audit` is the UI for this. The same filters
|
|
@@ -82,6 +94,6 @@ JSON `GET` endpoint above using `nextCursor`.
|
|
|
82
94
|
## Compliance posture
|
|
83
95
|
|
|
84
96
|
The [audit log landing page](/audit-log) is the marketing-side description.
|
|
85
|
-
The
|
|
86
|
-
`@ablo/audit-chain` package — embeddable if you
|
|
87
|
-
detached service.
|
|
97
|
+
The HMAC-SHA256 chain algorithm and its semantics live in the
|
|
98
|
+
`@ablo/audit-chain` package — the reference implementation, embeddable if you
|
|
99
|
+
need to verify chains in a detached service (given the org's HMAC key).
|
package/docs/client-behavior.md
CHANGED
|
@@ -39,8 +39,9 @@ Common options:
|
|
|
39
39
|
| `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
|
|
40
40
|
|
|
41
41
|
Your database connects out of band — through logical replication (`npx ablo
|
|
42
|
-
connect`) or
|
|
43
|
-
|
|
42
|
+
connect`), or the signed [Data Source](./data-sources.md) endpoint as the
|
|
43
|
+
fallback for databases that can't grant replication — so the client holds only
|
|
44
|
+
`apiKey`, never a connection string. See
|
|
44
45
|
[Connect Your Database](./data-sources.md) for the full setup.
|
|
45
46
|
|
|
46
47
|
## Model Methods
|
|
@@ -95,8 +96,10 @@ such as `useAblo((ablo) => ablo.weatherReports.claim.state({ id }))`
|
|
|
95
96
|
receive active claim state. There is
|
|
96
97
|
no extra multiplayer setup beyond routing shared state through Ablo.
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
Writes flow through Ablo's commit chokepoint and land in your database, so every
|
|
100
|
+
actor routing through Ablo is coordinated. The one write it can't coordinate is
|
|
101
|
+
one made directly against your database, around Ablo — the WAL echo still catches
|
|
102
|
+
it for reads, but it bypasses claims and ordering.
|
|
100
103
|
|
|
101
104
|
## Per-Write Options
|
|
102
105
|
|
|
@@ -161,7 +161,7 @@ and `currentValues` is empty (re-read the group).
|
|
|
161
161
|
|
|
162
162
|
What the convention **guarantees**, and where it **stops**:
|
|
163
163
|
|
|
164
|
-
1. **Engine surfaces, actor decides.**
|
|
164
|
+
1. **Engine surfaces, actor decides.** Under `notify` the engine never
|
|
165
165
|
repairs, merges, or re-plans. It reports `currentValues` and the actor (agent
|
|
166
166
|
or human) owns the resolution. The engine does not distinguish them — it is
|
|
167
167
|
actor-neutral by design.
|
|
@@ -179,7 +179,7 @@ What the convention **guarantees**, and where it **stops**:
|
|
|
179
179
|
shared database, which are inherently reversible (prior value in
|
|
180
180
|
`sync_deltas`). **Irreversible external side-effects** (emails, payments,
|
|
181
181
|
third-party calls) are *out of scope* — the engine cannot hold or undo them,
|
|
182
|
-
so they must not be gated by `
|
|
182
|
+
so they must not be gated by `notify`.
|
|
183
183
|
|
|
184
184
|
5. **Defaults.** A plain write (no `readAt`) is last-writer-wins with **no**
|
|
185
185
|
check. A guarded write with `readAt` but no `onStale` defaults to `reject`
|
|
@@ -202,7 +202,7 @@ What the convention **guarantees**, and where it **stops**:
|
|
|
202
202
|
These are deliberately left open; they change behavior and are the user's call.
|
|
203
203
|
|
|
204
204
|
- **Default disposition for agents.** Should an agent-participant guarded write
|
|
205
|
-
default to `
|
|
205
|
+
default to `notify` (philosophy-aligned: surface, don't overwrite) instead of
|
|
206
206
|
`reject` (back-compat)? Trade-off: alignment vs. a behavior change for existing
|
|
207
207
|
agent callers.
|
|
208
208
|
- **Read-deps through the policy seam.** Should read-set conflicts also pass
|
package/docs/coordination.md
CHANGED
|
@@ -473,6 +473,18 @@ Each beat's answer carries two more things:
|
|
|
473
473
|
the lease. Durable progress belongs in the data itself — write a row, and
|
|
474
474
|
every subscriber already sees it.
|
|
475
475
|
|
|
476
|
+
Cooperative yield has a server-side backstop your deployment can turn on: a
|
|
477
|
+
**cumulative-hold ceiling**. Left unset — the default — a holder that keeps
|
|
478
|
+
beating holds the row as long as it likes, and the line behind it depends on
|
|
479
|
+
that holder reading `queueDepth` and releasing of its own accord. With a ceiling
|
|
480
|
+
configured for a model, a holder that runs past its fair share *while contenders
|
|
481
|
+
are queued* is preempted at the server: its next beat comes back `claim_lost`
|
|
482
|
+
(reason `preempted`) — the same loss you already handle, so abandon or re-claim,
|
|
483
|
+
and any write attempted under the old lease is fenced regardless. A holder with
|
|
484
|
+
no one waiting is never preempted, however long it runs. It is the same idea as
|
|
485
|
+
an SQS message that cannot stay invisible past a hard cap however often its lock
|
|
486
|
+
is refreshed, narrowed here to bite only under real contention.
|
|
487
|
+
|
|
476
488
|
Works identically on both transports: the realtime client sends a
|
|
477
489
|
`claim_heartbeat` frame; the HTTP client posts
|
|
478
490
|
`POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
|
|
@@ -603,7 +615,7 @@ inspect the `code`.
|
|
|
603
615
|
|
|
604
616
|
| error | `code` | thrown when | carries |
|
|
605
617
|
|---|---|---|---|
|
|
606
|
-
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, or was
|
|
618
|
+
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited) — while you were holding or waiting. | `claims?` |
|
|
607
619
|
| `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable — re-attempt the claim. | `claims?` |
|
|
608
620
|
| `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
|
|
609
621
|
| `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
|
package/docs/data-sources.md
CHANGED
|
@@ -20,12 +20,12 @@ exactly that.
|
|
|
20
20
|
|
|
21
21
|
Connecting sets up two capabilities on your Postgres: **logical replication**, so
|
|
22
22
|
Ablo can read and confirm, and a **scoped DML role**, so Ablo can write. `ablo
|
|
23
|
-
connect` prints the exact SQL. `ablo connect
|
|
23
|
+
connect` prints the exact SQL. `ablo connect apply` runs it for you.
|
|
24
24
|
|
|
25
25
|
## Connect in one command
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npx ablo connect
|
|
28
|
+
npx ablo connect apply --url postgres://admin:...@host:5432/db
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Pass an admin connection string with `--url` and it creates the publication, the
|
|
@@ -34,7 +34,7 @@ both scoped roles with Ablo, and proves the setup by reconnecting and reading ba
|
|
|
34
34
|
The admin credential is used on this machine only and never persisted — nothing is
|
|
35
35
|
written to your `.env`, which keeps holding only `ABLO_API_KEY`. Pass `--show-sql`
|
|
36
36
|
to see every statement first, or drop `--apply` to print the SQL and run it
|
|
37
|
-
yourself. Rotate the scoped passwords any time with `ablo connect
|
|
37
|
+
yourself. Rotate the scoped passwords any time with `ablo connect rotate`.
|
|
38
38
|
|
|
39
39
|
The rest of this page is what that command sets up, step by step, for when you want
|
|
40
40
|
to run it by hand or review exactly what changes.
|
|
@@ -52,7 +52,7 @@ ALTER SYSTEM SET wal_level = 'logical';
|
|
|
52
52
|
`wal_level` is **not reloadable** — you must **restart Postgres** for it to take
|
|
53
53
|
effect. On Amazon RDS / Aurora you can't `ALTER SYSTEM`; set
|
|
54
54
|
`rds.logical_replication = 1` in the instance's parameter group instead, then
|
|
55
|
-
reboot. (`ablo connect
|
|
55
|
+
reboot. (`ablo connect apply` attempts this for you and, where a managed
|
|
56
56
|
provider refuses, hands you the one remaining step.)
|
|
57
57
|
|
|
58
58
|
### 2. Run `ablo connect` for the publication and roles
|
|
@@ -101,44 +101,46 @@ Run it against your database as a superuser or the DB owner. It creates:
|
|
|
101
101
|
The **replication slot** is created and owned by Ablo's runtime when it first
|
|
102
102
|
subscribes — you don't pre-create it.
|
|
103
103
|
|
|
104
|
-
### 3.
|
|
104
|
+
### 3. Register the database with Ablo
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
`ablo connect apply` already did this. If you ran the SQL by hand instead, hand
|
|
107
|
+
Ablo both scoped connection strings once — the replication role it reads and
|
|
108
|
+
confirms through, and the writer role it lands your rows through. Set them just
|
|
109
|
+
long enough to register:
|
|
108
110
|
|
|
109
111
|
```bash
|
|
110
|
-
|
|
112
|
+
export ABLO_REPLICATION_DATABASE_URL=... # the replication role
|
|
113
|
+
export ABLO_WRITE_DATABASE_URL=... # the writer role
|
|
114
|
+
npx ablo connect register
|
|
111
115
|
```
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
`--register` posts them to Ablo, which holds them encrypted and uses them to read
|
|
118
|
+
and write your database. Ablo holds them from here, so you can drop both from your
|
|
119
|
+
environment — your app keeps only `ABLO_API_KEY`. The role passwords are generated
|
|
120
|
+
for you and never printed; rotate them any time with `ablo connect rotate`. After
|
|
121
|
+
this, Ablo does all the connecting.
|
|
122
|
+
|
|
123
|
+
### 4. Verify readiness with `ablo connect check`
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npx ablo connect check
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`--check` needs only `ABLO_API_KEY`. It asks Ablo to check the database it now
|
|
130
|
+
holds, from the same infrastructure replication runs on, and prints a green
|
|
131
|
+
checklist or the precise per-item fix:
|
|
115
132
|
|
|
116
133
|
- `wal_level` is `logical`
|
|
117
134
|
- the `ablo_publication` publication exists
|
|
118
135
|
- the replication role has the `REPLICATION` attribute
|
|
119
136
|
- every published table has a usable `REPLICA IDENTITY` (a primary key, or
|
|
120
137
|
`REPLICA IDENTITY FULL`) so `UPDATE`/`DELETE` can replicate
|
|
138
|
+
- the writer role is DML-ready — scoped, non-superuser, with the idempotency
|
|
139
|
+
ledger in place
|
|
121
140
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Re-run it until every item is green.
|
|
126
|
-
|
|
127
|
-
### 4. Register the two roles with Ablo
|
|
128
|
-
|
|
129
|
-
`ablo connect --apply` already did this. If you ran the SQL by hand instead, hand
|
|
130
|
-
Ablo both connection strings — the replication role it reads and confirms through,
|
|
131
|
-
and the writer role it lands your rows through:
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
npx ablo connect --register
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
This is out of band and happens once. `--register` reads the two scoped connection
|
|
138
|
-
strings from `ABLO_REPLICATION_DATABASE_URL` and `ABLO_WRITE_DATABASE_URL` and
|
|
139
|
-
posts them to Ablo, which holds them encrypted and uses them to read and write your
|
|
140
|
-
database. The role passwords are generated for you and never printed — rotate them
|
|
141
|
-
any time with `ablo connect --rotate`. After this, Ablo does all the connecting.
|
|
141
|
+
Because Ablo checks from its own network, a database your own machine can't reach —
|
|
142
|
+
IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every
|
|
143
|
+
item is green.
|
|
142
144
|
|
|
143
145
|
Your **app** holds only the API key — never a connection string:
|
|
144
146
|
|
|
@@ -172,10 +174,10 @@ landed:
|
|
|
172
174
|
|
|
173
175
|
```ts
|
|
174
176
|
// Enters the chokepoint (claims, ordering, idempotency), lands in your Postgres.
|
|
175
|
-
await ablo.weatherReports.update('report_stockholm', { high: 21 });
|
|
177
|
+
await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 } });
|
|
176
178
|
|
|
177
179
|
// Block until your database has it and the WAL echo confirms.
|
|
178
|
-
await ablo.weatherReports.update('report_stockholm', { high: 21 },
|
|
180
|
+
await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 }, wait: 'confirmed' });
|
|
179
181
|
|
|
180
182
|
// Reads are live off the same stream.
|
|
181
183
|
const report = ablo.weatherReports.get('report_stockholm');
|
|
@@ -50,11 +50,11 @@ export async function markReady(reportId: string) {
|
|
|
50
50
|
// queue: false → don't queue behind a current holder. If a human already
|
|
51
51
|
// holds the row, claim rejects with AbloClaimedError (caught below), so the
|
|
52
52
|
// agent yields instead of waiting. Omit it, or pass queue: true, to queue
|
|
53
|
-
// behind them.
|
|
53
|
+
// behind them. description → the label observers see while we work.
|
|
54
54
|
await using claim = await ablo.weatherReports.claim({
|
|
55
55
|
id: reportId,
|
|
56
56
|
queue: false,
|
|
57
|
-
|
|
57
|
+
description: 'marking_ready',
|
|
58
58
|
});
|
|
59
59
|
const claimed = claim.data;
|
|
60
60
|
|
|
@@ -43,7 +43,7 @@ const updateReport = tool({
|
|
|
43
43
|
// The claim is released automatically when it goes out of scope.
|
|
44
44
|
await using claim = await ablo.weatherReports.claim({
|
|
45
45
|
id: reportId,
|
|
46
|
-
|
|
46
|
+
description: 'editing',
|
|
47
47
|
ttl: '2m',
|
|
48
48
|
});
|
|
49
49
|
const claimed = claim.data;
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -39,7 +39,7 @@ export async function completeReport(reportId: string) {
|
|
|
39
39
|
await using claim = await ablo.weatherReports.claim({
|
|
40
40
|
id: reportId,
|
|
41
41
|
queue: false,
|
|
42
|
-
|
|
42
|
+
description: 'completing',
|
|
43
43
|
});
|
|
44
44
|
const claimed = claim.data;
|
|
45
45
|
|
|
@@ -62,7 +62,7 @@ The two options on the claim:
|
|
|
62
62
|
|
|
63
63
|
- `queue: false` — skip this record if another claim is already in progress,
|
|
64
64
|
rather than queueing behind it. (The default queues.)
|
|
65
|
-
- `
|
|
65
|
+
- `description: 'completing'` — a human-readable label for what your worker is doing,
|
|
66
66
|
visible to anyone reading `claim.state({ id })`.
|
|
67
67
|
|
|
68
68
|
Because the worker uses the same schema and `claim()` as the UI, its writes sync
|
package/docs/groups.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Change Propagation
|
|
2
|
+
|
|
3
|
+
> How a change to one row reaches the rows and actors that depend on it, and how
|
|
4
|
+
> to keep a chain of dependent work fresh. This is the propagation half of sync
|
|
5
|
+
> groups; [`identity.md`](./identity.md) is the access half (who may read a
|
|
6
|
+
> group), and [`concurrency-convention.md`](./concurrency-convention.md) is the
|
|
7
|
+
> convention this rests on.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Start from the problem
|
|
12
|
+
|
|
13
|
+
An agent reads deck `A` to write slide `B`. A moment later it reads `B` to write
|
|
14
|
+
layer `C`. Between those steps someone else edits `A`. The agent is now building
|
|
15
|
+
`C` on a premise that has moved — and nothing about writing `C` looks wrong in
|
|
16
|
+
isolation. That is stale context, and it is the thing sync groups let you catch.
|
|
17
|
+
|
|
18
|
+
The recipe is one field on the commit: declare the group you read as a premise,
|
|
19
|
+
and say what should happen if it moved.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// The agent read everything under deck:abc to compose this write.
|
|
23
|
+
await ablo.layers.update({
|
|
24
|
+
id: 'layer-C',
|
|
25
|
+
data: { text: revised },
|
|
26
|
+
reads: [{ group: 'deck:abc', readAt: watermark, onStale: 'notify' }],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
At commit, inside the write transaction, the engine asks a single question: *did
|
|
31
|
+
any delta routed to `deck:abc` land after `watermark`?* If nothing moved, the
|
|
32
|
+
write applies. If something moved, `onStale` decides — `notify` holds the write
|
|
33
|
+
and hands the agent a `StaleNotification` naming the group, so it re-reads
|
|
34
|
+
`deck:abc` and regenerates; `reject` aborts the batch with a `409`. The agent
|
|
35
|
+
never persists work built on a premise it can no longer see.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Three ways a change reaches other rows
|
|
40
|
+
|
|
41
|
+
"A affects B and C" means three different things. The engine does the first two
|
|
42
|
+
for you and leaves the third to you — on purpose.
|
|
43
|
+
|
|
44
|
+
**Routing — who hears about a change.** Every row belongs to one or more sync
|
|
45
|
+
groups, and a write fans out to all of them. A row also inherits its ancestors'
|
|
46
|
+
groups: editing a layer stamps the delta with `layer:…`, `slide:…`, *and*
|
|
47
|
+
`deck:…`, so everyone watching the deck sees the layer move. This is delivery,
|
|
48
|
+
resolved by walking the ownership tree at commit time. It routes the change; it
|
|
49
|
+
never recomputes a value.
|
|
50
|
+
|
|
51
|
+
**Structural cascade — what disappears with a change.** Deleting a deck removes
|
|
52
|
+
its slides and layers. The database does that through `ON DELETE CASCADE`, but a
|
|
53
|
+
database-level cascade emits no delta, so open clients would quietly hold rows
|
|
54
|
+
that no longer exist. The engine closes that gap: before the delete it snapshots
|
|
55
|
+
the subtree and emits a tombstone for each descendant, routed to the right
|
|
56
|
+
group. Watchers see the whole subtree vanish.
|
|
57
|
+
|
|
58
|
+
**Value recomputation — what a change implies for derived state.** If `B` holds a
|
|
59
|
+
number rolled up from `A`, the engine does not recompute `B` when `A` changes. It
|
|
60
|
+
surfaces that `A` moved and lets the actor decide what `B` should become. This is
|
|
61
|
+
the non-coercion principle: coordinate and report, resolve nothing by fiat.
|
|
62
|
+
Merging derived state is a judgment call, and for an agent in the loop that
|
|
63
|
+
judgment is the whole point.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## The chain: A → B → C
|
|
68
|
+
|
|
69
|
+
Model a dependency as shared group membership. Put `A` and `B` in one group, `B`
|
|
70
|
+
and `C` in another, and you have wired the edges of a chain. What travels along
|
|
71
|
+
those edges is a *signal*, one hop at a time — not a recomputation.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
A writes ──▶ group {A,B} ──▶ B hears it
|
|
75
|
+
│ B decides, B writes
|
|
76
|
+
▼
|
|
77
|
+
group {B,C} ──▶ C hears it
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A's delta lands in `{A,B}` and stops there. `C` is not in that group, so `C`
|
|
81
|
+
learns nothing from A directly. `C` advances only when **B itself writes** and
|
|
82
|
+
that new delta lands in `{B,C}`. `B` is the translator: it takes "A moved,"
|
|
83
|
+
decides what that means for its own state, commits, and *its* commit is what
|
|
84
|
+
reaches `C`.
|
|
85
|
+
|
|
86
|
+
The direction matters. The signal flows forward, A to B to C, and each hop is a
|
|
87
|
+
real write an actor chose to make. The engine supplies the edges (group
|
|
88
|
+
membership) and a stale signal on each edge (the read-dependency check); the
|
|
89
|
+
actors are the runtime that walks them. It is closer to a spreadsheet an analyst
|
|
90
|
+
recalculates cell by cell than to a reactive engine that recomputes the whole
|
|
91
|
+
column for you.
|
|
92
|
+
|
|
93
|
+
Two consequences worth designing around:
|
|
94
|
+
|
|
95
|
+
- **The chain runs as fast as actors react.** If `B` never acts on its signal,
|
|
96
|
+
the chain stops at `B` and `C` stays as it was. Freshness is an actor
|
|
97
|
+
responsibility; the engine guarantees the signal, not the follow-through.
|
|
98
|
+
- **Cycles don't settle themselves.** If `C` writes back to `A`, each hop is a
|
|
99
|
+
separate commit with its own stale check, and nothing damps the oscillation.
|
|
100
|
+
Keep the dependency graph acyclic, or give one actor the job of reaching a
|
|
101
|
+
fixpoint. Convergence lives above the engine.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Declaring a read premise
|
|
106
|
+
|
|
107
|
+
A read-dependency is a premise for the *whole* batch: its disposition governs
|
|
108
|
+
every write in the commit, not just one operation. You choose the granularity per
|
|
109
|
+
entry.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
reads: [
|
|
113
|
+
{ group: 'deck:abc', readAt: N, onStale: 'notify' }, // did anything in the deck move?
|
|
114
|
+
{ model: 'Slide', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move?
|
|
115
|
+
]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A **group** premise asks "did anything I was watching change?" — the native
|
|
119
|
+
Ablo granularity, and the right tool for the chain above. A **row** premise is
|
|
120
|
+
literal: this object, optionally these fields. A row premise with `fields`
|
|
121
|
+
conflicts only on real field overlap, so two actors editing disjoint fields of
|
|
122
|
+
the same row don't collide.
|
|
123
|
+
|
|
124
|
+
`onStale` has three settings, defaulting to `reject`:
|
|
125
|
+
|
|
126
|
+
- **`notify`** holds every write in the batch and returns a `StaleNotification`.
|
|
127
|
+
For a group premise it carries the group name and the new watermark
|
|
128
|
+
(`observedSyncId`); re-read the group at that point and regenerate. This is the
|
|
129
|
+
setting a chain wants — the actor gets the truth and resolves it.
|
|
130
|
+
- **`reject`** aborts the batch with a `stale_context` error (`409`). The right
|
|
131
|
+
default when there is nothing to reconcile and the write should simply not
|
|
132
|
+
land.
|
|
133
|
+
- **`overwrite`** skips the check and lets the write land — last-write-wins, the
|
|
134
|
+
explicit escape hatch.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Sizing groups
|
|
139
|
+
|
|
140
|
+
A group is the unit of both delivery and staleness, so its size is a real
|
|
141
|
+
tradeoff. A change fans out to every subscriber of every group it touches, and a
|
|
142
|
+
group premise fires when *anything* in the group moves — so a group that is too
|
|
143
|
+
broad wakes actors for changes they don't care about, and one that is too narrow
|
|
144
|
+
misses the dependency you meant to track.
|
|
145
|
+
|
|
146
|
+
The rule of thumb: **make a group the smallest set of rows that must stay
|
|
147
|
+
mutually consistent.** A deck and its slides belong together because editing one
|
|
148
|
+
changes what the others mean; two unrelated decks do not. Reach for finer,
|
|
149
|
+
overlapping groups when you genuinely have a dependency chain to track, and keep
|
|
150
|
+
them coarse everywhere else.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Where this is defined
|
|
155
|
+
|
|
156
|
+
- **Access** — who may read or write a group — is [`identity.md`](./identity.md).
|
|
157
|
+
- **The convention** — non-coercion, the read-set, and the notification — is
|
|
158
|
+
[`concurrency-convention.md`](./concurrency-convention.md) (§4 and §5).
|
|
159
|
+
- **The mechanics** — the three coordination layers underneath — are
|
|
160
|
+
[`coordination.md`](./coordination.md).
|
package/docs/guarantees.md
CHANGED
|
@@ -66,10 +66,8 @@ snapshot, the server rejects the write instead of applying stale reasoning.
|
|
|
66
66
|
Advanced policies exist for controlled product flows:
|
|
67
67
|
|
|
68
68
|
- `reject` fails the write when state moved.
|
|
69
|
-
- `
|
|
70
|
-
- `
|
|
71
|
-
|
|
72
|
-
`merge` is not yet available.
|
|
69
|
+
- `overwrite` applies the write without stale protection.
|
|
70
|
+
- `notify` accepts the write and marks it for product review.
|
|
73
71
|
|
|
74
72
|
## Claim Coordination
|
|
75
73
|
|
|
@@ -148,9 +146,13 @@ separates workflow or deployment lanes sharing the same store.
|
|
|
148
146
|
|
|
149
147
|
## Storage Boundary
|
|
150
148
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
Your rows live in your database; Ablo holds only the transaction log and the
|
|
150
|
+
coordination state. Writes enter Ablo's commit chokepoint and land in your
|
|
151
|
+
Postgres through a scoped writer role, and the WAL echo confirms them
|
|
152
|
+
(`queued` → `confirmed`). For a database that can't grant replication, Ablo
|
|
153
|
+
forwards the write to a signed Data Source endpoint instead — the marked
|
|
154
|
+
fallback. Either way Ablo never holds a database connection string. See
|
|
155
|
+
[Connect Your Database](./data-sources.md).
|
|
154
156
|
|
|
155
157
|
## Writes
|
|
156
158
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# How Ablo Works
|
|
2
|
+
|
|
3
|
+
You write through Ablo, and Ablo writes to your Postgres. That one sentence is the
|
|
4
|
+
whole model — everything below explains what it means and how to use it.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// You call Ablo. Ablo lands the change in your database and confirms it.
|
|
8
|
+
await ablo.tasks.update({ id: 'task_42', data: { status: 'done' }, wait: 'confirmed' });
|
|
9
|
+
|
|
10
|
+
// Reads come back live, kept current from your database.
|
|
11
|
+
const task = ablo.tasks.get('task_42');
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The mental model — read this once
|
|
15
|
+
|
|
16
|
+
Ablo is a **coordination layer in front of your Postgres**. Humans, agents, and
|
|
17
|
+
background jobs all change the same application data through one API, and Ablo
|
|
18
|
+
makes sure their writes don't clobber each other.
|
|
19
|
+
|
|
20
|
+
- **Writes go through Ablo.** `ablo.<model>.create / update / delete` enter Ablo's
|
|
21
|
+
commit chokepoint — where claims, ordering, and idempotency are enforced — and
|
|
22
|
+
Ablo applies the change to your Postgres through a scoped writer role. The commit
|
|
23
|
+
is accepted (`queued`) the moment Ablo takes it.
|
|
24
|
+
- **Your database confirms the write.** Ablo tails your write-ahead log (WAL). When
|
|
25
|
+
the row it wrote shows up there, the receipt is promoted to `confirmed`. So your
|
|
26
|
+
database, not Ablo, is the source of truth for row state — the WAL echo is how
|
|
27
|
+
Ablo *confirms*, not how it writes.
|
|
28
|
+
- **Reads are live.** Ablo serves current state and keeps every connected client up
|
|
29
|
+
to date off that same stream.
|
|
30
|
+
- **Ablo stores only the log.** Ablo holds the ordered transaction log
|
|
31
|
+
(`sync_deltas`) and the coordination state — the claims, the ordering, the
|
|
32
|
+
watermarks. Your rows live in your database. Ablo runs no DDL and owns no schema;
|
|
33
|
+
your migration tool stays in charge of the shape of your database.
|
|
34
|
+
|
|
35
|
+
That's the shape: **you write through Ablo → it lands in your Postgres → the WAL
|
|
36
|
+
echo confirms it → everyone connected sees it live.**
|
|
37
|
+
|
|
38
|
+
## Where your data lives
|
|
39
|
+
|
|
40
|
+
You point Ablo at a Postgres database, and that's where its rows live. Only *which*
|
|
41
|
+
database differs by environment — the code is identical.
|
|
42
|
+
|
|
43
|
+
- **Production** — your Postgres. `ablo connect` sets up a scoped writer role and
|
|
44
|
+
logical replication; your rows live in your database, and Ablo writes to them
|
|
45
|
+
through that role.
|
|
46
|
+
- **Sandbox and local dev** — a separate or local Postgres you can throw away. Same
|
|
47
|
+
models, same code, a different database behind them.
|
|
48
|
+
- **Before you connect one** — Ablo keeps state in its own log, so you can build the
|
|
49
|
+
whole app today and point it at a real database when you're ready.
|
|
50
|
+
|
|
51
|
+
Registering the database is the whole switch. There is no tier or flag to choose.
|
|
52
|
+
|
|
53
|
+
## Use it end to end
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# 1. Install and scaffold.
|
|
57
|
+
npm install @abloatai/ablo
|
|
58
|
+
npx ablo init
|
|
59
|
+
|
|
60
|
+
# 2. Push your schema (the models humans and agents edit together).
|
|
61
|
+
npx ablo push
|
|
62
|
+
|
|
63
|
+
# 3. Connect your database — one command, admin credential used once and discarded.
|
|
64
|
+
npx ablo connect apply --url postgres://admin:...@host:5432/db
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Your app's environment holds only the API key — never a connection string.
|
|
69
|
+
ABLO_API_KEY=sk_live_...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// 4. Build the client once.
|
|
74
|
+
import Ablo from '@abloatai/ablo';
|
|
75
|
+
import { schema } from './ablo/schema';
|
|
76
|
+
export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
77
|
+
|
|
78
|
+
// 5. Write through Ablo. It lands in your Postgres; wait: 'confirmed' blocks
|
|
79
|
+
// until the WAL echo proves the row is there.
|
|
80
|
+
await ablo.tasks.update({ id: 'task_42', data: { status: 'done' }, wait: 'confirmed' });
|
|
81
|
+
|
|
82
|
+
// 6. Read — live, no fetch loop.
|
|
83
|
+
const task = ablo.tasks.get('task_42');
|
|
84
|
+
|
|
85
|
+
// 7. Coordinate when more than one actor can touch a row. Hold a claim and Ablo
|
|
86
|
+
// serializes writes on that key against everyone else; read after claiming,
|
|
87
|
+
// then write. The lease releases automatically at the end of the scope.
|
|
88
|
+
await using _hold = await ablo.tasks.claim('task_42');
|
|
89
|
+
const latest = ablo.tasks.get('task_42'); // read after claiming, not from memory
|
|
90
|
+
await ablo.tasks.update({ id: 'task_42', data: { status: 'done' } });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Reading the architecture correctly
|
|
94
|
+
|
|
95
|
+
The model changed once (ADR 0010), and older material described the earlier one.
|
|
96
|
+
If you've read that a customer's app keeps writing while Ablo only tails the WAL,
|
|
97
|
+
here is the current, correct reading:
|
|
98
|
+
|
|
99
|
+
- **"Ablo tails the WAL."** True — to *confirm* your writes and serve live reads.
|
|
100
|
+
It is not the write path. You write **through** Ablo.
|
|
101
|
+
- **"Ablo is out of the write path."** Not anymore. Ablo is the write chokepoint;
|
|
102
|
+
every write flows through its coordination and then into your database.
|
|
103
|
+
- **"Ablo holds my rows."** Only in the no-database-yet sandbox. Once you connect a
|
|
104
|
+
database, your rows live in *your* Postgres — Ablo keeps just the log.
|
|
105
|
+
- **"Ablo migrates my schema."** No. Ablo writes rows through a scoped role and runs
|
|
106
|
+
no DDL; your migrations own the shape of your database.
|
|
107
|
+
|
|
108
|
+
For the setup details, see [Connect Your Database](./data-sources.md). For the
|
|
109
|
+
coordination loop, see [Concurrency Convention](./concurrency-convention.md). For
|
|
110
|
+
what `queued` and `confirmed` guarantee, see [Guarantees](./guarantees.md).
|