@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 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 schema-first state control layer for AI agents and collaborative apps.
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
- Use it when human UI, server actions, and AI agents need to edit the same typed
6
- state with realtime fanout, stale-write protection, active-work coordination,
7
- and audit.
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/edit/update(...)
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
- Browser apps should use a scoped capability/session route through the React
31
- provider. Do not ship `ABLO_API_KEY` in a browser bundle.
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
- ````ts
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
- ```c
69
+ ```
68
70
 
69
71
  Expected output:
70
72
 
71
73
  ```txt
72
74
  { id: '...', status: 'ready' }
73
- ````
75
+ ```
74
76
 
75
- Pass `schema` for typed model resources. Omit it only for advanced server-side
76
- resource clients such as custom agents and MCP routes.
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 touch an existing row for more than a quick
91
- write, coordinate through `ablo.<model>.intent(id)` the coordination accessor
92
- that sits beside `create`/`update`/`retrieve`. It returns a handle
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
- const report = ablo.weatherReports.intent('weather_stockholm');
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.settled();
107
+ await report.whenFree();
102
108
  }
103
109
 
104
- // Write side: acquire so other participants yield while we work.
105
- await report.acquire({ action: 'checking_weather', field: 'forecast', ttl: '2m' });
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 weather_stockholm is being checked.
109
- const row = ablo.weatherReports.retrieve('weather_stockholm');
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 releases the intent automatically once the write lands.
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 use the same schema client and write through `ablo.<model>`, they are on
126
- the same shared resource stream.
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
- If a team writes directly to its own database outside Ablo, that write bypasses
134
- the multiplayer stream until the app reports it through Data Source events.
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. They should not be
139
- ceremony in the first integration.
140
-
141
- ## Load vs Retrieve
142
-
143
- For schema clients, `load` and `retrieve` are intentionally different:
144
-
145
- - `ablo.weatherReports.load({ where })` is async. It hydrates matching rows from the
146
- local store and server, then returns them.
147
- - `ablo.weatherReports.retrieve(id)` is sync. It reads one already-loaded row from the
148
- local pool and returns `undefined` if it is not loaded yet.
149
- - `ablo.resource('weatherReports').retrieve(id)` is the lower-level resource API. It
150
- returns `{ data, stamp, intents }` for custom runtimes that need raw read
151
- stamps and receipts.
152
-
153
- ## Activity and Busy State
154
-
155
- Model edit activity is the live coordination signal. If another participant is
156
- reading, editing, or updating an entity, Ablo can return that state, wait for it
157
- to clear, or fail fast with `AbloBusyError`.
158
-
159
- ```ts
160
- const busy = ablo.intents.list({
161
- resource: 'weatherReports',
162
- id: 'weather_stockholm',
163
- });
164
-
165
- if (busy.length > 0) {
166
- console.log(`${busy[0].actor} is ${busy[0].action}`);
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
- Policy names are literal:
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
- - `ifBusy: 'return'` returns immediately with `intents`.
175
- - `ifBusy: 'wait'` waits on the live intent stream. Plain HTTP callers must
176
- provide their own explicit polling policy instead of getting hidden SDK polling.
177
- - `ifBusy: 'fail'` throws `AbloBusyError` with the active intents attached.
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 defaults to volatile local persistence. That keeps the SDK focused on shared
182
- state coordination instead of silently adding an IndexedDB storage product to
183
- every browser app.
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 browser durable local cache and offline queueing when you need it:
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 remains the source of truth, connect it with a signed
204
- Data Source endpoint. Your app keeps the database credentials; Ablo sends signed
205
- commit requests to your route.
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
- /** Biscuit capability token for `kind: 'agent'`. Sent as
127
- * `?authorization=Bearer <token>` on the WS upgrade. */
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
@@ -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
- /** Per-request timeout in milliseconds. */
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
- /** Number of retries for transient failures. */
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 `acquireOrAwait`.
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 the same accessor shape as
124
- * `create`/`update`/`retrieve`, but on the coordination plane. Returned
125
- * synchronously by `ablo.<model>.intent(id)` so a contender can read
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
- * Read side (any participant): `current`, `status`, `settled()`.
129
- * Write side (the holder): `acquire()`, `update()`, `release()`.
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
- * Acquire the lease so other participants yield. Resolves once the
144
- * claim is announced. Throws if another participant already holds it
145
- * (cooperative mutex enforced at the server boundary).
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
- acquire(options?: ModelIntentAcquireOptions): Promise<void>;
159
+ claim(options?: ModelIntentAcquireOptions): Promise<void>;
148
160
  /**
149
- * Acquire the target, or — if another participant holds it — wait for
150
- * them to finish, re-read the (now-changed) row, then acquire. This is
151
- * the runtime's serialize-on-contention primitive: the caller never
152
- * branches on who holds the target, it just gets the target safely.
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
- acquireOrAwait(options?: ModelIntentAcquireOptions): Promise<void>;
167
+ claimOrWait(options?: ModelIntentAcquireOptions): Promise<void>;
159
168
  /**
160
- * Optimistic update guarded by the lease this handle holds. Rejects
169
+ * Optimistic update guarded by the claim this handle holds. Rejects
161
170
  * with `AbloStaleContextError` if the row changed under you, then
162
- * auto-releases. Call `acquire()` first.
171
+ * auto-finishes. Call `claim()` first.
163
172
  */
164
173
  update(data: Partial<T>, options?: MutationOptions): Promise<T>;
165
- /** Release a lease you hold (commit / abandon). */
166
- release(): Promise<void>;
174
+ /** Finish: give back a claim you hold once the work is committed. */
175
+ finish(): Promise<void>;
167
176
  /**
168
- * Wait until the target is free, then resolve. The contender's
169
- * "let me wait until it completes." On resolution your cached copy may
170
- * be stale re-read before writing (the stale-context guard enforces
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
- settled(options?: IntentWaitOptions): Promise<void>;
174
- /** Drop a held lease without committing. */
175
- revoke(): void;
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, `acquire()` to lock, `update()`
210
- * to write under the lock, `settled()` to wait for a holder to finish.
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.settled(); // someone's editing — wait
215
- * await lock.acquire({ action: 'editing' });
216
- * await lock.update({ title: 'New' }); // auto-releases
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
- const revoke = () => {
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', revoke);
127
+ snapshot.signal.removeEventListener('abort', cancel);
126
128
  acquired?.revoke();
127
129
  };
128
- const release = async () => {
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', revoke);
137
+ snapshot.signal.removeEventListener('abort', cancel);
134
138
  await acquired?.release();
135
139
  };
136
- const settled = async (options) => {
140
+ const whenFree = async (options) => {
137
141
  if (!collaboration)
138
142
  return;
139
143
  await collaboration.waitFor(target, options);
140
144
  };
141
- const acquire = async (options) => {
145
+ const claim = async (options) => {
142
146
  if (!collaboration) {
143
- throw new AbloValidationError(`Model "${schemaKey}" cannot acquire an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
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', revoke, { once: true });
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 acquireOrAwait = async (options) => {
175
+ const claimOrWait = async (options) => {
172
176
  if (!collaboration) {
173
- throw new AbloValidationError(`Model "${schemaKey}" cannot acquire an intent without collaboration wiring.`, { code: 'model_intent_not_configured' });
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 release, then re-read before claiming.
177
- // Our own claim (or a free target) skips straight to acquire.
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 settled();
183
+ await whenFree();
180
184
  await load({ where: { id } });
181
185
  }
182
- await acquire(options);
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
- acquire,
193
- acquireOrAwait,
196
+ claim,
197
+ claimOrWait,
194
198
  async update(data, updateOptions) {
195
199
  if (!acquired || !snapshot) {
196
- throw new AbloValidationError(`Call acquire() before update() on ablo.${schemaKey}.intent(${id}).`, { code: 'intent_not_acquired' });
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 release();
219
+ await finish();
216
220
  }
217
221
  },
218
- release,
219
- settled,
220
- revoke,
221
- [Symbol.asyncDispose]: release,
222
+ finish,
223
+ whenFree,
224
+ cancel,
225
+ [Symbol.asyncDispose]: finish,
222
226
  };
223
227
  return handle;
224
228
  },
