@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
package/AGENTS.md
CHANGED
|
@@ -90,7 +90,7 @@ Claims live on a callable namespace beside `create` / `update` / `get`. Every me
|
|
|
90
90
|
Keep admission behavior together for anything beyond the default wait:
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
|
-
const claim = await ablo.
|
|
93
|
+
const claim = await ablo.records.claim({
|
|
94
94
|
id,
|
|
95
95
|
contention: {
|
|
96
96
|
mode: 'skip', // use 'wait' with maxDepth / timeoutMs when waiting is useful
|
package/CHANGELOG.md
CHANGED
|
@@ -1,23 +1,217 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
### A collection read says where the collection ends
|
|
6
|
+
|
|
7
|
+
`list` returns a page. The result is still an array, so it maps, spreads, and
|
|
8
|
+
iterates exactly as before, and it now carries `hasMore` and `nextCursor` beside
|
|
9
|
+
the rows. Pass `nextCursor` back as `cursor`, keeping `where` and `orderBy` the
|
|
10
|
+
same, to walk the rest:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
let cursor: string | null = null;
|
|
14
|
+
const open = [];
|
|
15
|
+
do {
|
|
16
|
+
const page = await ablo.weatherReports.list({
|
|
17
|
+
where: { status: ['draft', 'review'] },
|
|
18
|
+
orderBy: { createdAt: 'asc' },
|
|
19
|
+
limit: 100,
|
|
20
|
+
...(cursor ? { cursor } : {}),
|
|
21
|
+
});
|
|
22
|
+
open.push(...page);
|
|
23
|
+
cursor = page.hasMore ? page.nextCursor : null;
|
|
24
|
+
} while (cursor);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A list read has always been a page: the server applies a default size and caps
|
|
28
|
+
the largest one. Until now that page state was dropped on arrival, so a read
|
|
29
|
+
that returned twenty of five hundred matching rows looked exactly like a
|
|
30
|
+
complete one. Check `hasMore` before treating a result as the whole set.
|
|
31
|
+
|
|
32
|
+
The live client keeps a local graph and loads a working set rather than pages, so
|
|
33
|
+
it rejects `cursor` instead of returning the first page again. Narrow the
|
|
34
|
+
`where`, or construct the client with `transport: 'http'` to page. On the live
|
|
35
|
+
client `hasMore` reports whether a `limit` cut the working set short, and
|
|
36
|
+
`nextCursor` is `null`.
|
|
37
|
+
|
|
38
|
+
`GET /v1/projects` returns the same list envelope as every other collection,
|
|
39
|
+
with `has_more` and `next_cursor` beside `data`.
|
|
40
|
+
|
|
41
|
+
### The page cursor is called `cursor`
|
|
42
|
+
|
|
43
|
+
The parameter that resumes a collection is `cursor`, in the SDK and on every
|
|
44
|
+
HTTP collection route. It was `starting_after`, a spelling whose established
|
|
45
|
+
meaning elsewhere is a row id, while this value has always been an opaque token
|
|
46
|
+
tied to the sort it was issued for. A caller who read the familiar name and
|
|
47
|
+
passed a row id was refused, so the name promised something it never did.
|
|
48
|
+
|
|
49
|
+
`starting_after` is still accepted on the wire and is removed in a later
|
|
50
|
+
release. Requests that send it keep working; new code should send `cursor`.
|
|
51
|
+
Sending both uses `cursor`. The MCP `list_records` tool and the OpenAPI
|
|
52
|
+
description take `cursor`, and the spec marks the old name deprecated.
|
|
53
|
+
|
|
54
|
+
### A filter reaches the server intact
|
|
55
|
+
|
|
56
|
+
`where` accepts operators as well as equality. An array value is an `IN`, and
|
|
57
|
+
tuple form spells the rest out:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const storms = await ablo.weatherReports.list({
|
|
61
|
+
where: [
|
|
62
|
+
['title', 'ILIKE', '%storm%'],
|
|
63
|
+
['createdAt', '>=', cutoff],
|
|
64
|
+
['status', 'IN', ['draft', 'review']],
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Clauses combine with AND. For OR, run two reads and union the results.
|
|
70
|
+
|
|
71
|
+
On the stateless client (`transport: 'http'`) an `IN` filter and every
|
|
72
|
+
tuple-form clause were previously discarded before the request left, and the
|
|
73
|
+
read came back unfiltered. An agent or worker that filtered a collection over
|
|
74
|
+
HTTP was reading more rows than it asked for, with nothing to indicate it. Every
|
|
75
|
+
transport now encodes a filter the same way.
|
|
76
|
+
|
|
77
|
+
A filter on a boolean field could also match the opposite rows rather than fail,
|
|
78
|
+
when its value arrived as the database's own text spelling. Boolean values are
|
|
79
|
+
coerced before binding, and read back the same way.
|
|
80
|
+
|
|
81
|
+
### A number field reads back as a number
|
|
82
|
+
|
|
83
|
+
A field declared as a number arrives as one whatever integer width its column
|
|
84
|
+
uses. A wide column previously came back as a decimal string while its narrower
|
|
85
|
+
neighbour came back as a number, so the type a caller received depended on a
|
|
86
|
+
database detail the schema had already settled.
|
|
87
|
+
|
|
88
|
+
A stored value beyond the range a JavaScript number represents exactly now fails
|
|
89
|
+
with `column_value_out_of_range` rather than arriving quietly rounded. Declare
|
|
90
|
+
such a field as text to read those values digit for digit.
|
|
91
|
+
|
|
92
|
+
### A reconnect cannot roll back a confirmed write
|
|
93
|
+
|
|
94
|
+
Each row in the live client records the log position it reflects. A bootstrap or
|
|
95
|
+
an on-demand read from an earlier position is left unapplied, so a snapshot that
|
|
96
|
+
arrives late no longer overwrites a row the client already knows to be newer.
|
|
97
|
+
The ordered change stream continues to carry every other writer's edits. A
|
|
98
|
+
plugin receives that position as `syncId` on `AppliedChange`.
|
|
99
|
+
|
|
100
|
+
### The base URL is checked where the credential travels
|
|
101
|
+
|
|
102
|
+
`baseURL` accepts an HTTPS origin, preserving a path prefix for a deployment
|
|
103
|
+
mounted under one, and plain HTTP for localhost. A URL that embeds its own
|
|
104
|
+
credentials, or carries a query or a fragment, is refused when the client is
|
|
105
|
+
constructed rather than failing later as an opaque request error. Every request
|
|
106
|
+
attaches the resolved key against this origin, so the rule lives beside the
|
|
107
|
+
option rather than in each application that sets it.
|
|
108
|
+
|
|
109
|
+
`normalizeAbloHostedBaseUrl` is now `normalizeAbloBaseUrl`. The old name
|
|
110
|
+
resolves to the same function and is removed in 0.54.0.
|
|
111
|
+
|
|
112
|
+
### Two error codes added
|
|
113
|
+
|
|
114
|
+
`organization_disabled` is returned when an operator has disabled an
|
|
115
|
+
organization, and `query_relation_expansion_too_large` when a requested relation
|
|
116
|
+
expansion exceeds the nested-row budget. The error contract version is
|
|
117
|
+
`2026-08-15`.
|
|
118
|
+
|
|
119
|
+
### CLI
|
|
120
|
+
|
|
121
|
+
Where a command sends a management key is resolved and checked in one place: an
|
|
122
|
+
explicit `--url` on the commands that take one, then `ABLO_API_URL`, then the
|
|
123
|
+
hosted default. A host given without a scheme becomes absolute, and a
|
|
124
|
+
destination that would put the key on the wire in clear, or one carrying its own
|
|
125
|
+
credentials, is refused before the request is made.
|
|
126
|
+
|
|
127
|
+
## 0.52.0
|
|
128
|
+
|
|
129
|
+
### Models carry only `id`
|
|
130
|
+
|
|
131
|
+
`createdAt`, `updatedAt`, `organizationId`, and `createdBy` are no longer added
|
|
132
|
+
to every model. Declare them as ordinary fields wherever you want them, and
|
|
133
|
+
declare them to keep reading and writing them if you relied on Ablo supplying
|
|
134
|
+
them. Ablo still records who made each change in its own transaction log, and
|
|
135
|
+
still owns the tenancy value on every write.
|
|
136
|
+
|
|
137
|
+
A model can point at a table Ablo did not create, naming the columns that
|
|
138
|
+
differ:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { defineSchema, field, model } from '@abloatai/ablo/schema';
|
|
142
|
+
|
|
143
|
+
export const schema = defineSchema({
|
|
144
|
+
itemEvents: model(
|
|
145
|
+
{
|
|
146
|
+
itemId: field.string().from('item_id'),
|
|
147
|
+
createdAt: field.number().from('created_at'),
|
|
148
|
+
},
|
|
149
|
+
{ tableName: 'item_events' }
|
|
150
|
+
),
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Database adapters accept identifiers the database generates and return them as
|
|
155
|
+
canonical string ids, taking the id type from the connection rather than from
|
|
156
|
+
the model.
|
|
157
|
+
|
|
158
|
+
### Updates can carry a precondition
|
|
159
|
+
|
|
160
|
+
An update operation accepts `where`. The database changes the row only while its
|
|
161
|
+
current values still match. On a mismatch the commit fails with
|
|
162
|
+
`precondition_failed` and the whole batch declines, leaving every operation in it
|
|
163
|
+
unapplied. The Kysely source adapter supports preconditions; the Drizzle,
|
|
164
|
+
Prisma, and memory adapters report `source_adapter_misconfigured`.
|
|
165
|
+
|
|
166
|
+
### Commit receipts return the rows the database wrote
|
|
167
|
+
|
|
168
|
+
Receipts carry `operationResults`, pairing each operation's `transactionId` with
|
|
169
|
+
its outcome and the authoritative row the database transaction returned,
|
|
170
|
+
including identifiers and timestamps the database generated.
|
|
171
|
+
|
|
172
|
+
### Two error codes renamed
|
|
173
|
+
|
|
174
|
+
`task_id_missing` is now `item_id_missing`, and `task_id_required` is now
|
|
175
|
+
`item_id_required`. Neither old code was ever returned by a request, so a caller
|
|
176
|
+
matching on error codes has nothing to change unless it names one directly.
|
|
177
|
+
|
|
178
|
+
### CLI
|
|
179
|
+
|
|
180
|
+
`ablo setup` reads the repository and the current Ablo target, then prints the
|
|
181
|
+
decisions, actions, blockers, and postconditions a verified setup requires. It
|
|
182
|
+
reports and leaves the project untouched.
|
|
183
|
+
|
|
184
|
+
`ablo init --plan` shows every file action before any of it happens.
|
|
185
|
+
|
|
186
|
+
`ablo telemetry` controls limited CLI usage analytics. Collection is on by
|
|
187
|
+
default and stays off in continuous integration and whenever `DO_NOT_TRACK=1` or
|
|
188
|
+
`ABLO_TELEMETRY_DISABLED=1` is set. Run `ablo telemetry status` to see the
|
|
189
|
+
current state, `ablo telemetry disable` to turn collection off, and
|
|
190
|
+
`ablo telemetry reset` to rotate the local installation identity.
|
|
191
|
+
|
|
3
192
|
## 0.51.0
|
|
4
193
|
|
|
5
|
-
###
|
|
194
|
+
### One platform schema can serve every customer organization
|
|
6
195
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
196
|
+
Platforms no longer need to copy the same schema into every customer
|
|
197
|
+
organization. When a platform key creates a session for another organization,
|
|
198
|
+
Ablo now reads the schema from the platform's project while keeping every row
|
|
199
|
+
inside the customer's organization.
|
|
11
200
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
201
|
+
```ts
|
|
202
|
+
const { token } = await ablo.sessions.create({
|
|
203
|
+
user: { id: userId },
|
|
204
|
+
organizationId: customerOrganizationId,
|
|
205
|
+
can: { records: ['read', 'update'] },
|
|
206
|
+
});
|
|
207
|
+
```
|
|
15
208
|
|
|
16
|
-
|
|
209
|
+
Most platforms need no schema option at all. Migrations and advanced routing can
|
|
210
|
+
still select one explicitly with `schemaProject`.
|
|
17
211
|
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
212
|
+
The sessions guide now draws a firm line between policy-scoped customers and
|
|
213
|
+
separate customer organizations. Sync groups decide which changes travel; they
|
|
214
|
+
do not authorize reads.
|
|
21
215
|
|
|
22
216
|
## 0.50.0
|
|
23
217
|
|
|
@@ -31,14 +225,14 @@ and carries exact Ablo rows into `ctx.reads` for the write that follows.
|
|
|
31
225
|
const ctx = await context({
|
|
32
226
|
ablo,
|
|
33
227
|
data: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
memory: loadMemories(
|
|
228
|
+
record: ablo.records.get({ id: recordId }),
|
|
229
|
+
records: ablo.records.list({ where: { recordId } }),
|
|
230
|
+
memory: loadMemories(recordId),
|
|
37
231
|
},
|
|
38
232
|
});
|
|
39
233
|
|
|
40
|
-
await ablo.
|
|
41
|
-
id:
|
|
234
|
+
await ablo.records.update({
|
|
235
|
+
id: recordId,
|
|
42
236
|
data: result,
|
|
43
237
|
reads: ctx.reads,
|
|
44
238
|
});
|
|
@@ -64,11 +258,11 @@ write lands anyway, on top of a decision that is no longer true. Pass the rows
|
|
|
64
258
|
the decision was based on:
|
|
65
259
|
|
|
66
260
|
```ts
|
|
67
|
-
const
|
|
68
|
-
await ablo.
|
|
69
|
-
id:
|
|
70
|
-
data: { status: 'done', result: `Completed: ${
|
|
71
|
-
reads: [
|
|
261
|
+
const record = await ablo.records.get({ id: recordId });
|
|
262
|
+
await ablo.records.update({
|
|
263
|
+
id: record.id,
|
|
264
|
+
data: { status: 'done', result: `Completed: ${record.title}` },
|
|
265
|
+
reads: [record],
|
|
72
266
|
});
|
|
73
267
|
```
|
|
74
268
|
|
|
@@ -89,7 +283,7 @@ turn or skips, expires on its own, and keeps itself alive with a heartbeat while
|
|
|
89
283
|
the agent works.
|
|
90
284
|
|
|
91
285
|
If an agent loses its claim during a model call, its final write is refused. Two
|
|
92
|
-
agents cannot both believe they own the same
|
|
286
|
+
agents cannot both believe they own the same record and both write, and a slow
|
|
93
287
|
agent cannot land its answer on top of whoever picked the work up after it. Ablo
|
|
94
288
|
decides who holds the claim, so an agent cannot assert one it does not have.
|
|
95
289
|
|
|
@@ -114,7 +308,7 @@ database you connected.
|
|
|
114
308
|
|
|
115
309
|
An agent holding a key can now ask what that key permits and get the answer from
|
|
116
310
|
Ablo, whether it keeps a connection open or calls over HTTP for a single turn.
|
|
117
|
-
An agent that mints a narrower key for a sub-
|
|
311
|
+
An agent that mints a narrower key for a sub-record can confirm what it handed
|
|
118
312
|
over.
|
|
119
313
|
|
|
120
314
|
### A refused action says which permission was missing
|
|
@@ -307,7 +501,7 @@ New clients can pin their intended project and branch with `projectId` /
|
|
|
307
501
|
`ABLO_PROJECT_ID` and `branchId` / `ABLO_BRANCH_ID`. `ablo dev` writes both
|
|
308
502
|
immutable coordinates beside its branch-bound key, and `ready()` compares them
|
|
309
503
|
with the key's server-resolved target before opening the sync connection. A mail
|
|
310
|
-
deployment carrying
|
|
504
|
+
deployment carrying an unrelated key now fails with `project_scope_denied`; a
|
|
311
505
|
same-project key for the wrong environment fails with `branch_scope_denied`.
|
|
312
506
|
|
|
313
507
|
### Pre-existing rows arrive on their own
|
|
@@ -350,7 +544,7 @@ end-to-end regression pins the behavior.
|
|
|
350
544
|
The new `contention` option names what happens when the row is already held:
|
|
351
545
|
|
|
352
546
|
```ts
|
|
353
|
-
const claim = await ablo.
|
|
547
|
+
const claim = await ablo.records.claim({
|
|
354
548
|
id,
|
|
355
549
|
contention: {
|
|
356
550
|
mode: 'skip',
|
package/NOTICE
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Copyright 2025-2026 Lukas Andersson
|
|
3
3
|
|
|
4
4
|
This product includes software developed by Lukas Andersson
|
|
5
|
-
(https://
|
|
5
|
+
(https://abloatai.com).
|
|
6
6
|
|
|
7
7
|
"Ablo" is a trademark of Lukas Andersson. This license does not grant
|
|
8
8
|
permission to use the Ablo name, logo, or trademarks. Third parties
|
package/docs/agents.md
CHANGED
|
@@ -14,8 +14,8 @@ plugin — get the live plane (WebSocket: presence, optimistic, sub-100ms).
|
|
|
14
14
|
other*.**
|
|
15
15
|
|
|
16
16
|
<Note>
|
|
17
|
-
Agents transact against your **pushed schema**, same as everyone — `ablo.
|
|
18
|
-
exists because you defined a `
|
|
17
|
+
Agents transact against your **pushed schema**, same as everyone — `ablo.records`
|
|
18
|
+
exists because you defined a `record` model and ran `ablo push`. The key
|
|
19
19
|
authenticates; the [schema](/quickstart) defines what you can call.
|
|
20
20
|
</Note>
|
|
21
21
|
|
|
@@ -32,13 +32,13 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http"
|
|
|
32
32
|
|
|
33
33
|
// Reads + writes, fully typed off your schema.
|
|
34
34
|
// `get` resolves to the row, or `undefined` when none matches.
|
|
35
|
-
const open = await ablo.
|
|
35
|
+
const open = await ablo.records.list({ where: { status: "todo" } });
|
|
36
36
|
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
37
|
+
const record = await ablo.records.get({ id: open[0].id });
|
|
38
|
+
if (!record) throw new Error("record not found");
|
|
39
39
|
|
|
40
|
-
console.log(
|
|
41
|
-
await ablo.
|
|
40
|
+
console.log(record.title);
|
|
41
|
+
await ablo.records.update({ id: record.id, data: { status: "done" } });
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
It exposes `get` / `list` / `create` / `update` / `delete`, plus `commits`
|
|
@@ -61,27 +61,27 @@ import {
|
|
|
61
61
|
} from '@abloatai/ablo/ai-sdk';
|
|
62
62
|
|
|
63
63
|
const tools = {
|
|
64
|
-
getTask: readTool(ablo.
|
|
65
|
-
description: 'Read the current
|
|
66
|
-
inputSchema: z.object({
|
|
67
|
-
id: ({
|
|
64
|
+
getTask: readTool(ablo.records, {
|
|
65
|
+
description: 'Read the current record.',
|
|
66
|
+
inputSchema: z.object({ recordId: z.string() }),
|
|
67
|
+
id: ({ recordId }) => recordId,
|
|
68
68
|
}),
|
|
69
|
-
createTask: createTool(ablo.
|
|
70
|
-
description: 'Create a
|
|
69
|
+
createTask: createTool(ablo.records, {
|
|
70
|
+
description: 'Create a record.',
|
|
71
71
|
inputSchema: z.object({ requestId: z.string(), title: z.string() }),
|
|
72
72
|
id: ({ requestId }) => requestId,
|
|
73
73
|
data: ({ title }) => ({ title, status: 'todo' }),
|
|
74
74
|
}),
|
|
75
|
-
updateTask: updateTool(ablo.
|
|
76
|
-
description: 'Update a
|
|
77
|
-
inputSchema: z.object({
|
|
78
|
-
id: ({
|
|
75
|
+
updateTask: updateTool(ablo.records, {
|
|
76
|
+
description: 'Update a record without overwriting concurrent work.',
|
|
77
|
+
inputSchema: z.object({ recordId: z.string(), status: z.string() }),
|
|
78
|
+
id: ({ recordId }) => recordId,
|
|
79
79
|
apply: (_current, { status }) => ({ status }),
|
|
80
80
|
}),
|
|
81
|
-
deleteTask: deleteTool(ablo.
|
|
82
|
-
description: 'Delete a
|
|
83
|
-
inputSchema: z.object({
|
|
84
|
-
id: ({
|
|
81
|
+
deleteTask: deleteTool(ablo.records, {
|
|
82
|
+
description: 'Delete a record after taking its claim.',
|
|
83
|
+
inputSchema: z.object({ recordId: z.string() }),
|
|
84
|
+
id: ({ recordId }) => recordId,
|
|
85
85
|
// Destructive tools require AI SDK approval by default.
|
|
86
86
|
}),
|
|
87
87
|
};
|
|
@@ -102,18 +102,18 @@ clobber the same record.
|
|
|
102
102
|
|
|
103
103
|
```ts
|
|
104
104
|
// Acquire a lease, do work with the held row, release on scope exit:
|
|
105
|
-
await using claim = await ablo.
|
|
106
|
-
const
|
|
105
|
+
await using claim = await ablo.records.claim({ id: recordId });
|
|
106
|
+
const record = claim.data;
|
|
107
107
|
// …no one else can hold this row while you work…
|
|
108
|
-
await ablo.
|
|
109
|
-
id:
|
|
108
|
+
await ablo.records.update({
|
|
109
|
+
id: record.id,
|
|
110
110
|
data: { status: "in_review" },
|
|
111
111
|
claim,
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
await ablo.
|
|
115
|
-
await ablo.
|
|
116
|
-
await ablo.
|
|
114
|
+
await ablo.records.claim.state({ id: recordId }); // who holds it now (or null)
|
|
115
|
+
await ablo.records.claim.queue({ id: recordId }); // the FIFO wait-line behind the holder
|
|
116
|
+
await ablo.records.claim.reorder({ id: recordId, order: line }); // re-rank the line (privileged)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
Think of it as a queue per row — a durable, inspectable, reorderable lease
|
package/docs/api-keys.md
CHANGED
|
@@ -119,9 +119,9 @@ knobs, and you set exactly one.
|
|
|
119
119
|
|
|
120
120
|
| Mint | Call | Result |
|
|
121
121
|
|---|---|---|
|
|
122
|
-
| Human end-user session | `await server.sessions.create({ user: { id }, can: {
|
|
123
|
-
| Ready agent client | `await server.agents.create({ can: {
|
|
124
|
-
| Raw delegated agent token | `await server.sessions.create({ agent: { id }, can: {
|
|
122
|
+
| Human end-user session | `await server.sessions.create({ user: { id }, can: { records: ['read'] } })` | `ek_` (scoped to `can`) |
|
|
123
|
+
| Ready agent client | `await server.agents.create({ can: { records: ['update'] } })` | Auto-refreshing client scoped to `can` |
|
|
124
|
+
| Raw delegated agent token | `await server.sessions.create({ agent: { id }, can: { records: ['update'] } })` | `rk_` for another runtime |
|
|
125
125
|
|
|
126
126
|
The principal kind comes from *which* shape you pass — `{ user, can }` → `user`, `{ agent, can }` → `agent`.
|
|
127
127
|
|
|
@@ -217,6 +217,12 @@ restricted to exactly those grants:
|
|
|
217
217
|
- `project:manage` — list, create, and rename projects.
|
|
218
218
|
- `branch:manage` — list, create, and delete child branches and mint their
|
|
219
219
|
temporary credentials.
|
|
220
|
+
- `ephemeral:mint-any-org` — cross-organization authority to mint a short-lived
|
|
221
|
+
user session into a customer organization. It follows the Stripe Connect shape:
|
|
222
|
+
the request names the customer organization, but the resulting session is
|
|
223
|
+
still bounded by its `can` grant and expiry. A key restricted to this scope
|
|
224
|
+
cannot directly read or write customer organizations' rows, push schema, or
|
|
225
|
+
manage projects.
|
|
220
226
|
|
|
221
227
|
Both management scopes are explicit grants on `mk_` credentials. Runtime
|
|
222
228
|
`sk_`, `rk_`, `pk_`, and `ek_` credentials cannot become management
|
|
@@ -226,6 +232,27 @@ Branch binding remains an authority boundary even when a key has no granular
|
|
|
226
232
|
scope strings: a temporary child key can act only inside that child. It cannot
|
|
227
233
|
manage siblings or gain root authority.
|
|
228
234
|
|
|
235
|
+
### Cross-organization mint keys
|
|
236
|
+
|
|
237
|
+
Most applications do not need `ephemeral:mint-any-org`: their backend key mints
|
|
238
|
+
users into its own organization. A multi-organization backend needs it only
|
|
239
|
+
when each customer is a separate Ablo organization and one trusted service
|
|
240
|
+
mints sessions for all of them.
|
|
241
|
+
|
|
242
|
+
Treat that key as a dedicated minting credential:
|
|
243
|
+
|
|
244
|
+
- keep it in a server-side secret manager, never a browser or repository;
|
|
245
|
+
- grant only `ephemeral:mint-any-org`, with no data or schema scopes;
|
|
246
|
+
- mint short-lived sessions with the smallest typed `can` grant;
|
|
247
|
+
- rotate it on a schedule and revoke it immediately after suspected exposure;
|
|
248
|
+
- log the target `organizationId`, minted session id, and request id for audit.
|
|
249
|
+
|
|
250
|
+
The scope's broad name describes the cross-organization check it passes, not
|
|
251
|
+
the authority of each resulting session. The session can act only inside the
|
|
252
|
+
named customer organization and only for the models/verbs in `can`. See
|
|
253
|
+
[Customer Organizations](./customer-organizations.md) for the complete
|
|
254
|
+
integration.
|
|
255
|
+
|
|
229
256
|
## Current and legacy key spellings
|
|
230
257
|
|
|
231
258
|
New credentials use one spelling per capability class:
|
package/docs/api.md
CHANGED
|
@@ -67,7 +67,7 @@ fallback removed — nothing to await, so they return a value.
|
|
|
67
67
|
| Method | Returns | Use when |
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
|
|
70
|
-
| `list({ where })` | `Promise<T
|
|
70
|
+
| `list({ where })` | `Promise<ModelList<T>>` | You need to hydrate a collection from local store and server. |
|
|
71
71
|
| `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
|
|
72
72
|
| `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
|
|
73
73
|
| `local.count(options?)` | `number` | You want a synchronous count of local rows. |
|
|
@@ -79,6 +79,33 @@ fallback removed — nothing to await, so they return a value.
|
|
|
79
79
|
through the server. The `local` reads work off the rows a session has already
|
|
80
80
|
synced, so a cheap re-read needs no round-trip.
|
|
81
81
|
|
|
82
|
+
### Paging a collection
|
|
83
|
+
|
|
84
|
+
`list` returns a page. The result is an array, so it maps and iterates as
|
|
85
|
+
before, and it carries `hasMore` and `nextCursor` alongside the rows:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
let cursor: string | null = null;
|
|
89
|
+
const open = [];
|
|
90
|
+
do {
|
|
91
|
+
const page = await ablo.weatherReports.list({
|
|
92
|
+
where: { status: ['draft', 'review'] },
|
|
93
|
+
orderBy: { createdAt: 'asc' },
|
|
94
|
+
limit: 100,
|
|
95
|
+
...(cursor ? { cursor } : {}),
|
|
96
|
+
});
|
|
97
|
+
open.push(...page);
|
|
98
|
+
cursor = page.hasMore ? page.nextCursor : null;
|
|
99
|
+
} while (cursor);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Keep `where` and `orderBy` the same across pages: the cursor encodes the sort
|
|
103
|
+
position it was issued for, and a read that changes either starts a new walk.
|
|
104
|
+
|
|
105
|
+
`where` accepts operators as well as equality, and both travel to the server:
|
|
106
|
+
`{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest
|
|
107
|
+
out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`.
|
|
108
|
+
|
|
82
109
|
## Protected Writes
|
|
83
110
|
|
|
84
111
|
Use `snapshot` when a write should reject if the row changed mid-flight:
|
|
@@ -173,7 +200,7 @@ The SDK is a convenience wrapper over a model-scoped HTTP surface — the same
|
|
|
173
200
|
noun (`model`) and verbs as `ablo.<model>.…`. Non-JS callers (or curl) use it
|
|
174
201
|
directly. The table below shows the shape with `{model}` as a placeholder; the
|
|
175
202
|
[OpenAPI spec](./openapi.json) expands it into one **typed** path per model
|
|
176
|
-
(`/api/v1/models/
|
|
203
|
+
(`/api/v1/models/record`, `/api/v1/models/workspace`, …, generated from your schema) so each
|
|
177
204
|
endpoint documents that model's real field contract instead of a generic blob.
|
|
178
205
|
|
|
179
206
|
| SDK call | HTTP |
|
|
@@ -193,7 +220,9 @@ receipt; the typed SDK turns single-model writes into their application result
|
|
|
193
220
|
(the created or updated row, or nothing for delete). A rejected write carries an
|
|
194
221
|
error `code` (e.g. `stale_context`, `intent_conflict`) to act on.
|
|
195
222
|
`GET /api/v1/models/{model}` is cursor-paginated (`limit`, `order`, `order_by`,
|
|
196
|
-
`
|
|
223
|
+
`cursor`) and returns `{ data, has_more, next_cursor }`. The `starting_after`
|
|
224
|
+
spelling this parameter used through 0.52.0 is still honoured, and is removed in
|
|
225
|
+
a later release.
|
|
197
226
|
|
|
198
227
|
`POST /api/v1/commits` remains the path for **atomic multi-op** writes (several
|
|
199
228
|
operations across rows/models that must commit together) — the per-model routes
|
package/docs/cli.md
CHANGED
|
@@ -189,7 +189,7 @@ streams production. You never pass a project or branch. Follows by default;
|
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
191
|
npx ablo logs # last 50, then stream
|
|
192
|
-
npx ablo logs -n 100 --model
|
|
192
|
+
npx ablo logs -n 100 --model record # backfill 100, one model
|
|
193
193
|
npx ablo logs --since 15m --json # last 15m as NDJSON, then stream
|
|
194
194
|
```
|
|
195
195
|
|
|
@@ -237,7 +237,7 @@ DATABASE_URL=postgres://… npx ablo check
|
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
```text
|
|
240
|
-
✓
|
|
240
|
+
✓ records → records (id, organization_id ok)
|
|
241
241
|
✗ projects → projects
|
|
242
242
|
• missing "organization_id" — add it, or move this model behind a Data Source
|
|
243
243
|
2 models · 1 ok · 1 error
|
|
@@ -308,7 +308,7 @@ that broke and the Postgres SQLSTATE, not just "migration failed".
|
|
|
308
308
|
```txt
|
|
309
309
|
[migrate] migration plan failed {
|
|
310
310
|
code: 'migration_failed',
|
|
311
|
-
failedStatement: 'ALTER TABLE "public"."
|
|
311
|
+
failedStatement: 'ALTER TABLE "public"."records" RENAME COLUMN a TO b;',
|
|
312
312
|
failedStatementIndex: 4,
|
|
313
313
|
pgCode: '42P01',
|
|
314
314
|
durationMs: 133
|
package/docs/client-behavior.md
CHANGED
|
@@ -31,7 +31,7 @@ Common options:
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| `schema` | Required for typed model clients. |
|
|
33
33
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
34
|
-
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
34
|
+
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. An HTTPS origin, optionally with a path prefix; plain HTTP is accepted for localhost. Your key travels here, so a URL carrying its own credentials, a query, or a fragment is refused at construction. |
|
|
35
35
|
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
36
36
|
| `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
|
|
37
37
|
| `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (the `local` reads, `onChange`, `join`) are compile errors rather than runtime gaps. |
|
|
@@ -13,7 +13,7 @@ you declared and nothing else.
|
|
|
13
13
|
A plain write has no stale premise:
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
await ablo.
|
|
16
|
+
await ablo.records.update({ id, data: { status: 'done' } });
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
If no active claim conflicts with it, the write is last-write-wins. That is a
|
|
@@ -26,14 +26,14 @@ once in its `conflict` setting instead of at every call site.
|
|
|
26
26
|
Pass the exact returned rows when a write is based on values previously read:
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
|
-
const
|
|
29
|
+
const record = await ablo.records.get({ id });
|
|
30
30
|
const policy = await ablo.policies.get({ id: policyId });
|
|
31
|
-
if (!
|
|
31
|
+
if (!record || !policy) throw new Error('required input is missing');
|
|
32
32
|
|
|
33
|
-
await ablo.
|
|
34
|
-
id:
|
|
33
|
+
await ablo.records.update({
|
|
34
|
+
id: record.id,
|
|
35
35
|
data: { status: 'done' },
|
|
36
|
-
reads: [
|
|
36
|
+
reads: [record, policy],
|
|
37
37
|
});
|
|
38
38
|
```
|
|
39
39
|
|
|
@@ -83,7 +83,7 @@ See [Coordination](./coordination.md#claims) for the API.
|
|
|
83
83
|
## Cross-row and batch premises
|
|
84
84
|
|
|
85
85
|
Model writes and lower-level commits can declare rows they read even when the
|
|
86
|
-
write targets somewhere else. This protects decisions such as “update the
|
|
86
|
+
write targets somewhere else. This protects decisions such as “update the record
|
|
87
87
|
only if the deal I inspected has not changed.” A stale batch premise applies to
|
|
88
88
|
the whole batch so atomicity is preserved.
|
|
89
89
|
|