@abloatai/ablo 0.35.0 → 0.36.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 (215) hide show
  1. package/CHANGELOG.md +713 -629
  2. package/README.md +56 -519
  3. package/bin/ablo.cjs +39 -0
  4. package/dist/BaseSyncedStore.d.ts +24 -4
  5. package/dist/BaseSyncedStore.js +53 -37
  6. package/dist/Database.d.ts +8 -20
  7. package/dist/Database.js +61 -59
  8. package/dist/InstanceCache.d.ts +6 -2
  9. package/dist/InstanceCache.js +18 -16
  10. package/dist/Model.d.ts +17 -7
  11. package/dist/Model.js +17 -7
  12. package/dist/ModelRegistry.d.ts +4 -0
  13. package/dist/ModelRegistry.js +17 -15
  14. package/dist/NetworkMonitor.d.ts +3 -1
  15. package/dist/NetworkMonitor.js +7 -5
  16. package/dist/SyncClient.d.ts +5 -15
  17. package/dist/SyncClient.js +60 -57
  18. package/dist/client/Ablo.js +30 -19
  19. package/dist/client/createInternalComponents.d.ts +4 -0
  20. package/dist/client/createInternalComponents.js +8 -2
  21. package/dist/client/createModelProxy.d.ts +21 -1
  22. package/dist/client/createModelProxy.js +123 -57
  23. package/dist/client/humans.d.ts +30 -9
  24. package/dist/client/humans.js +45 -19
  25. package/dist/client/reactiveEngine.d.ts +16 -11
  26. package/dist/client/reactiveEngine.js +113 -335
  27. package/dist/client/storeCluster.d.ts +47 -0
  28. package/dist/client/storeCluster.js +118 -0
  29. package/dist/client/storeLifecycle.d.ts +61 -0
  30. package/dist/client/storeLifecycle.js +231 -0
  31. package/dist/context.d.ts +13 -0
  32. package/dist/context.js +23 -0
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/docs/catalog.js +6 -3
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.js +4 -2
  37. package/dist/query/client.d.ts +3 -0
  38. package/dist/query/client.js +6 -5
  39. package/dist/react/AbloProvider.d.ts +13 -1
  40. package/dist/react/AbloProvider.js +5 -2
  41. package/dist/react/context.d.ts +2 -2
  42. package/dist/react/createAbloReact.d.ts +56 -0
  43. package/dist/react/createAbloReact.js +51 -0
  44. package/dist/react/index.d.ts +1 -0
  45. package/dist/react/index.js +3 -0
  46. package/dist/react/useAblo.d.ts +9 -2
  47. package/dist/react/useAblo.js +25 -7
  48. package/dist/schema/coordination.js +5 -1
  49. package/dist/schema/index.d.ts +1 -0
  50. package/dist/schema/index.js +4 -0
  51. package/dist/schema/select.js +3 -0
  52. package/dist/schema/serialize.js +3 -0
  53. package/dist/source/adapter.d.ts +7 -5
  54. package/dist/source/adapter.js +7 -5
  55. package/dist/source/index.d.ts +1 -1
  56. package/dist/source/index.js +1 -1
  57. package/dist/{core/storeContract.d.ts → storeContract.d.ts} +5 -5
  58. package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
  59. package/dist/{core → stores}/DatabaseManager.js +13 -12
  60. package/dist/{core → stores}/StoreManager.d.ts +5 -3
  61. package/dist/{core → stores}/StoreManager.js +24 -22
  62. package/dist/stores/SyncActionStore.d.ts +3 -1
  63. package/dist/stores/SyncActionStore.js +9 -7
  64. package/dist/sync/BootstrapFetcher.d.ts +4 -0
  65. package/dist/sync/BootstrapFetcher.js +28 -26
  66. package/dist/sync/OnDemandLoader.d.ts +3 -0
  67. package/dist/sync/OnDemandLoader.js +1 -0
  68. package/dist/sync/bootstrapApply.d.ts +3 -0
  69. package/dist/sync/bootstrapApply.js +2 -2
  70. package/dist/sync/deltaPipeline.d.ts +13 -12
  71. package/dist/sync/deltaPipeline.js +21 -4
  72. package/dist/sync/groupChange.d.ts +3 -0
  73. package/dist/sync/groupChange.js +16 -14
  74. package/dist/sync/participants.d.ts +19 -2
  75. package/dist/sync/participants.js +3 -1
  76. package/dist/sync/schemas.d.ts +2 -1
  77. package/dist/sync/schemas.js +3 -3
  78. package/dist/syncLog/contract.d.ts +20 -0
  79. package/dist/syncLog/contract.js +19 -0
  80. package/dist/syncLog/index.d.ts +1 -0
  81. package/dist/syncLog/index.js +1 -0
  82. package/dist/transaction/auth/capability.d.ts +35 -0
  83. package/dist/transaction/auth/capability.js +25 -0
  84. package/dist/transaction/coordination/awaitClaimGrant.d.ts +7 -0
  85. package/dist/transaction/coordination/awaitClaimGrant.js +12 -0
  86. package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +34 -0
  87. package/dist/transaction/coordination/claimHeartbeatLoop.js +20 -0
  88. package/dist/transaction/coordination/index.d.ts +4 -4
  89. package/dist/transaction/coordination/index.js +4 -3
  90. package/dist/transaction/coordination/locator.d.ts +23 -2
  91. package/dist/transaction/coordination/locator.js +22 -2
  92. package/dist/transaction/coordination/schema.d.ts +125 -62
  93. package/dist/transaction/coordination/schema.js +228 -64
  94. package/dist/transaction/coordination/targetConflict.js +32 -28
  95. package/dist/transaction/errorCodes.d.ts +2 -2
  96. package/dist/transaction/errorCodes.js +13 -9
  97. package/dist/transaction/plugin.d.ts +95 -2
  98. package/dist/transaction/plugin.js +21 -2
  99. package/dist/transaction/resources/httpResources.d.ts +57 -2
  100. package/dist/transaction/resources/modelOperations.d.ts +148 -40
  101. package/dist/transaction/resources/where.d.ts +16 -0
  102. package/dist/transaction/resources/where.js +45 -0
  103. package/dist/transaction/schema/field.d.ts +12 -18
  104. package/dist/transaction/schema/fieldRef.d.ts +38 -0
  105. package/dist/transaction/schema/fieldRef.js +11 -0
  106. package/dist/transaction/schema/openapi.d.ts +16 -15
  107. package/dist/transaction/schema/openapi.js +186 -25
  108. package/dist/transaction/schema/relation.d.ts +7 -2
  109. package/dist/transaction/schema/schema.d.ts +27 -0
  110. package/dist/transaction/schema/schema.js +20 -0
  111. package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +1 -1
  112. package/dist/transaction/transactions/settlement/pendingWrite.d.ts +1 -1
  113. package/dist/transaction/transport/httpClient.d.ts +9 -1
  114. package/dist/transaction/transport/httpClient.js +1 -0
  115. package/dist/transaction/transport/httpTransport.js +156 -44
  116. package/dist/transaction/transport/wsTransport.d.ts +2 -4
  117. package/dist/transaction/transport/wsTransport.js +16 -10
  118. package/dist/transaction/types/streams.d.ts +10 -0
  119. package/dist/transaction/utils/duration.d.ts +25 -0
  120. package/dist/transaction/utils/duration.js +32 -0
  121. package/dist/transaction/wire/accountResponses.d.ts +69 -0
  122. package/dist/transaction/wire/accountResponses.js +36 -1
  123. package/dist/transaction/wire/auth.d.ts +9 -2
  124. package/dist/transaction/wire/auth.js +7 -1
  125. package/dist/transaction/wire/claims.d.ts +164 -97
  126. package/dist/transaction/wire/claims.js +126 -28
  127. package/dist/transaction/wire/commit.d.ts +1 -1
  128. package/dist/transaction/wire/feedEvent.d.ts +27 -0
  129. package/dist/transaction/wire/feedEvent.js +27 -1
  130. package/dist/transaction/wire/frames.d.ts +2 -2
  131. package/dist/transaction/wire/inboundFrames.d.ts +12 -2
  132. package/dist/transaction/wire/index.d.ts +10 -6
  133. package/dist/transaction/wire/index.js +12 -3
  134. package/dist/transaction/wire/modelMutations.d.ts +31 -0
  135. package/dist/transaction/wire/modelMutations.js +52 -0
  136. package/dist/transaction/wire/modelShape.d.ts +78 -0
  137. package/dist/transaction/wire/modelShape.js +74 -0
  138. package/dist/transactions/mutations/MutationQueue.d.ts +6 -0
  139. package/dist/transactions/mutations/MutationQueue.js +55 -45
  140. package/dist/transactions/mutations/commitPayload.d.ts +3 -2
  141. package/dist/transactions/mutations/commitPayload.js +5 -5
  142. package/dist/transactions/mutations/deltaConfirmation.d.ts +4 -0
  143. package/dist/transactions/mutations/deltaConfirmation.js +10 -8
  144. package/dist/transactions/mutations/replayValidation.d.ts +2 -1
  145. package/dist/transactions/mutations/replayValidation.js +3 -2
  146. package/dist/{core → views}/QueryView.d.ts +1 -1
  147. package/dist/{core → views}/QueryView.js +1 -1
  148. package/dist/{core → views}/ViewRegistry.d.ts +1 -1
  149. package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
  150. package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
  151. package/docs/agents.md +1 -1
  152. package/docs/api-keys.md +6 -6
  153. package/docs/api.md +5 -43
  154. package/docs/audit.md +4 -3
  155. package/docs/cli.md +11 -11
  156. package/docs/client-behavior.md +4 -4
  157. package/docs/concurrency-convention.md +28 -42
  158. package/docs/coordination.md +235 -83
  159. package/docs/data-sources.md +4 -4
  160. package/docs/debugging.md +34 -12
  161. package/docs/deployment.md +8 -8
  162. package/docs/examples/scoped-agent.md +3 -3
  163. package/docs/groups.md +57 -3
  164. package/docs/guarantees.md +37 -10
  165. package/docs/how-it-works.md +29 -5
  166. package/docs/idempotency.md +6 -6
  167. package/docs/identity.md +22 -23
  168. package/docs/index.md +8 -8
  169. package/docs/integration-guide.md +14 -3
  170. package/docs/mcp.md +7 -7
  171. package/docs/migration.md +34 -15
  172. package/docs/projects.md +1 -1
  173. package/docs/react.md +19 -8
  174. package/docs/sessions.md +1 -1
  175. package/docs/webhooks.md +9 -9
  176. package/llms.txt +4 -4
  177. package/package.json +13 -20
  178. package/dist/cli.cjs +0 -288600
  179. package/dist/testing/fixtures/bootstrap.d.ts +0 -49
  180. package/dist/testing/fixtures/bootstrap.js +0 -59
  181. package/dist/testing/fixtures/deltas.d.ts +0 -83
  182. package/dist/testing/fixtures/deltas.js +0 -136
  183. package/dist/testing/fixtures/httpResponses.d.ts +0 -70
  184. package/dist/testing/fixtures/httpResponses.js +0 -90
  185. package/dist/testing/fixtures/models.d.ts +0 -83
  186. package/dist/testing/fixtures/models.js +0 -272
  187. package/dist/testing/helpers/reactWrapper.d.ts +0 -69
  188. package/dist/testing/helpers/reactWrapper.js +0 -67
  189. package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
  190. package/dist/testing/helpers/syncEngineHarness.js +0 -73
  191. package/dist/testing/helpers/wait.d.ts +0 -30
  192. package/dist/testing/helpers/wait.js +0 -49
  193. package/dist/testing/index.d.ts +0 -23
  194. package/dist/testing/index.js +0 -33
  195. package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
  196. package/dist/testing/mocks/FakeDatabase.js +0 -10
  197. package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
  198. package/dist/testing/mocks/MockMutationExecutor.js +0 -186
  199. package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
  200. package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
  201. package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
  202. package/dist/testing/mocks/MockSyncContext.js +0 -72
  203. package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
  204. package/dist/testing/mocks/MockSyncStore.js +0 -171
  205. package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
  206. package/dist/testing/mocks/MockWebSocket.js +0 -118
  207. package/docs/interaction-model.md +0 -99
  208. /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
  209. /package/dist/{core → query}/QueryProcessor.js +0 -0
  210. /package/dist/{core/storeContract.js → storeContract.js} +0 -0
  211. /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
  212. /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
  213. /package/dist/{source → transaction}/footprint.d.ts +0 -0
  214. /package/dist/{source → transaction}/footprint.js +0 -0
  215. /package/dist/{core → views}/ViewRegistry.js +0 -0
