@abloatai/ablo 0.16.2 → 0.17.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.
@@ -1,16 +1,16 @@
1
1
  # Quickstart
2
2
 
3
3
  Build with Ablo on **the Postgres you already have**. You declare a small Ablo
4
- schema for the models humans and agents edit together, hand the client your
5
- Postgres `DATABASE_URL` (passed explicitly), and coordinate every write through
6
- `ablo.<model>`. In production, your database is the system of record. Ablo is the
7
- transaction layer on top: it registers your connection, commits every write there
8
- behind row-level security, and fans the confirmed rows out to every connected
9
- client.
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**.
10
10
 
11
11
  > No database yet? The hosted **sandbox** can host rows in Ablo's test plane —
12
- > pass an `apiKey` only and omit `databaseUrl`, like Stripe test mode — so you can
13
- > try Ablo before pointing it at your Postgres.
12
+ > pass an `apiKey` only and skip the database setup, like Stripe test mode — so
13
+ > you can try Ablo before connecting your Postgres.
14
14
 
15
15
  ## 1. Install and initialize
16
16
 
@@ -95,15 +95,34 @@ same idiom as tRPC's `typeof appRouter` and Drizzle's `typeof db`; it resolves
95
95
  the typed overload at the call site. Avoid `ReturnType<typeof Ablo>`, which
96
96
  collapses to the untyped client.
97
97
 
98
- ## 3. Point Ablo at your database
98
+ ## 3. Connect your database with `ablo connect`
99
99
 
100
- The client takes your schema, your key, and your `DATABASE_URL`. On first
101
- connect Ablo registers the connection (sent once over TLS, stored sealed, never
102
- echoed back) and from then on commits every write directly to your Postgres.
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.
103
+
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
111
+
112
+ # 3. Put the replication role's connection string in DATABASE_URL, then validate:
113
+ npx ablo connect --check
114
+ ```
115
+
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.
103
122
 
104
123
  ```bash
105
124
  # .env — server runtime only, never the browser
106
- DATABASE_URL=postgres://ablo_app:...@host:5432/db
125
+ DATABASE_URL=postgres://ablo_replicator:<password>@host:5432/db?sslmode=require
107
126
  ABLO_API_KEY=sk_test_...
108
127
  ```
109
128
 
@@ -115,50 +134,18 @@ import { schema } from './schema';
115
134
  export const ablo = Ablo({
116
135
  schema,
117
136
  apiKey: process.env.ABLO_API_KEY,
118
- databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
119
137
  });
120
138
  ```
121
139
 
122
- `databaseUrl` is not auto-read from the environment you pass it explicitly
123
- (as above). If a `DATABASE_URL` is set for another tool, `Ablo()` ignores it
124
- unless you wire it in like this.
125
-
126
- Use a dedicated **non-superuser role** for the connection — Ablo enforces
127
- tenant isolation with row-level security, so the server rejects superuser or
128
- `BYPASSRLS` roles outright (`database_role_cannot_enforce_rls`).
129
-
130
- > **Neon / Supabase note:** the connection string those dashboards hand you
131
- > uses the database OWNER role (e.g. `neondb_owner`), which is `BYPASSRLS` —
132
- > Ablo will reject it. You don't have to fix that by hand: `npx ablo dev`
133
- > (next step) detects the unsafe role and offers to create the scoped one for
134
- > you — from your machine, so the owner credential never reaches Ablo. It
135
- > writes the new `DATABASE_URL` into your env file (the generated password is
136
- > never printed).
137
- >
138
- > Prefer to do it manually? The equivalent SQL:
139
- >
140
- > ```sql
141
- > CREATE ROLE ablo_app LOGIN PASSWORD '<strong password>'
142
- > NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
143
- > GRANT CREATE, CONNECT ON DATABASE <your_db> TO ablo_app;
144
- > GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;
145
- > ```
146
- >
147
- > Then swap the user/password in the dashboard's string:
148
- > `postgres://ablo_app:<password>@<same-host>/<same-db>?sslmode=require`.
149
-
150
- Don't want a connection string to leave your infrastructure? Keep
151
- `DATABASE_URL` in your app only and expose one signed **Data Source endpoint**
152
- built from an ORM adapter instead — same product, same writes, see
153
- [Connect Your Database](./data-sources.md). In that setup, omit `databaseUrl`
154
- from `Ablo(...)`.
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).
155
143
 
