@abloatai/ablo 0.7.0 → 0.8.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 (83) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -45
  3. package/dist/BaseSyncedStore.js +7 -3
  4. package/dist/SyncEngineContext.d.ts +2 -1
  5. package/dist/SyncEngineContext.js +5 -3
  6. package/dist/agent/session.js +3 -2
  7. package/dist/auth/index.js +39 -11
  8. package/dist/client/Ablo.d.ts +111 -3
  9. package/dist/client/Ablo.js +143 -10
  10. package/dist/client/ApiClient.d.ts +32 -0
  11. package/dist/client/ApiClient.js +76 -44
  12. package/dist/client/auth.d.ts +11 -1
  13. package/dist/client/auth.js +21 -2
  14. package/dist/client/createModelProxy.d.ts +107 -63
  15. package/dist/client/createModelProxy.js +65 -33
  16. package/dist/client/identity.js +14 -0
  17. package/dist/client/registerDataSource.d.ts +19 -0
  18. package/dist/client/registerDataSource.js +57 -0
  19. package/dist/client/validateAbloOptions.d.ts +2 -1
  20. package/dist/client/validateAbloOptions.js +8 -7
  21. package/dist/errorCodes.d.ts +23 -1
  22. package/dist/errorCodes.js +34 -1
  23. package/dist/errors.d.ts +52 -1
  24. package/dist/errors.js +140 -42
  25. package/dist/index.d.ts +9 -5
  26. package/dist/index.js +9 -5
  27. package/dist/keys/index.d.ts +61 -0
  28. package/dist/keys/index.js +151 -0
  29. package/dist/query/client.js +19 -8
  30. package/dist/react/AbloProvider.d.ts +25 -0
  31. package/dist/react/AbloProvider.js +97 -2
  32. package/dist/react/ClientSideSuspense.d.ts +1 -1
  33. package/dist/react/DefaultFallback.d.ts +1 -1
  34. package/dist/react/SyncGroupProvider.d.ts +1 -1
  35. package/dist/react/index.d.ts +3 -2
  36. package/dist/react/index.js +3 -2
  37. package/dist/react/useAblo.d.ts +4 -4
  38. package/dist/react/useAblo.js +10 -5
  39. package/dist/react/useReactive.js +16 -3
  40. package/dist/schema/serialize.d.ts +3 -3
  41. package/dist/schema/serialize.js +2 -2
  42. package/dist/sync/BootstrapHelper.js +46 -27
  43. package/dist/sync/ConnectionManager.d.ts +3 -1
  44. package/dist/sync/ConnectionManager.js +37 -1
  45. package/dist/sync/HydrationCoordinator.js +3 -2
  46. package/dist/sync/NetworkProbe.d.ts +8 -0
  47. package/dist/sync/NetworkProbe.js +24 -2
  48. package/dist/sync/SyncWebSocket.d.ts +1 -1
  49. package/dist/sync/SyncWebSocket.js +43 -53
  50. package/dist/sync/participants.js +5 -2
  51. package/dist/transactions/TransactionQueue.js +13 -1
  52. package/docs/api-keys.md +5 -5
  53. package/docs/api.md +101 -44
  54. package/docs/audit.md +16 -9
  55. package/docs/cli.md +27 -17
  56. package/docs/client-behavior.md +34 -20
  57. package/docs/coordination.md +40 -51
  58. package/docs/data-sources.md +21 -19
  59. package/docs/examples/agent-human.md +72 -28
  60. package/docs/examples/ai-sdk-tool.md +14 -11
  61. package/docs/examples/existing-python-backend.md +27 -16
  62. package/docs/examples/nextjs.md +21 -8
  63. package/docs/examples/scoped-agent.md +42 -27
  64. package/docs/examples/server-agent.md +27 -5
  65. package/docs/guarantees.md +26 -17
  66. package/docs/identity.md +65 -59
  67. package/docs/index.md +30 -19
  68. package/docs/integration-guide.md +52 -52
  69. package/docs/interaction-model.md +38 -26
  70. package/docs/mcp/claude-code.md +9 -17
  71. package/docs/mcp/cursor.md +6 -24
  72. package/docs/mcp/windsurf.md +6 -19
  73. package/docs/mcp.md +103 -26
  74. package/docs/quickstart.md +31 -39
  75. package/docs/react.md +15 -11
  76. package/docs/roadmap.md +13 -13
  77. package/docs/schema-contract.md +109 -0
  78. package/examples/README.md +8 -4
  79. package/examples/data-source/README.md +6 -2
  80. package/examples/data-source/run.ts +4 -3
  81. package/examples/quickstart.ts +1 -1
  82. package/llms.txt +27 -16
  83. package/package.json +6 -1
