@abloatai/ablo 0.6.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 (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ A callable `claim` coordination namespace and bring-your-own-database support
6
+ via a new `databaseUrl` option.
7
+
8
+ ### Minor Changes
9
+
10
+ - **Callable `claim` coordination namespace.** Taking a claim and inspecting its
11
+ state now live under one accessor: `claim(id, work)` acquires a claim and runs
12
+ `work` while it's held, and `claim.state(id)`, `claim.queue(id)`,
13
+ `claim.release(id)`, and `claim.reorder(id, order)` cover the surrounding
14
+ lifecycle. The README leads with the problem (who is allowed to act, and in
15
+ what order) and the Quick Start now demonstrates `claim` directly.
16
+
17
+ - **Bring-your-own-database via `databaseUrl`.** Point a project at your own
18
+ Postgres with `Ablo({ schema, apiKey, databaseUrl })`. Ablo writes synced rows
19
+ back into your database, so your data stays canonical. Server-side only;
20
+ defaults to `process.env.DATABASE_URL`. See the data-sources guide for setup
21
+ and role requirements.
22
+
23
+ ### Breaking
24
+
25
+ - The flat coordination methods `claimState`, `queue`, `release`, and `reorder`
26
+ are removed in favor of the `claim` namespace above.
27
+
28
+ ```diff
29
+ - await ablo.task.claimState(id)
30
+ - await ablo.task.release(id)
31
+ + await ablo.task.claim.state(id)
32
+ + await ablo.task.claim.release(id)
33
+ ```
34
+
35
+ ## 0.7.0
36
+
37
+ ### Minor Changes
38
+
39
+ - Structured error contract, schema/migration engine, and a full `ablo` CLI.
40
+ - **Structured error contract across HTTP + WS planes.** A closed, canonical
41
+ error-code registry is now the `code` tier of a Stripe-style error model. A
42
+ single HTTP egress funnel converts every throw to a canonical
43
+ `{ type, code, message, doc_url, request_id, ...details }` envelope; the WS
44
+ plane narrows mutation/claim error codes to the same union.
45
+ - **Versioned contract + drift guard.** `ERROR_CONTRACT_VERSION` (date-based)
46
+ ships in `errors.json` and on the `Ablo-Version` response header, so consumers
47
+ detect contract changes without diffing docs. Generated `errors.mdx` /
48
+ `errors.json` plus a CI drift guard keep the docs, OpenAPI spec, and SDK from
49
+ silently diverging from the registry.
50
+ - **Always-on request correlation.** Every response carries a `req_…` request id
51
+ (honoring an inbound `x-request-id`), stamped into the envelope's `request_id`.
52
+ - **OpenAPI parity.** The stale `{ error, reason }` schema is replaced by the
53
+ canonical envelope plus a generated `ErrorCode` enum.
54
+
55
+ CLI + schema:
56
+ - **Schema diff + migration planning engine** (`generateProvisionPlan` /
57
+ `generateMigrationPlan` in `@abloatai/ablo/schema`) — pure diff, classify,
58
+ apply, and constant-value backfill for required-field migrations.
59
+ - **`ablo generate`** — emit TypeScript types from the pushed schema.
60
+ - **Full `ablo` CLI suite**, Stripe-CLI-shaped: `init`, `login` / `logout` /
61
+ `status`, `mode [test|live]`, `dev` (push schema to the test sandbox + watch),
62
+ `logs` (tail your scope's commit activity), and the data-source commands below.
63
+ Authentication is the OAuth 2.0 device flow; `login` provisions and stores a
64
+ test and a live key, and `mode` switches the active one.
65
+ - **Database-URL structure (bring-your-own-database).** The CLI is split by where
66
+ it writes:
67
+ - `ablo pull` / `ablo check` / `ablo migrate` operate on **your own
68
+ `DATABASE_URL`** — `pull` introspects it to emit `defineSchema(...)` from
69
+ existing tables (read-only, like `prisma db pull`), `check` verifies tables
70
+ fit the schema with no DDL, and `migrate` applies DDL to `DATABASE_URL`.
71
+ - `ablo schema push` / `ablo dev` target the **hosted** test/live sandbox; the
72
+ server diffs, migrates, and activates the uploaded schema. `dev` never
73
+ touches live data.
74
+
75
+ **BREAKING** — removed the legacy React hooks `useQuery` / `useOne` / `useMutate`
76
+ / `useReader`. Use `useAblo()` + `ablo.<model>.*` instead. The `MutateActions`,
77
+ `ReaderActions`, and `ReaderFindOptions` types are still re-exported for callers
78
+ that referenced them.
79
+
3
80
  ## 0.6.0
4
81
 
5
82
  ### Minor Changes
package/README.md CHANGED
@@ -1,39 +1,66 @@
1
1
  # Ablo
2
2
 
3
- Ablo is a typed sync engine for shared app state — the kind that humans,
4
- server code, and AI agents all edit at once.
3
+ [![npm](https://img.shields.io/npm/v/@abloatai/ablo.svg)](https://www.npmjs.com/package/@abloatai/ablo)
4
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
5
+ [![types](https://img.shields.io/badge/types-included-blue.svg)](#)
6
+ [![runtime](https://img.shields.io/badge/node-%E2%89%A522-brightgreen.svg)](#keys--runtime)
5
7
 
6
- Reach for it when those edits need to show up everywhere in real time, not
7
- silently overwrite each other, expose who's working on what, and leave a record
8
- of who changed what.
8
+ **Let people and AI agents work on the same data without overwriting each other.**
9
+
10
+ When an agent and a person change the same thing at once, work gets lost: one
11
+ edit silently clobbers another, or the agent acts on data that already moved.
12
+ Ablo gives them one shared, typed write path so people, server actions, and
13
+ agents can all work on the same rows without working blind.
14
+
15
+ The core idea is a **claim**. An agent's work is rarely one instant write; it
16
+ reads something, thinks, calls an LLM or tool, then writes back. While that is
17
+ happening, the row can change underneath it. So before slow work starts, the
18
+ agent claims the row. If someone else is already working on it, `claim` waits,
19
+ re-reads the fresh row, then hands it over. No stale overwrite, no separate
20
+ agent mutation path.
21
+
22
+ Under the hood, you define a Zod schema once and get typed model clients for
23
+ every actor:
9
24
 
10
25
  ```txt
11
26
  schema -> ablo.<model>.create/retrieve/update/claim(...)
12
27
  ```
13
28
 
29
+ The schema is the public contract. It gives you typed model methods, realtime
30
+ fanout, React selectors, agent writes, and the HTTP/Data Source shape for
31
+ non-JavaScript services. Every confirmed change shows up everywhere, and active
32
+ claims are visible while the work is still in progress.
33
+
34
+ [Get started ↓](#quick-start) · point your coding agent at the shipped `llms.txt`
35
+
36
+ It works with the auth and database you already have: realtime data is scoped to
37
+ *sync groups* from your own identity, and your database can stay the source of
38
+ truth via a Data Source.
39
+
40
+ **Built for** collaborative editors, AI agent workflows, and internal tools —
41
+ anywhere people and agents change shared state and everyone has to see it live.
42
+
14
43
  ## Set up
15
44
 
16
45
  ```bash
17
46
  npm install @abloatai/ablo
18
47
  ```
19
48
 
20
- The package ships an `llms.txt` a precise map of the API — so a coding agent
21
- integrates from the real surface instead of guessing it. Point Claude Code or
22
- Cursor at it:
49
+ **Keys & runtime.** Ablo needs Node 22+ and TypeScript 5+. Grab an `sk_test_*`
50
+ key for a sandbox
51
+ (`export ABLO_API_KEY=sk_test_...`); keep keys in trusted server runtimes only.
52
+ In the browser, `<AbloProvider>` authenticates with the signed-in user's
53
+ session — never the raw key.
23
54
 
24
- > Read `node_modules/@abloatai/ablo/llms.txt`, then add an Ablo schema and
25
- > `<AbloProvider>`, wire my first create / retrieve / update, and use `claim`
26
- > for anything an agent edits across a slow step (read → LLM → write).
27
-
28
- Or wire it by hand — the [Quick Start](#quick-start) below is the shape it
29
- produces. For production (React, an existing backend, Data Source, agents), the
55
+ Then wire it by hand the [Quick Start](#quick-start) below is the shape to
56
+ copy. For production (React, an existing backend, Data Source, agents), the
30
57
  [Integration Guide](./docs/integration-guide.md) is the deeper map.
31
58
 
32
- **Keys & runtime.** Ablo is ESM-only (`import`, not `require`) and needs Node
33
- 22+ and TypeScript 5+. Grab an `sk_test_*` key
34
- for a sandbox (`export ABLO_API_KEY=sk_test_...`); keep keys in trusted server
35
- runtimes only. In the browser, `<AbloProvider>` authenticates with the signed-in
36
- user's session never the raw key.
59
+ **Prefer to let an agent wire it?** The package ships an `llms.txt` a precise
60
+ map of the API so Claude Code or Cursor integrates from the real surface
61
+ instead of guessing:
62
+
63
+ > Read `node_modules/@abloatai/ablo/llms.txt`, then add an Ablo schema, a `<AbloProvider>`, and my first create / retrieve / update.
37
64
 
38
65
  ## Quick Start
39
66
 
@@ -61,12 +88,16 @@ const created = await ablo.weatherReports.create({
61
88
  status: 'pending',
62
89
  });
63
90
 
64
- const updated = await ablo.weatherReports.update(created.id, {
65
- status: 'ready',
66
- forecast: 'Light rain, 13C',
91
+ // An agent claims the row, does its slow work, then writes back. While the
92
+ // claim is held nobody else can overwrite it; anyone else who tries waits in
93
+ // line and re-reads the result. This is the whole point of Ablo.
94
+ await ablo.weatherReports.claim(created.id, async (report) => {
95
+ const forecast = await fetchForecast(report.location); // slow: API or LLM call
96
+ await ablo.weatherReports.update(report.id, { status: 'ready', forecast });
67
97
  });
68
98
 
69
- console.log({ id: updated.id, status: updated.status });
99
+ const ready = ablo.weatherReports.get(created.id);
100
+ console.log({ id: ready.id, status: ready.status });
70
101
 
71
102
  await ablo.dispose();
72
103
  ```
@@ -77,33 +108,32 @@ Expected output:
77
108
  { id: '...', status: 'ready' }
78
109
  ```
79
110
 
80
- Pass `schema` to get typed models like `ablo.weatherReports.update(...)`.
81
-
82
111
  ## Reading
83
112
 
84
- `retrieve(id)` returns one row from the local cache synchronous, no round-trip.
85
- `list(...)` filters and sorts what's already synced; it's also synchronous, and
86
- reactive under `useAblo`/`subscribe`. `load(...)` fetches from the server when a
87
- row may not be local yet.
113
+ Two ways to read, depending on whether you can wait. `get(id)` / `getAll({ where })`
114
+ / `getCount({ where })` are instant they read what's already local and re-render
115
+ on their own when it changes, so they're what your UI uses. `retrieve(id)` /
116
+ `list({ where })` go ask the server and return a `Promise`, for when you need the
117
+ authoritative answer right now.
88
118
 
89
119
  ```ts
90
- ablo.weatherReports.retrieve('report_stockholm'); // → row | undefined
120
+ ablo.weatherReports.get('report_stockholm');
91
121
 
92
- // Synchronous, from the local cache → row[]
93
- const pending = ablo.weatherReports.list({
94
- where: { status: 'pending' }, // equality filter (an array value means IN)
122
+ const pending = ablo.weatherReports.getAll({
123
+ where: { status: 'pending' },
95
124
  orderBy: { location: 'asc' },
96
125
  limit: 20,
97
126
  });
98
127
 
99
- // Server fetch Promise<row[]>. 'complete' waits for the server; 'unknown'
100
- // returns what's local now and refreshes in the background.
101
- const ready = await ablo.weatherReports.load({
128
+ const ready = await ablo.weatherReports.list({
102
129
  where: { status: 'ready' },
103
130
  type: 'complete',
104
131
  });
105
132
  ```
106
133
 
134
+ An array value in `where` means `IN`. On `list`, `type: 'complete'` waits for
135
+ the server; `'unknown'` returns what's local now and refreshes in the background.
136
+
107
137
  ## Writing
108
138
 
109
139
  `create` / `update` apply optimistically and resolve to the row. Two options
@@ -128,30 +158,35 @@ meanwhile, or worse, acts on stale state. `claim` holds the row across that gap:
128
158
 
129
159
  ```ts
130
160
  await ablo.weatherReports.claim('report_stockholm', async (report) => {
131
- // If someone else holds it, claim() WAITS in a fair queue, then re-reads —
132
- // so `report` is the current row, never a stale snapshot. Reads stay open by
133
- // default; only acting-on-the-row serializes.
134
-
135
- const forecast = await weatherAgent.getWeather(report.location); // slow LLM gap
161
+ const forecast = await weatherAgent.getWeather(report.location);
136
162
  await ablo.weatherReports.update(report.id, { forecast, status: 'ready' });
137
- }); // claim released here, whether the callback returns or throws
163
+ });
138
164
  ```
139
165
 
166
+ If someone else holds the row, `claim()` waits in a fair queue, then re-reads —
167
+ so `report` is the current row, never a stale snapshot. Reads stay open by
168
+ default; only acting on the row serializes. The claim releases when the callback
169
+ returns or throws.
170
+
140
171
  See who's mid-edit before you act — decide to wait, or skip:
141
172
 
142
173
  ```ts
143
- ablo.weatherReports.claimState('report_stockholm'); // → the holder, or null
144
- ablo.weatherReports.queue('report_stockholm'); // → { data: [{ heldBy, action, position }, …] }
174
+ ablo.weatherReports.claim.state('report_stockholm');
175
+ ablo.weatherReports.claim.queue('report_stockholm');
145
176
 
146
177
  await ablo.weatherReports.claim(id, async (report) => {
147
178
  /* do the held work */
148
- }, { wait: false }); // held? skip (dedup) — throws instead of waiting
179
+ }, { wait: false });
149
180
 
150
181
  await ablo.weatherReports.claim(id, async (report) => {
151
182
  /* do the held work */
152
- }, { maxQueueDepth: 2 }); // 2+ already ahead? bail
183
+ }, { maxQueueDepth: 2 });
153
184
  ```
154
185
 
186
+ `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
187
+ behind it. `wait: false` skips rather than waiting when the row is held;
188
+ `maxQueueDepth: 2` bails when two or more are already ahead.
189
+
155
190
  Default reads keep working while a row is claimed. Server reads that need claimed
156
191
  semantics can opt in with `ifClaimed: 'return' | 'wait' | 'fail'`.
157
192
 
@@ -168,8 +203,8 @@ try {
168
203
  > Prefer the callback form for ordinary held work. Manual scoped claims are
169
204
  > available for wider lifetimes, but callback claims are the docs default.
170
205
 
171
- See [Coordination](./docs/coordination.md) for the full `claim` / `claimState` /
172
- `queue` / `release` reference.
206
+ See [Coordination](./docs/coordination.md) for the full `claim` / `claim.state` /
207
+ `claim.queue` / `claim.release` reference.
173
208
 
174
209
  ## React
175
210
 
@@ -179,7 +214,7 @@ everything inside is live.
179
214
 
180
215
  ```tsx
181
216
  import { AbloProvider, useAblo } from '@abloatai/ablo/react';
182
- import { schema } from './ablo.schema';
217
+ import { schema } from './ablo/schema';
183
218
 
184
219
  function App() {
185
220
  return (
@@ -190,14 +225,11 @@ function App() {
190
225
  }
191
226
 
192
227
  function Report({ id }: { id: string }) {
193
- // Reactive read: this re-renders whenever the row changes — whether you,
194
- // a teammate, or an agent changed it.
195
- const report = useAblo((ablo) => ablo.weatherReports.retrieve(id));
228
+ const report = useAblo((ablo) => ablo.weatherReports.get(id));
196
229
  const ablo = useAblo();
197
230
 
198
231
  if (!report) return null;
199
232
 
200
- // Write: same method as the server example above. Optimistic; fans out.
201
233
  return (
202
234
  <button onClick={() => ablo?.weatherReports.update(id, { status: 'ready' })}>
203
235
  {report.status}
@@ -206,6 +238,10 @@ function Report({ id }: { id: string }) {
206
238
  }
207
239
  ```
208
240
 
241
+ The `useAblo(selector)` read re-renders whenever the row changes — whether you,
242
+ a teammate, or an agent changed it. The write is the same optimistic, fan-out
243
+ method as the server example above.
244
+
209
245
  `<AbloProvider>` owns the connection — no API key in the browser. That's the
210
246
  whole loop: read with `useAblo(selector)`, write with `ablo.<model>`, and every
211
247
  other client (human or agent) on that row sees it in real time. See
@@ -226,8 +262,9 @@ the browser connects as an already-scoped participant — it never holds the key
226
262
  and can't widen its own scope. Your schema's `identityRoles` map that identity
227
263
  to sync-group strings.
228
264
 
265
+ `userId` / `teamIds` come from your auth, resolved server-side:
266
+
229
267
  ```tsx
230
- // userId / teamIds come from YOUR auth, resolved server-side
231
268
  <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
232
269
  <App />
233
270
  </AbloProvider>
@@ -248,7 +285,7 @@ each other's changes in real time — that's the default, not a feature you turn
248
285
 
249
286
  - `ablo.<model>.create/update/delete` fan out confirmed deltas to subscribers.
250
287
  - `useAblo(...)` gives React clients the live row, kept current automatically.
251
- - `ablo.<model>.claim(id)` / `claimState(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
288
+ - `ablo.<model>.claim(id)` / `claim.state(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
252
289
 
253
290
  Always write through Ablo — either the SDK model methods
254
291
  (`ablo.<model>.create/update/delete`) or the HTTP write endpoint below. If you
@@ -262,7 +299,7 @@ HTTP endpoint when a server-to-server caller needs to write without opening a
262
299
  WebSocket:
263
300
 
264
301
  ```bash
265
- curl https://api.ablo.dev/v1/commits \
302
+ curl https://api.abloatai.com/v1/commits \
266
303
  -H "Authorization: Bearer sk_test_..." \
267
304
  -H "Content-Type: application/json" \
268
305
  -d '{ "operations": [
@@ -343,10 +380,11 @@ contract; there are no retry or timeout knobs to tune.
343
380
  ## Production Reference
344
381
 
345
382
  - [Identity & Sync Groups](./docs/identity.md) — bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
383
+ - [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
346
384
  - [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
347
385
  - [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
348
386
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
349
- - [Coordination](./docs/coordination.md) — `claim` / `claimState` / `queue` / `release` reference: hold a row across slow agent work, and observe the line waiting behind it.
387
+ - [Coordination](./docs/coordination.md) — `claim` / `claim.state` / `claim.queue` / `claim.release` reference: hold a row across slow agent work, and observe the line waiting behind it.
350
388
  - [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
351
389
  - [Connect Your Database](./docs/data-sources.md) — keep canonical rows in your app database without giving Ablo database credentials.
352
390
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
@@ -662,7 +662,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
662
662
  */
663
663
  queryByClass(modelClass: ModelConstructor<Model>, options?: {
664
664
  predicate?: (model: Model) => boolean;
665
- scope?: ModelScope;
665
+ state?: ModelScope;
666
666
  orderBy?: keyof Model;
667
667
  order?: 'asc' | 'desc';
668
668
  limit?: number;
@@ -12,7 +12,7 @@
12
12
  * pull generic methods into this base class.
13
13
  */
14
14
  import { makeObservable, observable, computed, runInAction } from 'mobx';
15
- import { AbloConnectionError, AbloValidationError } from './errors.js';
15
+ import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
16
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
17
  import { PropertyType } from './types/index.js';
18
18
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
@@ -447,14 +447,18 @@ export class BaseSyncedStore {
447
447
  }
448
448
  }
449
449
  }
450
- throw lastError || new Error('Bootstrap failed after all retry attempts');
450
+ throw lastError
451
+ ? toAbloError(lastError)
452
+ : new AbloConnectionError('Bootstrap failed after all retry attempts', {
453
+ code: 'bootstrap_fetch_timeout',
454
+ });
451
455
  }
452
456
  /** Create a timeout promise for bootstrap attempts */
453
457
  createBootstrapTimeout(attempt) {
454
458
  const timeoutMs = BOOTSTRAP_CONFIG.OVERALL_TIMEOUT_MS + (attempt - 1) * 3_000;
455
459
  return new Promise((_, reject) => {
456
460
  setTimeout(() => {
457
- reject(new Error(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`));
461
+ reject(new AbloConnectionError(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`, { code: 'bootstrap_fetch_timeout' }));
458
462
  }, timeoutMs);
459
463
  });
460
464
  }
@@ -1688,7 +1692,7 @@ export class BaseSyncedStore {
1688
1692
  const modelName = this.objectPool.registry.getModelNameFromConstructor(modelClass);
1689
1693
  if (!modelName)
1690
1694
  return { data: [], total: 0, hasMore: false };
1691
- let allModels = this.objectPool.getByType(modelClass, options?.scope ?? ModelScope.live);
1695
+ let allModels = this.objectPool.getByType(modelClass, options?.state ?? ModelScope.live);
1692
1696
  // Filter out pending deletes
1693
1697
  allModels = allModels.filter((m) => !this.pendingDeletes.has(m.id));
1694
1698
  // Apply predicate
@@ -33,7 +33,8 @@ export declare const noopObservability: SyncObservabilityProvider;
33
33
  export declare const noopAnalytics: SyncAnalytics;
34
34
  /** Browser-native online status provider */
35
35
  export declare const browserOnlineStatus: OnlineStatusProvider;
36
- /** Permissive session error detector — treats 401/403 as session errors */
36
+ /** Session error detector — delegates to SyncSessionError so detection is
37
+ * code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
37
38
  export declare const defaultSessionErrorDetector: SessionErrorDetector;
38
39
  /**
39
40
  * Fallback config used when the context is read before
@@ -4,6 +4,7 @@
4
4
  * All SDK classes receive this context at construction time.
5
5
  * It bundles every injectable dependency so constructors stay clean.
6
6
  */
7
+ import { SyncSessionError } from './errors.js';
7
8
  // ─────────────────────────────────────────────
8
9
  // No-op defaults for optional dependencies
9
10
  // ─────────────────────────────────────────────
@@ -45,7 +46,8 @@ export const browserOnlineStatus = {
45
46
  return typeof navigator !== 'undefined' ? navigator.onLine : true;
46
47
  },
47
48
  };
48
- /** Permissive session error detector — treats 401/403 as session errors */
49
+ /** Session error detector — delegates to SyncSessionError so detection is
50
+ * code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
49
51
  export const defaultSessionErrorDetector = {
50
52
  isSessionError(error) {
51
53
  if (error && typeof error === 'object' && 'isSessionError' in error) {
@@ -53,8 +55,8 @@ export const defaultSessionErrorDetector = {
53
55
  }
54
56
  return false;
55
57
  },
56
- isSessionErrorResponse(status) {
57
- return status === 401 || status === 403;
58
+ isSessionErrorResponse(status, body) {
59
+ return SyncSessionError.isSessionErrorResponse(status, body);
58
60
  },
59
61
  };
60
62
  /**
@@ -20,6 +20,7 @@
20
20
  * The helper itself imports nothing app-specific. Open-source-clean.
21
21
  */
22
22
  import { Ablo } from '../client/Ablo.js';
23
+ import { AbloConnectionError } from '../errors.js';
23
24
  /**
24
25
  * Returns a session whose `getAgent` method handles cache, mint,
25
26
  * sync_groups alignment, and lifecycle. Call `disposeAll()` from
@@ -113,8 +114,8 @@ export function createAgentSession(options) {
113
114
  causeMsg,
114
115
  err,
115
116
  });
116
- throw new Error(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
117
- (code ? ` (${code})` : ''));
117
+ throw new AbloConnectionError(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
118
+ (code ? ` (${code})` : ''), { code: 'bootstrap_fetch_timeout', cause: err });
118
119
  }
119
120
  cacheByKey.set(key, { agent, expiresAtMs: minted.expiresAtMs });
120
121
  return agent;
@@ -11,7 +11,25 @@
11
11
  * SDKs hide their internal auth-handshake — the apiKey is the only
12
12
  * credential the consumer touches.
13
13
  */
14
- import { AbloAuthenticationError } from '../errors.js';
14
+ import { AbloAuthenticationError, translateHttpError } from '../errors.js';
15
+ /**
16
+ * Whether an HTTP error body carries a code `translateHttpError` can read —
17
+ * a top-level `code`, a nested `error.code`, or a string `error`. When it
18
+ * doesn't (empty body, non-JSON, or a non-Ablo proxy 401), the caller falls
19
+ * back to its own default code rather than emitting a code-less error.
20
+ */
21
+ function hasWireCode(body) {
22
+ if (typeof body !== 'object' || body === null)
23
+ return false;
24
+ const b = body;
25
+ if (typeof b.code === 'string')
26
+ return true;
27
+ if (typeof b.error === 'string')
28
+ return true;
29
+ return (typeof b.error === 'object' &&
30
+ b.error !== null &&
31
+ typeof b.error.code === 'string');
32
+ }
15
33
  export async function exchangeApiKey(options) {
16
34
  if (!options.apiKey) {
17
35
  throw new AbloAuthenticationError('apiKey is required for capability exchange', { code: 'apikey_missing' });
@@ -59,11 +77,16 @@ export async function exchangeApiKey(options) {
59
77
  catch {
60
78
  // ignore — server returned non-JSON error
61
79
  }
62
- const errBody = body;
63
- throw new AbloAuthenticationError(`apiKey exchange rejected (${response.status}): ${errBody?.reason ?? response.statusText}`, {
64
- code: errBody?.error ?? 'exchange_failed',
65
- httpStatus: response.status,
66
- });
80
+ // Route through the canonical wire-error translator so the server's
81
+ // envelope (`code` + `message` + `doc_url`) propagates verbatim and maps to
82
+ // the right AbloError subclass — instead of the legacy `error`/`reason`
83
+ // shape this used to read (which the server no longer emits, collapsing
84
+ // every failure to a generic code with an empty message). Fall back to
85
+ // `exchange_failed` only when the body carried no recognizable code.
86
+ const requestId = response.headers.get('x-request-id') ?? undefined;
87
+ throw hasWireCode(body)
88
+ ? translateHttpError(response.status, body, requestId)
89
+ : new AbloAuthenticationError(`apiKey exchange rejected (${response.status})`, { code: 'exchange_failed', httpStatus: response.status });
67
90
  }
68
91
  const raw = (await response.json());
69
92
  if (!isCapabilityExchangeResponse(raw)) {
@@ -130,11 +153,16 @@ export async function resolveIdentity(options) {
130
153
  catch {
131
154
  // ignore non-JSON auth errors
132
155
  }
133
- const errBody = body;
134
- throw new AbloAuthenticationError(`identity resolve rejected (${response.status}): ${errBody?.reason ?? response.statusText}`, {
135
- code: errBody?.error ?? 'identity_resolve_failed',
136
- httpStatus: response.status,
137
- });
156
+ // Canonical envelope translation (see `exchangeApiKey` above). This is what
157
+ // surfaces the sync-server's precise auth diagnosis e.g.
158
+ // `jwt_issuer_untrusted` with its full message — to the SDK consumer,
159
+ // instead of collapsing every 401 to `identity_resolve_failed` with an
160
+ // empty reason because the old parser looked for `error`/`reason` keys the
161
+ // server doesn't emit.
162
+ const requestId = response.headers.get('x-request-id') ?? undefined;
163
+ throw hasWireCode(body)
164
+ ? translateHttpError(response.status, body, requestId)
165
+ : new AbloAuthenticationError(`identity resolve rejected (${response.status})`, { code: 'identity_resolve_failed', httpStatus: response.status });
138
166
  }
139
167
  return (await response.json());
140
168
  }