@abloatai/ablo 0.5.1 → 0.7.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 (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
@@ -9,9 +9,9 @@ import Ablo from '@abloatai/ablo';
9
9
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
10
10
 
11
11
  const schema = defineSchema({
12
- tasks: model({
13
- title: z.string(),
14
- status: z.enum(['todo', 'doing', 'done']),
12
+ weatherReports: model({
13
+ location: z.string(),
14
+ status: z.enum(['pending', 'ready']),
15
15
  }),
16
16
  });
17
17
 
@@ -25,10 +25,10 @@ Common options:
25
25
 
26
26
  | Option | Purpose |
27
27
  |---|---|
28
- | `schema` | Required for typed model resources. Omit only for advanced schema-less runtimes. |
28
+ | `schema` | Required for typed model clients. |
29
29
  | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
30
30
  | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
31
- | `persistence` | `volatile` by default. Use `indexeddb` for browser durable cache and offline queueing. |
31
+ | `persistence` | `volatile` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
32
32
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
33
33
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
34
34
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -40,17 +40,17 @@ endpoint.
40
40
 
41
41
  ## Model Methods
42
42
 
43
- Each schema model becomes a typed resource:
43
+ Each schema model becomes a typed model:
44
44
 
45
45
  ```ts
46
46
  await ablo.ready();
47
47
 
48
- const [task] = await ablo.tasks.load({ where: { id: 'task_123' } });
49
- const local = ablo.tasks.retrieve('task_123');
48
+ const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
49
+ const local = ablo.weatherReports.retrieve('report_stockholm');
50
50
 
51
- await ablo.tasks.create({ title: 'Draft launch plan', status: 'todo' });
52
- await ablo.tasks.update('task_123', { status: 'done' }, { wait: 'confirmed' });
53
- await ablo.tasks.delete('task_123', { wait: 'confirmed' });
51
+ await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
52
+ await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
53
+ await ablo.weatherReports.delete('report_stockholm', { wait: 'confirmed' });
54
54
  ```
55
55
 
56
56
  `load` is async hydration from local store and server. `retrieve`, `list`, and
@@ -63,15 +63,15 @@ rows.
63
63
 
64
64
  ## Multiplayer Behavior
65
65
 
66
- Multiplayer works when every participant uses the same model resource path. A
66
+ Multiplayer works when every participant uses the same model client path. A
67
67
  human Server Action, a browser view, and an agent worker can all use
68
- `ablo.tasks`:
68
+ `ablo.weatherReports`:
69
69
 
70
70
  ```ts
71
- const [task] = await ablo.tasks.load({ where: { id } });
72
- const snap = ablo.snapshot({ tasks: id });
71
+ const [report] = await ablo.weatherReports.load({ where: { id } });
72
+ const snap = ablo.snapshot({ weatherReports: id });
73
73
 
74
- await ablo.tasks.update(id, patch, {
74
+ await ablo.weatherReports.update(id, patch, {
75
75
  readAt: snap.stamp,
76
76
  onStale: 'reject',
77
77
  wait: 'confirmed',
@@ -79,9 +79,9 @@ await ablo.tasks.update(id, patch, {
79
79
  ```
80
80
 
81
81
  The confirmed write fans out over realtime subscriptions. React clients that use
82
- `useAblo((ablo) => ablo.tasks.retrieve(id))` receive the new row, and selectors
83
- such as `useAblo((ablo) => ablo.intents.list({ resource: 'tasks', id }))`
84
- receive active intents. There is
82
+ `useAblo((ablo) => ablo.weatherReports.retrieve(id))` receive the new row, and selectors
83
+ such as `useAblo((ablo) => ablo.weatherReports.claimState(id))`
84
+ receive active claim state. There is
85
85
  no extra multiplayer setup beyond routing shared state through Ablo.
86
86
 
87
87
  If an app writes directly to its database, Ablo cannot coordinate that write
@@ -90,15 +90,14 @@ until the app reports it through Data Source events.
90
90
  ## Per-Write Options
91
91
 
92
92
  ```ts
93
- await ablo.tasks.update(
94
- 'task_123',
95
- { status: 'done' },
93
+ await ablo.weatherReports.update(
94
+ 'report_stockholm',
95
+ { status: 'ready' },
96
96
  {
97
97
  wait: 'confirmed',
98
98
  readAt: snap.stamp,
99
99
  onStale: 'reject',
100
- intent,
101
- idempotencyKey: 'task_123:mark-done:v1',
100
+ idempotencyKey: 'report_stockholm:mark-ready:v1',
102
101
  timeout: 20_000,
103
102
  },
104
103
  );
@@ -109,31 +108,31 @@ await ablo.tasks.update(
109
108
  | `wait` | `queued` resolves after local queueing; `confirmed` waits for server acceptance. |
110
109
  | `readAt` | State cursor the write was based on. |
111
110
  | `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. |
112
- | `intent` | Active work claim associated with this write. |
113
111
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
114
112
  | `timeout` | Maximum time for the write call. |
115
113
 
116
- ## Busy Behavior
114
+ ## Claimed Behavior
117
115
 
118
116
  ```ts
119
- const busy = ablo.intents.list({ resource: 'tasks', id: 'task_123' });
117
+ const active = ablo.weatherReports.claimState('report_stockholm');
120
118
 
121
- if (busy.length > 0) {
122
- await ablo.intents.waitFor(
123
- { resource: 'tasks', id: 'task_123' },
124
- { timeout: 30_000 },
125
- );
119
+ if (active) {
120
+ return { status: 'claimed', active };
126
121
  }
122
+
123
+ await ablo.weatherReports.claim('report_stockholm', async (report) => {
124
+ await ablo.weatherReports.update(report.id, { status: 'ready' });
125
+ });
127
126
  ```
128
127
 
129
- Reads never silently block. For raw resource calls, use `ifBusy`:
128
+ Reads never silently block. For schema model calls, use `claimState(id)` to observe
129
+ current work and `claim(id, work)` to serialize a write across a slow step:
130
130
 
131
- - `return` returns active intents.
132
- - `wait` waits for matching intents to clear.
133
- - `fail` throws `AbloBusyError`.
131
+ - default `claim` waits in the fair queue and re-reads before invoking `work`;
132
+ - `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
133
+ - `{ maxQueueDepth }` rejects if the wait line is already too deep.
134
134
 
135
- Schema clients use the realtime stream for waits. Schema-less HTTP clients must
136
- provide `busyPollInterval` when using `ifBusy: 'wait'`.
135
+ Schema clients use the realtime stream for waits.
137
136
 
138
137
  ## Errors
139
138
 
@@ -149,16 +148,16 @@ All SDK errors extend `AbloError` and carry a stable `type`.
149
148
  | `AbloValidationError` | Invalid input or unsupported request shape. |
150
149
  | `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
151
150
  | `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
152
- | `AbloBusyError` | Active intent conflicted with `ifBusy: 'fail'` or a busy wait timed out. |
151
+ | `AbloClaimedError` | An active claim conflicted with `{ wait: false }`, the queue was too deep, or a claim wait timed out. |
153
152
 
154
153
  ```ts
155
- import { AbloBusyError } from '@abloatai/ablo';
154
+ import { AbloClaimedError } from '@abloatai/ablo';
156
155
 
157
156
  try {
158
- await ablo.tasks.update('task_123', { status: 'done' }, { wait: 'confirmed' });
157
+ await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
159
158
  } catch (error) {
160
- if (error instanceof AbloBusyError) {
161
- return { status: 'busy', intents: error.intents };
159
+ if (error instanceof AbloClaimedError) {
160
+ return { status: 'claimed' };
162
161
  }
163
162
  throw error;
164
163
  }
@@ -0,0 +1,343 @@
1
+ # Coordination Reference
2
+
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
5
+ and the server rejects it if the row moved. Reach for `claim` only when you'll
6
+ **hold a row across a slow gap** (read → LLM call → write).
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.
13
+
14
+ This reference opens with [the model](#the-model--three-layers-one-decision) — the
15
+ one answer to "how do two agents not clobber each other" — then covers the
16
+ [claim state object](#the-claim-state-object), the SDK [methods](#methods)
17
+ (`claim` · `claimState` · `queue` · `release` · [writing under a
18
+ claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
19
+
20
+ ---
21
+
22
+ ## The model — three layers, one decision
23
+
24
+ Ablo has exactly **three** coordination layers. They are **not** three competing
25
+ answers to the same question — they stack, and only one of them is a decision you
26
+ make:
27
+
28
+ | layer | kind | what it does | enforces? |
29
+ |---|---|---|---|
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
+ | **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
+
34
+ **The one decision: do you hold the row across a slow gap (read → LLM call →
35
+ write)?**
36
+
37
+ - **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
38
+ is optimistically guarded by stale-context already; it rejects with
39
+ `AbloStaleContextError` if the row moved under you. This is the default and
40
+ needs no ceremony.
41
+ - **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
42
+ excludes other participants for the duration, queues contenders fairly, and —
43
+ see below — your own writes under it stay stale-guarded too.
44
+
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.
69
+
70
+ ---
71
+
72
+ ## The claim state object
73
+
74
+ 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.
76
+
77
+ | field | type | description |
78
+ |---|---|---|
79
+ | `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. |
81
+ | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
82
+ | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
83
+ | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
84
+ | `participantKind` | `'human' \| 'agent'` | Who's behind it. |
85
+ | `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
86
+ | `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
87
+ | `expiresAt` | `string` | 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. |
88
+
89
+ ```jsonc
90
+ {
91
+ "id": "claim_8fJ2",
92
+ "status": "active",
93
+ "target": { "model": "weatherReports", "id": "report_stockholm" },
94
+ "action": "editing",
95
+ "heldBy": "agent:forecaster",
96
+ "participantKind": "agent",
97
+ "createdAt": "1748160000000",
98
+ "expiresAt": "1748160030000"
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Methods
105
+
106
+ Each method below follows one fixed shape: **signature · what it does ·
107
+ parameters · returns · example**.
108
+
109
+ ### `claim`
110
+
111
+ ```ts
112
+ ablo.<model>.claim(id, work, options?): Promise<R> // callback form
113
+ ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
114
+ ```
115
+
116
+ Claim a row so other writers serialize behind you until you're done; reads stay
117
+ open by default. The claim acquires through the server's fair FIFO queue: if the
118
+ target is free the lease is yours immediately, and if another participant holds
119
+ it your claim **waits in line** and resolves only once it reaches the head —
120
+ 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.
123
+
124
+ **Parameters**
125
+
126
+ | name | type | required | description |
127
+ |---|---|---|---|
128
+ | `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
129
+ | `options.action` | `string` | no | Phase shown to observers (default `'editing'`). |
130
+ | `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
131
+ | `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
+ | `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
+ | `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
+
136
+ The high-level `claim` queues by default, so on contention you either get the row
137
+ when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
138
+ `grant_timeout`).
139
+
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.
144
+
145
+ **Example**
146
+
147
+ ```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
+ });
153
+ ```
154
+
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.
157
+
158
+ ### Claim-gated reads
159
+
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
162
+ held. Server/model reads can choose a claimed policy:
163
+
164
+ ```ts
165
+ await ablo.model('weatherReports').retrieve('report_stockholm', {
166
+ ifClaimed: 'wait',
167
+ claimedTimeout: 30_000,
168
+ });
169
+ ```
170
+
171
+ - `ifClaimed: 'return'` reads now and includes active work metadata.
172
+ - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
173
+ - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
174
+
175
+ ### `claimState`
176
+
177
+ ```ts
178
+ ablo.<model>.claimState(id)
179
+ ```
180
+
181
+ Read who's currently working on a row, for observers and UI. Synchronous and
182
+ reactive (it reads the local coordination snapshot). Never blocks.
183
+
184
+ **Parameters**
185
+
186
+ | name | type | required | description |
187
+ |---|---|---|---|
188
+ | `id` | `string` | yes | The row id. |
189
+
190
+ **Returns** — the active [claim state object](#the-claim-state-object), or `null` when the row
191
+ is free.
192
+
193
+ **Example**
194
+
195
+ ```ts
196
+ const who = ablo.weatherReports.claimState('report_stockholm');
197
+ if (who) console.log(`${who.heldBy} is ${who.action}`);
198
+ ```
199
+
200
+ Returns the active claim state when the row is held, or `null` when it's free:
201
+
202
+ ```jsonc
203
+ {
204
+ "id": "claim_8fJ2",
205
+ "status": "active",
206
+ "target": { "model": "weatherReports", "id": "report_stockholm" },
207
+ "action": "editing",
208
+ "heldBy": "agent:forecaster",
209
+ "participantKind": "agent",
210
+ "expiresAt": "1748160030000"
211
+ }
212
+ ```
213
+
214
+ ### `queue`
215
+
216
+ ```ts
217
+ ablo.<model>.queue(id)
218
+ ```
219
+
220
+ 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
222
+ 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
225
+ decide the wait isn't worth it.
226
+
227
+ **Parameters**
228
+
229
+ | name | type | required | description |
230
+ |---|---|---|---|
231
+ | `id` | `string` | yes | The row id. |
232
+
233
+ **Returns** — a list envelope. `data` contains the queued
234
+ [claim state objects](#the-claim-state-object) in promotion order (head first), excluding
235
+ the active holder; `[]` when no one is waiting.
236
+
237
+ **Example**
238
+
239
+ ```ts
240
+ const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
241
+ console.log(`${waiting.length} ahead of you`);
242
+ console.log(waiting.map((i) => i.heldBy));
243
+ ```
244
+
245
+ ### `release`
246
+
247
+ ```ts
248
+ ablo.<model>.release(id): Promise<void>
249
+ ```
250
+
251
+ Release a claim you hold. Usually **implicit** — the callback returning releases
252
+ for you, and TTL cleans up a crashed holder.
253
+ Call this only to give a manually held claim back early (claimed, then decided
254
+ not to write).
255
+ Releasing **promotes the head of the queue**: the next waiter receives the claim.
256
+
257
+ **Parameters**
258
+
259
+ | name | type | required | description |
260
+ |---|---|---|---|
261
+ | `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
262
+
263
+ **Returns** — resolves once the claim is released.
264
+
265
+ **Example**
266
+
267
+ ```ts
268
+ const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
269
+ try {
270
+ const ok = await reviewExternally(report);
271
+ if (!ok) return; // abandon, no write
272
+ await ablo.weatherReports.update(report.id, { status: 'ready' });
273
+ } finally {
274
+ await ablo.weatherReports.release(report.id);
275
+ }
276
+ ```
277
+
278
+ ### Writing under a claim
279
+
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
282
+ automatically stale-guarded against the snapshot the claim took (`readAt` =
283
+ snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
284
+ it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
285
+
286
+ ```ts
287
+ await ablo.weatherReports.claim(id, async (report) => {
288
+ await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
289
+ });
290
+ ```
291
+
292
+ Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
293
+ participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
294
+ 'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
295
+ behind the current holder and re-reads once it's yours, so your `update` lands
296
+ on fresh data. You never conflict with your own claim, and reads are never gated.
297
+
298
+ ```ts
299
+ try {
300
+ await ablo.weatherReports.update(id, { status: 'ready' });
301
+ } catch (err) {
302
+ if (err instanceof AbloClaimedError) {
303
+ // someone else holds it — claim the row and retry from fresh state
304
+ }
305
+ }
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Errors
311
+
312
+ All extend `AbloError` (`packages/sync-engine/src/errors.ts`). Catch by `type` or
313
+ inspect the `code`.
314
+
315
+ | error | `code` | thrown when | carries |
316
+ |---|---|---|---|
317
+ | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | `claims?` |
318
+ | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
319
+ | `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?` |
320
+ | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
321
+ | `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
322
+ | `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[]` |
323
+ | `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration wiring. | — |
324
+ | `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
325
+
326
+ `AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
327
+ that moved during your generation window — use it for selective regeneration
328
+ (re-think only the slides that changed, not the whole deck) and for metrics.
329
+
330
+ ```ts
331
+ 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
+ });
336
+ } catch (err) {
337
+ if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
338
+ // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
339
+ } else if (err instanceof AbloStaleContextError) {
340
+ // The row moved under us — re-read and regenerate from the fresh snapshot.
341
+ } else throw err;
342
+ }
343
+ ```
@@ -8,7 +8,7 @@ the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
8
8
  `schema.prisma`.
9
9
 
10
10
  By default, Ablo stores the rows for the models you declare. That makes Ablo the
11
- managed state store for those resources, the same way Stripe stores `Customer`
11
+ managed state store for those models, the same way Stripe stores `Customer`
12
12
  and `PaymentIntent` objects that you create through Stripe's API.
13
13
 
14
14
  If you already have application tables and want those tables to remain
@@ -24,7 +24,7 @@ Use the SDK with an API key:
24
24
 
25
25
  ```ts
26
26
  import Ablo from '@abloatai/ablo';
27
- import { schema } from './ablo.schema';
27
+ import { schema } from './ablo/schema';
28
28
 
29
29
  export const ablo = Ablo({
30
30
  schema,
@@ -54,9 +54,9 @@ ABLO_API_KEY=sk_live_...
54
54
  The SDK call is the same in both modes:
55
55
 
56
56
  ```ts
57
- await ablo.tasks.create({ title: 'Draft launch plan', status: 'todo' });
58
- await ablo.tasks.update('task_123', { status: 'done' });
59
- const task = ablo.tasks.retrieve('task_123');
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');
60
60
  ```
61
61
 
62
62
  Only the backing store changes.
@@ -100,7 +100,7 @@ The shape is the same as a production webhook integration:
100
100
  ```ts
101
101
  // app/api/ablo/source/route.ts
102
102
  import { dataSource } from '@abloatai/ablo';
103
- import { schema } from '@/ablo.schema';
103
+ import { schema } from '@/ablo/schema';
104
104
  import { db } from '@/db';
105
105
 
106
106
  export const POST = dataSource({
@@ -120,13 +120,13 @@ export const POST = dataSource({
120
120
  return { rows };
121
121
  },
122
122
 
123
- tasks: {
123
+ reports: {
124
124
  async load({ id, context }) {
125
- return context.auth.db.task.findUnique({ where: { id } });
125
+ return context.auth.db.report.findUnique({ where: { id } });
126
126
  },
127
127
 
128
128
  async list({ query, context }) {
129
- return context.auth.db.task.findMany({
129
+ return context.auth.db.report.findMany({
130
130
  take: query.limit ?? 100,
131
131
  });
132
132
  },
@@ -137,9 +137,9 @@ export const POST = dataSource({
137
137
  Your app code still writes through the normal model API:
138
138
 
139
139
  ```ts
140
- await ablo.tasks.update(
141
- 'task_123',
142
- { status: 'done' },
140
+ await ablo.weatherReports.update(
141
+ 'report_stockholm',
142
+ { status: 'ready' },
143
143
  { wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
144
144
  );
145
145
  ```
@@ -155,9 +155,9 @@ When Ablo calls your Data Source, it sends a signed JSON request:
155
155
  operations: [
156
156
  {
157
157
  type: 'UPDATE',
158
- model: 'tasks',
159
- id: 'task_123',
160
- input: { status: 'done' },
158
+ model: 'weatherReports',
159
+ id: 'report_stockholm',
160
+ input: { status: 'ready' },
161
161
  readAt: 1042,
162
162
  onStale: 'reject',
163
163
  },
@@ -177,7 +177,7 @@ Return canonical rows:
177
177
  ```ts
178
178
  {
179
179
  rows: [
180
- { id: 'task_123', title: 'Fix docs', status: 'done' },
180
+ { id: 'report_stockholm', location: 'Stockholm', status: 'ready' },
181
181
  ],
182
182
  }
183
183
  ```