@abloatai/ablo 0.57.0 → 0.59.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 (90) hide show
  1. package/AGENTS.md +12 -5
  2. package/CHANGELOG.md +269 -13
  3. package/README.md +6 -5
  4. package/dist/ai-sdk.d.ts +1 -1
  5. package/dist/ai-sdk.d.ts.map +1 -1
  6. package/dist/context/evidence.d.ts +6 -8
  7. package/dist/context/evidence.d.ts.map +1 -1
  8. package/dist/context/evidence.js +6 -20
  9. package/dist/context/evidence.js.map +1 -1
  10. package/dist/context/index.d.ts +23 -0
  11. package/dist/context/index.d.ts.map +1 -0
  12. package/dist/context/index.js +26 -0
  13. package/dist/context/index.js.map +1 -0
  14. package/dist/context/onChange.d.ts +9 -0
  15. package/dist/context/onChange.d.ts.map +1 -0
  16. package/dist/context/onChange.js +37 -0
  17. package/dist/context/onChange.js.map +1 -0
  18. package/dist/source-conformance.d.ts +1 -1
  19. package/dist/source-conformance.d.ts.map +1 -1
  20. package/dist/source-conformance.js +1 -1
  21. package/dist/source-conformance.js.map +1 -1
  22. package/dist/source-drizzle.d.ts +1 -1
  23. package/dist/source-drizzle.d.ts.map +1 -1
  24. package/dist/source-drizzle.js +1 -1
  25. package/dist/source-drizzle.js.map +1 -1
  26. package/dist/source-kysely.d.ts +1 -1
  27. package/dist/source-kysely.d.ts.map +1 -1
  28. package/dist/source-kysely.js +1 -1
  29. package/dist/source-kysely.js.map +1 -1
  30. package/dist/source-next.d.ts +1 -1
  31. package/dist/source-next.d.ts.map +1 -1
  32. package/dist/source-next.js +1 -1
  33. package/dist/source-next.js.map +1 -1
  34. package/docs/agents.md +38 -15
  35. package/docs/api-keys.md +6 -6
  36. package/docs/api.md +107 -34
  37. package/docs/basic-usage.md +84 -0
  38. package/docs/branch-development.md +23 -4
  39. package/docs/cli.md +16 -9
  40. package/docs/client-behavior.md +27 -29
  41. package/docs/comparison.md +63 -0
  42. package/docs/concurrency-convention.md +92 -75
  43. package/docs/context.md +76 -31
  44. package/docs/coordinate-existing-work.md +104 -0
  45. package/docs/coordination.md +93 -38
  46. package/docs/data-sources.md +12 -6
  47. package/docs/debugging.md +1 -1
  48. package/docs/deployment.md +19 -1
  49. package/docs/examples/agent-human.md +6 -18
  50. package/docs/examples/coordination-conformance.md +69 -0
  51. package/docs/examples/evidence-backed-document-pipeline.md +488 -0
  52. package/docs/examples/existing-python-backend.md +10 -13
  53. package/docs/examples/nextjs.md +2 -2
  54. package/docs/examples/scoped-agent.md +18 -1
  55. package/docs/examples/server-agent.md +2 -2
  56. package/docs/faq.md +75 -0
  57. package/docs/groups.md +19 -139
  58. package/docs/guarantees.md +8 -8
  59. package/docs/idempotency.md +3 -0
  60. package/docs/identity.md +2 -1
  61. package/docs/implement.md +61 -0
  62. package/docs/implementation-index.md +20 -0
  63. package/docs/index.md +59 -173
  64. package/docs/installation.md +77 -0
  65. package/docs/instrumentation.md +52 -0
  66. package/docs/integration-guide.md +20 -19
  67. package/docs/integrations/sandbox-runtime.md +157 -0
  68. package/docs/integrations.md +9 -0
  69. package/docs/migration.md +12 -7
  70. package/docs/operating-on-your-database.md +7 -0
  71. package/docs/options.md +172 -0
  72. package/docs/quickstart.md +25 -14
  73. package/docs/react.md +9 -9
  74. package/docs/schema-contract.md +14 -13
  75. package/docs/security.md +64 -0
  76. package/docs/sessions.md +1 -1
  77. package/examples/README.md +8 -2
  78. package/examples/agent-turn.ts +1 -1
  79. package/examples/expensive-agent-turn.ts +1 -1
  80. package/examples/stale-context-agent-turn.ts +106 -0
  81. package/llms.txt +23 -12
  82. package/package.json +7 -6
  83. package/dist/context/sources.d.ts +0 -21
  84. package/dist/context/sources.d.ts.map +0 -1
  85. package/dist/context/sources.js +0 -36
  86. package/dist/context/sources.js.map +0 -1
  87. package/dist/context.d.ts +0 -22
  88. package/dist/context.d.ts.map +0 -1
  89. package/dist/context.js +0 -33
  90. package/dist/context.js.map +0 -1
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/drizzle';
1
+ export * from '@abloatai/transaction/source/adapters/drizzle';
2
2
  //# sourceMappingURL=source-drizzle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-drizzle.d.ts","sourceRoot":"","sources":["../src/source-drizzle.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC"}
