@abloatai/ablo 0.56.0 → 0.58.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 (82) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +428 -10
  3. package/LICENSE +1 -1
  4. package/NOTICE +3 -3
  5. package/README.md +2 -1
  6. package/dist/ai-sdk.d.ts +1 -1
  7. package/dist/ai-sdk.d.ts.map +1 -1
  8. package/dist/context/evidence.d.ts +6 -8
  9. package/dist/context/evidence.d.ts.map +1 -1
  10. package/dist/context/evidence.js +6 -20
  11. package/dist/context/evidence.js.map +1 -1
  12. package/dist/context/index.d.ts +23 -0
  13. package/dist/context/index.d.ts.map +1 -0
  14. package/dist/context/index.js +26 -0
  15. package/dist/context/index.js.map +1 -0
  16. package/dist/context/onChange.d.ts +9 -0
  17. package/dist/context/onChange.d.ts.map +1 -0
  18. package/dist/context/onChange.js +37 -0
  19. package/dist/context/onChange.js.map +1 -0
  20. package/dist/source-conformance.d.ts +1 -1
  21. package/dist/source-conformance.d.ts.map +1 -1
  22. package/dist/source-conformance.js +1 -1
  23. package/dist/source-conformance.js.map +1 -1
  24. package/dist/source-drizzle.d.ts +1 -1
  25. package/dist/source-drizzle.d.ts.map +1 -1
  26. package/dist/source-drizzle.js +1 -1
  27. package/dist/source-drizzle.js.map +1 -1
  28. package/dist/source-kysely.d.ts +1 -1
  29. package/dist/source-kysely.d.ts.map +1 -1
  30. package/dist/source-kysely.js +1 -1
  31. package/dist/source-kysely.js.map +1 -1
  32. package/dist/source-next.d.ts +1 -1
  33. package/dist/source-next.d.ts.map +1 -1
  34. package/dist/source-next.js +1 -1
  35. package/dist/source-next.js.map +1 -1
  36. package/docs/agent-integration-decision-guide.md +123 -0
  37. package/docs/agents.md +74 -13
  38. package/docs/api-keys.md +6 -6
  39. package/docs/api.md +117 -43
  40. package/docs/branch-development.md +23 -4
  41. package/docs/cli.md +16 -9
  42. package/docs/client-behavior.md +21 -15
  43. package/docs/concurrency-convention.md +67 -77
  44. package/docs/context.md +56 -31
  45. package/docs/coordination.md +115 -36
  46. package/docs/customer-organizations.md +49 -31
  47. package/docs/data-sources.md +12 -6
  48. package/docs/debugging.md +1 -1
  49. package/docs/examples/agent-human.md +6 -18
  50. package/docs/examples/coordination-conformance.md +69 -0
  51. package/docs/examples/existing-document-pipeline.md +488 -0
  52. package/docs/examples/existing-python-backend.md +10 -13
  53. package/docs/examples/nextjs.md +49 -6
  54. package/docs/examples/scoped-agent.md +18 -1
  55. package/docs/examples/server-agent.md +2 -2
  56. package/docs/groups.md +19 -139
  57. package/docs/guarantees.md +5 -6
  58. package/docs/identity.md +2 -1
  59. package/docs/index.md +5 -0
  60. package/docs/integration-guide.md +46 -19
  61. package/docs/integrations/sandbox-runtime.md +148 -0
  62. package/docs/integrations.md +9 -0
  63. package/docs/operating-on-your-database.md +7 -0
  64. package/docs/quickstart.md +19 -13
  65. package/docs/react.md +9 -9
  66. package/docs/schema-contract.md +14 -13
  67. package/docs/session-settings.md +9 -0
  68. package/docs/sessions.md +1 -1
  69. package/examples/README.md +2 -2
  70. package/examples/agent-turn.ts +1 -1
  71. package/examples/data-source/customer-server.ts +12 -5
  72. package/examples/expensive-agent-turn.ts +1 -1
  73. package/llms.txt +72 -10
  74. package/package.json +6 -6
  75. package/dist/context/sources.d.ts +0 -21
  76. package/dist/context/sources.d.ts.map +0 -1
  77. package/dist/context/sources.js +0 -36
  78. package/dist/context/sources.js.map +0 -1
  79. package/dist/context.d.ts +0 -22
  80. package/dist/context.d.ts.map +0 -1
  81. package/dist/context.js +0 -33
  82. package/dist/context.js.map +0 -1
