@abloatai/ablo 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +13 -1
package/docs/audit.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# Audit log
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The audit log records who changed what in your org, and when — including
|
|
4
|
+
changes an AI agent made on a person's behalf. Every change is one row, and the
|
|
5
|
+
rows are signed in a chain so you can later prove the history wasn't altered.
|
|
6
|
+
You can filter it, page through it, and export it.
|
|
7
|
+
|
|
8
|
+
Every commit becomes one row.
|
|
5
9
|
|
|
6
10
|
## Row shape
|
|
7
11
|
|
|
@@ -12,10 +16,10 @@ tamper-evident, queryable, exportable.
|
|
|
12
16
|
actorId: string,
|
|
13
17
|
onBehalfOfKind: 'user' | 'agent' | 'system' | null,
|
|
14
18
|
onBehalfOfId: string | null,
|
|
15
|
-
credentialId: string | null,
|
|
16
|
-
credentialLabel: string | null,
|
|
19
|
+
credentialId: string | null, // the API key/credential used for the write
|
|
20
|
+
credentialLabel: string | null, // its human-readable name, for scanning the log
|
|
17
21
|
delegationChainRoot: string | null, // always points at a human
|
|
18
|
-
causedByRunId: string | null,
|
|
22
|
+
causedByRunId: string | null, // the agent run that produced this write — group every change from one run
|
|
19
23
|
actionType: string, // e.g. 'weatherReport.update'
|
|
20
24
|
modelName: string | null, // e.g. 'claude-opus-4-7'
|
|
21
25
|
diffSummary: unknown,
|
|
@@ -28,9 +32,9 @@ tamper-evident, queryable, exportable.
|
|
|
28
32
|
|
|
29
33
|
## Delegation chain
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
Every action traces back to a human. Even when an agent makes the change,
|
|
36
|
+
`delegationChainRoot` names the person who set that work in motion — there is no
|
|
37
|
+
audit row whose root is an agent.
|
|
34
38
|
|
|
35
39
|
## Verify
|
|
36
40
|
|
|
@@ -71,7 +75,10 @@ curl 'https://<your-app>/api/orgs/<slug>/audit/export?actorKind=agent&since=2026
|
|
|
71
75
|
> may-agent-writes.csv
|
|
72
76
|
```
|
|
73
77
|
|
|
74
|
-
CSV up to a hard cap
|
|
78
|
+
One request exports CSV up to a hard row cap. If your window is larger than the
|
|
79
|
+
cap, the response is truncated at the cap rather than erroring — so for large
|
|
80
|
+
windows, split the window by date and request each slice, or page through the
|
|
81
|
+
JSON `GET` endpoint above using `nextCursor`.
|
|
75
82
|
|
|
76
83
|
## Compliance posture
|
|
77
84
|
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# CLI
|
|
2
|
+
|
|
3
|
+
The `ablo` CLI gets you from an empty project to live-syncing data: scaffold a
|
|
4
|
+
schema, authenticate, push the schema, and watch it sync. Your
|
|
5
|
+
`defineSchema(...)` is the single source of truth: whether you run the CLI
|
|
6
|
+
locally or push to the hosted server, the same engine turns it into the same
|
|
7
|
+
SQL — so what you test is what ships.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx ablo init # scaffold ablo/schema.ts + client
|
|
11
|
+
npx ablo login # authorize in the browser
|
|
12
|
+
npx ablo dev # push schema to the test sandbox + watch
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Two setups, and they pick your commands.** If Ablo manages your Postgres —
|
|
16
|
+
the default, **hosted** path — use `ablo dev` and `ablo push`. If you
|
|
17
|
+
**bring your own database (BYO)**, use `ablo migrate` to apply changes to your
|
|
18
|
+
own `DATABASE_URL` directly, and `ablo check` / `ablo pull` to adopt tables you
|
|
19
|
+
already have. The commands below are tagged **Hosted** or **BYO** so you can
|
|
20
|
+
tell which apply to you.
|
|
21
|
+
|
|
22
|
+
## Authenticate
|
|
23
|
+
|
|
24
|
+
`ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
|
|
25
|
+
**log in** or **create an account** and approve, and the CLI provisions a
|
|
26
|
+
**test + live key pair** (90-day, restricted) and stores them locally. This
|
|
27
|
+
mirrors `stripe login`.
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
|
|
32
|
+
| `ablo logout` | Remove the stored keys. |
|
|
33
|
+
| `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
|
|
34
|
+
| `ablo mode [test\|live]` | Switch the active mode. With no argument, prompts. |
|
|
35
|
+
|
|
36
|
+
Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
|
|
37
|
+
log in — set `ABLO_API_KEY`, which always overrides the stored key.
|
|
38
|
+
|
|
39
|
+
## Test vs live
|
|
40
|
+
|
|
41
|
+
Like Stripe, every account has a **test** mode and a **live** mode, and a key
|
|
42
|
+
belongs to one of them. Test keys are bound to an isolated sandbox: their reads
|
|
43
|
+
and writes never touch live data. Switch with `ablo mode`; `ablo dev` is always
|
|
44
|
+
test mode by design.
|
|
45
|
+
|
|
46
|
+
The schema, however, is **shared** across the org — pushing a schema (from
|
|
47
|
+
either mode) defines the same models test and live see; only the rows differ.
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
| Command | What it does | Flags |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
|
|
54
|
+
| `ablo login` / `logout` / `status` | Authentication & status (above). | — |
|
|
55
|
+
| `ablo mode [test\|live]` | Switch active mode. | — |
|
|
56
|
+
| `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
|
|
57
|
+
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
|
|
58
|
+
| `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
|
|
59
|
+
| `ablo migrate` | **BYO** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
|
|
60
|
+
| `ablo pull` | **BYO** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
|
|
61
|
+
| `ablo check` | **BYO** — verify your *existing* tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
|
|
62
|
+
| `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
|
|
63
|
+
|
|
64
|
+
## `ablo dev`
|
|
65
|
+
|
|
66
|
+
The development loop. It pushes `ablo/schema.ts` to your **test sandbox**,
|
|
67
|
+
prints the env line your app needs, then watches the file and re-pushes on every
|
|
68
|
+
save (300 ms debounce). It refuses live keys so a tight save loop can never
|
|
69
|
+
churn production data.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx ablo dev # push + watch
|
|
73
|
+
npx ablo dev --no-watch # push once and exit
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## `ablo logs`
|
|
77
|
+
|
|
78
|
+
Tail commit activity, like `stripe logs tail`. Scope comes from the key — a test
|
|
79
|
+
key streams only its sandbox's writes, a live key the org's — so you never pass
|
|
80
|
+
an org. Follows by default; `--no-follow` prints recent and exits.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx ablo logs # last 50, then stream
|
|
84
|
+
npx ablo logs -n 100 --model task # backfill 100, one model
|
|
85
|
+
npx ablo logs --since 15m --json # last 15m as NDJSON, then stream
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Each line is `time · op · model · id · actor`. `--json` emits one event per line
|
|
89
|
+
(NDJSON) for piping to `jq` or an agent.
|
|
90
|
+
|
|
91
|
+
## `ablo pull`
|
|
92
|
+
|
|
93
|
+
Generate `defineSchema(...)` from the tables you already have — the inverse of
|
|
94
|
+
provisioning, and read-only (like `prisma db pull`). It introspects
|
|
95
|
+
`DATABASE_URL`, emits a model per adoptable table (one that has `id` +
|
|
96
|
+
`organization_id`), maps Postgres types back to Zod, and writes `ablo/schema.ts`.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
DATABASE_URL=postgres://… npx ablo pull
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It never touches the database, and won't overwrite an existing schema without
|
|
103
|
+
`--force`. Introspection is lossy — enum members, JSON shape, relations, and
|
|
104
|
+
defaults can't be recovered from columns — so treat the output as a starting
|
|
105
|
+
point: review it, then run `ablo check`.
|
|
106
|
+
|
|
107
|
+
## `ablo check`
|
|
108
|
+
|
|
109
|
+
`ablo check` is how you adopt a database you already own. Instead of creating or
|
|
110
|
+
altering tables, it inspects your existing ones and tells you which fit the
|
|
111
|
+
schema: it introspects `DATABASE_URL`, compares each table to your
|
|
112
|
+
`defineSchema(...)`, and reports — per model — whether the table is adoptable.
|
|
113
|
+
It never writes or alters anything.
|
|
114
|
+
|
|
115
|
+
A table is adoptable when it has a primary key `id` and (for org-scoped models)
|
|
116
|
+
an `organization_id` column — the tenancy marker the engine isolates on. Every
|
|
117
|
+
other table in your database is ignored.
|
|
118
|
+
|
|
119
|
+
**Why `organization_id`?** It's the one column that makes a table safe to
|
|
120
|
+
multiplayer-sync. Row-level security scopes every read and write by it (org A
|
|
121
|
+
can't see org B's rows), and the engine routes realtime deltas by `org:<id>`. A
|
|
122
|
+
table without a tenancy key has no isolation boundary, so Ablo excludes it
|
|
123
|
+
**by default** rather than risk exposing it across tenants. If your tenancy
|
|
124
|
+
column has a different name, keep that table behind a
|
|
125
|
+
[Data Source endpoint](/data-sources) for now.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
DATABASE_URL=postgres://… npx ablo check
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
✓ tasks → tasks (id, organization_id ok)
|
|
133
|
+
✗ projects → projects
|
|
134
|
+
• missing "organization_id" — add it, or move this model behind a Data Source
|
|
135
|
+
2 models · 1 ok · 1 error
|
|
136
|
+
12 other tables in your database — ignored by Ablo
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
If a table can't carry `organization_id` (or has business logic Ablo shouldn't
|
|
140
|
+
bypass), keep it behind a [Data Source endpoint](/data-sources) rather than
|
|
141
|
+
reshaping it. `ablo check` is read-only; it never proposes a migration.
|
|
142
|
+
|
|
143
|
+
## `migrate` (BYO) vs `push` (Hosted)
|
|
144
|
+
|
|
145
|
+
Same engine, two setups. If you **bring your own database (BYO)**, use
|
|
146
|
+
`ablo migrate` — it applies the schema to your own `DATABASE_URL`, and you run
|
|
147
|
+
the DDL. If Ablo manages your Postgres (the **hosted** path), use `ablo push`
|
|
148
|
+
(and `ablo dev`) — the server applies the change to Ablo-managed Postgres
|
|
149
|
+
and version-gates connecting clients.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
ablo migrate --dry-run # preview the exact SQL
|
|
153
|
+
ablo migrate # apply to DATABASE_URL
|
|
154
|
+
ablo migrate --output schema.sql # write SQL to a file
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Zod → Postgres type mapping
|
|
158
|
+
|
|
159
|
+
The one type map, shared by both paths (there is no second mapping):
|
|
160
|
+
|
|
161
|
+
| Zod | Postgres |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `z.string()` | `TEXT` |
|
|
164
|
+
| `z.number()` | `DOUBLE PRECISION` — never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss |
|
|
165
|
+
| `z.boolean()` | `BOOLEAN` |
|
|
166
|
+
| `z.date()` | `TIMESTAMPTZ` |
|
|
167
|
+
| `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint |
|
|
168
|
+
| `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` |
|
|
169
|
+
| `.optional()` / `.nullable()` | nullable column |
|
|
170
|
+
|
|
171
|
+
Each table also gets the platform columns (`id`, `organization_id`,
|
|
172
|
+
`created_by`, `created_at`, `updated_at`), an `organization_id` index, and
|
|
173
|
+
row-level security so each org only sees its own rows — the engine sets this per
|
|
174
|
+
request (via `current_setting('app.current_org_id')`); you don't manage it.
|
|
175
|
+
|
|
176
|
+
`.default(...)` is **not** emitted as a SQL column default — Zod applies the
|
|
177
|
+
default at write time (`create`), in one place, so a DB default and a schema
|
|
178
|
+
default can't drift.
|
|
179
|
+
|
|
180
|
+
## Structured errors
|
|
181
|
+
|
|
182
|
+
A failed migration aborts the whole transaction (nothing partial lands) and
|
|
183
|
+
reports the same `migration_failed` shape on both paths — naming the statement
|
|
184
|
+
that broke and the Postgres SQLSTATE, not just "migration failed".
|
|
185
|
+
|
|
186
|
+
`ablo migrate` (local) logs it:
|
|
187
|
+
|
|
188
|
+
```txt
|
|
189
|
+
[migrate] migration plan failed {
|
|
190
|
+
code: 'migration_failed',
|
|
191
|
+
failedStatement: 'ALTER TABLE "public"."tasks" RENAME COLUMN a TO b;',
|
|
192
|
+
failedStatementIndex: 4,
|
|
193
|
+
pgCode: '42P01',
|
|
194
|
+
durationMs: 133
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`ablo push` (hosted) returns the canonical error envelope (HTTP 500),
|
|
199
|
+
which the SDK reconstructs as a typed `AbloServerError`:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"type": "AbloServerError",
|
|
204
|
+
"code": "migration_failed",
|
|
205
|
+
"message": "schema migration failed: relation \"...\" does not exist",
|
|
206
|
+
"doc_url": "https://docs.abloatai.com/errors#migration_failed",
|
|
207
|
+
"failedStatement": "ALTER TABLE ... RENAME COLUMN a TO b;",
|
|
208
|
+
"pgCode": "42P01"
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The pushed artifact is recorded `failed` and is never activated, so a broken
|
|
213
|
+
migration can't leave clients gated against tables that don't match.
|
|
214
|
+
|
|
215
|
+
## Environment
|
|
216
|
+
|
|
217
|
+
| Variable | Purpose | Default |
|
|
218
|
+
| --- | --- | --- |
|
|
219
|
+
| `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
|
|
220
|
+
| `ABLO_API_URL` | Control-plane / API host (`push`, `dev`, `status`). | `https://api.abloatai.com` |
|
|
221
|
+
| `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
|
|
222
|
+
| `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |
|
package/docs/client-behavior.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Client Behavior
|
|
2
2
|
|
|
3
|
-
This page
|
|
3
|
+
When several writers touch the same data at once — a person in the browser, a Server Action, an agent worker — the SDK decides whose write lands and how the others find out. This page is the reference for that: per-write options like `wait` and `onStale`, claiming a record so your slow work runs uninterrupted, and which errors are safe to retry.
|
|
4
|
+
|
|
5
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
|
|
4
6
|
|
|
5
7
|
## Constructor
|
|
6
8
|
|
|
@@ -28,7 +30,7 @@ Common options:
|
|
|
28
30
|
| `schema` | Required for typed model clients. |
|
|
29
31
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
30
32
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
31
|
-
| `persistence` | `volatile` by default. Use `indexeddb` for
|
|
33
|
+
| `persistence` | `volatile` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
32
34
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
33
35
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
34
36
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
@@ -45,30 +47,33 @@ Each schema model becomes a typed model:
|
|
|
45
47
|
```ts
|
|
46
48
|
await ablo.ready();
|
|
47
49
|
|
|
48
|
-
const
|
|
49
|
-
const local = ablo.weatherReports.
|
|
50
|
+
const report = await ablo.weatherReports.retrieve('report_stockholm');
|
|
51
|
+
const local = ablo.weatherReports.get('report_stockholm');
|
|
50
52
|
|
|
51
53
|
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
52
54
|
await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
|
|
53
55
|
await ablo.weatherReports.delete('report_stockholm', { wait: 'confirmed' });
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
`
|
|
57
|
-
`
|
|
58
|
+
Call `retrieve`/`list` first — they fetch from the server and you `await` them.
|
|
59
|
+
After that, `get`/`getAll`/`getCount` read the already-synced data instantly with
|
|
60
|
+
no `await`, and stay reactive in render. Use the async pair to load, the sync trio
|
|
61
|
+
to read.
|
|
58
62
|
|
|
59
|
-
`
|
|
60
|
-
`where`, `filter`, `orderBy`, `limit`, `offset`, and `
|
|
61
|
-
`'live'`; pass `'archived'` or `'all'` when you
|
|
62
|
-
rows.
|
|
63
|
+
`getAll` accepts the same practical read options the React selector path uses:
|
|
64
|
+
`where`, `filter`, `orderBy`, `limit`, `offset`, and `state`. The `state`
|
|
65
|
+
lifecycle filter defaults to `'live'`; pass `'archived'` or `'all'` when you
|
|
66
|
+
intentionally want non-live rows.
|
|
63
67
|
|
|
64
68
|
## Multiplayer Behavior
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
Two writers both try to mark `report_stockholm` ready at the same time. To stop
|
|
71
|
+
the second write from silently overwriting the first, every participant goes
|
|
72
|
+
through the same model client path. A human Server Action, a browser view, and an
|
|
73
|
+
agent worker can all use `ablo.weatherReports`:
|
|
69
74
|
|
|
70
75
|
```ts
|
|
71
|
-
const
|
|
76
|
+
const report = await ablo.weatherReports.retrieve(id);
|
|
72
77
|
const snap = ablo.snapshot({ weatherReports: id });
|
|
73
78
|
|
|
74
79
|
await ablo.weatherReports.update(id, patch, {
|
|
@@ -78,9 +83,10 @@ await ablo.weatherReports.update(id, patch, {
|
|
|
78
83
|
});
|
|
79
84
|
```
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
Once the server accepts the write, every other connected client gets the new row
|
|
87
|
+
automatically — no polling or manual refresh on your side. React clients that use
|
|
88
|
+
`useAblo((ablo) => ablo.weatherReports.get(id))` receive the new row, and selectors
|
|
89
|
+
such as `useAblo((ablo) => ablo.weatherReports.claim.state(id))`
|
|
84
90
|
receive active claim state. There is
|
|
85
91
|
no extra multiplayer setup beyond routing shared state through Ablo.
|
|
86
92
|
|
|
@@ -113,8 +119,13 @@ await ablo.weatherReports.update(
|
|
|
113
119
|
|
|
114
120
|
## Claimed Behavior
|
|
115
121
|
|
|
122
|
+
If your update involves a slow step — an API call, an LLM round-trip — and someone
|
|
123
|
+
else might write the same record meanwhile, claiming the record stops you from
|
|
124
|
+
overwriting their change. Check who holds the record with `claim.state(id)`, then
|
|
125
|
+
take it with `claim(id, work)`:
|
|
126
|
+
|
|
116
127
|
```ts
|
|
117
|
-
const active = ablo.weatherReports.
|
|
128
|
+
const active = ablo.weatherReports.claim.state('report_stockholm');
|
|
118
129
|
|
|
119
130
|
if (active) {
|
|
120
131
|
return { status: 'claimed', active };
|
|
@@ -125,14 +136,17 @@ await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
|
125
136
|
});
|
|
126
137
|
```
|
|
127
138
|
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
`claim.state(id)` returns the current holder (or nothing) without ever blocking.
|
|
140
|
+
When you call `claim(id, work)`, the SDK queues other claimers behind you, re-reads
|
|
141
|
+
the latest row, then runs your `work` — so you can't overwrite a change you didn't
|
|
142
|
+
see. Options on the wait:
|
|
130
143
|
|
|
131
144
|
- default `claim` waits in the fair queue and re-reads before invoking `work`;
|
|
132
145
|
- `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
|
|
133
146
|
- `{ maxQueueDepth }` rejects if the wait line is already too deep.
|
|
134
147
|
|
|
135
|
-
|
|
148
|
+
While waiting, schema clients learn when the claim clears from the live claim
|
|
149
|
+
stream, so they never poll.
|
|
136
150
|
|
|
137
151
|
## Errors
|
|
138
152
|
|
package/docs/coordination.md
CHANGED
|
@@ -5,28 +5,68 @@ other. Most writes need none of this — `ablo.<model>.update(id, …)` is optim
|
|
|
5
5
|
and the server rejects it if the row moved. Reach for `claim` only when you'll
|
|
6
6
|
**hold a row across a slow gap** (read → LLM call → write).
|
|
7
7
|
|
|
8
|
-
Claims
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
the
|
|
12
|
-
|
|
8
|
+
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
9
|
+
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
10
|
+
of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
|
|
11
|
+
until promoted to the head of the line — it does not fail and does not poll.
|
|
12
|
+
Reads stay open: reading a claimed row is allowed unless the caller explicitly
|
|
13
|
+
asks for claimed gating. A claim carries a TTL so a crashed holder is
|
|
14
|
+
auto-released and the queue advances.
|
|
15
|
+
|
|
16
|
+
This reference opens with [the model](#the-model--three-layers-one-decision) — the
|
|
17
|
+
one answer to "how do two agents not clobber each other" — then covers the
|
|
18
|
+
[claim state object](#the-claim-state-object), the SDK [methods](#methods)
|
|
19
|
+
(`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
|
|
20
|
+
claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The model — three layers, one decision
|
|
25
|
+
|
|
26
|
+
Ablo has exactly **three** coordination layers. They are **not** three competing
|
|
27
|
+
answers to the same question — they stack, and only one of them is a decision you
|
|
28
|
+
make:
|
|
29
|
+
|
|
30
|
+
| layer | kind | what it does | enforces? |
|
|
31
|
+
|---|---|---|---|
|
|
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
|
+
| **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
|
+
| **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
|
+
**The one decision: do you hold the row across a slow gap (read → LLM call →
|
|
37
|
+
write)?**
|
|
38
|
+
|
|
39
|
+
- **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
|
|
40
|
+
is optimistically guarded by stale-context already; it rejects with
|
|
41
|
+
`AbloStaleContextError` if the row moved under you. This is the default and
|
|
42
|
+
needs no ceremony.
|
|
43
|
+
- **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
|
|
44
|
+
excludes other participants for the duration, queues contenders fairly, and —
|
|
45
|
+
see below — your own writes under it stay stale-guarded too.
|
|
46
|
+
|
|
47
|
+
**How they compose (what wins):** If you don't hold the row, claims win — a
|
|
48
|
+
non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
|
|
49
|
+
`readAt`. If you do hold it, your own writes are still stale-checked — a row that
|
|
50
|
+
moved between your snapshot and your write still rejects with
|
|
51
|
+
`AbloStaleContextError`. With no claim held, the stale check is the only
|
|
52
|
+
protection, and it's automatic, which is why the no-claim path is safe by default.
|
|
53
|
+
Presence (`claim.state`) never decides anything — read it to render, act on the
|
|
54
|
+
errors. The two checks are independent: one rejects writes from people who don't
|
|
55
|
+
hold the claim, the other rejects writes based on a stale snapshot, and the SDK
|
|
56
|
+
adds the stale-check for you when you write under a claim, so you don't pass
|
|
57
|
+
anything extra.
|
|
18
58
|
|
|
19
59
|
---
|
|
20
60
|
|
|
21
61
|
## The claim state object
|
|
22
62
|
|
|
23
63
|
The claim state object is the live record that a participant is coordinating work on
|
|
24
|
-
a model row. It's what `
|
|
64
|
+
a model row. It's what `claim.state()` returns and what observers render.
|
|
25
65
|
|
|
26
66
|
| field | type | description |
|
|
27
67
|
|---|---|---|
|
|
28
68
|
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
29
|
-
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
|
|
69
|
+
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
|
|
30
70
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
31
71
|
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
32
72
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
@@ -36,7 +76,6 @@ a model row. It's what `claimState()` returns and what observers render.
|
|
|
36
76
|
| `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. |
|
|
37
77
|
|
|
38
78
|
```jsonc
|
|
39
|
-
// A live claim state, as returned by claimState():
|
|
40
79
|
{
|
|
41
80
|
"id": "claim_8fJ2",
|
|
42
81
|
"status": "active",
|
|
@@ -68,8 +107,8 @@ open by default. The claim acquires through the server's fair FIFO queue: if the
|
|
|
68
107
|
target is free the lease is yours immediately, and if another participant holds
|
|
69
108
|
it your claim **waits in line** and resolves only once it reaches the head —
|
|
70
109
|
then re-reads so the claimed snapshot reflects what the previous holder
|
|
71
|
-
committed. There's no
|
|
72
|
-
|
|
110
|
+
committed. There's no polling and no race window — the server decides the order,
|
|
111
|
+
so two claimers can't both think they won.
|
|
73
112
|
|
|
74
113
|
**Parameters**
|
|
75
114
|
|
|
@@ -95,7 +134,6 @@ release hook for manual scopes.
|
|
|
95
134
|
**Example**
|
|
96
135
|
|
|
97
136
|
```ts
|
|
98
|
-
// Callback form — works on any toolchain:
|
|
99
137
|
const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
100
138
|
const weather = await weatherAgent.getWeather(report.location);
|
|
101
139
|
await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
@@ -108,14 +146,14 @@ held work should use the callback form above.
|
|
|
108
146
|
|
|
109
147
|
### Claim-gated reads
|
|
110
148
|
|
|
111
|
-
`
|
|
112
|
-
`ablo.<model>.
|
|
149
|
+
`claim.state(id)` always returns immediately. Model reads such as
|
|
150
|
+
`ablo.<model>.get(id)` are local reads and stay available while a claim is
|
|
113
151
|
held. Server/model reads can choose a claimed policy:
|
|
114
152
|
|
|
115
153
|
```ts
|
|
116
154
|
await ablo.model('weatherReports').retrieve('report_stockholm', {
|
|
117
|
-
ifClaimed: 'wait',
|
|
118
|
-
claimedTimeout: 30_000,
|
|
155
|
+
ifClaimed: 'wait',
|
|
156
|
+
claimedTimeout: 30_000,
|
|
119
157
|
});
|
|
120
158
|
```
|
|
121
159
|
|
|
@@ -123,10 +161,10 @@ await ablo.model('weatherReports').retrieve('report_stockholm', {
|
|
|
123
161
|
- `ifClaimed: 'wait'` waits for the active claim to clear before reading.
|
|
124
162
|
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
125
163
|
|
|
126
|
-
### `
|
|
164
|
+
### `claim.state`
|
|
127
165
|
|
|
128
166
|
```ts
|
|
129
|
-
ablo.<model>.
|
|
167
|
+
ablo.<model>.claim.state(id)
|
|
130
168
|
```
|
|
131
169
|
|
|
132
170
|
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
@@ -144,12 +182,13 @@ is free.
|
|
|
144
182
|
**Example**
|
|
145
183
|
|
|
146
184
|
```ts
|
|
147
|
-
const who = ablo.weatherReports.
|
|
148
|
-
if (who) console.log(`${who.heldBy} is ${who.action}`);
|
|
185
|
+
const who = ablo.weatherReports.claim.state('report_stockholm');
|
|
186
|
+
if (who) console.log(`${who.heldBy} is ${who.action}`);
|
|
149
187
|
```
|
|
150
188
|
|
|
189
|
+
Returns the active claim state when the row is held, or `null` when it's free:
|
|
190
|
+
|
|
151
191
|
```jsonc
|
|
152
|
-
// Resolved value when the row is held:
|
|
153
192
|
{
|
|
154
193
|
"id": "claim_8fJ2",
|
|
155
194
|
"status": "active",
|
|
@@ -159,20 +198,19 @@ if (who) console.log(`${who.heldBy} is ${who.action}`); // 'agent:forecaster is
|
|
|
159
198
|
"participantKind": "agent",
|
|
160
199
|
"expiresAt": "1748160030000"
|
|
161
200
|
}
|
|
162
|
-
// → null when free.
|
|
163
201
|
```
|
|
164
202
|
|
|
165
|
-
### `queue`
|
|
203
|
+
### `claim.queue`
|
|
166
204
|
|
|
167
205
|
```ts
|
|
168
|
-
ablo.<model>.queue(id)
|
|
206
|
+
ablo.<model>.claim.queue(id)
|
|
169
207
|
```
|
|
170
208
|
|
|
171
209
|
Read the **wait line** behind a row — the FIFO of claims queued behind the
|
|
172
|
-
current holder, in promotion order. Like `
|
|
210
|
+
current holder, in promotion order. Like `claim.state`, it's synchronous and
|
|
173
211
|
reactive (it reads the local coordination snapshot, kept current by the server's
|
|
174
|
-
queue-mutation frames), and reading never blocks. Where `
|
|
175
|
-
holds it," `queue` answers "who's lined up next" — render "3rd in line", or
|
|
212
|
+
queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
|
|
213
|
+
holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
|
|
176
214
|
decide the wait isn't worth it.
|
|
177
215
|
|
|
178
216
|
**Parameters**
|
|
@@ -188,15 +226,15 @@ the active holder; `[]` when no one is waiting.
|
|
|
188
226
|
**Example**
|
|
189
227
|
|
|
190
228
|
```ts
|
|
191
|
-
const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
|
|
229
|
+
const { data: waiting } = ablo.weatherReports.claim.queue('report_stockholm');
|
|
192
230
|
console.log(`${waiting.length} ahead of you`);
|
|
193
|
-
console.log(waiting.map((i) => i.heldBy));
|
|
231
|
+
console.log(waiting.map((i) => i.heldBy));
|
|
194
232
|
```
|
|
195
233
|
|
|
196
|
-
### `release`
|
|
234
|
+
### `claim.release`
|
|
197
235
|
|
|
198
236
|
```ts
|
|
199
|
-
ablo.<model>.release(id): Promise<void>
|
|
237
|
+
ablo.<model>.claim.release(id): Promise<void>
|
|
200
238
|
```
|
|
201
239
|
|
|
202
240
|
Release a claim you hold. Usually **implicit** — the callback returning releases
|
|
@@ -222,13 +260,13 @@ try {
|
|
|
222
260
|
if (!ok) return; // abandon, no write
|
|
223
261
|
await ablo.weatherReports.update(report.id, { status: 'ready' });
|
|
224
262
|
} finally {
|
|
225
|
-
await ablo.weatherReports.release(report.id);
|
|
263
|
+
await ablo.weatherReports.claim.release(report.id);
|
|
226
264
|
}
|
|
227
265
|
```
|
|
228
266
|
|
|
229
267
|
### Writing under a claim
|
|
230
268
|
|
|
231
|
-
There is no separate "write" method on a claim — use the normal
|
|
269
|
+
There is no separate "write" method on a claim — use the normal
|
|
232
270
|
`ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
|
|
233
271
|
automatically stale-guarded against the snapshot the claim took (`readAt` =
|
|
234
272
|
snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
|