package/docs/audit.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # Audit log
2
2
 
3
- Every commit becomes one row. Rows are hash-chained per principal
4
- tamper-evident, queryable, exportable.
3
+ The audit log records who changed what in your org, and when including
4
+ changes an AI agent made on a person's behalf. Every change is one row, and the
5
+ rows are signed in a chain so you can later prove the history wasn't altered.
6
+ You can filter it, page through it, and export it.
7
+
8
+ Every commit becomes one row.
5
9
 
6
10
  ## Row shape
7
11
 
@@ -12,10 +16,10 @@ tamper-evident, queryable, exportable.
12
16
  actorId: string,
13
17
  onBehalfOfKind: 'user' | 'agent' | 'system' | null,
14
18
  onBehalfOfId: string | null,
15
- credentialId: string | null,
16
- credentialLabel: string | null,
19
+ credentialId: string | null, // the API key/credential used for the write
20
+ credentialLabel: string | null, // its human-readable name, for scanning the log
17
21
  delegationChainRoot: string | null, // always points at a human
18
- causedByRunId: string | null,
22
+ causedByRunId: string | null, // the agent run that produced this write — group every change from one run
19
23
  actionType: string, // e.g. 'weatherReport.update'
20
24
  modelName: string | null, // e.g. 'claude-opus-4-7'
21
25
  diffSummary: unknown,
@@ -28,9 +32,9 @@ tamper-evident, queryable, exportable.
28
32
 
29
33
  ## Delegation chain
30
34
 
31
- `delegationChainRoot` always points at the human who started the chain.
32
- There is no audit row whose root is an agent. Autonomous AI writes are not
33
- a thing in this system; every chain starts with a person.
35
+ Every action traces back to a human. Even when an agent makes the change,
36
+ `delegationChainRoot` names the person who set that work in motion there is no
37
+ audit row whose root is an agent.
34
38
 
35
39
  ## Verify
36
40
 
@@ -71,7 +75,10 @@ curl 'https://<your-app>/api/orgs/<slug>/audit/export?actorKind=agent&since=2026
71
75
  > may-agent-writes.csv
72
76
  ```
73
77
 
74
- CSV up to a hard cap per request. For larger windows, paginate.
78
+ One request exports CSV up to a hard row cap. If your window is larger than the
79
+ cap, the response is truncated at the cap rather than erroring — so for large
80
+ windows, split the window by date and request each slice, or page through the
81
+ JSON `GET` endpoint above using `nextCursor`.
75
82
 
76
83
  ## Compliance posture
77
84
 
package/docs/cli.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  The `ablo` CLI gets you from an empty project to live-syncing data: scaffold a
4
4
  schema, authenticate, push the schema, and watch it sync. Your
5
- `defineSchema(...)` is the single source of truth the CLI and the hosted
6
- server lower it to **the same SQL** through one engine
7
- (`generateProvisionPlan` / `generateMigrationPlan` in `@abloatai/ablo/schema`).
5
+ `defineSchema(...)` is the single source of truth: whether you run the CLI
6
+ locally or push to the hosted server, the same engine turns it into the same
7
+ SQL so what you test is what ships.
8
8
 
9
9
  ```bash
10
10
  npx ablo init # scaffold ablo/schema.ts + client
@@ -12,6 +12,13 @@ npx ablo login # authorize in the browser
12
12
  npx ablo dev # push schema to the test sandbox + watch
13
13
  ```
14
14
 
15
+ **Two setups, and they pick your commands.** If Ablo manages your Postgres —
16
+ the default, **hosted** path — use `ablo dev` and `ablo push`. If you
17
+ **bring your own database (BYO)**, use `ablo migrate` to apply changes to your
18
+ own `DATABASE_URL` directly, and `ablo check` / `ablo pull` to adopt tables you
19
+ already have. The commands below are tagged **Hosted** or **BYO** so you can
20
+ tell which apply to you.
21
+
15
22
  ## Authenticate
16
23
 
17
24
  `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
