@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.
Files changed (42) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +53 -12
  3. package/README.md +12 -14
  4. package/dist/cli.cjs +1787 -1329
  5. package/dist/coordination/schema.d.ts +4 -2
  6. package/dist/coordination/schema.js +23 -9
  7. package/dist/errorCodes.d.ts +1 -0
  8. package/dist/errorCodes.js +2 -1
  9. package/dist/schema/index.d.ts +1 -1
  10. package/dist/schema/index.js +1 -1
  11. package/dist/schema/schema.d.ts +57 -0
  12. package/dist/schema/schema.js +53 -0
  13. package/dist/schema/select.js +1 -0
  14. package/dist/schema/serialize.d.ts +3 -1
  15. package/dist/schema/serialize.js +7 -1
  16. package/dist/source/adapters/drizzle.js +4 -3
  17. package/dist/source/adapters/kysely.js +4 -4
  18. package/dist/source/adapters/prisma.js +3 -3
  19. package/dist/source/idempotency.d.ts +19 -4
  20. package/dist/source/idempotency.js +19 -4
  21. package/dist/source/migrations.d.ts +4 -1
  22. package/dist/source/migrations.js +36 -5
  23. package/docs/agent-messaging.md +1 -2
  24. package/docs/api.md +4 -4
  25. package/docs/audit.md +35 -23
  26. package/docs/client-behavior.md +7 -4
  27. package/docs/concurrency-convention.md +3 -3
  28. package/docs/data-sources.md +141 -102
  29. package/docs/examples/agent-human.md +2 -2
  30. package/docs/examples/ai-sdk-tool.md +1 -1
  31. package/docs/examples/nextjs.md +1 -1
  32. package/docs/examples/server-agent.md +2 -2
  33. package/docs/groups.md +160 -0
  34. package/docs/guarantees.md +9 -7
  35. package/docs/how-it-works.md +110 -0
  36. package/docs/integration-guide.md +8 -7
  37. package/docs/interaction-model.md +3 -3
  38. package/docs/quickstart.md +38 -31
  39. package/docs/schema-contract.md +7 -5
  40. package/docs/webhooks.md +23 -22
  41. package/llms.txt +10 -4
  42. 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).
@@ -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
- - `force` applies the write without stale protection.
70
- - `flag` accepts the write and marks it for product review.
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
- Ablo does not need a customer database URL. When your own database is canonical,
152
- Ablo calls a signed Data Source endpoint and records the coordination result for
153
- receipts, realtime fanout, and audit. See [Connect Your Database](./data-sources.md).
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
- `DATABASE_URL` inside your app and connect it out of band: run `npx ablo connect`
52
- for logical replication (Ablo tails your WAL), or expose a signed Data Source
53
- endpoint where Ablo coordinates each write and your app commits it to your
54
- Postgres. Either way, application and agent code hold only `ABLO_API_KEY` the
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
- reason: 'forecasting',
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, reason?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
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
- reason: 'editing',
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
- - `merge` — apply the write if it does not overlap with concurrent changes.
91
- - `force` — apply the write unconditionally.
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
 
