@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/guarantees.md
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# Guarantees
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
When an Ablo write succeeds, the server has accepted it — and when two people or
|
|
4
|
+
agents touch the same row, Ablo coordinates them instead of letting one silently
|
|
5
|
+
overwrite the other. This page is the precise list of what you can count on:
|
|
6
|
+
confirmed writes, stale-write protection, claims, and the audit trail behind
|
|
7
|
+
every change.
|
|
8
|
+
|
|
9
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
10
|
+
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
11
|
+
of clobbering.
|
|
5
12
|
|
|
6
13
|
## Confirmed Writes
|
|
7
14
|
|
|
@@ -17,8 +24,9 @@ const updated = await ablo.weatherReports.update(
|
|
|
17
24
|
```
|
|
18
25
|
|
|
19
26
|
If the call resolves, the write was accepted by the server. If it rejects, the
|
|
20
|
-
error
|
|
21
|
-
|
|
27
|
+
typed error tells you exactly why — the most common reasons being failed
|
|
28
|
+
authorization, a schema validation error, or a stale-state or claim conflict
|
|
29
|
+
(each covered below).
|
|
22
30
|
|
|
23
31
|
Schema model writes return the updated model row.
|
|
24
32
|
|
|
@@ -58,24 +66,28 @@ Advanced policies exist for controlled product flows:
|
|
|
58
66
|
- `reject` fails the write when state moved.
|
|
59
67
|
- `force` applies the write without stale protection.
|
|
60
68
|
- `flag` accepts the write and marks it for product review.
|
|
61
|
-
|
|
69
|
+
|
|
70
|
+
`merge` is not yet available.
|
|
62
71
|
|
|
63
72
|
## Claim Coordination
|
|
64
73
|
|
|
74
|
+
> The guarantee, not the how-to. Methods, the claim-state object, and the `claim.queue`
|
|
75
|
+
> live in [Coordination](./coordination.md).
|
|
76
|
+
|
|
65
77
|
Claims are live coordination signals. They are not database locks.
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
`ablo.<model>.claim(id, ...)` serializes on contention: if another human or agent
|
|
80
|
+
already holds the row, the claim waits for them to finish, then re-reads the row
|
|
81
|
+
before handing it back, so you proceed from fresh state. Reads stay open while a
|
|
82
|
+
claim is held — `ablo.<model>.claim.state(id)` returns the current claim state
|
|
83
|
+
(or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
|
|
84
|
+
wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
|
|
85
|
+
not return a row while someone else is mid-edit.
|
|
74
86
|
|
|
75
87
|
A claim does not reject or block other writers; it announces work so peers
|
|
76
88
|
serialize behind it rather than racing. While you hold a claim, the matching
|
|
77
|
-
`ablo.<model>.update(id, ...)` is
|
|
78
|
-
|
|
89
|
+
`ablo.<model>.update(id, ...)` is rejected with `AbloStaleContextError` if the row
|
|
90
|
+
changed underneath you after your claim point.
|
|
79
91
|
|
|
80
92
|
## Agent Runs
|
|
81
93
|
|
|
@@ -95,10 +107,10 @@ authorized it, which run did it, and what state was it based on?"
|
|
|
95
107
|
|
|
96
108
|
## Persistence
|
|
97
109
|
|
|
98
|
-
Ablo defaults to volatile
|
|
99
|
-
|
|
110
|
+
Ablo defaults to volatile in-memory persistence, so nothing is written to disk
|
|
111
|
+
unless you ask for it.
|
|
100
112
|
|
|
101
|
-
Opt into durable browser cache
|
|
113
|
+
Opt into a durable browser cache that survives reloads when you need it:
|
|
102
114
|
|
|
103
115
|
```ts
|
|
104
116
|
const ablo = Ablo({
|
package/docs/identity.md
CHANGED
|
@@ -30,12 +30,76 @@ 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
|
|
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.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
## Declare it, end to end
|
|
37
|
+
|
|
38
|
+
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
39
|
+
each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
|
|
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.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
|
|
46
|
+
import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
export const schema = defineSchema(
|
|
49
|
+
{
|
|
50
|
+
// A scope root: its rows form the group `deck:<id>` (kind from `scope`).
|
|
51
|
+
decks: model(
|
|
52
|
+
{ title: z.string(), status: z.enum(['draft', 'published']) },
|
|
53
|
+
{},
|
|
54
|
+
{ orgScoped: true, scope: 'deck' },
|
|
55
|
+
),
|
|
56
|
+
// A child: it has no group of its own; it inherits its deck's group via the
|
|
57
|
+
// `parent` edge. A write to a slide reaches everyone viewing the deck.
|
|
58
|
+
slides: model(
|
|
59
|
+
{ deckId: z.string() },
|
|
60
|
+
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
61
|
+
{ orgScoped: true },
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
// Each role is pure data: a `kind` (the group prefix) and the identity
|
|
66
|
+
// `source` field to read. No closures — so the schema stays JSON-serializable.
|
|
67
|
+
identityRoles: [
|
|
68
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
69
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
70
|
+
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// 2. app/providers.tsx — a HUMAN gets their full org / team scope
|
|
78
|
+
<AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
|
|
79
|
+
{children}
|
|
80
|
+
</AbloProvider>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// 3. an AGENT run inherits its user, narrowed to the entities in play.
|
|
85
|
+
// 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
|
+
<AbloProvider
|
|
88
|
+
schema={schema}
|
|
89
|
+
userId={user.id} // ceiling: the triggering user
|
|
90
|
+
scope={{ decks: deckId }} // floor: just the deck it's working on
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</AbloProvider>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
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:
|
|
39
103
|
|
|
40
104
|
- **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
|
|
41
105
|
`user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
|
|
@@ -45,9 +109,10 @@ to hold the model in your head:
|
|
|
45
109
|
They're granular — one per record — and any participant can be pointed at a
|
|
46
110
|
specific set of them.
|
|
47
111
|
|
|
48
|
-
Humans and agents fill that same space differently
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
51
116
|
|
|
52
117
|
| | Subscribed by | Declared where | Gets |
|
|
53
118
|
| --- | --- | --- | --- |
|
|
@@ -57,12 +122,13 @@ identity) while entity scope is dynamic (a property of the task):
|
|
|
57
122
|
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
58
123
|
> they've been given.
|
|
59
124
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
code**. The schema's only job for
|
|
65
|
-
|
|
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}`);
|
|
66
132
|
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
67
133
|
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
68
134
|
scope is fully schema-derived.)
|
|
@@ -83,62 +149,6 @@ The identity is a **participant** — and a participant is either a human
|
|
|
83
149
|
[Agents are participants too](#agents-are-participants-too) below. Everything in
|
|
84
150
|
the next two sections applies to both.
|
|
85
151
|
|
|
86
|
-
## Declare it, end to end
|
|
87
|
-
|
|
88
|
-
The entire declaration surface is three things: `identityRoles` (who may see
|
|
89
|
-
what), `syncGroupFormat` (which group a row fans out on), and an optional
|
|
90
|
-
`syncGroups` prop (narrowing). Here they are in one runnable place — the sections
|
|
91
|
-
after this explain each.
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
// 1. src/ablo.schema.ts — map identity → groups, and anchor each model to a group
|
|
95
|
-
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
96
|
-
|
|
97
|
-
export const schema = defineSchema(
|
|
98
|
-
{
|
|
99
|
-
conversations: model(
|
|
100
|
-
{ title: z.string(), createdBy: z.string() },
|
|
101
|
-
{},
|
|
102
|
-
{ orgScoped: true, syncGroupFormat: 'conversation:{id}' },
|
|
103
|
-
),
|
|
104
|
-
decks: model(
|
|
105
|
-
{ title: z.string(), status: z.enum(['draft', 'published']) },
|
|
106
|
-
{},
|
|
107
|
-
{ orgScoped: true, syncGroupFormat: 'deck:{id}' },
|
|
108
|
-
),
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
// Each role is pure data: a `template` and the identity `source` field to
|
|
112
|
-
// read. No closures — so the schema stays JSON-serializable end to end.
|
|
113
|
-
identityRoles: [
|
|
114
|
-
identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
|
|
115
|
-
identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
|
|
116
|
-
identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
|
|
117
|
-
],
|
|
118
|
-
},
|
|
119
|
-
);
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
```tsx
|
|
123
|
-
// 2. app/providers.tsx — a HUMAN gets their full org / team scope
|
|
124
|
-
<AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
|
|
125
|
-
{children}
|
|
126
|
-
</AbloProvider>
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
```tsx
|
|
130
|
-
// 3. an AGENT run inherits its user, narrowed to the entities in play
|
|
131
|
-
<AbloProvider
|
|
132
|
-
schema={schema}
|
|
133
|
-
userId={user.id} // ceiling: the triggering user
|
|
134
|
-
syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]} // floor: just its work
|
|
135
|
-
>
|
|
136
|
-
{children}
|
|
137
|
-
</AbloProvider>
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
141
|
-
|
|
142
152
|
## The two halves of scoping
|
|
143
153
|
|
|
144
154
|
Scoping is two declarations that meet in the middle. One describes the
|
|
@@ -148,15 +158,15 @@ row's sync group is in the participant's allowed set.
|
|
|
148
158
|
|
|
149
159
|
### Half 1 — `identityRoles`: identity → allowed groups
|
|
150
160
|
|
|
151
|
-
Declared once, on the schema, via the `identityRole({ kind,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
Declared once, on the schema, via the `identityRole({ kind, source })` factory.
|
|
162
|
+
Each role is **pure data**: a `kind` (the group's prefix — `org`, `user`, `team`)
|
|
163
|
+
and the `source` — the identity field to read. The engine reads `source` off the
|
|
164
|
+
identity *you* supply and mints `<kind>:<value>` for each value, building the
|
|
165
|
+
participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the
|
|
166
|
+
engine — the kinds and sources are entirely yours.
|
|
157
167
|
|
|
158
168
|
```ts
|
|
159
|
-
// src/ablo
|
|
169
|
+
// src/ablo/schema.ts
|
|
160
170
|
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
161
171
|
|
|
162
172
|
export const schema = defineSchema(
|
|
@@ -168,10 +178,10 @@ export const schema = defineSchema(
|
|
|
168
178
|
},
|
|
169
179
|
{
|
|
170
180
|
identityRoles: [
|
|
171
|
-
identityRole({ kind: '
|
|
172
|
-
identityRole({ kind: '
|
|
173
|
-
// `multi: true` reads an array field — one team group per id.
|
|
174
|
-
identityRole({ kind: '
|
|
181
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
182
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
183
|
+
// `multi: true` reads an array field — one `team:<id>` group per id.
|
|
184
|
+
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
|
|
175
185
|
],
|
|
176
186
|
},
|
|
177
187
|
);
|
|
@@ -189,27 +199,66 @@ in-process and on a hosted server that only ever sees the compiled JSON.
|
|
|
189
199
|
|
|
190
200
|
### Half 2 — per-model scope: row → group
|
|
191
201
|
|
|
192
|
-
|
|
193
|
-
|
|
202
|
+
You never write a sync-group string for a row. You declare a model's *place* in
|
|
203
|
+
the entity graph and the engine derives the groups its rows fan out on. Three
|
|
204
|
+
declarations, in order of how often you reach for them:
|
|
205
|
+
|
|
206
|
+
**`scope` — this model is a scope root.** Its rows form a group of their own.
|
|
207
|
+
The kind comes from the model's `typename` by default, or pass a string to set
|
|
208
|
+
it explicitly (use the string form when the wire kind differs from the typename,
|
|
209
|
+
e.g. typename `SlideDeck` but group `deck:<id>`):
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
|
|
213
|
+
// a deck row → group `deck:<id>`
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**`parent` — this row lives inside another entity.** Mark the `belongsTo` edge
|
|
217
|
+
to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC
|
|
218
|
+
*parent* relation — "access inherits from parent" — and it chains transitively
|
|
219
|
+
(a layer → its slide → its deck), so a write to any descendant reaches everyone
|
|
220
|
+
viewing the root. A *reference* (a provenance/template pointer, not ownership)
|
|
221
|
+
must **not** be marked `parent`, or the row would leak into an unrelated scope:
|
|
194
222
|
|
|
195
223
|
```ts
|
|
196
|
-
model(
|
|
197
|
-
{
|
|
198
|
-
{ /* relations */ },
|
|
224
|
+
slides: model(
|
|
225
|
+
{ deckId: z.string(), sourceSlideId: z.string().optional() },
|
|
199
226
|
{
|
|
200
|
-
|
|
201
|
-
|
|
227
|
+
deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
|
|
228
|
+
sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
|
|
229
|
+
},
|
|
230
|
+
{ orgScoped: true },
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
> **Declare the parent edge — don't infer it.** Optionality is not a proxy for
|
|
235
|
+
> ownership: many `parent` FKs are optional (a root folder, an inbox task), and
|
|
236
|
+
> some required FKs are mere references. Containment is a fact only you know, so
|
|
237
|
+
> it's declared, exactly as it is in OpenFGA/Zanzibar.
|
|
202
238
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
**`grants` — a membership edge.** On a join model (e.g. `dataroomMember`), it
|
|
240
|
+
says "this row grants a *subject* access to a *scope root*." Both are relation
|
|
241
|
+
names on the model. The server resolves it at connect time — for user `U`, it
|
|
242
|
+
finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
|
|
243
|
+
set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
|
|
244
|
+
org membership is already covered by the `org:` identity role.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
dataroomMember: model(
|
|
248
|
+
{ userId: z.string(), dataroomId: z.string() },
|
|
249
|
+
{
|
|
250
|
+
member: relation.belongsTo('users', 'userId'),
|
|
251
|
+
room: relation.belongsTo('datarooms', 'dataroomId'),
|
|
206
252
|
},
|
|
253
|
+
{ orgScoped: true, grants: { subject: 'member', scope: 'room' } },
|
|
207
254
|
);
|
|
208
255
|
```
|
|
209
256
|
|
|
210
|
-
For
|
|
211
|
-
|
|
212
|
-
|
|
257
|
+
For the rare group keyed on a plain field rather than a relation (per-recipient
|
|
258
|
+
inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
|
|
259
|
+
escape hatch. For rows that inherit *tenancy* (not a sync group) through a
|
|
260
|
+
foreign key without carrying `organization_id`, use `scopedVia` rather than
|
|
261
|
+
`orgScoped: false` — the latter exposes the whole table cross-tenant. See
|
|
213
262
|
`packages/sync-engine/src/schema/model.ts` for the full option set.
|
|
214
263
|
|
|
215
264
|
## How identity reaches Ablo — the proxy model
|
|
@@ -255,7 +304,7 @@ resolve the user in a Server Component and pass it down:
|
|
|
255
304
|
```tsx
|
|
256
305
|
// app/providers.tsx — 'use client'
|
|
257
306
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
258
|
-
import { schema } from '@/ablo
|
|
307
|
+
import { schema } from '@/ablo/schema';
|
|
259
308
|
|
|
260
309
|
export function Providers({
|
|
261
310
|
children,
|
|
@@ -311,27 +360,29 @@ agent authority = (triggering user's allowed set) ← ceiling, inherited (on-
|
|
|
311
360
|
∩ (the model instances it touches) ← floor, least privilege per run
|
|
312
361
|
```
|
|
313
362
|
|
|
314
|
-
|
|
315
|
-
[Half 2](#half-2--per-model-scope-row--group)
|
|
316
|
-
|
|
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:
|
|
317
367
|
|
|
318
368
|
```ts
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
decks:
|
|
369
|
+
// each scope-root model an agent edits forms a per-entity group
|
|
370
|
+
documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
|
|
371
|
+
decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
|
|
322
372
|
```
|
|
323
373
|
|
|
324
374
|
Then a run subscribes only to the entity groups for the rows it works on — a
|
|
325
375
|
subset of what its user could see:
|
|
326
376
|
|
|
327
377
|
```tsx
|
|
328
|
-
// agent run triggered by `user`, working on one
|
|
378
|
+
// agent run triggered by `user`, working on one document + one deck
|
|
329
379
|
<AbloProvider
|
|
330
380
|
schema={schema}
|
|
331
381
|
// identity inherited from the triggering user (the ceiling)
|
|
332
382
|
userId={user.id}
|
|
333
|
-
// authority narrowed to just the entities in play (the floor)
|
|
334
|
-
|
|
383
|
+
// authority narrowed to just the entities in play (the floor).
|
|
384
|
+
// Model form — keyed by model, resolved to groups via each model's `scope`.
|
|
385
|
+
scope={{ documents: documentId, decks: deckId }}
|
|
335
386
|
>
|
|
336
387
|
```
|
|
337
388
|
|
|
@@ -348,8 +399,8 @@ user it ran on behalf of, so audit answers "who did this, and on whose behalf."
|
|
|
348
399
|
It never appears in an `identityRole`, because it changes *who's accountable*,
|
|
349
400
|
not *what's reachable*.
|
|
350
401
|
|
|
351
|
-
|
|
352
|
-
|
|
402
|
+
Three rules make agent access safe, and they fall out of the model above rather
|
|
403
|
+
than needing a separate agent permission system:
|
|
353
404
|
|
|
354
405
|
- **Inherit the user, and no more** — the OAuth
|
|
355
406
|
[on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
|
|
@@ -368,24 +419,50 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
|
|
|
368
419
|
`claim` loop is, to the scoping layer, that same participant — scoped to the row
|
|
369
420
|
it claimed.
|
|
370
421
|
|
|
371
|
-
## Narrowing to
|
|
422
|
+
## Narrowing to specific entities — the `scope` prop
|
|
372
423
|
|
|
373
|
-
|
|
374
|
-
|
|
424
|
+
A human gets their full membership automatically (`identityRoles`). To narrow a
|
|
425
|
+
session — a page on one deck, or an agent pointed at the entities it's working
|
|
426
|
+
on — pass `scope`. You give it the **model and id(s)**; the engine builds the
|
|
427
|
+
group string from the model's `scope` (Half 2), so you never hand-write
|
|
428
|
+
`deck:<id>`.
|
|
375
429
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
430
|
+
`scope` accepts four shapes, all resolved through the schema:
|
|
431
|
+
|
|
432
|
+
| You pass | Resolves to | Use it for |
|
|
433
|
+
| --- | --- | --- |
|
|
434
|
+
| `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
|
|
435
|
+
| `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
|
|
436
|
+
| `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
|
|
437
|
+
| `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
|
|
380
438
|
|
|
381
439
|
```tsx
|
|
382
|
-
// page
|
|
383
|
-
<AbloProvider schema={schema} userId={user.id}
|
|
440
|
+
// a page on one deck
|
|
441
|
+
<AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
|
|
442
|
+
|
|
443
|
+
// an agent working across two decks and a document
|
|
444
|
+
<AbloProvider
|
|
445
|
+
schema={schema}
|
|
446
|
+
userId={user.id}
|
|
447
|
+
scope={{ decks: [deckA, deckB], documents: docId }}
|
|
448
|
+
/>
|
|
384
449
|
```
|
|
385
450
|
|
|
386
|
-
The
|
|
387
|
-
|
|
388
|
-
|
|
451
|
+
The key is the **model** (`decks`), the value is **which id(s)** — the `deck:`
|
|
452
|
+
prefix comes from that model's `scope: 'deck'`, never from a string you compose.
|
|
453
|
+
|
|
454
|
+
> **`scope` means one thing: sync-group scope.** It appears in two places that
|
|
455
|
+
> 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` prop (subscribe
|
|
457
|
+
> to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
|
|
458
|
+
> axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
|
|
459
|
+
> open/closed/all), precisely so it doesn't share the word.
|
|
460
|
+
|
|
461
|
+
> **`scope` requests, it never grants.** At connect, the server intersects your
|
|
462
|
+
> requested groups with what the identity is actually allowed (`requested ∩
|
|
463
|
+
> allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
|
|
464
|
+
> agent can't reach a deck its capability doesn't already permit, no matter what
|
|
465
|
+
> it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
|
|
389
466
|
|
|
390
467
|
## How this compares — and the best practices it follows
|
|
391
468
|
|
|
@@ -426,13 +503,13 @@ The best practices Ablo inherits from that lineage:
|
|
|
426
503
|
the line precisely: [token parameters are trusted and usable for access
|
|
427
504
|
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
428
505
|
In Ablo terms, the identity your server vouches for is the *trusted* claim that
|
|
429
|
-
sets scope; the provider's `userId` / `
|
|
506
|
+
sets scope; the provider's `userId` / `scope` props are *untrusted client
|
|
430
507
|
input* — convenient for app-owned fields and narrowing, but never the boundary.
|
|
431
508
|
This is why changing `userId` in the browser grants nothing.
|
|
432
509
|
|
|
433
|
-
3. **Scope by a hierarchical naming convention, declared once.** Ablo's
|
|
434
|
-
|
|
435
|
-
idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
|
|
510
|
+
3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
|
|
511
|
+
group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
|
|
512
|
+
`scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
|
|
436
513
|
(`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
|
|
437
514
|
Declaring the convention in one place — never composing scope strings in
|
|
438
515
|
consumer code — is the practice all three enforce.
|
package/docs/index.md
CHANGED
|
@@ -1,36 +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(deckId, { 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.
|
|
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.
|
|
34
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.
|
|
35
46
|
- [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
|
|
36
47
|
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
|
|
@@ -38,6 +49,7 @@ query-shaped sync).
|
|
|
38
49
|
- [API Reference](./api.md) — Model-by-model method shape.
|
|
39
50
|
- [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
|
|
40
51
|
- [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
|
|
52
|
+
- [React](./react.md) — Provider, hooks, and reactive reads for React apps.
|
|
41
53
|
- [API Keys](./api-keys.md) — Bearer tokens for the public API.
|
|
42
54
|
|
|
43
55
|
## API shape
|
|
@@ -58,19 +70,23 @@ query-shaped sync).
|
|
|
58
70
|
|
|
59
71
|
## Concepts
|
|
60
72
|
|
|
73
|
+
- [Schema Contract](./schema-contract.md) — What the schema drives across SDK, React, agents, Data Source, and migrations.
|
|
61
74
|
- [Model Methods](./api.md#model-methods) — Load and write typed state.
|
|
62
75
|
- [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
|
|
63
76
|
- [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
|
|
64
|
-
- [Coordination](./coordination.md) — `claim`, `
|
|
77
|
+
- [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue` for active work.
|
|
65
78
|
- [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
|
|
66
79
|
- [Receipt](./api.md#receipt) — Confirm what landed.
|
|
67
80
|
- [Usage](./api.md#usage) — Metering and audit dimensions.
|
|
81
|
+
- [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
|
|
82
|
+
- [MCP](./mcp.md) — Expose Ablo models to MCP clients (Claude, Cursor).
|
|
68
83
|
|
|
69
84
|
## Examples
|
|
70
85
|
|
|
71
86
|
- [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
|
|
72
87
|
- [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
|
|
73
88
|
- [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
|
|
89
|
+
- [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
|
|
74
90
|
- [Server Agent](./examples/server-agent.md) — Schema-backed worker.
|
|
75
91
|
- [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
|
|
76
92
|
|
|
@@ -83,3 +99,4 @@ query-shaped sync).
|
|
|
83
99
|
- [README](../README.md) — product overview and first example.
|
|
84
100
|
- [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
|
|
85
101
|
- [Changelog](../CHANGELOG.md) — what shipped recently.
|
|
102
|
+
- [Roadmap](./roadmap.md) — what's planned next.
|