@abloatai/ablo 0.12.0 → 0.14.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/AGENTS.md +2 -2
- package/CHANGELOG.md +29 -0
- package/README.md +3 -3
- package/dist/BaseSyncedStore.js +39 -32
- package/dist/batching/index.d.ts +57 -0
- package/dist/batching/index.js +150 -0
- package/dist/cli.cjs +158 -40
- package/dist/client/Ablo.d.ts +16 -25
- package/dist/client/Ablo.js +1 -1
- package/dist/client/auth.js +11 -0
- package/dist/client/createModelProxy.d.ts +33 -8
- package/dist/client/createModelProxy.js +4 -4
- package/dist/errorCodes.d.ts +3 -1
- package/dist/errorCodes.js +10 -1
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +2 -2
- package/dist/schema/model.d.ts +38 -84
- package/dist/schema/model.js +12 -12
- package/dist/schema/roles.d.ts +49 -0
- package/dist/schema/roles.js +21 -0
- package/dist/schema/schema.d.ts +1 -1
- package/dist/schema/schema.js +1 -1
- package/dist/schema/serialize.d.ts +4 -2
- package/dist/schema/serialize.js +4 -2
- package/dist/schema/sugar.d.ts +7 -28
- package/dist/schema/sugar.js +2 -7
- package/dist/schema/sync-delta-row.d.ts +2 -0
- package/dist/schema/sync-delta-row.js +2 -1
- package/dist/schema/tenancy.d.ts +67 -28
- package/dist/schema/tenancy.js +93 -23
- package/dist/server/commit.d.ts +8 -3
- package/docs/api.md +7 -6
- package/docs/cli.md +43 -4
- package/docs/client-behavior.md +2 -2
- package/docs/coordination.md +12 -12
- package/docs/examples/agent-human.md +6 -6
- package/docs/examples/ai-sdk-tool.md +1 -1
- package/docs/examples/existing-python-backend.md +0 -2
- package/docs/examples/nextjs.md +2 -2
- package/docs/examples/scoped-agent.md +3 -3
- package/docs/examples/server-agent.md +4 -4
- package/docs/identity.md +27 -20
- package/docs/index.md +0 -1
- package/docs/integration-guide.md +12 -9
- package/docs/interaction-model.md +1 -1
- package/docs/mcp.md +17 -5
- package/docs/quickstart.md +3 -3
- package/docs/react.md +69 -0
- package/llms.txt +2 -3
- package/package.json +8 -2
- package/docs/mcp/claude-code.md +0 -35
- package/docs/mcp/cursor.md +0 -35
- package/docs/mcp/windsurf.md +0 -33
- package/docs/roadmap.md +0 -55
- package/docs/the-loop.md +0 -21
- package/llms-full.txt +0 -396
package/dist/schema/tenancy.js
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tenancy — the single source of truth for how a model's rows are scoped to a
|
|
3
|
-
* tenant.
|
|
4
|
-
*
|
|
5
|
-
* one Zod discriminated union, resolved in one place and consumed everywhere
|
|
6
|
-
* (provision/RLS, introspection, runtime, CLI).
|
|
3
|
+
* tenant. There are exactly two layers here, and keeping them separate is the
|
|
4
|
+
* whole point:
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* 1. The CANONICAL form — {@link Tenancy}, a Zod discriminated union. This is
|
|
7
|
+
* what every consumer (provision/RLS, introspection, runtime, CLI) reads
|
|
8
|
+
* and what crosses the wire in `ModelJSON`. One shape, exhaustively
|
|
9
|
+
* switchable, so the type system holds the isolation boundary.
|
|
10
|
+
* 2. The AUTHORING form — {@link PolicyInput}, the `policy: { by }` option a
|
|
11
|
+
* schema author writes. The name follows Postgres/Supabase RLS vocabulary:
|
|
12
|
+
* a `policy` is the rule that decides which rows a tenant may read.
|
|
13
|
+
* {@link resolvePolicy} maps it to the canonical {@link Tenancy} at
|
|
14
|
+
* `model()`-build time (much as Supabase's `create policy` compiles to a
|
|
15
|
+
* `pg_policy` row), so the authoring vocabulary never reaches the wire or
|
|
16
|
+
* any consumer.
|
|
17
|
+
*
|
|
18
|
+
* Why one authoring option (`policy`) instead of the old
|
|
19
|
+
* `orgScoped`/`scopedVia`/`orgColumn` trio: those three were synonyms for one
|
|
20
|
+
* decision ("how is this row scoped?"), and the most dangerous of them
|
|
21
|
+
* (`orgScoped: false`) silently exposed a whole table cross-tenant. Collapsing
|
|
22
|
+
* them into a single discriminated union makes the opt-out (`{ by: 'none' }`) a
|
|
23
|
+
* loud, deliberate branch instead of a falsy flag — one concept, one name.
|
|
13
24
|
*/
|
|
14
25
|
import { z } from 'zod';
|
|
15
26
|
/** Default physical tenancy column. The ONLY place this literal is canonical. */
|
|
16
27
|
export const DEFAULT_ORG_COLUMN = 'organization_id';
|
|
17
28
|
/**
|
|
18
29
|
* Scope a table's rows through a parent table (for rows that carry no tenancy
|
|
19
|
-
* column of their own — e.g. `slide_layers` → slide → deck → org).
|
|
30
|
+
* column of their own — e.g. `slide_layers` → slide → deck → org). This is the
|
|
31
|
+
* CANONICAL `parent` payload; authors write the friendlier {@link PolicyInput}
|
|
32
|
+
* `{ by: 'parent', fk, parent }` shape, normalized into this by
|
|
33
|
+
* {@link resolvePolicy}.
|
|
20
34
|
*/
|
|
21
35
|
export const scopedViaRefSchema = z.object({
|
|
22
36
|
/** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
|
|
@@ -28,7 +42,7 @@ export const scopedViaRefSchema = z.object({
|
|
|
28
42
|
/** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
|
|
29
43
|
parentOrgColumn: z.string().min(1).optional(),
|
|
30
44
|
});
|
|
31
|
-
/** How a model's rows are scoped to a tenant. */
|
|
45
|
+
/** How a model's rows are scoped to a tenant — the CANONICAL, wire-facing form. */
|
|
32
46
|
export const tenancySchema = z.discriminatedUnion('kind', [
|
|
33
47
|
/** Row-local tenancy column (default name `organization_id`, overridable). */
|
|
34
48
|
z.object({ kind: z.literal('column'), column: z.string().min(1) }),
|
|
@@ -38,19 +52,75 @@ export const tenancySchema = z.discriminatedUnion('kind', [
|
|
|
38
52
|
z.object({ kind: z.literal('none') }),
|
|
39
53
|
]);
|
|
40
54
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
55
|
+
* The AUTHORING form of tenancy — what a schema author writes as the model's
|
|
56
|
+
* `policy` option (Postgres/Supabase RLS vocabulary: a policy is the rule that
|
|
57
|
+
* scopes which rows a tenant may read). A Zod discriminated union on `by`, so
|
|
58
|
+
* the three branches are mutually exclusive and the dangerous opt-out
|
|
59
|
+
* (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
|
|
60
|
+
*
|
|
61
|
+
* - `{ by: 'column' }` — row-local tenancy column (the default).
|
|
62
|
+
* `column` overrides the name (default {@link DEFAULT_ORG_COLUMN}).
|
|
63
|
+
* - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
|
|
64
|
+
* this table has no tenancy column of its own. `parentKey` (default `'id'`)
|
|
65
|
+
* and `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are overrides.
|
|
66
|
+
* - `{ by: 'none' }` — genuinely global / reference data. ⚠ Makes
|
|
67
|
+
* the whole table readable cross-tenant — only correct for tenant-less tables.
|
|
68
|
+
*/
|
|
69
|
+
export const policyInputSchema = z.discriminatedUnion('by', [
|
|
70
|
+
z.object({
|
|
71
|
+
by: z.literal('column'),
|
|
72
|
+
/** Override the physical tenancy column name. Default {@link DEFAULT_ORG_COLUMN}. */
|
|
73
|
+
column: z.string().min(1).optional(),
|
|
74
|
+
}),
|
|
75
|
+
z.object({
|
|
76
|
+
by: z.literal('parent'),
|
|
77
|
+
/** Column on THIS table pointing at the parent (e.g. `'slideId'`). */
|
|
78
|
+
fk: z.string().min(1),
|
|
79
|
+
/** Parent table name (e.g. `'slides'`). */
|
|
80
|
+
parent: z.string().min(1),
|
|
81
|
+
/** Column on the parent that `fk` references. Default `'id'`. */
|
|
82
|
+
parentKey: z.string().min(1).optional(),
|
|
83
|
+
/** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
|
|
84
|
+
parentTenantColumn: z.string().min(1).optional(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({ by: z.literal('none') }),
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* Normalize the authoring {@link PolicyInput} into the one canonical
|
|
90
|
+
* {@link Tenancy}. Called once, at `model()`-build, so `ModelDef`/`ModelJSON`
|
|
91
|
+
* and every consumer see only the canonical union. Omitting `policy` defaults
|
|
92
|
+
* to a row-local `organization_id` column.
|
|
93
|
+
*/
|
|
94
|
+
export function resolvePolicy(input) {
|
|
95
|
+
if (!input)
|
|
96
|
+
return { kind: 'column', column: DEFAULT_ORG_COLUMN };
|
|
97
|
+
switch (input.by) {
|
|
98
|
+
case 'column':
|
|
99
|
+
return { kind: 'column', column: input.column ?? DEFAULT_ORG_COLUMN };
|
|
100
|
+
case 'parent':
|
|
101
|
+
return {
|
|
102
|
+
kind: 'parent',
|
|
103
|
+
via: {
|
|
104
|
+
localKey: input.fk,
|
|
105
|
+
parentTable: input.parent,
|
|
106
|
+
parentKey: input.parentKey,
|
|
107
|
+
parentOrgColumn: input.parentTenantColumn,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
case 'none':
|
|
111
|
+
return { kind: 'none' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Read the canonical {@link Tenancy} off an already-built model def (or parsed
|
|
116
|
+
* `ModelJSON`), defaulting to a row-local `organization_id` column when absent.
|
|
117
|
+
*
|
|
118
|
+
* This is the READ-side helper — consumers (provision/RLS, membership resolver,
|
|
119
|
+
* DDL, CLI) call it to get a model's tenancy without re-deriving the default in
|
|
120
|
+
* each place. It is NOT the authoring normalizer; that's {@link resolveIsolation}.
|
|
45
121
|
*/
|
|
46
|
-
export function resolveTenancy(
|
|
47
|
-
|
|
48
|
-
return input.tenancy;
|
|
49
|
-
if (input.scopedVia)
|
|
50
|
-
return { kind: 'parent', via: input.scopedVia };
|
|
51
|
-
if (input.orgScoped === false)
|
|
52
|
-
return { kind: 'none' };
|
|
53
|
-
return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
|
|
122
|
+
export function resolveTenancy(def) {
|
|
123
|
+
return def.tenancy ?? { kind: 'column', column: DEFAULT_ORG_COLUMN };
|
|
54
124
|
}
|
|
55
125
|
/** The physical tenancy column for a column-scoped model, else `null`. */
|
|
56
126
|
export function tenancyColumn(t) {
|
package/dist/server/commit.d.ts
CHANGED
|
@@ -58,11 +58,16 @@ export interface CommitContext {
|
|
|
58
58
|
*/
|
|
59
59
|
onBehalfOf?: ParticipantRef | null;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* delta so the audit chain "delta → capability → human" is one FK hop.
|
|
61
|
+
* Scoped credential id. Non-null for agent / system commits when the
|
|
62
|
+
* authorizing credential is known; null for human-direct commits.
|
|
64
63
|
*/
|
|
65
64
|
capabilityId?: string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Human user id at the root of the delegated authority chain. Stored directly
|
|
67
|
+
* on `sync_deltas` so audit triggers never need to join mutable credential
|
|
68
|
+
* tables while appending the hash chain.
|
|
69
|
+
*/
|
|
70
|
+
delegationChainRootUserId?: string | null;
|
|
66
71
|
/**
|
|
67
72
|
* ApiKey row id when the caller authenticated with an API key. Used by the
|
|
68
73
|
* idempotency cache and usage attribution. Null for session / capability
|
package/docs/api.md
CHANGED
|
@@ -124,10 +124,11 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
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
|
+
| `reason` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
|
|
128
128
|
| `heldBy` | string | Participant id holding the claim. |
|
|
129
129
|
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
130
|
-
| `
|
|
130
|
+
| `createdAt` | number? | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
131
|
+
| `expiresAt` | number | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
|
|
131
132
|
|
|
132
133
|
```json
|
|
133
134
|
{
|
|
@@ -135,10 +136,10 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
135
136
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
136
137
|
"status": "active",
|
|
137
138
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
138
|
-
"
|
|
139
|
+
"reason": "editing",
|
|
139
140
|
"heldBy": "agent:report-writer",
|
|
140
141
|
"participantKind": "agent",
|
|
141
|
-
"expiresAt":
|
|
142
|
+
"expiresAt": 1716580000000
|
|
142
143
|
}
|
|
143
144
|
```
|
|
144
145
|
|
|
@@ -175,12 +176,12 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
|
|
|
175
176
|
const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
176
177
|
if (claim) {
|
|
177
178
|
claim.heldBy;
|
|
178
|
-
claim.
|
|
179
|
+
claim.reason;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
const handle = await ablo.weatherReports.claim({
|
|
182
183
|
id: 'report_stockholm',
|
|
183
|
-
|
|
184
|
+
reason: 'editing',
|
|
184
185
|
ttl: '2m',
|
|
185
186
|
});
|
|
186
187
|
await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
|
package/docs/cli.md
CHANGED
|
@@ -24,18 +24,23 @@ direct-connector commands are tagged **Direct Postgres**.
|
|
|
24
24
|
|
|
25
25
|
`ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
|
|
26
26
|
**log in** or **create an account** and approve, and the CLI provisions a
|
|
27
|
-
**test + live key pair** (90-day
|
|
28
|
-
|
|
27
|
+
**test + live key pair** (90-day) and stores them locally. The test key is a
|
|
28
|
+
sandbox `sk_test_` key; the live key is a restricted `rk_live_` key (read-only
|
|
29
|
+
observation — `logs`, `status`), so a stolen config can't write to production.
|
|
30
|
+
This mirrors `stripe login`.
|
|
29
31
|
|
|
30
32
|
| Command | What it does |
|
|
31
33
|
| ------------------------ | -------------------------------------------------------------------------- |
|
|
32
34
|
| `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
|
|
35
|
+
| `ablo login --project <slug>` | Same, but scope (and mint) the pair to a project, and make it active. |
|
|
33
36
|
| `ablo logout` | Remove the stored keys. |
|
|
34
37
|
| `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
|
|
35
38
|
| `ablo mode [sandbox\|production]` | Switch the active environment. With no argument, prompts. |
|
|
36
39
|
|
|
37
|
-
Keys
|
|
38
|
-
|
|
40
|
+
Keys live in `~/.config/ablo/credentials.json` (mode `0600`), keyed by project
|
|
41
|
+
then environment; the non-secret `config.json` holds only the active mode and
|
|
42
|
+
project. In **CI**, don't log in — set `ABLO_API_KEY`, which always overrides
|
|
43
|
+
the stored key.
|
|
39
44
|
|
|
40
45
|
## Test vs live
|
|
41
46
|
|
|
@@ -47,6 +52,39 @@ the sandbox by design.
|
|
|
47
52
|
The schema, however, is **shared** across the org — pushing a schema (from
|
|
48
53
|
either environment) defines the same models sandbox and production see; only the rows differ.
|
|
49
54
|
|
|
55
|
+
## Projects
|
|
56
|
+
|
|
57
|
+
An org can have multiple **projects**, each with its own isolated keys, schema,
|
|
58
|
+
and data. Keys are scoped to a project **at mint** and never re-scoped, so the
|
|
59
|
+
CLI keeps a separate credential profile per project — Stripe's
|
|
60
|
+
`login --project-name` model. The active project (set with `projects use`)
|
|
61
|
+
selects which profile every command authenticates with.
|
|
62
|
+
|
|
63
|
+
| Command | What it does |
|
|
64
|
+
| ----------------------------- | ---------------------------------------------------------------------------------- |
|
|
65
|
+
| `ablo projects list` | List the org's projects (marks the active one and the org-default). |
|
|
66
|
+
| `ablo projects create <slug>` | Create a project (`--name "Display Name"`). Its keys/schema/data are isolated. |
|
|
67
|
+
| `ablo projects use <slug>` | Switch the active project. `ablo projects use default` returns to the org-default. |
|
|
68
|
+
| `ablo login --project <slug>` | Mint and store a key pair for a project, and make it active. |
|
|
69
|
+
|
|
70
|
+
Because keys are fixed to a project, `projects use` only changes which profile
|
|
71
|
+
is active — it never re-scopes an existing key. Switch to a project you haven't
|
|
72
|
+
logged into yet and the CLI tells you to mint one:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx ablo projects use war-room
|
|
76
|
+
# ✓ now targeting project war-room (prj_…)
|
|
77
|
+
# No key stored for this project yet — run `ablo login --project war-room` to mint one.
|
|
78
|
+
|
|
79
|
+
npx ablo login --project war-room # mints + stores its key pair, keeps it active
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If you run a project-scoped command (`push`, `dev`) while the active project has
|
|
83
|
+
no key — but other projects do — the CLI **refuses** rather than silently
|
|
84
|
+
deploying with the wrong project's credential, and names the fix
|
|
85
|
+
(`ablo login --project <slug>`). In CI, an explicit `ABLO_API_KEY` bypasses
|
|
86
|
+
profiles entirely: it acts in whatever project it was minted for.
|
|
87
|
+
|
|
50
88
|
## Commands
|
|
51
89
|
|
|
52
90
|
| Command | What it does | Flags |
|
|
@@ -54,6 +92,7 @@ either environment) defines the same models sandbox and production see; only the
|
|
|
54
92
|
| `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
|
|
55
93
|
| `ablo login` / `logout` / `status` | Authentication & status (above). | — |
|
|
56
94
|
| `ablo mode [sandbox\|production]` | Switch active environment. | — |
|
|
95
|
+
| `ablo projects list\|create\|use\|rename` | Manage projects and the active one (see [Projects](#projects)). Each project's keys/schema/data are isolated. | `--name "<display>"` (create/rename) |
|
|
57
96
|
| `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>` |
|
|
58
97
|
| `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 sandbox\|production` |
|
|
59
98
|
| `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` |
|
package/docs/client-behavior.md
CHANGED
|
@@ -146,7 +146,7 @@ the latest row, then hands you the fresh row — so you can't overwrite a change
|
|
|
146
146
|
see. Options on the claim:
|
|
147
147
|
|
|
148
148
|
- default `claim` waits in the fair queue and re-reads before handing you the row;
|
|
149
|
-
- `{
|
|
149
|
+
- `{ queue: false }` rejects with `AbloClaimedError` instead of queuing;
|
|
150
150
|
- `{ maxQueueDepth }` rejects if the wait line is already too deep.
|
|
151
151
|
|
|
152
152
|
While waiting, schema clients learn when the claim clears from the live claim
|
|
@@ -166,7 +166,7 @@ All SDK errors extend `AbloError` and carry a stable `type`.
|
|
|
166
166
|
| `AbloValidationError` | Invalid input or unsupported request shape. |
|
|
167
167
|
| `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
|
|
168
168
|
| `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
|
|
169
|
-
| `AbloClaimedError` | An active claim conflicted with `{
|
|
169
|
+
| `AbloClaimedError` | An active claim conflicted with `{ queue: false }`, the queue was too deep, or a claim wait timed out. |
|
|
170
170
|
|
|
171
171
|
```ts
|
|
172
172
|
import { AbloClaimedError } from '@abloatai/ablo';
|
package/docs/coordination.md
CHANGED
|
@@ -72,23 +72,23 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
72
72
|
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
73
73
|
| `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). |
|
|
74
74
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
75
|
-
| `
|
|
75
|
+
| `reason` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
|
|
76
76
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
77
77
|
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
78
78
|
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
79
|
-
| `createdAt` | `
|
|
80
|
-
| `expiresAt` | `
|
|
79
|
+
| `createdAt` | `number?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
80
|
+
| `expiresAt` | `number` | 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. |
|
|
81
81
|
|
|
82
82
|
```jsonc
|
|
83
83
|
{
|
|
84
84
|
"id": "claim_8fJ2",
|
|
85
85
|
"status": "active",
|
|
86
86
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
87
|
-
"
|
|
87
|
+
"reason": "editing",
|
|
88
88
|
"heldBy": "agent:forecaster",
|
|
89
89
|
"participantKind": "agent",
|
|
90
|
-
"createdAt":
|
|
91
|
-
"expiresAt":
|
|
90
|
+
"createdAt": 1748160000000,
|
|
91
|
+
"expiresAt": 1748160030000
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -129,9 +129,9 @@ so two claimers can't both think they won.
|
|
|
129
129
|
| name | type | required | description |
|
|
130
130
|
|---|---|---|---|
|
|
131
131
|
| `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
|
|
132
|
-
| `options.
|
|
132
|
+
| `options.reason` | `string` | no | Phase shown to observers (default `'editing'`). Serialized on the wire as `action`. |
|
|
133
133
|
| `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
|
|
134
|
-
| `options.
|
|
134
|
+
| `options.queue` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
|
|
135
135
|
| `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
|
|
136
136
|
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
137
137
|
|
|
@@ -209,7 +209,7 @@ is free.
|
|
|
209
209
|
|
|
210
210
|
```ts
|
|
211
211
|
const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
212
|
-
if (who) console.log(`${who.heldBy} is ${who.
|
|
212
|
+
if (who) console.log(`${who.heldBy} is ${who.reason}`);
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
Returns the active claim state when the row is held, or `null` when it's free:
|
|
@@ -219,10 +219,10 @@ Returns the active claim state when the row is held, or `null` when it's free:
|
|
|
219
219
|
"id": "claim_8fJ2",
|
|
220
220
|
"status": "active",
|
|
221
221
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
222
|
-
"
|
|
222
|
+
"reason": "editing",
|
|
223
223
|
"heldBy": "agent:forecaster",
|
|
224
224
|
"participantKind": "agent",
|
|
225
|
-
"expiresAt":
|
|
225
|
+
"expiresAt": 1748160030000
|
|
226
226
|
}
|
|
227
227
|
```
|
|
228
228
|
|
|
@@ -280,7 +280,7 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
|
|
|
280
280
|
**Example**
|
|
281
281
|
|
|
282
282
|
```ts
|
|
283
|
-
const claim = await ablo.weatherReports.claim({ id: 'report_stockholm',
|
|
283
|
+
const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', reason: 'reviewing' });
|
|
284
284
|
const report = claim.data;
|
|
285
285
|
try {
|
|
286
286
|
const ok = await reviewExternally(report);
|
|
@@ -47,14 +47,14 @@ export async function markReady(reportId: string) {
|
|
|
47
47
|
if (!report) return { status: 'not_found' };
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
|
-
//
|
|
50
|
+
// queue: false → don't queue behind a current holder. If a human already
|
|
51
51
|
// holds the row, claim rejects with AbloClaimedError (caught below), so the
|
|
52
|
-
// agent yields instead of waiting. Omit it, or pass
|
|
53
|
-
// behind them.
|
|
52
|
+
// agent yields instead of waiting. Omit it, or pass queue: true, to queue
|
|
53
|
+
// behind them. reason → the label observers see while we work.
|
|
54
54
|
await using claim = await ablo.weatherReports.claim({
|
|
55
55
|
id: reportId,
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
queue: false,
|
|
57
|
+
reason: 'marking_ready',
|
|
58
58
|
});
|
|
59
59
|
const claimed = claim.data;
|
|
60
60
|
|
|
@@ -120,7 +120,7 @@ export function ReportRow({ report: serverReport }: Props) {
|
|
|
120
120
|
- The claim is visible to everyone: the UI reads it synchronously with
|
|
121
121
|
`claim.state({ id })`, and it also arrives over the live stream.
|
|
122
122
|
- `claim({ id })` makes writers take turns instead of racing — with
|
|
123
|
-
`
|
|
123
|
+
`queue: false`, the agent simply yields when a human already holds the row.
|
|
124
124
|
- The `update` made while the claim is held is stale-checked automatically, so a human's
|
|
125
125
|
edit landing mid-run rejects the agent's write with a typed
|
|
126
126
|
`AbloStaleContextError` instead of overwriting it.
|
|
@@ -43,7 +43,7 @@ const updateReport = tool({
|
|
|
43
43
|
// The claim is released automatically when it goes out of scope.
|
|
44
44
|
await using claim = await ablo.weatherReports.claim({
|
|
45
45
|
id: reportId,
|
|
46
|
-
|
|
46
|
+
reason: 'editing',
|
|
47
47
|
ttl: '2m',
|
|
48
48
|
});
|
|
49
49
|
const claimed = claim.data;
|
|
@@ -37,10 +37,8 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
|
37
37
|
|
|
38
38
|
export const schema = defineSchema({
|
|
39
39
|
weatherReports: model({
|
|
40
|
-
id: z.string(),
|
|
41
40
|
location: z.string(),
|
|
42
41
|
status: z.enum(['pending', 'ready']),
|
|
43
|
-
updatedAt: z.string(),
|
|
44
42
|
}),
|
|
45
43
|
});
|
|
46
44
|
```
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -57,8 +57,8 @@ import { ablo } from '@/lib/ablo';
|
|
|
57
57
|
export async function markReady(id: string) {
|
|
58
58
|
await using claim = await ablo.weatherReports.claim({
|
|
59
59
|
id,
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
queue: false,
|
|
61
|
+
reason: 'marking_ready',
|
|
62
62
|
});
|
|
63
63
|
const claimed = claim.data;
|
|
64
64
|
|
|
@@ -19,18 +19,18 @@ import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/s
|
|
|
19
19
|
|
|
20
20
|
export const schema = defineSchema(
|
|
21
21
|
{
|
|
22
|
-
// A deck's rows form the group `deck:<id>` (the kind comes from `
|
|
22
|
+
// A deck's rows form the group `deck:<id>` (the kind comes from `groups.root`).
|
|
23
23
|
decks: model(
|
|
24
24
|
{ title: z.string() },
|
|
25
25
|
{},
|
|
26
|
-
{
|
|
26
|
+
{ groups: { root: 'deck' } },
|
|
27
27
|
),
|
|
28
28
|
// A slide has no group of its own. It inherits its deck's group via the
|
|
29
29
|
// `parent` edge, so a slide write reaches everyone viewing the deck.
|
|
30
30
|
slides: model(
|
|
31
31
|
{ deckId: z.string(), body: z.string() },
|
|
32
32
|
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
33
|
-
{
|
|
33
|
+
{},
|
|
34
34
|
),
|
|
35
35
|
},
|
|
36
36
|
{
|
|
@@ -38,8 +38,8 @@ export async function completeReport(reportId: string) {
|
|
|
38
38
|
|
|
39
39
|
await using claim = await ablo.weatherReports.claim({
|
|
40
40
|
id: reportId,
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
queue: false,
|
|
42
|
+
reason: 'completing',
|
|
43
43
|
});
|
|
44
44
|
const claimed = claim.data;
|
|
45
45
|
|
|
@@ -60,9 +60,9 @@ the server has accepted it.
|
|
|
60
60
|
|
|
61
61
|
The two options on the claim:
|
|
62
62
|
|
|
63
|
-
- `
|
|
63
|
+
- `queue: false` — skip this record if another claim is already in progress,
|
|
64
64
|
rather than queueing behind it. (The default queues.)
|
|
65
|
-
- `
|
|
65
|
+
- `reason: 'completing'` — a human-readable label for what your worker is doing,
|
|
66
66
|
visible to anyone reading `claim.state({ id })`.
|
|
67
67
|
|
|
68
68
|
Because the worker uses the same schema and `claim()` as the UI, its writes sync
|
package/docs/identity.md
CHANGED
|
@@ -47,18 +47,20 @@ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/s
|
|
|
47
47
|
|
|
48
48
|
export const schema = defineSchema(
|
|
49
49
|
{
|
|
50
|
-
// A scope root: its rows form the group `deck:<id>` (kind from `
|
|
50
|
+
// A scope root: its rows form the group `deck:<id>` (kind from `groups.root`).
|
|
51
|
+
// Tenant isolation defaults to a row-local `organization_id` column, so no
|
|
52
|
+
// `policy` is needed here.
|
|
51
53
|
decks: model(
|
|
52
54
|
{ title: z.string(), status: z.enum(['draft', 'published']) },
|
|
53
55
|
{},
|
|
54
|
-
{
|
|
56
|
+
{ groups: { root: 'deck' } },
|
|
55
57
|
),
|
|
56
58
|
// A child: it has no group of its own; it inherits its deck's group via the
|
|
57
59
|
// `parent` edge. A write to a slide reaches everyone viewing the deck.
|
|
58
60
|
slides: model(
|
|
59
61
|
{ deckId: z.string() },
|
|
60
62
|
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
61
|
-
{
|
|
63
|
+
{},
|
|
62
64
|
),
|
|
63
65
|
},
|
|
64
66
|
{
|
|
@@ -208,13 +210,13 @@ You never write a sync-group string for a row. You declare a model's *place* in
|
|
|
208
210
|
the entity graph and the engine derives the groups its rows fan out on. Three
|
|
209
211
|
declarations, in order of how often you reach for them:
|
|
210
212
|
|
|
211
|
-
**`
|
|
212
|
-
The kind comes from the model's `typename` by default, or pass a string to
|
|
213
|
-
it explicitly (use the string form when the wire kind differs from the
|
|
214
|
-
e.g. typename `SlideDeck` but group `deck:<id>`):
|
|
213
|
+
**`groups.root` — this model is a scope root.** Its rows form a group of their
|
|
214
|
+
own. The kind comes from the model's `typename` by default, or pass a string to
|
|
215
|
+
set it explicitly (use the string form when the wire kind differs from the
|
|
216
|
+
typename, e.g. typename `SlideDeck` but group `deck:<id>`):
|
|
215
217
|
|
|
216
218
|
```ts
|
|
217
|
-
decks: model({ title: z.string() }, {}, {
|
|
219
|
+
decks: model({ title: z.string() }, {}, { groups: { root: 'deck' } });
|
|
218
220
|
// a deck row → group `deck:<id>`
|
|
219
221
|
```
|
|
220
222
|
|
|
@@ -232,7 +234,7 @@ slides: model(
|
|
|
232
234
|
deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
|
|
233
235
|
sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
|
|
234
236
|
},
|
|
235
|
-
{
|
|
237
|
+
{}, // default policy: row-local organization_id
|
|
236
238
|
);
|
|
237
239
|
```
|
|
238
240
|
|
|
@@ -241,8 +243,8 @@ slides: model(
|
|
|
241
243
|
> some required FKs are mere references. Containment is a fact only you know, so
|
|
242
244
|
> it's declared, exactly as it is in OpenFGA/Zanzibar.
|
|
243
245
|
|
|
244
|
-
**`grants` — a membership edge.** On a join model (e.g. `dataroomMember`),
|
|
245
|
-
says "this row grants a *subject* access to a *scope root*." Both are relation
|
|
246
|
+
**`groups.grants` — a membership edge.** On a join model (e.g. `dataroomMember`),
|
|
247
|
+
it says "this row grants a *subject* access to a *scope root*." Both are relation
|
|
246
248
|
names on the model. The server resolves it at connect time — for user `U`, it
|
|
247
249
|
finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
|
|
248
250
|
set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
|
|
@@ -255,15 +257,19 @@ dataroomMember: model(
|
|
|
255
257
|
member: relation.belongsTo('users', 'userId'),
|
|
256
258
|
room: relation.belongsTo('datarooms', 'dataroomId'),
|
|
257
259
|
},
|
|
258
|
-
{
|
|
260
|
+
{ groups: { grants: { subject: 'member', scope: 'room' } } },
|
|
259
261
|
);
|
|
260
262
|
```
|
|
261
263
|
|
|
262
264
|
For the rare group keyed on a plain field rather than a relation (per-recipient
|
|
263
|
-
inbox fan-out, say), there's
|
|
265
|
+
inbox fan-out, say), there's a `groups: { roles: [entityRole({ kind, source })] }`
|
|
264
266
|
escape hatch. For rows that inherit *tenancy* (not a sync group) through a
|
|
265
|
-
foreign key without carrying `organization_id`, use `
|
|
266
|
-
|
|
267
|
+
foreign key without carrying `organization_id`, use `policy: { by: 'parent', fk,
|
|
268
|
+
parent }` rather than opting out of isolation. The old `orgScoped: false`
|
|
269
|
+
exposed the whole table cross-tenant, so `validate_schema` rejects the removed
|
|
270
|
+
options as `tenancy-option-removed` errors and steers you to `policy: { by:
|
|
271
|
+
'parent' }` (FK inheritance) or, for genuinely global reference data, the
|
|
272
|
+
explicit `policy: { by: 'none' }`. See
|
|
267
273
|
`packages/sync-engine/src/schema/model.ts` for the full option set.
|
|
268
274
|
|
|
269
275
|
## How identity reaches Ablo — the proxy model
|
|
@@ -393,8 +399,8 @@ an entity anchor on the models an agent operates on:
|
|
|
393
399
|
|
|
394
400
|
```ts
|
|
395
401
|
// each scope-root model an agent edits forms a per-entity group
|
|
396
|
-
documents: model({ /* … */ }, {}, {
|
|
397
|
-
decks: model({ /* … */ }, {}, {
|
|
402
|
+
documents: model({ /* … */ }, {}, { groups: { root: 'document' } }),
|
|
403
|
+
decks: model({ /* … */ }, {}, { groups: { root: 'deck' } }),
|
|
398
404
|
```
|
|
399
405
|
|
|
400
406
|
Then a run subscribes only to the entity groups for the rows it works on — a
|
|
@@ -484,9 +490,10 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
484
490
|
(it returns a participant handle with `.peers`). See
|
|
485
491
|
[Coordination](./coordination.md).
|
|
486
492
|
|
|
487
|
-
> **`
|
|
488
|
-
> in `model(...)` declares a scope root
|
|
489
|
-
>
|
|
493
|
+
> **`groups.root` is the schema model option, not a client setting.**
|
|
494
|
+
> `groups: { root: 'deck' }` in `model(...)` declares a scope root
|
|
495
|
+
> ([Half 2](#half-2--per-model-scope-row--group)) — it names the group
|
|
496
|
+
> (`deck:<id>`) that the mechanisms above then subscribe to.
|
|
490
497
|
> There is no `Ablo({ scope })` constructor option. The lifecycle filter on
|
|
491
498
|
> [`list()`](./api.md#model-methods) is a separate axis named **`state`**
|
|
492
499
|
> (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
|
package/docs/index.md
CHANGED
|
@@ -99,4 +99,3 @@ Three things stay true no matter how you use Ablo:
|
|
|
99
99
|
- [README](../README.md) — product overview and first example.
|
|
100
100
|
- [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
|
|
101
101
|
- [Changelog](../CHANGELOG.md) — what shipped recently.
|
|
102
|
-
- [Roadmap](./roadmap.md) — what's planned next.
|