@abloatai/ablo 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +308 -0
- package/LICENSE +1 -1
- package/NOTICE +3 -3
- package/docs/agents.md +57 -0
- package/docs/api.md +65 -14
- package/docs/customer-organizations.md +132 -152
- package/docs/data-sources.md +10 -12
- package/docs/examples/nextjs.md +47 -4
- package/docs/integration-guide.md +26 -0
- package/docs/session-settings.md +9 -0
- package/examples/data-source/customer-server.ts +12 -5
- package/llms.txt +51 -0
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,313 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.57.0
|
|
4
|
+
|
|
5
|
+
### Before you upgrade: drain the endpoint outbox
|
|
6
|
+
|
|
7
|
+
This release adds a `sync_groups` column to the source outbox, and the migration
|
|
8
|
+
refuses to run while legacy rows are still sitting in it. That refusal is
|
|
9
|
+
deliberate. A legacy row has no routes recorded, and assigning it one would
|
|
10
|
+
either invent an audience or give it none, so the migration stops and tells you
|
|
11
|
+
rather than guessing.
|
|
12
|
+
|
|
13
|
+
If you run a `dataSource()` endpoint, do this on the previous release, in order:
|
|
14
|
+
|
|
15
|
+
1. Let Ablo poll until every event already in `ablo_outbox` has been consumed.
|
|
16
|
+
2. Confirm the polling cursor has advanced past the last of them.
|
|
17
|
+
3. Delete those consumed rows.
|
|
18
|
+
|
|
19
|
+
Then upgrade and run the migration. If you connect your database over
|
|
20
|
+
replication rather than an endpoint, there is no outbox and nothing to do.
|
|
21
|
+
|
|
22
|
+
### A row is authorized by the subject its schema declares
|
|
23
|
+
|
|
24
|
+
A model may now declare which field decides who a row belongs to, and that rule
|
|
25
|
+
is enforced on every path: reads, writes, claims, presence, and every storage
|
|
26
|
+
adapter, including the endpoint ones. A row is authorized exactly when the
|
|
27
|
+
request carries the sync group `${group}:${row[field]}`.
|
|
28
|
+
|
|
29
|
+
This is what 0.56.0's boundary change was heading towards. Sync groups routed
|
|
30
|
+
delivery and did not decide authorization, so a model that used them as though
|
|
31
|
+
they did was relying on something the guide told you not to rely on. A declared
|
|
32
|
+
subject is that rule made real, checked in one place and failing closed.
|
|
33
|
+
|
|
34
|
+
Two consequences worth knowing. A subject-scoped model stamps exactly one group
|
|
35
|
+
on a change, because delivery matching is OR-based and a second group would
|
|
36
|
+
widen the audience rather than narrow it. And a tombstone now reaches only the
|
|
37
|
+
row's authorized subject group, where before a delete could be announced more
|
|
38
|
+
widely than the row ever was.
|
|
39
|
+
|
|
40
|
+
A schema that routes by sync group without a matching row-access policy is
|
|
41
|
+
flagged. If the routing really is only routing, acknowledge it explicitly and
|
|
42
|
+
the flag goes quiet.
|
|
43
|
+
|
|
44
|
+
### Creating many rows is one commit
|
|
45
|
+
|
|
46
|
+
`create` takes a list as well as a single row, under the same verb:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const rows = await ablo.weatherReports.create({
|
|
50
|
+
data: [
|
|
51
|
+
{ location: 'Stockholm', summary: 'Clear' },
|
|
52
|
+
{ location: 'Oslo', summary: 'Rain' },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
They are written as one atomic commit rather than one request each, so either
|
|
58
|
+
every row lands or none does. The result comes back in the order you gave it,
|
|
59
|
+
not the order the batch settled, and carries whatever defaults the server
|
|
60
|
+
stamped. An empty list writes nothing rather than opening an empty commit.
|
|
61
|
+
|
|
62
|
+
### Reading a whole collection, in as many words
|
|
63
|
+
|
|
64
|
+
`listAll({ where, maxPages, signal })` reads a complete collection by walking the
|
|
65
|
+
same cursor `list` returns, so the common case stops being a hand-rolled loop:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const open = await ablo.weatherReports.listAll({
|
|
69
|
+
where: { status: ['draft', 'review'] },
|
|
70
|
+
maxPages: 20,
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It is bounded on purpose. `maxPages` is how you say how much you are willing to
|
|
75
|
+
read, and `signal` cancels a walk that is taking longer than the work is worth.
|
|
76
|
+
A complete read that cannot say when it will stop is how a page turns into an
|
|
77
|
+
outage.
|
|
78
|
+
|
|
79
|
+
### A claimed write carries its stale guard again
|
|
80
|
+
|
|
81
|
+
Holding a claim and then writing gave mutual exclusion but not lost-update
|
|
82
|
+
detection, on the stateless transport agents run. The claim handle carries the
|
|
83
|
+
position the row was read at, and the write defaults to rejecting on a change
|
|
84
|
+
since then. Unwrapping the handle cleared the claim before that default was
|
|
85
|
+
read, so the guard was unreachable and every model write through the public
|
|
86
|
+
surface lost it.
|
|
87
|
+
|
|
88
|
+
It read as though both protections were present: claim, read, decide, write. The
|
|
89
|
+
watermark now travels with the handle, and your own `readAt` or `onStale` still
|
|
90
|
+
win where you set them.
|
|
91
|
+
|
|
92
|
+
### A create on an id that already exists is refused
|
|
93
|
+
|
|
94
|
+
It reported success and returned a row. A caller-selected id is a claim about
|
|
95
|
+
which row this is, so a create that finds one already there is a conflict rather
|
|
96
|
+
than an update, and it now says so.
|
|
97
|
+
|
|
98
|
+
### CLI: a session route that revalidates before it mints
|
|
99
|
+
|
|
100
|
+
`ablo init` scaffolds a Next.js session route that re-checks membership at mint
|
|
101
|
+
time rather than trusting the caller, and puts secret clients behind the
|
|
102
|
+
framework's `server-only` boundary so a key cannot be imported into a component
|
|
103
|
+
that ships to a browser.
|
|
104
|
+
|
|
105
|
+
### Filtering a server read by a reference field
|
|
106
|
+
|
|
107
|
+
`list({ where: { issueId } })` matched nothing on a replicated plane. It raised
|
|
108
|
+
no error and returned a well-formed empty array, so the read looked like a
|
|
109
|
+
question with no answers rather than a filter that never ran. Filtering on
|
|
110
|
+
`id`, `title` or `body` worked, which made the failure look like a property of
|
|
111
|
+
the data instead of a property of the field name.
|
|
112
|
+
|
|
113
|
+
The cause was a key space. A row served from the log is a snapshot in the wire
|
|
114
|
+
shape, so its fields are spelled the way your schema spells them, while the
|
|
115
|
+
filter looked them up by their database column. Those two agree exactly when a
|
|
116
|
+
column is a single word, and part ways on every `issueId`, `teamId` or
|
|
117
|
+
`assigneeId`. Ordering, relation expansion and any field declared with
|
|
118
|
+
`.from()` were reading the same wrong spelling: a `related` list came back
|
|
119
|
+
empty, and a `.from()` field was simply absent from the row.
|
|
120
|
+
|
|
121
|
+
If you page a collection and filter it in your own code to work around this,
|
|
122
|
+
that code can go.
|
|
123
|
+
|
|
124
|
+
A filter naming a field the model does not declare is now refused, with the
|
|
125
|
+
same error the direct-database plane already gave it. It used to return
|
|
126
|
+
nothing, which reads as an answer.
|
|
127
|
+
|
|
128
|
+
### A list read walks its own pages
|
|
129
|
+
|
|
130
|
+
`list` returns a page, and a page of 20 looks exactly like a complete answer of 20. Every caller either checked `hasMore` or, more often, reasoned about a
|
|
131
|
+
truncated collection without knowing there was more.
|
|
132
|
+
|
|
133
|
+
Iterate the result for the page. Walk it for the collection:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
for await (const issue of await ablo.issues.list({ where: { teamId } })) {
|
|
137
|
+
…
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`hasMore` and `nextCursor` are unchanged, and taking the cursor yourself is
|
|
142
|
+
still the right thing when the pages go somewhere other than a loop.
|
|
143
|
+
|
|
144
|
+
### Clearing a field
|
|
145
|
+
|
|
146
|
+
`null` clears a field, and the types now say so. They used to accept only the
|
|
147
|
+
field's own type or `undefined`, and `undefined` means "leave this alone": it
|
|
148
|
+
is dropped from the payload, so an unassign written that way kept the old
|
|
149
|
+
assignee and reported success. The only spelling that both compiled and worked
|
|
150
|
+
was one that cast the payload, which turned off type checking for the whole
|
|
151
|
+
write.
|
|
152
|
+
|
|
153
|
+
Only a field your schema declares optional accepts `null`. A required field has
|
|
154
|
+
no empty value to move to, and the type says that too.
|
|
155
|
+
|
|
156
|
+
### A write that does not name its row is refused
|
|
157
|
+
|
|
158
|
+
`delete({ where: { id } })` reads like it should work, and `where` is what the
|
|
159
|
+
commit protocol takes one layer down. It used to spell the missing id into the
|
|
160
|
+
request as the literal text `undefined`, match no row, and return an ordinary
|
|
161
|
+
receipt. It now fails at the call, naming the model, the action, and `{ id }`.
|
|
162
|
+
|
|
163
|
+
The same guard covers `update`.
|
|
164
|
+
|
|
165
|
+
### A create honours the id you gave it
|
|
166
|
+
|
|
167
|
+
An id passed inside `data`, which the create input has always allowed, was
|
|
168
|
+
never read: the row was written under a generated id and you were handed back
|
|
169
|
+
one you had not named. Both spellings now work, and the standalone `id` wins if
|
|
170
|
+
they disagree.
|
|
171
|
+
|
|
172
|
+
### Every response says what your allowance is
|
|
173
|
+
|
|
174
|
+
The limiter knew the allowance and the refill and told you neither, so the only
|
|
175
|
+
strategy available was to retry and find the wall again.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
RateLimit-Policy: "secret";q=600;w=12
|
|
179
|
+
RateLimit: "secret";r=573;t=8
|
|
180
|
+
Retry-After: 3
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`RateLimit-Policy` is the standing allowance and is always present.
|
|
184
|
+
`RateLimit` reports what is left and when it refills, once a request is
|
|
185
|
+
attributed to a key. A 429 adds `Retry-After` in whole seconds. Pace against
|
|
186
|
+
these rather than retrying blind.
|
|
187
|
+
|
|
188
|
+
### A route says when it is going away
|
|
189
|
+
|
|
190
|
+
Every response carries `Ablo-Version`, a date stamp for the contract being
|
|
191
|
+
served, so a caller can notice the contract moved under it.
|
|
192
|
+
|
|
193
|
+
A route being withdrawn now says so on itself for at least 180 days first.
|
|
194
|
+
`Deprecation` (RFC 9745) carries when the deprecation took effect, and the route
|
|
195
|
+
keeps answering; `Sunset` (RFC 8594) carries when it stops. The same operations
|
|
196
|
+
are marked `deprecated: true` in the OpenAPI document, so a generated client
|
|
197
|
+
sees it too.
|
|
198
|
+
|
|
199
|
+
Breaking changes still arrive as a new path segment beside `/v1`, never as a
|
|
200
|
+
change to it. Additive ones land in `/v1`, so ignore what you do not recognise.
|
|
201
|
+
|
|
202
|
+
### The documentation answers a reader that is not a browser
|
|
203
|
+
|
|
204
|
+
The surfaces `llms.txt` names are routes now rather than a promise:
|
|
205
|
+
`/llms-full.txt` for the whole corpus in one fetch, `/openapi.json` for the REST
|
|
206
|
+
contract, `/developers` naming every developer surface on one page,
|
|
207
|
+
`/.well-known/mcp.json` for the MCP manifest.
|
|
208
|
+
|
|
209
|
+
Every page also answers from its own URL in Markdown. Send
|
|
210
|
+
`Accept: text/markdown`, or append `.md` where a client cannot set headers.
|
|
211
|
+
Responses carry `Vary: Accept`, a client that will take neither type gets a 406
|
|
212
|
+
listing what is available, and a path that does not exist answers a real 404
|
|
213
|
+
rather than a 200 carrying a sign-in page.
|
|
214
|
+
|
|
215
|
+
### Renamed and removed
|
|
216
|
+
|
|
217
|
+
`SourceRequestContext.requiredSyncGroups` is now `syncGroups`. Ablo populates
|
|
218
|
+
both spellings this release, so a source adapter still reading the old name gets
|
|
219
|
+
the groups rather than `undefined`, which on a routing field would read as "no
|
|
220
|
+
groups" rather than as a field that moved. The old spelling is removed in
|
|
221
|
+
0.58.0.
|
|
222
|
+
|
|
223
|
+
`DeltaPosition`, `deltaPositionSchema`, `ReadSetWatermark`, and
|
|
224
|
+
`readSetWatermarkSchema` are removed, as 0.56.0 announced. Use `LogPosition` and
|
|
225
|
+
`logPositionSchema`, which they have resolved to since then.
|
|
226
|
+
|
|
227
|
+
## 0.56.0
|
|
228
|
+
|
|
229
|
+
### Coordination reads are scoped to the customer, not the organization
|
|
230
|
+
|
|
231
|
+
Coordination has always been scoped to the organization, and through 0.51.0 that
|
|
232
|
+
was the whole boundary: a platform gave each customer its own organization, and
|
|
233
|
+
the sessions guide was explicit that sync groups decide which changes travel
|
|
234
|
+
rather than what a session may read.
|
|
235
|
+
|
|
236
|
+
This release moves that line. A platform's customers are rows in its own schema,
|
|
237
|
+
reached by the sync groups on the session, so many customers share one
|
|
238
|
+
organization and the organization is no longer the finest boundary. The delivery
|
|
239
|
+
path already applied the finer cut. The claim listing and the presence read did
|
|
240
|
+
not, so under that newer arrangement one customer could see which rows another
|
|
241
|
+
had claimed, who held them, what the work was called, and who was online. Row
|
|
242
|
+
contents were never exposed; everything around them was.
|
|
243
|
+
|
|
244
|
+
Both reads now take the same cut, from the groups each side already carries.
|
|
245
|
+
|
|
246
|
+
If you give each customer its own organization, nothing changes for you and
|
|
247
|
+
nothing was reachable across customers. If you serve many customers from one
|
|
248
|
+
organization, this closes the gap with no change on your side.
|
|
249
|
+
|
|
250
|
+
### A client converges on the head it was measured against
|
|
251
|
+
|
|
252
|
+
Catch-up measured the plane head, paged the log under the client's own scope, and
|
|
253
|
+
then set the cursor to the last row that scope happened to contain. On a plane
|
|
254
|
+
carrying traffic the client cannot see, that row sits below the head. Where the
|
|
255
|
+
scope held nothing at all, the cursor never moved.
|
|
256
|
+
|
|
257
|
+
The client half was the mirror image: it reconciled in one direction only and
|
|
258
|
+
could not adopt a head above its own. Together those left a client permanently
|
|
259
|
+
behind, and the catch-up poll turned that into standing load, taking the plane's
|
|
260
|
+
advisory lock every thirty seconds to find the same gap and serve the same
|
|
261
|
+
nothing. The head reads a global sequence, so on any deployment with more than
|
|
262
|
+
one active writer plane this was every client rather than an edge case.
|
|
263
|
+
|
|
264
|
+
The server now advances to the head it measured, and the client adopts a head
|
|
265
|
+
above its cursor when the response carries no deltas, because an empty response
|
|
266
|
+
is proof rather than a hint.
|
|
267
|
+
|
|
268
|
+
### A replicated array column arrives as an array
|
|
269
|
+
|
|
270
|
+
Every array column read through replication arrived one level too deep: `{a,b}`
|
|
271
|
+
as `[["a","b"]]`, and `{}` as `[[]]`. The driver's array parsers expect the
|
|
272
|
+
literal without its leading brace, and given the whole literal they read that
|
|
273
|
+
brace as the start of a nested array. The control plane refused such a value
|
|
274
|
+
outright; a `text[]` column in your own database would have carried it into the
|
|
275
|
+
log silently.
|
|
276
|
+
|
|
277
|
+
### `ablo doctor` separates two different failures
|
|
278
|
+
|
|
279
|
+
A plane where nothing routed at all and a plane where some changes did not have
|
|
280
|
+
different causes, so they no longer read the same:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
✗ delivery no change reached anyone (41 in the last hour)
|
|
284
|
+
→ run `ablo check`. When nothing routes, the tenancy value is usually missing for the whole plane rather than for particular rows.
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### `LogPosition` is the one name for a position in the log
|
|
288
|
+
|
|
289
|
+
`DeltaPosition`, `deltaPositionSchema`, `ReadSetWatermark`, and
|
|
290
|
+
`readSetWatermarkSchema` still resolve to it and are removed in 0.57.0. Where a
|
|
291
|
+
position needs an owner, the owner goes in the field name rather than into a
|
|
292
|
+
second type.
|
|
293
|
+
|
|
294
|
+
`ABLO_DOCS_BASE_URL` and `ABLO_SITE_BASE_URL` are exported for tools that link
|
|
295
|
+
back to the documentation.
|
|
296
|
+
|
|
297
|
+
### What an organization is, and what your customers are
|
|
298
|
+
|
|
299
|
+
The customer-organizations guide is rewritten around the distinction it kept
|
|
300
|
+
blurring. An organization is a team account: people join it with their own
|
|
301
|
+
logins, and share what it owns and is billed for. Nobody invites their customers
|
|
302
|
+
into that.
|
|
303
|
+
|
|
304
|
+
So a platform's customers are not organizations, and they are not projects
|
|
305
|
+
either, since a project is bound one to one to a database schema and an account
|
|
306
|
+
with four applications could no longer say which of the four a customer belonged
|
|
307
|
+
to. They are rows in the platform's own schema, reached by the sync groups on the
|
|
308
|
+
session: the account is ambient and derived from the key, the customer is a plain
|
|
309
|
+
row, and the session is minted against one of them.
|
|
310
|
+
|
|
3
311
|
## 0.55.0
|
|
4
312
|
|
|
5
313
|
### `ablo doctor` says whether the writes reached anyone
|
package/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright 2025-2026
|
|
189
|
+
Copyright 2025-2026 Ablo Inc.
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
package/NOTICE
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
@ablo/ablo
|
|
2
|
-
Copyright 2025-2026
|
|
2
|
+
Copyright 2025-2026 Ablo Inc.
|
|
3
3
|
|
|
4
|
-
This product includes software developed by
|
|
4
|
+
This product includes software developed by Ablo Inc.
|
|
5
5
|
(https://abloatai.com).
|
|
6
6
|
|
|
7
|
-
"Ablo" is a trademark of
|
|
7
|
+
"Ablo" is a trademark of Ablo Inc. This license does not grant
|
|
8
8
|
permission to use the Ablo name, logo, or trademarks. Third parties
|
|
9
9
|
may describe their use of or compatibility with Ablo factually (e.g.,
|
|
10
10
|
"built with @ablo/ablo") but may not use the Ablo name in a way
|
package/docs/agents.md
CHANGED
|
@@ -46,6 +46,63 @@ and `claim`. It does **not** expose stateful-only `local` reads or `onChange`
|
|
|
46
46
|
subscriptions. Those need a live connection, so with `transport: 'http'` they
|
|
47
47
|
are compile errors rather than runtime surprises.
|
|
48
48
|
|
|
49
|
+
## Managed scoped agents
|
|
50
|
+
|
|
51
|
+
When this process owns the secret client and also runs the agent, prefer
|
|
52
|
+
`agents.create`. It mints the restricted credential, returns a schema-typed
|
|
53
|
+
client, and renews that credential for a long run. `sessions.create({ agent })`
|
|
54
|
+
is the raw-token path for handing identity to another runtime.
|
|
55
|
+
|
|
56
|
+
Derive identity and groups from the run row or trusted job payload—not from
|
|
57
|
+
model output or an HTTP request body. A serverless handler normally creates and
|
|
58
|
+
disposes one child per invocation:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const run = await control.runs.get({ id: verifiedRunId });
|
|
62
|
+
if (!run) throw new Error('run not found');
|
|
63
|
+
|
|
64
|
+
const agent = await control.agents.create({
|
|
65
|
+
id: `run:${run.id}`,
|
|
66
|
+
name: 'run-worker',
|
|
67
|
+
can: { records: ['read', 'update'] },
|
|
68
|
+
syncGroups: [`workspace:${run.workspaceId}`],
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
await executeRun(agent, run);
|
|
72
|
+
} finally {
|
|
73
|
+
await agent.dispose();
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use a stable id only when one logical run is serialized; two concurrent workers
|
|
78
|
+
that share an id appear as the same participant. For independent concurrent
|
|
79
|
+
work, omit `id` and let Ablo create distinct identities.
|
|
80
|
+
|
|
81
|
+
A long-running worker may cache one managed client per stable scope, but the
|
|
82
|
+
cache owns lifecycle: evict idle clients, call `dispose()` on eviction, and
|
|
83
|
+
dispose every client during graceful shutdown. Never cache a client and later
|
|
84
|
+
reuse it for a different workspace or capability set.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const agents: Record<
|
|
88
|
+
string,
|
|
89
|
+
Awaited<ReturnType<typeof control.agents.create>> | undefined
|
|
90
|
+
> = {};
|
|
91
|
+
|
|
92
|
+
async function agentFor(run: Run) {
|
|
93
|
+
const key = `${run.workspaceId}:${run.workerSlot}`;
|
|
94
|
+
const cached = agents[key];
|
|
95
|
+
if (cached) return cached;
|
|
96
|
+
const created = await control.agents.create({
|
|
97
|
+
id: `worker:${key}`,
|
|
98
|
+
can: { records: ['read', 'update'] },
|
|
99
|
+
syncGroups: [`workspace:${run.workspaceId}`],
|
|
100
|
+
});
|
|
101
|
+
agents[key] = created;
|
|
102
|
+
return created;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
49
106
|
## AI SDK tools
|
|
50
107
|
|
|
51
108
|
Keep AI SDK in charge of the model loop and expose only the Ablo operations the
|
package/docs/api.md
CHANGED
|
@@ -53,6 +53,7 @@ Each schema model becomes a typed model on the client:
|
|
|
53
53
|
|
|
54
54
|
- `ablo.weatherReports.get({ id })` reads one row asynchronously (server read).
|
|
55
55
|
- `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
|
|
56
|
+
- `ablo.weatherReports.listAll({ where })` explicitly reads every matching page.
|
|
56
57
|
- `ablo.weatherReports.local.get(id)` reads one row synchronously from the local graph.
|
|
57
58
|
- `ablo.weatherReports.create({ data })` creates a row.
|
|
58
59
|
- `ablo.weatherReports.update({ id, data, ...options })` updates a row.
|
|
@@ -68,6 +69,7 @@ fallback removed — nothing to await, so they return a value.
|
|
|
68
69
|
|---|---|---|
|
|
69
70
|
| `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
|
|
70
71
|
| `list({ where })` | `Promise<ModelList<T>>` | You need to hydrate a collection from local store and server. |
|
|
72
|
+
| `listAll({ where, maxPages?, signal? })` | `Promise<T[]>` | You deliberately need every matching row. |
|
|
71
73
|
| `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
|
|
72
74
|
| `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
|
|
73
75
|
| `local.count(options?)` | `number` | You want a synchronous count of local rows. |
|
|
@@ -79,24 +81,57 @@ fallback removed — nothing to await, so they return a value.
|
|
|
79
81
|
through the server. The `local` reads work off the rows a session has already
|
|
80
82
|
synced, so a cheap re-read needs no round-trip.
|
|
81
83
|
|
|
82
|
-
###
|
|
84
|
+
### Reading a whole collection
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
Prefer a filtered `listAll` when the application truly needs one complete
|
|
87
|
+
array. It follows the same cursor loop as async iteration, defaults to at most
|
|
88
|
+
100 pages, and checks an abort signal between requests and rows:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const open = await ablo.weatherReports.listAll({
|
|
93
|
+
where: { status: ['draft', 'review'] },
|
|
94
|
+
orderBy: { createdAt: 'asc' },
|
|
95
|
+
maxPages: 25,
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A complete traversal can be expensive in latency, memory, and read volume.
|
|
101
|
+
Narrow it with `where`; use `list` and its cursor when a UI or worker can process
|
|
102
|
+
one page at a time.
|
|
103
|
+
|
|
104
|
+
`for await` walks the pages:
|
|
86
105
|
|
|
87
106
|
```ts
|
|
88
|
-
let cursor: string | null = null;
|
|
89
107
|
const open = [];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
for await (const report of await ablo.weatherReports.list({
|
|
109
|
+
where: { status: ['draft', 'review'] },
|
|
110
|
+
orderBy: { createdAt: 'asc' },
|
|
111
|
+
})) {
|
|
112
|
+
open.push(report);
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`list` returns a page, because the server applies a default size and caps the
|
|
117
|
+
largest. The result is an array, so it maps and iterates as before, and it
|
|
118
|
+
carries `hasMore` and `nextCursor` alongside the rows. Iterate it to work with
|
|
119
|
+
the page you were handed; `for await` it to work with the collection.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const page = await ablo.weatherReports.list({ where: { status: 'draft' } });
|
|
123
|
+
page.length; // the rows this page carries
|
|
124
|
+
page.hasMore; // whether the collection continues past them
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Take the cursor yourself when the pages go somewhere other than a loop — one
|
|
128
|
+
screenful at a time, or a job that stops and resumes:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const page = await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100 });
|
|
132
|
+
const next = page.hasMore
|
|
133
|
+
? await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100, cursor: page.nextCursor })
|
|
134
|
+
: null;
|
|
100
135
|
```
|
|
101
136
|
|
|
102
137
|
Keep `where` and `orderBy` the same across pages: the cursor encodes the sort
|
|
@@ -106,6 +141,22 @@ position it was issued for, and a read that changes either starts a new walk.
|
|
|
106
141
|
`{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest
|
|
107
142
|
out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`.
|
|
108
143
|
|
|
144
|
+
### Changing a field, and clearing one
|
|
145
|
+
|
|
146
|
+
`null` clears a field:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
await ablo.weatherReports.update({ id, data: { reviewerId: null } }); // unassigned
|
|
150
|
+
await ablo.weatherReports.update({ id, data: { reviewerId: 'usr_2' } }); // reassigned
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
An update is a patch, so a field you leave out keeps its value. That makes
|
|
154
|
+
`undefined` and "leave it alone" the same thing: `{ reviewerId: undefined }`
|
|
155
|
+
is dropped from the payload and the old reviewer stays. Reach for `null`
|
|
156
|
+
whenever a value is going away, and the type will hold you to it — only a
|
|
157
|
+
field your schema declares optional accepts one, since a required field has no
|
|
158
|
+
empty value to move to.
|
|
159
|
+
|
|
109
160
|
## Protected Writes
|
|
110
161
|
|
|
111
162
|
Use `snapshot` when a write should reject if the row changed mid-flight:
|
|
@@ -1,71 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Serving Many Customers
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> One account, one schema, and a session scoped to the customer whose data it may read.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
customer keep a hard organization boundary while all of them use the schema
|
|
8
|
-
pushed once by the owning project.
|
|
5
|
+
Serving many customers from one backend has two shapes, and the first question
|
|
6
|
+
is whether isolating them is a security boundary or a routing convenience.
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
**One Ablo organization per customer** is the hard boundary. Every row carries
|
|
9
|
+
the organization, and the engine compares it on every read and every write,
|
|
10
|
+
below your code. Choose it when one customer reading another's rows would be an
|
|
11
|
+
incident.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 `organization:act-as`.
|
|
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.
|
|
13
|
+
**One organization, customers as rows told apart by sync groups** is delivery
|
|
14
|
+
and read routing. It is declarative, it depends on every model being covered,
|
|
15
|
+
and it is not enforced on every path. Choose it when cross-customer reads are
|
|
16
|
+
tolerable or intentional, not when they are a breach.
|
|
37
17
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Create the Ablo client once in server-only code:
|
|
18
|
+
The rest of this page is the second shape. Read *Where the boundary is enforced*
|
|
19
|
+
before you rely on it.
|
|
41
20
|
|
|
42
21
|
```ts
|
|
43
|
-
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
export const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
22
|
+
// 1. src/ablo/schema.ts — your customer table is a scope root.
|
|
23
|
+
import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
|
|
24
|
+
|
|
25
|
+
export const schema = defineSchema(
|
|
26
|
+
{
|
|
27
|
+
// Its rows form the group `customer:<id>`; the kind comes from `groups.root`.
|
|
28
|
+
customers: model(
|
|
29
|
+
{ name: z.string() },
|
|
30
|
+
{ groups: { root: 'customer' } },
|
|
31
|
+
),
|
|
32
|
+
// A child inherits its customer's group through the `parent` edge.
|
|
33
|
+
decks: model(
|
|
34
|
+
{ customerId: z.string(), title: z.string() },
|
|
35
|
+
{ relations: { customer: relation.belongsTo('customers', 'customerId', { parent: true }) } },
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
identityRoles: [
|
|
40
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
41
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
);
|
|
50
45
|
```
|
|
51
46
|
|
|
52
|
-
After your application authenticates the user, resolve the customer from that
|
|
53
|
-
trusted membership and mint the session:
|
|
54
|
-
|
|
55
47
|
```ts
|
|
48
|
+
// 2. app/api/ablo-session/route.ts — mint for one customer, on your backend.
|
|
49
|
+
import { syncGroup } from '@abloatai/ablo/schema';
|
|
56
50
|
import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
|
|
57
|
-
import {
|
|
51
|
+
import { ablo } from '@/ablo/server';
|
|
58
52
|
|
|
59
53
|
export async function POST() {
|
|
60
|
-
const member = await
|
|
54
|
+
const member = await requireSignedInMember();
|
|
61
55
|
|
|
62
|
-
const session = await
|
|
56
|
+
const session = await ablo.sessions.create({
|
|
63
57
|
user: { id: member.userId },
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
projects: ['read'],
|
|
67
|
-
records: ['read', 'create', 'update'],
|
|
68
|
-
},
|
|
58
|
+
can: { customers: ['read'], decks: ['read', 'create', 'update'] },
|
|
59
|
+
syncGroups: [syncGroup('customer', member.customerId)],
|
|
69
60
|
});
|
|
70
61
|
|
|
71
62
|
return Response.json(
|
|
@@ -79,137 +70,126 @@ export async function POST() {
|
|
|
79
70
|
}
|
|
80
71
|
```
|
|
81
72
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
you just authenticated.
|
|
73
|
+
That is the whole integration. The rest of this page is why each line is where
|
|
74
|
+
it is.
|
|
85
75
|
|
|
86
|
-
##
|
|
76
|
+
## What each layer is
|
|
87
77
|
|
|
88
|
-
|
|
89
|
-
|
|
78
|
+
Four things carry a name in this arrangement, and mixing two of them up is the
|
|
79
|
+
one mistake worth spending a page to prevent.
|
|
90
80
|
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
| Layer | What it is | Where it lives |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Your account | The organization you signed up with. Colleagues join it with their own logins and share one bill. | Ablo |
|
|
84
|
+
| Your application | A project. One per app you run, bound to one schema in your database. | Ablo |
|
|
85
|
+
| Your customer | A row in your own table, with your own id on it. | Your database |
|
|
86
|
+
| One person's session | An `ek_` your backend mints, cut to one customer's group. | Minted per sign-in |
|
|
93
87
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
Your customers sit in the third row. They are not accounts, because an account
|
|
89
|
+
is something you invite colleagues into. They are not projects, because a
|
|
90
|
+
project binds to a Postgres schema and you run one application, not one per
|
|
91
|
+
customer.
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
authEndpoint: '/api/ablo-session',
|
|
101
|
-
});
|
|
93
|
+
Your `sk_` already carries your account, so a session never names it. What the
|
|
94
|
+
session adds is which customer the person in front of it may read.
|
|
102
95
|
|
|
103
|
-
|
|
104
|
-
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
105
|
-
}
|
|
106
|
-
```
|
|
96
|
+
## Where the boundary is enforced
|
|
107
97
|
|
|
108
|
-
|
|
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.
|
|
98
|
+
Two mechanisms do different jobs, and the difference is the whole of this page.
|
|
111
99
|
|
|
112
|
-
|
|
100
|
+
**Your account is the tenant boundary.** Every row Ablo stores carries your
|
|
101
|
+
organization, project, and branch, and all three are compared on every read and
|
|
102
|
+
every write, from the credential rather than the request. A client cannot reach
|
|
103
|
+
past them by asking. This is the boundary that holds unconditionally.
|
|
113
104
|
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
**Sync groups are a cut inside your account, and they are not applied
|
|
106
|
+
everywhere.** They decide which changes are delivered and which rows a
|
|
107
|
+
log-served read returns. That is routing. It is not a universal authorization
|
|
108
|
+
boundary, and the gaps are specific:
|
|
116
109
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
110
|
+
| Path | Group cut applied |
|
|
111
|
+
|---|---|
|
|
112
|
+
| Live delivery and fan-out | Yes |
|
|
113
|
+
| HTTP read on a log-served plane (a connected database) | Yes |
|
|
114
|
+
| HTTP read on a hosted or direct-query plane | **No.** Scoped by organization |
|
|
115
|
+
| Writes | **No.** The groups are recorded on the change, never checked against the row |
|
|
116
|
+
| Claim listings and presence | Yes |
|
|
117
|
+
|
|
118
|
+
So a session cut to one customer, on a hosted plane, can read another
|
|
119
|
+
customer's rows over HTTP; and on any plane it can write to them. What stops it
|
|
120
|
+
today is the organization, which both customers share under this shape.
|
|
121
|
+
|
|
122
|
+
If isolating your customers is a security requirement, give each one its own
|
|
123
|
+
Ablo organization. The stronger row-and-subject authorization that would make
|
|
124
|
+
this shape safe on every path is not in the engine yet.
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
## Naming a group
|
|
127
|
+
|
|
128
|
+
Build a group with the `syncGroup(kind, id)` helper rather than a string. The
|
|
129
|
+
kind is the one you declared in `groups.root`, and the id is your own
|
|
130
|
+
identifier for the customer.
|
|
126
131
|
|
|
127
132
|
```ts
|
|
128
|
-
|
|
129
|
-
user: { id: member.userId },
|
|
130
|
-
organizationId: member.abloOrganizationId,
|
|
131
|
-
schemaProject: {
|
|
132
|
-
organizationId: schemaOwnerOrganizationId,
|
|
133
|
-
projectId: migratingSchemaProjectId,
|
|
134
|
-
},
|
|
135
|
-
can: { records: ['read', 'update'] },
|
|
136
|
-
});
|
|
133
|
+
syncGroups: [syncGroup('customer', member.customerId)]
|
|
137
134
|
```
|
|
138
135
|
|
|
139
|
-
|
|
136
|
+
Resolve `member.customerId` from the membership you just authenticated on the
|
|
137
|
+
server. A signed-in person can put any value in a request body, and the session
|
|
138
|
+
you mint is what decides what they can read.
|
|
140
139
|
|
|
141
|
-
##
|
|
140
|
+
## When a customer should be its own organization
|
|
142
141
|
|
|
143
|
-
|
|
142
|
+
Whenever their isolation has to hold. Give each customer its own Ablo
|
|
143
|
+
organization when one of them reading or writing another's rows would be an
|
|
144
|
+
incident rather than a bug, when you cannot audit group coverage across every
|
|
145
|
+
model, or when a customer is a separate paying business that signs in to Ablo
|
|
146
|
+
itself and invites its own developers.
|
|
144
147
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
148
|
+
Your backend then names the customer's organization on the mint, which takes a
|
|
149
|
+
secret key carrying `organization:act-as`. The customer never sees Ablo; the
|
|
150
|
+
scope exists because the session leaves the organization the key belongs to.
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
immediate cutoff is required, and retire the customer's data-source access
|
|
157
|
-
through the control-plane process you use for provisioning.
|
|
152
|
+
## Onboarding a customer
|
|
158
153
|
|
|
159
|
-
|
|
154
|
+
Insert the row. There is nothing to register with Ablo, because the group is
|
|
155
|
+
derived from the row's id, and the first session minted against it is delivered
|
|
156
|
+
its data.
|
|
160
157
|
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
158
|
+
Add a project only when you add an application. `npx ablo projects create` takes
|
|
159
|
+
a management credential from `ablo login`, and one project holds one schema.
|
|
172
160
|
|
|
173
161
|
## Troubleshooting
|
|
174
162
|
|
|
175
|
-
###
|
|
176
|
-
|
|
177
|
-
The presenting credential must be a secret `sk_` with
|
|
178
|
-
`organization:act-as`. 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.
|
|
163
|
+
### A session reads nothing
|
|
182
164
|
|
|
183
|
-
|
|
165
|
+
Check the groups the session was minted with against the kind in `groups.root`.
|
|
166
|
+
A group whose kind is not declared matches nothing, which reads as an empty
|
|
167
|
+
database rather than an error.
|
|
184
168
|
|
|
185
|
-
|
|
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.
|
|
169
|
+
### A session reads another customer's rows
|
|
190
170
|
|
|
191
|
-
|
|
171
|
+
Check that the model declares a `parent` edge up to the scope root. A model with
|
|
172
|
+
no group of its own and no parent belongs to no group, so a group cut does not
|
|
173
|
+
narrow it.
|
|
192
174
|
|
|
193
|
-
|
|
194
|
-
model key spelling against the schema used to construct `customerSessions`, then
|
|
195
|
-
push that schema to the key's branch deliberately.
|
|
175
|
+
### The mint is refused
|
|
196
176
|
|
|
197
|
-
|
|
177
|
+
Naming `organizationId` reaches into a different account and takes
|
|
178
|
+
`organization:act-as`. A platform serving its own customers names groups
|
|
179
|
+
instead, and its key needs no scope at all.
|
|
198
180
|
|
|
199
|
-
|
|
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.
|
|
181
|
+
## See it yourself
|
|
202
182
|
|
|
203
|
-
|
|
183
|
+
```
|
|
184
|
+
npx ablo whoami --json
|
|
185
|
+
```
|
|
204
186
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
boundary must hold structurally across every model.
|
|
187
|
+
The `syncGroups` it reports are the cut the engine will apply. If a customer's
|
|
188
|
+
group is missing there, no read will show its rows.
|
|
208
189
|
|
|
209
190
|
## Related guides
|
|
210
191
|
|
|
211
|
-
- [
|
|
212
|
-
- [
|
|
213
|
-
- [
|
|
214
|
-
- [
|
|
215
|
-
- [Deployment](./deployment.md) — production schema and database rollout order.
|
|
192
|
+
- [Identity & Sync Groups](/identity) — how groups are declared and resolved.
|
|
193
|
+
- [Sessions](/sessions) — session lifetime, refresh, and revocation.
|
|
194
|
+
- [API Keys](/api-keys) — credential classes and scopes.
|
|
195
|
+
- [Projects](/projects) — one project per application.
|
package/docs/data-sources.md
CHANGED
|
@@ -355,13 +355,11 @@ Because Ablo checks from its own network, a database your own machine can't reac
|
|
|
355
355
|
IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every
|
|
356
356
|
item is green.
|
|
357
357
|
|
|
358
|
-
Your **app** holds only the API key
|
|
358
|
+
Your **app** holds only the API key, never a connection string:
|
|
359
359
|
|
|
360
360
|
```bash
|
|
361
|
-
# .env
|
|
361
|
+
# .env, server runtime only, never the browser
|
|
362
362
|
ABLO_API_KEY=sk_...
|
|
363
|
-
ABLO_PROJECT_ID=proj_...
|
|
364
|
-
ABLO_BRANCH_ID=br_...
|
|
365
363
|
```
|
|
366
364
|
|
|
367
365
|
```ts
|
|
@@ -371,17 +369,17 @@ import { schema } from './ablo/schema';
|
|
|
371
369
|
export const ablo = Ablo({
|
|
372
370
|
schema,
|
|
373
371
|
apiKey: process.env.ABLO_API_KEY,
|
|
374
|
-
projectId: process.env.ABLO_PROJECT_ID,
|
|
375
|
-
branchId: process.env.ABLO_BRANCH_ID,
|
|
376
372
|
});
|
|
377
373
|
```
|
|
378
374
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
375
|
+
The key names its own project and branch, so there is nothing else to configure.
|
|
376
|
+
|
|
377
|
+
If you want a process to refuse a key you did not expect, pin `projectId` or
|
|
378
|
+
`branchId` (they default to `ABLO_PROJECT_ID` and `ABLO_BRANCH_ID`). Both are
|
|
379
|
+
assertions, never routing inputs: during `ready()` Ablo asks the server what the
|
|
380
|
+
key actually targets and refuses to start when a coordinate differs. That is
|
|
381
|
+
worth setting where one deployment can be handed keys for more than one
|
|
382
|
+
environment, and worth leaving out everywhere else.
|
|
385
383
|
|
|
386
384
|
The Ablo schema describes **only your synced, collaborative models** — the rows
|
|
387
385
|
Ablo coordinates and fans out in realtime. It is _not_ your whole-database schema
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -45,6 +45,8 @@ has no credential and the engine fails to initialize with `session_expired`.
|
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
47
|
// lib/ablo.ts — server-only
|
|
48
|
+
import 'server-only';
|
|
49
|
+
|
|
48
50
|
import Ablo from '@abloatai/ablo';
|
|
49
51
|
import { schema } from './ablo.schema';
|
|
50
52
|
|
|
@@ -58,30 +60,59 @@ export const ablo = Ablo({
|
|
|
58
60
|
## Session Route
|
|
59
61
|
|
|
60
62
|
The browser can't hold `sk_`, so a backend route mints a scoped, short-lived
|
|
61
|
-
`ek_` for the signed-in user.
|
|
63
|
+
`ek_` for the signed-in user. Being signed in is not workspace authorization:
|
|
64
|
+
revalidate the active membership immediately before every mint, and derive all
|
|
65
|
+
organization, workspace, team, and group ids on the server. Never accept them
|
|
66
|
+
from the request body.
|
|
62
67
|
|
|
63
68
|
```ts
|
|
64
69
|
// app/api/ablo-session/route.ts
|
|
65
70
|
import { ablo } from '@/lib/ablo';
|
|
66
71
|
import { getCurrentUser } from '@/auth';
|
|
72
|
+
import { headers } from 'next/headers';
|
|
67
73
|
import {
|
|
68
74
|
credentialEndpointErrorSchema,
|
|
69
75
|
credentialEndpointSuccessSchema,
|
|
70
76
|
} from '@abloatai/ablo/auth';
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
const noStore = { 'Cache-Control': 'no-store' };
|
|
79
|
+
|
|
80
|
+
export async function POST(request: Request) {
|
|
81
|
+
if (!(await isSameOrigin(request))) {
|
|
82
|
+
return Response.json(
|
|
83
|
+
credentialEndpointErrorSchema.parse({
|
|
84
|
+
error: { code: 'origin_mismatch', message: 'Cross-origin mint rejected' },
|
|
85
|
+
}),
|
|
86
|
+
{ status: 403, headers: noStore },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
const user = await getCurrentUser();
|
|
74
91
|
if (!user) {
|
|
75
92
|
return Response.json(
|
|
76
93
|
credentialEndpointErrorSchema.parse({
|
|
77
94
|
error: { code: 'session_expired' },
|
|
78
95
|
}),
|
|
79
|
-
{ status: 401, headers:
|
|
96
|
+
{ status: 401, headers: noStore },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Query your membership table now—not when the login session was created.
|
|
101
|
+
// The helper reads the active workspace from server-side session state and
|
|
102
|
+
// returns null when the membership is stale or revoked.
|
|
103
|
+
const scope = await authorizeActiveWorkspace(user.id);
|
|
104
|
+
if (!scope) {
|
|
105
|
+
return Response.json(
|
|
106
|
+
credentialEndpointErrorSchema.parse({
|
|
107
|
+
error: { code: 'policy_denied', message: 'Workspace membership is stale or revoked' },
|
|
108
|
+
}),
|
|
109
|
+
{ status: 403, headers: noStore },
|
|
80
110
|
);
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
const { token, expiresAt } = await ablo.sessions.create({
|
|
84
114
|
user: { id: user.id },
|
|
115
|
+
syncGroups: scope.syncGroups,
|
|
85
116
|
can: { records: ['read', 'create', 'update'] },
|
|
86
117
|
});
|
|
87
118
|
return Response.json(
|
|
@@ -90,11 +121,23 @@ export async function POST() {
|
|
|
90
121
|
expiresAt,
|
|
91
122
|
credentialKind: 'ephemeral',
|
|
92
123
|
}),
|
|
93
|
-
{ headers:
|
|
124
|
+
{ headers: noStore },
|
|
94
125
|
);
|
|
95
126
|
}
|
|
127
|
+
|
|
128
|
+
async function isSameOrigin(request: Request): Promise<boolean> {
|
|
129
|
+
const origin = request.headers.get('origin');
|
|
130
|
+
if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site';
|
|
131
|
+
const host = (await headers()).get('host');
|
|
132
|
+
return host !== null && new URL(origin).host === host;
|
|
133
|
+
}
|
|
96
134
|
```
|
|
97
135
|
|
|
136
|
+
`authorizeActiveWorkspace` is application code: it must query the authoritative
|
|
137
|
+
membership store and return server-derived sync groups. If fifteen-minute token
|
|
138
|
+
expiry is too slow for your revocation requirements, mint a shorter
|
|
139
|
+
`ttlSeconds` and revoke active sessions when membership changes.
|
|
140
|
+
|
|
98
141
|
## Provider
|
|
99
142
|
|
|
100
143
|
The browser client points `authEndpoint` at that route and is handed to
|
|
@@ -494,6 +494,31 @@ const completeReport = tool({
|
|
|
494
494
|
|
|
495
495
|
Keep agent writes on the same schema client surface as the app.
|
|
496
496
|
|
|
497
|
+
## One command changes an Ablo model and an ORM-only table
|
|
498
|
+
|
|
499
|
+
Two independently committed calls are not one atomic command. If an Ablo write
|
|
500
|
+
lands and a following Prisma/Drizzle transaction fails—or the reverse—the
|
|
501
|
+
application must expect and repair the partial result. Calling that path
|
|
502
|
+
“coordinated” does not extend Ablo’s claims, stale-read checks, attribution, or
|
|
503
|
+
commit ordering into the ORM transaction.
|
|
504
|
+
|
|
505
|
+
The supported atomic answer is to model every invariant-bearing row in the
|
|
506
|
+
Ablo schema and submit the operations in one `commits.create` batch (the HTTP
|
|
507
|
+
equivalent is `POST /api/v1/commits`). This applies on both database paths:
|
|
508
|
+
|
|
509
|
+
- With direct logical replication, Ablo’s batch is one customer-database
|
|
510
|
+
transaction. A separate ORM transaction is still separate.
|
|
511
|
+
- With a signed Data Source endpoint, the adapter applies the Ablo batch,
|
|
512
|
+
idempotency record, and outbox entry in one customer-database transaction.
|
|
513
|
+
Unrelated ORM work outside that adapter is still separate.
|
|
514
|
+
|
|
515
|
+
There is no general transactional callback that can safely splice arbitrary
|
|
516
|
+
application SQL into the hosted direct-write path. If a table must remain
|
|
517
|
+
ORM-only, treat the command as a saga: give both steps the same durable business
|
|
518
|
+
operation id, make each step idempotent, record progress, retry unfinished
|
|
519
|
+
steps, and define compensation for a result that cannot be completed. State
|
|
520
|
+
that guarantee as eventual completion with repair—not atomicity.
|
|
521
|
+
|
|
497
522
|
## Optional Surface
|
|
498
523
|
|
|
499
524
|
| Optional piece | Why it exists |
|
|
@@ -517,6 +542,7 @@ them.
|
|
|
517
542
|
| -------------------------------------- | -------------------------------------------------------------------------------- |
|
|
518
543
|
| `get({ id })` | Async read of one row from the server (await it). |
|
|
519
544
|
| `list({ where })` | Async read of many rows from the server (await it). |
|
|
545
|
+
| `listAll({ where, maxPages?, signal? })` | Explicit bounded traversal of every matching page; filter before collecting. |
|
|
520
546
|
| `local.get(id)` | Synchronous local read of one synced row (use in render). |
|
|
521
547
|
| `local.list({ where })` | Synchronous local read of many synced rows. |
|
|
522
548
|
| `local.count({ where })` | Synchronous local count of synced rows. |
|
package/docs/session-settings.md
CHANGED
|
@@ -50,6 +50,7 @@ context, whether or not you map anything:
|
|
|
50
50
|
| `app.current_participant_id` | The participant making the write |
|
|
51
51
|
| `app.current_participant_kind` | Whether that participant is a person, an agent, or the system |
|
|
52
52
|
| `app.current_user_id` | The person on whose behalf the write is made |
|
|
53
|
+
| `app.current_subject_groups` | The subject groups the caller belongs to, as a JSON array of `group:value` strings |
|
|
53
54
|
|
|
54
55
|
If your policies read these names directly, you need no mapping at all — this
|
|
55
56
|
page is for the case where they read different ones.
|
|
@@ -62,6 +63,14 @@ from `app.current_org_id` can be useful as defense in depth, but it is not a
|
|
|
62
63
|
substitute for the tenant policy and you should never loosen RLS to make an
|
|
63
64
|
Ablo write pass.
|
|
64
65
|
|
|
66
|
+
`app.current_subject_groups` is the one a model with a `subject` rule reads. A
|
|
67
|
+
subject rule names a field and a group — `subject: { field: 'teamId', group:
|
|
68
|
+
'team' }` — and Ablo provisions a policy asking whether the array contains
|
|
69
|
+
`team:` followed by that row's value. The setting is always present, and it is
|
|
70
|
+
`[]` when the caller belongs to no group, so a policy on a pooled connection
|
|
71
|
+
reads an empty membership as an empty membership rather than inheriting what the
|
|
72
|
+
previous transaction left behind.
|
|
73
|
+
|
|
65
74
|
`app.current_user_id` is worth reading twice, because it has three states rather
|
|
66
75
|
than two. It carries a person's id when a person is behind the write. It carries
|
|
67
76
|
`*` when a backend credential is acting as the organization itself, which is the
|
|
@@ -114,10 +114,14 @@ export const handleAbloSource = dataSource({
|
|
|
114
114
|
// own transaction. The example uses a synchronous in-memory
|
|
115
115
|
// update; the surrounding `apply` helper shows where you would
|
|
116
116
|
// open `db.transaction(async (tx) => { ... })`.
|
|
117
|
-
commit({ operations, clientTxId }) {
|
|
117
|
+
commit({ operations, clientTxId, context }) {
|
|
118
|
+
// The routes an outbox event carries come from the trusted scope Ablo
|
|
119
|
+
// signed into the request, never from the row itself. Ablo adds the
|
|
120
|
+
// organization group on its side; these are the finer ones.
|
|
121
|
+
const syncGroups = context.scope?.syncGroups ?? [];
|
|
118
122
|
const rows: RecordRow[] = [];
|
|
119
123
|
for (const op of operations) {
|
|
120
|
-
const row = applyOperation(op, clientTxId);
|
|
124
|
+
const row = applyOperation(op, clientTxId, syncGroups);
|
|
121
125
|
if (row) rows.push(row);
|
|
122
126
|
}
|
|
123
127
|
return { rows };
|
|
@@ -145,6 +149,7 @@ export const handleAbloSource = dataSource({
|
|
|
145
149
|
function applyOperation(
|
|
146
150
|
op: SourceOperation,
|
|
147
151
|
clientTxId: string | undefined,
|
|
152
|
+
syncGroups: readonly string[],
|
|
148
153
|
): RecordRow | null {
|
|
149
154
|
if (op.model !== 'records') return null;
|
|
150
155
|
const id = op.id ?? `record_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -160,7 +165,7 @@ function applyOperation(
|
|
|
160
165
|
: {}),
|
|
161
166
|
};
|
|
162
167
|
recordStore.set(id, row);
|
|
163
|
-
appendOutbox({ operation: op, entityId: id, data: row, clientTxId });
|
|
168
|
+
appendOutbox({ operation: op, entityId: id, data: row, clientTxId, syncGroups });
|
|
164
169
|
return row;
|
|
165
170
|
}
|
|
166
171
|
|
|
@@ -169,7 +174,7 @@ function applyOperation(
|
|
|
169
174
|
if (!existing) return null;
|
|
170
175
|
const next: RecordRow = { ...existing, ...(op.input as Partial<RecordRow>) };
|
|
171
176
|
recordStore.set(id, next);
|
|
172
|
-
appendOutbox({ operation: op, entityId: id, data: next, clientTxId });
|
|
177
|
+
appendOutbox({ operation: op, entityId: id, data: next, clientTxId, syncGroups });
|
|
173
178
|
return next;
|
|
174
179
|
}
|
|
175
180
|
|
|
@@ -177,7 +182,7 @@ function applyOperation(
|
|
|
177
182
|
const existing = recordStore.get(id);
|
|
178
183
|
if (!existing) return null;
|
|
179
184
|
recordStore.delete(id);
|
|
180
|
-
appendOutbox({ operation: op, entityId: id, data: null, clientTxId });
|
|
185
|
+
appendOutbox({ operation: op, entityId: id, data: null, clientTxId, syncGroups });
|
|
181
186
|
return existing;
|
|
182
187
|
}
|
|
183
188
|
|
|
@@ -189,6 +194,7 @@ function appendOutbox(input: {
|
|
|
189
194
|
entityId: string;
|
|
190
195
|
data: RecordRow | null;
|
|
191
196
|
clientTxId: string | undefined;
|
|
197
|
+
syncGroups: readonly string[];
|
|
192
198
|
}): void {
|
|
193
199
|
outboxSequence += 1;
|
|
194
200
|
outbox.push(
|
|
@@ -197,6 +203,7 @@ function appendOutbox(input: {
|
|
|
197
203
|
operation: input.operation,
|
|
198
204
|
entityId: input.entityId,
|
|
199
205
|
data: input.data,
|
|
206
|
+
syncGroups: input.syncGroups,
|
|
200
207
|
...(input.clientTxId ? { clientTxId: input.clientTxId } : {}),
|
|
201
208
|
}),
|
|
202
209
|
);
|
package/llms.txt
CHANGED
|
@@ -6,6 +6,57 @@ Here is the problem it solves. Two agents reach for `report_stockholm` at once.
|
|
|
6
6
|
|
|
7
7
|
Use AI SDK for the agent loop. Use Ablo when agent reads and writes must persist, coordinate with concurrent work, and leave an audit trail.
|
|
8
8
|
|
|
9
|
+
## When to use Ablo
|
|
10
|
+
|
|
11
|
+
Reach for Ablo when MORE THAN ONE writer changes the same rows and the second one must not clobber the first. That is the whole judgement. If a single process owns the data, an ORM against your database is simpler and you should use it.
|
|
12
|
+
|
|
13
|
+
These are the jobs it is right for:
|
|
14
|
+
|
|
15
|
+
- **Two or more agents on one workload.** A planner and three workers editing the same records; a swarm assigned overlapping tickets; a retry that restarts while the first attempt is still running. Each takes `claim({ id })` before its slow step, so the second waits and is handed the row as the first left it instead of overwriting.
|
|
16
|
+
- **A read → LLM call → write gap.** The row can change during the seconds your model is thinking. Pass the `readAt` stamp from your read back into the write and a stale write is refused (`AbloStaleContextError`) rather than silently applied on top of someone else's.
|
|
17
|
+
- **A human and an agent in the same record.** A person editing in your app is just another holder of the claim. The agent queues behind them; nobody's edit is lost, and neither side needs to know the other exists.
|
|
18
|
+
- **Work that must survive the process.** Commits are durable and receipts are idempotent, so a crashed or re-scheduled agent replays its write with the same `idempotencyKey` and gets the original result instead of a duplicate.
|
|
19
|
+
- **Anything you will later have to explain.** Every accepted change lands in an append-only log with who wrote it, when, and against which read. `logs` answers "what did the agents actually do" without you having instrumented for the question in advance.
|
|
20
|
+
- **Data you are not allowed to move.** Ablo runs on the customer's own Postgres. It tails the replication stream and writes back through a scoped role; your rows never live in Ablo. If a data-residency or procurement rule forbids a vendor holding the records, this is the shape that passes.
|
|
21
|
+
|
|
22
|
+
These are the jobs it is WRONG for, and saying so saves you a wasted integration:
|
|
23
|
+
|
|
24
|
+
- **One writer, no concurrency.** Use Prisma, Drizzle, or SQL. Ablo's coordination costs a round trip and buys you nothing here.
|
|
25
|
+
- **Chat history, traces, or evals.** Append-only telemetry has no contention to resolve. Write it wherever you write logs.
|
|
26
|
+
- **A cache, a queue, or a pub/sub bus.** Ablo is a commit chokepoint over a system of record, not a message broker. Emit your events as usual and use Ablo for the state they are about.
|
|
27
|
+
- **Vector search, embeddings, or retrieval.** Different problem. Ablo coordinates the rows; your vector store indexes them.
|
|
28
|
+
- **A file, blob, or artifact store.** Coordinate the ROW that points at the object; put the bytes in object storage.
|
|
29
|
+
|
|
30
|
+
How to call it, shortest path first: install `@abloatai/ablo`, run `npx ablo init --yes --framework <nextjs|vite|remix|vanilla>`, and construct `Ablo({ schema, apiKey: process.env.ABLO_API_KEY })`. Read with `ablo.<model>.get({ id })` / `.list({ where })`, write with `ablo.<model>.update({ id, data, readAt })`, and wrap anything slow in `await using claim = await ablo.<model>.claim({ id })`. The sections below cover the rest; "Start here" is the first thing to run.
|
|
31
|
+
|
|
32
|
+
## Machine-readable surfaces
|
|
33
|
+
|
|
34
|
+
Everything below is public, needs no credential, and is served from `https://www.abloatai.com`:
|
|
35
|
+
|
|
36
|
+
- `/llms.txt` — this file.
|
|
37
|
+
- `/llms-full.txt` — the entire published documentation corpus in one fetch.
|
|
38
|
+
- `/developers` — every developer surface named on one page: the SDK, the API reference, the OpenAPI document, the MCP server, the CLI.
|
|
39
|
+
- `/openapi.json` — the REST contract as OpenAPI 3.1: every route, a stable `operationId` and description on each, typed responses, and the `ErrorEnvelope` every 4xx and 5xx decodes through. Generate a client from it when no SDK exists for your runtime.
|
|
40
|
+
- `/mcp` — the integration-helper MCP server over Streamable HTTP. POST your JSON-RPC here; a GET returns a descriptor rather than the protocol.
|
|
41
|
+
- `/.well-known/mcp.json` — that server's manifest, in the MCP registry's `server.json` format.
|
|
42
|
+
- `/api/docs/<page>` — any documentation page as plain Markdown, for a client that fetches URLs rather than speaking MCP.
|
|
43
|
+
- `/sitemap.xml` — every indexable page on the domain.
|
|
44
|
+
|
|
45
|
+
Every page on that host serves a Markdown representation from its own URL: send
|
|
46
|
+
`Accept: text/markdown` (q-values are honoured), or append `.md` to the path if
|
|
47
|
+
your client cannot set the header. Responses carry `Vary: Accept`, a client that
|
|
48
|
+
will accept neither `text/html` nor `text/markdown` gets a `406` listing what is
|
|
49
|
+
available, and a path that does not exist answers a real `404` — never a `200`
|
|
50
|
+
carrying a sign-in page.
|
|
51
|
+
|
|
52
|
+
## Versioning and deprecation
|
|
53
|
+
|
|
54
|
+
Every route lives under `/v1`, and that segment is part of the address you call. A change that would break you arrives as a new segment beside it, never as a change to this one. Additive changes do land in `/v1` — a new response field, a new optional parameter, a new error code — so ignore what you do not recognize.
|
|
55
|
+
|
|
56
|
+
Every response carries `Ablo-Version`, a date stamp for the contract being served. A route being withdrawn says so on itself for at least 180 days first: `Deprecation` (RFC 9745) carries when the deprecation took effect and the route keeps answering; `Sunset` (RFC 8594) carries when it stops. The same operations are marked `deprecated: true` in `/openapi.json`.
|
|
57
|
+
|
|
58
|
+
Responses also carry `RateLimit-Policy` (the standing allowance, e.g. `"secret";q=600;w=12`) and, once your request is attributed to a key, `RateLimit` (what is left and when it refills). A 429 adds `Retry-After` in whole seconds. Pace against these rather than retrying blind.
|
|
59
|
+
|
|
9
60
|
## Surfaces: pick by who is calling
|
|
10
61
|
|
|
11
62
|
Every surface reaches the same coordinated state. They are not interchangeable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -120,6 +120,8 @@
|
|
|
120
120
|
"lint:pricing": "tsx scripts/check-pricing-docs.mts",
|
|
121
121
|
"generate:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts",
|
|
122
122
|
"lint:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts --check",
|
|
123
|
+
"generate:discovery": "tsx --conditions=@ablo/source scripts/generate-discovery-docs.mts",
|
|
124
|
+
"lint:discovery": "tsx --conditions=@ablo/source scripts/generate-discovery-docs.mts --check",
|
|
123
125
|
"validate:openapi": "redocly lint ../../docs/ablo/public/openapi.json --extends=recommended --skip-rule=no-server-example.com",
|
|
124
126
|
"build:docs": "node scripts/build-blume-docs.mjs",
|
|
125
127
|
"lint:docs-site": "node scripts/build-blume-docs.mjs --check",
|
|
@@ -137,8 +139,8 @@
|
|
|
137
139
|
"directory": "packages/ablo"
|
|
138
140
|
},
|
|
139
141
|
"dependencies": {
|
|
140
|
-
"@abloatai/humans": "^0.
|
|
141
|
-
"@abloatai/transaction": "^0.
|
|
142
|
+
"@abloatai/humans": "^0.57.0",
|
|
143
|
+
"@abloatai/transaction": "^0.57.0",
|
|
142
144
|
"zod": "^4.4.3"
|
|
143
145
|
},
|
|
144
146
|
"peerDependencies": {
|