@abloatai/ablo 0.16.2 → 0.17.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 +50 -0
- package/dist/BaseSyncedStore.d.ts +3 -1
- package/dist/BaseSyncedStore.js +27 -4
- package/dist/cli.cjs +366 -211
- package/dist/client/Ablo.js +24 -11
- package/dist/schema/diff.d.ts +14 -0
- package/docs/agent-messaging.md +149 -0
- package/docs/agents.md +104 -0
- package/docs/data-sources.md +164 -287
- package/docs/index.md +0 -1
- package/docs/projects.md +97 -0
- package/docs/quickstart.md +51 -63
- package/docs/react.md +1 -1
- package/docs/sessions.md +239 -0
- package/docs/webhooks.md +231 -0
- package/package.json +4 -2
package/dist/client/Ablo.js
CHANGED
|
@@ -518,17 +518,30 @@ function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed,
|
|
|
518
518
|
}
|
|
519
519
|
return ModelClass;
|
|
520
520
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
521
|
+
const LOG_LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
|
|
522
|
+
function resolveLogLevel() {
|
|
523
|
+
// `globalThis.process` guard keeps this safe in browser/edge runtimes that
|
|
524
|
+
// have no `process` binding — there we fall through to the default.
|
|
525
|
+
const raw = globalThis.process?.env?.ABLO_LOG_LEVEL;
|
|
526
|
+
const normalized = raw?.toLowerCase();
|
|
527
|
+
if (normalized && normalized in LOG_LEVEL_RANK)
|
|
528
|
+
return normalized;
|
|
529
|
+
return 'warn';
|
|
530
|
+
}
|
|
531
|
+
const consoleLogger = (() => {
|
|
532
|
+
const threshold = LOG_LEVEL_RANK[resolveLogLevel()];
|
|
533
|
+
const emit = (level, fn, args) => {
|
|
534
|
+
if (typeof console === 'undefined' || LOG_LEVEL_RANK[level] < threshold)
|
|
535
|
+
return;
|
|
536
|
+
fn('[sync]', ...args);
|
|
537
|
+
};
|
|
538
|
+
return {
|
|
539
|
+
debug: (...args) => emit('debug', console.debug, args),
|
|
540
|
+
info: (...args) => emit('info', console.info, args),
|
|
541
|
+
warn: (...args) => emit('warn', console.warn, args),
|
|
542
|
+
error: (...args) => emit('error', console.error, args),
|
|
543
|
+
};
|
|
544
|
+
})();
|
|
532
545
|
// `readProcessEnv` lives in `./auth` alongside the other resolvers
|
|
533
546
|
// that read it. Re-exported there for use elsewhere in the file.
|
|
534
547
|
// ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
|
package/dist/schema/diff.d.ts
CHANGED
|
@@ -133,6 +133,20 @@ export interface MigrationSignal {
|
|
|
133
133
|
readonly model: string;
|
|
134
134
|
readonly field?: string;
|
|
135
135
|
readonly detail: string;
|
|
136
|
+
/**
|
|
137
|
+
* Reader-visibility context for a removal that shadows an existing artifact:
|
|
138
|
+
* the active schema this push is being diffed against. Lets the CLI show the
|
|
139
|
+
* baseline — version + WHEN it was pushed — so "incompatible" isn't a mystery
|
|
140
|
+
* (e.g. a first sandbox push diffed against a months-old production schema).
|
|
141
|
+
*/
|
|
142
|
+
readonly shadowed?: {
|
|
143
|
+
readonly environment: string;
|
|
144
|
+
readonly version: number;
|
|
145
|
+
/** ISO timestamp the shadowed artifact was activated/pushed, or null. */
|
|
146
|
+
readonly pushedAt: string | null;
|
|
147
|
+
/** Who pushed it (e.g. `apikey:…`), or null. */
|
|
148
|
+
readonly pushedBy: string | null;
|
|
149
|
+
};
|
|
136
150
|
}
|
|
137
151
|
export interface MigrationClassification {
|
|
138
152
|
/** Execute but may lose or risk data on a non-empty table. */
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Agent Messaging
|
|
2
|
+
|
|
3
|
+
Use a normal model when agents or humans need durable communication inside a
|
|
4
|
+
syncGroup. Use claim `description` and `meta` for live coordination context.
|
|
5
|
+
Use a `messages` row when the information must survive reconnects, work over
|
|
6
|
+
HTTP, or be replayed from the sync cursor.
|
|
7
|
+
|
|
8
|
+
## The rule
|
|
9
|
+
|
|
10
|
+
| Need | Use | Why |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| "I am holding this row because..." | `claim({ reason, description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
|
|
13
|
+
| "Remember this handoff/status/request" | A `messages` model | Durable row. Ordered in `sync_deltas`, replayed after reconnect, readable by HTTP agents. |
|
|
14
|
+
|
|
15
|
+
Claim context is ephemeral. If a participant was offline, reconnected later, or
|
|
16
|
+
only uses `transport: "http"`, it can miss claim/presence frames. Message rows
|
|
17
|
+
are facts in your database and in the sync log.
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
Add a `messages` model to the same schema as the work it discusses. Scope it
|
|
22
|
+
with the same field that scopes the work row.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import Ablo from "@abloatai/ablo";
|
|
26
|
+
import { defineSchema, entityRole, model, z } from "@abloatai/ablo/schema";
|
|
27
|
+
|
|
28
|
+
export const schema = defineSchema({
|
|
29
|
+
workItems: model(
|
|
30
|
+
{
|
|
31
|
+
title: z.string(),
|
|
32
|
+
status: z.string(),
|
|
33
|
+
teamId: z.string(),
|
|
34
|
+
},
|
|
35
|
+
{},
|
|
36
|
+
{ entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
|
|
37
|
+
),
|
|
38
|
+
|
|
39
|
+
messages: model(
|
|
40
|
+
{
|
|
41
|
+
body: z.string(),
|
|
42
|
+
kind: z.enum(["status", "request", "handoff"]),
|
|
43
|
+
teamId: z.string(),
|
|
44
|
+
|
|
45
|
+
// Causal link back to coordination or the row being discussed.
|
|
46
|
+
aboutEntityType: z.string().optional(),
|
|
47
|
+
aboutEntityId: z.string().optional(),
|
|
48
|
+
aboutIntentId: z.string().optional(),
|
|
49
|
+
},
|
|
50
|
+
{},
|
|
51
|
+
{ entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
|
|
52
|
+
),
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Push the schema before agents call it:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx ablo push
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Server-side agent
|
|
63
|
+
|
|
64
|
+
Most server-side agents use the direct database path. Pass the schema, API key,
|
|
65
|
+
database URL, and transport selector:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const ablo = Ablo({
|
|
69
|
+
schema,
|
|
70
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
71
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
72
|
+
transport: "http",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await ablo.ready();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`databaseUrl` is server-only. Browser clients must not receive it; live UIs use
|
|
79
|
+
the default WebSocket transport with a minted user/session token.
|
|
80
|
+
|
|
81
|
+
If your backend mints restricted agent tokens, register the database once from a
|
|
82
|
+
secret-key server process as above. Workers using the restricted token can then
|
|
83
|
+
construct `Ablo({ schema, authToken, transport: "http" })` because the project
|
|
84
|
+
already has a registered data plane.
|
|
85
|
+
|
|
86
|
+
## Link a message to a claim
|
|
87
|
+
|
|
88
|
+
The claim id is the causal id. Store it in `aboutIntentId` when the message is
|
|
89
|
+
about work currently protected by a claim.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const claim = await ablo.workItems.claim({
|
|
93
|
+
id: workItemId,
|
|
94
|
+
reason: "reformatting",
|
|
95
|
+
description: "normalizing the pricing table",
|
|
96
|
+
meta: { estimateSeconds: 120 },
|
|
97
|
+
queue: false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await ablo.messages.create({
|
|
102
|
+
id: crypto.randomUUID(),
|
|
103
|
+
data: {
|
|
104
|
+
body: "Taking the pricing table for about two minutes.",
|
|
105
|
+
kind: "status",
|
|
106
|
+
teamId,
|
|
107
|
+
aboutEntityType: "workItems",
|
|
108
|
+
aboutEntityId: workItemId,
|
|
109
|
+
aboutIntentId: claim.claimId,
|
|
110
|
+
},
|
|
111
|
+
wait: "confirmed",
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
await claim.release();
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Peers in `team:${teamId}` receive the message through the normal delta stream.
|
|
119
|
+
Peers outside that syncGroup do not.
|
|
120
|
+
|
|
121
|
+
## Reading messages
|
|
122
|
+
|
|
123
|
+
Live clients read locally and update when deltas arrive:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const rows = ablo.messages.getAll({
|
|
127
|
+
where: { teamId },
|
|
128
|
+
orderBy: { createdAt: "asc" },
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
HTTP agents read by request:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const rows = await ablo.messages.list({
|
|
136
|
+
where: { teamId },
|
|
137
|
+
orderBy: { createdAt: "asc" },
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Because messages are ordinary synced rows, reconnecting clients catch up from
|
|
142
|
+
their cursor and see the rows they missed while offline.
|
|
143
|
+
|
|
144
|
+
## Retention
|
|
145
|
+
|
|
146
|
+
Deleting or archiving old `messages` rows is your app's policy. The sync log is
|
|
147
|
+
still durable audit/history: `sync_deltas` has no message-specific TTL. That is
|
|
148
|
+
useful for coordination and compliance, but a chat-scale product should plan
|
|
149
|
+
retention before writing high-volume conversation traffic.
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Agents
|
|
2
|
+
|
|
3
|
+
An agent is a **reactive** participant: it wakes on something happening, reads
|
|
4
|
+
what it needs, writes a result, and goes idle. That's a request/response
|
|
5
|
+
workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The
|
|
6
|
+
credential *is* the identity; the server resolves the org, scope, and actor from
|
|
7
|
+
the key on every request (the Stripe server-SDK / Liveblocks-node shape).
|
|
8
|
+
|
|
9
|
+
Humans get the live plane (WebSocket: presence, optimistic, sub-100ms). Agents
|
|
10
|
+
get the stateless plane (HTTP). **Both operate on the same typed, coordinated
|
|
11
|
+
state — and coordinate *with each other*.**
|
|
12
|
+
|
|
13
|
+
<Note>
|
|
14
|
+
Agents transact against your **pushed schema**, same as everyone — `ablo.tasks`
|
|
15
|
+
exists because you defined a `task` model and ran `ablo push`. The key
|
|
16
|
+
authenticates; the [schema](/quickstart) defines what you can call.
|
|
17
|
+
</Note>
|
|
18
|
+
|
|
19
|
+
## The agent client
|
|
20
|
+
|
|
21
|
+
Same `Ablo()` entry point as everywhere else — pass `transport: 'http'`. No
|
|
22
|
+
socket, no connection state — just your schema (for types) and an API key.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import Ablo from "@abloatai/ablo";
|
|
26
|
+
import { schema } from "./schema";
|
|
27
|
+
|
|
28
|
+
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http" });
|
|
29
|
+
|
|
30
|
+
// Reads + writes, fully typed off your schema — each is one HTTP round-trip:
|
|
31
|
+
const open = await ablo.tasks.list({ where: { status: "todo" } });
|
|
32
|
+
const { data } = await ablo.tasks.retrieve({ id: open[0].id });
|
|
33
|
+
await ablo.tasks.update({ id: open[0].id, data: { status: "done" } });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
It exposes `retrieve` / `list` / `create` / `update` / `delete`, plus `commits`
|
|
37
|
+
and `claim`. It does **not** expose the stateful-only surface (`get` /
|
|
38
|
+
`getAll` / `getCount` local reads, `onChange` live subscription) — those need a
|
|
39
|
+
live connection, so with `transport: 'http'` the return type narrows and they
|
|
40
|
+
are a *compile error*, not a runtime surprise.
|
|
41
|
+
|
|
42
|
+
## Coordination — claim, queue, reorder
|
|
43
|
+
|
|
44
|
+
The differentiator. A claim is a **durable lease + FIFO wait-line** on a row —
|
|
45
|
+
"who's working on this, who's waiting" — and it's request/response, so an agent
|
|
46
|
+
holds it over HTTP. This is how two agents (or an agent and a human) don't
|
|
47
|
+
clobber the same record.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// Acquire a lease, do work with the held row, release on scope exit:
|
|
51
|
+
await using claim = await ablo.tasks.claim({ id: taskId });
|
|
52
|
+
const task = claim.data;
|
|
53
|
+
// …no one else can hold this row while you work…
|
|
54
|
+
await ablo.tasks.update({ id: task.id, data: { status: "in_review" } });
|
|
55
|
+
|
|
56
|
+
await ablo.tasks.claim.state({ id: taskId }); // who holds it now (or null)
|
|
57
|
+
await ablo.tasks.claim.queue({ id: taskId }); // the FIFO wait-line behind the holder
|
|
58
|
+
await ablo.tasks.claim.reorder({ id: taskId, order: line }); // re-rank the line (privileged)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Think of it as a queue per row — a durable, inspectable, reorderable lease line
|
|
62
|
+
("SQS for entity contention"). Use `{ queue: false }` for fail-fast dedup: *if
|
|
63
|
+
someone else has this job, skip it.*
|
|
64
|
+
|
|
65
|
+
## Messaging between agents
|
|
66
|
+
|
|
67
|
+
Use claim `description` and `meta` for live "what I am doing now" context. Use
|
|
68
|
+
ordinary synced rows for handoffs, status notes, and requests that must survive
|
|
69
|
+
reconnects or be readable by HTTP agents. The recipe is a `messages` model
|
|
70
|
+
scoped by the same syncGroup field as the work row, with `aboutIntentId` linking
|
|
71
|
+
a message back to the claim it discusses.
|
|
72
|
+
|
|
73
|
+
See [Agent Messaging](/agent-messaging) for the schema and direct
|
|
74
|
+
`databaseUrl` setup.
|
|
75
|
+
|
|
76
|
+
## Humans + agents on one state
|
|
77
|
+
|
|
78
|
+
There's no separate "agent mode." A human editing a record over their WebSocket
|
|
79
|
+
and an agent acting over HTTP share the same typed state and the same coordination
|
|
80
|
+
plane: the agent can claim the row a human is editing (and wait in line), and the
|
|
81
|
+
human sees the agent's committed changes stream in **live** — over the human's
|
|
82
|
+
own socket, even though the agent committed over HTTP. You write the agent once;
|
|
83
|
+
it's a first-class participant, not a bolt-on.
|
|
84
|
+
|
|
85
|
+
## How an agent runs
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
something happens ──▶ your agent (HTTP, no socket)
|
|
89
|
+
(a job, a webhook, read context (list/retrieve)
|
|
90
|
+
a queue message) claim → work → commit
|
|
91
|
+
done — no held connection
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Because it holds nothing open, an agent is a **stateless worker**: deploys and
|
|
95
|
+
restarts are free, and you scale by adding workers. A long-running fleet of idle
|
|
96
|
+
agents costs nothing on the live plane — that capacity stays for humans.
|
|
97
|
+
|
|
98
|
+
## What stays on the live (human) plane
|
|
99
|
+
|
|
100
|
+
`onChange` (live subscriptions) and `get`/`getAll`/`getCount` (local synced-pool
|
|
101
|
+
reads) require a WebSocket and a local store — they're for interactive UIs, not
|
|
102
|
+
stateless agents. An agent reacts to an external trigger (a job/queue/webhook),
|
|
103
|
+
then reads with `list`/`retrieve`. See [client behavior](/client-behavior) for
|
|
104
|
+
the full surface and [guarantees](/guarantees) for the coordination semantics.
|