@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,6 +1,8 @@
1
1
  # AI SDK Tool
2
2
 
3
- Use AI SDK for the loop and Ablo for the state boundary inside the tool.
3
+ When an AI agent updates a shared record from inside a tool call, you have a concurrency problem: another agent or a user might be editing the same row, and a naive write silently overwrites their change. This example shows the safe pattern read the record, claim the row so anyone else waits their turn, write through a version-checked update, and release the claim automatically.
4
+
5
+ Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
4
6
 
5
7
  ```ts
6
8
  import Ablo from '@abloatai/ablo';
@@ -31,25 +33,31 @@ const updateReport = tool({
31
33
  execute: async ({ reportId, status, forecast }) => {
32
34
  await ablo.ready();
33
35
 
34
- const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
36
+ // retrieve hits the server for the latest row (async — await it).
37
+ const report = await ablo.weatherReports.retrieve({ id: reportId });
35
38
  if (!report) return { ok: false, reason: 'not_found' };
36
39
 
37
- // claim is advisory: if another participant holds the row, it waits for
38
- // them to finish and re-reads before entering the callback. Released when
39
- // the callback returns or throws.
40
- return ablo.weatherReports.claim(
41
- reportId,
42
- async (claimed) => {
43
- // update is stale-guarded under the held claim
44
- const updated = await ablo.weatherReports.update(claimed.id, {
45
- status: status ?? claimed.status,
46
- forecast: forecast ?? claimed.forecast,
47
- });
40
+ // If another agent or user already holds this row, claim waits for them
41
+ // to finish, re-reads the fresh row, then hands it back on `claim.data`.
42
+ // The claim is released automatically when it goes out of scope.
43
+ await using claim = await ablo.weatherReports.claim({
44
+ id: reportId,
45
+ action: 'editing',
46
+ ttl: '2m',
47
+ });
48
+ const claimed = claim.data;
48
49
 
49
- return { ok: true, report: updated };
50
+ // Because you hold the claim, this update is rejected if the row
51
+ // changed underneath you, instead of silently overwriting it.
52
+ const updated = await ablo.weatherReports.update({
53
+ id: claimed.id,
54
+ data: {
55
+ status: status ?? claimed.status,
56
+ forecast: forecast ?? claimed.forecast,
50
57
  },
51
- { action: 'editing', ttl: '2m' },
52
- );
58
+ });
59
+
60
+ return { ok: true, report: updated };
53
61
  },
54
62
  });
55
63
 
@@ -64,11 +72,10 @@ export async function POST(req: Request) {
64
72
  }
65
73
  ```
66
74
 
67
- The important part is not the model provider. The important part is that the
68
- tool:
75
+ The model provider is interchangeable. What matters is that the tool:
69
76
 
70
- - loads the latest weather report,
71
- - claims the row (advisory serializes behind any current holder, then re-reads),
72
- - writes through the normal stale-guarded `update`,
73
- - releases the claim automatically when the callback returns or throws,
77
+ - reads the latest weather report with `retrieve` (a server read),
78
+ - claims the row — if someone else holds it, the claim waits for them, then re-reads,
79
+ - writes through `update`, which is rejected if the row changed underneath you,
80
+ - releases the claim automatically when the handle goes out of scope,
74
81
  - waits for server confirmation.
@@ -1,15 +1,21 @@
1
1
  # Existing Python Backend
2
2
 
3
- Use this path when a product already has a Python API server and every button
4
- currently calls an application endpoint.
3
+ Put Ablo in front of the records several people (or AI agents) edit at once and
4
+ you get two things at no cost to your stack: every edit fans out live to
5
+ everyone watching, and humans and agents write through one shared contract. Your
6
+ Python service and database stay the source of truth — Ablo doesn't replace your
7
+ backend, it coordinates the writes into it. You stop calling your endpoint
8
+ directly; you call Ablo, Ablo calls your endpoint, and Ablo pushes the result
9
+ back out to every browser and agent on that record.
5
10
 
