@abloatai/ablo 0.51.0 → 0.53.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 +1 -1
- package/CHANGELOG.md +220 -26
- package/NOTICE +1 -1
- package/docs/agents.md +28 -28
- package/docs/api-keys.md +30 -3
- package/docs/api.md +32 -3
- package/docs/cli.md +3 -3
- package/docs/client-behavior.md +1 -1
- package/docs/concurrency-convention.md +7 -7
- package/docs/context.md +11 -11
- package/docs/coordination.md +19 -19
- package/docs/customer-organizations.md +215 -0
- package/docs/data-sources.md +3 -3
- package/docs/debugging.md +8 -8
- package/docs/examples/agent-human.md +17 -17
- package/docs/examples/ai-sdk-tool.md +6 -6
- package/docs/examples/existing-python-backend.md +1 -1
- package/docs/examples/nextjs.md +15 -15
- package/docs/examples/scoped-agent.md +10 -10
- package/docs/examples/server-agent.md +14 -14
- package/docs/groups.md +12 -12
- package/docs/how-it-works.md +7 -7
- package/docs/idempotency.md +4 -4
- package/docs/identity.md +45 -38
- package/docs/index.md +2 -1
- package/docs/integration-guide.md +1 -1
- package/docs/integrations/inngest.md +2 -2
- package/docs/integrations/temporal.md +3 -3
- package/docs/integrations.md +3 -3
- package/docs/react.md +4 -4
- package/docs/sessions.md +21 -16
- package/docs/webhooks.md +2 -2
- package/examples/README.md +6 -6
- package/examples/agent-turn.ts +13 -13
- package/examples/data-source/README.md +3 -3
- package/examples/data-source/customer-server.ts +23 -23
- package/examples/data-source/run.ts +8 -8
- package/examples/data-source/schema.ts +2 -2
- package/examples/lease-outlives-the-machine.ts +66 -0
- package/examples/tsconfig.json +4 -10
- package/llms.txt +11 -0
- package/package.json +4 -4
package/docs/sessions.md
CHANGED
|
@@ -13,13 +13,13 @@ One resource mints both:
|
|
|
13
13
|
// A logged-in person's browser session — only the operations this UI needs.
|
|
14
14
|
const userSession = await ablo.sessions.create({
|
|
15
15
|
user: { id: currentUser.id },
|
|
16
|
-
can: {
|
|
16
|
+
can: { records: ['read', 'update'], workspaces: ['read'] },
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
// Recommended agent path — returns a ready, scoped client.
|
|
20
20
|
const agent = await ablo.agents.create({
|
|
21
|
-
name: '
|
|
22
|
-
can: {
|
|
21
|
+
name: 'record-writer',
|
|
22
|
+
can: { records: ['read', 'update'], workspaces: ['read'] },
|
|
23
23
|
});
|
|
24
24
|
```
|
|
25
25
|
|
|
@@ -51,7 +51,7 @@ import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
|
|
|
51
51
|
|
|
52
52
|
const { token, expiresAt } = await ablo.sessions.create({
|
|
53
53
|
user: { id: currentUser.id }, // who the session acts as
|
|
54
|
-
can: {
|
|
54
|
+
can: { records: ['read', 'update'] },
|
|
55
55
|
// syncGroups: [...], // optional; defaults to the user's org + user
|
|
56
56
|
});
|
|
57
57
|
return Response.json(
|
|
@@ -104,18 +104,18 @@ error, not a silent over-grant:
|
|
|
104
104
|
|
|
105
105
|
```ts
|
|
106
106
|
const agent = await ablo.agents.create({
|
|
107
|
-
name: '
|
|
108
|
-
can: {
|
|
107
|
+
name: 'record-writer',
|
|
108
|
+
can: { records: ['update'] }, // typed off the schema — no magic strings
|
|
109
109
|
ttlSeconds: 600,
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
await agent.
|
|
112
|
+
await agent.records.update({ id, data });
|
|
113
113
|
await agent.dispose();
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
The returned client refreshes its own short-lived credential. A write grant
|
|
117
117
|
automatically includes the corresponding read, so
|
|
118
|
-
`can: {
|
|
118
|
+
`can: { records: ['update'] }` is enforced as `record.update` plus `record.read`.
|
|
119
119
|
Operations are `'read' | 'create' | 'update' | 'delete'`.
|
|
120
120
|
|
|
121
121
|
For a reusable grant, use TypeScript's `satisfies`. It checks the object against
|
|
@@ -126,14 +126,14 @@ second permission model:
|
|
|
126
126
|
import type { CapabilityGrant } from '@abloatai/ablo/auth';
|
|
127
127
|
import { schema } from './ablo.schema';
|
|
128
128
|
|
|
129
|
-
const
|
|
130
|
-
|
|
129
|
+
const recordWriterCan = {
|
|
130
|
+
records: ['update'],
|
|
131
131
|
} satisfies CapabilityGrant<typeof schema>;
|
|
132
132
|
|
|
133
|
-
const agent = await ablo.agents.create({ can:
|
|
133
|
+
const agent = await ablo.agents.create({ can: recordWriterCan });
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
`documents` instead of `
|
|
136
|
+
`documents` instead of `records`, or `'write'` instead of `'update'`, is a compile
|
|
137
137
|
error. At runtime the SDK parses the same grant with the schema-bound Zod
|
|
138
138
|
contract before minting, and the server validates it again against the active
|
|
139
139
|
pushed schema.
|
|
@@ -154,6 +154,8 @@ for the actor.
|
|
|
154
154
|
|---|---|---|
|
|
155
155
|
| `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. |
|
|
156
156
|
| `can` | both | Required non-empty per-model operation allowlist, typed off the schema. |
|
|
157
|
+
| `organizationId` | user | Mint into a customer organization instead of the key's own. Requires `ephemeral:mint-any-org`. |
|
|
158
|
+
| `schemaProject` | user | Override the schema project for a cross-org mint. Usually omitted because the owning key's project is the default. |
|
|
157
159
|
| `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
|
|
158
160
|
| `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
|
|
159
161
|
| `userMeta` | both | Opaque identity blob echoed back to the client. |
|
|
@@ -217,7 +219,7 @@ errors.
|
|
|
217
219
|
|
|
218
220
|
A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
|
|
219
221
|
derived from the identity you minted it for. **Dynamic, relation-driven
|
|
220
|
-
membership** (e.g. a `
|
|
222
|
+
membership** (e.g. a `archive:<id>` the user was just added to) is resolved
|
|
221
223
|
**server-side at connect** and unioned on top — so scope stays live, not frozen
|
|
222
224
|
at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
|
|
223
225
|
|
|
@@ -255,6 +257,9 @@ deny reads. Do not use scope roots as a tenant security boundary unless every
|
|
|
255
257
|
model declares the matching policy. If that invariant is difficult to audit,
|
|
256
258
|
use one organization per customer.
|
|
257
259
|
|
|
260
|
+
For the complete key, backend-route, browser, lifecycle, and troubleshooting
|
|
261
|
+
flow, see [Customer Organizations](./customer-organizations.md).
|
|
262
|
+
|
|
258
263
|
The problem that creates: if each customer is a separate org, a naïve setup would
|
|
259
264
|
make you re-push your schema into every new customer's org. You don't have to.
|
|
260
265
|
Keep **one** project as the home of your schema. When its key mints into another
|
|
@@ -266,7 +271,7 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_PLATFORM_KEY });
|
|
|
266
271
|
const { token } = await ablo.sessions.create({
|
|
267
272
|
user: { id: userId },
|
|
268
273
|
organizationId, // DATA → this customer's isolated org
|
|
269
|
-
can: {
|
|
274
|
+
can: { records: ['read', 'update'] },
|
|
270
275
|
ttlSeconds: 3600,
|
|
271
276
|
});
|
|
272
277
|
```
|
|
@@ -281,8 +286,8 @@ plane (connection + row-level isolation) stays the customer's. A shared schema
|
|
|
281
286
|
can't leak data across orgs.
|
|
282
287
|
|
|
283
288
|
<Note>
|
|
284
|
-
This requires a
|
|
285
|
-
only a trusted
|
|
289
|
+
This requires a dedicated `sk_` carrying the `ephemeral:mint-any-org` scope —
|
|
290
|
+
only a trusted cross-organization key can mint a session into another org. Omit
|
|
286
291
|
`organizationId` and you get the default above: one project, one schema, all
|
|
287
292
|
your users in the key's own organization.
|
|
288
293
|
</Note>
|
package/docs/webhooks.md
CHANGED
|
@@ -39,7 +39,7 @@ Every delivery is a batch of events. Each event:
|
|
|
39
39
|
|
|
40
40
|
| field | meaning |
|
|
41
41
|
|---|---|
|
|
42
|
-
| `type` | `"<model>.<verb>"` with the model name lowercased, e.g. `
|
|
42
|
+
| `type` | `"<model>.<verb>"` with the model name lowercased, e.g. `record.updated` |
|
|
43
43
|
| `model` | the model name exactly as declared in your schema: the table to write |
|
|
44
44
|
| `objectId` | the changed row's id |
|
|
45
45
|
| `data` | the post-change row, or `null` on delete |
|
|
@@ -127,7 +127,7 @@ Scope which models fire and label the endpoint at creation with `--events` and
|
|
|
127
127
|
|
|
128
128
|
```bash
|
|
129
129
|
npx ablo webhooks create https://yourapp.com/api/ablo/[...all] \
|
|
130
|
-
--events
|
|
130
|
+
--events record,project --description "prod mirror"
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
Manage and inspect endpoints:
|
package/examples/README.md
CHANGED
|
@@ -30,13 +30,13 @@ For read-reason-write work, pass the exact returned rows that informed the
|
|
|
30
30
|
decision. Their watermarks stay opaque:
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
|
-
const
|
|
33
|
+
const record = await ablo.records.get({ id: recordId });
|
|
34
34
|
const policy = await ablo.policies.get({ id: policyId });
|
|
35
|
-
const result = await model({
|
|
36
|
-
await ablo.
|
|
37
|
-
id:
|
|
35
|
+
const result = await model({ record, policy });
|
|
36
|
+
await ablo.records.update({
|
|
37
|
+
id: record.id,
|
|
38
38
|
data: result,
|
|
39
|
-
reads: [
|
|
39
|
+
reads: [record, policy],
|
|
40
40
|
});
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -61,7 +61,7 @@ root and a bare `quickstart.ts` won't be found.
|
|
|
61
61
|
```bash
|
|
62
62
|
cd packages/ablo
|
|
63
63
|
ABLO_API_KEY=sk_... npx tsx examples/quickstart.ts
|
|
64
|
-
ABLO_API_KEY=sk_...
|
|
64
|
+
ABLO_API_KEY=sk_... RECORD_ID=record_... npx tsx examples/agent-turn.ts
|
|
65
65
|
ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/expensive-agent-turn.ts
|
|
66
66
|
```
|
|
67
67
|
|
package/examples/agent-turn.ts
CHANGED
|
@@ -2,36 +2,36 @@
|
|
|
2
2
|
* Canonical cheap turn: explicitly attach the exact rows used to decide the
|
|
3
3
|
* write. Ablo keeps their watermarks opaque and checks them at commit time.
|
|
4
4
|
*
|
|
5
|
-
* Run: ABLO_API_KEY=sk_...
|
|
5
|
+
* Run: ABLO_API_KEY=sk_... RECORD_ID=record_... npx tsx examples/agent-turn.ts
|
|
6
6
|
*/
|
|
7
7
|
import { Ablo } from '@abloatai/ablo';
|
|
8
8
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
9
9
|
|
|
10
10
|
const schema = defineSchema({
|
|
11
|
-
|
|
11
|
+
records: model({
|
|
12
12
|
title: z.string(),
|
|
13
13
|
status: z.enum(['pending', 'done']),
|
|
14
14
|
result: z.string().optional(),
|
|
15
15
|
}),
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
18
|
+
const recordId = process.env.RECORD_ID;
|
|
19
|
+
if (!recordId) throw new Error('RECORD_ID is required');
|
|
20
20
|
|
|
21
21
|
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
22
22
|
try {
|
|
23
23
|
await ablo.ready();
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
-
const commitId = `
|
|
27
|
-
await ablo.
|
|
28
|
-
id:
|
|
29
|
-
data: { status: 'done', result: `Completed: ${
|
|
30
|
-
reads: [
|
|
24
|
+
const record = await ablo.records.get({ id: recordId });
|
|
25
|
+
if (!record) throw new Error(`Record ${recordId} was not found`);
|
|
26
|
+
const commitId = `record:${recordId}:cheap`;
|
|
27
|
+
await ablo.records.update({
|
|
28
|
+
id: record.id,
|
|
29
|
+
data: { status: 'done', result: `Completed: ${record.title}` },
|
|
30
|
+
reads: [record],
|
|
31
31
|
idempotencyKey: commitId,
|
|
32
32
|
});
|
|
33
|
-
const
|
|
34
|
-
console.log({ identity: ablo.identity, commit
|
|
33
|
+
const commit = await ablo.commits.get({ id: commitId });
|
|
34
|
+
console.log({ identity: ablo.identity, commit });
|
|
35
35
|
} finally {
|
|
36
36
|
await ablo.dispose();
|
|
37
37
|
}
|
|
@@ -93,9 +93,9 @@ createServer(async (req, res) => {
|
|
|
93
93
|
Replace the `Map`-based store in `customer-server.ts` with your real
|
|
94
94
|
data layer. The handler shape stays the same:
|
|
95
95
|
|
|
96
|
-
- `
|
|
97
|
-
- `
|
|
98
|
-
- `
|
|
96
|
+
- `records.load({ id })` -> `db.record.findUnique({ where: { id } })`
|
|
97
|
+
- `records.list({ query })` -> `db.record.findMany({ take, cursor })`
|
|
98
|
+
- `records.commit({ operations, clientTxId })` -> `db.$transaction` that
|
|
99
99
|
applies each `op` and writes an outbox marker with `clientTxId` before commit
|
|
100
100
|
- `events({ cursor, limit })` -> read from your outbox table, return
|
|
101
101
|
rows with their `clientTxId` (Ablo dedupes its own commits) and the
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from '@abloatai/ablo/source';
|
|
24
24
|
import { schema } from './schema';
|
|
25
25
|
|
|
26
|
-
type
|
|
26
|
+
type RecordRow = {
|
|
27
27
|
id: string;
|
|
28
28
|
title: string;
|
|
29
29
|
status: 'todo' | 'doing' | 'done';
|
|
@@ -31,9 +31,9 @@ type TaskRow = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
// Stand-in for the customer's real database. Map keyed by row id.
|
|
34
|
-
const
|
|
34
|
+
const recordStore = new Map<string, RecordRow>();
|
|
35
35
|
|
|
36
|
-
// Outbox table. In production this is a `
|
|
36
|
+
// Outbox table. In production this is a `records_outbox` Postgres table
|
|
37
37
|
// populated in the same transaction as the app-row write. Ablo polls `events`
|
|
38
38
|
// to fan out changes that bypassed Ablo, and to repair SDK-origin writes if
|
|
39
39
|
// Ablo's immediate post-commit append failed.
|
|
@@ -41,8 +41,8 @@ const outbox: SourceEvent[] = [];
|
|
|
41
41
|
let outboxSequence = 0;
|
|
42
42
|
|
|
43
43
|
// Seed one row so the example's first `load` returns something.
|
|
44
|
-
|
|
45
|
-
id: '
|
|
44
|
+
recordStore.set('record_seed', {
|
|
45
|
+
id: 'record_seed',
|
|
46
46
|
title: 'Seeded by customer database',
|
|
47
47
|
status: 'todo',
|
|
48
48
|
});
|
|
@@ -91,13 +91,13 @@ export const handleAbloSource = dataSource({
|
|
|
91
91
|
return {};
|
|
92
92
|
},
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
records: {
|
|
95
95
|
load({ id }) {
|
|
96
|
-
return
|
|
96
|
+
return recordStore.get(id) ?? null;
|
|
97
97
|
},
|
|
98
98
|
|
|
99
99
|
list({ query }) {
|
|
100
|
-
const all = Array.from(
|
|
100
|
+
const all = Array.from(recordStore.values());
|
|
101
101
|
const start = query.cursor ? Number(query.cursor) : 0;
|
|
102
102
|
const limit = query.limit ?? 50;
|
|
103
103
|
const page = all.slice(start, start + limit);
|
|
@@ -115,7 +115,7 @@ export const handleAbloSource = dataSource({
|
|
|
115
115
|
// update; the surrounding `apply` helper shows where you would
|
|
116
116
|
// open `db.transaction(async (tx) => { ... })`.
|
|
117
117
|
commit({ operations, clientTxId }) {
|
|
118
|
-
const rows:
|
|
118
|
+
const rows: RecordRow[] = [];
|
|
119
119
|
for (const op of operations) {
|
|
120
120
|
const row = applyOperation(op, clientTxId);
|
|
121
121
|
if (row) rows.push(row);
|
|
@@ -145,38 +145,38 @@ export const handleAbloSource = dataSource({
|
|
|
145
145
|
function applyOperation(
|
|
146
146
|
op: SourceOperation,
|
|
147
147
|
clientTxId: string | undefined,
|
|
148
|
-
):
|
|
149
|
-
if (op.model !== '
|
|
150
|
-
const id = op.id ?? `
|
|
148
|
+
): RecordRow | null {
|
|
149
|
+
if (op.model !== 'records') return null;
|
|
150
|
+
const id = op.id ?? `record_${Math.random().toString(36).slice(2, 10)}`;
|
|
151
151
|
|
|
152
152
|
if (op.type === 'CREATE') {
|
|
153
|
-
const row:
|
|
153
|
+
const row: RecordRow = {
|
|
154
154
|
id,
|
|
155
155
|
title: String(op.input?.title ?? ''),
|
|
156
156
|
status:
|
|
157
|
-
(op.input?.status as
|
|
157
|
+
(op.input?.status as RecordRow['status'] | undefined) ?? 'todo',
|
|
158
158
|
...(op.input?.assignee
|
|
159
159
|
? { assignee: String(op.input.assignee) }
|
|
160
160
|
: {}),
|
|
161
161
|
};
|
|
162
|
-
|
|
162
|
+
recordStore.set(id, row);
|
|
163
163
|
appendOutbox({ operation: op, entityId: id, data: row, clientTxId });
|
|
164
164
|
return row;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
if (op.type === 'UPDATE') {
|
|
168
|
-
const existing =
|
|
168
|
+
const existing = recordStore.get(id);
|
|
169
169
|
if (!existing) return null;
|
|
170
|
-
const next:
|
|
171
|
-
|
|
170
|
+
const next: RecordRow = { ...existing, ...(op.input as Partial<RecordRow>) };
|
|
171
|
+
recordStore.set(id, next);
|
|
172
172
|
appendOutbox({ operation: op, entityId: id, data: next, clientTxId });
|
|
173
173
|
return next;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
if (op.type === 'DELETE') {
|
|
177
|
-
const existing =
|
|
177
|
+
const existing = recordStore.get(id);
|
|
178
178
|
if (!existing) return null;
|
|
179
|
-
|
|
179
|
+
recordStore.delete(id);
|
|
180
180
|
appendOutbox({ operation: op, entityId: id, data: null, clientTxId });
|
|
181
181
|
return existing;
|
|
182
182
|
}
|
|
@@ -187,7 +187,7 @@ function applyOperation(
|
|
|
187
187
|
function appendOutbox(input: {
|
|
188
188
|
operation: SourceOperation;
|
|
189
189
|
entityId: string;
|
|
190
|
-
data:
|
|
190
|
+
data: RecordRow | null;
|
|
191
191
|
clientTxId: string | undefined;
|
|
192
192
|
}): void {
|
|
193
193
|
outboxSequence += 1;
|
|
@@ -205,11 +205,11 @@ function appendOutbox(input: {
|
|
|
205
205
|
// Exposed for the orchestrator's `run.ts`. A real customer doesn't
|
|
206
206
|
// need this — it's a back door for the demo to verify state.
|
|
207
207
|
export function _inspectStore(): {
|
|
208
|
-
rows:
|
|
208
|
+
rows: RecordRow[];
|
|
209
209
|
outboxSize: number;
|
|
210
210
|
} {
|
|
211
211
|
return {
|
|
212
|
-
rows: Array.from(
|
|
212
|
+
rows: Array.from(recordStore.values()),
|
|
213
213
|
outboxSize: outbox.length,
|
|
214
214
|
};
|
|
215
215
|
}
|
|
@@ -36,7 +36,7 @@ async function main() {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
log('--- 1. load (existing seeded row) ---');
|
|
39
|
-
const seeded = await driver.load('
|
|
39
|
+
const seeded = await driver.load('records', 'record_seed');
|
|
40
40
|
log('loaded:', seeded);
|
|
41
41
|
|
|
42
42
|
log('\n--- 2. commit (CREATE + UPDATE in one batch) ---');
|
|
@@ -44,14 +44,14 @@ async function main() {
|
|
|
44
44
|
[
|
|
45
45
|
{
|
|
46
46
|
type: 'CREATE',
|
|
47
|
-
model: '
|
|
48
|
-
id: '
|
|
47
|
+
model: 'records',
|
|
48
|
+
id: 'record_new',
|
|
49
49
|
input: { title: 'Wire the data source', status: 'todo' },
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
type: 'UPDATE',
|
|
53
|
-
model: '
|
|
54
|
-
id: '
|
|
53
|
+
model: 'records',
|
|
54
|
+
id: 'record_seed',
|
|
55
55
|
input: { status: 'doing', assignee: 'alice' },
|
|
56
56
|
},
|
|
57
57
|
],
|
|
@@ -59,8 +59,8 @@ async function main() {
|
|
|
59
59
|
);
|
|
60
60
|
log('committed rows:', committed);
|
|
61
61
|
|
|
62
|
-
log('\n--- 3. list (all
|
|
63
|
-
const listed = await driver.list('
|
|
62
|
+
log('\n--- 3. list (all records after commit) ---');
|
|
63
|
+
const listed = await driver.list('records');
|
|
64
64
|
log('listed:', listed);
|
|
65
65
|
|
|
66
66
|
log('\n--- 4. events (outbox feed for cross-channel writes) ---');
|
|
@@ -73,7 +73,7 @@ async function main() {
|
|
|
73
73
|
apiKey: 'sk_wrong_example_key',
|
|
74
74
|
});
|
|
75
75
|
try {
|
|
76
|
-
await badDriver.load('
|
|
76
|
+
await badDriver.load('records', 'record_seed');
|
|
77
77
|
throw new Error('expected signature failure');
|
|
78
78
|
} catch (err) {
|
|
79
79
|
log('rejected as expected:', (err as Error).message);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The schema is the contract between three sides:
|
|
5
5
|
*
|
|
6
|
-
* 1. The application UI — `ablo.
|
|
6
|
+
* 1. The application UI — `ablo.records.update(...)`.
|
|
7
7
|
* 2. The Ablo Cloud — translates writes into signed POSTs.
|
|
8
8
|
* 3. The customer's Data Source endpoint — applies them to its own
|
|
9
9
|
* database.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
16
16
|
|
|
17
17
|
export const schema = defineSchema({
|
|
18
|
-
|
|
18
|
+
records: model({
|
|
19
19
|
title: z.string(),
|
|
20
20
|
status: z.enum(['todo', 'doing', 'done']),
|
|
21
21
|
assignee: z.string().optional(),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lease outlives the process that took it.
|
|
3
|
+
*
|
|
4
|
+
* Run in two terminals against your own project. The `holder` takes a claim
|
|
5
|
+
* and is killed without releasing it, exactly as a sandbox that is torn down
|
|
6
|
+
* mid-turn would be. The `successor`, already queued, is granted the claim when
|
|
7
|
+
* the lease lapses and reads the row as it stands then.
|
|
8
|
+
*
|
|
9
|
+
* Terminal 1: ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/lease-outlives-the-machine.ts holder
|
|
10
|
+
* Terminal 2: ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/lease-outlives-the-machine.ts successor
|
|
11
|
+
*
|
|
12
|
+
* Start the successor first, then the holder, so the queue is populated before
|
|
13
|
+
* the lease lapses.
|
|
14
|
+
*/
|
|
15
|
+
import { Ablo } from '@abloatai/ablo';
|
|
16
|
+
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
17
|
+
|
|
18
|
+
const schema = defineSchema({
|
|
19
|
+
jobs: model({
|
|
20
|
+
prompt: z.string(),
|
|
21
|
+
status: z.enum(['pending', 'complete']),
|
|
22
|
+
answer: z.string().optional(),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const role = process.argv[2];
|
|
27
|
+
if (role !== 'holder' && role !== 'successor') {
|
|
28
|
+
throw new Error('Pass "holder" or "successor" as the first argument');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const jobId = process.env.JOB_ID;
|
|
32
|
+
if (!jobId) throw new Error('JOB_ID is required');
|
|
33
|
+
|
|
34
|
+
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
35
|
+
await ablo.ready();
|
|
36
|
+
|
|
37
|
+
if (role === 'holder') {
|
|
38
|
+
// A short TTL and no heartbeat: this process takes the lease and then stops
|
|
39
|
+
// proving it is alive, which is what a machine that disappears looks like
|
|
40
|
+
// from the server's side.
|
|
41
|
+
const claim = await ablo.jobs.claim({
|
|
42
|
+
id: jobId,
|
|
43
|
+
description: 'drafting the summary',
|
|
44
|
+
ttl: '10s',
|
|
45
|
+
});
|
|
46
|
+
console.log('holder: lease taken, status is', claim.data.status);
|
|
47
|
+
console.log('holder: exiting without releasing it');
|
|
48
|
+
// Deliberately skip release and skip dispose. `process.exit` runs no
|
|
49
|
+
// cleanup, so the server never hears from this participant again.
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('successor: queueing behind whoever holds the lease');
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
await using claim = await ablo.jobs.claim({
|
|
56
|
+
id: jobId,
|
|
57
|
+
description: 'taking over the draft',
|
|
58
|
+
ttl: '30s',
|
|
59
|
+
heartbeat: { every: '10s' },
|
|
60
|
+
});
|
|
61
|
+
console.log(`successor: granted after ${Math.round((Date.now() - started) / 1000)}s`);
|
|
62
|
+
console.log('successor: read the row as it stands now —', {
|
|
63
|
+
status: claim.data.status,
|
|
64
|
+
answer: claim.data.answer,
|
|
65
|
+
});
|
|
66
|
+
await ablo.dispose();
|
package/examples/tsconfig.json
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
2
3
|
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022", "DOM"],
|
|
7
|
-
"strict": true,
|
|
8
4
|
"noEmit": true,
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"types": ["node"]
|
|
5
|
+
"rootDir": "..",
|
|
6
|
+
"lib": ["ES2022", "ESNext.Disposable"]
|
|
13
7
|
},
|
|
14
8
|
"include": ["**/*.ts"],
|
|
15
|
-
"exclude": ["node_modules"]
|
|
9
|
+
"exclude": ["node_modules", "dist"]
|
|
16
10
|
}
|
package/llms.txt
CHANGED
|
@@ -85,6 +85,17 @@ second verb to learn — `local.` is the only difference. The query reads accept
|
|
|
85
85
|
and `state`; state defaults to `'live'`, with `'archived'` and `'all'` to include
|
|
86
86
|
retired rows.
|
|
87
87
|
|
|
88
|
+
`where` takes operators, not only equality: an array value is an `IN`
|
|
89
|
+
(`{ status: ['draft', 'review'] }`), and tuple form spells the rest out
|
|
90
|
+
(`[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`). Clauses combine
|
|
91
|
+
with AND; for OR, run two reads and union them.
|
|
92
|
+
|
|
93
|
+
`list` returns a page, not always the whole collection. The result is an array,
|
|
94
|
+
so it maps and iterates as usual, and it carries `hasMore` and `nextCursor`
|
|
95
|
+
beside the rows. Pass `nextCursor` back as `cursor`, keeping `where` and
|
|
96
|
+
`orderBy` the same, to walk the rest. Check `hasMore` before treating a result
|
|
97
|
+
as complete.
|
|
98
|
+
|
|
88
99
|
Workers import the same app schema and select `transport: 'http'`. The transport
|
|
89
100
|
changes; the typed `ablo.<model>` contract does not. There is no public
|
|
90
101
|
schema-less or string-keyed model client.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"prepack": "npm run build && node scripts/strip-source-condition.mjs",
|
|
113
113
|
"postpack": "node scripts/restore-source-condition.mjs",
|
|
114
114
|
"pack:check": "node scripts/pack-check.mjs",
|
|
115
|
-
"typecheck": "tsc --noEmit && tsc -p typetests/tsconfig.json",
|
|
115
|
+
"typecheck": "tsc --noEmit && tsc -p typetests/tsconfig.json && tsc -p examples/tsconfig.json",
|
|
116
116
|
"test": "vitest run",
|
|
117
117
|
"generate:errors": "tsx scripts/generate-error-docs.mts",
|
|
118
118
|
"lint:errors": "tsx scripts/check-error-docs.mts",
|
|
@@ -137,8 +137,8 @@
|
|
|
137
137
|
"directory": "packages/ablo"
|
|
138
138
|
},
|
|
139
139
|
"dependencies": {
|
|
140
|
-
"@abloatai/humans": "^0.
|
|
141
|
-
"@abloatai/transaction": "^0.
|
|
140
|
+
"@abloatai/humans": "^0.53.0",
|
|
141
|
+
"@abloatai/transaction": "^0.53.0",
|
|
142
142
|
"zod": "^4.4.3"
|
|
143
143
|
},
|
|
144
144
|
"peerDependencies": {
|