156
144
  ## 4. Push the schema, then map it to tables
157
145
 
158
146
  ```bash
159
- npx ablo push # checks your DATABASE_URL role, pushes the schema (sandbox),
160
- # and writes ABLO_API_KEY to .env.local. Add --watch to
161
- # re-push on every save.
147
+ npx ablo push # pushes the schema definition and writes ABLO_API_KEY to
148
+ # .env.local. Add --watch to re-push on every save.
162
149
  ```
163
150
 
164
151
  `ablo push` uploads the schema *definition* — model names, fields, types. That
@@ -166,21 +153,22 @@ metadata is what tells Ablo which models to coordinate. Skipping it makes every
166
153
  write to a new or changed model fail with `server_execute_unknown_model` — that
167
154
  error literally means "run `npx ablo push`."
168
155
 
169
- Now Ablo needs real Postgres tables behind those models. Two ways, depending on
170
- who owns the tables:
156
+ Now map those models to your real Postgres tables. **Your migration tool owns the
157
+ tables** Ablo reads them, it does not create or migrate them:
158
+
159
+ - Run `npx ablo pull` to import the shape of your existing tables (created by
160
+ Prisma, Drizzle, or hand-written migrations) into your schema, or
161
+ `npx ablo check` to verify your schema and the live tables agree. Keep managing
162
+ the tables with your own migration tool; Ablo syncs the subset of models you
163
+ declared and reports the rest as "ignored / owned by you."
171
164
 
172
- - **Adopt existing tables (the common case).** Most teams already have the
173
- tables created by Prisma, Drizzle, or hand-written migrations. Run
174
- `npx ablo pull` to import their shape into your schema, or `npx ablo check`
175
- to verify your schema and the live tables agree. Keep managing the tables
176
- with your own migration tool; Ablo just syncs the subset of models you
177
- declared.
178
- - **Let Ablo provision them.** If Ablo should own the tables, `npx ablo migrate`
179
- creates your synced-model tables (with row-level security) in the registered
180
- database. Your other tables are left untouched.
165
+ > **Optional escape hatch:** if you have no tables yet and want Ablo to scaffold
166
+ > them, `npx ablo migrate` can create your synced-model tables for you. This is
167
+ > not the happy path connecting a real database is `ablo connect` (step 3), and
168
+ > your own migrations stay in charge of your schema.
181
169
 
182
170
  Nothing runs locally — there is no dev server to start. Your app talks to Ablo's
183
- hosted API with the sandbox key; the rows land in your database.
171
+ hosted API; the rows live in your database.
184
172
 
185
173
  ## 5. Write through the model
186
174
 
@@ -279,5 +267,5 @@ Keep using the schema client for app and agent writes.
279
267
  - [Schema Contract](./schema-contract.md) explains what the schema drives across SDK, React, agents, Data Source, and schema push.
280
268
  - [Guarantees](./guarantees.md) explains what confirmed writes and stale checks mean.
281
269
  - [Client Behavior](./client-behavior.md) covers errors, retries, and public imports.
282
- - [Connect Your Database](./data-sources.md) covers both connection shapes — `databaseUrl` and the signed Data Source endpoint.
270
+ - [Connect Your Database](./data-sources.md) covers the logical-replication connect path end to end — `ablo connect`, the honest footprint, and the WAL runtime's Preview status.
283
271
  - [AI SDK Tool](./examples/ai-sdk-tool.md) shows the same write path inside a tool call.
package/docs/react.md CHANGED
@@ -295,5 +295,5 @@ unmount.
295
295
 
296
296
  ## Next.js
297
297
 
298
- The Next.js [App Router landing](/nextjs) walks through Server Components
298
+ The Next.js [App Router landing](./examples/nextjs.md) walks through Server Components
299
299
  + Server Actions + `useAblo` together.