6
- The goal is not to replace the backend. Keep the Python service layer and
7
- database as the source of truth. Add Ablo as the shared write path for records
8
- that need multiplayer now and agent-safe writes later.
11
+ Use this path when a product already has a Python API server and every button
12
+ currently calls an application endpoint. It applies to any API-backed app, not
13
+ only Python a YC company's existing dashboard can keep its current
14
+ endpoint/service/database shape and migrate one coordinated model at a time.
9
15
 
10
- This also applies to any API-backed app, not only Python. A product like a YC
11
- company's existing dashboard can keep its current endpoint/service/database
12
- shape and migrate one coordinated model at a time.
16
+ Here is the full path a button takes. After your Python service commits the
17
+ change, Ablo pushes it live to every other browser and agent watching that
18
+ record (the "realtime fanout" step at the bottom):
13
19
 
14
20
  ```txt
15
21
  Browser UI
@@ -80,8 +86,8 @@ export function ReportRow({
80
86
  }: {
81
87
  report: { id: string; location: string; status: string };
82
88
  }) {
83
- const report = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
84
- const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
89
+ const report = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
90
+ const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
85
91
  const claimed = Boolean(active);
86
92
 
87
93
  return (
@@ -92,8 +98,9 @@ export function ReportRow({
92
98
  }
93
99
  ```
94
100
 
95
- No string model key is needed in the first example. The selector reads from
96
- `ablo.weatherReports`, so React uses the same model client as writes and agents.
101
+ No string model key is needed in the first example. Because the selector reads
102
+ straight from `ablo.weatherReports`, your reads, your writes, and any agent all
103
+ go through one client — so a live edit shows up here without extra wiring.
97
104
 
98
105
  ## 3. Add One Python Data Source Endpoint
99
106
 
@@ -206,15 +213,20 @@ The app does not need a flag-day rewrite. Move one model at a time.
206
213
  ```ts
207
214
  const snap = ablo.snapshot({ weatherReports: reportId });
208
215
 
209
- await ablo.weatherReports.update(
210
- reportId,
211
- { status: 'ready' },
212
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
213
- );
216
+ await ablo.weatherReports.update({
217
+ id: reportId,
218
+ data: { status: 'ready' },
219
+ readAt: snap.stamp,
220
+ onStale: 'reject',
221
+ wait: 'confirmed',
222
+ });
214
223
  ```
215
224
 
216
225
  Use `readAt` and `onStale: 'reject'` for actions that depend on state the user
217
- or agent already saw.
226
+ or agent already saw. If two people both click "mark ready" on a report one of
227
+ them already finished, `onStale: 'reject'` makes the second write fail instead
228
+ of silently clobbering — `readAt: snap.stamp` is the version the user actually
229
+ saw, and the write is rejected if the row changed underneath them.
218
230
 
219
231
  ## 5. Report Direct Database Writes
220
232
 
@@ -237,15 +249,18 @@ and timestamp. If the change originated from an Ablo commit, include the same
237
249
  Agents use the same model API as the UI:
238
250
 
239
251
  ```ts
240
- const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
252
+ const report = await ablo.weatherReports.retrieve({ id: reportId });
241
253
  const snap = ablo.snapshot({ weatherReports: reportId });
242
254
 
243
- await ablo.weatherReports.update(
244
- reportId,
245
- { status: 'ready' },
246
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
247
- );
255
+ await ablo.weatherReports.update({
256
+ id: reportId,
257
+ data: { status: 'ready' },
258
+ readAt: snap.stamp,
259
+ onStale: 'reject',
260
+ wait: 'confirmed',
261
+ });
248
262
  ```
249
263
 
250
- That is the point of the migration: humans and agents share one write contract,
251
- while the Python backend remains the canonical business logic and database owner.
264
+ Agents reach for the exact same calls the UI does the same write contract
265
+ stated at the top of this page. The Python backend keeps owning the business
266
+ logic and the database; agents just become another safe writer in front of it.
@@ -1,7 +1,19 @@
1
1
  # Next.js Example
2
2
 
