@abloatai/ablo 0.9.0 → 0.9.2
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 +15 -7
- package/dist/BaseSyncedStore.d.ts +10 -0
- package/dist/BaseSyncedStore.js +26 -0
- package/dist/SyncClient.d.ts +12 -0
- package/dist/SyncClient.js +15 -0
- package/dist/agent/index.js +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/client/Ablo.d.ts +9 -51
- package/dist/client/Ablo.js +2 -104
- package/dist/client/ApiClient.d.ts +3 -115
- package/dist/client/ApiClient.js +0 -232
- package/dist/client/auth.js +32 -2
- 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/errorCodes.js +3 -3
- package/dist/index.js +1 -1
- package/dist/interfaces/index.d.ts +4 -4
- package/dist/mutators/UndoManager.d.ts +100 -11
- package/dist/mutators/UndoManager.js +282 -13
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/context.d.ts +31 -0
- 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.d.ts +8 -0
- package/dist/schema/ddl.js +10 -0
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/server/commit.d.ts +4 -5
- package/dist/source/adapter.d.ts +18 -12
- package/dist/source/adapter.js +8 -7
- package/dist/source/adapters/drizzle.d.ts +15 -6
- package/dist/source/adapters/drizzle.js +87 -49
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +2 -2
- package/dist/source/adapters/prisma.d.ts +3 -3
- package/dist/source/adapters/prisma.js +6 -29
- package/dist/source/conformance.d.ts +1 -1
- package/dist/source/conformance.js +2 -2
- package/dist/source/contract.d.ts +3 -2
- package/dist/source/contract.js +3 -2
- package/dist/source/index.d.ts +1 -0
- package/dist/source/index.js +3 -2
- package/dist/source/migrations.d.ts +14 -0
- package/dist/source/migrations.js +39 -0
- package/dist/types/streams.d.ts +2 -1
- package/dist/wire/frames.d.ts +6 -8
- package/docs/api.md +1 -1
- package/docs/cli.md +18 -5
- package/docs/data-sources.md +68 -83
- 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/identity.md +86 -59
- package/docs/index.md +1 -1
- package/docs/integration-guide.md +85 -54
- package/docs/react.md +39 -28
- package/llms.txt +18 -11
- package/package.json +2 -2
package/docs/identity.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Identity & Sync Groups
|
|
2
2
|
|
|
3
3
|
This is the doc the Quickstart skips: **who is connecting, and which slice
|
|
4
|
-
of shared state do they get?** If you've wired `<AbloProvider
|
|
4
|
+
of shared state do they get?** If you've wired `<AbloProvider client={ablo}>`
|
|
5
5
|
and wondered where org / team / user actually come from — start here.
|
|
6
6
|
|
|
7
7
|
## Ablo does not do auth
|
|
@@ -37,9 +37,9 @@ runnable place, so the concepts below have code to attach to.
|
|
|
37
37
|
|
|
38
38
|
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
39
39
|
each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
|
|
40
|
-
optional `
|
|
41
|
-
gets their `org` / `team` scope, an agent gets one `deck` — then the
|
|
42
|
-
after explain each.
|
|
40
|
+
optional `scope` setting on the client (narrowing). Read the three blocks first —
|
|
41
|
+
a human gets their `org` / `team` scope, an agent gets one `deck` — then the
|
|
42
|
+
sections after explain each.
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
45
|
// 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
|
|
@@ -74,23 +74,28 @@ export const schema = defineSchema(
|
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
```tsx
|
|
77
|
-
// 2. app/providers.tsx — a HUMAN gets their full org / team scope
|
|
78
|
-
|
|
77
|
+
// 2. app/providers.tsx — a HUMAN gets their full org / team scope.
|
|
78
|
+
// teamIds is set on the client you build (Ablo({ schema, teamIds: user.teamIds })),
|
|
79
|
+
// not passed to the provider; the provider just takes that client.
|
|
80
|
+
<AbloProvider client={ablo} userId={user.id}>
|
|
79
81
|
{children}
|
|
80
82
|
</AbloProvider>
|
|
81
83
|
```
|
|
82
84
|
|
|
83
|
-
```
|
|
85
|
+
```ts
|
|
84
86
|
// 3. an AGENT run inherits its user, narrowed to the entities in play.
|
|
85
87
|
// Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
|
|
86
|
-
// the engine derives the group from each model's `scope`.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
// the engine derives the group from each model's `scope`. The narrowing lives
|
|
89
|
+
// on the client you build, then the provider mounts it.
|
|
90
|
+
const ablo = Ablo({
|
|
91
|
+
schema,
|
|
92
|
+
authEndpoint: '/api/ablo-session',
|
|
93
|
+
scope: { decks: deckId }, // floor: just the deck it's working on
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
<AbloProvider client={ablo} userId={user.id /* ceiling: the triggering user */}>
|
|
92
97
|
{children}
|
|
93
|
-
</AbloProvider
|
|
98
|
+
</AbloProvider>;
|
|
94
99
|
```
|
|
95
100
|
|
|
96
101
|
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
@@ -126,8 +131,8 @@ That's why you never write per-user scope code, but you always pass an agent's
|
|
|
126
131
|
scope at the call site. A user's org/team/user don't change per request, so
|
|
127
132
|
their scope is a **rule the schema derives automatically**. An agent's reach
|
|
128
133
|
depends on *what it's working on*, which is only knowable at dispatch — so you
|
|
129
|
-
|
|
130
|
-
entities is to declare *that* a model is
|
|
134
|
+
set its `scope` on the client **at the dispatch site, in code**. The schema's
|
|
135
|
+
only job for entities is to declare *that* a model is
|
|
131
136
|
entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
|
|
132
137
|
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
133
138
|
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
@@ -298,45 +303,62 @@ server, never by the browser.**
|
|
|
298
303
|
|
|
299
304
|
## Wiring the provider
|
|
300
305
|
|
|
301
|
-
The
|
|
302
|
-
resolve the user in a Server Component and pass
|
|
306
|
+
The identity your server resolved is carried by the client you build and the
|
|
307
|
+
`userId` prop. In a Next.js app, resolve the user in a Server Component and pass
|
|
308
|
+
it down. Build the client once (the schema, `teamIds`, and any `scope` narrowing
|
|
309
|
+
live here), then hand it to the provider:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// lib/ablo.ts
|
|
313
|
+
import Ablo from '@abloatai/ablo';
|
|
314
|
+
import { schema } from '@/ablo/schema';
|
|
315
|
+
|
|
316
|
+
// Build the client from the identity your server already resolved.
|
|
317
|
+
// teamIds → team sync groups via identityRoles.
|
|
318
|
+
export function makeAblo(user: { teamIds: string[] }) {
|
|
319
|
+
return Ablo({
|
|
320
|
+
schema,
|
|
321
|
+
authEndpoint: '/api/ablo-session',
|
|
322
|
+
teamIds: user.teamIds,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
```
|
|
303
326
|
|
|
304
327
|
```tsx
|
|
305
|
-
// app/providers.tsx
|
|
328
|
+
// app/providers.tsx
|
|
329
|
+
'use client';
|
|
330
|
+
|
|
331
|
+
import { useMemo } from 'react';
|
|
306
332
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
307
|
-
import {
|
|
333
|
+
import { makeAblo } from '@/lib/ablo';
|
|
308
334
|
|
|
309
335
|
export function Providers({
|
|
310
336
|
children,
|
|
311
|
-
user,
|
|
337
|
+
user, // { id, teamIds } — resolved server-side from YOUR auth
|
|
312
338
|
}: {
|
|
313
339
|
children: React.ReactNode;
|
|
314
340
|
user: { id: string; teamIds: string[] };
|
|
315
341
|
}) {
|
|
342
|
+
const ablo = useMemo(() => makeAblo(user), [user.id]);
|
|
316
343
|
return (
|
|
317
|
-
<AbloProvider
|
|
318
|
-
schema={schema}
|
|
319
|
-
userId={user.id}
|
|
320
|
-
teamIds={user.teamIds}
|
|
321
|
-
fallback={<AppSkeleton />}
|
|
322
|
-
>
|
|
344
|
+
<AbloProvider client={ablo} userId={user.id} fallback={<AppSkeleton />}>
|
|
323
345
|
{children}
|
|
324
346
|
</AbloProvider>
|
|
325
347
|
);
|
|
326
348
|
}
|
|
327
349
|
```
|
|
328
350
|
|
|
329
|
-
What
|
|
351
|
+
What carries identity — and just as importantly, what does *not* set the boundary:
|
|
330
352
|
|
|
331
|
-
|
|
|
353
|
+
| Where | Purpose |
|
|
332
354
|
| ------------ | ------------------------------------------------------------------------------------------------ |
|
|
333
|
-
| `userId`
|
|
334
|
-
| `teamIds`
|
|
335
|
-
| `
|
|
355
|
+
| `userId` prop | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
|
|
356
|
+
| `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
|
|
357
|
+
| `scope` (on the client) | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `{ decks: 'abc123' }`). |
|
|
336
358
|
|
|
337
359
|
Because the server is the boundary, a client that changes `userId` to another
|
|
338
360
|
user's id does not gain their data — the server resolves and enforces the real
|
|
339
|
-
identity on the connection.
|
|
361
|
+
identity on the connection. These are how your app *tells* Ablo who it
|
|
340
362
|
already authenticated, not how it *proves* it.
|
|
341
363
|
|
|
342
364
|
## Agents are participants too
|
|
@@ -374,16 +396,19 @@ decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
|
|
|
374
396
|
Then a run subscribes only to the entity groups for the rows it works on — a
|
|
375
397
|
subset of what its user could see:
|
|
376
398
|
|
|
377
|
-
```
|
|
378
|
-
// agent run triggered by `user`, working on one document + one deck
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
399
|
+
```ts
|
|
400
|
+
// agent run triggered by `user`, working on one document + one deck.
|
|
401
|
+
// The narrowing lives on the client; the provider just mounts it.
|
|
402
|
+
const ablo = Ablo({
|
|
403
|
+
schema,
|
|
404
|
+
authEndpoint: '/api/ablo-session',
|
|
383
405
|
// authority narrowed to just the entities in play (the floor).
|
|
384
406
|
// Model form — keyed by model, resolved to groups via each model's `scope`.
|
|
385
|
-
scope
|
|
386
|
-
|
|
407
|
+
scope: { documents: documentId, decks: deckId },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// identity inherited from the triggering user (the ceiling)
|
|
411
|
+
<AbloProvider client={ablo} userId={user.id}>
|
|
387
412
|
```
|
|
388
413
|
|
|
389
414
|
As the run touches more entities its set **accretes** to cover them; it never
|
|
@@ -419,13 +444,13 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
|
|
|
419
444
|
`claim` loop is, to the scoping layer, that same participant — scoped to the row
|
|
420
445
|
it claimed.
|
|
421
446
|
|
|
422
|
-
## Narrowing to specific entities — the `scope`
|
|
447
|
+
## Narrowing to specific entities — the `scope` setting
|
|
423
448
|
|
|
424
449
|
A human gets their full membership automatically (`identityRoles`). To narrow a
|
|
425
450
|
session — a page on one deck, or an agent pointed at the entities it's working
|
|
426
|
-
on —
|
|
427
|
-
|
|
428
|
-
`deck:<id>`.
|
|
451
|
+
on — set `scope` on the client you build (`Ablo({ schema, scope })`). You give it
|
|
452
|
+
the **model and id(s)**; the engine builds the group string from the model's
|
|
453
|
+
`scope` (Half 2), so you never hand-write `deck:<id>`.
|
|
429
454
|
|
|
430
455
|
`scope` accepts four shapes, all resolved through the schema:
|
|
431
456
|
|
|
@@ -438,14 +463,16 @@ group string from the model's `scope` (Half 2), so you never hand-write
|
|
|
438
463
|
|
|
439
464
|
```tsx
|
|
440
465
|
// a page on one deck
|
|
441
|
-
|
|
466
|
+
const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', scope: { decks: deckId } });
|
|
467
|
+
<AbloProvider client={ablo} userId={user.id} />;
|
|
442
468
|
|
|
443
469
|
// an agent working across two decks and a document
|
|
444
|
-
|
|
445
|
-
schema
|
|
446
|
-
|
|
447
|
-
scope
|
|
448
|
-
|
|
470
|
+
const ablo = Ablo({
|
|
471
|
+
schema,
|
|
472
|
+
authEndpoint: '/api/ablo-session',
|
|
473
|
+
scope: { decks: [deckA, deckB], documents: docId },
|
|
474
|
+
});
|
|
475
|
+
<AbloProvider client={ablo} userId={user.id} />;
|
|
449
476
|
```
|
|
450
477
|
|
|
451
478
|
The key is the **model** (`decks`), the value is **which id(s)** — the `deck:`
|
|
@@ -453,8 +480,8 @@ prefix comes from that model's `scope: 'deck'`, never from a string you compose.
|
|
|
453
480
|
|
|
454
481
|
> **`scope` means one thing: sync-group scope.** It appears in two places that
|
|
455
482
|
> are the same concept — the model option `scope: 'deck'` (declares a scope root,
|
|
456
|
-
> [Half 2](#half-2--per-model-scope-row--group)) and this `scope`
|
|
457
|
-
> to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
|
|
483
|
+
> [Half 2](#half-2--per-model-scope-row--group)) and this `scope` client setting
|
|
484
|
+
> (subscribe to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
|
|
458
485
|
> axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
|
|
459
486
|
> open/closed/all), precisely so it doesn't share the word.
|
|
460
487
|
|
|
@@ -485,9 +512,9 @@ how to reason about it.
|
|
|
485
512
|
the room/shape and the server signs off, as in
|
|
486
513
|
[Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
|
|
487
514
|
[ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
|
|
488
|
-
and Liveblocks **access tokens**. Ablo's `
|
|
489
|
-
half of this — but it can only ever shrink the server-derived set,
|
|
490
|
-
it.
|
|
515
|
+
and Liveblocks **access tokens**. Ablo's client `scope` setting is the
|
|
516
|
+
*narrowing* half of this — but it can only ever shrink the server-derived set,
|
|
517
|
+
never grow it.
|
|
491
518
|
|
|
492
519
|
The best practices Ablo inherits from that lineage:
|
|
493
520
|
|
|
@@ -503,9 +530,9 @@ The best practices Ablo inherits from that lineage:
|
|
|
503
530
|
the line precisely: [token parameters are trusted and usable for access
|
|
504
531
|
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
505
532
|
In Ablo terms, the identity your server vouches for is the *trusted* claim that
|
|
506
|
-
sets scope; the
|
|
507
|
-
input* — convenient for app-owned fields and narrowing, but never the
|
|
508
|
-
This is why changing `userId` in the browser grants nothing.
|
|
533
|
+
sets scope; the `userId` prop and the client's `scope` setting are *untrusted
|
|
534
|
+
client input* — convenient for app-owned fields and narrowing, but never the
|
|
535
|
+
boundary. This is why changing `userId` in the browser grants nothing.
|
|
509
536
|
|
|
510
537
|
3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
|
|
511
538
|
group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
|
package/docs/index.md
CHANGED
|
@@ -42,7 +42,7 @@ Three things stay true no matter how you use Ablo:
|
|
|
42
42
|
- [Quickstart](./quickstart.md) — Make your first schema-backed write.
|
|
43
43
|
- [Schema Contract](./schema-contract.md) — One schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
|
|
44
44
|
- [CLI & Migrations](./cli.md) — `init` / `migrate` / `push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
|
|
45
|
-
- [Identity & Sync Groups](./identity.md) —
|
|
45
|
+
- [Identity & Sync Groups](./identity.md) — Use your own authentication; tell Ablo who's connecting and how org / team / user map to sync-group scope.
|
|
46
46
|
- [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
|
|
47
47
|
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
|
|
48
48
|
- [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
|
|
@@ -170,17 +170,49 @@ export const ablo = Ablo({
|
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
Browser apps should use the React provider or a scoped session token, not a
|
|
173
|
-
server API key in the bundle.
|
|
173
|
+
server API key in the bundle. Build the client first, then hand it to the
|
|
174
|
+
provider — `AbloProvider` takes `{ client, userId?, onError?, fallback? }`, and
|
|
175
|
+
nothing else (`schema`, `teamIds`, `scope`, and `authEndpoint` all live on the
|
|
176
|
+
client now).
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// src/ablo-client.ts
|
|
180
|
+
import Ablo from '@abloatai/ablo';
|
|
181
|
+
import { schema } from '@/ablo/schema';
|
|
182
|
+
|
|
183
|
+
// The browser never holds the API key. The client mints a short-lived token
|
|
184
|
+
// from your session route (see below) and refreshes it before expiry.
|
|
185
|
+
export const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
|
|
186
|
+
```
|
|
174
187
|
|
|
175
188
|
```tsx
|
|
176
189
|
// app/providers.tsx
|
|
177
190
|
'use client';
|
|
178
191
|
|
|
179
192
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
180
|
-
import {
|
|
193
|
+
import { ablo } from '@/ablo-client';
|
|
181
194
|
|
|
182
195
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
183
|
-
return <AbloProvider
|
|
196
|
+
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The session route mints the scoped token server-side, where the API key lives:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// app/api/ablo-session/route.ts
|
|
204
|
+
import Ablo from '@abloatai/ablo';
|
|
205
|
+
import { schema } from '@/ablo/schema';
|
|
206
|
+
import { auth } from '@/auth';
|
|
207
|
+
|
|
208
|
+
export const runtime = 'nodejs';
|
|
209
|
+
|
|
210
|
+
const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
211
|
+
|
|
212
|
+
export async function POST() {
|
|
213
|
+
const session = await auth(); // your own auth — returns the signed-in user
|
|
214
|
+
const { token } = await sync.sessions.create({ user: { id: session.userId } });
|
|
215
|
+
return Response.json({ token });
|
|
184
216
|
}
|
|
185
217
|
```
|
|
186
218
|
|
|
@@ -214,10 +246,11 @@ refreshes before expiry.
|
|
|
214
246
|
## 3. Read State
|
|
215
247
|
|
|
216
248
|
Reads come in two flavors, and you pick based on whether you can wait.
|
|
217
|
-
`retrieve(id)` and `list({ where })` hit the server (and hydrate the local
|
|
218
|
-
store) — they're async, so you `await` them. `get(id)
|
|
219
|
-
and `getCount({ where })` read the already-synced local
|
|
220
|
-
they're the ones you call in render
|
|
249
|
+
`retrieve({ id })` and `list({ where })` hit the server (and hydrate the local
|
|
250
|
+
store) — they're async, so you `await` them. `get(id)` (positional),
|
|
251
|
+
`getAll({ where })`, and `getCount({ where })` read the already-synced local
|
|
252
|
+
graph synchronously, so they're the ones you call in render — and the ones you
|
|
253
|
+
use inside a `useAblo` selector, never the async `retrieve`/`list`.
|
|
221
254
|
|
|
222
255
|
Use `retrieve` when the row may not be local yet — it fetches from the server
|
|
223
256
|
and waits.
|
|
@@ -345,47 +378,41 @@ For the full Python shape, see
|
|
|
345
378
|
|
|
346
379
|
## 7. Data Source Endpoint
|
|
347
380
|
|
|
348
|
-
Use a Data Source when your app database remains the source of truth.
|
|
381
|
+
Use a Data Source when your app database remains the source of truth. Wire the
|
|
382
|
+
route with `dataSourceNext` and an adapter — `prismaDataSource(prisma, schema)`
|
|
383
|
+
or `drizzleDataSource(db, schema)`. You don't hand-write `commit`; the adapter
|
|
384
|
+
owns transactional commit, idempotency, and reads.
|
|
349
385
|
|
|
350
386
|
```ts
|
|
351
387
|
// app/api/ablo/source/route.ts
|
|
352
|
-
import {
|
|
388
|
+
import { dataSourceNext } from '@abloatai/ablo/source/next';
|
|
389
|
+
import { prismaDataSource } from '@abloatai/ablo/source';
|
|
353
390
|
import { schema } from '@/ablo/schema';
|
|
354
|
-
import {
|
|
355
|
-
|
|
356
|
-
export const POST = dataSource({
|
|
357
|
-
schema,
|
|
358
|
-
apiKey: process.env.ABLO_API_KEY,
|
|
391
|
+
import { prisma } from '@/db';
|
|
359
392
|
|
|
360
|
-
|
|
361
|
-
return { db };
|
|
362
|
-
},
|
|
393
|
+
export const runtime = 'nodejs';
|
|
363
394
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
395
|
+
export const { POST } = dataSourceNext({
|
|
396
|
+
schema,
|
|
397
|
+
apiKey: process.env.ABLO_API_KEY!,
|
|
398
|
+
adapter: prismaDataSource(prisma, schema),
|
|
399
|
+
});
|
|
400
|
+
```
|
|
369
401
|
|
|
370
|
-
|
|
371
|
-
|
|
402
|
+
With Drizzle, pass `drizzleDataSource(db, schema)` instead — the adapter takes
|
|
403
|
+
your Drizzle `db` and the Ablo `schema` (not your table objects):
|
|
372
404
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return context.auth.db.report.findUnique({ where: { id } });
|
|
376
|
-
},
|
|
405
|
+
```ts
|
|
406
|
+
import { drizzleDataSource } from '@abloatai/ablo/source/drizzle';
|
|
377
407
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
},
|
|
383
|
-
},
|
|
408
|
+
export const { POST } = dataSourceNext({
|
|
409
|
+
schema,
|
|
410
|
+
apiKey: process.env.ABLO_API_KEY!,
|
|
411
|
+
adapter: drizzleDataSource(db, schema),
|
|
384
412
|
});
|
|
385
413
|
```
|
|
386
414
|
|
|
387
|
-
Ablo needs your Data Source endpoint and API key.
|
|
388
|
-
reported through an optional `events` handler on the same route. Your app
|
|
415
|
+
Ablo needs your Data Source endpoint and API key. Your app
|
|
389
416
|
stores one Ablo credential:
|
|
390
417
|
|
|
391
418
|
```bash
|
|
@@ -404,16 +431,18 @@ which a human might touch the same row. Wrap that work in a claim. Claims don't
|
|
|
404
431
|
lock. If another writer holds the row, `claim` waits for them, re-reads the
|
|
405
432
|
fresh row, then hands it to you — so two writers serialize instead of clobbering.
|
|
406
433
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
434
|
+
A claim is a disposable handle (`await using`), not a callback: read the fresh
|
|
435
|
+
row off `claim.data`, do your work, and the handle auto-releases when it leaves
|
|
436
|
+
scope.
|
|
410
437
|
|
|
438
|
+
```ts
|
|
411
439
|
await using claim = await ablo.weatherReports.claim({
|
|
412
440
|
id: reportId,
|
|
413
|
-
wait: false,
|
|
414
441
|
action: 'forecasting',
|
|
415
442
|
});
|
|
416
443
|
const claimed = claim.data;
|
|
444
|
+
if (!claimed) return;
|
|
445
|
+
|
|
417
446
|
await ablo.weatherReports.update({
|
|
418
447
|
id: claimed.id,
|
|
419
448
|
data: { status: 'ready', forecast: await getForecast(claimed) },
|
|
@@ -464,17 +493,19 @@ them.
|
|
|
464
493
|
|
|
465
494
|
## Method Cheatsheet
|
|
466
495
|
|
|
467
|
-
| Method
|
|
468
|
-
|
|
|
469
|
-
| `retrieve(id)`
|
|
470
|
-
| `list({ where })`
|
|
471
|
-
| `get(id)`
|
|
472
|
-
| `getAll({ where })`
|
|
473
|
-
| `getCount({ where })`
|
|
474
|
-
| `create(data,
|
|
475
|
-
| `update(id, data,
|
|
476
|
-
| `delete(id,
|
|
477
|
-
| `claim.state({ id })`
|
|
478
|
-
| `claim(id,
|
|
479
|
-
|
|
480
|
-
Keep first integrations on the model methods above.
|
|
496
|
+
| Method | Use it for |
|
|
497
|
+
| -------------------------------------- | -------------------------------------------------------------------------------- |
|
|
498
|
+
| `retrieve({ id })` | Async read of one row from the server (await it). |
|
|
499
|
+
| `list({ where })` | Async read of many rows from the server (await it). |
|
|
500
|
+
| `get(id)` | Synchronous local read of one synced row (positional id; use in render). |
|
|
501
|
+
| `getAll({ where })` | Synchronous local read of many synced rows. |
|
|
502
|
+
| `getCount({ where })` | Synchronous local count of synced rows. |
|
|
503
|
+
| `create({ data, id? })` | Create through the model client. |
|
|
504
|
+
| `update({ id, data, ...opts })` | Update through the model client. |
|
|
505
|
+
| `delete({ id, ...opts })` | Delete through the model client. |
|
|
506
|
+
| `claim.state({ id })` | See who is currently working on a row (synchronous). |
|
|
507
|
+
| `claim({ id, action?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
|
|
508
|
+
|
|
509
|
+
Keep first integrations on the model methods above. Every mutation and
|
|
510
|
+
server-read verb takes one options object; only the synchronous `get(id)` stays
|
|
511
|
+
positional.
|
package/docs/react.md
CHANGED
|
@@ -14,6 +14,27 @@ The React bindings ship with the main package — no extra install.
|
|
|
14
14
|
import { useAblo } from '@abloatai/ablo/react';
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
## Building the client
|
|
18
|
+
|
|
19
|
+
You build the Ablo client once — that's where the schema, the session endpoint,
|
|
20
|
+
and connection config live — then hand it to the provider. The provider takes
|
|
21
|
+
the already-built `client`; it no longer takes `schema`, `url`, `apiKey`, etc.
|
|
22
|
+
as props. This mirrors Stripe's `<Elements stripe={stripePromise}>`: construct
|
|
23
|
+
the thing, then pass it.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// lib/ablo.ts
|
|
27
|
+
import Ablo from '@abloatai/ablo';
|
|
28
|
+
import { schema } from '@/ablo/schema';
|
|
29
|
+
|
|
30
|
+
// The browser never holds your API key. It mints a short-lived session token
|
|
31
|
+
// from your own server route (see Identity below).
|
|
32
|
+
export const ablo = Ablo({
|
|
33
|
+
schema,
|
|
34
|
+
authEndpoint: '/api/ablo-session',
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
17
38
|
## AbloProvider
|
|
18
39
|
|
|
19
40
|
Mount it once near the root of your tree. It owns the connection, the local
|
|
@@ -23,48 +44,38 @@ pool, and the engine lifecycle; everything below it reads with `useAblo`.
|
|
|
23
44
|
'use client';
|
|
24
45
|
|
|
25
46
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
26
|
-
import {
|
|
47
|
+
import { ablo } from '@/lib/ablo';
|
|
27
48
|
|
|
28
49
|
export function Providers({
|
|
29
50
|
children,
|
|
30
51
|
user, // resolved server-side from YOUR auth
|
|
31
52
|
}: {
|
|
32
53
|
children: React.ReactNode;
|
|
33
|
-
user: { id: string
|
|
54
|
+
user: { id: string };
|
|
34
55
|
}) {
|
|
35
56
|
return (
|
|
36
|
-
<AbloProvider
|
|
37
|
-
schema={schema}
|
|
38
|
-
userId={user.id}
|
|
39
|
-
teamIds={user.teamIds}
|
|
40
|
-
fallback={<AppSkeleton />}
|
|
41
|
-
>
|
|
57
|
+
<AbloProvider client={ablo} userId={user.id} fallback={<AppSkeleton />}>
|
|
42
58
|
{children}
|
|
43
59
|
</AbloProvider>
|
|
44
60
|
);
|
|
45
61
|
}
|
|
46
62
|
```
|
|
47
63
|
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
| Prop
|
|
51
|
-
|
|
|
52
|
-
| `
|
|
53
|
-
| `userId`
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Where `userId` / `teamIds` / `syncGroups` come from, and why the API key never
|
|
65
|
-
reaches the browser, is the whole of
|
|
66
|
-
[Identity & Sync Groups](./identity.md) — read that if it isn't obvious how org
|
|
67
|
-
/ team / user map to what a participant can see.
|
|
64
|
+
`client` is the only required prop. The rest are situational:
|
|
65
|
+
|
|
66
|
+
| Prop | Default | Purpose |
|
|
67
|
+
| ----------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| `client` | — | **Required.** The `Ablo({ schema, authEndpoint })` instance. It carries the schema and connection config. |
|
|
69
|
+
| `userId` | resolved from auth | App participant id for app-owned fields and your `identityRoles`. Not the security boundary. |
|
|
70
|
+
| `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
|
|
71
|
+
| `onError` | — | Engine / WebSocket / bootstrap errors. Wire to Sentry / Datadog. |
|
|
72
|
+
|
|
73
|
+
Everything that used to be a provider prop — `schema`, `url`, `apiKey`,
|
|
74
|
+
`teamIds`, `syncGroups`/`scope`, `persistence`, `bootstrapMode` — now lives on
|
|
75
|
+
the `Ablo({ ... })` client you build before mounting the provider. Where the
|
|
76
|
+
identity comes from, and why the API key never reaches the browser, is the whole
|
|
77
|
+
of [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how
|
|
78
|
+
org / team / user map to what a participant can see.
|
|
68
79
|
|
|
69
80
|
## useAblo — model client
|
|
70
81
|
|
package/llms.txt
CHANGED
|
@@ -6,6 +6,12 @@ Here is the problem it solves. Two writers touch `report_stockholm` at once. The
|
|
|
6
6
|
|
|
7
7
|
Use AI SDK for the agent loop. Use Ablo when agent reads and writes must persist, coordinate with concurrent work, and leave an audit trail.
|
|
8
8
|
|
|
9
|
+
## Start here
|
|
10
|
+
|
|
11
|
+
First action when integrating into an app: run `npx ablo init --yes --framework <nextjs|vite|remix|vanilla> --storage datasource`. Agents have no TTY — `--yes` is REQUIRED or it HANGS. It scaffolds `ablo/schema.ts`, the client, the Data Source endpoint, and (for Next.js) the browser provider + session route, all on the current API. Edit the generated files rather than hand-writing from this doc.
|
|
12
|
+
|
|
13
|
+
Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. After you create or change `ablo/schema.ts`, run `npx ablo push` (one-shot). Until you push, EVERY write to a new or changed model fails with `server_execute_unknown_model`. (Or keep `npx ablo dev` running — it pushes on save.)
|
|
14
|
+
|
|
9
15
|
## Use this API
|
|
10
16
|
|
|
11
17
|
```ts
|
|
@@ -23,15 +29,16 @@ const schema = defineSchema({
|
|
|
23
29
|
|
|
24
30
|
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
25
31
|
|
|
26
|
-
const report = await ablo.weatherReports.retrieve('report_stockholm');
|
|
32
|
+
const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
|
|
27
33
|
if (!report) throw new Error('Row not found');
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
+
// Claim the row (waits if someone else holds it), read the fresh copy off
|
|
36
|
+
// `claim.data`, write, then auto-release at the end of this scope (`await using`).
|
|
37
|
+
await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
|
|
38
|
+
const updated = await ablo.weatherReports.update({
|
|
39
|
+
id: claim.data.id,
|
|
40
|
+
data: { status: 'ready', forecast: await getForecast(claim.data) },
|
|
41
|
+
wait: 'confirmed',
|
|
35
42
|
});
|
|
36
43
|
```
|
|
37
44
|
|
|
@@ -46,7 +53,7 @@ For full integrations, use `integration-guide` as the canonical doc. It covers
|
|
|
46
53
|
the same model API across Ablo-managed state, Data Source-backed app databases,
|
|
47
54
|
React selectors, multiplayer, and future agent workers.
|
|
48
55
|
|
|
49
|
-
Reads come in two flavors, and you pick by whether you can wait. `retrieve(id)`
|
|
56
|
+
Reads come in two flavors, and you pick by whether you can wait. `retrieve({ id })`
|
|
50
57
|
(one row) and `list({ where })` (many) are async — they hit the server and return
|
|
51
58
|
a Promise, so await them. `get(id)`, `getAll({ where })`, and `getCount({ where })`
|
|
52
59
|
are synchronous — they read the local graph and are reactive in render, so no
|
|
@@ -68,7 +75,7 @@ first integration path.
|
|
|
68
75
|
Multiplayer is not a separate mode. When human UI, server actions, and agents use
|
|
69
76
|
the same schema client and write through `ablo.<model>`, Ablo coordinates the
|
|
70
77
|
shared model stream: confirmed deltas fan out to subscribers, active claims are
|
|
71
|
-
visible through `claim.state(id)`, and stale writes can be rejected with `readAt`.
|
|
78
|
+
visible through `claim.state({ id })`, and stale writes can be rejected with `readAt`.
|
|
72
79
|
|
|
73
80
|
If an app writes directly to its own database outside Ablo, that write bypasses
|
|
74
81
|
coordination until the app reports it through Data Source events.
|
|
@@ -76,7 +83,7 @@ coordination until the app reports it through Data Source events.
|
|
|
76
83
|
## Nouns
|
|
77
84
|
|
|
78
85
|
- `Model client` is the typed `ablo.<model>` object generated from schema.
|
|
79
|
-
- `Claim` holds a model row while slow work runs; `claim.state(id)` observes it.
|
|
86
|
+
- `Claim` holds a model row while slow work runs; `claim.state({ id })` observes it.
|
|
80
87
|
- `Commit` is the durable protocol write behind `ablo.<model>.update(...)`.
|
|
81
88
|
- `Receipt` confirms the commit.
|
|
82
89
|
|
|
@@ -121,7 +128,7 @@ import { prisma } from '@/lib/prisma';
|
|
|
121
128
|
export const { POST } = dataSourceNext({
|
|
122
129
|
schema,
|
|
123
130
|
apiKey: process.env.ABLO_API_KEY!,
|
|
124
|
-
adapter: prismaDataSource(prisma, schema), // or drizzleDataSource(db,
|
|
131
|
+
adapter: prismaDataSource(prisma, schema), // or drizzleDataSource(db, schema)
|
|
125
132
|
});
|
|
126
133
|
```
|
|
127
134
|
|