@@ -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 with **logical replication** (`ablo connect`), and coordinate every read
6
- and claim through `ablo.<model>`. Your database stays the system of record: your
7
- app keeps writing through its own backend, Ablo tails your write-ahead log (WAL),
8
- and the confirmed rows fan out to every connected client. Ablo **never runs DDL,
9
- owns, or migrates your schema**.
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? The hosted **sandbox** can host rows in Ablo's test plane —
12
- > pass an `apiKey` only and skip the database setup, like Stripe test mode so
13
- > you can try Ablo before connecting your Postgres.
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
- Connecting a real database = Postgres logical replication, and `ablo connect` is
101
- the one way to set it up. Ablo **reads** your WAL and never runs DDL, owns, or
102
- migrates your schema — your app keeps writing through its own backend.
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
- ```bash
105
- # 1. Enable logical decoding (then RESTART Postgres wal_level is not reloadable)
106
- # ALTER SYSTEM SET wal_level = 'logical';
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
- # 3. Put the replication role's connection string in DATABASE_URL, then validate:
113
- npx ablo connect --check
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` prints the copy-pasteable SQL (a publication named
117
- `ablo_publication` and a least-privilege `ablo_replicator` role with
118
- `REPLICATION` + `SELECT`, password you choose). `ablo connect --check` connects to
119
- `DATABASE_URL` and verifies `wal_level=logical`, the publication, the role's
120
- `REPLICATION` attribute, and that every published table has a usable
121
- `REPLICA IDENTITY` a green checklist or the precise fix per item.
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` role +
141
- the `wal_level` restart + slot/WAL retention Ablo monitors), and the Preview
142
- status of the WAL runtime are in [Connect Your Database](./data-sources.md).
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
- reason: 'checking_weather',
233
+ description: 'checking_weather',
227
234
  ttl: '2m',
228
235
  });
229
236
 
@@ -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. There are three start states,
106
- all covered in [Connect Your Database](./data-sources.md) (the single source of
107
- truth): the sandbox (`apiKey` only, no database), logical replication set up with
108
- `npx ablo connect` (Ablo tails your WAL), or a signed Data Source endpoint where
109
- your app keeps the database credential and commits each write itself.
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 owns the ordered transaction log the source of truth. **Webhooks are how
4
- that log reaches your database.** Ablo streams every committed change to an
5
- endpoint in your app as a signed event; your handler writes your own database.
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 to persist them (this webhook). Ablo never holds your
9
- database credentials.
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
- ```mermaid
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
+ ![Your app writes to Ablo's ordered log — the source of truth, which holds the log and coordination only, never your rows. The log fans out two ways: realtime sync to live clients for an instant, optimistic UI, and a signed event to your webhook route, which writes the durable copy into your own database.](/the-loop.svg)
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>"`, e.g. `task.updated` |
59
- | `model` | the model name (`task`) — the table to write |
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
- THE ONE PATH IS LOGICAL REPLICATION. In production every schema model is backed by YOUR OWN database, and Ablo connects to it by CONSUMING ITS WRITE-AHEAD LOG (logical replication) the Electric/PowerSync/Zero model. Ablo tails the WAL and fans changes out; YOUR APPLICATION CONTINUES TO OWN THE WRITE PATH (your own backend, your existing API). Ablo never runs DDL on, writes to, owns, or migrates your database. Connect once with `npx ablo connect` (prints the setup SQL: `wal_level=logical`, a publication, a `REPLICATION` role) then `npx ablo connect --register`. Validate with `npx ablo connect --check`. The role's privilege footprint is narrow but real a `REPLICATION`-attributed role that streams the WAL and `SELECT`s; do NOT describe it as "read-only" (a security reviewer will push backit is a replication privilege, just tightly scoped).
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 the database can't grant a `REPLICATION` role, e.g. a locked-down managed DB): a signed Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle default; Prisma and Kysely supported) — it owns the transaction, exactly-once idempotency, and outbox in ONE pass; your credentials never leave your infrastructure.
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 — being removed, do NOT recommend or scaffold: the `databaseUrl` constructor option (the dial-in, where Ablo held a read/write connection and committed directly to your DB). The end-state constructor is `Ablo({ schema, apiKey })`; the database is connected out of band via `ablo connect`, not through `databaseUrl`. The role rule you may have seen belongs ONLY to this dial-in: a `databaseUrl` had to name a NON-superuser, NON-BYPASSRLS role, or Ablo rejected it with `database_role_cannot_enforce_rls` (it enforced row-level security through the connection it held, so an owner/BYPASSRLS role e.g. Neon's `neondb_owner` would have silently skipped RLS). The WAL path retires that rule entirely: Ablo never holds a connection to your rows, so there is no `DATABASE_URL` to scope `ablo connect` needs a `REPLICATION` role instead. `ablo init` still scaffolds `--storage direct`/`databaseUrl` for existing dial-in projects and the CLI now warns on it; prefer `ablo connect` regardless of what `init` scaffolds.
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 --register` registers it, `npx ablo connect --check` validates.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.30.2",
3
+ "version": "0.32.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",