3
- A production-shaped Next.js + Ablo Sync app. App Router, Server Actions, React
4
- Server Components, and live client subscriptions.
3
+ Building collaborative state in a Next.js app means handling three things at
4
+ once: a fast initial render from the server, writes that don't overwrite a
5
+ teammate's change, and a UI that updates the moment data changes. This example
6
+ wires all three with Ablo Sync. The key piece is `claim()` — commit a write
7
+ through it and Ablo rejects the write if someone edited the same record since
8
+ you read it, so you never silently clobber another person's work.
9
+
10
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
11
+ re-reads the fresh row, then hands it to you — so two writers serialize instead
12
+ of clobbering.
13
+
14
+ The app uses three layers, mapped to three files: a React Server Component reads
15
+ and renders, a Server Action claims and writes, and a client component shows
16
+ live updates.
5
17
 
6
18
  ## Structure
7
19
 
@@ -10,7 +22,7 @@ app/
10
22
  reports/
11
23
  [id]/
12
24
  page.tsx # RSC: retrieve + render
13
- actions.ts # Server Action: schema update with stale-state check
25
+ actions.ts # Server Action: write that's rejected if someone else edited first
14
26
  ReportEditor.tsx # Client: live updates
15
27
  lib/
16
28
  ablo.ts # Schema-backed Ablo client for server actions
