@abloatai/ablo 0.51.0 → 0.53.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/AGENTS.md +1 -1
- package/CHANGELOG.md +220 -26
- package/NOTICE +1 -1
- package/docs/agents.md +28 -28
- package/docs/api-keys.md +30 -3
- package/docs/api.md +32 -3
- package/docs/cli.md +3 -3
- package/docs/client-behavior.md +1 -1
- package/docs/concurrency-convention.md +7 -7
- package/docs/context.md +11 -11
- package/docs/coordination.md +19 -19
- package/docs/customer-organizations.md +215 -0
- package/docs/data-sources.md +3 -3
- package/docs/debugging.md +8 -8
- package/docs/examples/agent-human.md +17 -17
- package/docs/examples/ai-sdk-tool.md +6 -6
- package/docs/examples/existing-python-backend.md +1 -1
- package/docs/examples/nextjs.md +15 -15
- package/docs/examples/scoped-agent.md +10 -10
- package/docs/examples/server-agent.md +14 -14
- package/docs/groups.md +12 -12
- package/docs/how-it-works.md +7 -7
- package/docs/idempotency.md +4 -4
- package/docs/identity.md +45 -38
- package/docs/index.md +2 -1
- package/docs/integration-guide.md +1 -1
- package/docs/integrations/inngest.md +2 -2
- package/docs/integrations/temporal.md +3 -3
- package/docs/integrations.md +3 -3
- package/docs/react.md +4 -4
- package/docs/sessions.md +21 -16
- package/docs/webhooks.md +2 -2
- package/examples/README.md +6 -6
- package/examples/agent-turn.ts +13 -13
- package/examples/data-source/README.md +3 -3
- package/examples/data-source/customer-server.ts +23 -23
- package/examples/data-source/run.ts +8 -8
- package/examples/data-source/schema.ts +2 -2
- package/examples/lease-outlives-the-machine.ts +66 -0
- package/examples/tsconfig.json +4 -10
- package/llms.txt +11 -0
- package/package.json +4 -4
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
You want an agent that edits **one workspace** and pushes realtime updates to the
|
|
6
6
|
participants on **that workspace only** — not a broadcast to the whole org. The
|
|
7
7
|
catch most people hit: which write reaches whom is decided by how the rows
|
|
8
|
-
*relate*, not by which columns the write touched. So a
|
|
9
|
-
`workspaceId` still reaches everyone watching the workspace, because the
|
|
8
|
+
*relate*, not by which columns the write touched. So a record edit that never sets
|
|
9
|
+
`workspaceId` still reaches everyone watching the workspace, because the record already
|
|
10
10
|
belongs to it. You get this by declaring the relationship once, then narrowing the
|
|
11
11
|
agent to the workspace id — you never assemble a `workspace:<id>` audience string by
|
|
12
12
|
hand.
|
|
@@ -27,9 +27,9 @@ export const schema = defineSchema(
|
|
|
27
27
|
{ title: z.string() },
|
|
28
28
|
{ groups: { root: 'workspace' } },
|
|
29
29
|
),
|
|
30
|
-
// A
|
|
31
|
-
// `parent` edge, so a
|
|
32
|
-
|
|
30
|
+
// A record has no group of its own. It inherits its workspace's group via the
|
|
31
|
+
// `parent` edge, so a record write reaches everyone watching the workspace.
|
|
32
|
+
records: model(
|
|
33
33
|
{ workspaceId: z.string(), title: z.string() },
|
|
34
34
|
{ relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } },
|
|
35
35
|
),
|
|
@@ -67,7 +67,7 @@ const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
|
67
67
|
export async function mintProjectAgentSession(workspaceId: string, agentId: string) {
|
|
68
68
|
const { token } = await server.sessions.create({
|
|
69
69
|
agent: { id: agentId },
|
|
70
|
-
can: {
|
|
70
|
+
can: { records: ['read', 'update'] }, // operation allowlist for this run
|
|
71
71
|
syncGroups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace
|
|
72
72
|
});
|
|
73
73
|
return token;
|
|
@@ -106,13 +106,13 @@ const ablo = useAblo();
|
|
|
106
106
|
// Other participants subscribed to workspace:<workspaceId> — a reviewer agent, a
|
|
107
107
|
// person watching in the UI — receive this delta in realtime. Participants on
|
|
108
108
|
// other workspaces never see it.
|
|
109
|
-
await ablo.
|
|
109
|
+
await ablo.records.update({ id: recordId, data: { title: 'Ship the Q4 report' } });
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
The
|
|
113
|
-
|
|
112
|
+
The record's delta is stamped `workspace:<workspaceId>`, derived server-side from the
|
|
113
|
+
record → workspace `parent` edge — not from `workspaceId` appearing in this particular
|
|
114
114
|
write, and not from whatever the agent happened to subscribe to. The routing is
|
|
115
|
-
decided by the data: a
|
|
115
|
+
decided by the data: a record belongs to its workspace, so its writes go to the
|
|
116
116
|
workspace's group, full stop.
|
|
117
117
|
|
|
118
118
|
## See also
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> A stateless schema-backed worker: wake, claim, commit, go idle.
|
|
4
4
|
|
|
5
|
-
A server agent is backend code — a cron job, a queue worker, an AI
|
|
5
|
+
A server agent is backend code — a cron job, a queue worker, an AI record — that
|
|
6
6
|
reads and writes your app's records outside the browser. The hard part is doing
|
|
7
|
-
it without racing whatever else is working: if two workers pick up the same
|
|
7
|
+
it without racing whatever else is working: if two workers pick up the same record
|
|
8
8
|
at once, one write clobbers the other. This is what `claim()` is for.
|
|
9
9
|
|
|
10
10
|
Agents hold no socket, so pass `transport: 'http'` and import the same schema the
|
|
11
|
-
rest of the app uses. Below, a worker finishes a
|
|
11
|
+
rest of the app uses. Below, a worker finishes a record by claiming it, writing the
|
|
12
12
|
result, and releasing it automatically when the claim goes out of scope.
|
|
13
13
|
|
|
14
14
|
`claim({ id })` takes the record for your worker and returns a disposable handle:
|
|
@@ -22,7 +22,7 @@ import Ablo from '@abloatai/ablo';
|
|
|
22
22
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
23
23
|
|
|
24
24
|
const schema = defineSchema({
|
|
25
|
-
|
|
25
|
+
records: model({
|
|
26
26
|
title: z.string(),
|
|
27
27
|
status: z.enum(['todo', 'doing', 'done']),
|
|
28
28
|
summary: z.string().optional(),
|
|
@@ -37,27 +37,27 @@ const control = Ablo({
|
|
|
37
37
|
async function clientForWorker(workerId: string) {
|
|
38
38
|
const { token } = await control.sessions.create({
|
|
39
39
|
agent: { id: workerId },
|
|
40
|
-
can: {
|
|
40
|
+
can: { records: ['read', 'update'] },
|
|
41
41
|
});
|
|
42
42
|
return Ablo({ schema, apiKey: token, transport: 'http' });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export async function completeTask(
|
|
45
|
+
export async function completeTask(recordId: string, workerId: string) {
|
|
46
46
|
// Participant identity comes from this worker-specific session. Two clients
|
|
47
47
|
// made directly from the same root key are re-entrant, not contenders.
|
|
48
48
|
const ablo = await clientForWorker(workerId);
|
|
49
49
|
await ablo.ready();
|
|
50
50
|
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
51
|
+
const record = await ablo.records.get({ id: recordId });
|
|
52
|
+
if (!record) return { status: 'not_found' };
|
|
53
53
|
|
|
54
|
-
const acquired = await ablo.
|
|
55
|
-
id:
|
|
54
|
+
const acquired = await ablo.records.claim({
|
|
55
|
+
id: recordId,
|
|
56
56
|
contention: {
|
|
57
57
|
mode: 'skip',
|
|
58
58
|
onStatus(event) {
|
|
59
59
|
if (event.type === 'skipped') {
|
|
60
|
-
console.info('
|
|
60
|
+
console.info('record already owned', event.error.code);
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
},
|
|
@@ -66,12 +66,12 @@ export async function completeTask(taskId: string, workerId: string) {
|
|
|
66
66
|
if (!acquired) return { status: 'already_claimed' };
|
|
67
67
|
|
|
68
68
|
await using claim = acquired;
|
|
69
|
-
const updated = await ablo.
|
|
69
|
+
const updated = await ablo.records.update({
|
|
70
70
|
id: claim.data.id,
|
|
71
71
|
data: { status: 'done' },
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
return { status: 'done',
|
|
74
|
+
return { status: 'done', record: updated };
|
|
75
75
|
// claim auto-releases as the function returns
|
|
76
76
|
}
|
|
77
77
|
```
|
|
@@ -97,7 +97,7 @@ schema-backed client:
|
|
|
97
97
|
```ts
|
|
98
98
|
await ablo.commits.create({
|
|
99
99
|
operations: [
|
|
100
|
-
{ action: 'update', model: '
|
|
100
|
+
{ action: 'update', model: 'records', id: 'record_123', data: { status: 'done' } },
|
|
101
101
|
],
|
|
102
102
|
wait: 'confirmed',
|
|
103
103
|
});
|
package/docs/groups.md
CHANGED
|
@@ -45,17 +45,17 @@ questions. Pick by the question you have.
|
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
47
|
// A screen that stays current.
|
|
48
|
-
ablo.
|
|
48
|
+
ablo.records.onChange((docs) => render(docs));
|
|
49
49
|
|
|
50
50
|
// Who else is in here, and what are they holding.
|
|
51
|
-
await using room = await ablo.
|
|
51
|
+
await using room = await ablo.records.join(documentIds, { ttl: '5m' });
|
|
52
52
|
room.peers;
|
|
53
53
|
|
|
54
54
|
// Stop this write if the thing I read moved while I composed it.
|
|
55
55
|
await ablo.blocks.update({ id, data, reads: [{ group: 'workspace:abc', readAt, onStale: 'notify' }] });
|
|
56
56
|
|
|
57
57
|
// Tell me later if this moves, even though I am not writing now.
|
|
58
|
-
await ablo.
|
|
58
|
+
await ablo.records.track({ id: 's-1' });
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
| Question | Channel | Arrives |
|
|
@@ -139,7 +139,7 @@ reaches `C`.
|
|
|
139
139
|
The direction matters. The signal flows forward, A to B to C, and each hop is a
|
|
140
140
|
real write an actor chose to make. The engine supplies the edges (group
|
|
141
141
|
membership) and a stale signal on each edge (the premise check); the actors are
|
|
142
|
-
the runtime that walks them. It is closer to a
|
|
142
|
+
the runtime that walks them. It is closer to a dataset an analyst
|
|
143
143
|
recalculates cell by cell than to a reactive engine that recomputes the whole
|
|
144
144
|
column for you.
|
|
145
145
|
|
|
@@ -206,7 +206,7 @@ you, arriving on the write you were going to make anyway.
|
|
|
206
206
|
|
|
207
207
|
```ts
|
|
208
208
|
// Register interest and walk away — no write required.
|
|
209
|
-
await ablo.
|
|
209
|
+
await ablo.records.track({ id: 's-1' });
|
|
210
210
|
|
|
211
211
|
// …minutes of other work later, on your next commit…
|
|
212
212
|
const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } });
|
|
@@ -223,7 +223,7 @@ You can also register a track as part of a write you are already making, the
|
|
|
223
223
|
persisted companion to `reads`:
|
|
224
224
|
|
|
225
225
|
```ts
|
|
226
|
-
await ablo.
|
|
226
|
+
await ablo.records.update({
|
|
227
227
|
id: 's-1',
|
|
228
228
|
data: { title: revised },
|
|
229
229
|
reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
|
|
@@ -242,8 +242,8 @@ A track says what a moved belief should do to your **next write**. Same enum the
|
|
|
242
242
|
`reads` premise carries, minus the one mode that cannot apply:
|
|
243
243
|
|
|
244
244
|
```ts
|
|
245
|
-
await ablo.
|
|
246
|
-
await ablo.
|
|
245
|
+
await ablo.records.track({ id: 's-1' }); // notify (default)
|
|
246
|
+
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate
|
|
247
247
|
```
|
|
248
248
|
|
|
249
249
|
- **`notify`** — the change rides your next receipt and the commit proceeds. You
|
|
@@ -263,12 +263,12 @@ re-read, then re-register the track to say so:
|
|
|
263
263
|
|
|
264
264
|
```ts
|
|
265
265
|
try {
|
|
266
|
-
await ablo.
|
|
266
|
+
await ablo.records.update({ id, data });
|
|
267
267
|
} catch (err) {
|
|
268
268
|
if (err.code === 'stale_context') {
|
|
269
|
-
const fresh = await ablo.
|
|
270
|
-
await ablo.
|
|
271
|
-
await ablo.
|
|
269
|
+
const fresh = await ablo.records.get({ id: 's-1' }); // read
|
|
270
|
+
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge
|
|
271
|
+
await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
```
|
package/docs/how-it-works.md
CHANGED
|
@@ -7,10 +7,10 @@ whole model — everything below explains what it means and how to use it.
|
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
9
|
// You call Ablo. Ablo lands the change in your database and confirms it.
|
|
10
|
-
await ablo.
|
|
10
|
+
await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
|
|
11
11
|
|
|
12
12
|
// Reads come back live, kept current from your database.
|
|
13
|
-
const
|
|
13
|
+
const record = ablo.records.local.get('record_42');
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## The mental model: read this once
|
|
@@ -90,17 +90,17 @@ export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
|
90
90
|
|
|
91
91
|
// 5. Write through Ablo. Local state changes immediately; await blocks until
|
|
92
92
|
// the authoritative feed proves the row is there.
|
|
93
|
-
await ablo.
|
|
93
|
+
await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
|
|
94
94
|
|
|
95
95
|
// 6. Read — live, no fetch loop.
|
|
96
|
-
const
|
|
96
|
+
const record = ablo.records.local.get('record_42');
|
|
97
97
|
|
|
98
98
|
// 7. Coordinate when more than one actor can touch a row. Hold a claim and Ablo
|
|
99
99
|
// serializes writes on that key against everyone else; read after claiming,
|
|
100
100
|
// then write. The lease releases automatically at the end of the scope.
|
|
101
|
-
await using _hold = await ablo.
|
|
102
|
-
const latest = ablo.
|
|
103
|
-
await ablo.
|
|
101
|
+
await using _hold = await ablo.records.claim('record_42');
|
|
102
|
+
const latest = ablo.records.local.get('record_42'); // read after claiming, not from memory
|
|
103
|
+
await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
For the setup details, see [Connect Your Database](./data-sources.md). For the
|
package/docs/idempotency.md
CHANGED
|
@@ -10,10 +10,10 @@ retry safe automatically. It cannot make a retry across a process restart safe,
|
|
|
10
10
|
process generates a new key — so for anything that must survive a crash, supply your own.
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
await ablo.
|
|
14
|
-
id:
|
|
13
|
+
await ablo.records.update({
|
|
14
|
+
id: recordId,
|
|
15
15
|
data: { status: 'done' },
|
|
16
|
-
idempotencyKey: `
|
|
16
|
+
idempotencyKey: `record:${recordId}:mark-done:v1`,
|
|
17
17
|
});
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ await ablo.tasks.update({
|
|
|
22
22
|
**Derive the key from the business event, not from the attempt.** A key built from
|
|
23
23
|
`crypto.randomUUID()` at the call site is regenerated on every retry, so it protects nothing — each
|
|
24
24
|
attempt looks like a new intention and the write lands twice. A key built from the thing that
|
|
25
|
-
happened (`
|
|
25
|
+
happened (`record:42:mark-done:v1`) is identical on every retry by construction, which is the whole
|
|
26
26
|
point.
|
|
27
27
|
|
|
28
28
|
The same rule stated as its failure: never derive a key from a timestamp, an attempt counter, or a
|
package/docs/identity.md
CHANGED
|
@@ -38,7 +38,7 @@ scoped operation throws `CapabilityError`; compare its
|
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
40
|
try {
|
|
41
|
-
await ablo.
|
|
41
|
+
await ablo.records.update({ id, data: { status: 'done' } });
|
|
42
42
|
} catch (error) {
|
|
43
43
|
if (error instanceof CapabilityError) {
|
|
44
44
|
console.error('missing grant', error.requiredCapability);
|
|
@@ -53,7 +53,7 @@ typed grant:
|
|
|
53
53
|
```ts
|
|
54
54
|
const session = await control.sessions.create({
|
|
55
55
|
agent: { id: agentId },
|
|
56
|
-
can: {
|
|
56
|
+
can: { records: ['read', 'update'] },
|
|
57
57
|
syncGroups: [syncGroup('workspace', workspaceId)],
|
|
58
58
|
});
|
|
59
59
|
```
|
|
@@ -106,7 +106,7 @@ export const schema = defineSchema(
|
|
|
106
106
|
),
|
|
107
107
|
// A child: it has no group of its own; it inherits its workspace's group via the
|
|
108
108
|
// `parent` edge. A write to a document reaches everyone viewing the workspace.
|
|
109
|
-
|
|
109
|
+
records: model(
|
|
110
110
|
{ workspaceId: z.string() },
|
|
111
111
|
{ relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } },
|
|
112
112
|
),
|
|
@@ -158,7 +158,7 @@ things:
|
|
|
158
158
|
- **Membership groups:** named after *who you are*: `org:{id}`, `team:{id}`,
|
|
159
159
|
`user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
|
|
160
160
|
standing and durable — they don't change as you work.
|
|
161
|
-
- **Entity groups:** named after *a thing*: `
|
|
161
|
+
- **Entity groups:** named after *a thing*: `archive:{id}`, `workspace:{id}`,
|
|
162
162
|
`document:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
|
|
163
163
|
They're granular — one per record — and any participant can be pointed at a
|
|
164
164
|
specific set of them.
|
|
@@ -171,7 +171,7 @@ they are, so you declare them once in the schema.
|
|
|
171
171
|
| | Subscribed by | Declared where | Gets |
|
|
172
172
|
| --- | --- | --- | --- |
|
|
173
173
|
| **Human** | *who they are*: membership | **the schema** (`identityRoles`): a rule, written once | every `org` / `team` / `user` group their identity implies: their whole standing world |
|
|
174
|
-
| **Agent** | *what it's been given*: entities | **code, at the spawn site**: chosen per run | a handful of entity groups: the
|
|
174
|
+
| **Agent** | *what it's been given*: entities | **code, at the spawn site**: chosen per run | a handful of entity groups: the archive it's in, the documents it has read: never beyond what its user's membership could reach |
|
|
175
175
|
|
|
176
176
|
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
177
177
|
> they've been given.
|
|
@@ -210,25 +210,24 @@ The default is simple: your schema lives in a **project**, you push it once, and
|
|
|
210
210
|
every session you mint resolves against it. Your end-users **don't have Ablo
|
|
211
211
|
accounts** — your server's `sk_` mints an `ek_` per user, and by default that
|
|
212
212
|
session lands in your project's own org. All your users share one schema, one
|
|
213
|
-
data tenant,
|
|
214
|
-
|
|
213
|
+
data tenant, and receive targeted realtime changes through sync-groups. Model
|
|
214
|
+
`policy` declarations govern which rows they may read; sync-groups are delivery
|
|
215
|
+
routing, not read authorization. That's the whole story for most apps.
|
|
215
216
|
|
|
216
217
|
**Add-on — org-per-customer isolation.** If you need each customer to be its own
|
|
217
218
|
hard tenant (separate row-level isolation, optionally a separate database) you'd
|
|
218
|
-
otherwise have to re-push your schema into every customer's org. Instead, keep
|
|
219
|
-
project as the home of your schema
|
|
220
|
-
|
|
219
|
+
otherwise have to re-push your schema into every customer's org. Instead, keep
|
|
220
|
+
one project as the home of your schema. A cross-organization mint automatically
|
|
221
|
+
uses the owning key's project for the session's *schema* while its *data*
|
|
222
|
+
stays in the customer's org:
|
|
221
223
|
|
|
222
224
|
```ts
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
projectId: schemaProjectId,
|
|
230
|
-
},
|
|
231
|
-
operations: ['task.read', 'task.update'],
|
|
225
|
+
const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
226
|
+
|
|
227
|
+
await server.sessions.create({
|
|
228
|
+
user: { id: userId },
|
|
229
|
+
organizationId, // DATA → this customer's RLS-isolated organization
|
|
230
|
+
can: { records: ['read', 'update'] },
|
|
232
231
|
ttlSeconds: 3600,
|
|
233
232
|
});
|
|
234
233
|
```
|
|
@@ -236,15 +235,23 @@ await mintUserSessionKey({
|
|
|
236
235
|
Server-side, the model **shape** loads from your schema project but column
|
|
237
236
|
enrichment and the tenant connection still target `organizationId` — so the
|
|
238
237
|
shared schema only *describes* the shape; the data plane stays the customer's and
|
|
239
|
-
can't cross-leak.
|
|
240
|
-
|
|
238
|
+
can't cross-leak. `schemaProject: { organizationId, projectId }` remains
|
|
239
|
+
available as an explicit override for migrations or advanced routing. Omit
|
|
240
|
+
`organizationId` for the single-organization default above. Requires a dedicated
|
|
241
|
+
`sk_` with `ephemeral:mint-any-org`; see
|
|
242
|
+
[Customer Organizations](./customer-organizations.md).
|
|
241
243
|
|
|
242
244
|
## The two halves of scoping
|
|
243
245
|
|
|
244
|
-
|
|
246
|
+
Delivery scoping is two declarations that meet in the middle. One describes the
|
|
245
247
|
**participant** (what may I subscribe to?), the other describes each **row**
|
|
246
|
-
(which group does this row belong to?). A participant
|
|
247
|
-
row's sync
|
|
248
|
+
(which group does this row belong to?). A participant receives a row's realtime
|
|
249
|
+
changes when the row's sync groups intersect the participant's allowed set.
|
|
250
|
+
|
|
251
|
+
That intersection does not itself authorize an HTTP read. A model's `policy`
|
|
252
|
+
governs read access. Treat sync-groups as change routing and `policy` (plus the
|
|
253
|
+
organization boundary beneath it) as authorization; declaring one never
|
|
254
|
+
silently creates the other.
|
|
248
255
|
|
|
249
256
|
### Half 1 (`identityRoles`): identity → allowed groups
|
|
250
257
|
|
|
@@ -296,7 +303,7 @@ declarations, in order of how often you reach for them:
|
|
|
296
303
|
**`groups.root` — this model is a scope root.** Its rows form a group of their
|
|
297
304
|
own. The kind comes from the model's `typename` by default, or pass a string to
|
|
298
305
|
set it explicitly (use the string form when the wire kind differs from the
|
|
299
|
-
typename, e.g. typename `
|
|
306
|
+
typename, e.g. typename `EntryCollection` but group `workspace:<id>`):
|
|
300
307
|
|
|
301
308
|
```ts
|
|
302
309
|
workspaces: model({ title: z.string() }, { groups: { root: 'workspace' } });
|
|
@@ -311,24 +318,24 @@ viewing the root. A *reference* (a provenance/template pointer, not ownership)
|
|
|
311
318
|
must **not** be marked `parent`, or the row would leak into an unrelated scope:
|
|
312
319
|
|
|
313
320
|
```ts
|
|
314
|
-
|
|
315
|
-
{ workspaceId: z.string(),
|
|
321
|
+
records: model(
|
|
322
|
+
{ workspaceId: z.string(), sourceEntryId: z.string().optional() },
|
|
316
323
|
{
|
|
317
324
|
// default policy: row-local organization_id
|
|
318
325
|
relations: {
|
|
319
326
|
workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }), // ownership → inherit workspace:<id>
|
|
320
|
-
|
|
327
|
+
sourceEntry: relation.belongsTo('documents', 'sourceEntryId'), // reference → NOT routed
|
|
321
328
|
},
|
|
322
329
|
},
|
|
323
330
|
);
|
|
324
331
|
```
|
|
325
332
|
|
|
326
333
|
> **Declare the parent edge — don't infer it.** Optionality is not a proxy for
|
|
327
|
-
> ownership: many `parent` FKs are optional (a root folder, an inbox
|
|
334
|
+
> ownership: many `parent` FKs are optional (a root folder, an inbox record), and
|
|
328
335
|
> some required FKs are mere references. Containment is a fact only you know, so
|
|
329
336
|
> it's declared, exactly as it is in OpenFGA/Zanzibar.
|
|
330
337
|
|
|
331
|
-
**`groups.grants` — a membership edge.** On a join model (e.g. `
|
|
338
|
+
**`groups.grants` — a membership edge.** On a join model (e.g. `archiveMember`),
|
|
332
339
|
it says "this row grants a *subject* access to a *scope root*." Both are relation
|
|
333
340
|
names on the model. The server resolves it at connect time — for user `U`, it
|
|
334
341
|
finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
|
|
@@ -336,12 +343,12 @@ set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
|
|
|
336
343
|
org membership is already covered by the `org:` identity role.
|
|
337
344
|
|
|
338
345
|
```ts
|
|
339
|
-
|
|
340
|
-
{ userId: z.string(),
|
|
346
|
+
archiveMember: model(
|
|
347
|
+
{ userId: z.string(), archiveId: z.string() },
|
|
341
348
|
{
|
|
342
349
|
relations: {
|
|
343
350
|
member: relation.belongsTo('users', 'userId'),
|
|
344
|
-
room: relation.belongsTo('
|
|
351
|
+
room: relation.belongsTo('archives', 'archiveId'),
|
|
345
352
|
},
|
|
346
353
|
groups: { grants: { subject: 'member', scope: 'room' } },
|
|
347
354
|
},
|
|
@@ -485,7 +492,7 @@ an entity anchor on the models an agent operates on:
|
|
|
485
492
|
|
|
486
493
|
```ts
|
|
487
494
|
// each scope-root model an agent edits forms a per-entity group
|
|
488
|
-
|
|
495
|
+
records: model({ /* … */ }, { groups: { root: 'document' } }),
|
|
489
496
|
workspaces: model({ /* … */ }, { groups: { root: 'workspace' } }),
|
|
490
497
|
```
|
|
491
498
|
|
|
@@ -499,7 +506,7 @@ subset of what its user could see:
|
|
|
499
506
|
const session = await server.sessions.create({
|
|
500
507
|
agent: { id: agentId },
|
|
501
508
|
can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
|
|
502
|
-
syncGroups: [syncGroup('document',
|
|
509
|
+
syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
|
|
503
510
|
});
|
|
504
511
|
// identity (the ceiling) is inherited from the triggering user via your
|
|
505
512
|
// session-mint logic; the agent runtime connects with the minted token.
|
|
@@ -526,7 +533,7 @@ than needing a separate agent permission system:
|
|
|
526
533
|
- **Inherit the user, and no more:** the OAuth
|
|
527
534
|
[on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
|
|
528
535
|
agent's reach is tied to the consenting user, never the org.
|
|
529
|
-
- **Least privilege, just-in-time:** scoped to the
|
|
536
|
+
- **Least privilege, just-in-time:** scoped to the record's entities, not standing
|
|
530
537
|
org-wide access (the over-privilege pattern
|
|
531
538
|
[OWASP's NHI Top 10](https://www.token.security/assets/the-ultimate-non-human-identity-security-guide)
|
|
532
539
|
flags as the dominant agent risk).
|
|
@@ -557,8 +564,8 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
557
564
|
agent: { id: agentId },
|
|
558
565
|
can: { Workspace: ['read', 'update'], Document: ['read'] },
|
|
559
566
|
syncGroups: [
|
|
560
|
-
syncGroup('workspace',
|
|
561
|
-
syncGroup('workspace',
|
|
567
|
+
syncGroup('workspace', collectionA),
|
|
568
|
+
syncGroup('workspace', collectionB),
|
|
562
569
|
syncGroup('document', docId),
|
|
563
570
|
],
|
|
564
571
|
});
|
package/docs/index.md
CHANGED
|
@@ -155,6 +155,7 @@ default caller, not a special one.
|
|
|
155
155
|
- [Projects](./projects.md) — one organization, many apps; each with its own schema, planes, and keys.
|
|
156
156
|
- [API Keys](./api-keys.md) — the credential that carries an agent's identity and its scopes.
|
|
157
157
|
- [Sessions](./sessions.md) — short-lived scoped credentials your backend mints.
|
|
158
|
+
- [Customer Organizations](./customer-organizations.md) — serve many isolated customer organizations from one schema and backend.
|
|
158
159
|
- [Audit Log](./audit.md) — trace any confirmed write back to the person behind it.
|
|
159
160
|
- [Operating on Your Database](./operating-on-your-database.md) — which actions run freely, which to verify first, and which belong to a human.
|
|
160
161
|
- [Session Settings](./session-settings.md) — point your row-level-security policies at Ablo's writes, by naming the settings they already read.
|
|
@@ -163,7 +164,7 @@ default caller, not a special one.
|
|
|
163
164
|
|
|
164
165
|
- [Quickstart](./quickstart.md) — make your first coordinated write.
|
|
165
166
|
- [Integration Guide](./integration-guide.md) — the canonical end-to-end integration.
|
|
166
|
-
- [Integrations](./integrations.md) — long-running
|
|
167
|
+
- [Integrations](./integrations.md) — long-running records, ingestion, and other application-edge runtimes.
|
|
167
168
|
- [CLI & Migrations](./cli.md) — `init` / `connect` / `push` / `migrate` / `generate`.
|
|
168
169
|
- [Connect Your Database](./data-sources.md) — where rows land when your own database is canonical.
|
|
169
170
|
- [Deployment](./deployment.md) — the database, the keys, and the schema push that take an integration to production.
|
|
@@ -215,7 +215,7 @@ export async function POST() {
|
|
|
215
215
|
const session = await auth(); // your own auth — returns the signed-in user
|
|
216
216
|
const { token, expiresAt } = await sync.sessions.create({
|
|
217
217
|
user: { id: session.userId },
|
|
218
|
-
can: {
|
|
218
|
+
can: { records: ['read', 'update'] },
|
|
219
219
|
});
|
|
220
220
|
return Response.json(
|
|
221
221
|
credentialEndpointSuccessSchema.parse({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Inngest for long-running
|
|
1
|
+
# Inngest for long-running records
|
|
2
2
|
|
|
3
|
-
> Run event-driven, retryable agent
|
|
3
|
+
> Run event-driven, retryable agent records with Inngest while Ablo makes each
|
|
4
4
|
> shared-state effect typed, idempotent, and authoritative.
|
|
5
5
|
|
|
6
6
|
Inngest and Ablo solve different parts of a durable agent system:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Temporal for long-running
|
|
1
|
+
# Temporal for long-running records
|
|
2
2
|
|
|
3
|
-
> Run long-lived, retryable agent
|
|
3
|
+
> Run long-lived, retryable agent records with Temporal while Ablo makes each
|
|
4
4
|
> shared-state effect typed, idempotent, and authoritative.
|
|
5
5
|
|
|
6
6
|
Temporal and Ablo solve different parts of a durable agent system:
|
|
@@ -10,7 +10,7 @@ Temporal and Ablo solve different parts of a durable agent system:
|
|
|
10
10
|
| Workflow history, replay, timers, retries, cancellation | Temporal |
|
|
11
11
|
| Provider, messages, tools, approval, model loop | AI SDK |
|
|
12
12
|
| Typed reads and writes, idempotency, claims, confirmation | Ablo |
|
|
13
|
-
| Workflow names,
|
|
13
|
+
| Workflow names, record queues, retry policy, business behavior | Your application |
|
|
14
14
|
|
|
15
15
|
The short version is:
|
|
16
16
|
|
package/docs/integrations.md
CHANGED
|
@@ -13,8 +13,8 @@ into authoritative application state.
|
|
|
13
13
|
|
|
14
14
|
| Category | Integration | Status | Use it for |
|
|
15
15
|
|---|---|---|---|
|
|
16
|
-
| Long-running
|
|
17
|
-
| Long-running
|
|
16
|
+
| Long-running records | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
|
|
17
|
+
| Long-running records | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
|
|
18
18
|
| Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority |
|
|
19
19
|
|
|
20
20
|
An integration gets its own guide when there is runnable application code and
|
|
@@ -22,7 +22,7 @@ the boundary has been tested. A dedicated package comes later still: only
|
|
|
22
22
|
repeated production integrations that reveal substantial reusable behavior
|
|
23
23
|
justify adding another public runtime dependency.
|
|
24
24
|
|
|
25
|
-
## Long-running
|
|
25
|
+
## Long-running records
|
|
26
26
|
|
|
27
27
|
Use [Temporal](./integrations/temporal.md) when work must survive process
|
|
28
28
|
failure, retry Activities, wait on timers, or preserve a durable model loop.
|
package/docs/react.md
CHANGED
|
@@ -201,9 +201,9 @@ write interest in it.
|
|
|
201
201
|
|
|
202
202
|
import { useJoin } from '@abloatai/ablo/react';
|
|
203
203
|
|
|
204
|
-
export function
|
|
204
|
+
export function CollectionPresence({ workspaceId }: { workspaceId: string }) {
|
|
205
205
|
const { peers, claims, status } = useJoin({
|
|
206
|
-
scope: {
|
|
206
|
+
scope: { entryCollections: workspaceId },
|
|
207
207
|
claim: true, // I intend to write — pin the scope + let peers observe the claim
|
|
208
208
|
hydrate: true, // backfill the workspace's current rows if not already loaded
|
|
209
209
|
});
|
|
@@ -217,7 +217,7 @@ Options (`UseJoinOptions`):
|
|
|
217
217
|
|
|
218
218
|
| Option | Default | Effect |
|
|
219
219
|
| --- | --- | --- |
|
|
220
|
-
| `scope` |: | Model-form scope (`{
|
|
220
|
+
| `scope` |: | Model-form scope (`{ entryCollections: id }`), resolved through the schema. Omit for engine-wide. |
|
|
221
221
|
| `claim` | `false` | Acquire a write-claim on the scope (sent so peers observe it; pins the scope so it never warm-drops while held). A viewer is not a claimant: leave `false` for read-only. |
|
|
222
222
|
| `hydrate` | `false` | Backfill the scope's current rows into the pool once on enter, then keep them fresh via the live tail. Set `true` for deep-linked / never-opened entities. Single-flight; soft-fails. |
|
|
223
223
|
| `ttlSeconds` |: | Lease TTL for the scope claim. |
|
|
@@ -241,7 +241,7 @@ connection is subscribed to.
|
|
|
241
241
|
import { usePeers } from '@abloatai/ablo/react';
|
|
242
242
|
|
|
243
243
|
export function CursorBroadcaster({ workspaceId }: { workspaceId: string }) {
|
|
244
|
-
const peers = usePeers({
|
|
244
|
+
const peers = usePeers({ entryCollections: workspaceId });
|
|
245
245
|
const alone = !peers.some((p) => p.participantKind === 'user');
|
|
246
246
|
// suppress live-cursor broadcasts while alone
|
|
247
247
|
}
|