@@ -20,7 +20,7 @@
20
20
  * ```
21
21
  *
22
22
  * For headless agents (workers, bots), pass `kind: 'agent'` plus a
23
- * Biscuit `capabilityToken`:
23
+ * restricted (`rk_`) API key as `capabilityToken`:
24
24
  *
25
25
  * ```ts
26
26
  * const bot = Ablo({
@@ -20,7 +20,7 @@
20
20
  * ```
21
21
  *
22
22
  * For headless agents (workers, bots), pass `kind: 'agent'` plus a
23
- * Biscuit `capabilityToken`:
23
+ * restricted (`rk_`) API key as `capabilityToken`:
24
24
  *
25
25
  * ```ts
26
26
  * const bot = Ablo({
package/dist/errors.d.ts CHANGED
@@ -186,16 +186,18 @@ export interface CommitReceipt {
186
186
  };
187
187
  }
188
188
  /**
189
- * Biscuit capability token failed verification — either it's
190
- * malformed / unknown / revoked (`capability_invalid`), or its
191
- * caveats deny the attempted action (`capability_scope_denied`).
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 Biscuit specifics.
197
+ * matches for consumers who don't care about the scope specifics.
196
198
  *
197
- * `requiredCapability` (when present) describes what an attenuated
198
- * capability must satisfy for the request to succeed on retry.
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
- * Biscuit capability token failed verification — either it's
129
- * malformed / unknown / revoked (`capability_invalid`), or its
130
- * caveats deny the attempted action (`capability_scope_denied`).
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 Biscuit specifics.
136
+ * matches for consumers who don't care about the scope specifics.
135
137
  *
136
- * `requiredCapability` (when present) describes what an attenuated
137
- * capability must satisfy for the request to succeed on retry.
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
- // Principal constructors explicit factories for delegation paths
43
- // (`Ablo({ kind: 'agent', as: session({...}) })`). Function exports
44
- // because they're constructors, not types.
56
+ // Advancedmost 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
- // Customer-owned storage adapter. Used only when Ablo Cloud coordinates
49
- // state while canonical rows remain in the customer's database. Runtime
50
- // helpers ship flat; type counterparts live under `Ablo.Source.*`
51
- // (`Ablo.Source.Operation`, `Ablo.Source.Commit.Params`, etc.).
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
- // Conflict policy `defaultPolicy` (the rejecting default) is a value
57
- // callers reference if they want to compose. The type counterparts
58
- // (`Conflict`, `ConflictPolicy`, etc.) live under `Ablo.Conflict`,
59
- // `Ablo.Conflict.Policy` on the namespace.
73
+ // Advancedmost 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
- // Custom mutators runtime entry point only.
70
- // Type counterparts moved to the `Ablo` namespace:
86
+ // Advancedmost 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
@@ -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';
@@ -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 Biscuit scope hint), the helper is the one
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 Biscuit scope hint), the helper is the one
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
@@ -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
- * Capability token (Biscuit) to attach as `Authorization: Bearer <token>`.
26
- * Required for Node consumers (agent-worker, server-side tests) that
27
- * have no session cookie to ride. Browser consumers can omit this and
28
- * fall back to `credentials: 'include'`. When both are present the
29
- * server prefers the Bearer header (see `agentTokenProvider` in
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
- /** Schema from `defineSchema()`. Determines the typed hook surface. */
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
- * Biscuit capability bearer token. When set, sent as
117
- * `?authorization=Bearer+<token>` on the WS upgrade — query-param
118
- * form so it works in both Node (no header support) and browsers.
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
  }
@@ -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 cryptographically via Biscuit attenuation.
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 for release, then re-read", and the runtime performs
511
- * that uniformly at the tool boundary (`IntentHandle.settled()` + the
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
- acquire() update() lands
149
+ claim() update() lands
150
150
  (free) ───────────▶ active ───────────────────────▶ committed
151
151
 
152
152
  ┌───────────┴───────────┐
153
153
  ▼ ▼
154
154
  canceled expired
155
- (release w/o write) (TTL; holder died)
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 acquiring
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.settled(); // wait until they finish, then continue
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.acquire({ action: 'editing', field: 'status', ttl: '2m' });
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 acquire point, so you
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.release()` if the work ends without a write.
183
+ resolves; call `task.finish()` if the work ends without a write.
184
184
 
185
- `task.settled({ timeout })` waits until the target is free. Pass `timeout` only
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.settled({ timeout: 30_000 });
38
+ if (claim.current) await claim.whenFree({ timeout: 30_000 });
39
39
 
40
- await claim.acquire({ action: 'editing', field: 'status', ttl: '2m' });
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.release();
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.settled(); // someone's working — wait
119
- await task.acquire({ action: 'editing' }); // claim so others yield
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.settled()` resolves when
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
@@ -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, `acquire()` to
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.settled();
92
+ if (report.current) await report.whenFree();
93
93
 
94
94
  // Claim it so other participants yield while we work.
95
- await report.acquire({ action: 'checking_weather', field: 'forecast', ttl: '2m' });
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",