@@ -26,7 +38,7 @@ export default async function ReportPage({
26
38
  params,
27
39
  }: { params: { id: string } }) {
28
40
  await ablo.ready();
29
- const [report] = await ablo.weatherReports.load({ where: { id: params.id } });
41
+ const report = await ablo.weatherReports.retrieve({ id: params.id });
30
42
  if (!report) return null;
31
43
 
32
44
  return <ReportEditor report={report} />;
@@ -42,23 +54,26 @@ export default async function ReportPage({
42
54
  import { ablo } from '@/lib/ablo';
43
55
 
44
56
  export async function markReady(id: string) {
45
- const report = await ablo.weatherReports.claim(
57
+ await using claim = await ablo.weatherReports.claim({
46
58
  id,
47
- async (claimed) =>
48
- ablo.weatherReports.update(
49
- claimed.id,
50
- { status: 'ready' },
51
- { wait: 'confirmed' },
52
- ),
53
- { wait: false, action: 'marking_ready' },
54
- );
59
+ wait: false,
60
+ action: 'marking_ready',
61
+ });
62
+ const claimed = claim.data;
63
+
64
+ const report = await ablo.weatherReports.update({
65
+ id: claimed.id,
66
+ data: { status: 'ready' },
67
+ wait: 'confirmed',
68
+ });
55
69
 
56
70
  return { status: 'ready', report };
57
71
  }
58
72
  ```
59
73
 
60
- If another participant commits between the read and the write, the commit
61
- rejects. The action can re-fetch and ask the user to retry.
74
+ The write runs while the `claim` is held. If another participant commits
75
+ between the read and the write, the commit is rejected because the row changed
76
+ underneath you. The action can re-fetch and ask the user to retry.
62
77
 
63
78
  ## Live Client
64
79
 
@@ -68,8 +83,8 @@ rejects. The action can re-fetch and ask the user to retry.
68
83
  import { useAblo } from '@abloatai/ablo/react';
69
84
 
70
85
  export function ReportEditor({ report: serverReport }: Props) {
71
- const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
72
- const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
86
+ const data = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
87
+ const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
73
88
  const claimed = Boolean(active);
74
89
 
75
90
  return (
@@ -1,28 +1,32 @@
1
1
  # Agent Scoped to One Deck
2
2
 
3
- An agent that edits **one deck** and receives realtime updates for **only that
4
- deck** — not the whole org. Shows the sync-group model end to end: a scope root,
5
- a containment (`parent`) edge, identity roles, and the model-form `scope`.
3
+ You want an agent that edits **one deck** and pushes realtime updates to the
4
+ people on **that deck only** — not a broadcast to the whole org. The catch most
5
+ people hit: which write reaches whom is decided by how the rows *relate*, not by
6
+ which columns the write touched. So a slide edit that never sets `deckId` still
7
+ reaches everyone viewing the deck, because the slide already belongs to it. You
8
+ get this by declaring the relationship once, then narrowing the agent to the deck
9
+ id — you never assemble a `deck:<id>` audience string by hand.
10
+
11
+ The three steps below show how to declare it, scope the agent, and write.
6
12
 
7
13
  See [Identity & Sync Groups](../identity.md) for the full reference.
8
14
 
9
- ## 1. Schema — declare the scope, once
15
+ ## 1. Schema — declare the relationship, once
10
16
 
11
17
  ```ts
12
18
  import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema';
13
19
 
14
20
  export const schema = defineSchema(
15
21
  {
16
- // A scope root: deck rows form the group `deck:<id>`.
22
+ // A deck's rows form the group `deck:<id>` (the kind comes from `scope`).
17
23
  decks: model(
18
24
  { title: z.string() },
19
25
  {},
20
26
  { orgScoped: true, scope: 'deck' },
21
27
  ),
22
- // A child: no group of its own. It inherits its deck's group via the
23
- // `parent` edge, so a slide write reaches everyone viewing the deck
24
- // even a slide edit that doesn't touch `deckId` (routing is keyed on the
25
- // row's id, not the changed columns).
28
+ // A slide has no group of its own. It inherits its deck's group via the
29
+ // `parent` edge, so a slide write reaches everyone viewing the deck.
26
30
  slides: model(
27
31
  { deckId: z.string(), body: z.string() },
28
32
  { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
@@ -41,36 +45,47 @@ export const schema = defineSchema(
41
45
 
42
46
  ## 2. Dispatch — narrow the agent to the deck it's working on
43
47
 
44
- The agent inherits the triggering user's identity (its ceiling) and is narrowed
45
- to one deck (the floor). You pass the **model and id** never a `deck:<id>`
46
- string; the engine builds the group from the model's `scope`.
48
+ An agent can never reach more than the user who triggered it that's the upper
49
+ limit. From there you narrow it to a single deck with `scope`. You pass the
50
+ **model and id** `{ decks: deckId }`, never a `deck:<id>` string; the engine
51
+ builds the group from the `decks` model's `scope`.
47
52
 
48
- ```ts
49
- const ablo = Ablo({
50
- schema,
51
- url: process.env.ABLO_URL,
52
- kind: 'agent',
53
- agentId: 'agent:slide-writer',
54
- userId: triggeringUser.id, // ceiling: can't exceed this user's reach
55
- organizationId: triggeringUser.organizationId,
56
- scope: { decks: deckId }, // floor: just this deck → deck:<deckId>
57
- });
58
- await ablo.ready();
53
+ ```tsx
54
+ import { AbloProvider } from '@abloatai/ablo/react';
55
+
56
+ // The agent run is mounted on behalf of its triggering user.
57
+ <AbloProvider
58
+ schema={schema}
59
+ userId={triggeringUser.id} // ceiling: can't exceed this user's reach
60
+ scope={{ decks: deckId }} // floor: narrowed to just this deck → deck:<deckId>
61
+ >
62
+ {children}
63
+ </AbloProvider>
59
64
  ```
60
65
 
66
+ `scope` requests, it never grants: at connect the server intersects the groups
67
+ you ask for with the groups the identity is actually allowed, so the agent can
68
+ never reach a deck its triggering user couldn't.
69
+
61
70
  ## 3. Write — it fans out to everyone on that deck
62
71
 
72
+ Inside any component under the provider, grab the scoped client with `useAblo()`
73
+ and write. The connection is already narrowed to `deck:<deckId>` from Step 2.
74
+
63
75
  ```ts
76
+ const ablo = useAblo<(typeof schema)['models']>();
77
+
64
78
  // Other participants subscribed to deck:<deckId> — the human in the editor,
65
79
  // a reviewer agent — receive this delta in realtime. Participants on other
66
80
  // decks never see it.
67
- await ablo.slides.update(slideId, { body: 'Q4 revenue up 12% YoY' });
81
+ await ablo.slides.update({ id: slideId, data: { body: 'Q4 revenue up 12% YoY' } });
68
82
  ```
69
83
 
70
- The slide's delta is stamped `deck:<deckId>` (derived server-side from the
71
- slide → deck `parent` edge), so it reaches the deck's audience authoritatively
72
- regardless of which groups the agent happened to subscribe to. And `scope` only
73
- ever *narrows*: the agent can't reach a deck its triggering user couldn't.
84
+ The slide's delta is stamped `deck:<deckId>`, derived server-side from the
85
+ slide → deck `parent` edge not from `deckId` appearing in this particular
86
+ write, and not from whatever the agent happened to subscribe to. The routing is
87
+ decided by the data: a slide belongs to its deck, so its writes go to the deck's
88
+ group, full stop.
74
89
 
75
90
  ## See also
76
91
 
@@ -1,7 +1,17 @@
1
1
  # Server Agent
2
2
 
3
- Most server agents should import the app schema and use the same model methods
4
- as the product UI.
3
+ A server agent is backend code a cron job, a queue worker, an AI task — that
4
+ reads and writes your app's records outside the browser. The hard part is doing
5
+ it without racing the live UI: if your worker and a user edit the same report at
6
+ once, one write clobbers the other. This is what `claim()` is for. Below, a
7
+ worker finishes a weather report by claiming it, writing the result, and
8
+ releasing it automatically when the claim goes out of scope.
9
+
10
+ `claim({ id })` takes the record for your worker and returns a disposable handle:
11
+ the fresh post-lease row is on `claim.data`, and holding the handle with
12
+ `await using` releases the claim on scope exit (or call `claim.release()`). Claims
13
+ don't lock. If another writer holds the row, `claim` waits for them, re-reads the
14
+ fresh row, then hands it to you — so two writers serialize instead of clobbering.
5
15
 
6
16
  ```ts
7
17
  import Ablo from '@abloatai/ablo';
@@ -23,23 +33,38 @@ const ablo = Ablo({
23
33
  export async function completeReport(reportId: string) {
24
34
  await ablo.ready();
25
35
 
26
- const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
36
+ const report = await ablo.weatherReports.retrieve({ id: reportId });
27
37
  if (!report) return { status: 'not_found' };
28
38
 
29
- const updated = await ablo.weatherReports.claim(
30
- reportId,
31
- async (claimed) =>
32
- ablo.weatherReports.update(
33
- claimed.id,
34
- { status: 'ready' },
35
- { wait: 'confirmed' },
36
- ),
37
- { wait: false, action: 'completing' },
38
- );
39
+ await using claim = await ablo.weatherReports.claim({
40
+ id: reportId,
41
+ wait: false,
42
+ action: 'completing',
43
+ });
44
+ const claimed = claim.data;
45
+
46
+ const updated = await ablo.weatherReports.update({
47
+ id: claimed.id,
48
+ data: { status: 'ready' },
49
+ wait: 'confirmed',
50
+ });
39
51
 
40
52
  return { status: 'ready', report: updated };
41
53
  }
42
54
  ```
43
55
 
44
- Use the schema-backed version for server agents so the worker, app, and React UI
45
- share the same model methods.
56
+ `retrieve({ id })` is an async server read it hits the server and returns the
57
+ row (or `null`, which the early `not_found` guard handles). The update runs while
58
+ the claim is held, and `wait: 'confirmed'` makes that update resolve only once
59
+ the server has accepted it.
60
+
61
+ The two options on the claim:
62
+
63
+ - `wait: false` — skip this record if another claim is already in progress,
64
+ rather than queueing behind it. (The default queues.)
65
+ - `action: 'completing'` — a human-readable label for what your worker is doing,
66
+ visible to anyone reading `claim.state({ id })`.
67
+
68
+ Because the worker uses the same schema and `claim()` as the UI, its writes sync
69
+ to every connected client in real time and never collide with edits already in
70
+ progress.
@@ -1,7 +1,14 @@
1
1
  # Guarantees
2
2
 
3
- This page is the short contract for what Ablo guarantees at the state
4
- boundary.
3
+ When an Ablo write succeeds, the server has accepted it and when two people or
4
+ agents touch the same row, Ablo coordinates them instead of letting one silently
5
+ overwrite the other. This page is the precise list of what you can count on:
6
+ confirmed writes, stale-write protection, claims, and the audit trail behind
7
+ every change.
8
+
9
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
10
+ re-reads the fresh row, then hands it to you — so two writers serialize instead
11
+ of clobbering.
5
12
 
6
13
  ## Confirmed Writes
7
14
 
@@ -9,16 +16,17 @@ boundary.
9
16
  the authoritative sync cursor.
10
17
 
11
18
  ```ts
12
- const updated = await ablo.weatherReports.update(
13
- 'report_stockholm',
14
- { status: 'ready' },
15
- { wait: 'confirmed' },
16
- );
19
+ const updated = await ablo.weatherReports.update({
20
+ id: 'report_stockholm',
21
+ data: { status: 'ready' },
22
+ wait: 'confirmed',
23
+ });
17
24
  ```
18
25
 
19
26
  If the call resolves, the write was accepted by the server. If it rejects, the
20
- error explains whether the write was rejected for auth, validation, stale state,
21
- active claim conflict, idempotency, rate limit, or transport failure.
27
+ typed error tells you exactly why the most common reasons being failed
28
+ authorization, a schema validation error, or a stale-state or claim conflict
29
+ (each covered below).
22
30
 
23
31
  Schema model writes return the updated model row.
24
32
 
@@ -43,11 +51,13 @@ read:
43
51
  ```ts
44
52
  const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
45
53
 
46
- await ablo.weatherReports.update(
47
- 'report_stockholm',
48
- { status: 'ready' },
49
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
50
- );
54
+ await ablo.weatherReports.update({
55
+ id: 'report_stockholm',
56
+ data: { status: 'ready' },
57
+ readAt: snap.stamp,
58
+ onStale: 'reject',
59
+ wait: 'confirmed',
60
+ });
51
61
  ```
52
62
 
53
63
  `onStale: 'reject'` prevents lost updates. If the target changed after the
@@ -58,27 +68,28 @@ Advanced policies exist for controlled product flows:
58
68
  - `reject` fails the write when state moved.
59
69
  - `force` applies the write without stale protection.
60
70
  - `flag` accepts the write and marks it for product review.
61
- - `merge` is reserved for server-defined merge behavior.
71
+
72
+ `merge` is not yet available.
62
73
 
63
74
  ## Claim Coordination
64
75
 
65
- > The guarantee, not the how-to. Methods, the claim-state object, and the `queue`
76
+ > The guarantee, not the how-to. Methods, the claim-state object, and the `claim.queue`
66
77
  > live in [Coordination](./coordination.md).
67
78
 
68
79
  Claims are live coordination signals. They are not database locks.
69
80
 
70
- Claims are **advisory** and **cooperative**. `ablo.<model>.claim(id, ...)`
71
- serializes on contention: if another human or agent already holds the row, the
72
- claim waits for them to finish, then re-reads the row before handing it back, so
73
- you proceed from fresh state. Reads are open by default
74
- `ablo.<model>.claimState(id)` returns the current claim state (or `null`) without
75
- ever blocking. Server/model reads can opt into `ifClaimed: 'wait'` or
76
- `ifClaimed: 'fail'` when they should not read through active work.
81
+ `ablo.<model>.claim({ id })` serializes on contention: if another human or agent
82
+ already holds the row, the claim waits for them to finish, then re-reads the row
83
+ before handing it back, so you proceed from fresh state. Reads stay open while a
84
+ claim is held `ablo.<model>.claim.state({ id })` returns the current claim state
85
+ (or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
86
+ wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
87
+ not return a row while someone else is mid-edit.
77
88
 
78
89
  A claim does not reject or block other writers; it announces work so peers
79
90
  serialize behind it rather than racing. While you hold a claim, the matching
80
- `ablo.<model>.update(id, ...)` is stale-guarded and rejects with
81
- `AbloStaleContextError` if the row advanced past your claim point.
91
+ `ablo.<model>.update({ id, ... })` is rejected with `AbloStaleContextError` if the row
92
+ changed underneath you after your claim point.
82
93
 
83
94
  ## Agent Runs
84
95
 
@@ -98,8 +109,8 @@ authorized it, which run did it, and what state was it based on?"
98
109
 
99
110
  ## Persistence
100
111
 
101
- Ablo defaults to volatile in-memory persistence. That keeps the SDK focused on
102
- coordination and audit instead of silently becoming a browser storage product.
112
+ Ablo defaults to volatile in-memory persistence, so nothing is written to disk
113
+ unless you ask for it.
103
114
 
104
115
  Opt into a durable browser cache that survives reloads when you need it:
105
116