@abloatai/ablo 0.6.0 → 0.7.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 +45 -0
- package/README.md +64 -35
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +1 -1
- package/dist/client/Ablo.d.ts +1 -0
- package/dist/client/Ablo.js +1 -0
- package/dist/client/createModelProxy.d.ts +26 -3
- package/dist/client/createModelProxy.js +4 -1
- package/dist/client/validateAbloOptions.js +2 -2
- 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 +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +51 -6
- package/dist/errors.js +56 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- 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/react/AbloProvider.d.ts +12 -0
- package/dist/react/AbloProvider.js +11 -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 +13 -9
- package/dist/schema/serialize.js +14 -10
- 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/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +5 -14
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +44 -0
- package/docs/api.md +11 -22
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +61 -12
- package/docs/data-sources.md +2 -2
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/guarantees.md +5 -2
- package/docs/identity.md +139 -68
- package/docs/index.md +6 -0
- package/docs/integration-guide.md +31 -35
- package/docs/interaction-model.md +3 -0
- package/docs/react.md +3 -3
- package/docs/roadmap.md +14 -2
- package/package.json +8 -1
package/docs/identity.md
CHANGED
|
@@ -62,7 +62,7 @@ their scope is a **rule the schema derives automatically** — you never write
|
|
|
62
62
|
per-user scope code. An agent's reach depends on *what it's working on*, which is
|
|
63
63
|
only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
|
|
64
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* (`
|
|
65
|
+
entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
|
|
66
66
|
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
67
67
|
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
68
68
|
scope is fully schema-derived.)
|
|
@@ -85,35 +85,38 @@ the next two sections applies to both.
|
|
|
85
85
|
|
|
86
86
|
## Declare it, end to end
|
|
87
87
|
|
|
88
|
-
The entire declaration surface is
|
|
89
|
-
|
|
90
|
-
`syncGroups` prop (narrowing). Here they are in one runnable place — the
|
|
91
|
-
after this explain each.
|
|
88
|
+
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
89
|
+
each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
|
|
90
|
+
optional `syncGroups` prop (narrowing). Here they are in one runnable place — the
|
|
91
|
+
sections after this explain each.
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
|
-
// 1. src/ablo
|
|
95
|
-
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
94
|
+
// 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
|
|
95
|
+
import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
|
|
96
96
|
|
|
97
97
|
export const schema = defineSchema(
|
|
98
98
|
{
|
|
99
|
-
|
|
100
|
-
{ title: z.string(), createdBy: z.string() },
|
|
101
|
-
{},
|
|
102
|
-
{ orgScoped: true, syncGroupFormat: 'conversation:{id}' },
|
|
103
|
-
),
|
|
99
|
+
// A scope root: its rows form the group `deck:<id>` (kind from `scope`).
|
|
104
100
|
decks: model(
|
|
105
101
|
{ title: z.string(), status: z.enum(['draft', 'published']) },
|
|
106
102
|
{},
|
|
107
|
-
{ orgScoped: true,
|
|
103
|
+
{ orgScoped: true, scope: 'deck' },
|
|
104
|
+
),
|
|
105
|
+
// A child: it has no group of its own; it inherits its deck's group via the
|
|
106
|
+
// `parent` edge. A write to a slide reaches everyone viewing the deck.
|
|
107
|
+
slides: model(
|
|
108
|
+
{ deckId: z.string() },
|
|
109
|
+
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
110
|
+
{ orgScoped: true },
|
|
108
111
|
),
|
|
109
112
|
},
|
|
110
113
|
{
|
|
111
|
-
// Each role is pure data: a `
|
|
112
|
-
// read. No closures — so the schema stays JSON-serializable
|
|
114
|
+
// Each role is pure data: a `kind` (the group prefix) and the identity
|
|
115
|
+
// `source` field to read. No closures — so the schema stays JSON-serializable.
|
|
113
116
|
identityRoles: [
|
|
114
|
-
identityRole({ kind: '
|
|
115
|
-
identityRole({ kind: '
|
|
116
|
-
identityRole({ kind: '
|
|
117
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
118
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
119
|
+
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
|
|
117
120
|
],
|
|
118
121
|
},
|
|
119
122
|
);
|
|
@@ -127,11 +130,13 @@ export const schema = defineSchema(
|
|
|
127
130
|
```
|
|
128
131
|
|
|
129
132
|
```tsx
|
|
130
|
-
// 3. an AGENT run inherits its user, narrowed to the entities in play
|
|
133
|
+
// 3. an AGENT run inherits its user, narrowed to the entities in play.
|
|
134
|
+
// Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
|
|
135
|
+
// the engine derives the group from each model's `scope`.
|
|
131
136
|
<AbloProvider
|
|
132
137
|
schema={schema}
|
|
133
|
-
userId={user.id}
|
|
134
|
-
|
|
138
|
+
userId={user.id} // ceiling: the triggering user
|
|
139
|
+
scope={{ decks: deckId }} // floor: just the deck it's working on
|
|
135
140
|
>
|
|
136
141
|
{children}
|
|
137
142
|
</AbloProvider>
|
|
@@ -148,15 +153,15 @@ row's sync group is in the participant's allowed set.
|
|
|
148
153
|
|
|
149
154
|
### Half 1 — `identityRoles`: identity → allowed groups
|
|
150
155
|
|
|
151
|
-
Declared once, on the schema, via the `identityRole({ kind,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
Declared once, on the schema, via the `identityRole({ kind, source })` factory.
|
|
157
|
+
Each role is **pure data**: a `kind` (the group's prefix — `org`, `user`, `team`)
|
|
158
|
+
and the `source` — the identity field to read. The engine reads `source` off the
|
|
159
|
+
identity *you* supply and mints `<kind>:<value>` for each value, building the
|
|
160
|
+
participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the
|
|
161
|
+
engine — the kinds and sources are entirely yours.
|
|
157
162
|
|
|
158
163
|
```ts
|
|
159
|
-
// src/ablo
|
|
164
|
+
// src/ablo/schema.ts
|
|
160
165
|
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
161
166
|
|
|
162
167
|
export const schema = defineSchema(
|
|
@@ -168,10 +173,10 @@ export const schema = defineSchema(
|
|
|
168
173
|
},
|
|
169
174
|
{
|
|
170
175
|
identityRoles: [
|
|
171
|
-
identityRole({ kind: '
|
|
172
|
-
identityRole({ kind: '
|
|
173
|
-
// `multi: true` reads an array field — one team group per id.
|
|
174
|
-
identityRole({ kind: '
|
|
176
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
177
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
178
|
+
// `multi: true` reads an array field — one `team:<id>` group per id.
|
|
179
|
+
identityRole({ kind: 'team', source: 'teamIds', multi: true }),
|
|
175
180
|
],
|
|
176
181
|
},
|
|
177
182
|
);
|
|
@@ -189,27 +194,66 @@ in-process and on a hosted server that only ever sees the compiled JSON.
|
|
|
189
194
|
|
|
190
195
|
### Half 2 — per-model scope: row → group
|
|
191
196
|
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
You never write a sync-group string for a row. You declare a model's *place* in
|
|
198
|
+
the entity graph and the engine derives the groups its rows fan out on. Three
|
|
199
|
+
declarations, in order of how often you reach for them:
|
|
200
|
+
|
|
201
|
+
**`scope` — this model is a scope root.** Its rows form a group of their own.
|
|
202
|
+
The kind comes from the model's `typename` by default, or pass a string to set
|
|
203
|
+
it explicitly (use the string form when the wire kind differs from the typename,
|
|
204
|
+
e.g. typename `SlideDeck` but group `deck:<id>`):
|
|
194
205
|
|
|
195
206
|
```ts
|
|
196
|
-
model(
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
|
|
208
|
+
// a deck row → group `deck:<id>`
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**`parent` — this row lives inside another entity.** Mark the `belongsTo` edge
|
|
212
|
+
to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC
|
|
213
|
+
*parent* relation — "access inherits from parent" — and it chains transitively
|
|
214
|
+
(a layer → its slide → its deck), so a write to any descendant reaches everyone
|
|
215
|
+
viewing the root. A *reference* (a provenance/template pointer, not ownership)
|
|
216
|
+
must **not** be marked `parent`, or the row would leak into an unrelated scope:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
slides: model(
|
|
220
|
+
{ deckId: z.string(), sourceSlideId: z.string().optional() },
|
|
199
221
|
{
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
|
|
223
|
+
sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
|
|
224
|
+
},
|
|
225
|
+
{ orgScoped: true },
|
|
226
|
+
);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> **Declare the parent edge — don't infer it.** Optionality is not a proxy for
|
|
230
|
+
> ownership: many `parent` FKs are optional (a root folder, an inbox task), and
|
|
231
|
+
> some required FKs are mere references. Containment is a fact only you know, so
|
|
232
|
+
> it's declared, exactly as it is in OpenFGA/Zanzibar.
|
|
202
233
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
234
|
+
**`grants` — a membership edge.** On a join model (e.g. `dataroomMember`), it
|
|
235
|
+
says "this row grants a *subject* access to a *scope root*." Both are relation
|
|
236
|
+
names on the model. The server resolves it at connect time — for user `U`, it
|
|
237
|
+
finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
|
|
238
|
+
set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
|
|
239
|
+
org membership is already covered by the `org:` identity role.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
dataroomMember: model(
|
|
243
|
+
{ userId: z.string(), dataroomId: z.string() },
|
|
244
|
+
{
|
|
245
|
+
member: relation.belongsTo('users', 'userId'),
|
|
246
|
+
room: relation.belongsTo('datarooms', 'dataroomId'),
|
|
206
247
|
},
|
|
248
|
+
{ orgScoped: true, grants: { subject: 'member', scope: 'room' } },
|
|
207
249
|
);
|
|
208
250
|
```
|
|
209
251
|
|
|
210
|
-
For
|
|
211
|
-
|
|
212
|
-
|
|
252
|
+
For the rare group keyed on a plain field rather than a relation (per-recipient
|
|
253
|
+
inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
|
|
254
|
+
escape hatch. For rows that inherit *tenancy* (not a sync group) through a
|
|
255
|
+
foreign key without carrying `organization_id`, use `scopedVia` rather than
|
|
256
|
+
`orgScoped: false` — the latter exposes the whole table cross-tenant. See
|
|
213
257
|
`packages/sync-engine/src/schema/model.ts` for the full option set.
|
|
214
258
|
|
|
215
259
|
## How identity reaches Ablo — the proxy model
|
|
@@ -255,7 +299,7 @@ resolve the user in a Server Component and pass it down:
|
|
|
255
299
|
```tsx
|
|
256
300
|
// app/providers.tsx — 'use client'
|
|
257
301
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
258
|
-
import { schema } from '@/ablo
|
|
302
|
+
import { schema } from '@/ablo/schema';
|
|
259
303
|
|
|
260
304
|
export function Providers({
|
|
261
305
|
children,
|
|
@@ -316,22 +360,23 @@ Mechanically, this is the per-model anchor from
|
|
|
316
360
|
anchor on the models an agent operates on:
|
|
317
361
|
|
|
318
362
|
```ts
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
decks:
|
|
363
|
+
// each scope-root model an agent edits forms a per-entity group
|
|
364
|
+
documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
|
|
365
|
+
decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
|
|
322
366
|
```
|
|
323
367
|
|
|
324
368
|
Then a run subscribes only to the entity groups for the rows it works on — a
|
|
325
369
|
subset of what its user could see:
|
|
326
370
|
|
|
327
371
|
```tsx
|
|
328
|
-
// agent run triggered by `user`, working on one
|
|
372
|
+
// agent run triggered by `user`, working on one document + one deck
|
|
329
373
|
<AbloProvider
|
|
330
374
|
schema={schema}
|
|
331
375
|
// identity inherited from the triggering user (the ceiling)
|
|
332
376
|
userId={user.id}
|
|
333
|
-
// authority narrowed to just the entities in play (the floor)
|
|
334
|
-
|
|
377
|
+
// authority narrowed to just the entities in play (the floor).
|
|
378
|
+
// Model form — keyed by model, resolved to groups via each model's `scope`.
|
|
379
|
+
scope={{ documents: documentId, decks: deckId }}
|
|
335
380
|
>
|
|
336
381
|
```
|
|
337
382
|
|
|
@@ -368,24 +413,50 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
|
|
|
368
413
|
`claim` loop is, to the scoping layer, that same participant — scoped to the row
|
|
369
414
|
it claimed.
|
|
370
415
|
|
|
371
|
-
## Narrowing to
|
|
416
|
+
## Narrowing to specific entities — the `scope` prop
|
|
372
417
|
|
|
373
|
-
|
|
374
|
-
|
|
418
|
+
A human gets their full membership automatically (`identityRoles`). To narrow a
|
|
419
|
+
session — a page on one deck, or an agent pointed at the entities it's working
|
|
420
|
+
on — pass `scope`. You give it the **model and id(s)**; the engine builds the
|
|
421
|
+
group string from the model's `scope` (Half 2), so you never hand-write
|
|
422
|
+
`deck:<id>`.
|
|
375
423
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
424
|
+
`scope` accepts four shapes, all resolved through the schema:
|
|
425
|
+
|
|
426
|
+
| You pass | Resolves to | Use it for |
|
|
427
|
+
| --- | --- | --- |
|
|
428
|
+
| `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
|
|
429
|
+
| `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
|
|
430
|
+
| `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
|
|
431
|
+
| `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
|
|
380
432
|
|
|
381
433
|
```tsx
|
|
382
|
-
// page
|
|
383
|
-
<AbloProvider schema={schema} userId={user.id}
|
|
434
|
+
// a page on one deck
|
|
435
|
+
<AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
|
|
436
|
+
|
|
437
|
+
// an agent working across two decks and a document
|
|
438
|
+
<AbloProvider
|
|
439
|
+
schema={schema}
|
|
440
|
+
userId={user.id}
|
|
441
|
+
scope={{ decks: [deckA, deckB], documents: docId }}
|
|
442
|
+
/>
|
|
384
443
|
```
|
|
385
444
|
|
|
386
|
-
The
|
|
387
|
-
|
|
388
|
-
|
|
445
|
+
The key is the **model** (`decks`), the value is **which id(s)** — the `deck:`
|
|
446
|
+
prefix comes from that model's `scope: 'deck'`, never from a string you compose.
|
|
447
|
+
|
|
448
|
+
> **`scope` means one thing: sync-group scope.** It appears in two places that
|
|
449
|
+
> are the same concept — the model option `scope: 'deck'` (declares a scope root,
|
|
450
|
+
> [Half 2](#half-2--per-model-scope-row--group)) and this `scope` prop (subscribe
|
|
451
|
+
> to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
|
|
452
|
+
> axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
|
|
453
|
+
> open/closed/all), precisely so it doesn't share the word.
|
|
454
|
+
|
|
455
|
+
> **`scope` requests, it never grants.** At connect, the server intersects your
|
|
456
|
+
> requested groups with what the identity is actually allowed (`requested ∩
|
|
457
|
+
> allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
|
|
458
|
+
> agent can't reach a deck its capability doesn't already permit, no matter what
|
|
459
|
+
> it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
|
|
389
460
|
|
|
390
461
|
## How this compares — and the best practices it follows
|
|
391
462
|
|
|
@@ -426,13 +497,13 @@ The best practices Ablo inherits from that lineage:
|
|
|
426
497
|
the line precisely: [token parameters are trusted and usable for access
|
|
427
498
|
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
428
499
|
In Ablo terms, the identity your server vouches for is the *trusted* claim that
|
|
429
|
-
sets scope; the provider's `userId` / `
|
|
500
|
+
sets scope; the provider's `userId` / `scope` props are *untrusted client
|
|
430
501
|
input* — convenient for app-owned fields and narrowing, but never the boundary.
|
|
431
502
|
This is why changing `userId` in the browser grants nothing.
|
|
432
503
|
|
|
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)
|
|
504
|
+
3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
|
|
505
|
+
group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
|
|
506
|
+
`scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
|
|
436
507
|
(`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
|
|
437
508
|
Declaring the convention in one place — never composing scope strings in
|
|
438
509
|
consumer code — is the practice all three enforce.
|
package/docs/index.md
CHANGED
|
@@ -31,6 +31,7 @@ query-shaped sync).
|
|
|
31
31
|
## Start here
|
|
32
32
|
|
|
33
33
|
- [Quickstart](./quickstart.md) — Make your first schema-backed write.
|
|
34
|
+
- [CLI & Migrations](./cli.md) — `init` / `migrate` / `schema push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
|
|
34
35
|
- [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
36
|
- [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
|
|
36
37
|
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
|
|
@@ -38,6 +39,7 @@ query-shaped sync).
|
|
|
38
39
|
- [API Reference](./api.md) — Model-by-model method shape.
|
|
39
40
|
- [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
|
|
40
41
|
- [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
|
|
42
|
+
- [React](./react.md) — Provider, hooks, and reactive reads for React apps.
|
|
41
43
|
- [API Keys](./api-keys.md) — Bearer tokens for the public API.
|
|
42
44
|
|
|
43
45
|
## API shape
|
|
@@ -65,12 +67,15 @@ query-shaped sync).
|
|
|
65
67
|
- [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
|
|
66
68
|
- [Receipt](./api.md#receipt) — Confirm what landed.
|
|
67
69
|
- [Usage](./api.md#usage) — Metering and audit dimensions.
|
|
70
|
+
- [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
|
|
71
|
+
- [MCP](./mcp.md) — Expose Ablo models to MCP clients (Claude, Cursor).
|
|
68
72
|
|
|
69
73
|
## Examples
|
|
70
74
|
|
|
71
75
|
- [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
|
|
72
76
|
- [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
|
|
73
77
|
- [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
|
|
78
|
+
- [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
|
|
74
79
|
- [Server Agent](./examples/server-agent.md) — Schema-backed worker.
|
|
75
80
|
- [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
|
|
76
81
|
|
|
@@ -83,3 +88,4 @@ query-shaped sync).
|
|
|
83
88
|
- [README](../README.md) — product overview and first example.
|
|
84
89
|
- [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
|
|
85
90
|
- [Changelog](../CHANGELOG.md) — what shipped recently.
|
|
91
|
+
- [Roadmap](./roadmap.md) — what's planned next.
|
|
@@ -101,10 +101,10 @@ wait: 'confirmed' }), and add a smoke test for two concurrent writers.
|
|
|
101
101
|
|
|
102
102
|
Start with fields and relations. Keep load strategies, indexing hints, and
|
|
103
103
|
read-only/mutable shortcuts out of the first version unless you already need
|
|
104
|
-
|
|
104
|
+
them.
|
|
105
105
|
|
|
106
106
|
```ts
|
|
107
|
-
// src/ablo
|
|
107
|
+
// src/ablo/schema.ts
|
|
108
108
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
109
109
|
|
|
110
110
|
export const schema = defineSchema(
|
|
@@ -119,23 +119,15 @@ export const schema = defineSchema(
|
|
|
119
119
|
}),
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
|
-
// Identity-anchored sync-group roles. The server walks these to
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// consumer-controlled
|
|
126
|
-
//
|
|
127
|
-
//
|
|
122
|
+
// Identity-anchored sync-group roles. The server walks these to build each
|
|
123
|
+
// participant's allowed subscription set from the resolved identity context.
|
|
124
|
+
// `kind` is the group prefix; `source` is the identity field to read — both
|
|
125
|
+
// consumer-controlled, no hardcoded `org:` / `user:` convention anywhere in
|
|
126
|
+
// the engine. Pure data (no closures), so the schema stays JSON-serializable.
|
|
127
|
+
// Omit `identityRoles` entirely if you don't need identity-derived scoping.
|
|
128
128
|
identityRoles: [
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
template: 'org:{id}',
|
|
132
|
-
extract: (i) => (i.organizationId ? [String(i.organizationId)] : []),
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
kind: 'participant',
|
|
136
|
-
template: 'user:{id}',
|
|
137
|
-
extract: (i) => (i.userId ? [String(i.userId)] : []),
|
|
138
|
-
},
|
|
129
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
130
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
139
131
|
],
|
|
140
132
|
}
|
|
141
133
|
);
|
|
@@ -143,11 +135,15 @@ export const schema = defineSchema(
|
|
|
143
135
|
|
|
144
136
|
### Declaring scope on a model
|
|
145
137
|
|
|
146
|
-
|
|
147
|
-
`
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
138
|
+
> **Canonical reference: [Identity & Sync Groups](./identity.md).** This is the
|
|
139
|
+
> short version — `scope` (root), `parent` (containment), `grants` (membership),
|
|
140
|
+
> and the model-form `scope` prop are all covered in depth there. Read it once;
|
|
141
|
+
> this guide only shows the minimal shape inline.
|
|
142
|
+
|
|
143
|
+
Per-row tenancy and per-entity sync-group anchors live on the `model(...)`
|
|
144
|
+
options. The two halves compose: the identity roles above produce a
|
|
145
|
+
participant's _allowed_ set; the per-model options below define how rows are
|
|
146
|
+
filtered server-side and which sync-group each row fans out on.
|
|
151
147
|
|
|
152
148
|
```ts
|
|
153
149
|
model(
|
|
@@ -159,9 +155,9 @@ model(
|
|
|
159
155
|
// Rows carry organization_id and bootstrap filters on it.
|
|
160
156
|
orgScoped: true,
|
|
161
157
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
158
|
+
// Scope root: rows form the group `matter:<id>`. Children point at it with
|
|
159
|
+
// `relation.belongsTo('matters', 'matterId', { parent: true })` to inherit.
|
|
160
|
+
scope: 'matter',
|
|
165
161
|
}
|
|
166
162
|
);
|
|
167
163
|
```
|
|
@@ -178,7 +174,7 @@ Trusted runtimes can use `ABLO_API_KEY`.
|
|
|
178
174
|
```ts
|
|
179
175
|
// src/ablo.ts
|
|
180
176
|
import Ablo from '@abloatai/ablo';
|
|
181
|
-
import { schema } from './ablo
|
|
177
|
+
import { schema } from './ablo/schema';
|
|
182
178
|
|
|
183
179
|
export const ablo = Ablo({
|
|
184
180
|
schema,
|
|
@@ -194,7 +190,7 @@ server API key in the bundle.
|
|
|
194
190
|
'use client';
|
|
195
191
|
|
|
196
192
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
197
|
-
import { schema } from '@/ablo
|
|
193
|
+
import { schema } from '@/ablo/schema';
|
|
198
194
|
|
|
199
195
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
200
196
|
return <AbloProvider schema={schema}>{children}</AbloProvider>;
|
|
@@ -239,7 +235,7 @@ const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm
|
|
|
239
235
|
if (!report) throw new Error('report not found');
|
|
240
236
|
```
|
|
241
237
|
|
|
242
|
-
Use `retrieve`, `list`, and `count` for synchronous
|
|
238
|
+
Use `retrieve`, `list`, and `count` for synchronous reads after data has
|
|
243
239
|
loaded.
|
|
244
240
|
|
|
245
241
|
```ts
|
|
@@ -362,7 +358,7 @@ Use a Data Source when your app database remains the source of truth.
|
|
|
362
358
|
```ts
|
|
363
359
|
// app/api/ablo/source/route.ts
|
|
364
360
|
import { dataSource } from '@abloatai/ablo';
|
|
365
|
-
import { schema } from '@/ablo
|
|
361
|
+
import { schema } from '@/ablo/schema';
|
|
366
362
|
import { db } from '@/db';
|
|
367
363
|
|
|
368
364
|
export const POST = dataSource({
|
|
@@ -458,10 +454,10 @@ Keep agent writes on the same schema client surface as the app.
|
|
|
458
454
|
| `/react` | Live React selectors, provider lifecycle, presence, sync status. |
|
|
459
455
|
| `/testing` | Test harnesses and deterministic mocks. |
|
|
460
456
|
| `Data Source` | Keep your app database canonical. |
|
|
461
|
-
| `persistence: 'indexeddb'` | Durable browser cache
|
|
457
|
+
| `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
|
|
462
458
|
| `claim` / `claimState` / `queue` | Show active work and coordinate before a write. |
|
|
463
459
|
| `snapshot` + `readAt` | Reject writes based on stale state. |
|
|
464
|
-
| `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and
|
|
460
|
+
| `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
|
|
465
461
|
|
|
466
462
|
The first integration should not need most of these. Start with schema and
|
|
467
463
|
model methods, then add the optional pieces where the product actually needs
|
|
@@ -472,9 +468,9 @@ them.
|
|
|
472
468
|
| Method | Use it for |
|
|
473
469
|
| ---------------------------- | ---------------------------------------------------------------- |
|
|
474
470
|
| `load({ where })` | Async hydration from backing store/server. |
|
|
475
|
-
| `retrieve(id)` | Synchronous
|
|
476
|
-
| `list(options?)` | Synchronous
|
|
477
|
-
| `count(options?)` | Synchronous
|
|
471
|
+
| `retrieve(id)` | Synchronous read of one already-loaded row. |
|
|
472
|
+
| `list(options?)` | Synchronous collection read of loaded rows. |
|
|
473
|
+
| `count(options?)` | Synchronous count of loaded rows. |
|
|
478
474
|
| `create(data, options?)` | Create through the model client. |
|
|
479
475
|
| `update(id, data, options?)` | Update through the model client. |
|
|
480
476
|
| `delete(id, options?)` | Delete through the model client. |
|
|
@@ -51,6 +51,9 @@ await ablo.weatherReports.claim(id, async (report) => {
|
|
|
51
51
|
|
|
52
52
|
## Coordination
|
|
53
53
|
|
|
54
|
+
> Loop view only. Full claim reference — methods, the claim-state object, the
|
|
55
|
+
> `queue`, errors — is [Coordination](./coordination.md).
|
|
56
|
+
|
|
54
57
|
Claims broadcast across the org. Claim a row through the flat model verb, write
|
|
55
58
|
through the normal `update`, and the claim releases when the callback returns:
|
|
56
59
|
|
package/docs/react.md
CHANGED
|
@@ -23,7 +23,7 @@ pool, and the engine lifecycle; everything below it reads with `useAblo`.
|
|
|
23
23
|
'use client';
|
|
24
24
|
|
|
25
25
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
26
|
-
import { schema } from '@/ablo
|
|
26
|
+
import { schema } from '@/ablo/schema';
|
|
27
27
|
|
|
28
28
|
export function Providers({
|
|
29
29
|
children,
|
|
@@ -57,7 +57,7 @@ export function Providers({
|
|
|
57
57
|
| `apiKey` | session/cookie | Bootstrap auth. Browser apps **omit this** — the key stays server-side. See Identity below. |
|
|
58
58
|
| `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
|
|
59
59
|
| `bootstrapMode` | `'full'` | `'full'` pulls the org's baseline before ready; `'none'` skips the baseline and processes live deltas only.|
|
|
60
|
-
| `persistence` | `'volatile'` | `'indexeddb'` opts into
|
|
60
|
+
| `persistence` | `'volatile'` | `'indexeddb'` opts into a durable browser cache that survives reloads. |
|
|
61
61
|
| `onSessionExpired` | — | Fired after the engine has already purged on a rejected session — use for redirect-to-sign-in. |
|
|
62
62
|
| `onError` | — | Engine / WebSocket / `postBootstrap` errors. Wire to Sentry / Datadog. |
|
|
63
63
|
|
|
@@ -109,7 +109,7 @@ const reports = useAblo((ablo) =>
|
|
|
109
109
|
ablo.weatherReports.list({
|
|
110
110
|
where: { projectId },
|
|
111
111
|
filter: (report) => report.status !== 'ready',
|
|
112
|
-
|
|
112
|
+
state: 'live',
|
|
113
113
|
}),
|
|
114
114
|
);
|
|
115
115
|
```
|
package/docs/roadmap.md
CHANGED
|
@@ -9,15 +9,27 @@ What is shipped, what is next, and what we will not build.
|
|
|
9
9
|
- **MCP transport** — HTTP server at `/api/mcp`.
|
|
10
10
|
- **TypeScript SDK** — `@abloatai/ablo`, with React bindings.
|
|
11
11
|
- **Dashboard** — keys, audit, metrics, allowed origins.
|
|
12
|
+
- **Schema migrations** — declarative model schema changes. Pure
|
|
13
|
+
diff/classify/cast-safety/backfill planning engine (`diffSchema`,
|
|
14
|
+
`classifyMigration`, `classifyCast`, `isAutoApplicable`) ships in the SDK;
|
|
15
|
+
`ablo generate` emits typed clients from a pushed schema; the server
|
|
16
|
+
applies and activates migrations only on success.
|
|
17
|
+
- **Structured error contract** — versioned (`ERROR_CONTRACT_VERSION`),
|
|
18
|
+
drift-guarded code registry shared across the HTTP, WebSocket, and MCP
|
|
19
|
+
planes, with always-on request ids and an OpenAPI error envelope.
|
|
20
|
+
- **Relation-driven sync groups** — membership-routed fan-out via branded
|
|
21
|
+
`SyncGroup` + schema-declared identity roles (schema-JSON v2).
|
|
22
|
+
- **Intent coordination plane** — Stripe-shaped `Intent` with per-model
|
|
23
|
+
`intent(id)` handles for lease/await/commit coordination between agents.
|
|
12
24
|
|
|
13
25
|
## In flight
|
|
14
26
|
|
|
15
|
-
- **Real-time presence** — see who else is viewing/editing a model
|
|
27
|
+
- **Real-time presence** — see who else is viewing/editing a model
|
|
28
|
+
(coordination primitives landed; presence surface in progress).
|
|
16
29
|
- **Cross-instance fan-out via Redis** — pub/sub deltas at scale.
|
|
17
30
|
|
|
18
31
|
## On deck
|
|
19
32
|
|
|
20
|
-
- **Schema migrations** — declarative model schema changes.
|
|
21
33
|
- **Field-level subscriptions** — subscribe to one path, not the whole row.
|
|
22
34
|
- **Bulk import/export** — CSV/JSON round-trip with chain verification.
|
|
23
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "State control API for AI agents and collaborative apps.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"import": "./dist/schema/index.js",
|
|
21
21
|
"default": "./dist/schema/index.js"
|
|
22
22
|
},
|
|
23
|
+
"./coordination": {
|
|
24
|
+
"types": "./dist/coordination/index.d.ts",
|
|
25
|
+
"import": "./dist/coordination/index.js",
|
|
26
|
+
"default": "./dist/coordination/index.js"
|
|
27
|
+
},
|
|
23
28
|
"./react": {
|
|
24
29
|
"types": "./dist/react/index.d.ts",
|
|
25
30
|
"import": "./dist/react/index.js",
|
|
@@ -71,6 +76,8 @@
|
|
|
71
76
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
72
77
|
"pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
|
|
73
78
|
"lint:imports": "node scripts/check-js-extensions.mjs",
|
|
79
|
+
"generate:errors": "tsx scripts/generate-error-docs.mts",
|
|
80
|
+
"lint:errors": "tsx scripts/check-error-docs.mts",
|
|
74
81
|
"lint:pkg": "publint",
|
|
75
82
|
"prepublishOnly": "npm run build && npm run lint:pkg",
|
|
76
83
|
"test": "jest",
|