@abloatai/ablo 0.7.0 → 0.9.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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -1,20 +1,22 @@
1
1
  # Coordination Reference
2
2
 
3
3
  Coordinate long-running work on a row so humans and agents don't clobber each
4
- other. Most writes need none of this — `ablo.<model>.update(id, )` is optimistic
4
+ other. Most writes need none of this — `ablo.<model>.update({ id, data })` is optimistic
5
5
  and the server rejects it if the row moved. Reach for `claim` only when you'll
6
6
  **hold a row across a slow gap** (read → LLM call → write).
7
7
 
8
- Claims are **fair**: on contention a second claimer joins a **server-side FIFO
9
- queue** and blocks until promoted to the head of the line it does not fail and
10
- does not poll. Reads are open by default; reading a claimed row is allowed unless
11
- the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
12
- holder is auto-released and the queue advances.
8
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
9
+ re-reads the fresh row, then hands it to youso two writers serialize instead
10
+ of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
11
+ until promoted to the head of the line it does not fail and does not poll.
12
+ Reads stay open: reading a claimed row is allowed unless the caller explicitly
13
+ asks for claimed gating. A claim carries a TTL so a crashed holder is
14
+ auto-released and the queue advances.
13
15
 
14
16
  This reference opens with [the model](#the-model--three-layers-one-decision) — the
15
17
  one answer to "how do two agents not clobber each other" — then covers the
16
18
  [claim state object](#the-claim-state-object), the SDK [methods](#methods)
17
- (`claim` · `claimState` · `queue` · `release` · [writing under a
19
+ (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
18
20
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
19
21
 
20
22
  ---
@@ -27,8 +29,8 @@ make:
27
29
 
28
30
  | layer | kind | what it does | enforces? |
29
31
  |---|---|---|---|
30
- | **Presence** (`claimState`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
31
- | **Claim** (`claim`/`queue`/`release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
32
+ | **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
33
+ | **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
32
34
  | **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
33
35
 
34
36
  **The one decision: do you hold the row across a slow gap (read → LLM call →
@@ -42,42 +44,29 @@ write)?**
42
44
  excludes other participants for the duration, queues contenders fairly, and —
43
45
  see below — your own writes under it stay stale-guarded too.
44
46
 
45
- **How they compose (what wins):**
46
-
47
- 1. **Claim supersedes stale-context for *foreign* writers.** A non-holder writing
48
- to a claimed row is rejected by the claim guard (`AbloClaimedError`,
49
- `claim_conflict`/`entity_claimed`) *before* any watermark check `readAt` is
50
- irrelevant when you don't hold the lease. Pessimistic exclusion is the outer
51
- gate.
52
- 2. **Stale-context is the always-on backstop for *unclaimed* writes.** No claim
53
- held the watermark check is the only protection, and it's automatic. This is
54
- why the no-claim path is safe by default.
55
- 3. **Inside a claim, both apply.** A claim is not a license to clobber yourself:
56
- writes under a held claim carry the claim's snapshot as `readAt` with
57
- `onStale: 'reject'` (see [Writing under a claim](#writing-under-a-claim)), so a
58
- `bypass` write or a row that moved between snapshot and write still rejects.
59
- Claim = "no one else"; stale-context = "and not against a moved snapshot."
60
- 4. **Presence never decides.** It is the visualization of (1)–(3), not a fourth
61
- gate. Never branch enforcement logic on `claimState` — read it to render, act
62
- on the errors above.
63
-
64
- Claims and stale-context are **orthogonal by construction**, not wired into each
65
- other on the server: the claim guard runs pre-transaction; the watermark check
66
- runs inside it. The SDK attaches `readAt`/`onStale` for you when writing under a
67
- claim — that coupling lives in the SDK, deliberately, so the server's two checks
68
- stay independent and individually testable.
47
+ **How they compose (what wins):** If you don't hold the row, claims win — a
48
+ non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
49
+ `readAt`. If you do hold it, your own writes are still stale-checked — a row that
50
+ moved between your snapshot and your write still rejects with
51
+ `AbloStaleContextError`. With no claim held, the stale check is the only
52
+ protection, and it's automatic, which is why the no-claim path is safe by default.
53
+ Presence (`claim.state`) never decides anything — read it to render, act on the
54
+ errors. The two checks are independent: one rejects writes from people who don't
55
+ hold the claim, the other rejects writes based on a stale snapshot, and the SDK
56
+ adds the stale-check for you when you write under a claim, so you don't pass
57
+ anything extra.
69
58
 
70
59
  ---
71
60
 
72
61
  ## The claim state object
73
62
 
74
63
  The claim state object is the live record that a participant is coordinating work on
75
- a model row. It's what `claimState()` returns and what observers render.
64
+ a model row. It's what `claim.state()` returns and what observers render.
76
65
 
77
66
  | field | type | description |
78
67
  |---|---|---|
79
68
  | `id` | `string` | The claim id (distinct from the target row id). |
80
- | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
69
+ | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
81
70
  | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
82
71
  | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
83
72
  | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
@@ -109,8 +98,7 @@ parameters · returns · example**.
109
98
  ### `claim`
110
99
 
111
100
  ```ts
112
- ablo.<model>.claim(id, work, options?): Promise<R> // callback form
113
- ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
101
+ ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>> // handle; AsyncDisposable, auto-releases with `await using`
114
102
  ```
115
103
 
116
104
  Claim a row so other writers serialize behind you until you're done; reads stay
@@ -118,8 +106,8 @@ open by default. The claim acquires through the server's fair FIFO queue: if the
118
106
  target is free the lease is yours immediately, and if another participant holds
119
107
  it your claim **waits in line** and resolves only once it reaches the head —
120
108
  then re-reads so the claimed snapshot reflects what the previous holder
121
- committed. There's no client-side poll and no TOCTOU gap: the server orders
122
- contenders.
109
+ committed. There's no polling and no race window the server decides the order,
110
+ so two claimers can't both think they won.
123
111
 
124
112
  **Parameters**
125
113
 
@@ -131,38 +119,36 @@ contenders.
131
119
  | `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
132
120
  | `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
133
121
  | `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
134
- | `work` | `(row) => …` | no | Callback form: hold the claim for the callback, release when it returns. |
135
122
 
136
123
  The high-level `claim` queues by default, so on contention you either get the row
137
124
  when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
138
125
  `grant_timeout`).
139
126
 
140
- **Returns** — with the callback form, returns whatever `work` returns and
141
- releases after the callback returns or throws. The manual form returns the
142
- claimed row (`ClaimedRow<T> = T & AsyncDisposable`): the row data plus a
143
- release hook for manual scopes.
127
+ **Returns** — a `ClaimHandle<T>` (an `AsyncDisposable`): `handle.data` is the
128
+ fresh row snapshot taken once the lease is yours, and `handle.release()` gives
129
+ the claim back. Bind it with `await using` so the claim auto-releases when the
130
+ scope exits.
144
131
 
145
132
  **Example**
146
133
 
147
134
  ```ts
148
- const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
149
- const weather = await weatherAgent.getWeather(report.location);
150
- await ablo.weatherReports.update(report.id, { forecast: weather });
151
- return weather;
152
- });
135
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
136
+ const report = claim.data;
137
+ const weather = await weatherAgent.getWeather(report.location);
138
+ await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
153
139
  ```
154
140
 
155
- The manual scoped form is still available for wider TS 5.2+ scopes, but ordinary
156
- held work should use the callback form above.
141
+ The claim releases when the `await using` scope exits on return or throw.
157
142
 
158
143
  ### Claim-gated reads
159
144
 
160
- `claimState(id)` always returns immediately. Model reads such as
161
- `ablo.<model>.retrieve(id)` are local reads and stay available while a claim is
145
+ `claim.state({ id })` always returns immediately. Model reads such as
146
+ `ablo.<model>.get(id)` are local reads and stay available while a claim is
162
147
  held. Server/model reads can choose a claimed policy:
163
148
 
164
149
  ```ts
165
- await ablo.model('weatherReports').retrieve('report_stockholm', {
150
+ await ablo.weatherReports.retrieve({
151
+ id: 'report_stockholm',
166
152
  ifClaimed: 'wait',
167
153
  claimedTimeout: 30_000,
168
154
  });
@@ -172,10 +158,10 @@ await ablo.model('weatherReports').retrieve('report_stockholm', {
172
158
  - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
173
159
  - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
174
160
 
175
- ### `claimState`
161
+ ### `claim.state`
176
162
 
177
163
  ```ts
178
- ablo.<model>.claimState(id)
164
+ ablo.<model>.claim.state({ id })
179
165
  ```
180
166
 
181
167
  Read who's currently working on a row, for observers and UI. Synchronous and
@@ -193,7 +179,7 @@ is free.
193
179
  **Example**
194
180
 
195
181
  ```ts
196
- const who = ablo.weatherReports.claimState('report_stockholm');
182
+ const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
197
183
  if (who) console.log(`${who.heldBy} is ${who.action}`);
198
184
  ```
199
185
 
@@ -211,17 +197,17 @@ Returns the active claim state when the row is held, or `null` when it's free:
211
197
  }
212
198
  ```
213
199
 
214
- ### `queue`
200
+ ### `claim.queue`
215
201
 
216
202
  ```ts
217
- ablo.<model>.queue(id)
203
+ ablo.<model>.claim.queue({ id })
218
204
  ```
219
205
 
220
206
  Read the **wait line** behind a row — the FIFO of claims queued behind the
221
- current holder, in promotion order. Like `claimState`, it's synchronous and
207
+ current holder, in promotion order. Like `claim.state`, it's synchronous and
222
208
  reactive (it reads the local coordination snapshot, kept current by the server's
223
- queue-mutation frames), and reading never blocks. Where `claimState` answers "who
224
- holds it," `queue` answers "who's lined up next" — render "3rd in line", or
209
+ queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
210
+ holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
225
211
  decide the wait isn't worth it.
226
212
 
227
213
  **Parameters**
@@ -237,19 +223,19 @@ the active holder; `[]` when no one is waiting.
237
223
  **Example**
238
224
 
239
225
  ```ts
240
- const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
226
+ const { data: waiting } = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
241
227
  console.log(`${waiting.length} ahead of you`);
242
228
  console.log(waiting.map((i) => i.heldBy));
243
229
  ```
244
230
 
245
- ### `release`
231
+ ### `claim.release`
246
232
 
247
233
  ```ts
248
- ablo.<model>.release(id): Promise<void>
234
+ ablo.<model>.claim.release({ id }): Promise<void>
249
235
  ```
250
236
 
251
- Release a claim you hold. Usually **implicit** — the callback returning releases
252
- for you, and TTL cleans up a crashed holder.
237
+ Release a claim you hold. Usually **implicit** — the `await using` scope exiting
238
+ releases for you, and TTL cleans up a crashed holder.
253
239
  Call this only to give a manually held claim back early (claimed, then decided
254
240
  not to write).
255
241
  Releasing **promotes the head of the queue**: the next waiter receives the claim.
@@ -265,28 +251,28 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
265
251
  **Example**
266
252
 
267
253
  ```ts
268
- const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
254
+ const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
255
+ const report = claim.data;
269
256
  try {
270
257
  const ok = await reviewExternally(report);
271
258
  if (!ok) return; // abandon, no write
272
- await ablo.weatherReports.update(report.id, { status: 'ready' });
259
+ await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
273
260
  } finally {
274
- await ablo.weatherReports.release(report.id);
261
+ await ablo.weatherReports.claim.release({ id: report.id });
275
262
  }
276
263
  ```
277
264
 
278
265
  ### Writing under a claim
279
266
 
280
- There is no separate "write" method on a claim — use the normal flat
281
- `ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
267
+ There is no separate "write" method on a claim — use the normal
268
+ `ablo.<model>.update({ id, data })`. While you hold a claim on `id`, that `update` is
282
269
  automatically stale-guarded against the snapshot the claim took (`readAt` =
283
270
  snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
284
271
  it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
285
272
 
286
273
  ```ts
287
- await ablo.weatherReports.claim(id, async (report) => {
288
- await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
289
- });
274
+ await using claim = await ablo.weatherReports.claim({ id });
275
+ await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
290
276
  ```
291
277
 
292
278
  Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
@@ -297,7 +283,7 @@ on fresh data. You never conflict with your own claim, and reads are never gated
297
283
 
298
284
  ```ts
299
285
  try {
300
- await ablo.weatherReports.update(id, { status: 'ready' });
286
+ await ablo.weatherReports.update({ id, data: { status: 'ready' } });
301
287
  } catch (err) {
302
288
  if (err instanceof AbloClaimedError) {
303
289
  // someone else holds it — claim the row and retry from fresh state
@@ -329,10 +315,10 @@ that moved during your generation window — use it for selective regeneration
329
315
 
330
316
  ```ts
331
317
  try {
332
- await ablo.weatherReports.claim('report_stockholm', async (report) => {
333
- const weather = await weatherAgent.getWeather(report.location); // slow gap
334
- await ablo.weatherReports.update(report.id, { forecast: weather });
335
- });
318
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
319
+ const report = claim.data;
320
+ const weather = await weatherAgent.getWeather(report.location); // slow gap
321
+ await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
336
322
  } catch (err) {
337
323
  if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
338
324
  // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
@@ -1,19 +1,19 @@
1
1
  # Connect Your Database
2
2
 
3
- Every schema model has a backing store.
3
+ By default, Ablo stores the rows for the models you define, so you don't need a
4
+ database to get started. But if you already have your own application database
5
+ and want it to stay the source of truth, you can attach it as a Data Source —
6
+ then Ablo coordinates each write and calls your app to commit it, instead of
7
+ storing the data itself.
4
8
 
5
- Customer apps must define an Ablo schema. The schema is the contract between
6
- the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
7
- `defineSchema`, `model`, and Zod the same way a Prisma project starts with a
8
- `schema.prisma`.
9
+ That default makes Ablo the managed state store for your models, the same way
10
+ Stripe stores `Customer` and `PaymentIntent` objects that you create through
11
+ Stripe's API.
9
12
 
10
- By default, Ablo stores the rows for the models you declare. That makes Ablo the
11
- managed state store for those models, the same way Stripe stores `Customer`
12
- and `PaymentIntent` objects that you create through Stripe's API.
13
-
14
- If you already have application tables and want those tables to remain
15
- canonical, attach a Data Source. Then Ablo coordinates the write and calls your
16
- app to commit it.
13
+ Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod
14
+ the same way a Prisma project starts with a `schema.prisma`. Your schema
15
+ describes your data once, and everything else (the SDK, agents, and your
16
+ database connection) relies on that one definition.
17
17
 
18
18
  Your app can keep using its own `DATABASE_URL`. Store that value in your app or
19
19
  backend environment, not in Ablo. The integration boundary is the HTTPS
@@ -54,17 +54,20 @@ ABLO_API_KEY=sk_live_...
54
54
  The SDK call is the same in both modes:
55
55
 
56
56
  ```ts
57
- await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
58
- await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
59
- const report = ablo.weatherReports.retrieve('report_stockholm');
57
+ await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
58
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
59
+ const report = ablo.weatherReports.get('report_stockholm');
60
60
  ```
61
61
 
62
62
  Only the backing store changes.
63
63
 
64
64
  Multiplayer behavior is the same in both modes. Writes made through
65
65
  `ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
66
- fan out to subscribers. Direct database writes outside Ablo need Data Source
67
- events so connected humans and agents see the change.
66
+ fan out to subscribers. If something writes to your database without going
67
+ through Ablo (a cron job, an admin tool), Ablo can't know about it
68
+ automatically. To keep everyone's screen up to date, your app reports those
69
+ outside changes back through an events feed — shown below in
70
+ [External Writes](#external-writes).
68
71
 
69
72
  ## When To Use A Data Source
70
73
 
@@ -93,13 +96,13 @@ The shape is the same as a production webhook integration:
93
96
  2. Store `ABLO_API_KEY` in your app.
94
97
  3. Verify signed HTTP calls before opening a database transaction.
95
98
  4. Keep your database credentials in your app.
96
- 5. Write an outbox row when data changes outside Ablo.
99
+ 5. Write an outbox row in the same transaction as every app-row change.
97
100
 
98
101
  ## Route
99
102
 
100
103
  ```ts
101
104
  // app/api/ablo/source/route.ts
102
- import { dataSource } from '@abloatai/ablo';
105
+ import { dataSource, sourceEventForOperation } from '@abloatai/ablo';
103
106
  import { schema } from '@/ablo/schema';
104
107
  import { db } from '@/db';
105
108
 
@@ -114,7 +117,23 @@ export const POST = dataSource({
114
117
  async commit({ operations, clientTxId, context }) {
115
118
  const rows = await context.auth.db.transaction(async (tx) => {
116
119
  await tx.idempotency.upsert({ key: clientTxId, operations });
117
- return applyOperations(tx, operations);
120
+ const changes = await applyOperations(tx, operations);
121
+ await tx.outbox.createMany({
122
+ data: changes.map(({ eventId, operation, entityId, data }) =>
123
+ sourceEventForOperation({
124
+ eventId,
125
+ operation,
126
+ entityId,
127
+ data,
128
+ ...(clientTxId ? { clientTxId } : {}),
129
+ ...(context.scope?.organizationId
130
+ ? { organizationId: context.scope.organizationId }
131
+ : {}),
132
+ occurredAt: Date.now(),
133
+ }),
134
+ ),
135
+ });
136
+ return changes.map(({ row }) => row);
118
137
  });
119
138
 
120
139
  return { rows };
@@ -137,11 +156,13 @@ export const POST = dataSource({
137
156
  Your app code still writes through the normal model API:
138
157
 
139
158
  ```ts
140
- await ablo.weatherReports.update(
141
- 'report_stockholm',
142
- { status: 'ready' },
143
- { wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
144
- );
159
+ await ablo.weatherReports.update({
160
+ id: 'report_stockholm',
161
+ data: { status: 'ready' },
162
+ wait: 'confirmed',
163
+ readAt: snap.stamp,
164
+ onStale: 'reject',
165
+ });
145
166
  ```
146
167
 
147
168
  ## Commit Request
@@ -185,10 +206,12 @@ Return canonical rows:
185
206
  Use explicit `deltas` only when your source already computes canonical change
186
207
  events.
187
208
 
188
- ## External Writes
209
+ ## Outbox Events
189
210
 
190
- If your app changes data outside Ablo, return those changes from an `events`
191
- handler so connected humans and agents stay current:
211
+ Return your outbox feed from an `events` handler so connected humans and agents
212
+ stay current. Include SDK-origin events too. If Ablo already appended the commit
213
+ directly, `clientTxId` lets Ablo filter the echo; if the direct append failed,
214
+ the same outbox row repairs it on the next poll or push.
192
215
 
193
216
  ```ts
194
217
  export const POST = dataSource({
@@ -215,7 +238,6 @@ export const POST = dataSource({
215
238
  });
216
239
  ```
217
240
 
218
- `clientTxId` lets Ablo drop SDK echoes that already produced a realtime update.
219
241
  Events without `clientTxId` are treated as external writes.
220
242
 
221
243
  ## Production Checklist
@@ -227,14 +249,14 @@ Before using a customer-owned database in production:
227
249
  - Verify signatures before opening a database transaction.
228
250
  - Store `clientTxId` in an idempotency table before applying writes.
229
251
  - Return canonical rows after each commit.
230
- - Write outbox events in the same transaction as non-Ablo writes.
252
+ - Write outbox events in the same transaction as every app-row write, including
253
+ Data Source `commit` writes.
231
254
  - Dedupe outbox events by event `id`.
232
255
  - Monitor last success, last error, retry count, event lag, and cursor.
233
256
 
234
- Do not send the customer's database URL to Ablo for this path. Direct database
235
- URL custody would be a separate connector product with encrypted secret storage,
236
- rotation, least-privilege roles, connection limits, table allowlists, and clear
237
- data-processing terms.
257
+ Don't give Ablo your database URL for this integration Ablo never connects to
258
+ your database directly. (Direct database access would be a separate product with
259
+ its own security model.)
238
260
 
239
261
  ## Security
240
262
 
@@ -4,21 +4,30 @@ A report-writing agent that yields when a human is editing the same report.
4
4
 
5
5
  ## Scenario
6
6
 
7
- A product queue has reports that humans and agents both update. They must not
8
- collide:
7
+ The same reports are edited by both humans and agents. They must not collide:
9
8
 
10
- - If the user is editing, the agent waits or yields.
11
- - If the agent is updating, the UI can show who is active.
12
- - If the report changes mid-run, the commit rejects instead of overwriting newer
13
- state.
9
+ - If a human already holds the row, the agent yields instead of fighting for it.
10
+ - While the agent is updating, the UI can show who is active.
11
+ - If the report changes mid-run, the commit is rejected instead of overwriting
12
+ the human's newer edit.
13
+
14
+ A **claim** does both jobs. Claims don't lock — if another writer holds the row,
15
+ `claim` waits for them, re-reads the fresh row, then hands it back to you on
16
+ `claim.data`, so two writers serialize instead of clobbering. The handle is an
17
+ `AsyncDisposable`: hold it with `await using` and it releases on scope exit. And
18
+ once you hold a claim, any `update` you make while it's held is stale-checked for
19
+ free: the SDK records the row version you were handed and rejects the write with
20
+ a typed error if the row moved underneath you while the agent was busy.
14
21
 
15
22
  ## Schema-Backed Worker
16
23
 
17
- Use the same schema client the app uses. The worker loads the report, claims the
18
- row, and writes through `ablo.weatherReports.update(...)`.
24
+ The worker uses the same schema client the app uses. It reads the report from
25
+ the server with `retrieve({ id })`, claims the row, and writes through
26
+ `ablo.weatherReports.update(...)` with a stale-check so a human's concurrent edit
27
+ can't be overwritten.
19
28
 
20
29
  ```ts
21
- import Ablo from '@abloatai/ablo';
30
+ import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
22
31
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
23
32
 
24
33
  const schema = defineSchema({
@@ -33,21 +42,53 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
33
42
  export async function markReady(reportId: string) {
34
43
  await ablo.ready();
35
44
 
36
- const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
45
+ // retrieve({ id }) is an async server read await it.
46
+ const report = await ablo.weatherReports.retrieve({ id: reportId });
37
47
  if (!report) return { status: 'not_found' };
38
48
 
39
- const updated = await ablo.weatherReports.claim(
40
- reportId,
41
- async (claimed) =>
42
- ablo.weatherReports.update(
43
- claimed.id,
44
- { status: 'ready' },
45
- { wait: 'confirmed' },
46
- ),
47
- { wait: false, action: 'marking_ready' },
48
- );
49
-
50
- return { status: 'ready', report: updated };
49
+ try {
50
+ // wait: false → don't queue behind a current holder. If a human already
51
+ // holds the row, claim rejects with AbloClaimedError (caught below), so the
52
+ // agent yields instead of waiting. Omit it, or pass wait: true, to queue
53
+ // behind them. action → the label observers see while we work.
54
+ await using claim = await ablo.weatherReports.claim({
55
+ id: reportId,
56
+ wait: false,
57
+ action: 'marking_ready',
58
+ });
59
+ const claimed = claim.data;
60
+
61
+ // Inside an active claim, `update` is stale-checked automatically: the SDK
62
+ // attaches the claim's snapshot version as `readAt` and sets
63
+ // `onStale: 'reject'`. The write below is therefore equivalent to passing
64
+ // those options yourself:
65
+ //
66
+ // ablo.weatherReports.update({
67
+ // id: claimed.id,
68
+ // data: { status: 'ready' },
69
+ // wait: 'confirmed',
70
+ // readAt: <claim snapshot version>,
71
+ // onStale: 'reject',
72
+ // });
73
+ //
74
+ // If a human saved a newer version mid-run, the row no longer matches
75
+ // `readAt`, so the server rejects this commit with AbloStaleContextError
76
+ // (caught below) instead of clobbering their edit.
77
+ const updated = await ablo.weatherReports.update({
78
+ id: claimed.id,
79
+ data: { status: 'ready' },
80
+ wait: 'confirmed',
81
+ });
82
+
83
+ return { status: 'ready', report: updated };
84
+ } catch (err) {
85
+ // A human already holds the row — yield this run and let them finish.
86
+ if (err instanceof AbloClaimedError) return { status: 'yielded' };
87
+ // A human saved a newer version while we held the claim. The stale-check
88
+ // rejected our commit, so nothing was overwritten — re-run on fresh data.
89
+ if (err instanceof AbloStaleContextError) return { status: 'stale' };
90
+ throw err;
91
+ }
51
92
  }
52
93
  ```
53
94
 
@@ -61,8 +102,8 @@ Keep workers on the same schema-backed client as the app.
61
102
  import { useAblo } from '@abloatai/ablo/react';
62
103
 
63
104
  export function ReportRow({ report: serverReport }: Props) {
64
- const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
65
- const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
105
+ const data = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
106
+ const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
66
107
  const agentActive = active?.participantKind === 'agent';
67
108
 
68
109
  return (
@@ -76,7 +117,12 @@ export function ReportRow({ report: serverReport }: Props) {
76
117
 
77
118
  ## Why It Works
78
119
 
79
- - Claims are visible through `claimState(id)` and over the live stream.
80
- - `claim(id, work)` lets agents wait for active work instead of racing.
81
- - `readAt` plus `onStale: 'reject'` turns mid-flight changes into typed errors.
82
- - Audit rows tie each accepted write back to the run that caused it.
120
+ - The claim is visible to everyone: the UI reads it synchronously with
121
+ `claim.state({ id })`, and it also arrives over the live stream.
122
+ - `claim({ id })` makes writers take turns instead of racing with
123
+ `wait: false`, the agent simply yields when a human already holds the row.
124
+ - The `update` made while the claim is held is stale-checked automatically, so a human's
125
+ edit landing mid-run rejects the agent's write with a typed
126
+ `AbloStaleContextError` instead of overwriting it.
127
+ - That same write carries the claim, so each accepted change is attributed to
128
+ the run that made it.