@abloatai/ablo 0.30.2 → 0.32.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 +53 -12
- package/README.md +12 -14
- package/dist/cli.cjs +1787 -1329
- 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 +57 -0
- package/dist/schema/schema.js +53 -0
- package/dist/schema/select.js +1 -0
- package/dist/schema/serialize.d.ts +3 -1
- package/dist/schema/serialize.js +7 -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/data-sources.md +141 -102
- 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 +8 -7
- package/docs/interaction-model.md +3 -3
- package/docs/quickstart.md +38 -31
- package/docs/schema-contract.md +7 -5
- package/docs/webhooks.md +23 -22
- package/llms.txt +10 -4
- package/package.json +1 -1
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).
|
|
@@ -47,11 +47,12 @@ objects by hand.
|
|
|
47
47
|
Every schema model is backed by **your own database**. The SDK call shape is the
|
|
48
48
|
same everywhere.
|
|
49
49
|
|
|
50
|
-
In this guide — an app that already owns its backend and database — keep
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
In this guide — an app that already owns its backend and database — keep the
|
|
51
|
+
database credentials inside your server runtime and connect out of band: run `npx
|
|
52
|
+
ablo connect` to set up logical replication and a scoped writer role, or expose a
|
|
53
|
+
signed Data Source endpoint when your database can't grant replication. Either way,
|
|
54
|
+
you write through `ablo.<model>`; Ablo lands each change in your Postgres and
|
|
55
|
+
confirms it over the WAL. Application and agent code hold only `ABLO_API_KEY` — the
|
|
55
56
|
client never sees a connection string. [Connect Your Database](./data-sources.md)
|
|
56
57
|
is the single source of truth for both paths.
|
|
57
58
|
|
|
@@ -445,7 +446,7 @@ scope.
|
|
|
445
446
|
```ts
|
|
446
447
|
await using claim = await ablo.weatherReports.claim({
|
|
447
448
|
id: reportId,
|
|
448
|
-
|
|
449
|
+
description: 'forecasting',
|
|
449
450
|
});
|
|
450
451
|
const claimed = claim.data;
|
|
451
452
|
if (!claimed) return;
|
|
@@ -512,7 +513,7 @@ them.
|
|
|
512
513
|
| `update({ id, data, ...opts })` | Update through the model client. |
|
|
513
514
|
| `delete({ id, ...opts })` | Delete through the model client. |
|
|
514
515
|
| `claim.state({ id })` | See who is currently working on a row (synchronous). |
|
|
515
|
-
| `claim({ id,
|
|
516
|
+
| `claim({ id, description?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
|
|
516
517
|
|
|
517
518
|
Keep first integrations on the model methods above. Every mutation and
|
|
518
519
|
server-read verb takes one options object; only the synchronous `get(id)` stays
|
|
@@ -70,7 +70,7 @@ automatically when the scope exits:
|
|
|
70
70
|
```ts
|
|
71
71
|
await using claim = await ablo.weatherReports.claim({
|
|
72
72
|
id: 'report_stockholm',
|
|
73
|
-
|
|
73
|
+
description: 'editing',
|
|
74
74
|
});
|
|
75
75
|
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // rejected if the row changed under the claim
|
|
76
76
|
```
|
|
@@ -87,8 +87,8 @@ Schema updates can carry `readAt` and `onStale`. If the state advanced past
|
|
|
87
87
|
`readAt`, Ablo applies the `onStale` policy:
|
|
88
88
|
|
|
89
89
|
- `reject` — fail the commit (first writer wins).
|
|
90
|
-
- `
|
|
91
|
-
- `
|
|
90
|
+
- `notify` — accept the write, but flag it for product review.
|
|
91
|
+
- `overwrite` — apply the write unconditionally.
|
|
92
92
|
|
|
93
93
|
The choice is per-commit. No CRDT default; the policy is explicit.
|
|
94
94
|
|
package/docs/quickstart.md
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Build with Ablo on **the Postgres you already have**. You declare a small Ablo
|
|
4
4
|
schema for the models humans and agents edit together, connect Ablo to your
|
|
5
|
-
database
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
database (`ablo connect`), and read and write every one of those models through
|
|
6
|
+
`ablo.<model>`. You write through Ablo; it lands the change in your Postgres and
|
|
7
|
+
confirms it by tailing your write-ahead log (WAL). Your rows live in your database,
|
|
8
|
+
which stays the system of record. Ablo writes rows but **runs no DDL and owns no
|
|
9
|
+
schema** — your migration tool stays in charge of the shape of your database.
|
|
10
10
|
|
|
11
|
-
> No database yet?
|
|
12
|
-
>
|
|
13
|
-
>
|
|
11
|
+
> No database yet? Pass an `apiKey` only and Ablo keeps your rows in its own log,
|
|
12
|
+
> so you can build the whole app today — like Stripe test mode. Point it at a
|
|
13
|
+
> separate or local Postgres for a throwaway sandbox, or at your production
|
|
14
|
+
> database when you're ready.
|
|
14
15
|
|
|
15
16
|
## 1. Install and initialize
|
|
16
17
|
|
|
@@ -97,32 +98,38 @@ collapses to the untyped client.
|
|
|
97
98
|
|
|
98
99
|
## 3. Connect your database with `ablo connect`
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
`ablo connect` sets your database up so Ablo can write your rows (a scoped DML
|
|
102
|
+
role) and read them back to confirm (logical replication). It writes rows through
|
|
103
|
+
that role but runs no DDL and owns no schema — your migration tool stays in charge.
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# On RDS/Aurora: set rds.logical_replication = 1 in the parameter group, reboot.
|
|
108
|
-
|
|
109
|
-
# 2. Print the exact publication + replication-role SQL for YOUR Postgres, run it:
|
|
110
|
-
npx ablo connect
|
|
105
|
+
You run `ablo connect` once, out of band — it provisions the roles and hands them
|
|
106
|
+
to Ablo. From then on Ablo does the connecting; your app never opens a database
|
|
107
|
+
connection.
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
```bash
|
|
110
|
+
# Point it at an admin connection once — it does the whole ceremony: creates the
|
|
111
|
+
# roles + publication, turns on logical decoding where it can, registers both
|
|
112
|
+
# scoped roles with Ablo, and proves it by reading back. Nothing lands in your .env.
|
|
113
|
+
npx ablo connect apply --url postgres://admin:...@host:5432/db
|
|
114
|
+
|
|
115
|
+
# ...or print the SQL and run it yourself, then register:
|
|
116
|
+
# npx ablo connect # prints the publication + two scoped roles
|
|
117
|
+
# npx ablo connect check # validates the database is ready
|
|
118
|
+
# npx ablo connect register # hands the two scoped roles to Ablo
|
|
114
119
|
```
|
|
115
120
|
|
|
116
|
-
`ablo connect`
|
|
117
|
-
`
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
`ablo connect apply` generates two roles and their passwords — an
|
|
122
|
+
`ablo_replicator` role (`REPLICATION` + `SELECT`, for reads and confirmation) and
|
|
123
|
+
an `ablo_writer` role (scoped row DML, for writes) — and registers both connection
|
|
124
|
+
strings with Ablo's control plane, encrypted. Ablo's runtime uses them to read and
|
|
125
|
+
write your database. The admin credential you pass to `--url` is used on this
|
|
126
|
+
machine only and never persisted. The role passwords are generated for you and
|
|
127
|
+
never printed — rotate them any time with `ablo connect rotate`.
|
|
128
|
+
|
|
129
|
+
Your **app** holds only the API key — never a connection string:
|
|
122
130
|
|
|
123
131
|
```bash
|
|
124
132
|
# .env — server runtime only, never the browser
|
|
125
|
-
DATABASE_URL=postgres://ablo_replicator:<password>@host:5432/db?sslmode=require
|
|
126
133
|
ABLO_API_KEY=sk_test_...
|
|
127
134
|
```
|
|
128
135
|
|
|
@@ -137,9 +144,9 @@ export const ablo = Ablo({
|
|
|
137
144
|
});
|
|
138
145
|
```
|
|
139
146
|
|
|
140
|
-
The full setup, the honest footprint (publication + slot + `REPLICATION`
|
|
141
|
-
the `wal_level` restart + slot/WAL retention Ablo monitors), and the
|
|
142
|
-
status
|
|
147
|
+
The full setup, the honest footprint (publication + slot + the `REPLICATION` and
|
|
148
|
+
writer roles + the `wal_level` restart + slot/WAL retention Ablo monitors), and the
|
|
149
|
+
Preview status are in [Connect Your Database](./data-sources.md).
|
|
143
150
|
|
|
144
151
|
## 4. Push the schema, then map it to tables
|
|
145
152
|
|
|
@@ -223,7 +230,7 @@ locked.
|
|
|
223
230
|
// Claim the row so other participants serialize behind us while we work.
|
|
224
231
|
await using handle = await ablo.weatherReports.claim({
|
|
225
232
|
id: 'weather_stockholm',
|
|
226
|
-
|
|
233
|
+
description: 'checking_weather',
|
|
227
234
|
ttl: '2m',
|
|
228
235
|
});
|
|
229
236
|
|
package/docs/schema-contract.md
CHANGED
|
@@ -102,11 +102,13 @@ the fresh row. Reads stay open; only acting on the row serializes.
|
|
|
102
102
|
|
|
103
103
|
## Storage boundary
|
|
104
104
|
|
|
105
|
-
Every schema model is backed by your own database
|
|
106
|
-
all covered in [Connect Your
|
|
107
|
-
truth): the sandbox (`apiKey`
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
Every schema model is backed by your own database, and you write to it through
|
|
106
|
+
`ablo.<model>`. There are three start states, all covered in [Connect Your
|
|
107
|
+
Database](./data-sources.md) (the single source of truth): the sandbox (`apiKey`
|
|
108
|
+
only, no database — Ablo keeps your rows in its own log), `npx ablo connect` (a
|
|
109
|
+
scoped writer role plus logical replication, so Ablo writes your rows and confirms
|
|
110
|
+
them over the WAL), or a signed Data Source endpoint when your database can't grant
|
|
111
|
+
replication.
|
|
110
112
|
|
|
111
113
|
Your database connects out of band, so the client holds only `ABLO_API_KEY` —
|
|
112
114
|
never a connection string. Browser code goes through `<AbloProvider>` or a scoped
|
package/docs/webhooks.md
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
1
|
# Webhooks
|
|
2
2
|
|
|
3
|
-
Ablo
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Ablo keeps an ordered transaction log of every committed change and coordinates
|
|
4
|
+
the writers — people and agents — that produce it. Your rows live in your own
|
|
5
|
+
database; Ablo holds only the log. **Webhooks stream that log to your systems as
|
|
6
|
+
signed events:** every committed change is POSTed to an endpoint in your app, and
|
|
7
|
+
your handler decides what to do with it.
|
|
6
8
|
|
|
7
9
|
It's the same two-sided shape as Stripe: you call Ablo to make changes (the
|
|
8
|
-
client), and Ablo calls you
|
|
9
|
-
database
|
|
10
|
+
client), and Ablo calls you with each change (this webhook). Webhooks are the
|
|
11
|
+
*push* way to keep a store in step with the log — your own database, a warehouse,
|
|
12
|
+
a search index, a background job. The *direct* alternative is `ablo connect`,
|
|
13
|
+
where Ablo reads your write-ahead log and writes back through a scoped role. Either
|
|
14
|
+
way your handler owns the write: the webhook path gives Ablo no database
|
|
15
|
+
credentials at all.
|
|
10
16
|
|
|
11
17
|
## The loop
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
flowchart LR
|
|
15
|
-
App["Your app<br/>(the client)"]
|
|
16
|
-
subgraph Ablo["Ablo (hosted)"]
|
|
17
|
-
Log["Transaction log<br/><i>source of truth</i>"]
|
|
18
|
-
end
|
|
19
|
-
Clients["Other clients<br/>(live, optimistic)"]
|
|
20
|
-
Route["/api/ablo/[...all]<br/>(your webhook route)"]
|
|
21
|
-
DB[("Your database")]
|
|
22
|
-
|
|
23
|
-
App -->|write| Log
|
|
24
|
-
Log -->|realtime sync| Clients
|
|
25
|
-
Log -->|signed event| Route
|
|
26
|
-
Route -->|write| DB
|
|
27
|
-
```
|
|
19
|
+

|
|
28
20
|
|
|
29
21
|
There are two ways data flows out of Ablo, and they're for different jobs:
|
|
30
22
|
|
|
@@ -55,8 +47,8 @@ Every delivery is a batch of events. Each event:
|
|
|
55
47
|
|
|
56
48
|
| field | meaning |
|
|
57
49
|
|---|---|
|
|
58
|
-
| `type` | `"<model>.<verb>"
|
|
59
|
-
| `model` | the model name
|
|
50
|
+
| `type` | `"<model>.<verb>"` with the model name lowercased, e.g. `task.updated` |
|
|
51
|
+
| `model` | the model name exactly as declared in your schema — the table to write |
|
|
60
52
|
| `objectId` | the changed row's id |
|
|
61
53
|
| `data` | the post-change row, or `null` on delete (like Stripe's `event.data.object`) |
|
|
62
54
|
| `syncId` | monotonic log position — **dedupe and order by this** |
|
|
@@ -138,12 +130,21 @@ npx ablo webhooks create https://yourapp.com/api/ablo/[...all]
|
|
|
138
130
|
# ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once)
|
|
139
131
|
```
|
|
140
132
|
|
|
133
|
+
Scope which models fire and label the endpoint at creation with `--events` and
|
|
134
|
+
`--description` (both optional; events default to `*` = every model):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npx ablo webhooks create https://yourapp.com/api/ablo/[...all] \
|
|
138
|
+
--events task,project --description "prod mirror"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
141
|
Manage and inspect endpoints:
|
|
142
142
|
|
|
143
143
|
```bash
|
|
144
144
|
npx ablo webhooks list # endpoints + delivery health (status, cursor, last error)
|
|
145
145
|
npx ablo webhooks roll <id> # mint a fresh signing secret
|
|
146
146
|
npx ablo webhooks enable <id> # re-enable a disabled endpoint
|
|
147
|
+
npx ablo webhooks rm <id> # remove an endpoint
|
|
147
148
|
```
|
|
148
149
|
|
|
149
150
|
Or call the API directly — the org is derived from your secret key:
|
package/llms.txt
CHANGED
|
@@ -88,6 +88,10 @@ visible through `claim.state({ id })`, and stale writes can be rejected with `re
|
|
|
88
88
|
If an app writes directly to its own database outside Ablo, that write bypasses
|
|
89
89
|
coordination until the app reports it through Data Source events.
|
|
90
90
|
|
|
91
|
+
## Change propagation
|
|
92
|
+
|
|
93
|
+
A change to one row reaches other rows three ways. ROUTING: a write fans out to every sync group the row belongs to, INCLUDING its ancestors' groups (editing a layer routes to `layer:` + `slide:` + `deck:`), so everyone watching the deck sees it — delivery, not recomputation. DELETE CASCADE: deleting a parent emits explicit tombstone deltas for its descendants, so open clients never silently hold rows that are gone. VALUE: derived values are NOT recomputed server-side — Ablo surfaces that the source moved and the actor decides. To keep dependent work fresh, declare what you read as a batch premise: `reads: [{ group: 'deck:abc', readAt: N, onStale: 'notify' }]`. At commit the server checks whether anything in that group moved past `readAt`; `notify` holds the write and returns a `StaleNotification` (re-read the group and regenerate), `reject` aborts. To chain A→B→C, put A+B in one group and B+C in another: A's change reaches B, and C hears it only once B ITSELF writes — the engine wires the edges and signals each hop, the actor walks them. No transitive auto-recompute, no convergence guarantee for cycles.
|
|
94
|
+
|
|
91
95
|
## Nouns
|
|
92
96
|
|
|
93
97
|
- `Model client` is the typed `ablo.<model>` object generated from schema.
|
|
@@ -123,11 +127,13 @@ A schema is model fields and relations. Advanced schema helpers such as `mutable
|
|
|
123
127
|
|
|
124
128
|
## Storage Boundary
|
|
125
129
|
|
|
126
|
-
|
|
130
|
+
YOU WRITE THROUGH ABLO; ABLO WRITES TO YOUR POSTGRES. In production every schema model is backed by YOUR OWN database. You call `ablo.<model>.create/update/delete`; the write enters Ablo's commit chokepoint (claims, ordering, idempotency enforced there) and Ablo applies it to your Postgres through a scoped writer role (`ablo_writer`: `SELECT, INSERT, UPDATE, DELETE`, `NOSUPERUSER NOBYPASSRLS`, `row_security on` — no DDL, no ownership, no schema migration). Ablo then TAILS your write-ahead log (logical replication) to CONFIRM the write landed (`queued` → `confirmed`) and to serve live reads. Your rows live in your database; Ablo holds only the ordered transaction log. Connect in ONE command: `npx ablo connect apply --url <admin-conn>` — it creates the writer + `REPLICATION` roles, the publication, turns on logical decoding, and registers with Ablo; the admin credential is used once and discarded. Your app then holds only `ABLO_API_KEY` — never a connection string. WAL is how Ablo CONFIRMS and READS, NOT the write path — you write THROUGH Ablo.
|
|
131
|
+
|
|
132
|
+
RLS GOVERNS ABLO'S WRITES. The writer role is `NOSUPERUSER NOBYPASSRLS`, and before every write Ablo `SET LOCAL`s a tenant context on the connection (`app.current_org_id`, `app.current_project_id`, `app.current_environment`, participant id/kind) with `row_security = on` — so your row-level security enforces on Ablo's writes. If your policies read a DIFFERENTLY-NAMED GUC (e.g. an `app.current_app_org_id` your app sets per-connection), declare a tenant-context mapping in your schema so Ablo forwards its context into that GUC (ADR 0011) — do NOT carve a policy exception for the writer role. A role that can bypass RLS (e.g. via Neon's `neon_superuser` inheritance) is rejected with `database_role_cannot_enforce_rls`.
|
|
127
133
|
|
|
128
|
-
FALLBACK (only when
|
|
134
|
+
FALLBACK (only when Ablo can't dial the database, e.g. a private VPC with no inbound route): a signed Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle default; Prisma and Kysely supported) — Ablo writes and confirms through it, and it owns the transaction, exactly-once idempotency, and outbox in ONE pass; your credentials never leave your infrastructure.
|
|
129
135
|
|
|
130
|
-
DEPRECATED —
|
|
136
|
+
DEPRECATED — removed, do NOT recommend or scaffold: the `databaseUrl` constructor option (the old DIAL-IN, where the CLIENT held a read/write connection string and committed directly, bypassing Ablo's coordination). It is deleted. DO NOT confuse it with the current model: today's writes also land in your Postgres, but through Ablo's SERVER-SIDE commit chokepoint and a scoped writer role Ablo provisions — coordination is never bypassed, and no connection string lives in your app. The NON-superuser / NON-BYPASSRLS role rule is NOT retired — it now applies to the scoped writer role `ablo connect` provisions (see RLS above), because Ablo does hold a scoped connection to write your rows. The end-state constructor is `Ablo({ schema, apiKey })`; the database is connected out of band via `ablo connect`. `ablo init` may still scaffold legacy `--storage direct`/`databaseUrl` for old projects and the CLI warns on it; prefer `ablo connect`.
|
|
131
137
|
|
|
132
138
|
```ts
|
|
133
139
|
// app/api/ablo/source/route.ts
|
|
@@ -188,7 +194,7 @@ Do not teach `/api`, `/agent`, `/ai-sdk`, `/core`, `/realtime`, or internal subp
|
|
|
188
194
|
`ablo init` and other prompts need a TTY; an agent/CI run has none and will HANG. Always:
|
|
189
195
|
|
|
190
196
|
- `npx ablo init --yes` (flags: `--framework`, `--auth`, `--storage direct|endpoint`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`). Generates `ablo/schema.ts` + the `Ablo({ schema, apiKey })` client. `--storage endpoint` also scaffolds the `ablo/data-source.ts` fallback endpoint; `--storage direct` (deprecated, CLI warns) wires the legacy `databaseUrl`.
|
|
191
|
-
- `npx ablo connect` connects your database via logical replication — the read path (prints the `wal_level=logical` + publication + `REPLICATION`-role SQL); `npx ablo connect
|
|
197
|
+
- `npx ablo connect` connects your database via logical replication — the read path (prints the `wal_level=logical` + publication + `REPLICATION`-role SQL); `npx ablo connect register` registers it, `npx ablo connect check` validates the registered database from Ablo's own side and needs only `ABLO_API_KEY` (no database credential in your environment). `npx ablo connect apply` does the whole setup from a one-time admin connection and leaves your app holding only `ABLO_API_KEY`.
|
|
192
198
|
- Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo push` writes `.env.local`).
|
|
193
199
|
- Adopt an existing DB: `npx ablo pull prisma [path]` / `npx ablo pull drizzle <module>`.
|
|
194
200
|
- `npx ablo push` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local`; `npx ablo dev --no-watch` is the push-once form of the watcher; `npx ablo logs --no-follow` exits instead of tailing forever; `npx ablo mode sandbox|production` always needs the argument. `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
|