@@ -48,7 +55,8 @@ either mode) defines the same models test and live see; only the rows differ.
48
55
  | `ablo mode [test\|live]` | Switch active mode. | — |
49
56
  | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
50
57
  | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
51
- | `ablo schema push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
58
+ | `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
59
+ | `ablo migrate` | **BYO** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
52
60
  | `ablo pull` | **BYO** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
53
61
  | `ablo check` | **BYO** — verify your *existing* tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
54
62
  | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
@@ -98,10 +106,11 @@ point: review it, then run `ablo check`.
98
106
 
99
107
  ## `ablo check`
100
108
 
101
- The BYO front door. Instead of migrating (DDL on your database), Ablo *adopts*
102
- the tables you already have: `ablo check` introspects `DATABASE_URL`, compares it
103
- to your `defineSchema(...)`, and reports per model — whether the table is
104
- adoptable. It never writes or alters anything.
109
+ `ablo check` is how you adopt a database you already own. Instead of creating or
110
+ altering tables, it inspects your existing ones and tells you which fit the
111
+ schema: it introspects `DATABASE_URL`, compares each table to your
112
+ `defineSchema(...)`, and reports per model — whether the table is adoptable.
113
+ It never writes or alters anything.
105
114
 
106
115
  A table is adoptable when it has a primary key `id` and (for org-scoped models)
107
116
  an `organization_id` column — the tenancy marker the engine isolates on. Every
@@ -131,12 +140,13 @@ If a table can't carry `organization_id` (or has business logic Ablo shouldn't
131
140
  bypass), keep it behind a [Data Source endpoint](/data-sources) rather than
132
141
  reshaping it. `ablo check` is read-only; it never proposes a migration.
133
142
 
134
- ## `migrate` vs `schema push`
143
+ ## `migrate` (BYO) vs `push` (Hosted)
135
144
 
136
- Two front doors to the same engine. Use `migrate` when your app owns the
137
- database (it applies to `DATABASE_URL`); use `schema push` (and `dev`) on the
138
- hosted path (the server applies to Ablo-managed Postgres and version-gates
139
- connecting clients).
145
+ Same engine, two setups. If you **bring your own database (BYO)**, use
146
+ `ablo migrate` — it applies the schema to your own `DATABASE_URL`, and you run
147
+ the DDL. If Ablo manages your Postgres (the **hosted** path), use `ablo push`
148
+ (and `ablo dev`) — the server applies the change to Ablo-managed Postgres
149
+ and version-gates connecting clients.
140
150
 
141
151
  ```bash
142
152
  ablo migrate --dry-run # preview the exact SQL
@@ -160,8 +170,8 @@ The one type map, shared by both paths (there is no second mapping):
160
170
 
161
171
  Each table also gets the platform columns (`id`, `organization_id`,
162
172
  `created_by`, `created_at`, `updated_at`), an `organization_id` index, and
163
- row-level security keyed on `current_setting('app.current_org_id')` for tenant
164
- isolation.
173
+ row-level security so each org only sees its own rows — the engine sets this per
174
+ request (via `current_setting('app.current_org_id')`); you don't manage it.
165
175
 
166
176
  `.default(...)` is **not** emitted as a SQL column default — Zod applies the
167
177
  default at write time (`create`), in one place, so a DB default and a schema
@@ -185,7 +195,7 @@ that broke and the Postgres SQLSTATE, not just "migration failed".
185
195
  }
186
196
  ```
187
197
 
188
- `ablo schema push` (hosted) returns the canonical error envelope (HTTP 500),
198
+ `ablo push` (hosted) returns the canonical error envelope (HTTP 500),
189
199
  which the SDK reconstructs as a typed `AbloServerError`:
190
200
 
191
201
  ```json
@@ -207,6 +217,6 @@ migration can't leave clients gated against tables that don't match.
207
217
  | Variable | Purpose | Default |
208
218
  | --- | --- | --- |
209
219
  | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
210
- | `ABLO_API_URL` | Control-plane / API host (`schema push`, `dev`, `status`). | `https://api.abloatai.com` |
220
+ | `ABLO_API_URL` | Control-plane / API host (`push`, `dev`, `status`). | `https://api.abloatai.com` |
211
221
  | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
