@abloatai/ablo 0.11.0 → 0.11.2
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 +58 -0
- package/README.md +72 -25
- package/dist/Model.d.ts +39 -0
- package/dist/Model.js +68 -0
- package/dist/auth/credentialPolicy.d.ts +145 -0
- package/dist/auth/credentialPolicy.js +130 -0
- package/dist/cli.cjs +154 -25
- package/dist/client/Ablo.d.ts +39 -88
- package/dist/client/Ablo.js +54 -99
- package/dist/client/ApiClient.d.ts +10 -1
- package/dist/client/ApiClient.js +23 -12
- package/dist/client/auth.d.ts +21 -9
- package/dist/client/auth.js +42 -6
- package/dist/client/createModelProxy.d.ts +74 -10
- package/dist/client/createModelProxy.js +85 -4
- 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/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +3 -1
- package/dist/errors.d.ts +3 -2
- package/dist/errors.js +3 -2
- 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 +1 -6
- package/dist/react/AbloProvider.js +1 -5
- package/dist/react/context.d.ts +1 -31
- package/dist/react/context.js +2 -2
- package/dist/react/index.d.ts +0 -6
- package/dist/react/index.js +0 -7
- package/dist/react/useSyncStatus.d.ts +1 -1
- package/dist/realtime/index.d.ts +1 -1
- package/dist/schema/generate.js +1 -2
- package/dist/schema/schema.d.ts +16 -5
- 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/transactions/TransactionQueue.js +22 -10
- package/dist/types/global.d.ts +11 -3
- package/dist/types/global.js +8 -3
- package/dist/types/streams.d.ts +0 -22
- package/dist/utils/mobx-setup.js +1 -0
- package/docs/api-keys.md +49 -0
- package/docs/api.md +6 -5
- package/docs/client-behavior.md +7 -3
- package/docs/coordination.md +88 -24
- package/docs/data-sources.md +29 -9
- 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 +49 -2
- package/docs/quickstart.md +65 -33
- package/docs/react.md +49 -3
- package/docs/schema-contract.md +23 -5
- package/llms-full.txt +43 -24
- package/llms.txt +17 -15
- 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
|
@@ -20,18 +20,29 @@
|
|
|
20
20
|
* Designed to be embedded by `BaseSyncedStore`: one instance per store,
|
|
21
21
|
* started on first successful connect, disposed on teardown.
|
|
22
22
|
*
|
|
23
|
-
* CONNECTED
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* CONNECTED ──(socket drop)──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
|
|
24
|
+
* │ │ │
|
|
25
|
+
* (network lost) ▼ ▼
|
|
26
|
+
* ▼ SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
|
|
27
|
+
* OFFLINE ──(online)──► PROBING_NETWORK
|
|
28
|
+
* │
|
|
29
|
+
* ▼
|
|
30
|
+
* WAITING_FOR_NETWORK
|
|
27
31
|
*
|
|
28
|
-
* Includes
|
|
32
|
+
* Includes three fixes over the original app-side FSM:
|
|
29
33
|
* 1. `backoff` accepts `NETWORK_ONLINE` / `TAB_VISIBLE` — jumps to
|
|
30
34
|
* probing immediately when the network comes back, without
|
|
31
35
|
* waiting for the backoff timer to elapse.
|
|
32
36
|
* 2. `scheduleBackoff` parks in `waiting_for_network` (resetting
|
|
33
37
|
* `attempt`) when `navigator.onLine === false` at max retries,
|
|
34
38
|
* instead of hard-reloading an already-offline browser.
|
|
39
|
+
* 3. A socket drop (`WS_DISCONNECTED`, typically code 1006) goes
|
|
40
|
+
* STRAIGHT to `probing_network`, not the passive `offline` state.
|
|
41
|
+
* 1006 is browser-local and carries no connectivity signal, so on a
|
|
42
|
+
* healthy machine no `online`/`offline` event ever fires — parking in
|
|
43
|
+
* `offline` stranded recovery until the 30s watchdog, long enough for
|
|
44
|
+
* queued commits to roll back. Only a genuine OS-level `NETWORK_LOST`
|
|
45
|
+
* parks in `offline` and waits for the `online` event.
|
|
35
46
|
*/
|
|
36
47
|
import { makeAutoObservable, runInAction } from 'mobx';
|
|
37
48
|
import { getContext } from '../context.js';
|
|
@@ -142,8 +153,19 @@ export class ConnectionManager {
|
|
|
142
153
|
case 'connected':
|
|
143
154
|
switch (event.type) {
|
|
144
155
|
case 'NETWORK_LOST':
|
|
145
|
-
|
|
156
|
+
// The OS reported the NIC down — park passively in `offline` and
|
|
157
|
+
// wait for the `online` event. Probing a downed adapter is wasted
|
|
158
|
+
// work.
|
|
146
159
|
return 'offline';
|
|
160
|
+
case 'WS_DISCONNECTED':
|
|
161
|
+
// The socket died (typically code 1006) but the OS network is
|
|
162
|
+
// almost certainly fine — 1006 is generated locally when the TCP
|
|
163
|
+
// conn vanishes and carries NO connectivity signal, so the browser
|
|
164
|
+
// fires no online/offline event. Probe IMMEDIATELY rather than
|
|
165
|
+
// landing in the passive `offline` dead-end (which only escaped via
|
|
166
|
+
// the 30s watchdog, long after queued commits rolled back). The
|
|
167
|
+
// probe fast-fails if we genuinely ARE offline → waiting_for_network.
|
|
168
|
+
return 'probing_network';
|
|
147
169
|
case 'WS_SESSION_ERROR':
|
|
148
170
|
case 'BOOTSTRAP_FAILED_SESSION':
|
|
149
171
|
return 'session_expired';
|
|
@@ -301,7 +323,7 @@ export class ConnectionManager {
|
|
|
301
323
|
}
|
|
302
324
|
}
|
|
303
325
|
// ── Side effects per state ───────────────────────────────────────────
|
|
304
|
-
onEnterState(state,
|
|
326
|
+
onEnterState(state, event) {
|
|
305
327
|
switch (state) {
|
|
306
328
|
case 'connected':
|
|
307
329
|
this.clearBackoffTimer();
|
|
@@ -314,6 +336,19 @@ export class ConnectionManager {
|
|
|
314
336
|
this.callbacks?.onDisconnectWebSocket();
|
|
315
337
|
break;
|
|
316
338
|
case 'probing_network':
|
|
339
|
+
// A socket drop (`WS_DISCONNECTED`) now lands here directly so recovery
|
|
340
|
+
// starts immediately. Tear the dead socket down FIRST — this is what
|
|
341
|
+
// sets SyncWebSocket's `isManualClose=true` and suppresses its own
|
|
342
|
+
// scheduleReconnect, keeping the FSM the single reconnect authority on
|
|
343
|
+
// the human path. The teardown runs synchronously inside the
|
|
344
|
+
// `disconnected` emit, before `SyncWebSocket.onclose` checks the flag,
|
|
345
|
+
// so the timing matches the previous `offline`-entry teardown. We gate
|
|
346
|
+
// on the drop event specifically: the other paths into `probing_network`
|
|
347
|
+
// (TAB_VISIBLE re-validation, handshake retry, backoff elapse) must NOT
|
|
348
|
+
// tear down a socket that may still be live.
|
|
349
|
+
if (event.type === 'WS_DISCONNECTED') {
|
|
350
|
+
this.callbacks?.onDisconnectWebSocket();
|
|
351
|
+
}
|
|
317
352
|
this.runProbe();
|
|
318
353
|
break;
|
|
319
354
|
case 'waiting_for_network':
|
|
@@ -773,6 +773,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
773
773
|
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
774
774
|
: this.extractUpdateData(model);
|
|
775
775
|
const previousData = this.extractPreviousData(model, updateInput);
|
|
776
|
+
// Advance the per-field baseline for the keys we just froze into this
|
|
777
|
+
// transaction. `Model.propertyChanged` is first-old-wins and only cleared on
|
|
778
|
+
// sync-ack, so without this a SECOND update to the same field before the
|
|
779
|
+
// first acks would re-capture the original `.old` (the pre-session value)
|
|
780
|
+
// instead of THIS update's result — corrupting the stream-recorded undo
|
|
781
|
+
// inverse (the second move's "before" would point all the way back). The
|
|
782
|
+
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
783
|
+
// consumed entries is safe.
|
|
784
|
+
model.consumeModifiedFields(Object.keys(updateInput));
|
|
776
785
|
const modelKey = normalizeModelKey(actualModelName);
|
|
777
786
|
const priorityScore = this.computePriorityScore('update', actualModelName);
|
|
778
787
|
const transaction = {
|
|
@@ -1990,16 +1999,19 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1990
1999
|
// model ever needs to surface previous-state outside `modifiedProperties`,
|
|
1991
2000
|
// expose a typed `getPreviousData()` accessor on Model and call that.
|
|
1992
2001
|
extractPreviousData(model, updateInput) {
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2002
|
+
// When the update's written keys are known, capture a before-image for
|
|
2003
|
+
// EXACTLY those keys so the recorded undo inverse can revert them and only
|
|
2004
|
+
// them (a full-row inverse would clobber concurrent edits to unrelated
|
|
2005
|
+
// fields). `fallbackToLive: false` makes `Model.capturePreviousValues` OMIT
|
|
2006
|
+
// any key it can't resolve from `modifiedProperties.old` / the original
|
|
2007
|
+
// snapshot — `buildUndoOps` then drops an un-revertible inverse rather than
|
|
2008
|
+
// inventing one. With no `updateInput` (full extract) fall back to every
|
|
2009
|
+
// tracked field. `Model.capturePreviousValues` is the single before-image
|
|
2010
|
+
// source shared with `RecordingTransaction.snapshotFields`.
|
|
2011
|
+
const keys = updateInput
|
|
2012
|
+
? Object.keys(updateInput)
|
|
2013
|
+
: [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
|
|
2014
|
+
return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
|
|
2003
2015
|
}
|
|
2004
2016
|
/**
|
|
2005
2017
|
* Public API
|
package/dist/types/global.d.ts
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
13
|
* in the compilation can augment it and every resolver below picks it up.
|
|
14
14
|
*
|
|
15
|
-
* Consumer example
|
|
15
|
+
* Consumer example (`npx ablo init` scaffolds this as `ablo/register.ts`, a
|
|
16
|
+
* sibling of `ablo/schema.ts`). It's a regular `.ts` module, NOT a hand-authored
|
|
17
|
+
* `.d.ts`: the top-level `import type { schema }` makes the `declare module`
|
|
18
|
+
* block MERGE (augment) this interface rather than collide with it — the same
|
|
19
|
+
* shape TanStack Router uses in `src/router.tsx`. Any `.ts` file in the
|
|
20
|
+
* `tsconfig` `include` works; it never needs to be imported.
|
|
16
21
|
*
|
|
17
22
|
* ```ts
|
|
18
|
-
* //
|
|
19
|
-
* import type { schema } from './
|
|
23
|
+
* // ablo/register.ts
|
|
24
|
+
* import type { schema } from './schema';
|
|
20
25
|
*
|
|
21
26
|
* declare module '@abloatai/ablo' {
|
|
22
27
|
* interface Register {
|
|
@@ -55,6 +60,9 @@ export interface DefaultSyncShape {
|
|
|
55
60
|
* Empty by default — every SDK resolver falls back to {@link DefaultSyncShape}
|
|
56
61
|
* when an expected key is absent. Exported from the package root so the module
|
|
57
62
|
* augmentation merges into this declaration.
|
|
63
|
+
*
|
|
64
|
+
* The `Schema` augmentation key holds the type produced by `defineSchema`, so
|
|
65
|
+
* the same noun reads consistently here and in {@link ResolveSchema}.
|
|
58
66
|
*/
|
|
59
67
|
export interface Register {
|
|
60
68
|
}
|
package/dist/types/global.js
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
13
|
* in the compilation can augment it and every resolver below picks it up.
|
|
14
14
|
*
|
|
15
|
-
* Consumer example
|
|
15
|
+
* Consumer example (`npx ablo init` scaffolds this as `ablo/register.ts`, a
|
|
16
|
+
* sibling of `ablo/schema.ts`). It's a regular `.ts` module, NOT a hand-authored
|
|
17
|
+
* `.d.ts`: the top-level `import type { schema }` makes the `declare module`
|
|
18
|
+
* block MERGE (augment) this interface rather than collide with it — the same
|
|
19
|
+
* shape TanStack Router uses in `src/router.tsx`. Any `.ts` file in the
|
|
20
|
+
* `tsconfig` `include` works; it never needs to be imported.
|
|
16
21
|
*
|
|
17
22
|
* ```ts
|
|
18
|
-
* //
|
|
19
|
-
* import type { schema } from './
|
|
23
|
+
* // ablo/register.ts
|
|
24
|
+
* import type { schema } from './schema';
|
|
20
25
|
*
|
|
21
26
|
* declare module '@abloatai/ablo' {
|
|
22
27
|
* interface Register {
|
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
|
*
|
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
|
@@ -120,18 +120,18 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
120
120
|
|
|
121
121
|
| Field | Type | Description |
|
|
122
122
|
|---|---|---|
|
|
123
|
-
| `object` | `'
|
|
123
|
+
| `object` | `'claim'` | String representing the object's type. |
|
|
124
124
|
| `id` | string | Unique identifier for the claim. |
|
|
125
125
|
| `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
|
|
126
126
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
127
127
|
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
128
128
|
| `heldBy` | string | Participant id holding the claim. |
|
|
129
|
-
| `participantKind` | `'
|
|
129
|
+
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
130
130
|
| `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
|
|
131
131
|
|
|
132
132
|
```json
|
|
133
133
|
{
|
|
134
|
-
"object": "
|
|
134
|
+
"object": "claim",
|
|
135
135
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
136
136
|
"status": "active",
|
|
137
137
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
@@ -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
|
@@ -29,16 +29,20 @@ Common options:
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| `schema` | Required for typed model clients. |
|
|
31
31
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
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. |
|
|
32
33
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
33
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. |
|
|
34
36
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
35
37
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
36
38
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
37
39
|
| `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
`databaseUrl` is an optional, server-only constructor option. It is **not**
|
|
42
|
+
auto-read from the environment — pass it explicitly to register your Postgres
|
|
43
|
+
directly (the connection-string path). Omit it when you expose a signed
|
|
44
|
+
[Data Source](./data-sources.md) endpoint, or when trying Ablo against the hosted
|
|
45
|
+
sandbox.
|
|
42
46
|
|
|
43
47
|
## Model Methods
|
|
44
48
|
|
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
|
|
@@ -29,17 +30,19 @@ make:
|
|
|
29
30
|
|
|
30
31
|
| layer | kind | what it does | enforces? |
|
|
31
32
|
|---|---|---|---|
|
|
32
|
-
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
|
|
33
|
+
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only — it never blocks or rejects a write. |
|
|
33
34
|
| **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
|
|
34
35
|
| **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
|
|
35
36
|
|
|
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
|
---
|
|
@@ -70,7 +74,7 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
70
74
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
71
75
|
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
72
76
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
73
|
-
| `participantKind` | `'
|
|
77
|
+
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
74
78
|
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
75
79
|
| `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
76
80
|
| `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
|
|
@@ -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
|
|
@@ -167,6 +186,16 @@ ablo.<model>.claim.state({ id })
|
|
|
167
186
|
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
168
187
|
reactive (it reads the local coordination snapshot). Never blocks.
|
|
169
188
|
|
|
189
|
+
**You don't subscribe to anything first.** Reading or claiming a row
|
|
190
|
+
automatically enrolls you in that row's sync group: reading it (including
|
|
191
|
+
`retrieve`/`get`, or `claim.state` itself) gives you **read-interest**, and
|
|
192
|
+
`claim`-ing it gives you a **pinned write-intent**. So `claim.state({ id })`
|
|
193
|
+
observes co-participants on that row from **any** client — a browser, a Server
|
|
194
|
+
Action, or a Node agent — and a holder sees its own claim, with no manual
|
|
195
|
+
subscribe step. There is no `participants.join` to call: the typed
|
|
196
|
+
`ablo.<model>` surface (read / `claim` / `claim.state` / `claim.queue`) is the
|
|
197
|
+
whole coordination API.
|
|
198
|
+
|
|
170
199
|
**Parameters**
|
|
171
200
|
|
|
172
201
|
| name | type | required | description |
|
|
@@ -262,19 +291,54 @@ try {
|
|
|
262
291
|
}
|
|
263
292
|
```
|
|
264
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
|
+
|
|
265
313
|
### Writing under a claim
|
|
266
314
|
|
|
267
315
|
There is no separate "write" method on a claim — use the normal
|
|
268
|
-
`ablo.<model>.update({ id, data })`.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
272
322
|
|
|
273
323
|
```ts
|
|
274
324
|
await using claim = await ablo.weatherReports.claim({ id });
|
|
275
325
|
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
|
|
276
326
|
```
|
|
277
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
|
+
|
|
278
342
|
Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
|
|
279
343
|
participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
|
|
280
344
|
'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
|
|
@@ -306,7 +370,7 @@ inspect the `code`.
|
|
|
306
370
|
| `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
|
|
307
371
|
| `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
|
|
308
372
|
| `AbloStaleContextError` | — | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot — your reasoning is stale. | `readAt`, `conflicts[]` |
|
|
309
|
-
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration
|
|
373
|
+
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime — an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. | — |
|
|
310
374
|
| `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
|
|
311
375
|
|
|
312
376
|
`AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
|
package/docs/data-sources.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
**In production, your database is the system of record.** Every synced model is
|
|
4
|
+
backed by your own Postgres; Ablo is the transaction layer on top of it. There
|
|
5
|
+
are two ways to connect, and they are the same product with the same writes — the
|
|
6
|
+
only difference is where your database credential lives:
|
|
7
7
|
|
|
8
8
|
| | How Ablo reaches your Postgres | Use when |
|
|
9
9
|
|---|---|---|
|
|
10
|
-
| **Connection string** (
|
|
10
|
+
| **Connection string** (primary) | You pass `databaseUrl` to `Ablo(...)` explicitly (it is never auto-read from the environment); Ablo registers the connection and commits each write directly, behind row-level security. | You can hand over a scoped connection string. |
|
|
11
11
|
| **Signed endpoint** | Your app exposes one route built from an ORM adapter; Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
|
|
12
12
|
|
|
13
|
+
> Just trying Ablo? You don't need a database at all to start: the hosted
|
|
14
|
+
> **sandbox** can host rows in Ablo's test plane — pass an `apiKey` only and omit
|
|
15
|
+
> `databaseUrl`, like Stripe test mode. Connect your Postgres (either shape
|
|
16
|
+
> below) when you're ready for it to be the system of record.
|
|
17
|
+
|
|
13
18
|
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod. The
|
|
14
19
|
Ablo schema describes **only your synced, collaborative models** — the rows Ablo
|
|
15
20
|
coordinates and fans out in realtime. It is *not* your whole-database schema and
|
|
@@ -36,7 +41,7 @@ import { schema } from './ablo/schema';
|
|
|
36
41
|
export const ablo = Ablo({
|
|
37
42
|
schema,
|
|
38
43
|
apiKey: process.env.ABLO_API_KEY,
|
|
39
|
-
databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here
|
|
44
|
+
databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
|
|
40
45
|
});
|
|
41
46
|
```
|
|
42
47
|
|
|
@@ -50,6 +55,20 @@ On first connect the SDK registers the connection — sent once over TLS, stored
|
|
|
50
55
|
sealed, never returned by any API. From then on Ablo commits every confirmed
|
|
51
56
|
write directly to your database and reads canonical rows from it.
|
|
52
57
|
|
|
58
|
+
### A localhost Postgres can't be the system of record
|
|
59
|
+
|
|
60
|
+
This is the connection-string fact people hit first. Ablo's **cloud** registers
|
|
61
|
+
your connection string and connects to your Postgres **over the network**. A
|
|
62
|
+
`localhost` / private-range database (`127.0.0.1`, `192.168.*`, Docker's
|
|
63
|
+
`db:5432`) is unreachable from Ablo's side, so such connection strings are
|
|
64
|
+
**rejected**. Two escape hatches for local development against your own DB:
|
|
65
|
+
|
|
66
|
+
- **Expose a signed Data Source endpoint.** Your app — which *can* reach your
|
|
67
|
+
local DB — proxies Ablo's commits to it. See [Signed Endpoint](#signed-endpoint)
|
|
68
|
+
below. This is the right answer for "my dev DB stays on my machine."
|
|
69
|
+
- **Use the hosted sandbox.** Skip the database entirely: pass an `apiKey` only,
|
|
70
|
+
omit `databaseUrl`, and let Ablo's test plane host the rows while you build.
|
|
71
|
+
|
|
53
72
|
Safety requirements, enforced server-side before the first write:
|
|
54
73
|
|
|
55
74
|
- **Non-superuser role.** The connection must not be a superuser or hold
|
|
@@ -58,11 +77,12 @@ Safety requirements, enforced server-side before the first write:
|
|
|
58
77
|
- **Row-level security on synced tables.** `npx ablo migrate` provisions your
|
|
59
78
|
synced-model tables with `FORCE ROW LEVEL SECURITY` already applied; tables
|
|
60
79
|
you create yourself must do the same.
|
|
61
|
-
- **
|
|
62
|
-
address ranges are rejected.
|
|
80
|
+
- **Network-reachable host.** As above, connection strings resolving to loopback
|
|
81
|
+
or private address ranges are rejected — Ablo connects from its cloud.
|
|
63
82
|
|
|
64
83
|
`databaseUrl` is server-only: the SDK throws if it sees one in a browser-like
|
|
65
|
-
environment, and `dangerouslyAllowBrowser` does not override that.
|
|
84
|
+
environment, and `dangerouslyAllowBrowser` does not override that. It is also
|
|
85
|
+
never auto-read from the environment — pass it explicitly to `Ablo(...)`.
|
|
66
86
|
|
|
67
87
|
## Signed Endpoint
|
|
68
88
|
|
|
@@ -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>;
|