@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.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- 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
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@abloatai/ablo)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](#)
|
|
6
|
+
[](#keys--runtime)
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
120
|
+
ablo.weatherReports.get('report_stockholm');
|
|
91
121
|
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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.
|
|
144
|
-
ablo.weatherReports.queue('report_stockholm');
|
|
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 });
|
|
179
|
+
}, { wait: false });
|
|
149
180
|
|
|
150
181
|
await ablo.weatherReports.claim(id, async (report) => {
|
|
151
182
|
/* do the held work */
|
|
152
|
-
}, { maxQueueDepth: 2 });
|
|
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` / `
|
|
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
|
|
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
|
-
|
|
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)` / `
|
|
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.
|
|
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` / `
|
|
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
|
-
|
|
665
|
+
state?: ModelScope;
|
|
666
666
|
orderBy?: keyof Model;
|
|
667
667
|
order?: 'asc' | 'desc';
|
|
668
668
|
limit?: number;
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -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
|
|
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
|
|
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?.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
58
|
+
isSessionErrorResponse(status, body) {
|
|
59
|
+
return SyncSessionError.isSessionErrorResponse(status, body);
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/auth/index.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
}
|