212
222
  | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |
@@ -1,6 +1,8 @@
1
1
  # Client Behavior
2
2
 
3
- This page covers the SDK behavior around options, errors, retries, and runtimes.
3
+ When several writers touch the same data at once — a person in the browser, a Server Action, an agent worker — the SDK decides whose write lands and how the others find out. This page is the reference for that: per-write options like `wait` and `onStale`, claiming a record so your slow work runs uninterrupted, and which errors are safe to retry.
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
  ## Constructor
6
8
 
@@ -45,30 +47,33 @@ Each schema model becomes a typed model:
45
47
  ```ts
46
48
  await ablo.ready();
47
49
 
48
- const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
49
- const local = ablo.weatherReports.retrieve('report_stockholm');
50
+ const report = await ablo.weatherReports.retrieve('report_stockholm');
51
+ const local = ablo.weatherReports.get('report_stockholm');
50
52
 
51
53
  await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
52
54
  await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
53
55
  await ablo.weatherReports.delete('report_stockholm', { wait: 'confirmed' });
54
56
  ```
55
57
 
56
- `load` is async hydration from local store and server. `retrieve`, `list`, and
57
- `count` are synchronous local reads after data is loaded.
58
+ Call `retrieve`/`list` first they fetch from the server and you `await` them.
59
+ After that, `get`/`getAll`/`getCount` read the already-synced data instantly with
60
+ no `await`, and stay reactive in render. Use the async pair to load, the sync trio
61
+ to read.
58
62
 
59
- `list` accepts the same practical read options the React selector path uses:
60
- `where`, `filter`, `orderBy`, `limit`, `offset`, and `scope`. Scope defaults to
61
- `'live'`; pass `'archived'` or `'all'` when you intentionally want non-live
62
- rows.
63
+ `getAll` accepts the same practical read options the React selector path uses:
64
+ `where`, `filter`, `orderBy`, `limit`, `offset`, and `state`. The `state`
65
+ lifecycle filter defaults to `'live'`; pass `'archived'` or `'all'` when you
66
+ intentionally want non-live rows.
63
67
 
64
68
  ## Multiplayer Behavior
65
69
 
66
- Multiplayer works when every participant uses the same model client path. A
67
- human Server Action, a browser view, and an agent worker can all use
68
- `ablo.weatherReports`:
70
+ Two writers both try to mark `report_stockholm` ready at the same time. To stop
71
+ the second write from silently overwriting the first, every participant goes
72
+ through the same model client path. A human Server Action, a browser view, and an
73
+ agent worker can all use `ablo.weatherReports`:
69
74
 
70
75
  ```ts
71
- const [report] = await ablo.weatherReports.load({ where: { id } });
76
+ const report = await ablo.weatherReports.retrieve(id);
72
77
  const snap = ablo.snapshot({ weatherReports: id });
73
78
 
74
79
  await ablo.weatherReports.update(id, patch, {
@@ -78,9 +83,10 @@ await ablo.weatherReports.update(id, patch, {
78
83
  });
79
84
  ```
80
85
 
81
- The confirmed write fans out over realtime subscriptions. React clients that use
82
- `useAblo((ablo) => ablo.weatherReports.retrieve(id))` receive the new row, and selectors
83
- such as `useAblo((ablo) => ablo.weatherReports.claimState(id))`
86
+ Once the server accepts the write, every other connected client gets the new row
87
+ automatically no polling or manual refresh on your side. React clients that use
88
+ `useAblo((ablo) => ablo.weatherReports.get(id))` receive the new row, and selectors
89
+ such as `useAblo((ablo) => ablo.weatherReports.claim.state(id))`
84
90
  receive active claim state. There is
85
91
  no extra multiplayer setup beyond routing shared state through Ablo.
86
92
 
