@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.
Files changed (42) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +220 -26
  3. package/NOTICE +1 -1
  4. package/docs/agents.md +28 -28
  5. package/docs/api-keys.md +30 -3
  6. package/docs/api.md +32 -3
  7. package/docs/cli.md +3 -3
  8. package/docs/client-behavior.md +1 -1
  9. package/docs/concurrency-convention.md +7 -7
  10. package/docs/context.md +11 -11
  11. package/docs/coordination.md +19 -19
  12. package/docs/customer-organizations.md +215 -0
  13. package/docs/data-sources.md +3 -3
  14. package/docs/debugging.md +8 -8
  15. package/docs/examples/agent-human.md +17 -17
  16. package/docs/examples/ai-sdk-tool.md +6 -6
  17. package/docs/examples/existing-python-backend.md +1 -1
  18. package/docs/examples/nextjs.md +15 -15
  19. package/docs/examples/scoped-agent.md +10 -10
  20. package/docs/examples/server-agent.md +14 -14
  21. package/docs/groups.md +12 -12
  22. package/docs/how-it-works.md +7 -7
  23. package/docs/idempotency.md +4 -4
  24. package/docs/identity.md +45 -38
  25. package/docs/index.md +2 -1
  26. package/docs/integration-guide.md +1 -1
  27. package/docs/integrations/inngest.md +2 -2
  28. package/docs/integrations/temporal.md +3 -3
  29. package/docs/integrations.md +3 -3
  30. package/docs/react.md +4 -4
  31. package/docs/sessions.md +21 -16
  32. package/docs/webhooks.md +2 -2
  33. package/examples/README.md +6 -6
  34. package/examples/agent-turn.ts +13 -13
  35. package/examples/data-source/README.md +3 -3
  36. package/examples/data-source/customer-server.ts +23 -23
  37. package/examples/data-source/run.ts +8 -8
  38. package/examples/data-source/schema.ts +2 -2
  39. package/examples/lease-outlives-the-machine.ts +66 -0
  40. package/examples/tsconfig.json +4 -10
  41. package/llms.txt +11 -0
  42. 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.tasks.claim({
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
- ### Minor Changes
194
+ ### One platform schema can serve every customer organization
6
195
 
7
- - 3a25ab4: Default cross-organization user sessions to the platform key's schema project.
8
- Customer data remains isolated in the target organization, while one pushed
9
- schema can describe every customer tenant. `sessions.create` also accepts an
10
- explicit `schemaProject` override for migrations and advanced routing.
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
- The sessions guide now distinguishes policy-scoped customers from structurally
13
- isolated customer organizations and makes clear that sync-group routing is not
14
- read authorization.
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
- ### Patch Changes
209
+ Most platforms need no schema option at all. Migrations and advanced routing can
210
+ still select one explicitly with `schemaProject`.
17
211
 
18
- - Updated dependencies [3a25ab4]
19
- - @abloatai/transaction@0.51.0
20
- - @abloatai/humans@0.51.0
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
- task: ablo.tasks.get({ id: taskId }),
35
- documents: ablo.documents.list({ where: { taskId } }),
36
- memory: loadMemories(taskId),
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.tasks.update({
41
- id: taskId,
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 task = await ablo.tasks.get({ id: taskId });
68
- await ablo.tasks.update({
69
- id: task.id,
70
- data: { status: 'done', result: `Completed: ${task.title}` },
71
- reads: [task],
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 task and both write, and a slow
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-task can confirm what it handed
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 a slides key now fails with `project_scope_denied`; a
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.tasks.claim({
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://ablo.finance).
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.tasks`
18
- exists because you defined a `task` model and ran `ablo push`. The key
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.tasks.list({ where: { status: "todo" } });
35
+ const open = await ablo.records.list({ where: { status: "todo" } });
36
36
 
37
- const task = await ablo.tasks.get({ id: open[0].id });
38
- if (!task) throw new Error("task not found");
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(task.title);
41
- await ablo.tasks.update({ id: task.id, data: { status: "done" } });
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.tasks, {
65
- description: 'Read the current task.',
66
- inputSchema: z.object({ taskId: z.string() }),
67
- id: ({ taskId }) => taskId,
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.tasks, {
70
- description: 'Create a task.',
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.tasks, {
76
- description: 'Update a task without overwriting concurrent work.',
77
- inputSchema: z.object({ taskId: z.string(), status: z.string() }),
78
- id: ({ taskId }) => taskId,
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.tasks, {
82
- description: 'Delete a task after taking its claim.',
83
- inputSchema: z.object({ taskId: z.string() }),
84
- id: ({ taskId }) => taskId,
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.tasks.claim({ id: taskId });
106
- const task = claim.data;
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.tasks.update({
109
- id: task.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.tasks.claim.state({ id: taskId }); // who holds it now (or null)
115
- await ablo.tasks.claim.queue({ id: taskId }); // the FIFO wait-line behind the holder
116
- await ablo.tasks.claim.reorder({ id: taskId, order: line }); // re-rank the line (privileged)
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: { tasks: ['read'] } })` | `ek_` (scoped to `can`) |
123
- | Ready agent client | `await server.agents.create({ can: { tasks: ['update'] } })` | Auto-refreshing client scoped to `can` |
124
- | Raw delegated agent token | `await server.sessions.create({ agent: { id }, can: { tasks: ['update'] } })` | `rk_` for another runtime |
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[]>` | You need to hydrate a collection from local store and server. |
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/task`, `/api/v1/models/workspace`, …, generated from your schema) so each
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
- `starting_after`) and returns `{ data, has_more, next_cursor }`.
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 task # backfill 100, one 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
- taskstasks (id, organization_id ok)
240
+ recordsrecords (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"."tasks" RENAME COLUMN a TO b;',
311
+ failedStatement: 'ALTER TABLE "public"."records" RENAME COLUMN a TO b;',
312
312
  failedStatementIndex: 4,
313
313
  pgCode: '42P01',
314
314
  durationMs: 133
@@ -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.tasks.update({ id, data: { status: 'done' } });
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 task = await ablo.tasks.get({ id });
29
+ const record = await ablo.records.get({ id });
30
30
  const policy = await ablo.policies.get({ id: policyId });
31
- if (!task || !policy) throw new Error('required input is missing');
31
+ if (!record || !policy) throw new Error('required input is missing');
32
32
 
33
- await ablo.tasks.update({
34
- id: task.id,
33
+ await ablo.records.update({
34
+ id: record.id,
35
35
  data: { status: 'done' },
36
- reads: [task, policy],
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 task
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