1
+ {"version":3,"file":"source-drizzle.d.ts","sourceRoot":"","sources":["../src/source-drizzle.ts"],"names":[],"mappings":"AAAA,cAAc,+CAA+C,CAAC"}
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/drizzle';
1
+ export * from '@abloatai/transaction/source/adapters/drizzle';
2
2
  //# sourceMappingURL=source-drizzle.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-drizzle.js","sourceRoot":"","sources":["../src/source-drizzle.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC"}
1
+ {"version":3,"file":"source-drizzle.js","sourceRoot":"","sources":["../src/source-drizzle.ts"],"names":[],"mappings":"AAAA,cAAc,+CAA+C,CAAC"}
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/kysely';
1
+ export * from '@abloatai/transaction/source/adapters/kysely';
2
2
  //# sourceMappingURL=source-kysely.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-kysely.d.ts","sourceRoot":"","sources":["../src/source-kysely.ts"],"names":[],"mappings":"AAAA,cAAc,qCAAqC,CAAC"}
1
+ {"version":3,"file":"source-kysely.d.ts","sourceRoot":"","sources":["../src/source-kysely.ts"],"names":[],"mappings":"AAAA,cAAc,8CAA8C,CAAC"}
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/kysely';
1
+ export * from '@abloatai/transaction/source/adapters/kysely';
2
2
  //# sourceMappingURL=source-kysely.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-kysely.js","sourceRoot":"","sources":["../src/source-kysely.ts"],"names":[],"mappings":"AAAA,cAAc,qCAAqC,CAAC"}
1
+ {"version":3,"file":"source-kysely.js","sourceRoot":"","sources":["../src/source-kysely.ts"],"names":[],"mappings":"AAAA,cAAc,8CAA8C,CAAC"}
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/next';
1
+ export * from '@abloatai/transaction/source/endpoint';
2
2
  //# sourceMappingURL=source-next.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-next.d.ts","sourceRoot":"","sources":["../src/source-next.ts"],"names":[],"mappings":"AAAA,cAAc,mCAAmC,CAAC"}
1
+ {"version":3,"file":"source-next.d.ts","sourceRoot":"","sources":["../src/source-next.ts"],"names":[],"mappings":"AAAA,cAAc,uCAAuC,CAAC"}
@@ -1,2 +1,2 @@
1
- export * from '@abloatai/transaction/source/next';
1
+ export * from '@abloatai/transaction/source/endpoint';
2
2
  //# sourceMappingURL=source-next.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"source-next.js","sourceRoot":"","sources":["../src/source-next.ts"],"names":[],"mappings":"AAAA,cAAc,mCAAmC,CAAC"}
1
+ {"version":3,"file":"source-next.js","sourceRoot":"","sources":["../src/source-next.ts"],"names":[],"mappings":"AAAA,cAAc,uCAAuC,CAAC"}
package/docs/agents.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  > The stateless participant: wake on a trigger, read, claim, commit, go idle.
4
4
 