package/docs/api.md CHANGED
@@ -12,9 +12,11 @@ confirmed write streams to everyone watching. When two writers touch the same
12
12
  row, you can optionally `claim` it so they serialize instead of clobbering
13
13
  each other.
14
14
 
15
- Two things to know before the method list. **Reads come in two flavors:**
16
- `get({ id })` / `list({ where })` are async they answer from what is
17
- already local and fall back to the server. Put `local.` in front of either and
15
+ Three things to know before the method list. **`get` observes; `read` declares.**
16
+ Both fetch one current row, but only the exact object returned by `read({ id })`
17
+ can be carried in a mutation's `reads` array. If it changed, that mutation does
18
+ not land. `get({ id })` and `list({ where })` are ordinary queries with no stale
19
+ guard. **Local reads do not fetch.** Put `local.` in front of a query and
18
20
  you get the same read restricted to what is already here, which is why it can
19
21
  return a value rather than a promise: `local.get(id)`, `local.list({ where })`,
20
22
  `local.count({ where })`. Use those in render, after data has synced.
@@ -38,10 +40,14 @@ const schema = defineSchema({
38
40
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
39
41
 
40
42
  await ablo.ready();
41
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
43
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
42
44
  if (!report) throw new Error('Row not found');
43
45
 
44
- await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
46
+ await ablo.weatherReports.update({
47
+ id: 'report_stockholm',
48
+ data: { status: 'ready' },
49
+ reads: [report],
50
+ });
45
51
  ```
46
52
 
47
53
  For end-to-end app setup across React, existing backends, Data Source, and
@@ -51,14 +57,17 @@ agents, read the [Integration Guide](./integration-guide.md).
51
57
 
52
58
  Each schema model becomes a typed model on the client:
53
59
 
54
- - `ablo.weatherReports.get({ id })` reads one row asynchronously (server read).
55
- - `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
60
+ - `ablo.weatherReports.get({ id })` fetches one row without declaring a decision dependency.
61
+ - `ablo.weatherReports.read({ id })` fetches one guardable decision input.
62
+ - `ablo.weatherReports.list({ where })` fetches an observational collection.
63
+ - `ablo.weatherReports.listAll({ where })` explicitly reads every matching page.
56
64
  - `ablo.weatherReports.local.get(id)` reads one row synchronously from the local graph.
57
65
  - `ablo.weatherReports.create({ data })` creates a row.
58
66
  - `ablo.weatherReports.update({ id, data, ...options })` updates a row.
59
67
  - `ablo.weatherReports.delete({ id, ...options })` deletes a row.
68
+ - `ablo.weatherReports.claim({ id, description })` acquires a durable write lease; the HTTP form is awaited.
60
69
 
61
- `local.` narrows a read to what has already synced. `get({ id })` and
70
+ `local.` narrows a query to what has already synced. `get({ id })`, `read({ id })`, and
62
71
  `list({ where })` answer from the local graph and fall back to IndexedDB and
63
72
  then the network, so reach for them when the row may not be here yet.
64
73
  `local.get(id)` and `local.list({ where })` are the same reads with the
@@ -66,37 +75,78 @@ fallback removed — nothing to await, so they return a value.
66
75
 
67
76
  | Method | Returns | Use when |
68
77
  |---|---|---|
69
- | `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
70
- | `list({ where })` | `Promise<ModelList<T>>` | You need to hydrate a collection from local store and server. |
78
+ | `get({ id })` | `Promise<T \| undefined>` | You need to observe one current row. |
79
+ | `read({ id })` | `Promise<CapturedRow<T> \| undefined>` | A later mutation is based on this row. |
80
+ | `list({ where })` | `Promise<ModelList<T>>` | You need to observe a collection. |
81
+ | `listAll({ where, maxPages?, signal? })` | `Promise<T[]>` | You deliberately need every matching row. |
71
82
  | `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
72
83
  | `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
73
84
  | `local.count(options?)` | `number` | You want a synchronous count of local rows. |
74
85
  | `create({ data, ...options })` | `Promise<T>` | You want to create through the schema model. |
75
86
  | `update({ id, data, ...options })` | `Promise<T>` | You want to update through the schema model. |
76
87
  | `delete({ id, ...options })` | `Promise<void>` | You want to delete through the schema model. |
77
-
78
- `get`, `list`, `create`, `update`, and `delete` are the main path they go
88
+ | `claim({ id, description })` | `Promise<HeldClaim<T>>` | Slow or expensive work must exclude another writer. |
89
+ | `claim.state({ id })` | `Promise<Claim \| null>` on HTTP | You need the current holder without acquiring the row. |
90
+ | `claim.list({ id })` | `Promise<{ object: 'list'; data: Claim[] }>` on HTTP | You need every disjoint holder on the row. |
91
+ | `claim.queue({ id })` | `Promise<ClaimQueueView>` on HTTP | You need the durable wait line. |
92
+ | `claim.release({ id })` | `Promise<void>` on HTTP | You need to release a claim early. |
93
+ | `claim.reorder({ id, order })` | `Promise<void>` on HTTP | A privileged coordinator needs to reorder the wait line. |
94
+
95
+ `get`, `read`, `list`, `create`, `update`, `delete`, and `claim` go
79
96
  through the server. The `local` reads work off the rows a session has already
80
97
  synced, so a cheap re-read needs no round-trip.
81
98
 
82
- ### Paging a collection
99
+ ### Reading a whole collection
100
+
101
+ Prefer a filtered `listAll` when the application truly needs one complete
102
+ array. It follows the same cursor loop as async iteration, defaults to at most
103
+ 100 pages, and checks an abort signal between requests and rows:
104
+
105
+ ```ts
106
+ const controller = new AbortController();
107
+ const open = await ablo.weatherReports.listAll({
108
+ where: { status: ['draft', 'review'] },
109
+ orderBy: { createdAt: 'asc' },
110
+ maxPages: 25,
111
+ signal: controller.signal,
112
+ });
113
+ ```
114
+
115
+ A complete traversal can be expensive in latency, memory, and read volume.
116
+ Narrow it with `where`; use `list` and its cursor when a UI or worker can process
117
+ one page at a time.
83
118
 
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:
119
+ `for await` walks the pages:
86
120
 
87
121
  ```ts
88
- let cursor: string | null = null;
89
122
  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);
123
+ for await (const report of await ablo.weatherReports.list({
124
+ where: { status: ['draft', 'review'] },
125
+ orderBy: { createdAt: 'asc' },
126
+ })) {
127
+ open.push(report);
128
+ }
129
+ ```
130
+
131
+ `list` returns a page, because the server applies a default size and caps the
132
+ largest. The result is an array, so it maps and iterates as before, and it
133
+ carries `hasMore` and `nextCursor` alongside the rows. Iterate it to work with
134
+ the page you were handed; `for await` it to work with the collection.
135
+
136
+ ```ts
137
+ const page = await ablo.weatherReports.list({ where: { status: 'draft' } });
138
+ page.length; // the rows this page carries
139
+ page.hasMore; // whether the collection continues past them
140
+ ```
141
+
142
+ Take the cursor yourself when the pages go somewhere other than a loop — one
143
+ screenful at a time, or a job that stops and resumes:
144
+
145
+ ```ts
146
+ const page = await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100 });
147
+ const next = page.hasMore
148
+ ? await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100, cursor: page.nextCursor })
149
+ : null;
100
150
  ```
101
151
 
102
152
  Keep `where` and `orderBy` the same across pages: the cursor encodes the sort
@@ -106,30 +156,51 @@ position it was issued for, and a read that changes either starts a new walk.
106
156
  `{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest
107
157
  out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`.
108
158
 
109
- ## Protected Writes
159
+ ### Changing a field, and clearing one
160
+
161
+ `null` clears a field:
110
162
 
111
- Use `snapshot` when a write should reject if the row changed mid-flight:
163
+ ```ts
164
+ await ablo.weatherReports.update({ id, data: { reviewerId: null } }); // unassigned
165
+ await ablo.weatherReports.update({ id, data: { reviewerId: 'usr_2' } }); // reassigned
166
+ ```
167
+
168
+ An update is a patch, so a field you leave out keeps its value. That makes
169
+ `undefined` and "leave it alone" the same thing: `{ reviewerId: undefined }`
170
+ is dropped from the payload and the old reviewer stays. Reach for `null`
171
+ whenever a value is going away, and the type will hold you to it — only a
172
+ field your schema declares optional accepts one, since a required field has no
173
+ empty value to move to.
174
+
175
+ ## Guarded Writes
176
+
177
+ Use `read` when a write depends on the row's current state, then pass that exact
178
+ row in `reads`:
112
179
 
113
180
  ```ts
114
- const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
181
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
182
+ if (!report) throw new Error('report not found');
115
183
 
116
184
  await ablo.weatherReports.update({
117
- id: 'report_stockholm',
185
+ id: report.id,
118
186
  data: { status: 'ready' },
119
- readAt: snap.stamp,
120
- onStale: 'reject',
187
+ reads: [report],
121
188
  });
122
189
  ```
123
190
 
124
191
  Reactive local state changes optimistically at call time; awaiting the model
125
192
  write waits for authoritative confirmation.
126
193
 
127
- Protected write options:
194
+ If the row changed after `read`, the write rejects with
195
+ `AbloStaleContextError`. Ablo retains only model, id, and the read watermark as
196
+ evidence; it does not record the row contents. A write without `reads` is an
197
+ intentional unconditional assignment.
198
+
199
+ Write options:
128
200
 
129
201
  | Option | Purpose |
130
202
  |---|---|
131
- | `readAt` | The state cursor the write was based on. |
132
- | `onStale` | Stale-state policy. Prefer `reject` for agent writes. |
203
+ | `reads` | Exact rows returned by `read` that the mutation depends on. |
133
204
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
134
205
  | `timeout` | Maximum time to wait for the write call. |
135
206
 
@@ -143,10 +214,12 @@ on its own if the holder stops, and is never saved as a row.
143
214
 
144
215
  You coordinate a row with calls on its model, beside `create`/`update`/`get`:
145
216
  `ablo.<model>.claim({ id })` takes the claim and returns a handle,
146
- `ablo.<model>.claim.state({ id })` reads who currently holds it (synchronous, never
147
- blocks), and `ablo.<model>.claim.release({ id })` releases it early. The full
148
- coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
149
- `claim.release({ id })` / `claim.reorder({ id, order })` hanging off `claim`.
217
+ `ablo.<model>.claim.state({ id })` reads who currently holds it, and
218
+ `ablo.<model>.claim.release({ id })` releases it early. These reads are synchronous
219
+ on the stateful client and awaited server calls on the HTTP client. The full
220
+ coordination surface is `claim.state({ id })` / `claim.list({ id })` /
221
+ `claim.queue({ id })` / `claim.release({ id })` /
222
+ `claim.reorder({ id, order })` hanging off `claim`.
150
223
 
151
224
  The fields on a claim, its lifecycle diagram, and the full method surface are in
152
225
  [Coordination](./coordination.md#the-claim-state-object), which is where that
@@ -156,8 +229,9 @@ line.
156
229
 
157
230
  ### Reading and claiming
158
231
 
159
- `claim.state({ id })` is the read side for observers: synchronous, never blocks, and
160
- returns the live claim state object (or `null`). `claim({ id })` is the write
232
+ `claim.state({ id })` is the read side for observers and returns the current claim
233
+ state object (or `null`). It reads the stateful client's local cache synchronously;
234
+ the HTTP client returns a promise because it asks the server. `claim({ id })` is the write
161
235
  side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if someone else
162
236
  already holds the row, `claim` waits for them to finish, re-reads the fresh row,
163
237
  then hands it to you, so you always proceed from current state. Default reads
@@ -167,7 +241,7 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
167
241
  (the claim queues fairly behind the holder).
168
242
 
169
243
  ```ts
170
- const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
244
+ const claim = await ablo.weatherReports.claim.state({ id: 'report_stockholm' });
171
245
  if (claim) {
172
246
  claim.heldBy;
173
247
  claim.description;
@@ -207,7 +281,7 @@ endpoint documents that model's real field contract instead of a generic blob.
207
281
  |---|---|
208
282
  | `ablo.<model>.create({ data })` | `POST /api/v1/models/{model}` |
209
283
  | `ablo.<model>.list({ where })` | `GET /api/v1/models/{model}` |
210
- | `ablo.<model>.get({ id })` | `GET /api/v1/models/{model}/{id}` |
284
+ | `ablo.<model>.read({ id })` | `GET /api/v1/models/{model}/{id}` |
211
285
  | `ablo.<model>.update({ id, data })` | `PATCH /api/v1/models/{model}/{id}` |
212
286
  | `ablo.<model>.delete({ id })` | `DELETE /api/v1/models/{model}/{id}` |
213
287
  | `ablo.<model>.claim({ id })` | `POST /api/v1/models/{model}/{id}/claim` |
@@ -66,7 +66,9 @@ Login stores one project-scoped `mk_` management credential. It has no
66
66
  production/test mode and no application-data authority. It can manage projects
67
67
  and branches and exchange for an expiring credential bound to one branch.
68
68
 
69
- If you switch projects, log in for the selected project before running `dev`:
69
+ If you switch projects, log in for the selected project before running `dev`.
70
+ A plain `npx ablo login` offers the organization's projects in the terminal;
71
+ `--project` names one outright:
70
72
 
71
73
  ```bash
72
74
  npx ablo projects use orders
@@ -253,8 +255,8 @@ database's safe coordinates, and an exact readiness fix.
253
255
  For a one-shot CI schema check:
254
256
 
255
257
  ```bash
256
- # Store the project management credential as the masked secret.
257
- ABLO_MANAGEMENT_KEY="mk_..." \
258
+ # The one credential input temporarily carries CI's masked management grant.
259
+ ABLO_API_KEY="mk_..." \
258
260
  ABLO_BRANCH="preview-pr-${PR_NUMBER}" \
259
261
  npx ablo dev --no-watch
260
262
  ```
@@ -273,6 +275,22 @@ npx ablo branch ensure "preview-pr-${PR_NUMBER}" \
273
275
  result as a secret, mask it in logs, and pass it through the deployment
274
276
  provider's secret-variable mechanism.
275
277
 
278
+ For live integration tests that should not require a customer database, request
279
+ an explicitly hosted, expiring test branch:
280
+
281
+ ```bash
282
+ npx ablo branch ensure "sandbox-live-${RUN_ID}" \
283
+ --kind test \
284
+ --hosted \
285
+ --expires-at "${EXPIRES_AT}" \
286
+ --credential \
287
+ --json
288
+ ```
289
+
290
+ Hosted storage is never inferred. It is accepted only for `kind: test` branches
291
+ that expire within 24 hours; ordinary branches stay unbound until connected to
292
+ the customer's database.
293
+
276
294
  Closing a preview should call `branch delete`. Deletion immediately makes
277
295
  branch-bound credentials fail authentication even if their expiry is later.
278
296
 
@@ -338,7 +356,8 @@ Run:
338
356
  npx ablo login
339
357
  ```
340
358
 
341
- For a non-default project:
359
+ The terminal then offers the organization's projects, cursor on the active
360
+ one; a plain Enter keeps it. To name the project and skip the picker:
342
361
 
343
362
  ```bash
344
363
  npx ablo login --project <project>
package/docs/cli.md CHANGED
@@ -32,8 +32,8 @@ resume a branch and exchanges it for a temporary branch-bound runtime key.
32
32
 
33
33
  | Command | What it does |
34
34
  | ------------------------ | -------------------------------------------------------------------------- |
35
- | `ablo login` | Authorize in the browser; store one project management credential. |
36
- | `ablo login --project <slug>` | Same, scoped to a project, which becomes active. |
35
+ | `ablo login` | Authorize in the browser, pick a project; store its management credential. |
36
+ | `ablo login --project <slug>` | Same without the picker: scoped to the named project, which becomes active. |
37
37
  | `ablo logout` | Remove the stored credentials. |
38
38
  | `ablo whoami` | Strictly confirm which project and branch a credential acts on. |
39
39
  | `ablo status` | Show the active org/project, resolved runtime credential, branch target, and server health. |
@@ -55,9 +55,10 @@ reports the server-confirmed branch before it writes. For one-time recovery,
55
55
  putting the secret in argv.
56
56
 
57
57
  Keys live in `~/.config/ablo/credentials.json` (mode `0600`), keyed by project.
58
- The non-secret `config.json` holds the active project. In **CI**, don't log in —
59
- set the project management credential as `ABLO_MANAGEMENT_KEY`; it overrides the
60
- stored credential during branch bootstrap.
58
+ The non-secret `config.json` holds the active project. There is one explicit
59
+ credential input: `ABLO_API_KEY`. In headless CI it may temporarily contain an
60
+ `mk_` credential during branch bootstrap; the runtime receives the resulting
61
+ branch-bound `sk_` or restricted `rk_` value through the same variable.
61
62
 
62
63
  ## Development branches and the production root
63
64
 
@@ -94,7 +95,8 @@ with `projects use`) selects which profile every command authenticates with.
94
95
  | `ablo projects list` | List the org's projects (marks the active one and the org-default). |
95
96
  | `ablo projects create <slug>` | Create a project (`--name "Display Name"`). Its keys/schema/data are isolated. |
96
97
  | `ablo projects use <slug>` | Switch the active project. `ablo projects use default` returns to the org-default. |
97
- | `ablo login --project <slug>` | Store management access for a project and make it active. |
98
+ | `ablo login` | Pick a project in the terminal; store its management access and make it active. |
99
+ | `ablo login --project <slug>` | The same for a named project, with no picker. |
98
100
 
99
101
  Because keys are fixed to a project, `projects use` only changes which profile
100
102
  is active — it never re-scopes an existing key. Switch to a project you haven't
@@ -108,12 +110,17 @@ npx ablo projects use war-room
108
110
  npx ablo login --project war-room # stores its management credential, keeps it active
109
111
  ```
110
112
 
113
+ A plain `npx ablo login` reaches the same place through a picker: once the
114
+ browser has approved, the terminal lists the organization's projects, with the
115
+ cursor on the active one, and the choice becomes the credential's project. An
116
+ organization holding only its default project is not asked.
117
+
111
118
  If you run a project-scoped command (`push`, `dev`) while the active project has
112
119
  no key — but other projects do — the CLI **refuses** rather than silently
113
120
  deploying with the wrong project's credential, and names the fix
114
- (`ablo login --project <slug>`). In CI, an explicit `ABLO_MANAGEMENT_KEY`
115
- bypasses profiles for project/branch administration; the runtime key remains
116
- `ABLO_API_KEY`.
121
+ (`ablo login --project <slug>`). In CI, an explicit `mk_` in `ABLO_API_KEY`
122
+ bypasses profiles for project/branch administration. Replace it with the
123
+ branch-bound runtime credential before starting application code.
117
124
 
118
125
  ## Commands
119
126
 
@@ -1,8 +1,11 @@
1
1
  # Client Behavior
2
2
 
3
- > Per-write options, claim behavior, and which errors are safe to retry.
3
+ > Guarded writes, claim behavior, and which errors are safe to retry.
4
4
 
5
- When several writers touch the same data at once — an agent worker, a Server Action, a person in the browser — the SDK decides whose write lands and how the others find out. This page is the reference for that: per-write options like `wait` and `onStale`, claiming a record so your slow work runs uninterrupted, and which errors are safe to retry.
5
+ When several writers touch the same data at once — an agent worker, a Server
6
+ Action, a person in the browser — the SDK protects explicit read dependencies
7
+ and claims records across slow work. This page describes those guarantees and
8
+ which errors are safe to retry.
6
9
 
7
10
  Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
8
11
 
@@ -34,7 +37,7 @@ Common options:
34
37
  | `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
38
  | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
36
39
  | `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
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. |
40
+ | `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and model `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but ordinary calls are HTTP round trips with no socket. Stateful-only model methods (`local`, model `onChange`, and `join`) are compile errors. A listener added through `context().onChange` holds one HTTP response open only until its context changes or its last listener stops. |
38
41
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
39
42
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
40
43
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -53,7 +56,7 @@ Each schema model becomes a typed model:
53
56
  ```ts
54
57
  await ablo.ready();
55
58
 
56
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
59
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
57
60
  const local = ablo.weatherReports.local.get('report_stockholm');
58
61
 
59
62
  await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
@@ -65,7 +68,7 @@ On the reactive client, each model write changes local state optimistically
65
68
  before the call returns. Its promise always waits for authoritative
66
69
  confirmation, so `await update(...)` is the confirmation barrier.
67
70
 
68
- Call `get`/`list` first they fetch from the server and you `await` them.
71
+ Call `get`/`list` to observe, or `read` when a later mutation depends on the row.
69
72
  After that, `local.get`/`local.list`/`local.count` read the already-synced data instantly with
70
73
  no `await`, and stay reactive in render. Use the async pair to load, the sync trio
71
74
  to read.
@@ -83,14 +86,13 @@ through the same model client path. A human Server Action, a browser view, and a
83
86
  agent worker can all use `ablo.weatherReports`:
84
87
 
85
88
  ```ts
86
- const report = await ablo.weatherReports.get({ id });
87
- const snap = ablo.snapshot({ weatherReports: id });
89
+ const report = await ablo.weatherReports.read({ id });
90
+ if (!report) throw new Error('Row not found');
88
91
 
89
92
  await ablo.weatherReports.update({
90
93
  id,
91
94
  data: patch,
92
- readAt: snap.stamp,
93
- onStale: 'reject',
95
+ reads: [report],
94
96
  });
95
97
  ```
96
98
 
@@ -106,24 +108,28 @@ actor routing through Ablo is coordinated. The one write it can't coordinate is
106
108
  one made directly against your database, around Ablo — the WAL echo still catches
107
109
  it for reads, but it bypasses claims and ordering.
108
110
 
109
- ## Per-Write Options
111
+ ## Guarded Writes
110
112
 
111
113
  ```ts
114
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
115
+ if (!report) throw new Error('report not found');
116
+
112
117
  await ablo.weatherReports.update({
113
- id: 'report_stockholm',
118
+ id: report.id,
114
119
  data: { status: 'ready' },
115
- readAt: snap.stamp,
116
- onStale: 'reject',
120
+ reads: [report],
117
121
  idempotencyKey: 'report_stockholm:mark-ready:v1',
118
122
  });
119
123
  ```
120
124
 
121
125
  | Option | Purpose |
122
126
  |---|---|
123
- | `readAt` | State cursor the write was based on. |
124
- | `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. |
127
+ | `reads` | Exact rows returned by `read` that this mutation depends on. |
125
128
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
126
129
 
130
+ A stale premise always rejects with `AbloStaleContextError`. Omit `reads` only
131
+ when the assignment is intentionally unconditional.
132
+
127
133
  ## Claimed Behavior
128
134
 
129
135
  If your update involves a slow step — an API call, an LLM round-trip — and someone