@abloatai/ablo 0.7.0 → 0.9.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 +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- 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/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
package/docs/identity.md
CHANGED
|
@@ -30,65 +30,16 @@ A **sync group** is a named channel of shared state — a string like
|
|
|
30
30
|
There is no built-in `org` / `team` / `user` concept in the engine. Those are
|
|
31
31
|
*your* domain words. Ablo only knows sync-group strings. The mapping from "this
|
|
32
32
|
is user U in org O" to "they may subscribe to `org:acme` and `user:U`" is
|
|
33
|
-
something **you declare in your schema
|
|
34
|
-
|
|
35
|
-
### Two kinds of group — the whole mental model
|
|
36
|
-
|
|
37
|
-
Every sync group is named after one of two things, and that's the cleanest way
|
|
38
|
-
to hold the model in your head:
|
|
39
|
-
|
|
40
|
-
- **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
|
|
41
|
-
`user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
|
|
42
|
-
standing and durable — they don't change as you work.
|
|
43
|
-
- **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
|
|
44
|
-
`slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
|
|
45
|
-
They're granular — one per record — and any participant can be pointed at a
|
|
46
|
-
specific set of them.
|
|
47
|
-
|
|
48
|
-
Humans and agents fill that same space differently — and, crucially, the two
|
|
49
|
-
are **declared in different places**, because membership is static (a property of
|
|
50
|
-
identity) while entity scope is dynamic (a property of the task):
|
|
51
|
-
|
|
52
|
-
| | Subscribed by | Declared where | Gets |
|
|
53
|
-
| --- | --- | --- | --- |
|
|
54
|
-
| **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
|
|
55
|
-
| **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
|
|
56
|
-
|
|
57
|
-
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
58
|
-
> they've been given.
|
|
59
|
-
|
|
60
|
-
The asymmetry is the point. A user's org/team/user don't change per request, so
|
|
61
|
-
their scope is a **rule the schema derives automatically** — you never write
|
|
62
|
-
per-user scope code. An agent's reach depends on *what it's working on*, which is
|
|
63
|
-
only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
|
|
64
|
-
code**. The schema's only job for entities is to declare *that* a model is
|
|
65
|
-
entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
|
|
66
|
-
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
67
|
-
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
68
|
-
scope is fully schema-derived.)
|
|
69
|
-
|
|
70
|
-
So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
|
|
71
|
-
at a few entity groups, bounded above by its triggering user's membership. That
|
|
72
|
-
boundary is the whole safety story, and it's covered in
|
|
73
|
-
[Agents are participants too](#agents-are-participants-too).
|
|
74
|
-
|
|
75
|
-
```txt
|
|
76
|
-
your auth → identity { kind, userId|agentId, organizationId, teamIds }
|
|
77
|
-
→ identityRoles (schema) → allowed sync groups
|
|
78
|
-
→ participant receives deltas for rows in those groups
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
The identity is a **participant** — and a participant is either a human
|
|
82
|
-
(`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
|
|
83
|
-
[Agents are participants too](#agents-are-participants-too) below. Everything in
|
|
84
|
-
the next two sections applies to both.
|
|
33
|
+
something **you declare in your schema**. Here is that whole declaration in one
|
|
34
|
+
runnable place, so the concepts below have code to attach to.
|
|
85
35
|
|
|
86
36
|
## Declare it, end to end
|
|
87
37
|
|
|
88
38
|
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
89
39
|
each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
|
|
90
|
-
optional `syncGroups` prop (narrowing).
|
|
91
|
-
|
|
40
|
+
optional `syncGroups` prop (narrowing). Read the three blocks first — a human
|
|
41
|
+
gets their `org` / `team` scope, an agent gets one `deck` — then the sections
|
|
42
|
+
after explain each.
|
|
92
43
|
|
|
93
44
|
```ts
|
|
94
45
|
// 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
|
|
@@ -144,6 +95,60 @@ export const schema = defineSchema(
|
|
|
144
95
|
|
|
145
96
|
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
146
97
|
|
|
98
|
+
## Two kinds of group — the whole mental model
|
|
99
|
+
|
|
100
|
+
You just saw a human get `org` / `team` groups and an agent get one `deck`
|
|
101
|
+
group. That split is the model. Every sync group is named after one of two
|
|
102
|
+
things:
|
|
103
|
+
|
|
104
|
+
- **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
|
|
105
|
+
`user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
|
|
106
|
+
standing and durable — they don't change as you work.
|
|
107
|
+
- **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
|
|
108
|
+
`slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
|
|
109
|
+
They're granular — one per record — and any participant can be pointed at a
|
|
110
|
+
specific set of them.
|
|
111
|
+
|
|
112
|
+
Humans and agents fill that same space differently, and you declare the two in
|
|
113
|
+
different places. A human's groups come from who they are, so you declare them
|
|
114
|
+
once in the schema. An agent's groups come from what it's working on right now,
|
|
115
|
+
so you pass them in code when you start the run.
|
|
116
|
+
|
|
117
|
+
| | Subscribed by | Declared where | Gets |
|
|
118
|
+
| --- | --- | --- | --- |
|
|
119
|
+
| **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
|
|
120
|
+
| **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
|
|
121
|
+
|
|
122
|
+
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
123
|
+
> they've been given.
|
|
124
|
+
|
|
125
|
+
That's why you never write per-user scope code, but you always pass an agent's
|
|
126
|
+
scope at the call site. A user's org/team/user don't change per request, so
|
|
127
|
+
their scope is a **rule the schema derives automatically**. An agent's reach
|
|
128
|
+
depends on *what it's working on*, which is only knowable at dispatch — so you
|
|
129
|
+
pass its `syncGroups` **at the call site, in code**. The schema's only job for
|
|
130
|
+
entities is to declare *that* a model is
|
|
131
|
+
entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
|
|
132
|
+
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
133
|
+
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
134
|
+
scope is fully schema-derived.)
|
|
135
|
+
|
|
136
|
+
So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
|
|
137
|
+
at a few entity groups, bounded above by its triggering user's membership. That
|
|
138
|
+
boundary is the whole safety story, and it's covered in
|
|
139
|
+
[Agents are participants too](#agents-are-participants-too).
|
|
140
|
+
|
|
141
|
+
```txt
|
|
142
|
+
your auth → identity { kind, userId|agentId, organizationId, teamIds }
|
|
143
|
+
→ identityRoles (schema) → allowed sync groups
|
|
144
|
+
→ participant receives deltas for rows in those groups
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The identity is a **participant** — and a participant is either a human
|
|
148
|
+
(`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
|
|
149
|
+
[Agents are participants too](#agents-are-participants-too) below. Everything in
|
|
150
|
+
the next two sections applies to both.
|
|
151
|
+
|
|
147
152
|
## The two halves of scoping
|
|
148
153
|
|
|
149
154
|
Scoping is two declarations that meet in the middle. One describes the
|
|
@@ -355,9 +360,10 @@ agent authority = (triggering user's allowed set) ← ceiling, inherited (on-
|
|
|
355
360
|
∩ (the model instances it touches) ← floor, least privilege per run
|
|
356
361
|
```
|
|
357
362
|
|
|
358
|
-
|
|
359
|
-
[Half 2](#half-2--per-model-scope-row--group)
|
|
360
|
-
|
|
363
|
+
Concretely: each model an agent edits declares a `scope`
|
|
364
|
+
([Half 2](#half-2--per-model-scope-row--group)), so each row forms its own
|
|
365
|
+
group. The agent subscribes only to the groups for the rows it touches. Declare
|
|
366
|
+
an entity anchor on the models an agent operates on:
|
|
361
367
|
|
|
362
368
|
```ts
|
|
363
369
|
// each scope-root model an agent edits forms a per-entity group
|
|
@@ -393,8 +399,8 @@ user it ran on behalf of, so audit answers "who did this, and on whose behalf."
|
|
|
393
399
|
It never appears in an `identityRole`, because it changes *who's accountable*,
|
|
394
400
|
not *what's reachable*.
|
|
395
401
|
|
|
396
|
-
|
|
397
|
-
|
|
402
|
+
Three rules make agent access safe, and they fall out of the model above rather
|
|
403
|
+
than needing a separate agent permission system:
|
|
398
404
|
|
|
399
405
|
- **Inherit the user, and no more** — the OAuth
|
|
400
406
|
[on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
|
package/docs/index.md
CHANGED
|
@@ -1,37 +1,47 @@
|
|
|
1
1
|
# Ablo Docs
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
You have a database, and you want an AI agent to edit the same rows your
|
|
4
|
+
users are editing — without the two clobbering each other's work. Ablo gives
|
|
5
|
+
the agent a narrow, audited write path: you declare your models, then everyone
|
|
6
|
+
(React components, server actions, and agents) calls the same
|
|
7
|
+
`ablo.deck.update(...)`. Ablo streams confirmed changes to everyone live and
|
|
8
|
+
rejects any write based on stale data.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
The flow is four steps: declare your models, read the current rows, claim the
|
|
11
|
+
row you're about to change, then write — and the write is rejected if someone
|
|
12
|
+
changed the row first.
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
```ts
|
|
15
|
+
// The same call, whether a person, a server action, or an agent makes it.
|
|
16
|
+
await ablo.deck.update({ id: deckId, data: { title: "Q3 Strategy" } });
|
|
17
|
+
```
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
20
|
+
re-reads the fresh row, then hands it to you — so two writers serialize
|
|
21
|
+
instead of clobbering.
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
query-shaped sync).
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
Three things stay true no matter how you use Ablo:
|
|
20
26
|
|
|
21
27
|
- **One model API for every actor.** `ablo.<model>.update(...)` is the
|
|
22
28
|
call from React components, server actions, background workers, and
|
|
23
29
|
AI agents alike. No separate "agent SDK," no parallel mutation path.
|
|
24
30
|
Attribution comes from the credential, not the call site.
|
|
25
|
-
- **
|
|
31
|
+
- **You declare tenancy scopes once.** Tenancy / per-entity scope
|
|
26
32
|
prefixes (`org:`, `deck:`, or your own `region:` / `customer:`) are
|
|
27
|
-
declared once on the schema's `identityRoles
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
declared once on the schema's `identityRoles`, so application code
|
|
34
|
+
never builds an `org:123` string by hand — which keeps tenant
|
|
35
|
+
boundaries from leaking.
|
|
36
|
+
- **Stale writes are rejected.** If the row changed after you read it,
|
|
37
|
+
your write is turned away instead of silently overwriting the change
|
|
38
|
+
you didn't see.
|
|
30
39
|
|
|
31
40
|
## Start here
|
|
32
41
|
|
|
33
42
|
- [Quickstart](./quickstart.md) — Make your first schema-backed write.
|
|
34
|
-
- [
|
|
43
|
+
- [Schema Contract](./schema-contract.md) — One schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
|
|
44
|
+
- [CLI & Migrations](./cli.md) — `init` / `migrate` / `push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
|
|
35
45
|
- [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
|
|
36
46
|
- [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
|
|
37
47
|
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
|
|
@@ -60,10 +70,11 @@ query-shaped sync).
|
|
|
60
70
|
|
|
61
71
|
## Concepts
|
|
62
72
|
|
|
73
|
+
- [Schema Contract](./schema-contract.md) — What the schema drives across SDK, React, agents, Data Source, and migrations.
|
|
63
74
|
- [Model Methods](./api.md#model-methods) — Load and write typed state.
|
|
64
75
|
- [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
|
|
65
76
|
- [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
|
|
66
|
-
- [Coordination](./coordination.md) — `claim`, `
|
|
77
|
+
- [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue` for active work.
|
|
67
78
|
- [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
|
|
68
79
|
- [Receipt](./api.md#receipt) — Confirm what landed.
|
|
69
80
|
- [Usage](./api.md#usage) — Metering and audit dimensions.
|
|
@@ -1,39 +1,26 @@
|
|
|
1
1
|
# Integration Guide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
If humans and AI agents both edit the same records in your app, they overwrite
|
|
4
|
+
each other and there's no good place to coordinate. Ablo gives them one shared,
|
|
5
|
+
typed write path — the same `ablo.<model>.update(...)` call for a React
|
|
6
|
+
component, a server action, a background worker, or an agent — and reconciles the
|
|
7
|
+
edits. This guide adds it to a product that already has a backend and database,
|
|
8
|
+
one model at a time.
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Ablo is a sync engine designed from the ground up for **humans and AI
|
|
8
|
-
agents editing the same state at the same time**. That premise drives
|
|
9
|
-
every design choice in this guide; if you only need server-to-server
|
|
10
|
-
data syncing without agents in the loop, the trade-offs land elsewhere
|
|
11
|
-
(Replicache, ElectricSQL, PowerSync are good answers for human-only
|
|
12
|
-
real-time apps; Zero is a good answer for query-shaped sync).
|
|
13
|
-
|
|
14
|
-
The shape of the SDK reflects three commitments:
|
|
10
|
+
Three things hold no matter which actor is writing:
|
|
15
11
|
|
|
16
12
|
- **One model API for every actor.** `ablo.<model>.update(...)` is what
|
|
17
13
|
React components, server actions, background workers, and AI agents
|
|
18
14
|
all call. No separate "agent SDK," no parallel mutation path. The
|
|
19
15
|
attribution comes from the credential, not the call site.
|
|
20
|
-
- **
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
API keys protect server-to-server humans. Agents get per-run,
|
|
29
|
-
per-scope, leased credentials with per-request signature verification
|
|
30
|
-
and instant revocation. The 2025-2026 AI-agent auth consensus
|
|
31
|
-
(OAuth 2.1 / MCP, AWS STS, Vault leases, Auth0 Token Vault) converged
|
|
32
|
-
on this shape. Capabilities are Ablo's instance.
|
|
33
|
-
|
|
34
|
-
If you have already built a sync layer or an agent runtime, you know
|
|
35
|
-
what each of those costs. This guide assumes you want them solved once,
|
|
36
|
-
together, behind one client.
|
|
16
|
+
- **You never type `org:123` in client code.** The server derives what each
|
|
17
|
+
caller can see from their authenticated identity, using the `identityRoles`
|
|
18
|
+
you declare once in the schema. The client just names which model and id it
|
|
19
|
+
wants. The `org:` / `user:` / `team:` (or your own `region:` / `customer:`)
|
|
20
|
+
prefixes live in the schema, never in consumer code.
|
|
21
|
+
- **Agents don't use your account API key.** Each agent run gets a short-lived
|
|
22
|
+
credential scoped to just what that run can touch, verified per request and
|
|
23
|
+
revocable instantly. (See the Agents section below for the actual calls.)
|
|
37
24
|
|
|
38
25
|
## The integration in one diagram
|
|
39
26
|
|
|
@@ -49,7 +36,7 @@ Declare the models Ablo coordinates, then read and write through
|
|
|
49
36
|
that same model path.
|
|
50
37
|
|
|
51
38
|
```txt
|
|
52
|
-
schema -> ablo.<model>.
|
|
39
|
+
schema -> ablo.<model>.list(...) -> ablo.<model>.update(...)
|
|
53
40
|
```
|
|
54
41
|
|
|
55
42
|
Commits and receipts exist under the hood. Most apps do not create protocol
|
|
@@ -226,21 +213,28 @@ refreshes before expiry.
|
|
|
226
213
|
|
|
227
214
|
## 3. Read State
|
|
228
215
|
|
|
229
|
-
|
|
216
|
+
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)`, `getAll({ where })`,
|
|
219
|
+
and `getCount({ where })` read the already-synced local graph synchronously, so
|
|
220
|
+
they're the ones you call in render.
|
|
221
|
+
|
|
222
|
+
Use `retrieve` when the row may not be local yet — it fetches from the server
|
|
223
|
+
and waits.
|
|
230
224
|
|
|
231
225
|
```ts
|
|
232
226
|
await ablo.ready();
|
|
233
227
|
|
|
234
|
-
const
|
|
228
|
+
const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
|
|
235
229
|
if (!report) throw new Error('report not found');
|
|
236
230
|
```
|
|
237
231
|
|
|
238
|
-
Use `
|
|
239
|
-
|
|
232
|
+
Use `get`, `getAll`, and `getCount` for synchronous local-graph reads after
|
|
233
|
+
data has synced.
|
|
240
234
|
|
|
241
235
|
```ts
|
|
242
|
-
const report = ablo.weatherReports.
|
|
243
|
-
const activeReports = ablo.weatherReports.
|
|
236
|
+
const report = ablo.weatherReports.get('report_stockholm');
|
|
237
|
+
const activeReports = ablo.weatherReports.getAll({
|
|
244
238
|
where: { projectId: 'proj_123' },
|
|
245
239
|
filter: (report) => report.status !== 'ready',
|
|
246
240
|
orderBy: { updatedAt: 'desc' },
|
|
@@ -260,8 +254,8 @@ export function ReportRow({
|
|
|
260
254
|
}: {
|
|
261
255
|
report: { id: string; location: string; status: string };
|
|
262
256
|
}) {
|
|
263
|
-
const report = useAblo((ablo) => ablo.weatherReports.
|
|
264
|
-
const active = useAblo((ablo) => ablo.weatherReports.
|
|
257
|
+
const report = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
|
|
258
|
+
const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
|
|
265
259
|
|
|
266
260
|
return <button disabled={Boolean(active) || report.status === 'ready'}>{report.location}</button>;
|
|
267
261
|
}
|
|
@@ -278,7 +272,7 @@ const ablo = useAblo();
|
|
|
278
272
|
For simple writes:
|
|
279
273
|
|
|
280
274
|
```ts
|
|
281
|
-
await ablo.weatherReports.update('report_stockholm', { status: 'ready' },
|
|
275
|
+
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, wait: 'confirmed' });
|
|
282
276
|
```
|
|
283
277
|
|
|
284
278
|
For writes based on state the user or agent already read, snapshot first and
|
|
@@ -287,15 +281,13 @@ reject stale updates:
|
|
|
287
281
|
```ts
|
|
288
282
|
const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
|
|
289
283
|
|
|
290
|
-
await ablo.weatherReports.update(
|
|
291
|
-
'report_stockholm',
|
|
292
|
-
{ status: 'ready' },
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
);
|
|
284
|
+
await ablo.weatherReports.update({
|
|
285
|
+
id: 'report_stockholm',
|
|
286
|
+
data: { status: 'ready' },
|
|
287
|
+
readAt: snap.stamp,
|
|
288
|
+
onStale: 'reject',
|
|
289
|
+
wait: 'confirmed',
|
|
290
|
+
});
|
|
299
291
|
```
|
|
300
292
|
|
|
301
293
|
`wait: 'confirmed'` resolves after the server accepts the write. Rejections roll
|
|
@@ -342,11 +334,11 @@ The migration can be gradual:
|
|
|
342
334
|
|
|
343
335
|
1. Declare schema for one model, such as `reports`.
|
|
344
336
|
2. Keep existing server loads for first paint.
|
|
345
|
-
3. Add `useAblo((ablo) => ablo.weatherReports.
|
|
337
|
+
3. Add `useAblo((ablo) => ablo.weatherReports.get(id)) ?? serverReport` for live rows.
|
|
346
338
|
4. Add one Data Source endpoint that calls the existing service layer.
|
|
347
339
|
5. Move one mutation button from `fetch('/api/reports/...')` to `ablo.weatherReports.update(...)`.
|
|
348
340
|
6. Add an outbox/events path for writes that still happen outside Ablo.
|
|
349
|
-
7. Let agents use the same `ablo.weatherReports.
|
|
341
|
+
7. Let agents use the same `ablo.weatherReports.list(...)` and `ablo.weatherReports.update(...)`.
|
|
350
342
|
|
|
351
343
|
For the full Python shape, see
|
|
352
344
|
[Existing Python Backend](./examples/existing-python-backend.md).
|
|
@@ -407,21 +399,26 @@ The API key verifies Ablo's request. It is not a database credential.
|
|
|
407
399
|
Agents should use the same model methods as the app when they can import the
|
|
408
400
|
schema.
|
|
409
401
|
|
|
402
|
+
An agent often reads a row, calls an LLM, then writes back — a slow gap during
|
|
403
|
+
which a human might touch the same row. Wrap that work in a claim. Claims don't
|
|
404
|
+
lock. If another writer holds the row, `claim` waits for them, re-reads the
|
|
405
|
+
fresh row, then hands it to you — so two writers serialize instead of clobbering.
|
|
406
|
+
|
|
410
407
|
```ts
|
|
411
|
-
const
|
|
408
|
+
const report = await ablo.weatherReports.retrieve({ id: reportId });
|
|
412
409
|
if (!report) return;
|
|
413
410
|
|
|
414
|
-
await ablo.weatherReports.claim(
|
|
415
|
-
reportId,
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
},
|
|
423
|
-
|
|
424
|
-
);
|
|
411
|
+
await using claim = await ablo.weatherReports.claim({
|
|
412
|
+
id: reportId,
|
|
413
|
+
wait: false,
|
|
414
|
+
action: 'forecasting',
|
|
415
|
+
});
|
|
416
|
+
const claimed = claim.data;
|
|
417
|
+
await ablo.weatherReports.update({
|
|
418
|
+
id: claimed.id,
|
|
419
|
+
data: { status: 'ready', forecast: await getForecast(claimed) },
|
|
420
|
+
wait: 'confirmed',
|
|
421
|
+
});
|
|
425
422
|
```
|
|
426
423
|
|
|
427
424
|
Use AI SDK for the model loop. Put Ablo inside the tool that persists the final
|
|
@@ -436,11 +433,13 @@ const completeReport = tool({
|
|
|
436
433
|
}),
|
|
437
434
|
execute: async ({ reportId, forecast }) => {
|
|
438
435
|
const snap = ablo.snapshot({ weatherReports: reportId });
|
|
439
|
-
return ablo.weatherReports.update(
|
|
440
|
-
reportId,
|
|
441
|
-
{ status: 'ready', forecast },
|
|
442
|
-
|
|
443
|
-
|
|
436
|
+
return ablo.weatherReports.update({
|
|
437
|
+
id: reportId,
|
|
438
|
+
data: { status: 'ready', forecast },
|
|
439
|
+
readAt: snap.stamp,
|
|
440
|
+
onStale: 'reject',
|
|
441
|
+
wait: 'confirmed',
|
|
442
|
+
});
|
|
444
443
|
},
|
|
445
444
|
});
|
|
446
445
|
```
|
|
@@ -455,7 +454,7 @@ Keep agent writes on the same schema client surface as the app.
|
|
|
455
454
|
| `/testing` | Test harnesses and deterministic mocks. |
|
|
456
455
|
| `Data Source` | Keep your app database canonical. |
|
|
457
456
|
| `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
|
|
458
|
-
| `claim` / `
|
|
457
|
+
| `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. |
|
|
459
458
|
| `snapshot` + `readAt` | Reject writes based on stale state. |
|
|
460
459
|
| `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
|
|
461
460
|
|
|
@@ -465,16 +464,17 @@ them.
|
|
|
465
464
|
|
|
466
465
|
## Method Cheatsheet
|
|
467
466
|
|
|
468
|
-
| Method | Use it for
|
|
469
|
-
| ---------------------------- |
|
|
470
|
-
| `
|
|
471
|
-
| `
|
|
472
|
-
| `
|
|
473
|
-
| `
|
|
474
|
-
| `
|
|
475
|
-
| `
|
|
476
|
-
| `
|
|
477
|
-
| `
|
|
478
|
-
| `claim(id
|
|
467
|
+
| Method | Use it for |
|
|
468
|
+
| ---------------------------- | --------------------------------------------------------------------------- |
|
|
469
|
+
| `retrieve(id)` | Async read of one row from the server (await it). |
|
|
470
|
+
| `list({ where })` | Async read of many rows from the server (await it). |
|
|
471
|
+
| `get(id)` | Synchronous local read of one synced row (use in render). |
|
|
472
|
+
| `getAll({ where })` | Synchronous local read of many synced rows. |
|
|
473
|
+
| `getCount({ where })` | Synchronous local count of synced rows. |
|
|
474
|
+
| `create(data, options?)` | Create through the model client. |
|
|
475
|
+
| `update(id, data, options?)` | Update through the model client. |
|
|
476
|
+
| `delete(id, options?)` | Delete through the model client. |
|
|
477
|
+
| `claim.state({ id })` | See who is currently working on a row (synchronous). |
|
|
478
|
+
| `claim(id, work, options?)` | Wait for your turn, re-read, and hold the row while `work` runs. |
|
|
479
479
|
|
|
480
480
|
Keep first integrations on the model methods above.
|
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
# Interaction Model
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
When a person, a server action, and an AI agent can all write to the same row,
|
|
4
|
+
you need one write path that stops them from clobbering each other. Ablo gives
|
|
5
|
+
you exactly one: load the row, claim it while you work, update it, and wait for
|
|
6
|
+
confirmation. This page walks through that path and the few primitives behind it.
|
|
5
7
|
|
|
8
|
+
Here's the whole path in one block — claim a row, update it inside the claim, and
|
|
9
|
+
let the claim release when your callback returns:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
|
|
13
|
+
|
|
14
|
+
await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
|
|
15
|
+
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' }, wait: 'confirmed' });
|
|
6
16
|
```
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
|
|
18
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
19
|
+
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
20
|
+
of clobbering.
|
|
9
21
|
|
|
10
22
|
## Primitives
|
|
11
23
|
|
|
12
24
|
| Primitive | Plane | Purpose |
|
|
13
25
|
|---|---|---|
|
|
14
26
|
| `Schema` | State | Declares typed models the app and agents can read and write. |
|
|
15
|
-
| `Model` | State | The generated `ablo.<model>` model. Use `
|
|
16
|
-
| `Claim` | Coordination | Who is working on a target.
|
|
27
|
+
| `Model` | State | The generated `ablo.<model>` model. Use `retrieve`/`list` (async server reads), `get`/`getAll`/`getCount` (synchronous local reads), `create`, `update`, and `delete`. |
|
|
28
|
+
| `Claim` | Coordination | Who is working on a target. Taken via `ablo.<model>.claim({ id })` and read via `ablo.<model>.claim.state({ id })`. Ephemeral — never persisted. |
|
|
17
29
|
| `Commit` | Protocol | The durable write underneath model updates. Most users do not call it directly. |
|
|
18
30
|
| `Receipt` | Protocol | The lower-level durable result for custom runtimes. Schema writes use `wait: 'confirmed'`. |
|
|
19
31
|
|
|
20
32
|
### Why each primitive is separate
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
you over that minimum:
|
|
34
|
+
Why are `Claim`, `Commit`, and `Receipt` separate things instead of one? Each
|
|
35
|
+
does a job the others can't. If you're coming from Replicache or Yjs you'd
|
|
36
|
+
expect just `Commit`; here's what the other two buy you over that minimum:
|
|
26
37
|
|
|
27
38
|
- **`Claim` is not a read lock.** Reads stay open. Claims serialize
|
|
28
39
|
acting-on-the-row, so slow work can wait in FIFO order, re-read, and write
|
|
@@ -33,45 +44,42 @@ you over that minimum:
|
|
|
33
44
|
client. A status code can't be re-read by a sub-agent that wasn't on
|
|
34
45
|
the original call.
|
|
35
46
|
|
|
36
|
-
The shape is borrowed from systems that learned the cost of collapse:
|
|
37
|
-
coordination from operational-transform CRDTs and Linear's optimistic
|
|
38
|
-
multiplayer model, and receipts from durable write protocols.
|
|
39
|
-
|
|
40
47
|
## Run Loop
|
|
41
48
|
|
|
42
49
|
A normal schema-backed run is:
|
|
43
50
|
|
|
44
|
-
```
|
|
45
|
-
const
|
|
46
|
-
const active = ablo.weatherReports.
|
|
47
|
-
await ablo.weatherReports.claim(id
|
|
48
|
-
|
|
49
|
-
});
|
|
51
|
+
```ts
|
|
52
|
+
const report = await ablo.weatherReports.retrieve({ id });
|
|
53
|
+
const active = ablo.weatherReports.claim.state({ id });
|
|
54
|
+
await using claim = await ablo.weatherReports.claim({ id });
|
|
55
|
+
await ablo.weatherReports.update({ id: claim.data.id, data: patch, wait: 'confirmed' });
|
|
50
56
|
```
|
|
51
57
|
|
|
58
|
+
`retrieve({ id })` is an async server read (await it). `claim.state({ id })` is a
|
|
59
|
+
synchronous local read of who currently holds the row — it never blocks.
|
|
60
|
+
|
|
52
61
|
## Coordination
|
|
53
62
|
|
|
54
63
|
> Loop view only. Full claim reference — methods, the claim-state object, the
|
|
55
|
-
> `queue`, errors — is [Coordination](./coordination.md).
|
|
64
|
+
> `claim.queue`, errors — is [Coordination](./coordination.md).
|
|
56
65
|
|
|
57
|
-
Claims broadcast across the org.
|
|
58
|
-
|
|
66
|
+
Claims broadcast across the org. Call `claim({ id })`, do your writes with the
|
|
67
|
+
normal `update` inside the `await using` scope, and the claim releases
|
|
68
|
+
automatically when the scope exits:
|
|
59
69
|
|
|
60
70
|
```ts
|
|
61
|
-
await ablo.weatherReports.claim(
|
|
62
|
-
'report_stockholm',
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{ action: 'editing' },
|
|
67
|
-
);
|
|
71
|
+
await using claim = await ablo.weatherReports.claim({
|
|
72
|
+
id: 'report_stockholm',
|
|
73
|
+
action: 'editing',
|
|
74
|
+
});
|
|
75
|
+
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // rejected if the row changed under the claim
|
|
68
76
|
```
|
|
69
77
|
|
|
70
|
-
`ablo.weatherReports.
|
|
71
|
-
blocking.
|
|
72
|
-
`claim` waits for them to finish
|
|
73
|
-
same signal is visible to every schema client through `
|
|
74
|
-
claim stream.
|
|
78
|
+
`ablo.weatherReports.claim.state({ id: 'report_stockholm' })` reads the live claim (or
|
|
79
|
+
`null`) without blocking. Claims don't lock: if another participant holds the
|
|
80
|
+
row, `claim` waits for them to finish, re-reads, and then hands you the fresh
|
|
81
|
+
row. The same signal is visible to every schema client through `claim.state({ id })`
|
|
82
|
+
and the live claim stream.
|
|
75
83
|
|
|
76
84
|
## Conflict resolution
|
|
77
85
|
|