@abloatai/ablo 0.9.1 → 0.9.3
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 +84 -0
- package/CHANGELOG.md +40 -0
- package/README.md +53 -27
- package/dist/BaseSyncedStore.d.ts +2 -36
- package/dist/BaseSyncedStore.js +11 -55
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +22 -5
- package/dist/SyncClient.js +77 -0
- package/dist/SyncEngineContext.js +5 -1
- package/dist/agent/index.js +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +302645 -0
- package/dist/client/Ablo.d.ts +19 -52
- package/dist/client/Ablo.js +30 -106
- package/dist/client/ApiClient.d.ts +1 -113
- package/dist/client/ApiClient.js +39 -238
- package/dist/client/auth.js +32 -2
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/httpClient.d.ts +5 -6
- package/dist/client/httpClient.js +2 -3
- package/dist/client/index.d.ts +1 -1
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +19 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -1
- package/dist/interfaces/index.d.ts +14 -4
- package/dist/mutators/UndoManager.d.ts +48 -5
- package/dist/mutators/UndoManager.js +166 -1
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/useUndoScope.js +7 -0
- package/dist/schema/ddl.js +2 -1
- package/dist/schema/field.js +2 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/commit.d.ts +4 -5
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/transactions/TransactionQueue.d.ts +6 -7
- package/dist/transactions/TransactionQueue.js +33 -9
- package/dist/types/streams.d.ts +2 -1
- package/dist/utils/duration.js +3 -2
- package/dist/wire/frames.d.ts +6 -8
- package/docs/api.md +1 -1
- package/docs/cli.md +17 -4
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +129 -125
- package/docs/examples/ai-sdk-tool.md +11 -5
- package/docs/examples/existing-python-backend.md +26 -4
- package/docs/examples/nextjs.md +3 -2
- package/docs/examples/scoped-agent.md +38 -11
- package/docs/guarantees.md +2 -2
- package/docs/identity.md +86 -59
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +89 -61
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/react.md +39 -28
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +360 -0
- package/llms.txt +30 -18
- package/package.json +23 -3
package/docs/data-sources.md
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
3
|
+
**Your database is the system of record — Ablo never hosts your data.** Every
|
|
4
|
+
synced model is backed by your own Postgres; Ablo is the transaction layer on
|
|
5
|
+
top of it. There are two ways to connect, and they are the same product with the
|
|
6
|
+
same writes — the only difference is where your database credential lives:
|
|
7
|
+
|
|
8
|
+
| | How Ablo reaches your Postgres | Use when |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| **Connection string** (default) | You pass `databaseUrl` to `Ablo(...)`; Ablo registers the connection and commits each write directly, behind row-level security. | You can hand over a scoped connection string. |
|
|
11
|
+
| **Signed endpoint** | Your app exposes one route built from an ORM adapter; Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
|
|
12
|
+
|
|
13
|
+
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod. The
|
|
14
|
+
Ablo schema describes **only your synced, collaborative models** — the rows Ablo
|
|
15
|
+
coordinates and fans out in realtime. It is *not* your whole-database schema and
|
|
16
|
+
does *not* replace your `schema.prisma` (or your Drizzle schema). Your auth,
|
|
17
|
+
billing, and any other non-synced tables stay in your own ORM schema, owned by
|
|
18
|
+
your own migrations. One database, two schemas, side by side: Ablo owns the
|
|
19
|
+
synced models; you keep owning everything else. `ablo check` reflects this — it
|
|
20
|
+
reports your other tables as "ignored / owned by you," which is exactly right.
|
|
21
|
+
|
|
22
|
+
What Ablo stores, in both shapes: your schema *definition* (model names, fields,
|
|
23
|
+
types — pushed with `ablo push`), your hashed API keys, a safe projection of the
|
|
24
|
+
connection registration (host, database, schema — the connection string itself
|
|
25
|
+
is sealed and never echoed back), and the commit log that drives sync. Never
|
|
26
|
+
your rows.
|
|
27
|
+
|
|
28
|
+
## Connection String (default)
|
|
29
|
+
|
|
30
|
+
The canonical client carries all three values:
|
|
24
31
|
|
|
25
32
|
```ts
|
|
26
33
|
import Ablo from '@abloatai/ablo';
|
|
@@ -29,29 +36,50 @@ import { schema } from './ablo/schema';
|
|
|
29
36
|
export const ablo = Ablo({
|
|
30
37
|
schema,
|
|
31
38
|
apiKey: process.env.ABLO_API_KEY,
|
|
39
|
+
databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
|
|
32
40
|
});
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
Do not pass a database URL to `Ablo(...)`.
|
|
36
|
-
|
|
37
|
-
For the first production integration, prefer this shape:
|
|
38
|
-
|
|
39
43
|
```bash
|
|
40
|
-
#
|
|
41
|
-
DATABASE_URL=postgres
|
|
42
|
-
|
|
43
|
-
# The only Ablo credential in the customer app
|
|
44
|
+
# .env — server runtime only, never the browser
|
|
45
|
+
DATABASE_URL=postgres://ablo_app:...@host:5432/db
|
|
44
46
|
ABLO_API_KEY=sk_live_...
|
|
45
47
|
```
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
On first connect the SDK registers the connection — sent once over TLS, stored
|
|
50
|
+
sealed, never returned by any API. From then on Ablo commits every confirmed
|
|
51
|
+
write directly to your database and reads canonical rows from it.
|
|
52
|
+
|
|
53
|
+
Safety requirements, enforced server-side before the first write:
|
|
54
|
+
|
|
55
|
+
- **Non-superuser role.** The connection must not be a superuser or hold
|
|
56
|
+
`BYPASSRLS` — Ablo's tenant isolation is row-level security, and a role that
|
|
57
|
+
can bypass it is rejected outright.
|
|
58
|
+
- **Row-level security on synced tables.** `npx ablo migrate` provisions your
|
|
59
|
+
synced-model tables with `FORCE ROW LEVEL SECURITY` already applied; tables
|
|
60
|
+
you create yourself must do the same.
|
|
61
|
+
- **Public hosts only.** Connection strings resolving to loopback or private
|
|
62
|
+
address ranges are rejected.
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
| Ablo-managed | Ablo | Writes directly to Ablo's managed state store, then returns the confirmed row and fans out realtime deltas. | New collaborative/agent state that can live in Ablo. |
|
|
52
|
-
| Data Source | Your app database | Sends a signed commit request to your route; your app writes its DB and returns canonical rows. | Existing app tables, regulated data, or teams that need their DB to stay canonical. |
|
|
64
|
+
`databaseUrl` is server-only: the SDK throws if it sees one in a browser-like
|
|
65
|
+
environment, and `dangerouslyAllowBrowser` does not override that.
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
## Signed Endpoint
|
|
68
|
+
|
|
69
|
+
When a connection string must not leave your infrastructure, keep
|
|
70
|
+
`DATABASE_URL` in your app and expose one HTTPS endpoint instead. Ablo signs a
|
|
71
|
+
commit request; an ORM adapter in your route runs it in one transaction against
|
|
72
|
+
your Postgres and returns the canonical rows. Omit `databaseUrl` from
|
|
73
|
+
`Ablo(...)` in this setup — the client takes only the schema and the API key:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export const ablo = Ablo({
|
|
77
|
+
schema,
|
|
78
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The SDK call is identical in both shapes:
|
|
55
83
|
|
|
56
84
|
```ts
|
|
57
85
|
await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
|
|
@@ -59,20 +87,18 @@ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'read
|
|
|
59
87
|
const report = ablo.weatherReports.get('report_stockholm');
|
|
60
88
|
```
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Multiplayer behavior is the same in both modes. Writes made through
|
|
90
|
+
Multiplayer behavior is built in. Writes made through
|
|
65
91
|
`ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
|
|
66
92
|
fan out to subscribers. If something writes to your database without going
|
|
67
93
|
through Ablo (a cron job, an admin tool), Ablo can't know about it
|
|
68
94
|
automatically. To keep everyone's screen up to date, your app reports those
|
|
69
|
-
outside changes back through
|
|
70
|
-
[
|
|
95
|
+
outside changes back through the outbox feed — shown below in
|
|
96
|
+
[Outbox Events](#outbox-events).
|
|
71
97
|
|
|
72
|
-
##
|
|
98
|
+
## Your Database Stays Canonical
|
|
73
99
|
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
Your application database remains the source of truth and Ablo coordinates writes
|
|
101
|
+
against it.
|
|
76
102
|
|
|
77
103
|
If you are migrating an app where every button already calls a backend endpoint,
|
|
78
104
|
read [Integration Guide](./integration-guide.md) first, then
|
|
@@ -100,59 +126,53 @@ The shape is the same as a production webhook integration:
|
|
|
100
126
|
|
|
101
127
|
## Route
|
|
102
128
|
|
|
129
|
+
You don't hand-write the commit transaction, the idempotency upsert, or the
|
|
130
|
+
outbox writes. You pass an ORM **adapter** and it does all of that for you —
|
|
131
|
+
transaction, exactly-once idempotency, and outbox — driven by the same Ablo
|
|
132
|
+
schema. The whole route is three fields:
|
|
133
|
+
|
|
103
134
|
```ts
|
|
104
135
|
// app/api/ablo/source/route.ts
|
|
105
|
-
import {
|
|
136
|
+
import { dataSourceNext } from '@abloatai/ablo/source/next';
|
|
137
|
+
import { prismaDataSource } from '@abloatai/ablo/source';
|
|
106
138
|
import { schema } from '@/ablo/schema';
|
|
107
|
-
import {
|
|
139
|
+
import { prisma } from '@/lib/prisma';
|
|
108
140
|
|
|
109
|
-
|
|
141
|
+
// Data Source routes touch the database, so they run on the Node runtime.
|
|
142
|
+
export const runtime = 'nodejs';
|
|
143
|
+
|
|
144
|
+
export const { POST } = dataSourceNext({
|
|
110
145
|
schema,
|
|
111
|
-
apiKey: process.env.ABLO_API_KEY
|
|
146
|
+
apiKey: process.env.ABLO_API_KEY!,
|
|
147
|
+
adapter: prismaDataSource(prisma, schema),
|
|
148
|
+
});
|
|
149
|
+
```
|
|
112
150
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
151
|
+
Using Drizzle instead of Prisma is the same shape — swap the adapter for
|
|
152
|
+
`drizzleDataSource(db, schema)`:
|
|
116
153
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
sourceEventForOperation({
|
|
124
|
-
eventId,
|
|
125
|
-
operation,
|
|
126
|
-
entityId,
|
|
127
|
-
data,
|
|
128
|
-
...(clientTxId ? { clientTxId } : {}),
|
|
129
|
-
...(context.scope?.organizationId
|
|
130
|
-
? { organizationId: context.scope.organizationId }
|
|
131
|
-
: {}),
|
|
132
|
-
occurredAt: Date.now(),
|
|
133
|
-
}),
|
|
134
|
-
),
|
|
135
|
-
});
|
|
136
|
-
return changes.map(({ row }) => row);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
return { rows };
|
|
140
|
-
},
|
|
154
|
+
```ts
|
|
155
|
+
// app/api/ablo/source/route.ts
|
|
156
|
+
import { dataSourceNext } from '@abloatai/ablo/source/next';
|
|
157
|
+
import { drizzleDataSource } from '@abloatai/ablo/source/drizzle';
|
|
158
|
+
import { schema } from '@/ablo/schema';
|
|
159
|
+
import { db } from '@/db';
|
|
141
160
|
|
|
142
|
-
|
|
143
|
-
async load({ id, context }) {
|
|
144
|
-
return context.auth.db.report.findUnique({ where: { id } });
|
|
145
|
-
},
|
|
161
|
+
export const runtime = 'nodejs';
|
|
146
162
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
},
|
|
152
|
-
},
|
|
163
|
+
export const { POST } = dataSourceNext({
|
|
164
|
+
schema,
|
|
165
|
+
apiKey: process.env.ABLO_API_KEY!,
|
|
166
|
+
adapter: drizzleDataSource(db, schema),
|
|
153
167
|
});
|
|
154
168
|
```
|
|
155
169
|
|
|
170
|
+
The adapter is constructed from your ORM client and the Ablo `schema` —
|
|
171
|
+
`prismaDataSource(prisma, schema)` or `drizzleDataSource(db, schema)`. It maps
|
|
172
|
+
each synced model to your table, wraps every commit in one transaction, dedupes
|
|
173
|
+
on `clientTxId` via `ablo_idempotency`, and appends `ablo_outbox` rows for the
|
|
174
|
+
external-write feed — the bookkeeping you used to write by hand.
|
|
175
|
+
|
|
156
176
|
Your app code still writes through the normal model API:
|
|
157
177
|
|
|
158
178
|
```ts
|
|
@@ -208,55 +228,39 @@ events.
|
|
|
208
228
|
|
|
209
229
|
## Outbox Events
|
|
210
230
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
The adapter serves the outbox feed for you. Every `commit` it runs appends one
|
|
232
|
+
`ablo_outbox` row per operation in the same transaction, and the adapter's
|
|
233
|
+
built-in events handler streams those rows back to Ablo by cursor — so connected
|
|
234
|
+
humans and agents stay current with no extra code. If Ablo already appended the
|
|
235
|
+
commit directly, `clientTxId` lets Ablo filter the echo; if the direct append
|
|
236
|
+
failed, the same outbox row repairs it on the next poll or push.
|
|
215
237
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
Events without `clientTxId` are treated as external writes. The only thing you
|
|
239
|
+
add by hand is recording *outside* writes — changes made to your tables by a
|
|
240
|
+
cron job or admin tool that never went through Ablo. Append an `ablo_outbox` row
|
|
241
|
+
(with no `clientTxId`) for those in the same transaction as the change, and the
|
|
242
|
+
adapter's feed carries them to every connected screen.
|
|
220
243
|
|
|
221
|
-
|
|
222
|
-
const page = await context.auth.db.outbox.after(cursor, { limit });
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
events: page.rows.map((row) => ({
|
|
226
|
-
id: row.id,
|
|
227
|
-
model: row.model,
|
|
228
|
-
entityId: row.entityId,
|
|
229
|
-
type: row.type,
|
|
230
|
-
data: row.data,
|
|
231
|
-
organizationId: row.organizationId,
|
|
232
|
-
clientTxId: row.clientTxId,
|
|
233
|
-
occurredAt: row.createdAt.getTime(),
|
|
234
|
-
})),
|
|
235
|
-
nextCursor: page.nextCursor,
|
|
236
|
-
};
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
Events without `clientTxId` are treated as external writes.
|
|
244
|
+
## Production Checklist (signed endpoint)
|
|
242
245
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
Before using a customer-owned database in production:
|
|
246
|
+
Before using the signed-endpoint shape in production:
|
|
246
247
|
|
|
247
248
|
- Keep `DATABASE_URL` in the customer app or backend environment.
|
|
248
249
|
- Use only the Data Source endpoint and `ABLO_API_KEY` as the customer-facing integration boundary.
|
|
249
|
-
-
|
|
250
|
-
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
|
|
254
|
-
- Dedupe outbox events by event `id`.
|
|
250
|
+
- Run the adapter migrations so `ablo_outbox` and `ablo_idempotency` exist
|
|
251
|
+
alongside your synced tables (`ablo migrate`).
|
|
252
|
+
- Set `export const runtime = 'nodejs'` on the route so it can reach the database.
|
|
253
|
+
- For writes that bypass Ablo (cron, admin tools), append an `ablo_outbox` row
|
|
254
|
+
(no `clientTxId`) in the same transaction as the change.
|
|
255
255
|
- Monitor last success, last error, retry count, event lag, and cursor.
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
The adapter already handles the rest — signature verification, the commit
|
|
258
|
+
transaction, `clientTxId` idempotency, returning canonical rows, the outbox
|
|
259
|
+
append per operation, and deduping the feed by event `id`. You don't write any of
|
|
260
|
+
that by hand.
|
|
261
|
+
|
|
262
|
+
In this shape, leave `databaseUrl` out of `Ablo(...)` — the endpoint *is* the
|
|
263
|
+
connection, and registering both would point Ablo at your database twice.
|
|
260
264
|
|
|
261
265
|
## Security
|
|
262
266
|
|
|
@@ -7,7 +7,8 @@ Claims don't lock. If another writer holds the row, `claim` waits for them, re-r
|
|
|
7
7
|
```ts
|
|
8
8
|
import Ablo from '@abloatai/ablo';
|
|
9
9
|
import { defineSchema, model, z as schemaZ } from '@abloatai/ablo/schema';
|
|
10
|
-
import {
|
|
10
|
+
import { anthropic } from '@ai-sdk/anthropic';
|
|
11
|
+
import { convertToModelMessages, streamText, tool, type UIMessage } from 'ai';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
|
|
13
14
|
const schema = defineSchema({
|
|
@@ -62,17 +63,22 @@ const updateReport = tool({
|
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
export async function POST(req: Request) {
|
|
65
|
-
|
|
66
|
+
// `useChat` posts UIMessage[]; the model is a server-bound provider instance,
|
|
67
|
+
// never read off the request body.
|
|
68
|
+
const { messages }: { messages: UIMessage[] } = await req.json();
|
|
66
69
|
|
|
67
70
|
return streamText({
|
|
68
|
-
model,
|
|
69
|
-
messages,
|
|
71
|
+
model: anthropic('claude-sonnet-4-6'),
|
|
72
|
+
messages: await convertToModelMessages(messages),
|
|
70
73
|
tools: { updateReport },
|
|
71
74
|
}).toUIMessageStreamResponse();
|
|
72
75
|
}
|
|
73
76
|
```
|
|
74
77
|
|
|
75
|
-
The model provider is interchangeable
|
|
78
|
+
The model provider is interchangeable — swap `anthropic(...)` for any
|
|
79
|
+
server-bound provider instance. What matters is that the route binds the model
|
|
80
|
+
on the server (never trusting one sent in the request body), converts the
|
|
81
|
+
incoming `UIMessage[]` with `convertToModelMessages`, and that the tool:
|
|
76
82
|
|
|
77
83
|
- reads the latest weather report with `retrieve` (a server read),
|
|
78
84
|
- claims the row — if someone else holds it, the claim waits for them, then re-reads,
|
|
@@ -46,7 +46,7 @@ export const schema = defineSchema({
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
```ts
|
|
49
|
-
// web/ablo.ts
|
|
49
|
+
// web/ablo.ts — SERVER-ONLY client (holds the sk_ key; never imported in the browser).
|
|
50
50
|
import Ablo from '@abloatai/ablo';
|
|
51
51
|
import { schema } from './ablo/schema';
|
|
52
52
|
|
|
@@ -56,18 +56,40 @@ export const ablo = Ablo({
|
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Mount the React provider near the app root
|
|
60
|
-
|
|
59
|
+
Mount the React provider near the app root. Build the browser client first —
|
|
60
|
+
with an `authEndpoint` so it mints a short-lived session token instead of
|
|
61
|
+
carrying the secret key — then pass it to the provider via `client`.
|
|
61
62
|
|
|
62
63
|
```tsx
|
|
63
64
|
// web/app/providers.tsx
|
|
64
65
|
'use client';
|
|
65
66
|
|
|
67
|
+
import Ablo from '@abloatai/ablo';
|
|
66
68
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
67
69
|
import { schema } from '@/ablo/schema';
|
|
68
70
|
|
|
71
|
+
// Browser client: no secret key — `authEndpoint` mints the session token
|
|
72
|
+
// server-side (see the session route below).
|
|
73
|
+
const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
|
|
74
|
+
|
|
69
75
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
70
|
-
return <AbloProvider
|
|
76
|
+
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The session route mints with the server client that holds the `sk_` key — the
|
|
81
|
+
browser only ever sees the short-lived token:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// web/app/api/ablo-session/route.ts
|
|
85
|
+
import { ablo } from '@/ablo';
|
|
86
|
+
|
|
87
|
+
export const runtime = 'nodejs';
|
|
88
|
+
|
|
89
|
+
export async function POST() {
|
|
90
|
+
const userId = await currentUserId(); // your auth
|
|
91
|
+
const { token } = await ablo.sessions.create({ user: { id: userId } });
|
|
92
|
+
return Response.json({ token });
|
|
71
93
|
}
|
|
72
94
|
```
|
|
73
95
|
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -36,9 +36,10 @@ import { ablo } from '@/lib/ablo';
|
|
|
36
36
|
|
|
37
37
|
export default async function ReportPage({
|
|
38
38
|
params,
|
|
39
|
-
}: { params: { id: string } }) {
|
|
39
|
+
}: { params: Promise<{ id: string }> }) {
|
|
40
|
+
const { id } = await params;
|
|
40
41
|
await ablo.ready();
|
|
41
|
-
const report = await ablo.weatherReports.retrieve({ id
|
|
42
|
+
const report = await ablo.weatherReports.retrieve({ id });
|
|
42
43
|
if (!report) return null;
|
|
43
44
|
|
|
44
45
|
return <ReportEditor report={report} />;
|
|
@@ -46,26 +46,53 @@ export const schema = defineSchema(
|
|
|
46
46
|
## 2. Dispatch — narrow the agent to the deck it's working on
|
|
47
47
|
|
|
48
48
|
An agent can never reach more than the user who triggered it — that's the upper
|
|
49
|
-
limit. From there you narrow it to a single deck
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
limit. From there you narrow it to a single deck by minting the agent's session
|
|
50
|
+
against **just that deck's sync group**. You build the group from the **model
|
|
51
|
+
kind and id** with the typed `syncGroup` helper — `syncGroup('deck', deckId)`,
|
|
52
|
+
never a hand-assembled `deck:<id>` string — where `'deck'` is the kind declared
|
|
53
|
+
by the `decks` model's `scope`.
|
|
54
|
+
|
|
55
|
+
Mint the scoped session on your backend (it holds the `sk_` key; the browser
|
|
56
|
+
never does), then hand the short-lived token to the browser client:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// server — mints a scoped agent session for one deck
|
|
60
|
+
import Ablo from '@abloatai/ablo';
|
|
61
|
+
import { syncGroup } from '@abloatai/ablo/schema';
|
|
62
|
+
import { schema } from './schema';
|
|
63
|
+
|
|
64
|
+
const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
65
|
+
|
|
66
|
+
export async function mintDeckAgentSession(deckId: string, agentId: string) {
|
|
67
|
+
const { token } = await server.sessions.create({
|
|
68
|
+
agent: { id: agentId },
|
|
69
|
+
can: { slides: ['read', 'update'] }, // operation allowlist for this run
|
|
70
|
+
syncGroups: [syncGroup('deck', deckId)], // narrowed to just this deck
|
|
71
|
+
});
|
|
72
|
+
return token;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
52
75
|
|
|
53
76
|
```tsx
|
|
77
|
+
// client — the browser client carries only the scoped token.
|
|
78
|
+
import Ablo from '@abloatai/ablo';
|
|
54
79
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
80
|
+
import { schema } from './schema';
|
|
81
|
+
|
|
82
|
+
const ablo = Ablo({
|
|
83
|
+
schema,
|
|
84
|
+
getToken: async () => mintDeckAgentSession(deckId, agentId),
|
|
85
|
+
});
|
|
55
86
|
|
|
56
87
|
// The agent run is mounted on behalf of its triggering user.
|
|
57
|
-
<AbloProvider
|
|
58
|
-
schema={schema}
|
|
59
|
-
userId={triggeringUser.id} // ceiling: can't exceed this user's reach
|
|
60
|
-
scope={{ decks: deckId }} // floor: narrowed to just this deck → deck:<deckId>
|
|
61
|
-
>
|
|
88
|
+
<AbloProvider client={ablo} userId={triggeringUser.id}>
|
|
62
89
|
{children}
|
|
63
90
|
</AbloProvider>
|
|
64
91
|
```
|
|
65
92
|
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
never reach a deck its triggering user couldn't.
|
|
93
|
+
`syncGroups` requests, it never grants: at connect the server intersects the
|
|
94
|
+
groups the session asks for with the groups the identity is actually allowed,
|
|
95
|
+
so the agent can never reach a deck its triggering user couldn't.
|
|
69
96
|
|
|
70
97
|
## 3. Write — it fans out to everyone on that deck
|
|
71
98
|
|
package/docs/guarantees.md
CHANGED
|
@@ -109,7 +109,7 @@ authorized it, which run did it, and what state was it based on?"
|
|
|
109
109
|
|
|
110
110
|
## Persistence
|
|
111
111
|
|
|
112
|
-
Ablo defaults to
|
|
112
|
+
Ablo defaults to in-memory persistence ('memory'), so nothing is written to disk
|
|
113
113
|
unless you ask for it.
|
|
114
114
|
|
|
115
115
|
Opt into a durable browser cache that survives reloads when you need it:
|
|
@@ -122,7 +122,7 @@ const ablo = Ablo({
|
|
|
122
122
|
});
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Node, SSR, tests, and agents use
|
|
125
|
+
Node, SSR, tests, and agents use in-memory persistence ('memory') automatically.
|
|
126
126
|
|
|
127
127
|
## Storage Boundary
|
|
128
128
|
|