@abloatai/ablo 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +13 -1
package/docs/data-sources.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
By default, Ablo stores the rows for the models you define, so you don't need a
|
|
4
|
+
database to get started. But if you already have your own application database
|
|
5
|
+
and want it to stay the source of truth, you can attach it as a Data Source —
|
|
6
|
+
then Ablo coordinates each write and calls your app to commit it, instead of
|
|
7
|
+
storing the data itself.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`schema.prisma`.
|
|
9
|
+
That default makes Ablo the managed state store for your models, the same way
|
|
10
|
+
Stripe stores `Customer` and `PaymentIntent` objects that you create through
|
|
11
|
+
Stripe's API.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
and
|
|
13
|
-
|
|
14
|
-
If you already have application tables and want those tables to remain
|
|
15
|
-
canonical, attach a Data Source. Then Ablo coordinates the write and calls your
|
|
16
|
-
app to commit it.
|
|
13
|
+
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod —
|
|
14
|
+
the same way a Prisma project starts with a `schema.prisma`. Your schema
|
|
15
|
+
describes your data once, and everything else (the SDK, agents, and your
|
|
16
|
+
database connection) relies on that one definition.
|
|
17
17
|
|
|
18
18
|
Your app can keep using its own `DATABASE_URL`. Store that value in your app or
|
|
19
19
|
backend environment, not in Ablo. The integration boundary is the HTTPS
|
|
@@ -24,7 +24,7 @@ Use the SDK with an API key:
|
|
|
24
24
|
|
|
25
25
|
```ts
|
|
26
26
|
import Ablo from '@abloatai/ablo';
|
|
27
|
-
import { schema } from './ablo
|
|
27
|
+
import { schema } from './ablo/schema';
|
|
28
28
|
|
|
29
29
|
export const ablo = Ablo({
|
|
30
30
|
schema,
|
|
@@ -56,15 +56,18 @@ The SDK call is the same in both modes:
|
|
|
56
56
|
```ts
|
|
57
57
|
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
58
58
|
await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
59
|
-
const report = ablo.weatherReports.
|
|
59
|
+
const report = ablo.weatherReports.get('report_stockholm');
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Only the backing store changes.
|
|
63
63
|
|
|
64
64
|
Multiplayer behavior is the same in both modes. Writes made through
|
|
65
65
|
`ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
|
|
66
|
-
fan out to subscribers.
|
|
67
|
-
|
|
66
|
+
fan out to subscribers. If something writes to your database without going
|
|
67
|
+
through Ablo (a cron job, an admin tool), Ablo can't know about it
|
|
68
|
+
automatically. To keep everyone's screen up to date, your app reports those
|
|
69
|
+
outside changes back through an events feed — shown below in
|
|
70
|
+
[External Writes](#external-writes).
|
|
68
71
|
|
|
69
72
|
## When To Use A Data Source
|
|
70
73
|
|
|
@@ -100,7 +103,7 @@ The shape is the same as a production webhook integration:
|
|
|
100
103
|
```ts
|
|
101
104
|
// app/api/ablo/source/route.ts
|
|
102
105
|
import { dataSource } from '@abloatai/ablo';
|
|
103
|
-
import { schema } from '@/ablo
|
|
106
|
+
import { schema } from '@/ablo/schema';
|
|
104
107
|
import { db } from '@/db';
|
|
105
108
|
|
|
106
109
|
export const POST = dataSource({
|
|
@@ -231,10 +234,9 @@ Before using a customer-owned database in production:
|
|
|
231
234
|
- Dedupe outbox events by event `id`.
|
|
232
235
|
- Monitor last success, last error, retry count, event lag, and cursor.
|
|
233
236
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
data-processing terms.
|
|
237
|
+
Don't give Ablo your database URL for this integration — Ablo never connects to
|
|
238
|
+
your database directly. (Direct database access would be a separate product with
|
|
239
|
+
its own security model.)
|
|
238
240
|
|
|
239
241
|
## Security
|
|
240
242
|
|
|
@@ -4,21 +4,29 @@ A report-writing agent that yields when a human is editing the same report.
|
|
|
4
4
|
|
|
5
5
|
## Scenario
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
collide:
|
|
7
|
+
The same reports are edited by both humans and agents. They must not collide:
|
|
9
8
|
|
|
10
|
-
- If
|
|
11
|
-
-
|
|
12
|
-
- If the report changes mid-run, the commit
|
|
13
|
-
|
|
9
|
+
- If a human already holds the row, the agent yields instead of fighting for it.
|
|
10
|
+
- While the agent is updating, the UI can show who is active.
|
|
11
|
+
- If the report changes mid-run, the commit is rejected instead of overwriting
|
|
12
|
+
the human's newer edit.
|
|
13
|
+
|
|
14
|
+
A **claim** does both jobs. Claims don't lock — if another writer holds the row,
|
|
15
|
+
`claim` waits for them, re-reads the fresh row, then hands it to you, so two
|
|
16
|
+
writers serialize instead of clobbering. And once you hold a claim, any `update`
|
|
17
|
+
you make inside it is stale-checked for free: the SDK records the row version you
|
|
18
|
+
were handed and rejects the write with a typed error if the row moved underneath
|
|
19
|
+
you while the agent was busy.
|
|
14
20
|
|
|
15
21
|
## Schema-Backed Worker
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
row, and writes through
|
|
23
|
+
The worker uses the same schema client the app uses. It reads the report from
|
|
24
|
+
the server with `retrieve(id)`, claims the row, and writes through
|
|
25
|
+
`ablo.weatherReports.update(...)` with a stale-check so a human's concurrent edit
|
|
26
|
+
can't be overwritten.
|
|
19
27
|
|
|
20
28
|
```ts
|
|
21
|
-
import Ablo from '@abloatai/ablo';
|
|
29
|
+
import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
|
|
22
30
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
23
31
|
|
|
24
32
|
const schema = defineSchema({
|
|
@@ -33,21 +41,52 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
|
33
41
|
export async function markReady(reportId: string) {
|
|
34
42
|
await ablo.ready();
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
// retrieve(id) is an async server read — await it.
|
|
45
|
+
const report = await ablo.weatherReports.retrieve(reportId);
|
|
37
46
|
if (!report) return { status: 'not_found' };
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
try {
|
|
49
|
+
const updated = await ablo.weatherReports.claim(
|
|
50
|
+
reportId,
|
|
51
|
+
async (claimed) =>
|
|
52
|
+
// Inside an active claim, `update` is stale-checked automatically: the
|
|
53
|
+
// SDK attaches the claim's snapshot version as `readAt` and sets
|
|
54
|
+
// `onStale: 'reject'`. The write below is therefore equivalent to
|
|
55
|
+
// passing those options yourself:
|
|
56
|
+
//
|
|
57
|
+
// ablo.weatherReports.update(claimed.id, { status: 'ready' }, {
|
|
58
|
+
// wait: 'confirmed',
|
|
59
|
+
// readAt: <claim snapshot version>,
|
|
60
|
+
// onStale: 'reject',
|
|
61
|
+
// });
|
|
62
|
+
//
|
|
63
|
+
// If a human saved a newer version mid-run, the row no longer matches
|
|
64
|
+
// `readAt`, so the server rejects this commit with AbloStaleContextError
|
|
65
|
+
// (caught below) instead of clobbering their edit.
|
|
66
|
+
ablo.weatherReports.update(
|
|
67
|
+
claimed.id,
|
|
68
|
+
{ status: 'ready' },
|
|
69
|
+
{ wait: 'confirmed' },
|
|
70
|
+
),
|
|
71
|
+
{
|
|
72
|
+
// wait: false → don't queue behind a current holder. If a human already
|
|
73
|
+
// holds the row, claim rejects with AbloClaimedError (caught below), so
|
|
74
|
+
// the agent yields instead of waiting. Omit it, or pass wait: true, to
|
|
75
|
+
// queue behind them. action → the label observers see while we work.
|
|
76
|
+
wait: false,
|
|
77
|
+
action: 'marking_ready',
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return { status: 'ready', report: updated };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// A human already holds the row — yield this run and let them finish.
|
|
84
|
+
if (err instanceof AbloClaimedError) return { status: 'yielded' };
|
|
85
|
+
// A human saved a newer version while we held the claim. The stale-check
|
|
86
|
+
// rejected our commit, so nothing was overwritten — re-run on fresh data.
|
|
87
|
+
if (err instanceof AbloStaleContextError) return { status: 'stale' };
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
51
90
|
}
|
|
52
91
|
```
|
|
53
92
|
|
|
@@ -61,8 +100,8 @@ Keep workers on the same schema-backed client as the app.
|
|
|
61
100
|
import { useAblo } from '@abloatai/ablo/react';
|
|
62
101
|
|
|
63
102
|
export function ReportRow({ report: serverReport }: Props) {
|
|
64
|
-
const data = useAblo((ablo) => ablo.weatherReports.
|
|
65
|
-
const active = useAblo((ablo) => ablo.weatherReports.
|
|
103
|
+
const data = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
|
|
104
|
+
const active = useAblo((ablo) => ablo.weatherReports.claim.state(serverReport.id));
|
|
66
105
|
const agentActive = active?.participantKind === 'agent';
|
|
67
106
|
|
|
68
107
|
return (
|
|
@@ -76,7 +115,12 @@ export function ReportRow({ report: serverReport }: Props) {
|
|
|
76
115
|
|
|
77
116
|
## Why It Works
|
|
78
117
|
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
- `
|
|
82
|
-
|
|
118
|
+
- The claim is visible to everyone: the UI reads it synchronously with
|
|
119
|
+
`claim.state(id)`, and it also arrives over the live stream.
|
|
120
|
+
- `claim(id, work)` makes writers take turns instead of racing — with
|
|
121
|
+
`wait: false`, the agent simply yields when a human already holds the row.
|
|
122
|
+
- The `update` inside the claim is stale-checked automatically, so a human's
|
|
123
|
+
edit landing mid-run rejects the agent's write with a typed
|
|
124
|
+
`AbloStaleContextError` instead of overwriting it.
|
|
125
|
+
- That same write carries the claim, so each accepted change is attributed to
|
|
126
|
+
the run that made it.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# AI SDK Tool
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
When an AI agent updates a shared record from inside a tool call, you have a concurrency problem: another agent or a user might be editing the same row, and a naive write silently overwrites their change. This example shows the safe pattern — read the record, claim the row so anyone else waits their turn, write through a version-checked update, and release the claim automatically.
|
|
4
|
+
|
|
5
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
|
|
4
6
|
|
|
5
7
|
```ts
|
|
6
8
|
import Ablo from '@abloatai/ablo';
|
|
@@ -31,16 +33,18 @@ const updateReport = tool({
|
|
|
31
33
|
execute: async ({ reportId, status, forecast }) => {
|
|
32
34
|
await ablo.ready();
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
// retrieve hits the server for the latest row (async — await it).
|
|
37
|
+
const report = await ablo.weatherReports.retrieve(reportId);
|
|
35
38
|
if (!report) return { ok: false, reason: 'not_found' };
|
|
36
39
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// the callback returns or throws.
|
|
40
|
+
// If another agent or user already holds this row, claim waits for them
|
|
41
|
+
// to finish, re-reads the fresh row, then runs the callback. The claim
|
|
42
|
+
// is released when the callback returns or throws.
|
|
40
43
|
return ablo.weatherReports.claim(
|
|
41
44
|
reportId,
|
|
42
45
|
async (claimed) => {
|
|
43
|
-
// update is
|
|
46
|
+
// Because you hold the claim, this update is rejected if the row
|
|
47
|
+
// changed underneath you, instead of silently overwriting it.
|
|
44
48
|
const updated = await ablo.weatherReports.update(claimed.id, {
|
|
45
49
|
status: status ?? claimed.status,
|
|
46
50
|
forecast: forecast ?? claimed.forecast,
|
|
@@ -64,11 +68,10 @@ export async function POST(req: Request) {
|
|
|
64
68
|
}
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
The
|
|
68
|
-
tool:
|
|
71
|
+
The model provider is interchangeable. What matters is that the tool:
|
|
69
72
|
|
|
70
|
-
-
|
|
71
|
-
- claims the row
|
|
72
|
-
- writes through the
|
|
73
|
+
- reads the latest weather report with `retrieve` (a server read),
|
|
74
|
+
- claims the row — if someone else holds it, the claim waits for them, then re-reads,
|
|
75
|
+
- writes through `update`, which is rejected if the row changed underneath you,
|
|
73
76
|
- releases the claim automatically when the callback returns or throws,
|
|
74
77
|
- waits for server confirmation.
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# Existing Python Backend
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Put Ablo in front of the records several people (or AI agents) edit at once and
|
|
4
|
+
you get two things at no cost to your stack: every edit fans out live to
|
|
5
|
+
everyone watching, and humans and agents write through one shared contract. Your
|
|
6
|
+
Python service and database stay the source of truth — Ablo doesn't replace your
|
|
7
|
+
backend, it coordinates the writes into it. You stop calling your endpoint
|
|
8
|
+
directly; you call Ablo, Ablo calls your endpoint, and Ablo pushes the result
|
|
9
|
+
back out to every browser and agent on that record.
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
Use this path when a product already has a Python API server and every button
|
|
12
|
+
currently calls an application endpoint. It applies to any API-backed app, not
|
|
13
|
+
only Python — a YC company's existing dashboard can keep its current
|
|
14
|
+
endpoint/service/database shape and migrate one coordinated model at a time.
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
Here is the full path a button takes. After your Python service commits the
|
|
17
|
+
change, Ablo pushes it live to every other browser and agent watching that
|
|
18
|
+
record (the "realtime fanout" step at the bottom):
|
|
13
19
|
|
|
14
20
|
```txt
|
|
15
21
|
Browser UI
|
|
@@ -26,7 +32,7 @@ Browser UI
|
|
|
26
32
|
Create a schema for the records that need realtime coordination.
|
|
27
33
|
|
|
28
34
|
```ts
|
|
29
|
-
// web/ablo
|
|
35
|
+
// web/ablo/schema.ts
|
|
30
36
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
31
37
|
|
|
32
38
|
export const schema = defineSchema({
|
|
@@ -42,7 +48,7 @@ export const schema = defineSchema({
|
|
|
42
48
|
```ts
|
|
43
49
|
// web/ablo.ts
|
|
44
50
|
import Ablo from '@abloatai/ablo';
|
|
45
|
-
import { schema } from './ablo
|
|
51
|
+
import { schema } from './ablo/schema';
|
|
46
52
|
|
|
47
53
|
export const ablo = Ablo({
|
|
48
54
|
schema,
|
|
@@ -58,7 +64,7 @@ model clients without importing server credentials.
|
|
|
58
64
|
'use client';
|
|
59
65
|
|
|
60
66
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
61
|
-
import { schema } from '@/ablo
|
|
67
|
+
import { schema } from '@/ablo/schema';
|
|
62
68
|
|
|
63
69
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
64
70
|
return <AbloProvider schema={schema}>{children}</AbloProvider>;
|
|
@@ -80,8 +86,8 @@ export function ReportRow({
|
|
|
80
86
|
}: {
|
|
81
87
|
report: { id: string; location: string; status: string };
|
|
82
88
|
}) {
|
|
83
|
-
const report = useAblo((ablo) => ablo.weatherReports.
|
|
84
|
-
const active = useAblo((ablo) => ablo.weatherReports.
|
|
89
|
+
const report = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
|
|
90
|
+
const active = useAblo((ablo) => ablo.weatherReports.claim.state(serverReport.id));
|
|
85
91
|
const claimed = Boolean(active);
|
|
86
92
|
|
|
87
93
|
return (
|
|
@@ -92,8 +98,9 @@ export function ReportRow({
|
|
|
92
98
|
}
|
|
93
99
|
```
|
|
94
100
|
|
|
95
|
-
No string model key is needed in the first example.
|
|
96
|
-
`ablo.weatherReports`,
|
|
101
|
+
No string model key is needed in the first example. Because the selector reads
|
|
102
|
+
straight from `ablo.weatherReports`, your reads, your writes, and any agent all
|
|
103
|
+
go through one client — so a live edit shows up here without extra wiring.
|
|
97
104
|
|
|
98
105
|
## 3. Add One Python Data Source Endpoint
|
|
99
106
|
|
|
@@ -214,7 +221,10 @@ await ablo.weatherReports.update(
|
|
|
214
221
|
```
|
|
215
222
|
|
|
216
223
|
Use `readAt` and `onStale: 'reject'` for actions that depend on state the user
|
|
217
|
-
or agent already saw.
|
|
224
|
+
or agent already saw. If two people both click "mark ready" on a report one of
|
|
225
|
+
them already finished, `onStale: 'reject'` makes the second write fail instead
|
|
226
|
+
of silently clobbering — `readAt: snap.stamp` is the version the user actually
|
|
227
|
+
saw, and the write is rejected if the row changed underneath them.
|
|
218
228
|
|
|
219
229
|
## 5. Report Direct Database Writes
|
|
220
230
|
|
|
@@ -237,7 +247,7 @@ and timestamp. If the change originated from an Ablo commit, include the same
|
|
|
237
247
|
Agents use the same model API as the UI:
|
|
238
248
|
|
|
239
249
|
```ts
|
|
240
|
-
const
|
|
250
|
+
const report = await ablo.weatherReports.retrieve(reportId);
|
|
241
251
|
const snap = ablo.snapshot({ weatherReports: reportId });
|
|
242
252
|
|
|
243
253
|
await ablo.weatherReports.update(
|
|
@@ -247,5 +257,6 @@ await ablo.weatherReports.update(
|
|
|
247
257
|
);
|
|
248
258
|
```
|
|
249
259
|
|
|
250
|
-
|
|
251
|
-
|
|
260
|
+
Agents reach for the exact same calls the UI does — the same write contract
|
|
261
|
+
stated at the top of this page. The Python backend keeps owning the business
|
|
262
|
+
logic and the database; agents just become another safe writer in front of it.
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# Next.js Example
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Building collaborative state in a Next.js app means handling three things at
|
|
4
|
+
once: a fast initial render from the server, writes that don't overwrite a
|
|
5
|
+
teammate's change, and a UI that updates the moment data changes. This example
|
|
6
|
+
wires all three with Ablo Sync. The key piece is `claim()` — commit a write
|
|
7
|
+
through it and Ablo rejects the write if someone edited the same record since
|
|
8
|
+
you read it, so you never silently clobber another person's work.
|
|
9
|
+
|
|
10
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
11
|
+
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
12
|
+
of clobbering.
|
|
13
|
+
|
|
14
|
+
The app uses three layers, mapped to three files: a React Server Component reads
|
|
15
|
+
and renders, a Server Action claims and writes, and a client component shows
|
|
16
|
+
live updates.
|
|
5
17
|
|
|
6
18
|
## Structure
|
|
7
19
|
|
|
@@ -10,7 +22,7 @@ app/
|
|
|
10
22
|
reports/
|
|
11
23
|
[id]/
|
|
12
24
|
page.tsx # RSC: retrieve + render
|
|
13
|
-
actions.ts # Server Action:
|
|
25
|
+
actions.ts # Server Action: write that's rejected if someone else edited first
|
|
14
26
|
ReportEditor.tsx # Client: live updates
|
|
15
27
|
lib/
|
|
16
28
|
ablo.ts # Schema-backed Ablo client for server actions
|
|
@@ -26,7 +38,7 @@ export default async function ReportPage({
|
|
|
26
38
|
params,
|
|
27
39
|
}: { params: { id: string } }) {
|
|
28
40
|
await ablo.ready();
|
|
29
|
-
const
|
|
41
|
+
const report = await ablo.weatherReports.retrieve(params.id);
|
|
30
42
|
if (!report) return null;
|
|
31
43
|
|
|
32
44
|
return <ReportEditor report={report} />;
|
|
@@ -57,8 +69,9 @@ export async function markReady(id: string) {
|
|
|
57
69
|
}
|
|
58
70
|
```
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
The write runs inside the `claim` callback. If another participant commits
|
|
73
|
+
between the read and the write, the commit is rejected because the row changed
|
|
74
|
+
underneath you. The action can re-fetch and ask the user to retry.
|
|
62
75
|
|
|
63
76
|
## Live Client
|
|
64
77
|
|
|
@@ -68,8 +81,8 @@ rejects. The action can re-fetch and ask the user to retry.
|
|
|
68
81
|
import { useAblo } from '@abloatai/ablo/react';
|
|
69
82
|
|
|
70
83
|
export function ReportEditor({ report: serverReport }: Props) {
|
|
71
|
-
const data = useAblo((ablo) => ablo.weatherReports.
|
|
72
|
-
const active = useAblo((ablo) => ablo.weatherReports.
|
|
84
|
+
const data = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
|
|
85
|
+
const active = useAblo((ablo) => ablo.weatherReports.claim.state(serverReport.id));
|
|
73
86
|
const claimed = Boolean(active);
|
|
74
87
|
|
|
75
88
|
return (
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Agent Scoped to One Deck
|
|
2
|
+
|
|
3
|
+
You want an agent that edits **one deck** and pushes realtime updates to the
|
|
4
|
+
people on **that deck only** — not a broadcast to the whole org. The catch most
|
|
5
|
+
people hit: which write reaches whom is decided by how the rows *relate*, not by
|
|
6
|
+
which columns the write touched. So a slide edit that never sets `deckId` still
|
|
7
|
+
reaches everyone viewing the deck, because the slide already belongs to it. You
|
|
8
|
+
get this by declaring the relationship once, then narrowing the agent to the deck
|
|
9
|
+
id — you never assemble a `deck:<id>` audience string by hand.
|
|
10
|
+
|
|
11
|
+
The three steps below show how to declare it, scope the agent, and write.
|
|
12
|
+
|
|
13
|
+
See [Identity & Sync Groups](../identity.md) for the full reference.
|
|
14
|
+
|
|
15
|
+
## 1. Schema — declare the relationship, once
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema';
|
|
19
|
+
|
|
20
|
+
export const schema = defineSchema(
|
|
21
|
+
{
|
|
22
|
+
// A deck's rows form the group `deck:<id>` (the kind comes from `scope`).
|
|
23
|
+
decks: model(
|
|
24
|
+
{ title: z.string() },
|
|
25
|
+
{},
|
|
26
|
+
{ orgScoped: true, scope: 'deck' },
|
|
27
|
+
),
|
|
28
|
+
// A slide has no group of its own. It inherits its deck's group via the
|
|
29
|
+
// `parent` edge, so a slide write reaches everyone viewing the deck.
|
|
30
|
+
slides: model(
|
|
31
|
+
{ deckId: z.string(), body: z.string() },
|
|
32
|
+
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
33
|
+
{ orgScoped: true },
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// Humans get their full org scope automatically from these.
|
|
38
|
+
identityRoles: [
|
|
39
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
40
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 2. Dispatch — narrow the agent to the deck it's working on
|
|
47
|
+
|
|
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 with `scope`. You pass the
|
|
50
|
+
**model and id** — `{ decks: deckId }`, never a `deck:<id>` string; the engine
|
|
51
|
+
builds the group from the `decks` model's `scope`.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { AbloProvider } from '@abloatai/ablo/react';
|
|
55
|
+
|
|
56
|
+
// 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
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</AbloProvider>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`scope` requests, it never grants: at connect the server intersects the groups
|
|
67
|
+
you ask for with the groups the identity is actually allowed, so the agent can
|
|
68
|
+
never reach a deck its triggering user couldn't.
|
|
69
|
+
|
|
70
|
+
## 3. Write — it fans out to everyone on that deck
|
|
71
|
+
|
|
72
|
+
Inside any component under the provider, grab the scoped client with `useAblo()`
|
|
73
|
+
and write. The connection is already narrowed to `deck:<deckId>` from Step 2.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const ablo = useAblo<(typeof schema)['models']>();
|
|
77
|
+
|
|
78
|
+
// Other participants subscribed to deck:<deckId> — the human in the editor,
|
|
79
|
+
// a reviewer agent — receive this delta in realtime. Participants on other
|
|
80
|
+
// decks never see it.
|
|
81
|
+
await ablo.slides.update(slideId, { body: 'Q4 revenue up 12% YoY' });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The slide's delta is stamped `deck:<deckId>`, derived server-side from the
|
|
85
|
+
slide → deck `parent` edge — not from `deckId` appearing in this particular
|
|
86
|
+
write, and not from whatever the agent happened to subscribe to. The routing is
|
|
87
|
+
decided by the data: a slide belongs to its deck, so its writes go to the deck's
|
|
88
|
+
group, full stop.
|
|
89
|
+
|
|
90
|
+
## See also
|
|
91
|
+
|
|
92
|
+
- [Identity & Sync Groups](../identity.md) — the full scope / parent / grants model.
|
|
93
|
+
- [Agent + Human](./agent-human.md) — yielding when a human edits the same row.
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# Server Agent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
A server agent is backend code — a cron job, a queue worker, an AI task — that
|
|
4
|
+
reads and writes your app's records outside the browser. The hard part is doing
|
|
5
|
+
it without racing the live UI: if your worker and a user edit the same report at
|
|
6
|
+
once, one write clobbers the other. This is what `claim()` is for. Below, a
|
|
7
|
+
worker finishes a weather report by claiming it, writing the result, and
|
|
8
|
+
releasing it — all in one call.
|
|
9
|
+
|
|
10
|
+
`claim(id, work)` takes the record for your worker, runs your update inside the
|
|
11
|
+
callback, then releases it when the callback returns. Claims don't lock. If
|
|
12
|
+
another writer holds the row, `claim` waits for them, re-reads the fresh row,
|
|
13
|
+
then hands it to you — so two writers serialize instead of clobbering.
|
|
5
14
|
|
|
6
15
|
```ts
|
|
7
16
|
import Ablo from '@abloatai/ablo';
|
|
@@ -23,7 +32,7 @@ const ablo = Ablo({
|
|
|
23
32
|
export async function completeReport(reportId: string) {
|
|
24
33
|
await ablo.ready();
|
|
25
34
|
|
|
26
|
-
const
|
|
35
|
+
const report = await ablo.weatherReports.retrieve(reportId);
|
|
27
36
|
if (!report) return { status: 'not_found' };
|
|
28
37
|
|
|
29
38
|
const updated = await ablo.weatherReports.claim(
|
|
@@ -41,5 +50,18 @@ export async function completeReport(reportId: string) {
|
|
|
41
50
|
}
|
|
42
51
|
```
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
`retrieve(id)` is an async server read — it hits the server and returns the row
|
|
54
|
+
(or `null`, which the early `not_found` guard handles). The update runs inside
|
|
55
|
+
the claim callback, and `{ wait: 'confirmed' }` makes that update resolve only
|
|
56
|
+
once the server has accepted it.
|
|
57
|
+
|
|
58
|
+
The two options on the claim:
|
|
59
|
+
|
|
60
|
+
- `wait: false` — skip this record if another claim is already in progress,
|
|
61
|
+
rather than queueing behind it. (The default queues.)
|
|
62
|
+
- `action: 'completing'` — a human-readable label for what your worker is doing,
|
|
63
|
+
visible to anyone reading `claim.state(id)`.
|
|
64
|
+
|
|
65
|
+
Because the worker uses the same schema and `claim()` as the UI, its writes sync
|
|
66
|
+
to every connected client in real time and never collide with edits already in
|
|
67
|
+
progress.
|