@abloatai/ablo 0.11.1 → 0.12.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 +49 -0
- package/README.md +10 -2
- package/dist/Model.d.ts +39 -0
- package/dist/Model.js +68 -0
- package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
- package/dist/ai-sdk/claim-broadcast.js +2 -2
- package/dist/ai-sdk/wrap.d.ts +5 -4
- package/dist/ai-sdk/wrap.js +3 -3
- package/dist/auth/credentialPolicy.d.ts +145 -0
- package/dist/auth/credentialPolicy.js +130 -0
- package/dist/cli.cjs +42 -7
- package/dist/client/Ablo.d.ts +64 -91
- package/dist/client/Ablo.js +43 -103
- package/dist/client/ApiClient.d.ts +10 -1
- package/dist/client/ApiClient.js +45 -22
- package/dist/client/auth.d.ts +12 -5
- package/dist/client/auth.js +2 -1
- package/dist/client/createModelProxy.d.ts +64 -17
- package/dist/client/createModelProxy.js +18 -12
- package/dist/client/httpClient.d.ts +17 -3
- package/dist/client/httpClient.js +1 -0
- package/dist/client/identity.js +134 -122
- package/dist/client/index.d.ts +1 -1
- package/dist/client/sessionMint.d.ts +15 -0
- package/dist/client/sessionMint.js +86 -0
- package/dist/coordination/schema.d.ts +1 -1
- package/dist/coordination/schema.js +3 -1
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +6 -3
- package/dist/errors.js +9 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -7
- package/dist/mutators/RecordingTransaction.js +14 -42
- package/dist/react/AbloProvider.d.ts +12 -13
- package/dist/react/AbloProvider.js +10 -10
- package/dist/react/context.d.ts +10 -45
- package/dist/react/context.js +12 -17
- package/dist/react/index.d.ts +8 -10
- package/dist/react/index.js +8 -11
- package/dist/react/useMutators.js +3 -2
- package/dist/react/useSyncStatus.d.ts +1 -1
- package/dist/react/useUndoScope.js +3 -2
- package/dist/realtime/index.d.ts +1 -1
- package/dist/schema/generate.js +1 -2
- package/dist/schema/model.d.ts +10 -3
- package/dist/schema/schema.d.ts +13 -2
- package/dist/schema/schema.js +26 -0
- package/dist/surface.d.ts +29 -0
- package/dist/surface.js +60 -0
- package/dist/sync/ConnectionManager.d.ts +16 -5
- package/dist/sync/ConnectionManager.js +42 -7
- package/dist/sync/createClaimStream.js +5 -4
- package/dist/sync/participants.js +1 -1
- package/dist/transactions/TransactionQueue.d.ts +0 -11
- package/dist/transactions/TransactionQueue.js +12 -56
- package/dist/types/global.d.ts +3 -0
- package/dist/types/streams.d.ts +17 -29
- package/dist/utils/mobx-setup.js +1 -0
- package/docs/api-keys.md +49 -0
- package/docs/api.md +3 -2
- package/docs/client-behavior.md +1 -0
- package/docs/coordination.md +75 -21
- package/docs/examples/existing-python-backend.md +9 -5
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/guarantees.md +4 -3
- package/docs/identity.md +89 -82
- package/docs/integration-guide.md +19 -10
- package/docs/migration.md +11 -3
- package/docs/quickstart.md +6 -2
- package/docs/react.md +3 -3
- package/docs/schema-contract.md +23 -5
- package/llms-full.txt +18 -16
- package/llms.txt +6 -6
- package/package.json +1 -1
- package/dist/api/index.d.ts +0 -10
- package/dist/api/index.js +0 -9
- package/dist/principal.d.ts +0 -44
- package/dist/principal.js +0 -49
- package/dist/react/SyncGroupProvider.d.ts +0 -19
- package/dist/react/SyncGroupProvider.js +0 -44
- package/dist/react/useClaim.d.ts +0 -29
- package/dist/react/useClaim.js +0 -42
- package/dist/react/usePresence.d.ts +0 -32
- package/dist/react/usePresence.js +0 -41
package/dist/types/streams.d.ts
CHANGED
|
@@ -57,28 +57,6 @@ export interface AgentDelta {
|
|
|
57
57
|
causedByTaskId?: string | null;
|
|
58
58
|
createdAt: string;
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
* A reference to whoever's authority bounds a joined participant.
|
|
62
|
-
* The spawned participant can never see or do more than this principal.
|
|
63
|
-
* Enforced server-side: the spawned agent gets its own restricted
|
|
64
|
-
* (`rk_`) key whose scope is a subset of the parent's.
|
|
65
|
-
*
|
|
66
|
-
* • `SessionRef` — human is joining an agent (chat assistant flow)
|
|
67
|
-
* • `AgentRef` — agent spawning a sub-agent (attenuation chain)
|
|
68
|
-
* • omitted — the API key on the Ablo client is the ceiling
|
|
69
|
-
*/
|
|
70
|
-
export type Principal = SessionRef | AgentRef;
|
|
71
|
-
export interface SessionRef {
|
|
72
|
-
readonly kind: 'session';
|
|
73
|
-
readonly id: string;
|
|
74
|
-
readonly userId: string;
|
|
75
|
-
readonly organizationId: string;
|
|
76
|
-
}
|
|
77
|
-
export interface AgentRef {
|
|
78
|
-
readonly kind: 'agent';
|
|
79
|
-
readonly id: string;
|
|
80
|
-
readonly capabilityToken: string;
|
|
81
|
-
}
|
|
82
60
|
/**
|
|
83
61
|
* Flat snapshot view returned from `participant.snapshot(...)`.
|
|
84
62
|
*
|
|
@@ -382,7 +360,7 @@ export interface ClaimStream {
|
|
|
382
360
|
/**
|
|
383
361
|
* Reactive view of the wait queue on one target — the FIFO line of
|
|
384
362
|
* `status: 'queued'` claims behind the current holder, each with its
|
|
385
|
-
* `
|
|
363
|
+
* `reason`, `heldBy`, and `position`. Synced from the server's per-entity
|
|
386
364
|
* `claim_queue` frame; empty when no one's waiting. Pair with
|
|
387
365
|
* `subscribe(...)` for change notifications.
|
|
388
366
|
*/
|
|
@@ -479,10 +457,12 @@ export interface ClaimDeclaration {
|
|
|
479
457
|
/** Human-readable reason — "rewriting title" / "restyling chart". */
|
|
480
458
|
readonly reason: string;
|
|
481
459
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
460
|
+
* Seconds remaining until the server auto-expires this claim. An OUTPUT
|
|
461
|
+
* field carrying a concrete countdown, so it's a plain `number` — distinct
|
|
462
|
+
* from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
|
|
463
|
+
* from `expiresAt - now`.
|
|
484
464
|
*/
|
|
485
|
-
readonly ttlSeconds?:
|
|
465
|
+
readonly ttlSeconds?: number;
|
|
486
466
|
}
|
|
487
467
|
/**
|
|
488
468
|
* Handle returned from `announce(...)` / `analyzing(...)` / etc.
|
|
@@ -529,7 +509,13 @@ export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposabl
|
|
|
529
509
|
readonly range?: TargetRange;
|
|
530
510
|
readonly meta?: Record<string, unknown>;
|
|
531
511
|
};
|
|
532
|
-
|
|
512
|
+
/**
|
|
513
|
+
* The human-readable phase this claim represents — `'editing'`, `'writing'`,
|
|
514
|
+
* `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
|
|
515
|
+
* distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
|
|
516
|
+
* to `'editing'`. Serialized on the wire as `action`.
|
|
517
|
+
*/
|
|
518
|
+
readonly reason: string;
|
|
533
519
|
readonly description?: string;
|
|
534
520
|
/** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
|
|
535
521
|
readonly data?: T;
|
|
@@ -587,8 +573,10 @@ export interface Claim {
|
|
|
587
573
|
readonly status: ClaimStatus;
|
|
588
574
|
/** What is being coordinated. */
|
|
589
575
|
readonly target: EntityRef;
|
|
590
|
-
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`.
|
|
591
|
-
|
|
576
|
+
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
|
|
577
|
+
* field on every claim surface; distinct from the CRUD operation. Serialized
|
|
578
|
+
* on the wire as `action`. */
|
|
579
|
+
readonly reason: string;
|
|
592
580
|
/** Peer-visible explanation of the work being performed. */
|
|
593
581
|
readonly description?: string;
|
|
594
582
|
/** Participant holding it. */
|
package/dist/utils/mobx-setup.js
CHANGED
package/docs/api-keys.md
CHANGED
|
@@ -12,6 +12,55 @@ The key identifies the Ablo account. Application code does not pass an organizat
|
|
|
12
12
|
|
|
13
13
|
"Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key.
|
|
14
14
|
|
|
15
|
+
## Which credential to use
|
|
16
|
+
|
|
17
|
+
There's **one field — `apiKey`** — and what you pass depends on **where the code runs**.
|
|
18
|
+
Pick your row:
|
|
19
|
+
|
|
20
|
+
| Where your code runs | What to pass | Example |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| **Server / worker / CLI** (can hold a secret) | your secret `sk_` — it defaults to `ABLO_API_KEY`, so usually pass **nothing** | `Ablo({ schema })` |
|
|
23
|
+
| **Browser — read-only** | a publishable `pk_` (safe to ship, like a Stripe `pk_`) | `Ablo({ schema, apiKey: process.env.NEXT_PUBLIC_ABLO_PUBLISHABLE_KEY })` |
|
|
24
|
+
| **Browser — writing as the signed-in user** | a function that fetches a short-lived per-user token from your own backend | `Ablo({ schema, apiKey: () => fetch('/api/ablo-session').then((r) => r.text()) })` |
|
|
25
|
+
|
|
26
|
+
That's the whole story: one knob, filled by audience.
|
|
27
|
+
|
|
28
|
+
**Coming from Stripe? It's the same key model, same prefixes:**
|
|
29
|
+
|
|
30
|
+
| Stripe | Ablo | Where it goes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| publishable `pk_` (client-safe) | `pk_` | browser — read-only |
|
|
33
|
+
| secret `sk_` (server, full) | `sk_` | server — full authority |
|
|
34
|
+
| restricted `rk_` (granular) | `rk_` | scoped agent sessions (`sessions.create({ agent, can })`) |
|
|
35
|
+
| ephemeral key (client, customer-scoped) | `ek_` | per-user browser sessions (`sessions.create({ user })`) |
|
|
36
|
+
|
|
37
|
+
Mode lives in the prefix too — `sk_test_` / `sk_live_` — exactly like Stripe. The
|
|
38
|
+
`apiKey` resolver fetching an `ek_` is Ablo's ephemeral-key flow: server mints, client holds.
|
|
39
|
+
|
|
40
|
+
**Why a function for browser writes?** Anything you ship to a browser must be public, and a
|
|
41
|
+
public `pk_` is **read-only** — it can't carry one specific user's write authority. So when
|
|
42
|
+
the browser writes *as the logged-in user*, your backend (which holds the secret `sk_` and
|
|
43
|
+
knows who's signed in) mints a short-lived per-user token with `sessions.create({ user })`,
|
|
44
|
+
and the browser's `apiKey` function fetches it. You don't manage refresh — the SDK calls the
|
|
45
|
+
function once before connecting and then keeps the token fresh (re-mint before expiry, and on
|
|
46
|
+
tab-focus / network-online / device-wake). This is the Stripe ephemeral-key / Supabase
|
|
47
|
+
session model. For a read-only app you don't need any of this — just the `pk_` above.
|
|
48
|
+
|
|
49
|
+
Server-side, because `apiKey` defaults to `process.env.ABLO_API_KEY`, most backend and agent
|
|
50
|
+
code passes nothing. The secret `sk_` (and `databaseUrl`) are **server-only** — never in a
|
|
51
|
+
browser bundle. There is no `getToken`, `authEndpoint`, or `as` option — `apiKey` (a string,
|
|
52
|
+
or a function for the browser-write case) is the single credential knob.
|
|
53
|
+
|
|
54
|
+
### Minting per-user / agent tokens (server-side, with your `sk_`)
|
|
55
|
+
|
|
56
|
+
| Mint | Call | Result |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Human end-user session | `await server.sessions.create({ user: { id } })` | `ek_` (full user authority) |
|
|
59
|
+
| Scoped delegated agent | `await server.sessions.create({ agent: { id }, can: { Task: ['update'] } })` | `rk_` (scoped to `can`) |
|
|
60
|
+
| Connect Ablo to your own Postgres | `Ablo({ schema, apiKey, databaseUrl })` (server-only) | dedicated tenant |
|
|
61
|
+
|
|
62
|
+
The principal kind comes from *which* shape you pass — `{ user }` → `user`, `{ agent, can }` → `agent`.
|
|
63
|
+
|
|
15
64
|
## Server-Side API Keys
|
|
16
65
|
|
|
17
66
|
Use API keys from trusted (server-side) runtimes:
|
package/docs/api.md
CHANGED
|
@@ -167,8 +167,9 @@ side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if s
|
|
|
167
167
|
already holds the row, `claim` waits for them to finish, re-reads the fresh row,
|
|
168
168
|
then hands it to you, so you always proceed from current state. Default reads
|
|
169
169
|
return the row even while someone is mid-edit; if a server read should not
|
|
170
|
-
return a row while it's claimed, pass `ifClaimed: '
|
|
171
|
-
to
|
|
170
|
+
return a row while it's claimed, pass `ifClaimed: 'fail'` to error out instead.
|
|
171
|
+
Reads never block on a claim — to wait for a row to free up, `claim({ id })` it
|
|
172
|
+
(the claim queues fairly behind the holder).
|
|
172
173
|
|
|
173
174
|
```ts
|
|
174
175
|
const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
package/docs/client-behavior.md
CHANGED
|
@@ -32,6 +32,7 @@ Common options:
|
|
|
32
32
|
| `databaseUrl` | Optional, server-only. Registers your Postgres directly (the connection-string path). Pass it explicitly — it is **not** auto-read from the environment. Omit it for a signed Data Source endpoint or the hosted sandbox. The SDK throws if it sees this in a browser. |
|
|
33
33
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
34
34
|
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
35
|
+
| `transport` | `'websocket'` (default) is the live, stateful client — a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (`get`/`getAll`, `onChange`, `watch`) are compile errors rather than runtime gaps. |
|
|
35
36
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
36
37
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
37
38
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
package/docs/coordination.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Coordination Reference
|
|
2
2
|
|
|
3
3
|
Coordinate long-running work on a row so humans and agents don't clobber each
|
|
4
|
-
other. Most writes need none of this — `ablo.<model>.update({ id, data })`
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
|
|
5
|
+
is **last-write-wins** by default. For lost-update detection, take a claim or pass
|
|
6
|
+
`readAt` / `onStale` yourself. Reach for `claim` only when you'll **hold a row
|
|
7
|
+
across a slow gap** (read → LLM call → write).
|
|
7
8
|
|
|
8
9
|
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
9
10
|
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
@@ -36,10 +37,12 @@ make:
|
|
|
36
37
|
**The one decision: do you hold the row across a slow gap (read → LLM call →
|
|
37
38
|
write)?**
|
|
38
39
|
|
|
39
|
-
- **No** (the common case — a single quick `update`):
|
|
40
|
-
is
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
- **No** (the common case — a single quick `update`): a plain `ablo.<model>.update`
|
|
41
|
+
is **last-write-wins** — it carries no `readAt`, so the server skips the stale
|
|
42
|
+
check and the write simply lands. That's fine for most fields. If you need
|
|
43
|
+
lost-update detection on a no-claim write, pass `readAt` + `onStale: 'reject'`
|
|
44
|
+
yourself and it rejects with `AbloStaleContextError` when the row moved under
|
|
45
|
+
you.
|
|
43
46
|
- **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
|
|
44
47
|
excludes other participants for the duration, queues contenders fairly, and —
|
|
45
48
|
see below — your own writes under it stay stale-guarded too.
|
|
@@ -48,12 +51,13 @@ write)?**
|
|
|
48
51
|
non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
|
|
49
52
|
`readAt`. If you do hold it, your own writes are still stale-checked — a row that
|
|
50
53
|
moved between your snapshot and your write still rejects with
|
|
51
|
-
`AbloStaleContextError`. With no claim held
|
|
52
|
-
protection
|
|
53
|
-
Presence (`claim.state`)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
`AbloStaleContextError`. With no claim held and no `readAt`, there is **no**
|
|
55
|
+
stale protection — the plain write is last-write-wins; opt into lost-update
|
|
56
|
+
detection by passing `readAt` + `onStale` yourself. Presence (`claim.state`)
|
|
57
|
+
never decides anything — read it to render, act on the errors. The two checks are
|
|
58
|
+
independent: one rejects writes from people who don't hold the claim, the other
|
|
59
|
+
rejects writes based on a stale snapshot, and the SDK adds the stale-check for you
|
|
60
|
+
when you write under a claim **you took on this client**, so there you don't pass
|
|
57
61
|
anything extra.
|
|
58
62
|
|
|
59
63
|
---
|
|
@@ -92,6 +96,17 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
92
96
|
|
|
93
97
|
## Methods
|
|
94
98
|
|
|
99
|
+
One word — "claim" — names four distinct things; keep them separate as you read:
|
|
100
|
+
|
|
101
|
+
- **the lease (claim handle)** — the *object* returned by `ablo.<model>.claim({ id })`
|
|
102
|
+
(`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
|
|
103
|
+
- **acquiring a claim/lease** — the *verb* `ablo.<model>.claim({ id })`, the call
|
|
104
|
+
that takes the lease.
|
|
105
|
+
- **`claim.state` / `claim.queue`** — the *inspection namespace* hanging off the
|
|
106
|
+
model, for reading who holds the row and who's lined up.
|
|
107
|
+
- **the write's `claim` param** — `update({ id, data, claim })`, where you pass a
|
|
108
|
+
lease the proxy didn't take itself.
|
|
109
|
+
|
|
95
110
|
Each method below follows one fixed shape: **signature · what it does ·
|
|
96
111
|
parameters · returns · example**.
|
|
97
112
|
|
|
@@ -149,15 +164,19 @@ held. Server/model reads can choose a claimed policy:
|
|
|
149
164
|
```ts
|
|
150
165
|
await ablo.weatherReports.retrieve({
|
|
151
166
|
id: 'report_stockholm',
|
|
152
|
-
ifClaimed: '
|
|
153
|
-
claimedTimeout: 30_000,
|
|
167
|
+
ifClaimed: 'fail',
|
|
154
168
|
});
|
|
155
169
|
```
|
|
156
170
|
|
|
157
|
-
- `ifClaimed: 'return'` reads now and includes active work metadata.
|
|
158
|
-
- `ifClaimed: 'wait'` waits for the active claim to clear before reading.
|
|
171
|
+
- `ifClaimed: 'return'` (the default) reads now and includes active work metadata.
|
|
159
172
|
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
160
173
|
|
|
174
|
+
Reads never block on a claim — there is no `ifClaimed: 'wait'`. Waiting for a row
|
|
175
|
+
to free up is a **claim-side** concern: take `ablo.<model>.claim({ id })` (it
|
|
176
|
+
queues fairly behind the current holder and re-reads the fresh row once it's
|
|
177
|
+
yours). Use `ifClaimed: 'fail'` when a read should simply refuse to proceed
|
|
178
|
+
against a row someone else is mid-editing.
|
|
179
|
+
|
|
161
180
|
### `claim.state`
|
|
162
181
|
|
|
163
182
|
```ts
|
|
@@ -272,19 +291,54 @@ try {
|
|
|
272
291
|
}
|
|
273
292
|
```
|
|
274
293
|
|
|
294
|
+
### `watch` — presence for a set of rows
|
|
295
|
+
|
|
296
|
+
Reading or claiming a row auto-enrolls you in its sync group, which is enough for
|
|
297
|
+
`claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
|
|
298
|
+
presence on a known set of rows — a deck's slides, a board's cards — and react to
|
|
299
|
+
who joins or leaves, use `watch`:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
await using room = await ablo.slides.watch(slideIds, { ttl: '5m' });
|
|
303
|
+
room.peers; // who else is here, live
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`watch(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
|
|
307
|
+
a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
|
|
308
|
+
disposal). It is the model-scoped successor to the old top-level
|
|
309
|
+
`ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
|
|
310
|
+
socket, so `watch` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
|
|
311
|
+
throws on any non-ws construction.
|
|
312
|
+
|
|
275
313
|
### Writing under a claim
|
|
276
314
|
|
|
277
315
|
There is no separate "write" method on a claim — use the normal
|
|
278
|
-
`ablo.<model>.update({ id, data })`.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
316
|
+
`ablo.<model>.update({ id, data })`. The auto-guarding holds **only when this same
|
|
317
|
+
client took the claim** via `ablo.<model>.claim({ id })` (the proxy remembers the
|
|
318
|
+
lease in-process): that `update` is then stale-guarded against the snapshot the
|
|
319
|
+
claim took (`readAt` = snapshot watermark, `onStale: 'reject'`) and attributed to
|
|
320
|
+
the claim's lease, so it rejects with [`AbloStaleContextError`](#errors) if the
|
|
321
|
+
row changed under you.
|
|
282
322
|
|
|
283
323
|
```ts
|
|
284
324
|
await using claim = await ablo.weatherReports.claim({ id });
|
|
285
325
|
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
|
|
286
326
|
```
|
|
287
327
|
|
|
328
|
+
A claim handle minted by **another client** (or returned over HTTP) is not known
|
|
329
|
+
to this proxy, so a plain `update` won't pick it up. Pass it explicitly:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
await ablo.weatherReports.update({ id, data: { status: 'ready' }, claim: handle });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Self-stale on a second write.** The claim's watermark is fixed at claim time
|
|
336
|
+
and is **not** re-baselined as you write. So a *second* `update` under one held
|
|
337
|
+
claim is stale-checked against the snapshot the claim took — which your *first*
|
|
338
|
+
write already moved past — and rejects with `AbloStaleContextError` against your
|
|
339
|
+
own earlier write. Re-read (and re-claim) between writes if you need to write the
|
|
340
|
+
same row more than once under one claim.
|
|
341
|
+
|
|
288
342
|
Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
|
|
289
343
|
participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
|
|
290
344
|
'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
|
|
@@ -57,8 +57,9 @@ export const ablo = Ablo({
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Mount the React provider near the app root. Build the browser client first —
|
|
60
|
-
with an `
|
|
61
|
-
|
|
60
|
+
with an `apiKey` resolver (an async `() => Promise<string | null>`) that fetches
|
|
61
|
+
the short-lived session token your backend minted, instead of carrying the
|
|
62
|
+
secret key — then pass it to the provider via `client`.
|
|
62
63
|
|
|
63
64
|
```tsx
|
|
64
65
|
// web/app/providers.tsx
|
|
@@ -68,9 +69,12 @@ import Ablo from '@abloatai/ablo';
|
|
|
68
69
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
69
70
|
import { schema } from '@/ablo/schema';
|
|
70
71
|
|
|
71
|
-
// Browser client: no secret key — `
|
|
72
|
-
// server
|
|
73
|
-
const ablo = Ablo({
|
|
72
|
+
// Browser client: no secret key — the `apiKey` resolver fetches the session
|
|
73
|
+
// token your server route mints (see the session route below).
|
|
74
|
+
const ablo = Ablo({
|
|
75
|
+
schema,
|
|
76
|
+
apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
|
|
77
|
+
});
|
|
74
78
|
|
|
75
79
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
76
80
|
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
@@ -81,7 +81,7 @@ import { schema } from './schema';
|
|
|
81
81
|
|
|
82
82
|
const ablo = Ablo({
|
|
83
83
|
schema,
|
|
84
|
-
|
|
84
|
+
apiKey: async () => mintDeckAgentSession(deckId, agentId),
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
// The agent run is mounted on behalf of its triggering user.
|
package/docs/guarantees.md
CHANGED
|
@@ -82,9 +82,10 @@ Claims are live coordination signals. They are not database locks.
|
|
|
82
82
|
already holds the row, the claim waits for them to finish, then re-reads the row
|
|
83
83
|
before handing it back, so you proceed from fresh state. Reads stay open while a
|
|
84
84
|
claim is held — `ablo.<model>.claim.state({ id })` returns the current claim state
|
|
85
|
-
(or `null`) without ever blocking. A server read can pass `ifClaimed: '
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
(or `null`) without ever blocking. A server read can pass `ifClaimed: 'fail'` to
|
|
86
|
+
error out, when it should not return a row while someone else is mid-edit. Reads
|
|
87
|
+
never block on a claim — to wait for a row to free up, `claim({ id })` it (the
|
|
88
|
+
claim queues fairly behind the holder).
|
|
88
89
|
|
|
89
90
|
A claim does not reject or block other writers; it announces work so peers
|
|
90
91
|
serialize behind it rather than racing. While you hold a claim, the matching
|
package/docs/identity.md
CHANGED
|
@@ -36,8 +36,8 @@ runnable place, so the concepts below have code to attach to.
|
|
|
36
36
|
## Declare it, end to end
|
|
37
37
|
|
|
38
38
|
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
39
|
-
each model `scope` / `parent` / `grants` (which group a row fans out on), plus
|
|
40
|
-
optional `
|
|
39
|
+
each model `scope` / `parent` / `grants` (which group a row fans out on), plus
|
|
40
|
+
optional `syncGroups` at session-mint time (narrowing). Read the three blocks first —
|
|
41
41
|
a human gets their `org` / `team` scope, an agent gets one `deck` — then the
|
|
42
42
|
sections after explain each.
|
|
43
43
|
|
|
@@ -84,18 +84,17 @@ export const schema = defineSchema(
|
|
|
84
84
|
|
|
85
85
|
```ts
|
|
86
86
|
// 3. an AGENT run inherits its user, narrowed to the entities in play.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
// You narrow at SESSION-MINT time: your backend calls `sessions.create` with the
|
|
88
|
+
// agent's allowed `syncGroups`, built from each model's scope via the
|
|
89
|
+
// `syncGroup(kind, id)` helper — never a hand-built `deck:<id>` string. The agent's
|
|
90
|
+
// runtime then connects with the minted token.
|
|
91
|
+
const session = await server.sessions.create({
|
|
92
|
+
agent: { id: agentId },
|
|
93
|
+
can: { Deck: ['read', 'update'] },
|
|
94
|
+
syncGroups: [syncGroup('deck', deckId)], // floor: just the deck it's working on
|
|
94
95
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{children}
|
|
98
|
-
</AbloProvider>;
|
|
96
|
+
// the agent runtime authenticates with the minted token
|
|
97
|
+
const ablo = Ablo({ schema, apiKey: session.token });
|
|
99
98
|
```
|
|
100
99
|
|
|
101
100
|
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
@@ -127,11 +126,12 @@ so you pass them in code when you start the run.
|
|
|
127
126
|
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
128
127
|
> they've been given.
|
|
129
128
|
|
|
130
|
-
That's why you never write per-user scope code, but you always
|
|
131
|
-
|
|
129
|
+
That's why you never write per-user scope code, but you always choose an agent's
|
|
130
|
+
groups at the dispatch site. A user's org/team/user don't change per request, so
|
|
132
131
|
their scope is a **rule the schema derives automatically**. An agent's reach
|
|
133
132
|
depends on *what it's working on*, which is only knowable at dispatch — so you
|
|
134
|
-
|
|
133
|
+
pass its `syncGroups` **when your backend mints the agent session**
|
|
134
|
+
(`sessions.create({ agent, can, syncGroups })`). The schema's
|
|
135
135
|
only job for entities is to declare *that* a model is
|
|
136
136
|
entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
|
|
137
137
|
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
@@ -305,8 +305,9 @@ server, never by the browser.**
|
|
|
305
305
|
|
|
306
306
|
The identity your server resolved is carried by the client you build and the
|
|
307
307
|
`userId` prop. In a Next.js app, resolve the user in a Server Component and pass
|
|
308
|
-
it down. Build the client once (the schema, `teamIds`, and
|
|
309
|
-
live here), then hand
|
|
308
|
+
it down. Build the client once (the schema, `teamIds`, and the `apiKey` resolver
|
|
309
|
+
live here; entity narrowing rides the minted session's `syncGroups`), then hand
|
|
310
|
+
it to the provider:
|
|
310
311
|
|
|
311
312
|
```ts
|
|
312
313
|
// lib/ablo.ts
|
|
@@ -318,7 +319,10 @@ import { schema } from '@/ablo/schema';
|
|
|
318
319
|
export function makeAblo(user: { teamIds: string[] }) {
|
|
319
320
|
return Ablo({
|
|
320
321
|
schema,
|
|
321
|
-
|
|
322
|
+
// The browser holds no secret — the `apiKey` resolver fetches the
|
|
323
|
+
// short-lived session token your `/api/ablo-session` route minted, and the
|
|
324
|
+
// client keeps it fresh before expiry.
|
|
325
|
+
apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
|
|
322
326
|
teamIds: user.teamIds,
|
|
323
327
|
});
|
|
324
328
|
}
|
|
@@ -354,7 +358,7 @@ What carries identity — and just as importantly, what does *not* set the bound
|
|
|
354
358
|
| ------------ | ------------------------------------------------------------------------------------------------ |
|
|
355
359
|
| `userId` prop | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
|
|
356
360
|
| `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
|
|
357
|
-
| `
|
|
361
|
+
| `syncGroups` (at session mint) | Optional. **Narrows** a minted session's subscription to a subset of what auth already allows — it can never widen it. Passed to `sessions.create({ user \| agent, syncGroups })`; build entries with `syncGroup(kind, id)`. Use it to scope an agent (or a focused page's session) to one entity, e.g. `[syncGroup('deck', 'abc123')]`. |
|
|
358
362
|
|
|
359
363
|
Because the server is the boundary, a client that changes `userId` to another
|
|
360
364
|
user's id does not gain their data — the server resolves and enforces the real
|
|
@@ -398,20 +402,20 @@ subset of what its user could see:
|
|
|
398
402
|
|
|
399
403
|
```ts
|
|
400
404
|
// agent run triggered by `user`, working on one document + one deck.
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
scope: { documents: documentId, decks: deckId },
|
|
405
|
+
// Your backend mints the agent session narrowed to just the entities in play
|
|
406
|
+
// (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
|
|
407
|
+
const session = await server.sessions.create({
|
|
408
|
+
agent: { id: agentId },
|
|
409
|
+
can: { Document: ['read', 'update'], Deck: ['read', 'update'] },
|
|
410
|
+
syncGroups: [syncGroup('document', documentId), syncGroup('deck', deckId)],
|
|
408
411
|
});
|
|
409
|
-
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
+
// identity (the ceiling) is inherited from the triggering user via your
|
|
413
|
+
// session-mint logic; the agent runtime connects with the minted token.
|
|
414
|
+
const ablo = Ablo({ schema, apiKey: session.token });
|
|
412
415
|
```
|
|
413
416
|
|
|
414
|
-
As the run touches more entities
|
|
417
|
+
As the run touches more entities, claim or read them and the client auto-enrolls
|
|
418
|
+
in their entity groups — its set **accretes** to cover them; it never
|
|
415
419
|
widens past the user's ceiling, and it carries no standing access to entities it
|
|
416
420
|
isn't working on. The `identityRoles` need no agent-specific entry: the agent
|
|
417
421
|
carries the triggering user's `userId`, so the same `user:{id}` role that scopes
|
|
@@ -444,52 +448,55 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
|
|
|
444
448
|
`claim` loop is, to the scoping layer, that same participant — scoped to the row
|
|
445
449
|
it claimed.
|
|
446
450
|
|
|
447
|
-
## Narrowing to specific entities
|
|
448
|
-
|
|
449
|
-
A human gets their full membership automatically (`identityRoles`).
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
`
|
|
454
|
-
|
|
455
|
-
`
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const ablo = Ablo({
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
>
|
|
484
|
-
> (
|
|
485
|
-
>
|
|
486
|
-
>
|
|
487
|
-
|
|
488
|
-
>
|
|
489
|
-
>
|
|
490
|
-
|
|
491
|
-
>
|
|
492
|
-
>
|
|
451
|
+
## Narrowing to specific entities
|
|
452
|
+
|
|
453
|
+
A human gets their full membership automatically (`identityRoles`). There are
|
|
454
|
+
three ways to narrow a participant to specific entities — a page on one deck, or
|
|
455
|
+
an agent pointed at the entities it's working on. You **never hand-write**
|
|
456
|
+
`deck:<id>`; build groups from the model's `scope` (Half 2) with the typed
|
|
457
|
+
`syncGroup(kind, id)` helper from `@abloatai/ablo/schema`.
|
|
458
|
+
|
|
459
|
+
1. **At session mint — `syncGroups`.** When your backend mints a session, pass the
|
|
460
|
+
exact groups it may subscribe to. This is the floor for a delegated agent (and
|
|
461
|
+
the way to scope a focused page's session):
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
// an agent working across two decks and a document
|
|
465
|
+
const session = await server.sessions.create({
|
|
466
|
+
agent: { id: agentId },
|
|
467
|
+
can: { Deck: ['read', 'update'], Document: ['read'] },
|
|
468
|
+
syncGroups: [
|
|
469
|
+
syncGroup('deck', deckA),
|
|
470
|
+
syncGroup('deck', deckB),
|
|
471
|
+
syncGroup('document', docId),
|
|
472
|
+
],
|
|
473
|
+
});
|
|
474
|
+
const ablo = Ablo({ schema, apiKey: session.token });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
2. **Automatically, on read or claim.** Reading a row (`retrieve`/`get`/
|
|
478
|
+
`claim.state`) auto-enrolls the client in that row's entity group
|
|
479
|
+
(**read-interest**), and `claim`-ing it pins a **write-intent** subscription.
|
|
480
|
+
So an agent's reachable set **accretes** as it works — no extra subscribe call.
|
|
481
|
+
|
|
482
|
+
3. **Explicitly, for presence — `watch`.** To hold presence on a known set of rows
|
|
483
|
+
and react to peers, use the WebSocket-only `ablo.<model>.watch(ids, { ttl })`
|
|
484
|
+
(it returns a participant handle with `.peers`). See
|
|
485
|
+
[Coordination](./coordination.md).
|
|
486
|
+
|
|
487
|
+
> **`scope` is the schema model option, not a client setting.** `scope: 'deck'`
|
|
488
|
+
> in `model(...)` declares a scope root ([Half 2](#half-2--per-model-scope-row--group)) —
|
|
489
|
+
> it names the group (`deck:<id>`) that the mechanisms above then subscribe to.
|
|
490
|
+
> There is no `Ablo({ scope })` constructor option. The lifecycle filter on
|
|
491
|
+
> [`list()`](./api.md#model-methods) is a separate axis named **`state`**
|
|
492
|
+
> (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
|
|
493
|
+
> doesn't share the word.
|
|
494
|
+
|
|
495
|
+
> **Requested groups never grant.** At connect, the server intersects the session's
|
|
496
|
+
> `syncGroups` with what the identity is actually allowed (`requested ∩ allowed`).
|
|
497
|
+
> So `syncGroups` only ever *narrows* within a participant's ceiling — an agent
|
|
498
|
+
> can't reach a deck its capability doesn't already permit, no matter what it
|
|
499
|
+
> passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
|
|
493
500
|
|
|
494
501
|
## How this compares — and the best practices it follows
|
|
495
502
|
|
|
@@ -512,7 +519,7 @@ how to reason about it.
|
|
|
512
519
|
the room/shape and the server signs off, as in
|
|
513
520
|
[Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
|
|
514
521
|
[ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
|
|
515
|
-
and Liveblocks **access tokens**. Ablo's
|
|
522
|
+
and Liveblocks **access tokens**. Ablo's session-mint `syncGroups` is the
|
|
516
523
|
*narrowing* half of this — but it can only ever shrink the server-derived set,
|
|
517
524
|
never grow it.
|
|
518
525
|
|
|
@@ -529,10 +536,10 @@ The best practices Ablo inherits from that lineage:
|
|
|
529
536
|
2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws
|
|
530
537
|
the line precisely: [token parameters are trusted and usable for access
|
|
531
538
|
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
532
|
-
In Ablo terms, the identity your server vouches for
|
|
533
|
-
|
|
534
|
-
client input* — convenient for app-owned fields
|
|
535
|
-
boundary. This is why changing `userId` in the browser grants nothing.
|
|
539
|
+
In Ablo terms, the identity your server vouches for — and the session's
|
|
540
|
+
`syncGroups`, minted server-side — are the *trusted* claims that set scope; the
|
|
541
|
+
`userId` prop is *untrusted client input* — convenient for app-owned fields, but
|
|
542
|
+
never the boundary. This is why changing `userId` in the browser grants nothing.
|
|
536
543
|
|
|
537
544
|
3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
|
|
538
545
|
group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
|