@@ -38,21 +38,36 @@ Reads stay open: reading a claimed row is allowed unless the caller explicitly
38
38
  asks for claimed gating. A claim carries a TTL so a crashed holder is
39
39
  auto-released and the queue advances.
40
40
 
41
- > **Transport: WebSocket queues, HTTP reconciles.** The "blocks until promoted"
42
- > behaviour above needs a live socket to wake the waiter, so it applies to the
43
- > **WebSocket client only**. On the **stateless HTTP client**
44
- > (`Ablo({ transport: 'http' })` the transport server-side agents use) there is
45
- > nothing to park a waiter, so a contended `claim` does **not** wait: it acquires
46
- > immediately, or rejects right away with `AbloClaimedError('claim_queued')`
47
- > (`queue: true`, the default) / `AbloClaimedError('entity_claimed')`
48
- > (`queue: false`). And because short claim→write→release cycles rarely overlap,
49
- > concurrent HTTP agents usually all acquire, then collide at **write** time the
50
- > first write wins and the rest get `AbloStaleContextError('stale_context')`.
51
- > The coordination pattern over HTTP is therefore a **reconcile loop**, not a
52
- > queue: catch `claim_queued` / `claim_lost` / `stale_context`, re-read fresh,
53
- > regenerate, retry. See [Errors](#errors) and the loop sketch there.
54
-
55
- This reference opens with [the model](#the-model--three-layers-one-decision) — the
41
+ A claim is also as narrow as you make it. Name a part of the row — a `field`, a
42
+ `path` into a document, a `range` of text and exclusion follows the target:
43
+ **two claims on non-overlapping parts of the same row are both granted**, and
44
+ only overlapping targets queue behind each other. See
45
+ [claiming part of a row](#claiming-part-of-a-row).
46
+
47
+ > **Transport: both wait — only the mechanism differs.** `claim({ id })` means
48
+ > "serialize me behind whoever holds this row" on every transport. The
49
+ > realtime client parks the promise on its socket and resolves it on the grant
50
+ > frame. The **stateless HTTP client** (`Ablo({ transport: 'http' })` — the
51
+ > transport server-side agents use) holds the same place in the same
52
+ > server-side FIFO line; under the hood it heartbeats its queued ticket until
53
+ > the line moves, then re-reads the row and resolves to the same held claim.
54
+ > The same snippet works on both.
55
+ >
56
+ > Shape the wait the same way on either transport: cap it with
57
+ > `waitTimeoutMs` (rejects `grant_timeout` and leaves the line), cancel it
58
+ > from outside with `signal` (an `AbortSignal`; rejects
59
+ > `claim_wait_aborted`), bound the line you'll join with `maxQueueDepth`
60
+ > (`queue_too_deep`), or skip waiting entirely with `queue: false` — the
61
+ > try-claim, which resolves `null` when the target is held (a declined try
62
+ > is not an error) and takes no place in line. For
63
+ > callers that manage the wait themselves, the ticket surface remains:
64
+ > `ablo.claims.retrieve({ claimId })` polls a ticket to its grant,
65
+ > `ablo.claims.heartbeat({ claimId })` keeps the slot, and
66
+ > `ablo.claims.release({ claimId })` leaves the line. And contention can be
67
+ > treated as a signal rather than a wait at all — catch the error, re-read
68
+ > fresh, regenerate, retry; see [Errors](#errors) for the loop sketch.
69
+
70
+ This reference opens with [the model](#the-model-three-layers-one-decision) — the
56
71
  one answer to "how do two agents not clobber each other" — then covers the
57
72
  [claim state object](#the-claim-state-object), the SDK [methods](#methods)
58
73
  (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
@@ -83,7 +98,7 @@ claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
83
98
 
84
99
  ---
85
100
 
86
- ## The model three layers, one decision
101
+ ## The model: three layers, one decision
87
102
 
88
103
  Ablo has exactly **three** coordination layers. They are **not** three competing
89
104
  answers to the same question — they stack, and only one of them is a decision you
@@ -91,9 +106,9 @@ make:
91
106
 
92
107
  | layer | kind | what it does | enforces? |
93
108
  |---|---|---|---|
94
- | **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only it never blocks or rejects a write. |
95
- | **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. |
96
- | **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. |
109
+ | **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only: it never blocks or rejects a write. |
110
+ | **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. |
111
+ | **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. |
97
112
 
98
113
  **The one decision: do you hold the row across a slow gap (read → LLM call →
99
114
  write)?**
@@ -205,14 +220,15 @@ a model row. It's what `claim.state()` returns and what observers render.
205
220
  | field | type | description |
206
221
  |---|---|---|
207
222
  | `id` | `string` | The claim id (distinct from the target row id). |
208
- | `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). |
209
- | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
210
- | `description` | `string` | Peer-visible description of the work the sentence another participant reads to decide whether to wait or move on (`'rewriting the risk section'`). Defaults to `'editing'`. |
223
+ | `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). |
224
+ | `target` | `EntityRef` | What is being coordinated: the row (`{ model, id }`) plus any sub-row narrowing the holder claimed: `path?`, `range?`, `field?`, `fields?`, and opaque `meta?`. A target with no narrowing covers the whole row. |
225
+ | `description` | `string` | Peer-visible description of the work: the sentence another participant reads to decide whether to wait or move on (`'rewriting the risk section'`). Defaults to `'editing'`. |
211
226
  | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
212
- | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
213
- | `position` | `number?` | 0-based place in the FIFO line present only when `status: 'queued'` (`0` = next behind the holder). |
214
- | `createdAt` | `number?` | Ms-epoch the holder opened it. Optional derived shapes may omit it. |
215
- | `expiresAt` | `number` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive a crash-cleanup floor, not a duration you size. |
227
+ | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it: a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
228
+ | `position` | `number?` | 0-based place in the FIFO line: present only when `status: 'queued'` (`0` = next behind the holder). |
229
+ | `createdAt` | `number?` | Ms-epoch the holder opened it. Optional: derived shapes may omit it. |
230
+ | `expiresAt` | `number` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive: a crash-cleanup floor, not a duration you size. |
231
+ | `meta` | `Record<string, unknown>?` | The claim's open metadata bag, as it stands on the wire. A [heartbeat](#heartbeat-holding-a-claim-for-long-running-work) writes its `details` here under `progress`: last beat wins, so an observer can read what a long hold is doing without the holder releasing it. Distinct from `target.meta`, which is the shape your program declared: a declared shape has no member for a key the coordinator wrote. |
216
232
 
217
233
  ```jsonc
218
234
  {
@@ -223,23 +239,49 @@ a model row. It's what `claim.state()` returns and what observers render.
223
239
  "heldBy": "agent:forecaster",
224
240
  "participantKind": "agent",
225
241
  "createdAt": 1748160000000,
226
- "expiresAt": 1748160030000
242
+ "expiresAt": 1748160030000,
243
+ "meta": { "progress": { "phase": "writing", "done": 2, "of": 5 } }
227
244
  }
228
245
  ```
229
246
 
247
+ ### Lifecycle
248
+
249
+ ```
250
+ claim({ id }) update({ id }) lands
251
+ (free) ───────────▶ active ───────────────────────▶ committed
252
+
253
+ ┌───────────┴───────────┐
254
+ ▼ ▼
255
+ canceled expired
256
+ (release w/o write) (TTL; holder died)
257
+ ```
258
+
259
+ A target is free when `ablo.<model>.claim.state({ id })` returns `null`. Terminal
260
+ states drop out of the live stream, so a claim you can see is either `active`
261
+ (the holder) or `queued` (waiting in the FIFO line behind it; see
262
+ [`claim.queue`](#claimqueue)).
263
+
264
+ Reading a holder's progress is the same synchronous read as everything else
265
+ here — no second subscription, and nothing to poll:
266
+
267
+ ```ts
268
+ const held = ablo.documents.claim.state({ id: docId });
269
+ const phase = held?.meta?.progress?.phase ?? 'reading';
270
+ ```
271
+
230
272
  ---
231
273
 
232
274
  ## Methods
233
275
 
234
276
  One word — "claim" — names four distinct things; keep them separate as you read:
235
277
 
236
- - **the lease (claim handle)** the *object* returned by `ablo.<model>.claim({ id })`
278
+ - **the lease (claim handle):** the *object* returned by `ablo.<model>.claim({ id })`
237
279
  (`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
238
- - **acquiring a claim/lease** the *verb* `ablo.<model>.claim({ id })`, the call
280
+ - **acquiring a claim/lease:** the *verb* `ablo.<model>.claim({ id })`, the call
239
281
  that takes the lease.
240
- - **`claim.state` / `claim.queue`** the *inspection namespace* hanging off the
282
+ - **`claim.state` / `claim.queue`:** the *inspection namespace* hanging off the
241
283
  model, for reading who holds the row and who's lined up.
242
- - **the write's `claim` param** `update({ id, data, claim })`, where you pass a
284
+ - **the write's `claim` param:** `update({ id, data, claim })`, where you pass a
243
285
  lease the proxy didn't take itself.
244
286
 
245
287
  Each method below follows one fixed shape: **signature · what it does ·
@@ -259,16 +301,41 @@ then re-reads so the claimed snapshot reflects what the previous holder
259
301
  committed. There's no polling and no race window — the server decides the order,
260
302
  so two claimers can't both think they won.
261
303
 
262
- **Parameters**
304
+ **Parameters** — every option is flat on the call, and each sits on one of
305
+ four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
306
+
307
+ *What you claim* — the target, narrowed below the row:
308
+
309
+ | name | type | required | description |
310
+ |---|---|---|---|
311
+ | `id` | `string` | yes | The row id: same id as `retrieve` / `update`. |
312
+ | `options.field` | `ClaimField` | no | **Deprecated: say it as a set: `fields: ['title']`. Removed in 0.37.0.** One member for a set of one, beside another for a set of any size, is two ways to say one thing, and the singular is the one that misled: a caller needing two parts packed them into it. The wire still reads `field`, so this changes what you write, not what is understood. |
313
+ | `options.fields` | `ClaimField[]` | no | Claim named parts of the row instead of all of it: one or several. Prefer the selector form, `fields: (f) => [f.status]`, where the model hands you its own fields, so a field it does not have stops compiling and a rename is a compile error at every use. A bare name (`'status'`) autocompletes but is not checked; an app-defined part, a cell, a section, is named with `part('B2')`. Two sets conflict where they intersect, so holders of disjoint parts do not wait for each other, see [claiming part of a row](#claiming-part-of-a-row). A name containing a comma is refused: two names in one string compare as a single unrelated name, and both writers would be granted the same part. |
314
+ | `options.path` | `string` | no | A hierarchical position in a document-shaped row: `'/content/3'` claims one block. Paths conflict on containment: `/content` covers `/content/3`, while `/content/3` and `/content/7` are disjoint and both granted. |
315
+ | `options.range` | `TargetRange` | no | A span of the row's text: `{ startLine, endLine, startColumn?, endColumn? }`. Ranges conflict only where their line intervals overlap. An editor addressing by integer position maps it onto `startLine`/`endLine`: the test is plain interval intersection. |
316
+
317
+ *What others see* — the presence half:
263
318
 
264
319
  | name | type | required | description |
265
320
  |---|---|---|---|
266
- | `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
267
321
  | `options.description` | `string` | no | Peer-visible description of the work, shown to observers (default `'editing'`). |
268
- | `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
269
- | `options.queue` | `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). |
322
+ | `options.meta` | `object` | no | App-defined structured metadata, carried verbatim to every participant observing the claim. Declare its shape once on `Register`'s `ClaimMeta` slot. |
323
+
324
+ *How you wait* — admission to the line:
325
+
326
+ | name | type | required | description |
327
+ |---|---|---|---|
328
+ | `options.queue` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is the try-claim: if another participant holds the row it resolves `null`: an expected outcome, not an error, so claim-or-skip dedup reads `if (!claim) return` (waiting would double-process). Who holds it stays readable via `claim.state`. A *write* to a held row still rejects `entity_claimed`. |
270
329
  | `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. |
271
- | `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. |
330
+ | `options.waitTimeoutMs` | `number` | no | Cap on how long a queued claim waits for its grant before rejecting with `AbloClaimedError('grant_timeout')`. Omit to wait as long as the line takes. Same meaning on both transports; over HTTP a timed-out wait also leaves the line. |
331
+ | `options.signal` | `AbortSignal` | no | Abort a pending wait from outside: a cancelled agent task or an unmounted component takes its queued claim with it. Rejects with `AbloClaimedError('claim_wait_aborted')`; over HTTP the abort also leaves the line. Ignored once the grant has arrived, release a held lease instead. |
332
+
333
+ *How long you hold* — the lease:
334
+
335
+ | name | type | required | description |
336
+ |---|---|---|---|
337
+ | `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. |
338
+ | `options.heartbeat` | `true \| Duration \| { every?, onBeat?, onLost? }` | no | Keep the lease alive for work that outlives the TTL: `true` beats every third of the TTL, a duration sets the cadence, and the structured form carries the cadence and both callbacks in one place: `onBeat` fires after every successful beat (chiefly `queueDepth`, the pressure signal), `onLost` once if a beat learns the lease is gone. The loop stops on release. |
272
339
 
273
340
  The high-level `claim` queues by default, so on contention you either get the row
274
341
  when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
@@ -300,6 +367,65 @@ committed — pass an idempotency key on the write if you replay the block.) The
300
367
  lower-level [`claim.release`](#claimrelease) shows the manual `try/finally`
301
368
  equivalent for when you hold a claim without `await using`.
302
369
 
370
+ ### Claiming part of a row
371
+
372
+ Name a field and a typo cannot survive — the model is already bound by the call,
373
+ so it hands you its own fields:
374
+
375
+ ```ts
376
+ await using mine = await ablo.tasks.claim({ id, fields: (f) => [f.status] });
377
+ ```
378
+
379
+ `f.status` is checked against the model: a field it does not have stops
380
+ compiling, and renaming one is a compile error at every use. Nothing to import,
381
+ nothing to add to your schema file.
382
+
383
+ `fields: ['status']` still works and still autocompletes; it just accepts
384
+ `'titel'` too, which is granted, excludes nobody, and leaves the write of
385
+ `title` unguarded.
386
+
387
+ A claim covers the whole row only when you name nothing narrower. Name a
388
+ target — a `path` into a document, a `range` of text, a `field` or set of
389
+ `fields` — and exclusion follows it: **two claims on non-overlapping parts of
390
+ the same row are both granted**, and only overlapping targets queue behind
391
+ each other.
392
+
393
+ ```ts
394
+ // Agent A holds one block of the document…
395
+ await using intro = await ablo.documents.claim({
396
+ id: docId,
397
+ path: '/content/3',
398
+ description: 'rewriting the risk section',
399
+ });
400
+
401
+ // …while agent B, in another process, holds a different block of the SAME
402
+ // row. Granted immediately — /content/3 and /content/7 do not overlap.
403
+ await using summary = await ablo.documents.claim({
404
+ id: docId,
405
+ path: '/content/7',
406
+ description: 'tightening the summary',
407
+ });
408
+ ```
409
+
410
+ Overlap is judged per axis:
411
+
412
+ - **`path`:** hierarchical containment, on a separator boundary. `/content`
413
+ covers `/content/3`, so the parent's holder queues behind (or is queued
414
+ behind by) the child's; `/content/3` and `/content/7` are disjoint, and
415
+ `/content` never collides with `/contentious`.
416
+ - **`field` / `fields`:** set intersection. Holders on `title` and `status`
417
+ proceed concurrently; naming no field covers all of them.
418
+ - **`range`:** interval intersection on `[startLine, endLine]`. An editor
419
+ that addresses text by integer position rather than by line maps that
420
+ position axis straight onto `startLine`/`endLine`: the server's test is
421
+ plain interval intersection and never assumes the units are lines of a
422
+ file.
423
+
424
+ A claim with no target is the widest parent: it covers every part of the row
425
+ and conflicts with any narrower claim on it. Region locking on a single
426
+ document row is therefore one claim per region — the row stays one row, and
427
+ the claims carve it up.
428
+
303
429
  ### Claim-gated reads
304
430
 
305
431
  `claim.state({ id })` always returns immediately. Model reads such as
@@ -347,8 +473,15 @@ whole coordination API.
347
473
  |---|---|---|---|
348
474
  | `id` | `string` | yes | The row id. |
349
475
 
350
- **Returns** — the active [claim state object](#the-claim-state-object), or `null` when the row
351
- is free.
476
+ **Returns** — an active [claim state object](#the-claim-state-object) on the row, or
477
+ `null` when the row is free.
478
+
479
+ **One holder, and a row can have several.** This reads a row, not a target, and
480
+ answers with a single claim. That is the whole story for a whole-row claim, and
481
+ only part of it once you [claim parts of a row](#claiming-part-of-a-row): three
482
+ agents holding `/content/3`, `/content/7`, and `title` are all active at once,
483
+ and this read surfaces one of them. To render every holder — a rail per claimed
484
+ block, a chip per participant — use [`claim.list`](#claimlist).
352
485
 
353
486
  **Example**
354
487
 
@@ -371,6 +504,45 @@ Returns the active claim state when the row is held, or `null` when it's free:
371
504
  }
372
505
  ```
373
506
 
507
+ ### `claim.list`
508
+
509
+ ```ts
510
+ ablo.<model>.claim.list({ id })
511
+ ```
512
+
513
+ Every holder of a row. Same synchronous, reactive read as `claim.state` — off
514
+ the same local snapshot, safe to call inline in a render — and the same list
515
+ envelope as [`claim.queue`](#claimqueue).
516
+
517
+ Reach for it whenever a row can be claimed in parts. One agent rewriting
518
+ `/content/3` while another tightens `/content/7` are two active claims on one
519
+ row, and only this read returns both.
520
+
521
+ **Parameters**
522
+
523
+ | name | type | required | description |
524
+ |---|---|---|---|
525
+ | `id` | `string` | yes | The row id. |
526
+
527
+ **Returns** — `{ object: 'list', data: Claim[] }`. Your own claim comes first
528
+ when this client holds one, then the other participants'. Empty `data` when the
529
+ row is free.
530
+
531
+ **Example** — a rail for every claimed block:
532
+
533
+ ```tsx
534
+ const { data: holders } = ablo.documents.claim.list({ id: docId });
535
+
536
+ return blocks.map((block) => {
537
+ const held = holders.find((c) => c.target.path === `/content/${block.index}`);
538
+ return <Block key={block.id} rail={held?.heldBy} note={held?.description} />;
539
+ });
540
+ ```
541
+
542
+ `claim.state({ id })` remains the right read when a row is claimed whole, or
543
+ when all you need is "is anyone working here" — it answers with one claim and
544
+ `null` when the row is free.
545
+
374
546
  ### `claim.queue`
375
547
 
376
548
  ```ts
@@ -436,7 +608,7 @@ try {
436
608
  }
437
609
  ```
438
610
 
439
- ### `heartbeat` holding a claim for long-running work
611
+ ### `heartbeat`: holding a claim for long-running work
440
612
 
441
613
  ```ts
442
614
  held.heartbeat(ttl?: Duration): Promise<{ expiresAt: number }>
@@ -457,8 +629,7 @@ await using claim = await ablo.reports.claim({
457
629
  id: 'report_q3',
458
630
  description: 'generating',
459
631
  ttl: '5m',
460
- heartbeat: true, // or an explicit cadence: heartbeat: '2m'
461
- onHeartbeatLost: () => abortWork(),
632
+ heartbeat: { onLost: () => abortWork() }, // `true` and '2m' are the shorthands
462
633
  });
463
634
  await runLongGeneration(claim.data); // lease held for the duration
464
635
  // scope exit releases; the loop stops with it
@@ -473,11 +644,11 @@ failures (a connection blip) don't stop the loop — the next tick retries.
473
644
 
474
645
  Each beat's answer carries two more things:
475
646
 
476
- - **`queueDepth`** how many participants wait in line behind the lease.
647
+ - **`queueDepth`:** how many participants wait in line behind the lease.
477
648
  This is the cooperative-yield pressure signal: a worker that can checkpoint
478
649
  may release early when others wait. Read it from the resolved beat, or pass
479
- `onHeartbeat` when claiming to observe every auto-beat.
480
- - **progress `details`** `held.heartbeat({ details: { pages: 42, of: 100 } })`
650
+ `heartbeat: { onBeat }` when claiming to observe every auto-beat.
651
+ - **progress `details`:** `held.heartbeat({ details: { pages: 42, of: 100 } })`
481
652
  stores the payload as the claim's peer-visible `meta.progress` (last beat
482
653
  wins, via `claim.state`). This is presence, not a checkpoint: it dies with
483
654
  the lease. Durable progress belongs in the data itself — write a row, and
@@ -507,7 +678,7 @@ A stateless worker holding **many** rows beats them all in one round trip:
507
678
  entry per extended lease. This is the socketless twin of the realtime
508
679
  keepalive, which already renews every held lease on each ping.
509
680
 
510
- ### durability what a claim survives
681
+ ### durability: what a claim survives
511
682
 
512
683
  A lease belongs to your **identity** — the participant behind the credential —
513
684
  not to the socket it was claimed on; the server keys each lease by participant
@@ -547,12 +718,12 @@ snapshot.
547
718
 
548
719
  | the holder… | what happens to the claim |
549
720
  | --- | --- |
550
- | blips, then reconnects within the window | renewed automatically on reconnect no interruption |
721
+ | blips, then reconnects within the window | renewed automatically on reconnect: no interruption |
551
722
  | crashes or drops for good | released within one keepalive cycle; the queue advances |
552
- | still has a second live connection | survives release fires only on the last connection |
723
+ | still has a second live connection | survives: release fires only on the last connection |
553
724
  | loses the server to a restart | rides the TTL in the coordination store; re-announced on reconnect |
554
725
 
555
- ### `join` presence for a set of rows
726
+ ### `join`: presence for a set of rows
556
727
 
557
728
  Reading or claiming a row auto-enrolls you in its sync group, which is enough for
558
729
  `claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
@@ -625,15 +796,15 @@ inspect the `code`.
625
796
 
626
797
  | error | `code` | thrown when | carries |
627
798
  |---|---|---|---|
628
- | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited) while you were holding or waiting. | `claims?` |
629
- | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable re-attempt the claim. | `claims?` |
799
+ | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away: the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited), while you were holding or waiting. | `claims?` |
800
+ | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable: re-attempt the claim. | `claims?` |
630
801
  | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
631
- | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join fail-fast instead of waiting. | `claims?` |
632
- | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds the server's pre-commit check rejected it. | |
633
- | `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | |
634
- | `AbloStaleContextError` | | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot your reasoning is stale. | `readAt`, `conflicts[]` |
635
- | `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. | |
636
- | `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | |
802
+ | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join: fail-fast instead of waiting. | `claims?` |
803
+ | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds: the server's pre-commit check rejected it. |: |
804
+ | `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. |: |
805
+ | `AbloStaleContextError` |: | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot: your reasoning is stale. | `readAt`, `conflicts[]` |
806
+ | `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime: an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. |: |
807
+ | `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. |: |
637
808
 
638
809
  `AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
639
810
  that moved during your generation window — use it for selective regeneration
@@ -741,9 +912,9 @@ state**, which is the shape that races. The other two aren't read-modify-write:
741
912
 
742
913
  | Verb | Functional form? | Why | Its "just works" property |
743
914
  | --- | --- | --- | --- |
744
- | `update` | **yes** `update(id, current => next)` | next value depends on the current one (lost-update hazard) | compare-and-swap + reconcile |
745
- | `create` | no `create({ data, id? })` | no prior state to read; the hazard is *id collision*, a terminal `unique_violation`, not a lost update | **idempotency** stable id / `idempotencyKey` makes a retried create safe |
746
- | `delete` | no `delete({ id })` | no resulting state to compute; "make it not exist" is unchanged by concurrent edits, and delete is idempotent | naturally idempotent |
915
+ | `update` | **yes**: `update(id, current => next)` | next value depends on the current one (lost-update hazard) | compare-and-swap + reconcile |
916
+ | `create` | no: `create({ data, id? })` | no prior state to read; the hazard is *id collision*, a terminal `unique_violation`, not a lost update | **idempotency**: stable id / `idempotencyKey` makes a retried create safe |
917
+ | `delete` | no: `delete({ id })` | no resulting state to compute; "make it not exist" is unchanged by concurrent edits, and delete is idempotent | naturally idempotent |
747
918
 
748
919
  The same reason React has `setState(prev => next)` but no functional mount /
749
920
  unmount. A *conditional* delete ("only if unchanged since I read it") is the one
@@ -752,38 +923,19 @@ not a function.
752
923
 
753
924
  ---
754
925
 
755
- ## Observability — the `ClaimLog`
926
+ ## Observability
756
927
 
757
928
  Coordination you can't see is coordination you can't debug. Pass an
758
929
  `observability` provider to `Ablo({ ... })` and the client reports every claim
759
930
  lifecycle event and stale-write collision it sees. The batteries-included
760
- provider is `ClaimLog`:
931
+ provider is `ClaimLog`, and `collisions()` is the eval primitive:
761
932
 
762
933
  ```ts
763
- import Ablo, { ClaimLog } from '@abloatai/ablo';
764
-
765
934
  const log = new ClaimLog();
766
935
  const ablo = Ablo({ schema, apiKey, observability: log });
767
- // …run your agents…
768
936
 
769
- log.entries // full ordered timeline, one readable line each
770
- log.collisions() // just the collisions: rejected/lost claims + stale writes
771
- log.toString() // pretty, greppable timeline to print
772
- log.onChange(fn) // reactive subscribe → drive a live activity feed / useSyncExternalStore
937
+ expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
773
938
  ```
774
939
 
775
- `collisions()` is the eval primitive `expect(log.collisions()).toHaveLength(0)`
776
- asserts "no one stepped on anyone." Each line names the row it touched, e.g.
777
- `conflict: tx … — 1 row(s) changed underneath: reports/r1`.
778
-
779
- `ClaimLog` implements the full `SyncObservabilityProvider`, so it drops straight
780
- into the `observability` slot; spread `noopObservability` if you only want to
781
- override a few hooks (e.g. forward to Sentry/OTel). Exports: `ClaimLog`,
782
- `formatClaim`, `formatConflict`, `noopObservability`, and the types `ClaimEvent`,
783
- `ConflictEvent`, `ClaimLogEntry`, `SyncObservabilityProvider`.
784
-
785
- > **Both transports, from 0.21.0.** Observability fires on the WebSocket **and**
786
- > the stateless HTTP transport (claim acquired + coordination-conflict
787
- > rejections, on every write door). Before 0.21.0 only WebSocket emitted, so a
788
- > `ClaimLog` on an HTTP client — e.g. a headless server-agent eval — stayed
789
- > silent even though coordination still worked.
940
+ See [Debugging & Logs](./debugging.md) for the setup, the event shapes, a
941
+ reactive activity feed, and routing events to your own backend.
@@ -75,7 +75,7 @@ Run it against your database as a superuser or the DB owner. It creates:
75
75
 
76
76
  Scope it to a subset with `npx ablo connect --tables a,b,c`.
77
77
 
78
- - **A replication role** it streams the WAL and `SELECT`s, nothing more. This is
78
+ - **A replication role:** it streams the WAL and `SELECT`s, nothing more. This is
79
79
  the role Ablo reads and confirms through. You choose the password; it never
80
80
  passes through Ablo's CLI or servers:
81
81
 
@@ -87,7 +87,7 @@ Run it against your database as a superuser or the DB owner. It creates:
87
87
  On Amazon RDS the `REPLICATION` attribute is granted, not set directly:
88
88
  `GRANT rds_replication TO "ablo_replicator";`.
89
89
 
90
- - **A scoped writer role** the role Ablo writes your rows through. It gets row
90
+ - **A scoped writer role:** the role Ablo writes your rows through. It gets row
91
91
  DML (`SELECT, INSERT, UPDATE, DELETE`) and the sync ledger, and nothing else: no
92
92
  `REPLICATION`, no schema `CREATE`, `NOSUPERUSER NOBYPASSRLS`, row security on. It
93
93
  can change rows in your tables; it cannot change your database:
@@ -189,7 +189,7 @@ A commit is accepted the moment Ablo takes it (`queued`); it becomes `confirmed`
189
189
  once the row appears on your WAL. See [Guarantees](./guarantees.md) for what each
190
190
  state means and when to wait.
191
191
 
192
- ## What Ablo touches in your database the honest footprint
192
+ ## What Ablo touches in your database: the honest footprint
193
193
 
194
194
  This is the complete list. Nothing else.
195
195
 
@@ -197,7 +197,7 @@ This is the complete list. Nothing else.
197
197
  |---|---|---|
198
198
  | `ablo_publication` | A publication naming the tables Ablo reads and confirms against. | You create it (step 2). |
199
199
  | `ablo_replicator` role | A `REPLICATION` + `SELECT` role Ablo reads and confirms through. | You create it (step 2). |
200
- | `ablo_writer` role | A scoped DML role Ablo writes your rows through row DML + ledger, nothing more. | You create it (step 2). |
200
+ | `ablo_writer` role | A scoped DML role Ablo writes your rows through: row DML + ledger, nothing more. | You create it (step 2). |
201
201
  | Replication slot | A logical slot Ablo subscribes through to track its WAL position. | Ablo's runtime creates it on first connect. |
202
202
  | `wal_level = logical` | A server setting that **requires a restart**. | You set it (step 1). |
203
203
 
package/docs/debugging.md CHANGED
@@ -13,6 +13,17 @@ import { schema } from './ablo/schema';
13
13
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true });
14
14
  ```
15
15
 
16
+ ## CLI environment and target
17
+
18
+ The CLI checks an explicit credential in this order: exported `ABLO_API_KEY`,
19
+ `.env.local`, `.env`, then the key saved by `ablo login`. An exported value wins
20
+ over project files. If a stale shell export (for example `OPENAI_API_KEY`) is
21
+ shadowing a value in `.env.local`, restart the shell or unset the stale variable.
22
+
23
+ When a command appears to use the wrong plane or project, run `ablo status`.
24
+ It reports the credential source and, when reachable, the server-confirmed
25
+ project and environment; that confirmed target is authoritative.
26
+
16
27
  `debug: true` is the simple switch. For finer control use `logLevel`, or set it without touching code via the `ABLO_LOG_LEVEL` environment variable.
17
28
 
18
29
  ```ts
@@ -31,15 +42,15 @@ ABLO_LOG_LEVEL=debug npm run dev # same, from the environment
31
42
  |---|---|
32
43
  | `silent` | nothing |
33
44
  | `error` | failures only |
34
- | `warn` | **default** warnings + errors |
45
+ | `warn` | **default**: warnings + errors |
35
46
  | `info` | the above + the **coordination trace** (claims, grants, queueing) + connection state |
36
- | `debug` | the above + internal lifecycle (per-model registration, store hydration) the full firehose |
47
+ | `debug` | the above + internal lifecycle (per-model registration, store hydration): the full firehose |
37
48
 
38
49
  Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then `ABLO_LOG_LEVEL`, then the `warn` default. `debug: false` (or omitting it) just means "don't raise the level."
39
50
 
40
51
  > For watching coordination, **`logLevel: 'info'` is the sweet spot** — you get the claim trace without the per-model registration chatter that `debug` adds.
41
52
 
42
- ## What you'll see the coordination trace
53
+ ## What you'll see: the coordination trace
43
54
 
44
55
  These lines (all at `info`) let you watch the handover you built:
45
56
 
@@ -54,12 +65,12 @@ These lines (all at `info`) let you watch the handover you built:
54
65
 
55
66
  Read it as the lifecycle of one claim:
56
67
 
57
- - **`requesting`** your code (or an agent) called `ablo.<model>.claim(...)`. `(will queue if contended)` appears when you passed `{ queue: true }`.
58
- - **`queued … position N of M`** the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
59
- - **`granted … your turn`** you reached the head of the line; the lease is now yours and the row may have changed while you waited.
60
- - **`rejected … held by <who>`** your claim was refused because someone else holds it (and the model's policy didn't let you in).
61
- - **`lost`** you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
62
- - **`released`** you (or `await using`'s scope exit) gave the lease back.
68
+ - **`requesting`:** your code (or an agent) called `ablo.<model>.claim(...)`. `(will queue if contended)` appears when you passed `{ queue: true }`.
69
+ - **`queued … position N of M`:** the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
70
+ - **`granted … your turn`:** you reached the head of the line; the lease is now yours and the row may have changed while you waited.
71
+ - **`rejected … held by <who>`:** your claim was refused because someone else holds it (and the model's policy didn't let you in).
72
+ - **`lost`:** you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
73
+ - **`released`:** you (or `await using`'s scope exit) gave the lease back.
63
74
 
64
75
  ## Where the logs run
65
76
 
@@ -82,7 +93,7 @@ Ablo({
82
93
  });
83
94
  ```
84
95
 
85
- ## Read the coordination in code the activity log
96
+ ## Read the coordination in code: the activity log
86
97
 
87
98
  The console trace above is for *you*, at a terminal. To put the same activity **inside your app** — an activity feed, a "who's editing" badge, a Sentry breadcrumb trail — read it programmatically. Same events, three layers; pick by audience:
88
99
 
@@ -114,7 +125,7 @@ interface ConflictEvent {
114
125
 
115
126
  `phase` is past-tense — the state the claim just entered — and maps one-to-one to what arrives on the wire.
116
127
 
117
- ### Collect them `ClaimLog`
128
+ ### Collect them: `ClaimLog`
118
129
 
119
130
  `ClaimLog` records both into an ordered list. Hand it to `observability`, then read it back:
120
131
 
@@ -136,7 +147,7 @@ It's also the simplest way to **assert** coordination in a test — no log scrap
136
147
  expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
137
148
  ```
138
149
 
139
- ### Show it on a page reactive
150
+ ### Show it on a page: reactive
140
151
 
141
152
  `ClaimLog.onChange` fires on every event and returns an unsubscribe — the exact shape `useSyncExternalStore` wants, so a live feed is a few lines:
142
153
 
@@ -185,6 +196,17 @@ const ablo = Ablo({
185
196
  });
186
197
  ```
187
198
 
199
+ `ClaimLog` implements the full `SyncObservabilityProvider`, so it drops straight
200
+ into the `observability` slot. The surface exports `ClaimLog`, `formatClaim`,
201
+ `formatConflict`, and `noopObservability`, plus the types `ClaimEvent`,
202
+ `ConflictEvent`, `ClaimLogEntry`, and `SyncObservabilityProvider`.
203
+
204
+ > **Both transports, from 0.21.0.** Observability fires on the WebSocket and on
205
+ > the stateless HTTP transport (claim acquired, plus coordination-conflict
206
+ > rejections on every write door). Before 0.21.0 only WebSocket emitted, so a
207
+ > `ClaimLog` on an HTTP client, such as a headless server-agent eval, stayed
208
+ > silent even though coordination still worked.
209
+
188
210
  ## Errors
189
211
 
190
212
  Ablo's thrown errors are typed and self-describing — `String(err)` (or logging it) yields one clean line, never a stack dump: