@abloatai/ablo 0.4.0 → 0.5.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 +15 -0
- package/README.md +89 -70
- package/dist/BaseSyncedStore.d.ts +3 -2
- package/dist/client/Ablo.d.ts +45 -13
- package/dist/client/createModelProxy.d.ts +44 -36
- package/dist/client/createModelProxy.js +26 -22
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/errors.d.ts +8 -6
- package/dist/errors.js +8 -6
- package/dist/index.d.ts +14 -0
- package/dist/index.js +32 -13
- package/dist/policy/types.d.ts +10 -0
- package/dist/principal.d.ts +2 -2
- package/dist/principal.js +2 -2
- package/dist/query/client.d.ts +7 -6
- package/dist/react/AbloProvider.d.ts +44 -1
- package/dist/react/AbloProvider.js +3 -1
- package/dist/sync/SyncWebSocket.d.ts +6 -5
- package/dist/types/streams.d.ts +4 -3
- package/docs/api.md +8 -8
- package/docs/examples/ai-sdk-tool.md +3 -3
- package/docs/interaction-model.md +3 -3
- package/docs/quickstart.md +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9154c1b: Rename intent handle methods to a clearer claim vocabulary; add `AbloProvider` `bootstrapMode`.
|
|
8
|
+
|
|
9
|
+
BREAKING — on the model intent handle (`ablo.<model>.intent(id)`):
|
|
10
|
+
`acquire`→`claim`, `acquireOrAwait`→`claimOrWait`, `settled`→`whenFree`,
|
|
11
|
+
`release`→`finish`, `revoke`→`cancel`. The lower-level `IntentHandle` /
|
|
12
|
+
`IntentLeaseHandle` (`ablo.intents.*`) are unchanged.
|
|
13
|
+
|
|
14
|
+
Also: `AbloProvider` gains a `bootstrapMode` prop (`'full' | 'none'`) to skip the
|
|
15
|
+
baseline pull on read-light pages; `StaleContextConflict` gains an optional
|
|
16
|
+
`conflictingFields`; README + JSDoc clarity pass and a new HTTP API section.
|
|
17
|
+
|
|
3
18
|
## 0.4.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# Ablo
|
|
2
2
|
|
|
3
|
-
Ablo Sync is a
|
|
3
|
+
Ablo Sync is a typed sync engine for shared app state — the kind that humans,
|
|
4
|
+
server code, and AI agents all edit at once.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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
9
|
|
|
9
10
|
```txt
|
|
10
|
-
schema -> ablo.<model>.create/load/
|
|
11
|
+
schema -> ablo.<model>.create/load/update(...)
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
## Install
|
|
@@ -27,12 +28,13 @@ server runtimes only.
|
|
|
27
28
|
export ABLO_API_KEY=sk_test_...
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
In the browser, connect through the React provider (`<AbloProvider>`), which
|
|
32
|
+
authenticates with the signed-in user's session — never the raw API key. Do not
|
|
33
|
+
ship `ABLO_API_KEY` in a browser bundle.
|
|
32
34
|
|
|
33
35
|
## Quick Start
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
```ts
|
|
36
38
|
import Ablo from '@abloatai/ablo';
|
|
37
39
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
38
40
|
|
|
@@ -64,16 +66,17 @@ const updated = await ablo.weatherReports.update(created.id, {
|
|
|
64
66
|
console.log({ id: updated.id, status: updated.status });
|
|
65
67
|
|
|
66
68
|
await ablo.dispose();
|
|
67
|
-
```
|
|
69
|
+
```
|
|
68
70
|
|
|
69
71
|
Expected output:
|
|
70
72
|
|
|
71
73
|
```txt
|
|
72
74
|
{ id: '...', status: 'ready' }
|
|
73
|
-
|
|
75
|
+
```
|
|
74
76
|
|
|
75
|
-
Pass `schema`
|
|
76
|
-
|
|
77
|
+
Pass `schema` to get typed models like `ablo.weatherReports.update(...)`. Omit it
|
|
78
|
+
only for the lower-level client used by custom agents and MCP routes that can't
|
|
79
|
+
import your app's schema.
|
|
77
80
|
|
|
78
81
|
Run the package example from this directory:
|
|
79
82
|
|
|
@@ -87,26 +90,29 @@ future agents, read [Integration Guide](./docs/integration-guide.md).
|
|
|
87
90
|
|
|
88
91
|
## AI Activity on Existing State
|
|
89
92
|
|
|
90
|
-
When AI or background work will
|
|
91
|
-
write
|
|
92
|
-
that sits beside `create`/`update`/`retrieve`. It
|
|
93
|
+
When AI or background work will spend real time on an existing row — not just a
|
|
94
|
+
one-shot write — coordinate through `ablo.<model>.intent(id)`, the coordination
|
|
95
|
+
accessor that sits beside `create`/`update`/`retrieve`. It takes the row's `id` (the same
|
|
96
|
+
id you pass to `retrieve(id)` / `update(id, …)`) and returns a handle
|
|
93
97
|
synchronously, so you can see who's already working on a row before you start.
|
|
94
98
|
|
|
95
99
|
```ts
|
|
96
|
-
|
|
100
|
+
// `report_stockholm` is this weather report's id — set at create time, or the
|
|
101
|
+
// `created.id` returned above.
|
|
102
|
+
const report = ablo.weatherReports.intent('report_stockholm');
|
|
97
103
|
|
|
98
104
|
// Read side: is someone already on it? Wait for them to finish.
|
|
99
105
|
if (report.current) {
|
|
100
106
|
report.current.heldBy; // 'agent:forecaster'
|
|
101
|
-
await report.
|
|
107
|
+
await report.whenFree();
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
// Write side:
|
|
105
|
-
await report.
|
|
110
|
+
// Write side: claim so other participants yield while we work.
|
|
111
|
+
await report.claim({ action: 'checking_weather', field: 'forecast', ttl: '2m' });
|
|
106
112
|
|
|
107
113
|
// Your existing weather tool or agent call. While this runs, other clients see
|
|
108
|
-
// that
|
|
109
|
-
const row = ablo.weatherReports.retrieve('
|
|
114
|
+
// that report_stockholm is being checked.
|
|
115
|
+
const row = ablo.weatherReports.retrieve('report_stockholm');
|
|
110
116
|
const weather = await weatherAgent.getWeather(row.location);
|
|
111
117
|
|
|
112
118
|
await report.update({
|
|
@@ -117,72 +123,80 @@ await report.update({
|
|
|
117
123
|
|
|
118
124
|
Ablo does not fetch the weather. It keeps the activity visible while the work
|
|
119
125
|
runs, rejects `report.update(...)` with `AbloStaleContextError` if the row
|
|
120
|
-
changed under you, and
|
|
126
|
+
changed under you, and finishes the claim automatically once the write lands.
|
|
121
127
|
|
|
122
128
|
## Multiplayer
|
|
123
129
|
|
|
124
130
|
There is no separate multiplayer mode. When human UI, server actions, and agent
|
|
125
|
-
workers
|
|
126
|
-
the
|
|
131
|
+
workers share the same schema and write through `ablo.<model>`, they all see
|
|
132
|
+
each other's changes in real time — that's the default, not a feature you turn on.
|
|
127
133
|
|
|
128
134
|
- `ablo.<model>.create/update/delete` fan out confirmed deltas to subscribers.
|
|
129
135
|
- `useAblo(...)` gives React clients the live row plus active intents.
|
|
130
136
|
- `ablo.<model>.intent(id)` lets humans and agents see and coordinate active work before a write lands.
|
|
131
|
-
- `ablo.intents` remains available for custom lower-level coordination across resources.
|
|
132
137
|
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
Always write through Ablo — the SDK (`ablo.<model>.create/update/delete`) or the
|
|
139
|
+
HTTP API (`POST /v1/commits`). If you write straight to your own database
|
|
140
|
+
instead, those changes won't reach connected clients. Use Ablo's endpoints and
|
|
141
|
+
the fan-out is automatic.
|
|
135
142
|
|
|
136
143
|
Under the hood, capabilities, tasks, leases, intents, commits, and receipts are
|
|
137
144
|
real protocol primitives. They exist so agent work is scoped, coordinated,
|
|
138
|
-
attributable, and cleaned up if a runtime disappears
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
##
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
145
|
+
attributable, and cleaned up if a runtime disappears — but you don't touch them
|
|
146
|
+
in a first integration. The default path above is enough.
|
|
147
|
+
|
|
148
|
+
## HTTP API
|
|
149
|
+
|
|
150
|
+
The SDK is a typed wrapper over a signed HTTP API, the same way `stripe-node`
|
|
151
|
+
wraps Stripe's REST API. Everything you do through `ablo.<model>` is reachable
|
|
152
|
+
over HTTP, so backends in other languages and server-to-server callers work
|
|
153
|
+
without the SDK.
|
|
154
|
+
|
|
155
|
+
Writes — create, update, and delete — all go through one endpoint as a batch of
|
|
156
|
+
operations:
|
|
157
|
+
|
|
158
|
+
```http
|
|
159
|
+
POST /v1/commits
|
|
160
|
+
Authorization: Bearer sk_live_...
|
|
161
|
+
Idempotency-Key: <your-unique-id>
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"operations": [
|
|
165
|
+
{
|
|
166
|
+
"type": "UPDATE",
|
|
167
|
+
"model": "weatherReports",
|
|
168
|
+
"id": "report_stockholm",
|
|
169
|
+
"input": { "status": "ready" },
|
|
170
|
+
"readAt": 1042,
|
|
171
|
+
"onStale": "reject"
|
|
172
|
+
}
|
|
173
|
+
]
|
|
167
174
|
}
|
|
168
|
-
|
|
169
|
-
await ablo.intents.waitFor({ resource: 'weatherReports', id: 'weather_stockholm' });
|
|
170
175
|
```
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
Each operation is one `CREATE` / `UPDATE` / `DELETE` (plus `ARCHIVE` /
|
|
178
|
+
`UNARCHIVE`). Reads fetch a single row with `GET /v1/resources/{model}/{id}`.
|
|
179
|
+
The same API key, scope, idempotency, and stale-write rules apply as in the SDK
|
|
180
|
+
— the SDK just removes the boilerplate.
|
|
173
181
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
## Load vs Retrieve
|
|
183
|
+
|
|
184
|
+
Most reads are `retrieve` — it's the everyday path, especially inside `useAblo(...)`:
|
|
185
|
+
|
|
186
|
+
- `ablo.weatherReports.retrieve(id)` is sync. It returns the already-loaded row from
|
|
187
|
+
the local pool (or `undefined` if it isn't loaded yet). In React,
|
|
188
|
+
`useAblo((ablo) => ablo.weatherReports.retrieve(id))` keeps that read live.
|
|
189
|
+
- `ablo.weatherReports.load({ where })` is async. Reach for it when you need to
|
|
190
|
+
guarantee a row is hydrated before you read it — initial fetch, SSR, scripts, or
|
|
191
|
+
any non-reactive runtime.
|
|
178
192
|
|
|
179
193
|
## Persistence
|
|
180
194
|
|
|
181
|
-
Ablo
|
|
182
|
-
state
|
|
183
|
-
|
|
195
|
+
Ablo keeps local state in memory by default. That keeps the SDK focused on
|
|
196
|
+
coordinating shared state, rather than silently turning every browser app into
|
|
197
|
+
an offline database it didn't ask for.
|
|
184
198
|
|
|
185
|
-
Opt into
|
|
199
|
+
Opt into a durable browser cache and offline write queue when you want it:
|
|
186
200
|
|
|
187
201
|
```ts
|
|
188
202
|
const ablo = Ablo({
|
|
@@ -200,11 +214,16 @@ Every schema model has a backing store. By default, Ablo stores rows for the
|
|
|
200
214
|
models you declare, so `ablo.weatherReports.create(...)` and `ablo.weatherReports.update(...)`
|
|
201
215
|
write to Ablo-managed state.
|
|
202
216
|
|
|
203
|
-
If your existing database
|
|
204
|
-
|
|
205
|
-
|
|
217
|
+
If your existing database stays the source of truth, connect it as a Data
|
|
218
|
+
Source: Ablo sends signed commit requests to an endpoint you host, and your app
|
|
219
|
+
writes its own database. Ablo never sees your database credentials — only the
|
|
220
|
+
API key:
|
|
206
221
|
|
|
207
222
|
```bash
|
|
223
|
+
# stays in your app — Ablo never receives this
|
|
224
|
+
DATABASE_URL=postgres://...
|
|
225
|
+
|
|
226
|
+
# the only Ablo credential your app needs
|
|
208
227
|
ABLO_API_KEY=sk_live_...
|
|
209
228
|
```
|
|
210
229
|
|
|
@@ -123,8 +123,9 @@ export interface UserContext {
|
|
|
123
123
|
* store routes this to SyncWebSocket so the WS URL carries
|
|
124
124
|
* `kind=agent` and the server applies capability-token auth. */
|
|
125
125
|
kind?: 'user' | 'agent' | 'system';
|
|
126
|
-
/**
|
|
127
|
-
* `?authorization=Bearer <token>` on the
|
|
126
|
+
/** Restricted (`rk_`) API key for `kind: 'agent'` — the agent's
|
|
127
|
+
* bearer credential. Sent as `?authorization=Bearer <token>` on the
|
|
128
|
+
* WS upgrade. (Field name predates the Biscuit→opaque-key migration.) */
|
|
128
129
|
capabilityToken?: string;
|
|
129
130
|
/** Server-authoritative sync groups, supplied by auth/capability
|
|
130
131
|
* exchange. The SDK does not invent org/user/default groups; app
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -55,14 +55,46 @@ export interface Turn {
|
|
|
55
55
|
export type { ApiKeySetter } from './auth.js';
|
|
56
56
|
import type { ApiKeySetter } from './auth.js';
|
|
57
57
|
import { type AbloPersistence } from './persistence.js';
|
|
58
|
+
/**
|
|
59
|
+
* Options for `Ablo({...})`.
|
|
60
|
+
*
|
|
61
|
+
* The only required field is `schema`. The default path is one line:
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* `apiKey` itself defaults to `process.env.ABLO_API_KEY`, so in most
|
|
68
|
+
* server setups `Ablo({ schema })` is enough. Every other field is
|
|
69
|
+
* optional tuning (timeouts, retries, custom fetch, persistence) —
|
|
70
|
+
* if you're not sure whether you need one, you don't. Reach for them
|
|
71
|
+
* the way you'd reach for the equivalent option on the Stripe / OpenAI
|
|
72
|
+
* / Anthropic clients: rarely, and deliberately.
|
|
73
|
+
*
|
|
74
|
+
* @see https://docs.ablo.finance — full option reference
|
|
75
|
+
*/
|
|
58
76
|
export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
77
|
+
/**
|
|
78
|
+
* TypeScript schema defined with `defineSchema()`. Required — it's what
|
|
79
|
+
* makes `ablo.tasks.update(...)` typed. This is the one field you must
|
|
80
|
+
* pass; start here.
|
|
81
|
+
*/
|
|
82
|
+
schema: Schema<S>;
|
|
59
83
|
/**
|
|
60
84
|
* API key used for authentication.
|
|
61
85
|
*
|
|
62
86
|
* Accepts a static string (`sk_live_...`) or an async function that
|
|
63
|
-
* resolves to one. Defaults to `process.env['ABLO_API_KEY']
|
|
87
|
+
* resolves to one. Defaults to `process.env['ABLO_API_KEY']`, so you
|
|
88
|
+
* usually don't pass this explicitly server-side.
|
|
64
89
|
*/
|
|
65
90
|
apiKey?: string | ApiKeySetter | null | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
93
|
+
* queueing and a reload-surviving browser cache.
|
|
94
|
+
*
|
|
95
|
+
* @default 'volatile'
|
|
96
|
+
*/
|
|
97
|
+
persistence?: AbloPersistence;
|
|
66
98
|
/**
|
|
67
99
|
* Bearer auth token. Hosted-cloud consumers pass `apiKey`; self-hosted
|
|
68
100
|
* deployments may pass a bearer token minted by their own auth layer.
|
|
@@ -72,9 +104,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
72
104
|
* Override the Ablo API base URL. Defaults to hosted production.
|
|
73
105
|
*/
|
|
74
106
|
baseURL?: string | null | undefined;
|
|
75
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Maximum time (ms) to wait for a single request before timing out.
|
|
109
|
+
* Timed-out requests are retried, so worst-case wait can exceed this.
|
|
110
|
+
*
|
|
111
|
+
* @default 600_000
|
|
112
|
+
*/
|
|
76
113
|
timeout?: number | undefined;
|
|
77
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Maximum retries on transient failure (network error / 5xx / 429).
|
|
116
|
+
* Honors `Retry-After`.
|
|
117
|
+
*
|
|
118
|
+
* @default 2
|
|
119
|
+
*/
|
|
78
120
|
maxRetries?: number | undefined;
|
|
79
121
|
/** Custom fetch implementation for tests, proxies, or non-standard runtimes. */
|
|
80
122
|
fetch?: typeof fetch | undefined;
|
|
@@ -88,16 +130,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
88
130
|
* key or a controlled server proxy.
|
|
89
131
|
*/
|
|
90
132
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
91
|
-
/**
|
|
92
|
-
* TypeScript schema defined with `defineSchema()`. This enables typed
|
|
93
|
-
* resources such as `ablo.tasks.update(...)`.
|
|
94
|
-
*/
|
|
95
|
-
schema: Schema<S>;
|
|
96
|
-
/**
|
|
97
|
-
* Local persistence mode. Defaults to `volatile`. Pass `indexeddb` only
|
|
98
|
-
* when you want offline queueing and a reload-surviving browser cache.
|
|
99
|
-
*/
|
|
100
|
-
persistence?: AbloPersistence;
|
|
101
133
|
}
|
|
102
134
|
export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
103
135
|
/**
|
|
@@ -104,7 +104,7 @@ export interface ModelCollaboration<T> {
|
|
|
104
104
|
}, options?: IntentWaitOptions): Promise<void>;
|
|
105
105
|
/**
|
|
106
106
|
* The local participant's id. Used to distinguish "I already hold this"
|
|
107
|
-
* from "someone else holds it" in `
|
|
107
|
+
* from "someone else holds it" in `claimOrWait`.
|
|
108
108
|
*/
|
|
109
109
|
readonly selfParticipantId: string;
|
|
110
110
|
}
|
|
@@ -120,13 +120,25 @@ export interface ModelIntentAcquireOptions {
|
|
|
120
120
|
wait?: MutationOptions['wait'];
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
123
|
-
* Per-entity coordination handle
|
|
124
|
-
* `
|
|
125
|
-
*
|
|
126
|
-
* `.current` without awaiting; `acquire()` is the async lock.
|
|
123
|
+
* Per-entity coordination handle, returned synchronously by
|
|
124
|
+
* `ablo.<model>.intent(id)`. It lets humans and agents claim a row before
|
|
125
|
+
* they work on it, so two of them don't edit the same thing at once.
|
|
127
126
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
127
|
+
* The lifecycle reads like a sentence:
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* const report = ablo.weatherReports.intent('weather_stockholm');
|
|
131
|
+
*
|
|
132
|
+
* if (report.current) await report.whenFree(); // someone's on it — wait
|
|
133
|
+
* await report.claim({ action: 'checking_weather' }); // it's mine now
|
|
134
|
+
* await report.update({ status: 'ready' }); // write, then auto-finish
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* `current` is the live `Intent` (or `null` if free). `claim()` announces
|
|
138
|
+
* you're working so others yield. `whenFree()` waits for whoever holds it.
|
|
139
|
+
* `claimOrWait()` does both — claim, or wait your turn then claim — which
|
|
140
|
+
* is what you bind to an agent's write tool so it never reasons about
|
|
141
|
+
* coordination itself. `finish()`/`cancel()` give the claim back.
|
|
130
142
|
*/
|
|
131
143
|
export interface ModelIntentHandle<T> extends AsyncDisposable {
|
|
132
144
|
/** The target entity id this handle coordinates. */
|
|
@@ -140,39 +152,35 @@ export interface ModelIntentHandle<T> extends AsyncDisposable {
|
|
|
140
152
|
/** Convenience: `current?.status ?? 'idle'`. */
|
|
141
153
|
readonly status: IntentStatus | 'idle';
|
|
142
154
|
/**
|
|
143
|
-
*
|
|
144
|
-
* claim is announced. Throws if
|
|
145
|
-
* (
|
|
155
|
+
* Claim this row so other participants yield while you work. Resolves
|
|
156
|
+
* once the claim is announced. Throws if someone else already holds it
|
|
157
|
+
* — call `whenFree()` first, or use `claimOrWait()` to do both.
|
|
146
158
|
*/
|
|
147
|
-
|
|
159
|
+
claim(options?: ModelIntentAcquireOptions): Promise<void>;
|
|
148
160
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* A claim held by *this* participant is treated as already-mine and
|
|
155
|
-
* acquired without waiting. Bind this to an agent's write-tool boundary
|
|
156
|
-
* so agents never reason about coordination themselves.
|
|
161
|
+
* Claim the row, or — if someone else holds it — wait for them to
|
|
162
|
+
* finish, re-read the (now-changed) row, then claim. The caller never
|
|
163
|
+
* branches on who holds it; it just gets the row safely. A claim you
|
|
164
|
+
* already hold is treated as yours and taken without waiting. Bind this
|
|
165
|
+
* to an agent's write-tool boundary.
|
|
157
166
|
*/
|
|
158
|
-
|
|
167
|
+
claimOrWait(options?: ModelIntentAcquireOptions): Promise<void>;
|
|
159
168
|
/**
|
|
160
|
-
* Optimistic update guarded by the
|
|
169
|
+
* Optimistic update guarded by the claim this handle holds. Rejects
|
|
161
170
|
* with `AbloStaleContextError` if the row changed under you, then
|
|
162
|
-
* auto-
|
|
171
|
+
* auto-finishes. Call `claim()` first.
|
|
163
172
|
*/
|
|
164
173
|
update(data: Partial<T>, options?: MutationOptions): Promise<T>;
|
|
165
|
-
/**
|
|
166
|
-
|
|
174
|
+
/** Finish: give back a claim you hold once the work is committed. */
|
|
175
|
+
finish(): Promise<void>;
|
|
167
176
|
/**
|
|
168
|
-
* Wait until the
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* this if you go through `acquire().update()`).
|
|
177
|
+
* Wait until the row is free, then resolve. On resolution your cached
|
|
178
|
+
* copy may be stale — re-read before writing (the stale-context guard
|
|
179
|
+
* enforces this if you go through `claim()` + `update()`).
|
|
172
180
|
*/
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
|
|
181
|
+
whenFree(options?: IntentWaitOptions): Promise<void>;
|
|
182
|
+
/** Cancel: drop a claim you hold without committing any work. */
|
|
183
|
+
cancel(): void;
|
|
176
184
|
}
|
|
177
185
|
export interface ModelOperations<T, CreateInput> {
|
|
178
186
|
/**
|
|
@@ -206,14 +214,14 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
206
214
|
* Coordination accessor for one entity — the same `ablo.<model>(id)`
|
|
207
215
|
* shape as `create`/`update`/`retrieve`, but on the coordination plane
|
|
208
216
|
* (ephemeral, TTL'd, never persisted). Returns a handle synchronously:
|
|
209
|
-
* read `.current` to see who's editing, `
|
|
210
|
-
* to write under the
|
|
217
|
+
* read `.current` to see who's editing, `claim()` to take it, `update()`
|
|
218
|
+
* to write under the claim, `whenFree()` to wait for a holder to finish.
|
|
211
219
|
*
|
|
212
220
|
* ```ts
|
|
213
221
|
* const lock = ablo.slide.intent(slideId);
|
|
214
|
-
* if (lock.current) await lock.
|
|
215
|
-
* await lock.
|
|
216
|
-
* await lock.update({ title: 'New' });
|
|
222
|
+
* if (lock.current) await lock.whenFree(); // someone's editing — wait
|
|
223
|
+
* await lock.claim({ action: 'editing' });
|
|
224
|
+
* await lock.update({ title: 'New' }); // auto-finishes
|
|
217
225
|
* ```
|
|
218
226
|
*/
|
|
219
227
|
intent(id: string): ModelIntentHandle<T>;
|
|
@@ -117,30 +117,34 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
117
117
|
let snapshot = null;
|
|
118
118
|
let released = false;
|
|
119
119
|
let acquireWait;
|
|
120
|
-
|
|
120
|
+
// Public `cancel()` — drop the claim without committing. Calls the
|
|
121
|
+
// lower-level lease handle's `revoke()` (a different API; leave it).
|
|
122
|
+
const cancel = () => {
|
|
121
123
|
if (released)
|
|
122
124
|
return;
|
|
123
125
|
released = true;
|
|
124
126
|
if (snapshot)
|
|
125
|
-
snapshot.signal.removeEventListener('abort',
|
|
127
|
+
snapshot.signal.removeEventListener('abort', cancel);
|
|
126
128
|
acquired?.revoke();
|
|
127
129
|
};
|
|
128
|
-
|
|
130
|
+
// Public `finish()` — give the claim back after committing. Calls the
|
|
131
|
+
// lower-level lease handle's `release()` (a different API; leave it).
|
|
132
|
+
const finish = async () => {
|
|
129
133
|
if (released)
|
|
130
134
|
return;
|
|
131
135
|
released = true;
|
|
132
136
|
if (snapshot)
|
|
133
|
-
snapshot.signal.removeEventListener('abort',
|
|
137
|
+
snapshot.signal.removeEventListener('abort', cancel);
|
|
134
138
|
await acquired?.release();
|
|
135
139
|
};
|
|
136
|
-
const
|
|
140
|
+
const whenFree = async (options) => {
|
|
137
141
|
if (!collaboration)
|
|
138
142
|
return;
|
|
139
143
|
await collaboration.waitFor(target, options);
|
|
140
144
|
};
|
|
141
|
-
const
|
|
145
|
+
const claim = async (options) => {
|
|
142
146
|
if (!collaboration) {
|
|
143
|
-
throw new AbloValidationError(`Model "${schemaKey}" cannot
|
|
147
|
+
throw new AbloValidationError(`Model "${schemaKey}" cannot claim an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
|
|
144
148
|
}
|
|
145
149
|
if (acquired)
|
|
146
150
|
return;
|
|
@@ -155,7 +159,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
155
159
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
156
160
|
}
|
|
157
161
|
const snap = collaboration.createSnapshot(schemaKey, id);
|
|
158
|
-
snap.signal.addEventListener('abort',
|
|
162
|
+
snap.signal.addEventListener('abort', cancel, { once: true });
|
|
159
163
|
snapshot = snap;
|
|
160
164
|
released = false;
|
|
161
165
|
acquired = await collaboration.createIntent({
|
|
@@ -168,18 +172,18 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
168
172
|
ttl: options?.ttl,
|
|
169
173
|
});
|
|
170
174
|
};
|
|
171
|
-
const
|
|
175
|
+
const claimOrWait = async (options) => {
|
|
172
176
|
if (!collaboration) {
|
|
173
|
-
throw new AbloValidationError(`Model "${schemaKey}" cannot
|
|
177
|
+
throw new AbloValidationError(`Model "${schemaKey}" cannot claim an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
|
|
174
178
|
}
|
|
175
179
|
const held = collaboration.observe(target);
|
|
176
|
-
// A foreign holder: wait for
|
|
177
|
-
// Our own claim (or a free target)
|
|
180
|
+
// A foreign holder: wait for them to finish, then re-read before
|
|
181
|
+
// claiming. Our own claim (or a free target) goes straight to claim.
|
|
178
182
|
if (held && held.heldBy !== collaboration.selfParticipantId) {
|
|
179
|
-
await
|
|
183
|
+
await whenFree();
|
|
180
184
|
await load({ where: { id } });
|
|
181
185
|
}
|
|
182
|
-
await
|
|
186
|
+
await claim(options);
|
|
183
187
|
};
|
|
184
188
|
const handle = {
|
|
185
189
|
id,
|
|
@@ -189,11 +193,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
189
193
|
get status() {
|
|
190
194
|
return collaboration?.observe(target)?.status ?? 'idle';
|
|
191
195
|
},
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
claim,
|
|
197
|
+
claimOrWait,
|
|
194
198
|
async update(data, updateOptions) {
|
|
195
199
|
if (!acquired || !snapshot) {
|
|
196
|
-
throw new AbloValidationError(`Call
|
|
200
|
+
throw new AbloValidationError(`Call claim() before update() on ablo.${schemaKey}.intent(${id}).`, { code: 'intent_not_acquired' });
|
|
197
201
|
}
|
|
198
202
|
if (snapshot.signal.aborted) {
|
|
199
203
|
throw new AbloStaleContextError(`Intent context is stale for ${schemaKey}/${id}. Re-read the row and retry.`, {
|
|
@@ -212,13 +216,13 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
212
216
|
});
|
|
213
217
|
}
|
|
214
218
|
finally {
|
|
215
|
-
await
|
|
219
|
+
await finish();
|
|
216
220
|
}
|
|
217
221
|
},
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
[Symbol.asyncDispose]:
|
|
222
|
+
finish,
|
|
223
|
+
whenFree,
|
|
224
|
+
cancel,
|
|
225
|
+
[Symbol.asyncDispose]: finish,
|
|
222
226
|
};
|
|
223
227
|
return handle;
|
|
224
228
|
},
|
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
package/dist/errors.d.ts
CHANGED
|
@@ -186,16 +186,18 @@ export interface CommitReceipt {
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
189
|
+
* A scoped credential was denied — either the key is unknown / revoked /
|
|
190
|
+
* expired (`capability_invalid`), or the connection's resolved scope
|
|
191
|
+
* doesn't cover the attempted action (`capability_scope_denied`). With
|
|
192
|
+
* opaque restricted (`rk_`) API keys this is a server-side check against
|
|
193
|
+
* the key's `syncGroups` / `operations`, not a signed-caveat verification.
|
|
192
194
|
*
|
|
193
195
|
* Extends `AbloPermissionError` so existing `instanceof CapabilityError`
|
|
194
196
|
* checks keep working AND broader `instanceof AbloPermissionError`
|
|
195
|
-
* matches for consumers who don't care about the
|
|
197
|
+
* matches for consumers who don't care about the scope specifics.
|
|
196
198
|
*
|
|
197
|
-
* `requiredCapability` (when present) describes
|
|
198
|
-
*
|
|
199
|
+
* `requiredCapability` (when present) describes the scope a key must
|
|
200
|
+
* carry for the request to succeed on retry.
|
|
199
201
|
*/
|
|
200
202
|
export declare class CapabilityError extends AbloPermissionError {
|
|
201
203
|
readonly requiredCapability?: RequiredCapability;
|
package/dist/errors.js
CHANGED
|
@@ -125,16 +125,18 @@ export class AbloBusyError extends AbloError {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
128
|
+
* A scoped credential was denied — either the key is unknown / revoked /
|
|
129
|
+
* expired (`capability_invalid`), or the connection's resolved scope
|
|
130
|
+
* doesn't cover the attempted action (`capability_scope_denied`). With
|
|
131
|
+
* opaque restricted (`rk_`) API keys this is a server-side check against
|
|
132
|
+
* the key's `syncGroups` / `operations`, not a signed-caveat verification.
|
|
131
133
|
*
|
|
132
134
|
* Extends `AbloPermissionError` so existing `instanceof CapabilityError`
|
|
133
135
|
* checks keep working AND broader `instanceof AbloPermissionError`
|
|
134
|
-
* matches for consumers who don't care about the
|
|
136
|
+
* matches for consumers who don't care about the scope specifics.
|
|
135
137
|
*
|
|
136
|
-
* `requiredCapability` (when present) describes
|
|
137
|
-
*
|
|
138
|
+
* `requiredCapability` (when present) describes the scope a key must
|
|
139
|
+
* carry for the request to succeed on retry.
|
|
138
140
|
*/
|
|
139
141
|
export class CapabilityError extends AbloPermissionError {
|
|
140
142
|
requiredCapability;
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,20 @@
|
|
|
26
26
|
* Consumer code should converge on `ablo.<model>.load(...)`, which routes
|
|
27
27
|
* through the engine's `HydrationCoordinator` and dedupes single-flight
|
|
28
28
|
* hydrations.
|
|
29
|
+
*
|
|
30
|
+
* ── What to import (read this first) ────────────────────────────────
|
|
31
|
+
* Default path — this is all most apps and agents ever need:
|
|
32
|
+
* • `Ablo` (default export) + `AbloOptions` + the `Model*Options` bags
|
|
33
|
+
* • the `Ablo*Error` classes, to discriminate failures in catch blocks
|
|
34
|
+
* That's it. If you're reaching past those, you're in advanced territory.
|
|
35
|
+
*
|
|
36
|
+
* Advanced — opt-in, most apps never import these (each is tagged
|
|
37
|
+
* "Advanced —" at its export below, with the one situation it's for):
|
|
38
|
+
* • `dataSource` / `abloSource` — only if your own DB stays canonical
|
|
39
|
+
* • `session` / `agent` — only for delegated agent principals
|
|
40
|
+
* • `defaultPolicy` — only to customize conflict resolution
|
|
41
|
+
* • `defineMutators` / `createTransaction` — only for custom mutators
|
|
42
|
+
* If you don't recognize one, you don't need it — the default path covers you.
|
|
29
43
|
*/
|
|
30
44
|
export { Ablo } from './client/Ablo.js';
|
|
31
45
|
export type { AbloOptions, ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelIntentHandle, ModelIntentAcquireOptions, ModelOperations, } from './client/Ablo.js';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,20 @@
|
|
|
26
26
|
* Consumer code should converge on `ablo.<model>.load(...)`, which routes
|
|
27
27
|
* through the engine's `HydrationCoordinator` and dedupes single-flight
|
|
28
28
|
* hydrations.
|
|
29
|
+
*
|
|
30
|
+
* ── What to import (read this first) ────────────────────────────────
|
|
31
|
+
* Default path — this is all most apps and agents ever need:
|
|
32
|
+
* • `Ablo` (default export) + `AbloOptions` + the `Model*Options` bags
|
|
33
|
+
* • the `Ablo*Error` classes, to discriminate failures in catch blocks
|
|
34
|
+
* That's it. If you're reaching past those, you're in advanced territory.
|
|
35
|
+
*
|
|
36
|
+
* Advanced — opt-in, most apps never import these (each is tagged
|
|
37
|
+
* "Advanced —" at its export below, with the one situation it's for):
|
|
38
|
+
* • `dataSource` / `abloSource` — only if your own DB stays canonical
|
|
39
|
+
* • `session` / `agent` — only for delegated agent principals
|
|
40
|
+
* • `defaultPolicy` — only to customize conflict resolution
|
|
41
|
+
* • `defineMutators` / `createTransaction` — only for custom mutators
|
|
42
|
+
* If you don't recognize one, you don't need it — the default path covers you.
|
|
29
43
|
*/
|
|
30
44
|
// ── Consumer API ──────────────────────────────────────────────────────────
|
|
31
45
|
// These are the only symbols external consumers should need from this path.
|
|
@@ -39,24 +53,27 @@ export { Ablo } from './client/Ablo.js';
|
|
|
39
53
|
// `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
|
|
40
54
|
// `Ablo.Participant.JoinOptions`, etc. Same dot-access shape as
|
|
41
55
|
// `Ablo.Peer`, `Ablo.Claim`, `Ablo.Turn`. No flat re-exports.
|
|
42
|
-
//
|
|
43
|
-
// (`Ablo({ kind: 'agent', as: session({...}) })`).
|
|
44
|
-
//
|
|
56
|
+
// Advanced — most apps never import this. Principal constructors for
|
|
57
|
+
// delegated agent paths (`Ablo({ kind: 'agent', as: session({...}) })`).
|
|
58
|
+
// The default `Ablo({ schema, apiKey })` resolves identity from the key;
|
|
59
|
+
// reach for these only when minting a delegated agent principal.
|
|
45
60
|
export { session, agent } from './principal.js';
|
|
46
61
|
import { Ablo } from './client/Ablo.js';
|
|
47
62
|
export default Ablo;
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
63
|
+
// Advanced — most apps never import this. Customer-owned storage adapter
|
|
64
|
+
// for Data Source mode: only when Ablo Cloud coordinates state while
|
|
65
|
+
// canonical rows stay in YOUR database. The default is Ablo-managed
|
|
66
|
+
// storage — if you haven't deliberately chosen to keep your own DB
|
|
67
|
+
// canonical, skip this entirely. Type counterparts live under
|
|
68
|
+
// `Ablo.Source.*` (`Ablo.Source.Operation`, `Ablo.Source.Commit.Params`).
|
|
52
69
|
export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
53
70
|
// Schema DSL is intentionally published from `@abloatai/ablo/schema`.
|
|
54
71
|
// Keeping it out of the root import preserves one clean runtime surface:
|
|
55
72
|
// `import Ablo from '@abloatai/ablo'`.
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
73
|
+
// Advanced — most apps never import this. Conflict policy: `defaultPolicy`
|
|
74
|
+
// (reject-on-stale) is already applied server-side, so you only import it
|
|
75
|
+
// to COMPOSE a custom policy. Leave it alone and stale writes are rejected
|
|
76
|
+
// safely by default. Type counterparts live under `Ablo.Conflict.*`.
|
|
60
77
|
export { defaultPolicy } from './policy/index.js';
|
|
61
78
|
// Typed error hierarchy — Stripe-style. One import gets every class
|
|
62
79
|
// consumers need to discriminate failures (`e instanceof AbloX` or
|
|
@@ -66,8 +83,10 @@ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionErr
|
|
|
66
83
|
// Intents/UserMeta once in a `.d.ts` via `declare global { interface AbloSync
|
|
67
84
|
// { ... } }`. Resolver types live under the `Ablo` namespace —
|
|
68
85
|
// `Ablo.ResolveSchema`, `Ablo.ResolvePresence`, etc. — pure type-level.
|
|
69
|
-
//
|
|
70
|
-
//
|
|
86
|
+
// Advanced — most apps never import this. Custom (Zero-style) mutators:
|
|
87
|
+
// `ablo.<model>.create/update/delete` already covers normal writes. Reach
|
|
88
|
+
// for `defineMutators` only when you need a named, multi-step mutation with
|
|
89
|
+
// custom undo. Type counterparts live under the `Ablo` namespace:
|
|
71
90
|
// Ablo.Mutator.Fn, Ablo.Transaction
|
|
72
91
|
// Ablo.Mutator.UndoEntry, Ablo.Mutator.InverseOp
|
|
73
92
|
// Ablo.Query, Ablo.QueryBatch, Ablo.QueryBatchResult
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface StaleContextConflict extends ConflictBase {
|
|
|
29
29
|
readonly readAt: number;
|
|
30
30
|
/** Most recent delta id on the target. */
|
|
31
31
|
readonly observedSyncId: number;
|
|
32
|
+
/**
|
|
33
|
+
* The fields whose concurrent change triggered this conflict — the
|
|
34
|
+
* intersection of the committer's written fields and the columns a
|
|
35
|
+
* newer delta touched. Empty array means the conflicting delta was a
|
|
36
|
+
* whole-entity change (CREATE/DELETE, or a pre-`changed_fields`
|
|
37
|
+
* legacy delta), which conflicts with any write. Lets a policy decide
|
|
38
|
+
* at field granularity, e.g. allow when the only collision is on a
|
|
39
|
+
* cosmetic field. See `docs/internal/per-field-conflict-detection.md`.
|
|
40
|
+
*/
|
|
41
|
+
readonly conflictingFields?: readonly string[];
|
|
32
42
|
}
|
|
33
43
|
export interface IntentHeldConflict extends ConflictBase {
|
|
34
44
|
readonly kind: 'intent_held';
|
package/dist/principal.d.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* participant layer handles attenuation.
|
|
19
19
|
*
|
|
20
20
|
* These are pure — no I/O, no hidden state. If the shape ever grows a
|
|
21
|
-
* required field (say, a
|
|
22
|
-
* place to flag migrations.
|
|
21
|
+
* required field (say, a scope hint for the restricted key), the helper
|
|
22
|
+
* is the one place to flag migrations.
|
|
23
23
|
*/
|
|
24
24
|
import type { AgentRef, SessionRef } from './types/streams.js';
|
|
25
25
|
/**
|
package/dist/principal.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* participant layer handles attenuation.
|
|
19
19
|
*
|
|
20
20
|
* These are pure — no I/O, no hidden state. If the shape ever grows a
|
|
21
|
-
* required field (say, a
|
|
22
|
-
* place to flag migrations.
|
|
21
|
+
* required field (say, a scope hint for the restricted key), the helper
|
|
22
|
+
* is the one place to flag migrations.
|
|
23
23
|
*/
|
|
24
24
|
/**
|
|
25
25
|
* Build a `SessionRef` from the identifiers your auth system already
|
package/dist/query/client.d.ts
CHANGED
|
@@ -22,13 +22,14 @@ export interface PostQueryOptions {
|
|
|
22
22
|
/** Timeout in ms for the fetch request. Default: 30000. */
|
|
23
23
|
fetchTimeout?: number;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* Required for Node consumers
|
|
27
|
-
* have no session cookie to
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* Bearer credential — a restricted (`rk_`) API key — attached as
|
|
26
|
+
* `Authorization: Bearer <token>`. Required for Node consumers
|
|
27
|
+
* (agent-worker, server-side tests) that have no session cookie to
|
|
28
|
+
* ride. Browser consumers can omit this and fall back to
|
|
29
|
+
* `credentials: 'include'`. When both are present the server prefers
|
|
30
|
+
* the Bearer header (see `apiKeyProvider` in
|
|
30
31
|
* `apps/sync-server/src/auth`), so passing the token in browser code
|
|
31
|
-
* is harmless.
|
|
32
|
+
* is harmless. (Field name predates the Biscuit→opaque-key migration.)
|
|
32
33
|
*/
|
|
33
34
|
capabilityToken?: string;
|
|
34
35
|
}
|
|
@@ -29,8 +29,29 @@ import { type SyncStoreContract } from './context.js';
|
|
|
29
29
|
* only when `userId` / account scope / `url` change. React
|
|
30
30
|
* Strict Mode double-mount does not leak a second WebSocket.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Props for `<AbloProvider>`.
|
|
34
|
+
*
|
|
35
|
+
* The default path is one prop:
|
|
36
|
+
*
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <AbloProvider schema={schema}>
|
|
39
|
+
* <App />
|
|
40
|
+
* </AbloProvider>
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* That's it for most apps — the provider resolves identity, account
|
|
44
|
+
* scope, and realtime permissions from auth. `userId`/`apiKey`/`url`
|
|
45
|
+
* are situational; the `bootstrapMode`, `persistence`, and `fallback`
|
|
46
|
+
* props are opt-in tuning; and the block tagged "Optional DI (advanced)"
|
|
47
|
+
* below is escape-hatch wiring for tests and platform builders — if you
|
|
48
|
+
* don't recognize a prop there, you don't need it.
|
|
49
|
+
*/
|
|
32
50
|
export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
33
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Schema from `defineSchema()`. Determines the typed hook surface.
|
|
53
|
+
* This is the only prop most apps pass — start here.
|
|
54
|
+
*/
|
|
34
55
|
schema: Schema<R>;
|
|
35
56
|
/**
|
|
36
57
|
* WebSocket URL of the sync server (`wss://...` or `ws://...`).
|
|
@@ -103,6 +124,28 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
103
124
|
* for the full semantics.
|
|
104
125
|
*/
|
|
105
126
|
persistence?: AbloPersistence;
|
|
127
|
+
/**
|
|
128
|
+
* How aggressively this provider pulls baseline state at startup.
|
|
129
|
+
*
|
|
130
|
+
* - `'full'` (default): pull every delta in the configured sync
|
|
131
|
+
* groups before the engine reports ready — a local replica of the
|
|
132
|
+
* org's tenant plane. Right for collaborative editors and any page
|
|
133
|
+
* that reads a lot of shared state.
|
|
134
|
+
* - `'none'`: open the connection and process live deltas only — no
|
|
135
|
+
* baseline fetch. Reads round-trip via `ablo.<model>.retrieve(...)`
|
|
136
|
+
* and subscriptions populate the pool lazily. Right for read-light
|
|
137
|
+
* pages (a mostly-static dashboard, a settings screen) that don't
|
|
138
|
+
* want to download the whole org to render.
|
|
139
|
+
*
|
|
140
|
+
* Note: `'none'` still opens the realtime connection — it skips the
|
|
141
|
+
* baseline pull, not the socket. A fully connection-free mode for
|
|
142
|
+
* pages that do zero multiplayer is a separate follow-up (the socket
|
|
143
|
+
* open lives inside `engine.ready()`, so deferring it needs
|
|
144
|
+
* engine-level lazy-connect support, not just a provider prop).
|
|
145
|
+
*
|
|
146
|
+
* Mirrors `AbloOptions.bootstrapMode`. Changing it rotates the engine.
|
|
147
|
+
*/
|
|
148
|
+
bootstrapMode?: 'full' | 'none';
|
|
106
149
|
/**
|
|
107
150
|
* Rendered in place of `children` during the *first* bootstrap pass —
|
|
108
151
|
* while the engine is actively transitioning from `initial` →
|
|
@@ -32,7 +32,7 @@ function createErrorEmitter() {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
export function AbloProvider(props) {
|
|
35
|
-
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, bootstrapBaseUrl, maxPoolSize, persistence, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
35
|
+
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
36
36
|
// Account scope is no longer accepted from props. The engine learns
|
|
37
37
|
// it from auth (capability token) at bootstrap and we read it back
|
|
38
38
|
// out of `_store.orgId` once `engine.ready()` resolves.
|
|
@@ -62,6 +62,7 @@ export function AbloProvider(props) {
|
|
|
62
62
|
const engineKey = JSON.stringify({
|
|
63
63
|
userId: userId ?? null,
|
|
64
64
|
url,
|
|
65
|
+
bootstrapMode: bootstrapMode ?? null,
|
|
65
66
|
});
|
|
66
67
|
const [engineState, setEngineState] = useState({ key: engineKey, engine: null });
|
|
67
68
|
// Keep a ref to the current engine key so the rotation effect can
|
|
@@ -89,6 +90,7 @@ export function AbloProvider(props) {
|
|
|
89
90
|
bootstrapBaseUrl,
|
|
90
91
|
maxPoolSize,
|
|
91
92
|
persistence,
|
|
93
|
+
...(bootstrapMode ? { bootstrapMode } : {}),
|
|
92
94
|
autoStart: false,
|
|
93
95
|
};
|
|
94
96
|
const engine = Ablo(engineOptions);
|
|
@@ -113,11 +113,12 @@ export interface SyncWebSocketOptions {
|
|
|
113
113
|
*/
|
|
114
114
|
kind?: 'user' | 'agent' | 'system';
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
* `?authorization=Bearer+<token>` on the WS upgrade —
|
|
118
|
-
* form so it works in both Node (no header support) and
|
|
119
|
-
* The server's auth path accepts either form. Required for
|
|
120
|
-
* `kind: 'agent'`; ignored for `kind: 'user'`.
|
|
116
|
+
* The agent's bearer credential — a restricted (`rk_`) API key. When
|
|
117
|
+
* set, sent as `?authorization=Bearer+<token>` on the WS upgrade —
|
|
118
|
+
* query-param form so it works in both Node (no header support) and
|
|
119
|
+
* browsers. The server's auth path accepts either form. Required for
|
|
120
|
+
* `kind: 'agent'`; ignored for `kind: 'user'`. (Field name predates
|
|
121
|
+
* the Biscuit→opaque-key migration.)
|
|
121
122
|
*/
|
|
122
123
|
capabilityToken?: string;
|
|
123
124
|
}
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -57,7 +57,8 @@ export interface AgentDelta {
|
|
|
57
57
|
/**
|
|
58
58
|
* A reference to whoever's authority bounds a joined participant.
|
|
59
59
|
* The spawned participant can never see or do more than this principal.
|
|
60
|
-
* Enforced
|
|
60
|
+
* Enforced server-side: the spawned agent gets its own restricted
|
|
61
|
+
* (`rk_`) key whose scope is a subset of the parent's.
|
|
61
62
|
*
|
|
62
63
|
* • `SessionRef` — human is joining an agent (chat assistant flow)
|
|
63
64
|
* • `AgentRef` — agent spawning a sub-agent (attenuation chain)
|
|
@@ -507,8 +508,8 @@ export interface IntentWaitOptions {
|
|
|
507
508
|
* the fields *are* the awareness ("agent X is editing this until Y").
|
|
508
509
|
*
|
|
509
510
|
* Deliberately omits a Stripe-style `next_action`: a contender's only
|
|
510
|
-
* response is "wait
|
|
511
|
-
* that uniformly at the tool boundary (`IntentHandle.
|
|
511
|
+
* response is "wait until free, then re-read", and the runtime performs
|
|
512
|
+
* that uniformly at the tool boundary (`IntentHandle.whenFree()` + the
|
|
512
513
|
* stale-context guard that forces a re-read). Encoding a constant
|
|
513
514
|
* instruction the engine always takes would be the kind of ceremony this
|
|
514
515
|
* object exists to remove.
|
package/docs/api.md
CHANGED
|
@@ -146,20 +146,20 @@ so you can inspect who holds a target without awaiting.
|
|
|
146
146
|
### Lifecycle
|
|
147
147
|
|
|
148
148
|
```
|
|
149
|
-
|
|
149
|
+
claim() update() lands
|
|
150
150
|
(free) ───────────▶ active ───────────────────────▶ committed
|
|
151
151
|
│
|
|
152
152
|
┌───────────┴───────────┐
|
|
153
153
|
▼ ▼
|
|
154
154
|
canceled expired
|
|
155
|
-
(
|
|
155
|
+
(finish w/o write) (TTL; holder died)
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
A target is free when `ablo.<model>.intent(id).current` is `null`. Terminal
|
|
159
159
|
states drop out of the live stream — a present intent is, by definition,
|
|
160
160
|
`active`.
|
|
161
161
|
|
|
162
|
-
### Reading and
|
|
162
|
+
### Reading and claiming
|
|
163
163
|
|
|
164
164
|
```ts
|
|
165
165
|
const task = ablo.tasks.intent('task_123');
|
|
@@ -168,21 +168,21 @@ const task = ablo.tasks.intent('task_123');
|
|
|
168
168
|
if (task.current) {
|
|
169
169
|
task.current.heldBy; // 'agent:task-writer'
|
|
170
170
|
task.current.action; // 'editing'
|
|
171
|
-
await task.
|
|
171
|
+
await task.whenFree(); // wait until they finish, then continue
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
// Write side — claim, write, auto-release in one flow.
|
|
175
|
-
await task.
|
|
175
|
+
await task.claim({ action: 'editing', field: 'status', ttl: '2m' });
|
|
176
176
|
const updated = await task.update({ status: 'done' });
|
|
177
177
|
updated.status; // 'done'
|
|
178
178
|
```
|
|
179
179
|
|
|
180
180
|
`task.update(...)` carries the same stale-check as a plain update: it rejects
|
|
181
|
-
with `AbloStaleContextError` if the row advanced past your
|
|
181
|
+
with `AbloStaleContextError` if the row advanced past your claim point, so you
|
|
182
182
|
re-read before retrying. The intent releases automatically when `update`
|
|
183
|
-
resolves; call `task.
|
|
183
|
+
resolves; call `task.finish()` if the work ends without a write.
|
|
184
184
|
|
|
185
|
-
`task.
|
|
185
|
+
`task.whenFree({ timeout })` waits until the target is free. Pass `timeout` only
|
|
186
186
|
when your product needs an upper bound.
|
|
187
187
|
|
|
188
188
|
### Cross-resource coordination
|
|
@@ -35,9 +35,9 @@ const updateTask = tool({
|
|
|
35
35
|
if (!task) return { ok: false, reason: 'not_found' };
|
|
36
36
|
|
|
37
37
|
const claim = ablo.tasks.intent(taskId);
|
|
38
|
-
if (claim.current) await claim.
|
|
38
|
+
if (claim.current) await claim.whenFree({ timeout: 30_000 });
|
|
39
39
|
|
|
40
|
-
await claim.
|
|
40
|
+
await claim.claim({ action: 'editing', field: 'status', ttl: '2m' });
|
|
41
41
|
try {
|
|
42
42
|
// update commits with the held claim and auto-releases on success
|
|
43
43
|
const updated = await claim.update({
|
|
@@ -47,7 +47,7 @@ const updateTask = tool({
|
|
|
47
47
|
|
|
48
48
|
return { ok: true, task: updated };
|
|
49
49
|
} catch (err) {
|
|
50
|
-
await claim.
|
|
50
|
+
await claim.finish();
|
|
51
51
|
throw err;
|
|
52
52
|
}
|
|
53
53
|
},
|
|
@@ -115,12 +115,12 @@ model accessor:
|
|
|
115
115
|
|
|
116
116
|
```ts
|
|
117
117
|
const task = ablo.tasks.intent('task_123');
|
|
118
|
-
if (task.current) await task.
|
|
119
|
-
await task.
|
|
118
|
+
if (task.current) await task.whenFree(); // someone's working — wait
|
|
119
|
+
await task.claim({ action: 'editing' }); // claim so others yield
|
|
120
120
|
await task.update({ status: 'done' }); // commits + releases
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`task.current` is the live `Intent` (or `null`); `task.
|
|
123
|
+
`task.current` is the live `Intent` (or `null`); `task.whenFree()` resolves when
|
|
124
124
|
the holder finishes. The same signal is visible to every schema client through
|
|
125
125
|
`ablo.intents.list(...)` and the live intent stream, so callers decide whether
|
|
126
126
|
to yield, wait, or fail fast. See [API → Intent](./api.md#intent) for the object
|
package/docs/quickstart.md
CHANGED
|
@@ -82,17 +82,17 @@ ABLO_API_KEY=sk_test_... npx tsx quickstart.ts
|
|
|
82
82
|
|
|
83
83
|
When AI or background work will touch an existing row for more than a quick
|
|
84
84
|
write, coordinate through `ablo.<model>.intent(id)`. It returns a handle
|
|
85
|
-
synchronously — read `.current` to see who's working on the row, `
|
|
85
|
+
synchronously — read `.current` to see who's working on the row, `claim()` to
|
|
86
86
|
claim it, `update()` to write under the claim (which auto-releases).
|
|
87
87
|
|
|
88
88
|
```ts
|
|
89
89
|
const report = ablo.weatherReports.intent('weather_stockholm');
|
|
90
90
|
|
|
91
91
|
// If another participant holds it, wait for them to finish.
|
|
92
|
-
if (report.current) await report.
|
|
92
|
+
if (report.current) await report.whenFree();
|
|
93
93
|
|
|
94
94
|
// Claim it so other participants yield while we work.
|
|
95
|
-
await report.
|
|
95
|
+
await report.claim({ action: 'checking_weather', field: 'forecast', ttl: '2m' });
|
|
96
96
|
|
|
97
97
|
// Your existing weather tool or agent call. While this runs, other clients see
|
|
98
98
|
// that weather_stockholm is being checked.
|