5
+ ## Stateless HTTP reads
6
+
7
+ Use `get` to read one task row by id and `list` to find matching rows. Agents
8
+ and other stateless workers use the HTTP client directly, without a
9
+ synchronization step or a `.data` wrapper.
10
+
11
+ ```ts
12
+ const task = await ablo.tasks.get({ id: taskId });
13
+ if (!task) throw new Error('task not found');
14
+ console.log(task.title);
15
+
16
+ const matching = await ablo.tasks.list({ where: { title } });
17
+ if (!matching[0]) throw new Error('task not found');
18
+ console.log(matching[0].title);
19
+ ```
20
+
21
+ These are observational reads. Use `read({ id })` only when a later Ablo write
22
+ depends on that exact version and will pass it through `reads`.
23
+
5
24
  An agent is a **reactive** participant: it wakes on something happening, reads
6
25
  what it needs, writes a result, and goes idle. That's a request/response
7
26
  workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The
@@ -16,7 +35,7 @@ other*.**
16
35
  <Note>
17
36
  Agents transact against your **pushed schema**, same as everyone — `ablo.records`
18
37
  exists because you defined a `record` model and ran `ablo push`. The key
19
- authenticates; the [schema](/quickstart) defines what you can call.
38
+ authenticates; the [schema](/installation) defines what you can call.
20
39
  </Note>
21
40
 
22
41
  ## The agent client
@@ -34,7 +53,7 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http"
34
53
  // `get` resolves to the row, or `undefined` when none matches.
35
54
  const open = await ablo.records.list({ where: { status: "todo" } });
36
55
 
37
- const record = await ablo.records.get({ id: open[0].id });
56
+ const record = await ablo.records.read({ id: open[0].id });
38
57
  if (!record) throw new Error("record not found");
39
58
 
40
59
  console.log(record.title);
@@ -42,9 +61,11 @@ await ablo.records.update({ id: record.id, data: { status: "done" } });
42
61
  ```
43
62
 
44
63
  It exposes `get` / `list` / `create` / `update` / `delete`, plus `commits`
45
- and `claim`. It does **not** expose stateful-only `local` reads or `onChange`
46
- subscriptions. Those need a live connection, so with `transport: 'http'` they
47
- are compile errors rather than runtime surprises.
64
+ and `claim`. It does **not** expose stateful-only `local` reads or model
65
+ `onChange` subscriptions. Those need a WebSocket, so with `transport: 'http'`
66
+ they are compile errors. `context().onChange` is separate: while its listener
67
+ is active, it holds one HTTP response open until the context changes or the
68
+ listener stops.
48
69
 
49
70
  ## Managed scoped agents
50
71
 
@@ -58,7 +79,7 @@ model output or an HTTP request body. A serverless handler normally creates and
58
79
  disposes one child per invocation:
59
80
 
60
81
  ```ts
61
- const run = await control.runs.get({ id: verifiedRunId });
82
+ const run = await control.runs.read({ id: verifiedRunId });
62
83
  if (!run) throw new Error('run not found');
63
84
 