@@ -0,0 +1,239 @@
1
+ # Sessions
2
+
3
+ A **session** is a short-lived credential your backend mints with its `sk_` and
4
+ hands to one actor — a signed-in **person's browser** or a scoped **agent**. It's
5
+ the same primitive in both cases (backend-minted, short-lived, scoped); the only
6
+ difference is the subject and how much authority it carries.
7
+
8
+ One resource mints both:
9
+
10
+ ```ts Your backend (sk_)
11
+ // A logged-in person's browser session — full authority within their org.
12
+ const userSession = await ablo.sessions.create({
13
+ user: { id: currentUser.id },
14
+ });
15
+
16
+ // A scoped agent session — gated to exactly the operations you name.
17
+ const agentSession = await ablo.sessions.create({
18
+ agent: { id: 'agent:task-writer' },
19
+ can: { Task: ['read', 'update'], Deck: ['read'] },
20
+ });
21
+ ```
22
+
23
+ `user` mints an `ek_` (ephemeral key); `agent` mints an `rk_` (restricted key).
24
+ You pass `user` **or** `agent` — never both.
25
+
26
+ It exists because of one rule: **the browser can never hold a secret.** Your
27
+ `sk_` lives on the server; the browser only ever holds a minted session token
28
+ (which already names your org). So the per-actor credential is minted
29
+ server-side, scoped, and expires in minutes — the model Stripe uses for
30
+ client-side SDKs.
31
+
32
+ ## Why
33
+
34
+ Ablo doesn't authenticate your users — you do, however you like (your own
35
+ sessions, an IdP, anything). Ablo authenticates your **project** (the `sk_` that
36
+ minted the session) and trusts the identity you asserted at mint time. The
37
+ session token *is* that assertion: "this connection is acting as `U`, in org
38
+ `O`, until it expires."
39
+
40
+ ## End-user sessions (`ek_`)
41
+
42
+ For a logged-in person using your app. Mint on a backend route that has already
43
+ authenticated the user:
44
+
45
+ ```ts Your backend route (session-authed)
46
+ const { token } = await ablo.sessions.create({
47
+ user: { id: currentUser.id }, // who the session acts as
48
+ // syncGroups: [...], // optional; defaults to the user's org + user
49
+ });
50
+ return Response.json({ token }); // return ONLY the token to the browser
51
+ ```
52
+
53
+ A user session has **full data authority** within its org — no operation
54
+ allowlist. It's the human acting as themselves.
55
+
56
+ Build a browser `Ablo` client whose `getToken` fetches from that route, and pass
57
+ the **instance** to [`<AbloProvider>`](/react). The client fetches the token,
58
+ opens the connection, and re-mints before expiry — your app writes no token
59
+ plumbing:
60
+
61
+ ```tsx
62
+ 'use client';
63
+
64
+ import Ablo from '@abloatai/ablo';
65
+ import { AbloProvider } from '@abloatai/ablo/react';
66
+ import { schema } from '@/ablo.schema';
67
+
68
+ const ablo = Ablo({
69
+ schema,
70
+ getToken: () =>
71
+ fetch('/api/ablo-session', { method: 'POST' })
72
+ .then((r) => r.json())
73
+ .then((d) => d.token),
74
+ });
75
+
76
+ export function Providers({ children }: { children: React.ReactNode }) {
77
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
78
+ }
79
+ ```
80
+
81
+ The client owns auth, the credential lifecycle, and the connection; the provider
82
+ is the thin reactive binding over it (Stripe's `<Elements stripe={...}>` model).
83
+ Build the client **once** at module scope — a new instance per render tears down
84
+ the socket. `authEndpoint: '/api/ablo-session'` is accepted as sugar for the
85
+ `getToken` fetch above if you prefer a URL.
86
+
87
+ ## Agent sessions (`rk_`)
88
+
89
+ For a non-human actor — an agent or automation that should only do **specific**
90
+ operations. The `can` map is the permission boundary, and it's **typed against
91
+ your schema** — the model keys are your schema's models, so a typo is a compile
92
+ error, not a silent over-grant:
93
+
94
+ ```ts
95
+ const session = await ablo.sessions.create({
96
+ agent: { id: 'agent:task-writer' },
97
+ can: { Task: ['read', 'update'] }, // typed off the schema — no magic strings
98
+ ttlSeconds: 600,
99
+ });
100
+
101
+ const agent = Ablo({ schema, apiKey: session.token }); // the agent's scoped client
102
+ ```
103
+
104
+ `can: { Task: ['update'] }` serializes to the wire allowlist `task.update`; the
105
+ server rejects any commit whose operation isn't listed. Operations are
106
+ `'read' | 'create' | 'update' | 'delete'`.
107
+
108
+ <Note>
109
+ Use `sessions.create({ agent })` to mint a scoped agent credential, then write
110
+ with `ablo.<model>.update(...)` / `ablo.commits.create(...)` under a `claim`.
111
+ This is the path for custom runtimes, MCP sessions, and protocol-level integrations.
112
+ </Note>
113
+
114
+ ## Mint
115
+
116
+ Only a **secret key** (`sk_`) can mint a session — never another session token.
117
+ The `sk_` is the trust anchor; minting is your backend vouching
118
+ for the actor.
119
+
120
+ | Param | For | Meaning |
121
+ |---|---|---|
122
+ | `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. |
123
+ | `can` | agent | Per-model operation allowlist, typed off the schema. |
124
+ | `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
125
+ | `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
126
+ | `userMeta` | both | Opaque identity blob echoed back to the client. |
127
+
128
+ ## Lifecycle
129
+
130
+ Sessions are **short-lived by design** (~15 minutes) and, for browsers,
131
+ **auto-refreshed** — the provider re-mints ahead of expiry, so a session never
132
+ drops at the boundary. A revoked or signed-out actor simply stops getting a fresh
133
+ token; the old one expires on its own. There's nothing to revoke by hand.
134
+
135
+ ### Offline & sign-out
136
+
137
+ The short session token is **not** your user's login — it's a minutes-long
138
+ credential layered on top of whatever long-lived auth your `authEndpoint`
139
+ already enforces (your own session cookie, an IdP, etc.). The provider keeps
140
+ those two lifetimes separate, which means:
141
+
142
+ - **Going offline never signs the user out.** The provider keeps working from
143
+ its local cache and treats a failed re-mint (no network, a timeout, a `5xx`
144
+ from your endpoint) as **transient** — it retries, and re-mints the instant
145
+ connectivity or tab focus returns. The user stays signed in for as long as
146
+ your underlying session is valid, however brief or long the network drop.
147
+ - **The user is signed out only when the underlying session is genuinely
148
+ rejected** — i.e. your `authEndpoint` responds **`401`/`403`** because the
149
+ cookie (or IdP session) is missing, expired, or revoked. That's the one
150
+ signal the provider treats as terminal.
151
+
152
+ This mirrors the OAuth refresh-token rule (Okta/Auth0/Authgear): only a
153
+ rejection of the *long-lived* credential ends the session — a network failure
154
+ never does.
155
+
156
+ <Note>
157
+ Your `authEndpoint` contract follows from this: return the token on success,
158
+ respond **`401`/`403`** only when the user's session is actually gone, and let
159
+ network/`5xx` failures surface as errors. Don't collapse "can't reach the mint
160
+ endpoint" into "session expired" — returning a `401` for a transient blip will
161
+ bounce a still-valid user to your sign-in page.
162
+ </Note>
163
+
164
+ ## Scope
165
+
166
+ A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
167
+ derived from the identity you minted it for. **Dynamic, relation-driven
168
+ membership** (e.g. a `dataroom:<id>` the user was just added to) is resolved
169
+ **server-side at connect** and unioned on top — so scope stays live, not frozen
170
+ at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
171
+
172
+ ## Your schema, your users (the default)
173
+
174
+ Your schema lives in a **project** — you push it once (`npx ablo push`) and every
175
+ session you mint resolves against it. The flow for serving end-users:
176
+
177
+ 1. **Push your schema** to your project.
178
+ 2. **Mint an `ek_` per user** — `sessions.create({ user: { id } })`. Your users
179
+ commit to that one schema.
180
+
181
+ **Your users do not have Ablo accounts.** You authenticate them however you
182
+ already do; your server's `sk_` mints the `ek_`. By default the session lands in
183
+ your project's own org, so all your users share one schema and one data tenant,
184
+ isolated from each other by sync-groups. For most apps (the Cursor shape) that's
185
+ the whole story — nothing below is needed.
186
+
187
+ ## Org-per-customer isolation (the add-on)
188
+
189
+ Some apps need each customer to be its **own** tenant — a hard data boundary
190
+ (separate row-level isolation, optionally a separate database), not just per-user
191
+ scoping. The law-firm shape (Legora): every firm is its own org, many users
192
+ inside it.
193
+
194
+ The problem that creates: if each customer is a separate org, a naïve setup would
195
+ make you re-push your schema into every new customer's org. You don't have to.
196
+ Keep **one** project as the home of your schema, and point each customer's
197
+ session's *schema* at it while its *data* stays in the customer's own org:
198
+
199
+ ```ts
200
+ const { token } = await mintUserSessionKey({
201
+ apiKey: process.env.ABLO_PLATFORM_KEY, // sk_ with the ephemeral:mint-any-org scope
202
+ userId,
203
+ organizationId, // DATA → this customer's org (its own isolated tenant)
204
+ schemaProject: { // SCHEMA → the project that owns your schema
205
+ organizationId: schemaOwnerOrgId,
206
+ projectId: schemaProjectId,
207
+ },
208
+ ttlSeconds: 3600,
209
+ });
210
+ ```
211
+
212
+ Server-side the split is clean: the model **shape** loads from your schema
213
+ project, but column enrichment and the tenant connection target the customer's
214
+ `organizationId` — so the shared schema only *describes* the shape; the data
215
+ plane (connection + row-level isolation) stays the customer's. A shared schema
216
+ can't leak data across orgs.
217
+
218
+ <Note>
219
+ This requires a platform `sk_` carrying the `ephemeral:mint-any-org` scope —
220
+ only a trusted first-party key can mint a session into another org and bind its
221
+ schema to your project. Omit these fields and you get the default above: one
222
+ project, one schema, all your users.
223
+ </Note>
224
+
225
+ ## Security
226
+
227
+ The whole safety argument is the short TTL: a session token leaked from a
228
+ browser (XSS) is valid for minutes, scoped to one actor's data, and can't mint
229
+ anything or touch the control plane. Contrast `sk_`, which would be a full org
230
+ compromise — which is exactly why it never leaves your server.
231
+
232
+ ## User vs. agent sessions
233
+
234
+ | | User session (`ek_`) | Agent session (`rk_`) |
235
+ |---|---|---|
236
+ | For | a **person** in the browser | an **agent** / automation |
237
+ | Authority | full, within their org | narrow (explicit `can` allowlist) |
238
+ | Mint | `ablo.sessions.create({ user: { id } })` | `ablo.sessions.create({ agent: { id }, can })` |
239
+ | Lives where | the user's **browser** | the agent runtime |
@@ -0,0 +1,231 @@
1
+ # Webhooks
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.
6
+
7
+ 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
+
11
+ ## The loop
12
+
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
+ ```
28
+
29
+ There are two ways data flows out of Ablo, and they're for different jobs:
30
+
31
+ | | reaches | use it for |
32
+ |---|---|---|
33
+ | **Realtime** (`useAblo`, WSS) | your live UI | instant, optimistic rendering |
34
+ | **Webhooks** (this page) | your database | a durable copy you own — analytics, backups, server logic |
35
+
36
+ Most apps use both: the realtime stream for the UI, the webhook stream to keep
37
+ their database in sync. This page is the webhook stream.
38
+
39
+ If you know Stripe, you already know the shape:
40
+
41
+ | Stripe | Ablo |
42
+ |---|---|
43
+ | `stripe.x.create(...)` — make the change | the Ablo client — make the change (+ live sync) |
44
+ | `/stripe-webhook` — confirm and persist | `/api/ablo/[...all]` — persist into your database |
45
+ | Stripe owns the charges | Ablo owns the transaction log |
46
+ | you mirror charges into your database | you mirror the log into your database |
47
+
48
+ The difference in Ablo's favor: every event carries `syncId`, a monotonic log
49
+ position, so you can both dedupe **and** apply in order — Ablo guarantees the
50
+ order because it owns the log.
51
+
52
+ ## The event object
53
+
54
+ Every delivery is a batch of events. Each event:
55
+
56
+ | field | meaning |
57
+ |---|---|
58
+ | `type` | `"<model>.<verb>"`, e.g. `task.updated` |
59
+ | `model` | the model name (`task`) — the table to write |
60
+ | `objectId` | the changed row's id |
61
+ | `data` | the post-change row, or `null` on delete (like Stripe's `event.data.object`) |
62
+ | `syncId` | monotonic log position — **dedupe and order by this** |
63
+ | `id` | `String(syncId)` — the event id |
64
+ | `createdAt` | ISO commit timestamp |
65
+
66
+ ```ts
67
+ import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
68
+ ```
69
+
70
+ ## 1. Create a handler
71
+
72
+ `npx ablo init` scaffolds this for you at `app/api/ablo/[...all]/route.ts`. If
73
+ your project uses Prisma it's a **working generic mirror** — one upsert/delete
74
+ for every model, no per-model code. Otherwise it's a neutral route with a single
75
+ place to plug your database in.
76
+
77
+ ```ts app/api/ablo/[...all]/route.ts
78
+ import { Webhook } from 'svix'; // any Standard Webhooks library
79
+ import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
80
+ import { PrismaClient } from '@prisma/client';
81
+
82
+ const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
83
+ const prisma = new PrismaClient();
84
+
85
+ type ModelDelegate = {
86
+ upsert(a: { where: { id: string }; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<unknown>;
87
+ delete(a: { where: { id: string } }): Promise<unknown>;
88
+ };
89
+
90
+ export async function POST(req: Request): Promise<Response> {
91
+ const body = await req.text(); // RAW body — required to verify
92
+ let batch: { data: AbloWebhookEvent[] };
93
+ try {
94
+ batch = wh.verify(body, Object.fromEntries(req.headers)) as { data: AbloWebhookEvent[] };
95
+ } catch {
96
+ return new Response('invalid signature', { status: 400 });
97
+ }
98
+
99
+ const delegates = prisma as unknown as Record<string, ModelDelegate | undefined>;
100
+ for (const event of [...batch.data].sort((a, b) => a.syncId - b.syncId)) {
101
+ const model = delegates[event.model];
102
+ if (!model) continue; // a model you don't mirror — skip
103
+ if (event.data === null) {
104
+ await model.delete({ where: { id: event.objectId } }).catch(() => {});
105
+ } else {
106
+ await model.upsert({ where: { id: event.objectId }, create: event.data, update: event.data });
107
+ }
108
+ }
109
+
110
+ return new Response(null, { status: 200 }); // 2xx = delivered
111
+ }
112
+ ```
113
+
114
+ You only edit this if your tables **diverge** from Ablo's schema (renamed
115
+ columns, extra side effects) — add a `case` for that model before the generic
116
+ mirror. If your tables match Ablo's, you never touch it.
117
+
118
+ <Note>
119
+ Return a `2xx` quickly. Any other status is a failure and is retried. Do heavy
120
+ work asynchronously after responding.
121
+ </Note>
122
+
123
+ ## 2. Test locally
124
+
125
+ You don't register a `localhost` URL. `npx ablo dev` forwards committed changes
126
+ to your machine with a local signing secret — the same idea as Stripe's
127
+ `stripe listen`. Run your app, run `ablo dev`, and writes flow into your local
128
+ handler.
129
+
130
+ ## 3. Register your endpoint
131
+
132
+ For a deployed URL, register it once. Ablo mints the signing secret and returns
133
+ it a single time — the CLI writes it straight into your `.env.local`.
134
+
135
+ ```bash
136
+ npx ablo webhooks create https://yourapp.com/api/ablo/[...all]
137
+ # ✓ Registered we_… → https://yourapp.com/api/ablo/[...all]
138
+ # ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once)
139
+ ```
140
+
141
+ Manage and inspect endpoints:
142
+
143
+ ```bash
144
+ npx ablo webhooks list # endpoints + delivery health (status, cursor, last error)
145
+ npx ablo webhooks roll <id> # mint a fresh signing secret
146
+ npx ablo webhooks enable <id> # re-enable a disabled endpoint
147
+ ```
148
+
149
+ Or call the API directly — the org is derived from your secret key:
150
+
151
+ ```bash
152
+ curl https://api.abloatai.com/api/v1/webhook_endpoints \
153
+ -H "authorization: Bearer $ABLO_API_KEY" \
154
+ -H "content-type: application/json" \
155
+ -d '{ "url": "https://yourapp.com/api/ablo/[...all]" }'
156
+ # → { "id": "we_…", "secret": "whsec_…", "status": "enabled", ... }
157
+ ```
158
+
159
+ ## 4. Verify the signature
160
+
161
+ Ablo signs every request with the [Standard Webhooks](https://www.standardwebhooks.com)
162
+ scheme (the spec Svix authored). Verify with any compatible library — `svix` or
163
+ `standardwebhooks` — using the secret from registration. Ablo ships no
164
+ verification code of its own; you use the open library.
165
+
166
+ ```ts
167
+ const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
168
+ const event = wh.verify(rawBody, Object.fromEntries(req.headers));
169
+ ```
170
+
171
+ Verification checks three headers — `webhook-id`, `webhook-timestamp`,
172
+ `webhook-signature` — and rejects a timestamp outside a 5-minute window (replay
173
+ protection). Always verify against the **raw** request body.
174
+
175
+ ## Event delivery
176
+
177
+ Ablo delivers from a per-endpoint **cursor** over the log, advancing only on a
178
+ `2xx`. A failed delivery leaves the cursor in place, so the same events are
179
+ re-sent until they land — at-least-once, in order.
180
+
181
+ ```mermaid
182
+ sequenceDiagram
183
+ participant L as Ablo log
184
+ participant W as Delivery worker
185
+ participant E as Your endpoint
186
+ W->>L: read events after cursor
187
+ L-->>W: events (ordered by syncId)
188
+ W->>E: POST signed batch
189
+ alt 2xx
190
+ E-->>W: 200
191
+ W->>L: advance cursor
192
+ else non-2xx / timeout
193
+ E-->>W: error
194
+ Note over W,E: retry with backoff<br/>5s → 5m → … → 10h (8 attempts)<br/>then auto-disable
195
+ end
196
+ ```
197
+
198
+ | behavior | how it works |
199
+ |---|---|
200
+ | **Ordering** | Every event carries `syncId`, a monotonic log position. Apply in `syncId` order — Ablo guarantees the order because it owns the log. |
201
+ | **Retries** | A non-`2xx` (or no response within the timeout) is retried with backoff: immediate, 5s, 5m, 30m, 2h, 5h, 10h, 10h — 8 attempts over ~32h. |
202
+ | **Auto-disable** | After the retries exhaust, the endpoint is marked `disabled` and delivery stops until you `ablo webhooks enable <id>`. |
203
+ | **Replay** | Nothing is lost on failure: the log *is* the durable buffer, and delivery resumes from the endpoint's cursor once it's healthy. |
204
+
205
+ ## Best practices
206
+
207
+ - **Dedupe by `syncId`.** Skip any `syncId` you've already stored. Delivery is
208
+ at-least-once, so the same event can arrive twice after a retry.
209
+ - **Apply in `syncId` order.** It's the log position; sort each batch by it.
210
+ - **Return `2xx` fast.** Acknowledge first, then do slow work asynchronously.
211
+ - **Subscribe to only what you need.** Set `enabledEvents` to the models you
212
+ mirror (`['*']` is the default = all).
213
+ - **Roll secrets periodically.** `ablo webhooks roll <id>` mints a new secret;
214
+ Standard Webhooks supports a rotation window so in-flight events still verify.
215
+ - **Verify the raw body.** Frameworks that re-serialize JSON will break the
216
+ signature — verify the bytes you received.
217
+
218
+ ## Event types
219
+
220
+ The verb is derived from the change:
221
+
222
+ | type | when |
223
+ |---|---|
224
+ | `<model>.created` | a row was inserted |
225
+ | `<model>.updated` | a row was updated |
226
+ | `<model>.deleted` | a row was deleted (`data` is `null`) |
227
+ | `<model>.archived` | a row was soft-archived |
228
+ | `<model>.unarchived` | a soft-archived row was restored |
229
+
230
+ Internal coordination changes (permissions, sync groups) carry no webhook — only
231
+ your data models produce events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -140,7 +140,9 @@
140
140
  "build:cli": "tsup --config tsup.cli.config.ts",
141
141
  "typecheck:cli": "tsc -p tsconfig.cli.json",
142
142
  "pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
143
- "lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs",
143
+ "lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs && npm run lint:mintlify",
144
+ "build:docs": "node scripts/build-mintlify-docs.mjs",
145
+ "lint:mintlify": "node scripts/build-mintlify-docs.mjs --check",
144
146
  "lint:imports": "node scripts/check-js-extensions.mjs",
145
147
  "generate:errors": "tsx scripts/generate-error-docs.mts",
146
148
  "lint:errors": "tsx scripts/check-error-docs.mts",