@@ -113,8 +119,13 @@ await ablo.weatherReports.update(
113
119
 
114
120
  ## Claimed Behavior
115
121
 
122
+ If your update involves a slow step — an API call, an LLM round-trip — and someone
123
+ else might write the same record meanwhile, claiming the record stops you from
124
+ overwriting their change. Check who holds the record with `claim.state(id)`, then
125
+ take it with `claim(id, work)`:
126
+
116
127
  ```ts
117
- const active = ablo.weatherReports.claimState('report_stockholm');
128
+ const active = ablo.weatherReports.claim.state('report_stockholm');
118
129
 
119
130
  if (active) {
120
131
  return { status: 'claimed', active };
@@ -125,14 +136,17 @@ await ablo.weatherReports.claim('report_stockholm', async (report) => {
125
136
  });
126
137
  ```
127
138
 
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:
139
+ `claim.state(id)` returns the current holder (or nothing) without ever blocking.
140
+ When you call `claim(id, work)`, the SDK queues other claimers behind you, re-reads
141
+ the latest row, then runs your `work` — so you can't overwrite a change you didn't
142
+ see. Options on the wait:
130
143
 
131
144
  - default `claim` waits in the fair queue and re-reads before invoking `work`;
132
145
  - `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
133
146
  - `{ maxQueueDepth }` rejects if the wait line is already too deep.
134
147
 
135
- Schema clients use the realtime stream for waits.
148
+ While waiting, schema clients learn when the claim clears from the live claim
149
+ stream, so they never poll.
136
150
 
137
151
  ## Errors
138
152
 
@@ -5,16 +5,18 @@ other. Most writes need none of this — `ablo.<model>.update(id, …)` is optim
5
5
  and the server rejects it if the row moved. Reach for `claim` only when you'll
6
6
  **hold a row across a slow gap** (read → LLM call → write).
7
7
 
8
- Claims are **fair**: on contention a second claimer joins a **server-side FIFO
9
- queue** and blocks until promoted to the head of the line it does not fail and
10
- does not poll. Reads are open by default; reading a claimed row is allowed unless
11
- the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
12
- holder is auto-released and the queue advances.
8
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
9
+ re-reads the fresh row, then hands it to youso two writers serialize instead
10
+ of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
11
+ until promoted to the head of the line it does not fail and does not poll.
12
+ Reads stay open: reading a claimed row is allowed unless the caller explicitly
13
+ asks for claimed gating. A claim carries a TTL so a crashed holder is
14
+ auto-released and the queue advances.
13
15
 
14
16
  This reference opens with [the model](#the-model--three-layers-one-decision) — the
15
17
  one answer to "how do two agents not clobber each other" — then covers the
16
18
  [claim state object](#the-claim-state-object), the SDK [methods](#methods)
17
- (`claim` · `claimState` · `queue` · `release` · [writing under a
19
+ (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
18
20
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
19
21
 
20
22
  ---
@@ -27,8 +29,8 @@ make:
27
29
 
28
30
  | layer | kind | what it does | enforces? |
29
31
  |---|---|---|---|
30
- | **Presence** (`claimState`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
31
- | **Claim** (`claim`/`queue`/`release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
32
+ | **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
33
+ | **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
32
34
  | **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
33
35
 
34
36
  **The one decision: do you hold the row across a slow gap (read → LLM call →
@@ -42,42 +44,29 @@ write)?**
42
44
  excludes other participants for the duration, queues contenders fairly, and —
43
45
  see below — your own writes under it stay stale-guarded too.
44
46
 
45
- **How they compose (what wins):**
46
-
47
- 1. **Claim supersedes stale-context for *foreign* writers.** A non-holder writing
48
- to a claimed row is rejected by the claim guard (`AbloClaimedError`,
49
- `claim_conflict`/`entity_claimed`) *before* any watermark check `readAt` is
50
- irrelevant when you don't hold the lease. Pessimistic exclusion is the outer
51
- gate.
52
- 2. **Stale-context is the always-on backstop for *unclaimed* writes.** No claim
53
- held the watermark check is the only protection, and it's automatic. This is
54
- why the no-claim path is safe by default.
55
- 3. **Inside a claim, both apply.** A claim is not a license to clobber yourself:
56
- writes under a held claim carry the claim's snapshot as `readAt` with
57
- `onStale: 'reject'` (see [Writing under a claim](#writing-under-a-claim)), so a
58
- `bypass` write or a row that moved between snapshot and write still rejects.
59
- Claim = "no one else"; stale-context = "and not against a moved snapshot."
60
- 4. **Presence never decides.** It is the visualization of (1)–(3), not a fourth
61
- gate. Never branch enforcement logic on `claimState` — read it to render, act
62
- on the errors above.
63
-
64
- Claims and stale-context are **orthogonal by construction**, not wired into each
65
- other on the server: the claim guard runs pre-transaction; the watermark check
66
- runs inside it. The SDK attaches `readAt`/`onStale` for you when writing under a
67
- claim — that coupling lives in the SDK, deliberately, so the server's two checks
68
- stay independent and individually testable.
47
+ **How they compose (what wins):** If you don't hold the row, claims win — a
48
+ non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
49
+ `readAt`. If you do hold it, your own writes are still stale-checked — a row that
50
+ moved between your snapshot and your write still rejects with
51
+ `AbloStaleContextError`. With no claim held, the stale check is the only
52
+ protection, and it's automatic, which is why the no-claim path is safe by default.
53
+ Presence (`claim.state`) never decides anything — read it to render, act on the
54
+ errors. The two checks are independent: one rejects writes from people who don't
55
+ hold the claim, the other rejects writes based on a stale snapshot, and the SDK
56
+ adds the stale-check for you when you write under a claim, so you don't pass
57
+ anything extra.
69
58
 
70
59
  ---
71
60
 
72
61
  ## The claim state object
73
62
 
74
63
  The claim state object is the live record that a participant is coordinating work on
75
- a model row. It's what `claimState()` returns and what observers render.
64
+ a model row. It's what `claim.state()` returns and what observers render.
76
65
 
77
66
  | field | type | description |
78
67
  |---|---|---|
79
68
  | `id` | `string` | The claim id (distinct from the target row id). |
80
- | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
69
+ | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
81
70
  | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
82
71
  | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
83
72
  | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
@@ -118,8 +107,8 @@ open by default. The claim acquires through the server's fair FIFO queue: if the
118
107
  target is free the lease is yours immediately, and if another participant holds
119
108
  it your claim **waits in line** and resolves only once it reaches the head —
120
109
  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.
110
+ committed. There's no polling and no race window the server decides the order,
111
+ so two claimers can't both think they won.
123
112
 
124
113
  **Parameters**
125
114
 
@@ -157,8 +146,8 @@ held work should use the callback form above.
157
146
 
158
147
  ### Claim-gated reads
159
148
 
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
149
+ `claim.state(id)` always returns immediately. Model reads such as
150
+ `ablo.<model>.get(id)` are local reads and stay available while a claim is
162
151
  held. Server/model reads can choose a claimed policy:
163
152
 
164
153
  ```ts
@@ -172,10 +161,10 @@ await ablo.model('weatherReports').retrieve('report_stockholm', {
172
161
  - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
173
162
  - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
174
163
 
175
- ### `claimState`
164
+ ### `claim.state`
176
165
 
177
166
  ```ts
178
- ablo.<model>.claimState(id)
167
+ ablo.<model>.claim.state(id)
179
168
  ```
180
169
 
181
170
  Read who's currently working on a row, for observers and UI. Synchronous and
@@ -193,7 +182,7 @@ is free.
193
182
  **Example**
194
183
 
195
184
  ```ts
196
- const who = ablo.weatherReports.claimState('report_stockholm');
185
+ const who = ablo.weatherReports.claim.state('report_stockholm');
197
186
  if (who) console.log(`${who.heldBy} is ${who.action}`);
198
187
  ```
199
188
 
@@ -211,17 +200,17 @@ Returns the active claim state when the row is held, or `null` when it's free:
211
200
  }
212
201
  ```
213
202
 
214
- ### `queue`
203
+ ### `claim.queue`
215
204
 
216
205
  ```ts
217
- ablo.<model>.queue(id)
206
+ ablo.<model>.claim.queue(id)
218
207
  ```
219
208
 
220
209
  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
210
+ current holder, in promotion order. Like `claim.state`, it's synchronous and
222
211
  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
212
+ queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
213
+ holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
225
214
  decide the wait isn't worth it.
226
215
 
227
216
  **Parameters**
@@ -237,15 +226,15 @@ the active holder; `[]` when no one is waiting.
237
226
  **Example**
238
227
 
239
228
  ```ts
240
- const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
229
+ const { data: waiting } = ablo.weatherReports.claim.queue('report_stockholm');
241
230
  console.log(`${waiting.length} ahead of you`);
242
231
  console.log(waiting.map((i) => i.heldBy));
243
232
  ```
244
233
 
245
- ### `release`
234
+ ### `claim.release`
246
235
 
247
236
  ```ts
248
- ablo.<model>.release(id): Promise<void>
237
+ ablo.<model>.claim.release(id): Promise<void>
249
238
  ```
250
239
 
251
240
  Release a claim you hold. Usually **implicit** — the callback returning releases
@@ -271,13 +260,13 @@ try {
271
260
  if (!ok) return; // abandon, no write
272
261
  await ablo.weatherReports.update(report.id, { status: 'ready' });
273
262
  } finally {
274
- await ablo.weatherReports.release(report.id);
263
+ await ablo.weatherReports.claim.release(report.id);
275
264
  }
276
265
  ```
277
266
 
278
267
  ### Writing under a claim
279
268
 
280
- There is no separate "write" method on a claim — use the normal flat
269
+ There is no separate "write" method on a claim — use the normal
281
270
  `ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
282
271
  automatically stale-guarded against the snapshot the claim took (`readAt` =
283
272
  snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
@@ -1,19 +1,19 @@
1
1
  # Connect Your Database
2
2
 
3
- Every schema model has a backing store.
3
+ By default, Ablo stores the rows for the models you define, so you don't need a
4
+ database to get started. But if you already have your own application database
5
+ and want it to stay the source of truth, you can attach it as a Data Source —
6
+ then Ablo coordinates each write and calls your app to commit it, instead of
7
+ storing the data itself.
4
8
 
5
- Customer apps must define an Ablo schema. The schema is the contract between
6
- the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
7
- `defineSchema`, `model`, and Zod the same way a Prisma project starts with a
8
- `schema.prisma`.
9
+ That default makes Ablo the managed state store for your models, the same way
10
+ Stripe stores `Customer` and `PaymentIntent` objects that you create through
11
+ Stripe's API.
9
12
 
10
- By default, Ablo stores the rows for the models you declare. That makes Ablo the
11
- managed state store for those models, the same way Stripe stores `Customer`
12
- and `PaymentIntent` objects that you create through Stripe's API.
13
-
14
- If you already have application tables and want those tables to remain
15
- canonical, attach a Data Source. Then Ablo coordinates the write and calls your
16
- app to commit it.
13
+ Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod
14
+ the same way a Prisma project starts with a `schema.prisma`. Your schema
15
+ describes your data once, and everything else (the SDK, agents, and your
16
+ database connection) relies on that one definition.
17
17
 
18
18
  Your app can keep using its own `DATABASE_URL`. Store that value in your app or
19
19
  backend environment, not in Ablo. The integration boundary is the HTTPS
@@ -56,15 +56,18 @@ The SDK call is the same in both modes:
56
56
  ```ts
57
57
  await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
58
58
  await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
59
- const report = ablo.weatherReports.retrieve('report_stockholm');
59
+ const report = ablo.weatherReports.get('report_stockholm');
60
60
  ```
61
61
 
62
62
  Only the backing store changes.
63
63
 
64
64
  Multiplayer behavior is the same in both modes. Writes made through
65
65
  `ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
66
- fan out to subscribers. Direct database writes outside Ablo need Data Source
67
- events so connected humans and agents see the change.
66
+ fan out to subscribers. If something writes to your database without going
67
+ through Ablo (a cron job, an admin tool), Ablo can't know about it
68
+ automatically. To keep everyone's screen up to date, your app reports those
69
+ outside changes back through an events feed — shown below in
70
+ [External Writes](#external-writes).
68
71
 
69
72
  ## When To Use A Data Source
70
73
 
@@ -231,10 +234,9 @@ Before using a customer-owned database in production:
231
234
  - Dedupe outbox events by event `id`.
232
235
  - Monitor last success, last error, retry count, event lag, and cursor.
233
236
 
234
- Do not send the customer's database URL to Ablo for this path. Direct database
235
- URL custody would be a separate connector product with encrypted secret storage,
236
- rotation, least-privilege roles, connection limits, table allowlists, and clear
237
- data-processing terms.
237
+ Don't give Ablo your database URL for this integration Ablo never connects to
238
+ your database directly. (Direct database access would be a separate product with
239
+ its own security model.)
238
240
 
239
241
  ## Security
240
242