64
85
  const agent = await control.agents.create({
@@ -204,19 +225,21 @@ default caller here, not a bolt-on.
204
225
 
205
226
  ```text
206
227
  something happens ──▶ your agent (HTTP, no socket)
207
- (a job, a webhook, read context (list/get)
228
+ (a job, a webhook, read context (list/get/read)
208
229
  a queue message) claim → work → commit
209
230
  done — no held connection
210
231
  ```
211
232
 
212
- Because it holds nothing open, an agent is a **stateless worker**: deploys and
213
- restarts are free, and you scale by adding workers. A long-running fleet of idle
214
- agents costs nothing on the live plane that capacity stays for humans.
233
+ Without `context().onChange`, an agent holds nothing open and remains a
234
+ **stateless worker**: deploys and restarts are free, and you scale by adding
235
+ workers. Each active context listener is explicit connection capacity and must
236
+ be stopped when its work ends.
215
237
 
216
238
  ## What stays on the live (human) plane
217
239
 
218
- `onChange` (live subscriptions) and the `local` reads (local synced-pool
219
- reads) require a WebSocket and a local store they're for interactive UIs, not
220
- stateless agents. An agent reacts to an external trigger (a job/queue/webhook),
221
- then reads with `list`/`get`. See [client behavior](/client-behavior) for
222
- the full surface and [guarantees](/guarantees) for the coordination semantics.
240
+ Model `onChange` and `local` reads require a WebSocket and a local store —
241
+ they're for interactive UIs. An HTTP agent normally reacts to an external
242
+ trigger, then reads with `list`/`get`. For costly work, `context().onChange` can
243
+ stop that one run early while the final write still uses `context().reads`.
244
+ See [client behavior](/client-behavior) for the full surface and
245
+ [guarantees](/guarantees) for the coordination semantics.
package/docs/api-keys.md CHANGED
@@ -23,9 +23,9 @@ and remap them before each command.
23
23
 
24
24
  | Job | Credential | How you get it |
25
25
  |---|---|---|
26
- | Manage a project or its branches | `mk_` | `npx ablo login --project <slug>` stores it for the CLI. |
26
+ | Manage a project or its branches | `mk_` | `npx ablo login` stores it for the CLI; pick the project in the terminal, or name it with `--project <slug>`. |
27
27
  | Develop locally | expiring `sk_` bound to the current branch | `npx ablo dev` writes it as `ABLO_API_KEY` in gitignored `.env.local`. |
28
- | Prepare a branch once, including CI | expiring `sk_` bound to that branch | `npx ablo dev --no-watch --branch <ref>`; CI supplies `ABLO_MANAGEMENT_KEY`. |
28
+ | Prepare a branch once, including CI | expiring `sk_` bound to that branch | `npx ablo dev --no-watch --branch <ref>`; headless CI supplies an `mk_` credential through `ABLO_API_KEY`. |
29
29
  | Run the production backend | `sk_` bound to the production root | Store it as the deployment's `ABLO_API_KEY`. |
30
30
  | Read in a browser | `pk_` | Publishable, read-only key. |
31
31
  | Write in a browser as a user | short-lived `ek_` | Your backend exposes `authEndpoint` and mints it. |
@@ -33,9 +33,9 @@ and remap them before each command.
33
33
  The everyday loop is therefore:
34
34
 
35
35
  ```bash
36
- npx ablo login --project <project> # once per project
37
- npx ablo dev # follows Git, mints and wires this branch
38
- npx ablo status # broad readiness report
36
+ npx ablo login # once per project: approve in the browser, pick the project
37
+ npx ablo dev # follows Git, mints and wires this branch
38
+ npx ablo status # broad readiness report
39
39
  ```
40
40
 
41
41
  Application code and agents still read one variable:
@@ -91,7 +91,7 @@ The credential class lives in the prefix:
91
91
 
92
92
  | Prefix | Purpose | Stored where |
93
93
  |---|---|---|
94
- | `mk_` | project and branch management | CLI credential store or `ABLO_MANAGEMENT_KEY` |
94
+ | `mk_` | project and branch management | CLI credential store; `ABLO_API_KEY` only in headless automation |
95
95
  | `sk_` | trusted runtime, full branch authority | server-side `ABLO_API_KEY` |
96
96
  | `rk_` | restricted runtime or agent | trusted runtime that needs the delegated scope |
97
97
  | `pk_` | publishable, browser-safe read access | browser bundle |
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,15 +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.
56
63
  - `ablo.weatherReports.listAll({ where })` explicitly reads every matching page.
57
64
  - `ablo.weatherReports.local.get(id)` reads one row synchronously from the local graph.
58
65
  - `ablo.weatherReports.create({ data })` creates a row.
59
66
  - `ablo.weatherReports.update({ id, data, ...options })` updates a row.
60
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.
61
69
 
62
- `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
63
71
  `list({ where })` answer from the local graph and fall back to IndexedDB and
64
72
  then the network, so reach for them when the row may not be here yet.
65
73
  `local.get(id)` and `local.list({ where })` are the same reads with the
@@ -67,8 +75,9 @@ fallback removed — nothing to await, so they return a value.
67
75
 
68
76
  | Method | Returns | Use when |
69
77
  |---|---|---|
70
- | `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
71
- | `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. |
72
81
  | `listAll({ where, maxPages?, signal? })` | `Promise<T[]>` | You deliberately need every matching row. |
73
82
  | `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
74
83
  | `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
@@ -76,11 +85,60 @@ fallback removed — nothing to await, so they return a value.
76
85
  | `create({ data, ...options })` | `Promise<T>` | You want to create through the schema model. |
77
86
  | `update({ id, data, ...options })` | `Promise<T>` | You want to update through the schema model. |
78
87
  | `delete({ id, ...options })` | `Promise<void>` | You want to delete through the schema model. |
79
-
80
- `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
81
96
  through the server. The `local` reads work off the rows a session has already
82
97
  synced, so a cheap re-read needs no round-trip.
83
98
 
99
+ ## Atomic commits
100
+
101
+ Use one `ablo.commits.create` when several Ablo model writes must all land or
102
+ none may land. Put every operation in `operations` and every exact row returned
103
+ by `read` that influenced the batch in the top-level `reads` array.
104
+
105
+ ```ts
106
+ import { AbloStaleContextError } from '@abloatai/ablo';
107
+
108
+ const task = await ablo.tasks.read({ id: taskId });
109
+ if (!task) throw new Error('task not found');
110
+
111
+ try {
112
+ await ablo.commits.create({
113
+ operations: [
114
+ {
115
+ action: 'update', model: 'tasks',
116
+ id: task.id,
117
+ data: { status: 'done' },
118
+ },
119
+ {
120
+ action: 'create', model: 'tasks',
121
+ id: markerId,
122
+ data: { title: 'atomic marker', status: 'done' },
123
+ },
124
+ ],
125
+ reads: [task],
126
+ });
127
+ } catch (error) {
128
+ if (error instanceof AbloStaleContextError && error.code === 'stale_context') {
129
+ console.log(error.code);
130
+ } else {
131
+ throw error;
132
+ }
133
+ }
134
+ ```
135
+
136
+ The server checks the premises and applies the operations in one transaction.
137
+ If any premise is stale or any operation fails, no operation lands. Independent
138
+ model calls are not an atomic batch. External effects and application-owned
139
+ Postgres writes cannot join this commit; keep those in their existing
140
+ transaction or outbox.
141
+
84
142
  ### Reading a whole collection
85
143
 
86
144
  Prefer a filtered `listAll` when the application truly needs one complete
@@ -157,30 +215,35 @@ whenever a value is going away, and the type will hold you to it — only a
157
215
  field your schema declares optional accepts one, since a required field has no
158
216
  empty value to move to.
159
217
 
160
- ## Protected Writes
218
+ ## Guarded Writes
161
219
 
162
- Use `snapshot` when a write should reject if the row changed mid-flight:
220
+ Use `read` when a write depends on the row's current state, then pass that exact
221
+ row in `reads`:
163
222
 
164
223
  ```ts
165
- const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
224
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
225
+ if (!report) throw new Error('report not found');
166
226
 
167
227
  await ablo.weatherReports.update({
168
- id: 'report_stockholm',
228
+ id: report.id,
169
229
  data: { status: 'ready' },
170
- readAt: snap.stamp,
171
- onStale: 'reject',
230
+ reads: [report],
172
231
  });
173
232
  ```
174
233
 
175
234
  Reactive local state changes optimistically at call time; awaiting the model
176
235
  write waits for authoritative confirmation.
177
236
 
178
- Protected write options:
237
+ If the row changed after `read`, the write rejects with
238
+ `AbloStaleContextError`. Ablo retains only model, id, and the read watermark as
239
+ evidence; it does not record the row contents. A write without `reads` is an
240
+ intentional unconditional assignment.
241
+
242
+ Write options:
179
243
 
180
244
  | Option | Purpose |
181
245
  |---|---|
182
- | `readAt` | The state cursor the write was based on. |
183
- | `onStale` | Stale-state policy. Prefer `reject` for agent writes. |
246
+ | `reads` | Exact rows returned by `read` that the mutation depends on. |
184
247
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
185
248
  | `timeout` | Maximum time to wait for the write call. |
186
249
 
@@ -194,10 +257,12 @@ on its own if the holder stops, and is never saved as a row.
194
257
 
195
258
  You coordinate a row with calls on its model, beside `create`/`update`/`get`:
196
259
  `ablo.<model>.claim({ id })` takes the claim and returns a handle,
197
- `ablo.<model>.claim.state({ id })` reads who currently holds it (synchronous, never
198
- blocks), and `ablo.<model>.claim.release({ id })` releases it early. The full
199
- coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
200
- `claim.release({ id })` / `claim.reorder({ id, order })` hanging off `claim`.
260
+ `ablo.<model>.claim.state({ id })` reads who currently holds it, and
261
+ `ablo.<model>.claim.release({ id })` releases it early. These reads are synchronous
262
+ on the stateful client and awaited server calls on the HTTP client. The full
263
+ coordination surface is `claim.state({ id })` / `claim.list({ id })` /
264
+ `claim.queue({ id })` / `claim.release({ id })` /
265
+ `claim.reorder({ id, order })` hanging off `claim`.
201
266
 
202
267
  The fields on a claim, its lifecycle diagram, and the full method surface are in
203
268
  [Coordination](./coordination.md#the-claim-state-object), which is where that
@@ -207,8 +272,9 @@ line.
207
272
 
208
273
  ### Reading and claiming
209
274
 
210
- `claim.state({ id })` is the read side for observers: synchronous, never blocks, and
211
- returns the live claim state object (or `null`). `claim({ id })` is the write
275
+ `claim.state({ id })` is the read side for observers and returns the current claim
276
+ state object (or `null`). It reads the stateful client's local cache synchronously;
277
+ the HTTP client returns a promise because it asks the server. `claim({ id })` is the write
212
278
  side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if someone else
213
279
  already holds the row, `claim` waits for them to finish, re-reads the fresh row,
214
280
  then hands it to you, so you always proceed from current state. Default reads
@@ -218,7 +284,7 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
218
284
  (the claim queues fairly behind the holder).
219
285
 
220
286
  ```ts
221
- const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
287
+ const claim = await ablo.weatherReports.claim.state({ id: 'report_stockholm' });
222
288
  if (claim) {
223
289
  claim.heldBy;
224
290
  claim.description;
@@ -229,13 +295,18 @@ const handle = await ablo.weatherReports.claim({
229
295
  description: 'editing',
230
296
  ttl: '2m',
231
297
  });
232
- await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
298
+ await ablo.weatherReports.update({
299
+ id: handle.data.id,
300
+ data: { status: 'ready' },
301
+ claim: handle,
302
+ });
233
303
  await handle.release();
234
304
  ```
235
305
 
236
- Writes go through the normal `ablo.<model>.update({ id, data })`. While you hold
237
- a claim on `id`, that `update` rejects with `AbloStaleContextError` if the row
238
- changed underneath you since you took the claim, so you re-read before retrying.
306
+ Writes go through the normal model mutation and pass the held handle as `claim`.
307
+ That explicit handle carries commit-time fencing. If the row changed underneath
308
+ you since you took the claim, the update rejects with `AbloStaleContextError`,
309
+ so you re-read before retrying.
239
310
  Call `handle.release()` (or `ablo.weatherReports.claim.release({ id })`) to release
240
311
  the claim when your work is done.
241
312
 
@@ -258,7 +329,7 @@ endpoint documents that model's real field contract instead of a generic blob.
258
329
  |---|---|
259
330
  | `ablo.<model>.create({ data })` | `POST /api/v1/models/{model}` |
260
331
  | `ablo.<model>.list({ where })` | `GET /api/v1/models/{model}` |
261
- | `ablo.<model>.get({ id })` | `GET /api/v1/models/{model}/{id}` |
332
+ | `ablo.<model>.read({ id })` | `GET /api/v1/models/{model}/{id}` |
262
333
  | `ablo.<model>.update({ id, data })` | `PATCH /api/v1/models/{model}/{id}` |
263
334
  | `ablo.<model>.delete({ id })` | `DELETE /api/v1/models/{model}/{id}` |
264
335
  | `ablo.<model>.claim({ id })` | `POST /api/v1/models/{model}/{id}/claim` |
@@ -284,7 +355,9 @@ rendered as agent tools.
284
355
 
285
356
  ## Errors
286
357
 
287
- All SDK errors extend `AbloError` and expose a stable `type` string.
358
+ All SDK errors extend `AbloError`. `type` is the class-name discriminator, such
359
+ as `AbloStaleContextError`; `code` is the wire condition, such as
360
+ `stale_context`. Use `instanceof` in-process and `type` after serialization.
288
361
 
289
362
  | Error | Meaning |
290
363
  |---|---|
@@ -0,0 +1,84 @@
1
+ # Basic Usage
2
+
3
+ > Read one task row by id, list matching rows, create and update state, and coordinate work when needed.
4
+
5
+ Assume the configured client from [Installation](./installation.md) is exported
6
+ from `./ablo/client`.
7
+
8
+ ## Read rows
9
+
10
+ ```ts
11
+ import { ablo } from './ablo/client';
12
+
13
+ const task = await ablo.tasks.get({ id: taskId });
14
+ if (!task) throw new Error('task not found');
15
+
16
+ const open = await ablo.tasks.list({ where: { status: 'open' } });
17
+ ```
18
+
19
+ `get` observes one current row. Use `read` instead only when a later Ablo write
20
+ must be rejected if that exact premise changes.
21
+
22
+ ## Write rows
23
+
24
+ ```ts
25
+ const created = await ablo.tasks.create({
26
+ data: { title: 'Review return', status: 'open' },
27
+ });
28
+
29
+ await ablo.tasks.update({
30
+ id: created.id,
31
+ data: { status: 'done' },
32
+ });
33
+ ```
34
+
35
+ Writes return after Ablo confirms the authoritative result. Your PostgreSQL
36
+ constraints and schema remain in force.
37
+
38
+ ## Coordinate slow work
39
+
40
+ When the target is an Ablo model row, claim it before the expensive step and
41
+ pass the claim to the final write.
42
+
43
+ ```ts
44
+ await using claim = await ablo.tasks.claim({ id: taskId });
45
+
46
+ const result = await performExpensiveWork(claim.data);
47
+
48
+ await ablo.tasks.update({
49
+ id: claim.data.id,
50
+ data: { title: result.title, status: 'done' },
51
+ claim,
52
+ });
53
+ ```
54
+
55
+ A contender follows the chosen wait, skip, or fail policy. Disposal releases
56
+ the claim, and expiry lets another participant recover when the owner
57
+ disappears.
58
+
59
+ ## Preserve an existing write
60
+
61
+ The claimed target does not have to be an Ablo row. Claim a stable business id
62
+ and keep the final transaction in the application that already owns it.
63
+
64
+ ```ts
65
+ await using lease = await ablo.taskRuns.claim(taskId, {
66
+ contention: { mode: 'skip' },
67
+ });
68
+ if (!lease) return;
69
+
70
+ const prepared = await performExpensiveWork(taskId);
71
+ return existingTaskService.commitPrepared(taskId, prepared);
72
+ ```
73
+
74
+ That transaction must still re-read, validate, and commit authoritatively. An
75
+ Ablo claim does not join a transaction in another process.
76
+
77
+ ## Add stronger guarantees when required
78
+
79
+ - [Concurrency Convention](./concurrency-convention.md): reject a write when an
80
+ earlier premise changed.
81
+ - [Atomic commits](./api.md#atomic-commits): apply several Ablo writes together.
82
+ - [Idempotency](./idempotency.md): make retrying the same Ablo mutation safe.
83
+ - [Agents](./agents.md): configure a stateless HTTP worker.
84
+ - [React](./react.md): add live state and presence for a human interface.
@@ -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