@abloatai/ablo 0.51.0 → 0.52.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 +96 -26
- package/NOTICE +1 -1
- package/docs/agents.md +28 -28
- package/docs/api-keys.md +30 -3
- package/docs/api.md +1 -1
- package/docs/cli.md +3 -3
- 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 +11 -11
- 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/package.json +3 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Customer Organizations
|
|
2
|
+
|
|
3
|
+
> Serve many customer organizations from one backend and one shared schema without weakening tenant isolation.
|
|
4
|
+
|
|
5
|
+
An application serving many customers has two independent choices: where
|
|
6
|
+
customer data is isolated and where the schema is authored. Ablo lets every
|
|
7
|
+
customer keep a hard organization boundary while all of them use the schema
|
|
8
|
+
pushed once by the owning project.
|
|
9
|
+
|
|
10
|
+
## Choose the customer boundary first
|
|
11
|
+
|
|
12
|
+
| Customer model | Isolation guarantee | Choose it when |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| One Ablo organization with customer scope roots | Whatever read boundary every model declares in `policy` | Cross-customer reads are intentional, or every model is explicitly and continuously policy-partitioned |
|
|
15
|
+
| One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must stay isolated even when a model has no customer-specific policy |
|
|
16
|
+
|
|
17
|
+
Sync-groups decide which changes are delivered. They do not, by themselves,
|
|
18
|
+
authorize HTTP reads. If you cannot audit matching policies across every model,
|
|
19
|
+
use one Ablo organization per customer.
|
|
20
|
+
|
|
21
|
+
The rest of this guide uses organization-per-customer: one trusted backend
|
|
22
|
+
holds one dedicated mint key, every customer has an `organizationId`, and each
|
|
23
|
+
user session names that customer organization.
|
|
24
|
+
|
|
25
|
+
## What you need
|
|
26
|
+
|
|
27
|
+
- An owning project containing the schema every customer uses.
|
|
28
|
+
- The schema pushed to that project's production root.
|
|
29
|
+
- A server-side `sk_` carrying only `ephemeral:mint-any-org`.
|
|
30
|
+
- A customer `organizationId` resolved from your authenticated application
|
|
31
|
+
membership, never accepted unchecked from the browser.
|
|
32
|
+
- A model-by-model `can` grant for the UI being opened.
|
|
33
|
+
|
|
34
|
+
The cross-organization key is a minting credential, not a tenant-data
|
|
35
|
+
credential. Keep it in a secret manager and expose only your own authenticated
|
|
36
|
+
session endpoint.
|
|
37
|
+
|
|
38
|
+
## Mint on your backend
|
|
39
|
+
|
|
40
|
+
Create the Ablo client once in server-only code:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import Ablo from '@abloatai/ablo';
|
|
44
|
+
import { schema } from '@/ablo/schema';
|
|
45
|
+
|
|
46
|
+
export const customerSessions = Ablo({
|
|
47
|
+
schema,
|
|
48
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
After your application authenticates the user, resolve the customer from that
|
|
53
|
+
trusted membership and mint the session:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
|
|
57
|
+
import { customerSessions } from '@/ablo/customer-sessions';
|
|
58
|
+
|
|
59
|
+
export async function POST() {
|
|
60
|
+
const member = await requireSignedInCustomerMember();
|
|
61
|
+
|
|
62
|
+
const session = await customerSessions.sessions.create({
|
|
63
|
+
user: { id: member.userId },
|
|
64
|
+
organizationId: member.abloOrganizationId,
|
|
65
|
+
can: {
|
|
66
|
+
projects: ['read'],
|
|
67
|
+
records: ['read', 'create', 'update'],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return Response.json(
|
|
72
|
+
credentialEndpointSuccessSchema.parse({
|
|
73
|
+
token: session.token,
|
|
74
|
+
expiresAt: session.expiresAt,
|
|
75
|
+
credentialKind: 'ephemeral',
|
|
76
|
+
}),
|
|
77
|
+
{ headers: { 'Cache-Control': 'no-store' } },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Do not take `organizationId` directly from request JSON. A signed-in user could
|
|
83
|
+
replace it with another customer's id. Derive it from the server-side membership
|
|
84
|
+
you just authenticated.
|
|
85
|
+
|
|
86
|
+
## Connect the browser
|
|
87
|
+
|
|
88
|
+
The browser knows only your session endpoint. It never receives the
|
|
89
|
+
cross-organization key:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
'use client';
|
|
93
|
+
|
|
94
|
+
import Ablo from '@abloatai/ablo';
|
|
95
|
+
import { AbloProvider } from '@abloatai/ablo/react';
|
|
96
|
+
import { schema } from '@/ablo/schema';
|
|
97
|
+
|
|
98
|
+
const ablo = Ablo({
|
|
99
|
+
schema,
|
|
100
|
+
authEndpoint: '/api/ablo-session',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
104
|
+
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The client re-mints before expiry. Your endpoint should return
|
|
109
|
+
`session_expired` only when the application's own login is gone; network and
|
|
110
|
+
server failures are transient and must not sign the user out.
|
|
111
|
+
|
|
112
|
+
## Schema and data stay on different axes
|
|
113
|
+
|
|
114
|
+
For a cross-organization mint, Ablo derives the schema binding from the owning
|
|
115
|
+
key automatically:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
owning key organization/project -> schema shape
|
|
119
|
+
customer organization -> rows, RLS, database, sync groups
|
|
120
|
+
session can -> allowed model operations
|
|
121
|
+
model policy -> allowed reads inside that organization
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Most applications should not pass a schema option. An explicit override exists for
|
|
125
|
+
migrations or advanced routing:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await customerSessions.sessions.create({
|
|
129
|
+
user: { id: member.userId },
|
|
130
|
+
organizationId: member.abloOrganizationId,
|
|
131
|
+
schemaProject: {
|
|
132
|
+
organizationId: schemaOwnerOrganizationId,
|
|
133
|
+
projectId: migratingSchemaProjectId,
|
|
134
|
+
},
|
|
135
|
+
can: { records: ['read', 'update'] },
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Both schema coordinates move together; customer data does not move with them.
|
|
140
|
+
|
|
141
|
+
## Customer lifecycle checklist
|
|
142
|
+
|
|
143
|
+
When onboarding a customer:
|
|
144
|
+
|
|
145
|
+
1. Provision or resolve its Ablo organization and store that immutable id on
|
|
146
|
+
your customer record.
|
|
147
|
+
2. Connect that organization's production data source if it owns a separate
|
|
148
|
+
database.
|
|
149
|
+
3. Keep the shared schema in the owning project; do not copy it into every
|
|
150
|
+
customer organization.
|
|
151
|
+
4. Mint a test user session through the same backend route production uses.
|
|
152
|
+
5. Verify one known read and write in the customer organization before enabling
|
|
153
|
+
the integration.
|
|
154
|
+
|
|
155
|
+
When offboarding, stop minting immediately, revoke active sessions when an
|
|
156
|
+
immediate cutoff is required, and retire the customer's data-source access
|
|
157
|
+
through the control-plane process you use for provisioning.
|
|
158
|
+
|
|
159
|
+
## Security checklist
|
|
160
|
+
|
|
161
|
+
- Store the cross-organization key only in the backend secret manager.
|
|
162
|
+
- Give it only `ephemeral:mint-any-org`; do not combine minting with schema or
|
|
163
|
+
data authority.
|
|
164
|
+
- Resolve `organizationId` from authenticated membership server-side.
|
|
165
|
+
- Keep `can` to the smallest model/verb set the UI needs.
|
|
166
|
+
- Use organization-per-customer when a missing model policy must not expose
|
|
167
|
+
another customer's rows.
|
|
168
|
+
- Rotate the cross-organization key on a schedule and after personnel or
|
|
169
|
+
infrastructure changes.
|
|
170
|
+
- Record target organization, user, session id, and request id without logging
|
|
171
|
+
plaintext credentials.
|
|
172
|
+
|
|
173
|
+
## Troubleshooting
|
|
174
|
+
|
|
175
|
+
### The mint is forbidden
|
|
176
|
+
|
|
177
|
+
The presenting credential must be a secret `sk_` with
|
|
178
|
+
`ephemeral:mint-any-org`. A normal project key can mint users into its own
|
|
179
|
+
organization but cannot name another one. Run `npx ablo whoami --json` in the
|
|
180
|
+
backend environment to confirm which project and branch the configured key
|
|
181
|
+
actually belongs to; the command never prints the full secret.
|
|
182
|
+
|
|
183
|
+
### The session mints but bootstrap is empty
|
|
184
|
+
|
|
185
|
+
Confirm that the cross-organization key belongs to the project where the schema
|
|
186
|
+
was pushed, and that the customer's organization has its data source/root
|
|
187
|
+
branch ready. Do not copy the schema into the customer organization. If you
|
|
188
|
+
supplied `schemaProject`, remove it unless you intentionally override the
|
|
189
|
+
owning-key default.
|
|
190
|
+
|
|
191
|
+
### The mint reports an unknown model
|
|
192
|
+
|
|
193
|
+
Every model named by `can` must exist in the active shared schema. Check the
|
|
194
|
+
model key spelling against the schema used to construct `customerSessions`, then
|
|
195
|
+
push that schema to the key's branch deliberately.
|
|
196
|
+
|
|
197
|
+
### Data appears under the wrong customer
|
|
198
|
+
|
|
199
|
+
Inspect the membership-to-`organizationId` lookup in your backend route first.
|
|
200
|
+
The schema project never selects the data tenant. The `organizationId` passed to
|
|
201
|
+
`sessions.create` does, and it must come from trusted server-side membership.
|
|
202
|
+
|
|
203
|
+
### Realtime looks isolated but an HTTP read is too broad
|
|
204
|
+
|
|
205
|
+
Sync-groups route changes; they are not a read policy. Add or correct the
|
|
206
|
+
model's `policy`, or move customers to separate Ablo organizations when the
|
|
207
|
+
boundary must hold structurally across every model.
|
|
208
|
+
|
|
209
|
+
## Related guides
|
|
210
|
+
|
|
211
|
+
- [Sessions](./sessions.md) — session lifecycle, refresh, revocation, and grants.
|
|
212
|
+
- [API Keys](./api-keys.md) — credential classes, scopes, inspection, and rotation.
|
|
213
|
+
- [Identity & Sync Groups](./identity.md) — participant delivery groups versus model read policy.
|
|
214
|
+
- [Connect Your Database](./data-sources.md) — customer-owned database setup.
|
|
215
|
+
- [Deployment](./deployment.md) — production schema and database rollout order.
|
package/docs/data-sources.md
CHANGED
|
@@ -149,8 +149,8 @@ own schema:
|
|
|
149
149
|
ABLO_API_KEY="$MAIL_KEY" DATABASE_URL="$PRODUCTION_URL" \
|
|
150
150
|
npx ablo connect apply --schema mail --yes
|
|
151
151
|
|
|
152
|
-
ABLO_API_KEY="$
|
|
153
|
-
npx ablo connect apply --schema
|
|
152
|
+
ABLO_API_KEY="$ENTRIES_KEY" DATABASE_URL="$PRODUCTION_URL" \
|
|
153
|
+
npx ablo connect apply --schema entries --yes
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
For a Neon or Supabase preview branch, use that branch's direct URL and keep the
|
|
@@ -380,7 +380,7 @@ export const ablo = Ablo({
|
|
|
380
380
|
The API key still selects the project and branch; during `ready()` Ablo asks the
|
|
381
381
|
server what the key actually targets and refuses startup when either coordinate
|
|
382
382
|
differs. `ablo dev` writes all three values together, so accidentally exporting
|
|
383
|
-
a
|
|
383
|
+
a entries key into the mail app—or a mail development key into production—fails
|
|
384
384
|
before any read, write, or subscription begins.
|
|
385
385
|
|
|
386
386
|
The Ablo schema describes **only your synced, collaborative models** — the rows
|
package/docs/debugging.md
CHANGED
|
@@ -72,12 +72,12 @@ Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then
|
|
|
72
72
|
These lines (all at `info`) let you watch the handover you built:
|
|
73
73
|
|
|
74
74
|
```
|
|
75
|
-
[Ablo] claim: requesting
|
|
76
|
-
[Ablo] claim: queued for
|
|
75
|
+
[Ablo] claim: requesting records:doc_42 for "editing" (will queue if contended)
|
|
76
|
+
[Ablo] claim: queued for records:doc_42 — position 2 of 3, waiting
|
|
77
77
|
[Ablo] claim: granted 7f3c… — your turn (waited in queue)
|
|
78
|
-
[Ablo] claim: rejected
|
|
79
|
-
[Ablo] claim: lost
|
|
80
|
-
[Ablo] claim: released
|
|
78
|
+
[Ablo] claim: rejected records:doc_42 — held by agent_writer
|
|
79
|
+
[Ablo] claim: lost records:doc_42 (preempted or expired)
|
|
80
|
+
[Ablo] claim: released records:doc_42
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
Read it as the lifecycle of one claim:
|
|
@@ -189,7 +189,7 @@ function ActivityFeed({ log }: { log: ClaimLog }) {
|
|
|
189
189
|
For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring:
|
|
190
190
|
|
|
191
191
|
```tsx
|
|
192
|
-
const holder = useAblo((ablo) => ablo.
|
|
192
|
+
const holder = useAblo((ablo) => ablo.records.claim.state({ id })); // Claim | null
|
|
193
193
|
```
|
|
194
194
|
|
|
195
195
|
See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs.
|
|
@@ -265,7 +265,7 @@ Every rejected live commit carries `requestId` on the thrown error and
|
|
|
265
265
|
import { AbloError } from '@abloatai/ablo';
|
|
266
266
|
|
|
267
267
|
try {
|
|
268
|
-
await ablo.
|
|
268
|
+
await ablo.records.create({
|
|
269
269
|
data,
|
|
270
270
|
});
|
|
271
271
|
} catch (error) {
|
|
@@ -289,7 +289,7 @@ evidence that the replication source has no history.
|
|
|
289
289
|
Use:
|
|
290
290
|
|
|
291
291
|
```ts
|
|
292
|
-
await ablo.
|
|
292
|
+
await ablo.records.list({ type: 'complete' });
|
|
293
293
|
```
|
|
294
294
|
|
|
295
295
|
`type: 'complete'` waits for a server round trip and returns the confirmed
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
> An agent that yields the row when a person is already holding it.
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A record-writing agent that yields when a person is editing the same record.
|
|
6
6
|
|
|
7
7
|
## Scenario
|
|
8
8
|
|
|
9
|
-
The same
|
|
9
|
+
The same records are edited by agents and by the people watching them. They must
|
|
10
10
|
not collide:
|
|
11
11
|
|
|
12
12
|
- If a person already holds the row, the agent yields instead of fighting for it.
|
|
13
13
|
- While the agent is updating, the UI can show who is active.
|
|
14
|
-
- If the
|
|
14
|
+
- If the record changes mid-run, the commit is rejected instead of overwriting the
|
|
15
15
|
newer edit.
|
|
16
16
|
|
|
17
17
|
A **claim** does both jobs. Claims don't lock — if another writer holds the row,
|
|
@@ -24,9 +24,9 @@ a typed error if the row moved underneath you while the agent was busy.
|
|
|
24
24
|
|
|
25
25
|
## Schema-Backed Worker
|
|
26
26
|
|
|
27
|
-
The worker uses the same schema client the app uses. It reads the
|
|
27
|
+
The worker uses the same schema client the app uses. It reads the record from the
|
|
28
28
|
server with `get({ id })`, claims the row, and writes through
|
|
29
|
-
`ablo.
|
|
29
|
+
`ablo.records.update(...)` with a stale-check so a concurrent edit can't be
|
|
30
30
|
overwritten.
|
|
31
31
|
|
|
32
32
|
```ts
|
|
@@ -34,7 +34,7 @@ import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
|
|
|
34
34
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
35
35
|
|
|
36
36
|
const schema = defineSchema({
|
|
37
|
-
|
|
37
|
+
records: model({
|
|
38
38
|
title: z.string(),
|
|
39
39
|
status: z.enum(['todo', 'doing', 'done']),
|
|
40
40
|
}),
|
|
@@ -46,19 +46,19 @@ const ablo = Ablo({
|
|
|
46
46
|
transport: 'http',
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
export async function markDone(
|
|
49
|
+
export async function markDone(recordId: string) {
|
|
50
50
|
await ablo.ready();
|
|
51
51
|
|
|
52
52
|
// get({ id }) is an async server read — await it.
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
53
|
+
const record = await ablo.records.get({ id: recordId });
|
|
54
|
+
if (!record) return { status: 'not_found' };
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
57
|
// queue: false → don't queue behind a current holder. If another
|
|
58
58
|
// participant holds the row, claim resolves null, so the agent yields
|
|
59
59
|
// instead of waiting. Omit it, or pass queue: true, to queue behind them.
|
|
60
|
-
const acquired = await ablo.
|
|
61
|
-
id:
|
|
60
|
+
const acquired = await ablo.records.claim({
|
|
61
|
+
id: recordId,
|
|
62
62
|
queue: false,
|
|
63
63
|
description: 'marking_done',
|
|
64
64
|
});
|
|
@@ -72,7 +72,7 @@ export async function markDone(taskId: string) {
|
|
|
72
72
|
// `onStale: 'reject'`. The write below is therefore equivalent to passing
|
|
73
73
|
// those options yourself:
|
|
74
74
|
//
|
|
75
|
-
// ablo.
|
|
75
|
+
// ablo.records.update({
|
|
76
76
|
// id: claim.data.id,
|
|
77
77
|
// data: { status: 'done' },
|
|
78
78
|
// readAt: <claim snapshot version>,
|
|
@@ -82,12 +82,12 @@ export async function markDone(taskId: string) {
|
|
|
82
82
|
// If a newer version landed mid-run, the row no longer matches `readAt`, so
|
|
83
83
|
// the server rejects this commit with AbloStaleContextError (caught below)
|
|
84
84
|
// instead of clobbering that edit.
|
|
85
|
-
const updated = await ablo.
|
|
85
|
+
const updated = await ablo.records.update({
|
|
86
86
|
id: claim.data.id,
|
|
87
87
|
data: { status: 'done' },
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
return { status: 'done',
|
|
90
|
+
return { status: 'done', record: updated };
|
|
91
91
|
} catch (err) {
|
|
92
92
|
// The lease was lost or a foreign holder rejected the write.
|
|
93
93
|
if (err instanceof AbloClaimedError) return { status: 'yielded' };
|
|
@@ -108,9 +108,9 @@ Keep workers on the same schema-backed client as the app.
|
|
|
108
108
|
|
|
109
109
|
import { useAblo } from '@abloatai/ablo/react';
|
|
110
110
|
|
|
111
|
-
export function
|
|
112
|
-
const data = useAblo((ablo) => ablo.
|
|
113
|
-
const holder = useAblo((ablo) => ablo.
|
|
111
|
+
export function RecordRow({ record: serverTask }: Props) {
|
|
112
|
+
const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
|
|
113
|
+
const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
|
|
114
114
|
const agentActive = holder?.participantKind === 'agent';
|
|
115
115
|
|
|
116
116
|
return (
|
|
@@ -27,7 +27,7 @@ import { z } from 'zod';
|
|
|
27
27
|
export const runtime = 'nodejs';
|
|
28
28
|
|
|
29
29
|
const schema = defineSchema({
|
|
30
|
-
|
|
30
|
+
records: model({
|
|
31
31
|
title: schemaZ.string(),
|
|
32
32
|
status: schemaZ.enum(['todo', 'doing', 'done']),
|
|
33
33
|
summary: schemaZ.string().optional(),
|
|
@@ -40,15 +40,15 @@ const ablo = Ablo({
|
|
|
40
40
|
transport: 'http',
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
const updateTask = updateTool(ablo.
|
|
44
|
-
title: 'Update
|
|
45
|
-
description: 'Update a
|
|
43
|
+
const updateTask = updateTool(ablo.records, {
|
|
44
|
+
title: 'Update record',
|
|
45
|
+
description: 'Update a record without overwriting concurrent work.',
|
|
46
46
|
inputSchema: z.object({
|
|
47
|
-
|
|
47
|
+
recordId: z.string(),
|
|
48
48
|
status: z.enum(['todo', 'doing', 'done']).optional(),
|
|
49
49
|
summary: z.string().optional(),
|
|
50
50
|
}),
|
|
51
|
-
id: ({
|
|
51
|
+
id: ({ recordId }) => recordId,
|
|
52
52
|
apply: (current, { status, summary }) => ({
|
|
53
53
|
status: status ?? current.status,
|
|
54
54
|
summary: summary ?? current.summary,
|
|
@@ -95,7 +95,7 @@ export async function POST() {
|
|
|
95
95
|
const userId = await currentUserId(); // your auth
|
|
96
96
|
const { token, expiresAt } = await ablo.sessions.create({
|
|
97
97
|
user: { id: userId },
|
|
98
|
-
can: {
|
|
98
|
+
can: { records: ['read', 'update'] },
|
|
99
99
|
});
|
|
100
100
|
return Response.json(
|
|
101
101
|
credentialEndpointSuccessSchema.parse({
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -21,11 +21,11 @@ app/
|
|
|
21
21
|
api/
|
|
22
22
|
ablo-session/
|
|
23
23
|
route.ts # mints a per-user ek_ token for the browser
|
|
24
|
-
|
|
24
|
+
records/
|
|
25
25
|
[id]/
|
|
26
26
|
page.tsx # RSC: get + render
|
|
27
27
|
actions.ts # Server Action: claim, then write
|
|
28
|
-
|
|
28
|
+
RecordEditor.tsx # Client: live updates
|
|
29
29
|
lib/
|
|
30
30
|
ablo.ts # Server Ablo client (holds ABLO_API_KEY)
|
|
31
31
|
ablo.schema.ts # shared schema
|
|
@@ -82,7 +82,7 @@ export async function POST() {
|
|
|
82
82
|
|
|
83
83
|
const { token, expiresAt } = await ablo.sessions.create({
|
|
84
84
|
user: { id: user.id },
|
|
85
|
-
can: {
|
|
85
|
+
can: { records: ['read', 'create', 'update'] },
|
|
86
86
|
});
|
|
87
87
|
return Response.json(
|
|
88
88
|
credentialEndpointSuccessSchema.parse({
|
|
@@ -137,40 +137,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
137
137
|
## RSC Initial Render
|
|
138
138
|
|
|
139
139
|
```tsx
|
|
140
|
-
// app/
|
|
140
|
+
// app/records/[id]/page.tsx
|
|
141
141
|
import { ablo } from '@/lib/ablo';
|
|
142
142
|
|
|
143
|
-
export default async function
|
|
143
|
+
export default async function RecordPage({
|
|
144
144
|
params,
|
|
145
145
|
}: { params: Promise<{ id: string }> }) {
|
|
146
146
|
const { id } = await params;
|
|
147
147
|
await ablo.ready();
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
148
|
+
const record = await ablo.records.get({ id });
|
|
149
|
+
if (!record) return null;
|
|
150
150
|
|
|
151
|
-
return <
|
|
151
|
+
return <RecordEditor record={record} />;
|
|
152
152
|
}
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
## Server Action Commit
|
|
156
156
|
|
|
157
157
|
```ts
|
|
158
|
-
// app/
|
|
158
|
+
// app/records/[id]/actions.ts
|
|
159
159
|
'use server';
|
|
160
160
|
|
|
161
161
|
import { ablo } from '@/lib/ablo';
|
|
162
162
|
|
|
163
163
|
export async function markDone(id: string) {
|
|
164
164
|
// Claim grants exclusive, ordered access and hands back the fresh row.
|
|
165
|
-
await using claim = await ablo.
|
|
165
|
+
await using claim = await ablo.records.claim({ id });
|
|
166
166
|
|
|
167
|
-
const
|
|
167
|
+
const record = await ablo.records.update({
|
|
168
168
|
id,
|
|
169
169
|
data: { status: 'done' },
|
|
170
170
|
claim,
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
return { status: 'done',
|
|
173
|
+
return { status: 'done', record };
|
|
174
174
|
// claim auto-releases as the action returns
|
|
175
175
|
}
|
|
176
176
|
```
|
|
@@ -186,9 +186,9 @@ you — re-fetch and retry.
|
|
|
186
186
|
|
|
187
187
|
import { useAblo } from '@abloatai/ablo/react';
|
|
188
188
|
|
|
189
|
-
export function
|
|
190
|
-
const data = useAblo((ablo) => ablo.
|
|
191
|
-
const holder = useAblo((ablo) => ablo.
|
|
189
|
+
export function RecordEditor({ record: serverTask }: Props) {
|
|
190
|
+
const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
|
|
191
|
+
const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
|
|
192
192
|
const busy = Boolean(holder);
|
|
193
193
|
|
|
194
194
|
return (
|
|
@@ -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
|
});
|