@abloatai/ablo 0.59.2 → 0.60.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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A deterministic terminal proof of Ablo's product contract.
3
+ *
4
+ * It uses a real Ablo project, mints distinct human and agent participants,
5
+ * proves stale work is rejected, then proves claim contention serializes the
6
+ * same two participants onto fresh state. The final write is looked up through
7
+ * the durable commit API so the terminal shows evidence, not just application
8
+ * output.
9
+ *
10
+ * Run from packages/ablo:
11
+ *
12
+ * npx ablo push --schema examples/terminal-showcase/schema.ts
13
+ * ABLO_API_KEY=sk_... npx tsx examples/terminal-showcase/index.ts
14
+ */
15
+ import Ablo, { AbloStaleContextError } from '@abloatai/ablo';
16
+ import Sessions from '@abloatai/ablo/sessions';
17
+ import { schema } from './schema';
18
+
19
+ const apiKey = process.env.ABLO_API_KEY;
20
+ if (!apiKey?.startsWith('sk_')) {
21
+ throw new Error('ABLO_API_KEY must be a secret sk_ project key so the demo can mint scoped participants.');
22
+ }
23
+
24
+ const delayMs = Number.parseInt(process.env.ABLO_SHOWCASE_DELAY_MS ?? '650', 10);
25
+ const keepRow = process.env.ABLO_SHOWCASE_KEEP === '1';
26
+ const color = process.stdout.isTTY && process.env.NO_COLOR === undefined;
27
+ const ink = {
28
+ blue: (value: string) => color ? `\u001B[34m${value}\u001B[39m` : value,
29
+ dim: (value: string) => color ? `\u001B[2m${value}\u001B[22m` : value,
30
+ green: (value: string) => color ? `\u001B[32m${value}\u001B[39m` : value,
31
+ red: (value: string) => color ? `\u001B[31m${value}\u001B[39m` : value,
32
+ yellow: (value: string) => color ? `\u001B[33m${value}\u001B[39m` : value,
33
+ };
34
+
35
+ function pause(ms = delayMs): Promise<void> {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+
39
+ function heading(index: number, title: string): void {
40
+ console.log(`\n${ink.blue(String(index).padStart(2, '0'))} ${title}`);
41
+ }
42
+
43
+ function fact(label: string, value: unknown): void {
44
+ console.log(` ${ink.dim(label.padEnd(16))}${String(value)}`);
45
+ }
46
+
47
+ function ok(message: string): void {
48
+ console.log(` ${ink.green('✓')} ${message}`);
49
+ }
50
+
51
+ async function main(): Promise<void> {
52
+ const control = Ablo({ schema, apiKey });
53
+ const sessions = Sessions({ schema, apiKey });
54
+ const participants: Array<{ dispose(): Promise<void> }> = [];
55
+ let dealId: string | undefined;
56
+ let humanClaim: { release(): Promise<void> } | undefined;
57
+ let agentClaim: { release(): Promise<void> } | undefined;
58
+
59
+ console.log();
60
+ console.log(' ABLO / ONE ROW, TWO ACTORS, ONE CONFIRMED REALITY');
61
+ console.log(ink.dim(' Real credentials · real coordination · no model call · no simulated server'));
62
+
63
+ try {
64
+ await control.ready();
65
+
66
+ heading(1, 'Create one shared row');
67
+ const deal = await control.deals.create({
68
+ data: {
69
+ name: `Northstar renewal ${Date.now().toString(36)}`,
70
+ stage: 'open',
71
+ value: 100_000,
72
+ revision: 1,
73
+ note: 'Initial account plan',
74
+ },
75
+ });
76
+ dealId = deal.id;
77
+ fact('row', `${deal.id} · revision ${deal.revision}`);
78
+ ok('create resolved after authoritative confirmation');
79
+
80
+ heading(2, 'Give each actor its own bounded authority');
81
+ const humanSession = await sessions.create({
82
+ user: { id: `showcase-human-${deal.id}` },
83
+ can: { deals: ['read', 'update'] },
84
+ ttlSeconds: 300,
85
+ });
86
+ const agentSession = await sessions.create({
87
+ agent: { id: `showcase-agent-${deal.id}` },
88
+ onBehalfOf: { user: { id: `showcase-human-${deal.id}` } },
89
+ can: { deals: ['read', 'update'] },
90
+ ttlSeconds: 300,
91
+ });
92
+ const human = Ablo({ schema, session: humanSession, transport: 'http' });
93
+ const pricingAgent = Ablo({ schema, session: agentSession, transport: 'http' });
94
+ participants.push(human, pricingAgent);
95
+ await Promise.all([human.ready(), pricingAgent.ready()]);
96
+ fact('human', `${human.identity?.participantKind}:${human.identity?.participantId}`);
97
+ fact('agent', `${pricingAgent.identity?.participantKind}:${pricingAgent.identity?.participantId}`);
98
+ fact('agent can', pricingAgent.identity?.operations.join(', '));
99
+
100
+ heading(3, 'Reject reasoning built on stale state');
101
+ const agentRead = await pricingAgent.deals.read({ id: deal.id });
102
+ if (!agentRead) throw new Error('The agent could not read the showcase row.');
103
+ fact('agent reads', `revision ${agentRead.revision} · value ${agentRead.value}`);
104
+ console.log(` ${ink.yellow('…')} agent starts a slow pricing calculation`);
105
+ await pause();
106
+
107
+ const humanEdit = await human.deals.update({
108
+ id: deal.id,
109
+ data: {
110
+ value: 120_000,
111
+ revision: 2,
112
+ note: 'Human added the expansion seats',
113
+ },
114
+ });
115
+ fact('human writes', `revision ${humanEdit.revision} · value ${humanEdit.value}`);
116
+
117
+ try {
118
+ await pricingAgent.deals.update({
119
+ id: deal.id,
120
+ data: {
121
+ value: 105_000,
122
+ revision: 2,
123
+ note: 'Price calculated from revision 1',
124
+ },
125
+ reads: [agentRead],
126
+ idempotencyKey: `showcase:${deal.id}:stale-price`,
127
+ });
128
+ throw new Error('The stale write unexpectedly landed.');
129
+ } catch (error) {
130
+ if (!(error instanceof AbloStaleContextError)) throw error;
131
+ console.log(` ${ink.red('REJECTED')} ${error.code} — the human edit was not overwritten`);
132
+ }
133
+
134
+ const afterRejection = await control.deals.get({ id: deal.id });
135
+ if (!afterRejection) throw new Error('The showcase row disappeared after stale rejection.');
136
+ fact('still true', `revision ${afterRejection.revision} · value ${afterRejection.value}`);
137
+
138
+ heading(4, 'Serialize slow work with a visible claim');
139
+ const claimedByHuman = await human.deals.claim({
140
+ id: deal.id,
141
+ description: 'reviewing commercial terms',
142
+ ttl: '30s',
143
+ });
144
+ humanClaim = claimedByHuman;
145
+ fact('human holds', `fence ${claimedByHuman.fenceToken}`);
146
+
147
+ const queuedClaim = pricingAgent.deals.claim({
148
+ id: deal.id,
149
+ description: 'recalculating final price',
150
+ ttl: '30s',
151
+ queue: true,
152
+ });
153
+ await pause();
154
+ const queue = await control.deals.claim.queue({ id: deal.id });
155
+ fact('agent waits', `${queue.size} participant in FIFO queue`);
156
+
157
+ await human.deals.update({
158
+ id: deal.id,
159
+ data: {
160
+ stage: 'reviewing',
161
+ revision: 3,
162
+ note: 'Commercial review complete',
163
+ },
164
+ claim: claimedByHuman,
165
+ idempotencyKey: `showcase:${deal.id}:human-review`,
166
+ });
167
+ await claimedByHuman.release();
168
+ humanClaim = undefined;
169
+
170
+ const claimedByAgent = await queuedClaim;
171
+ agentClaim = claimedByAgent;
172
+ fact('agent receives', `fresh revision ${claimedByAgent.data.revision} · fence ${claimedByAgent.fenceToken}`);
173
+ ok('the queued agent did not continue from its earlier revision 1 read');
174
+
175
+ heading(5, 'Commit and inspect the durable evidence');
176
+ const commitId = `showcase:${deal.id}:agent-approval`;
177
+ const confirmed = await pricingAgent.deals.update({
178
+ id: deal.id,
179
+ data: {
180
+ stage: 'approved',
181
+ value: 118_000,
182
+ revision: 4,
183
+ note: 'Repriced from the fresh claimed row',
184
+ },
185
+ claim: claimedByAgent,
186
+ idempotencyKey: commitId,
187
+ });
188
+ await claimedByAgent.release();
189
+ agentClaim = undefined;
190
+
191
+ const evidence = await control.commits.get({ id: commitId });
192
+ if (!evidence) throw new Error(`Durable commit ${commitId} was not found.`);
193
+ fact('row', `${confirmed.id} · revision ${confirmed.revision} · ${confirmed.stage}`);
194
+ fact('commit', commitId);
195
+ fact('status', evidence.status);
196
+ fact('actor', `${evidence.actor.kind}:${evidence.actor.id}`);
197
+ fact('claim refs', evidence.claims.length);
198
+ fact('attempts', evidence.attempts.length);
199
+ fact('confirmation', `${Date.parse(evidence.statusAt) - Date.parse(evidence.createdAt)} ms`);
200
+ ok('the authoritative source confirmed the write before the SDK resolved');
201
+
202
+ console.log();
203
+ console.log(` ${ink.green('PROVEN')} stale work did not land · contenders serialized · fresh state won · evidence persisted`);
204
+ if (keepRow) fact('kept row', deal.id);
205
+ } finally {
206
+ await agentClaim?.release().catch(() => undefined);
207
+ await humanClaim?.release().catch(() => undefined);
208
+ await Promise.all(participants.map((participant) => participant.dispose()));
209
+ if (dealId && !keepRow) {
210
+ await control.deals.delete({ id: dealId }).catch(() => undefined);
211
+ }
212
+ await control.dispose();
213
+ }
214
+ }
215
+
216
+ main().catch((error: unknown) => {
217
+ console.error(`\n ${ink.red('SHOWCASE FAILED')}`, error);
218
+ process.exitCode = 1;
219
+ });
@@ -0,0 +1,12 @@
1
+ /** The isolated model used by the terminal product proof. */
2
+ import { defineSchema, model, z } from '@abloatai/ablo/schema';
3
+
4
+ export const schema = defineSchema({
5
+ deals: model({
6
+ name: z.string(),
7
+ stage: z.enum(['open', 'reviewing', 'approved']),
8
+ value: z.number(),
9
+ revision: z.number(),
10
+ note: z.string(),
11
+ }),
12
+ });
package/llms.txt CHANGED
@@ -66,7 +66,7 @@ Responses also carry `RateLimit-Policy` (the standing allowance, e.g. `"secret";
66
66
 
67
67
  Every surface reaches the same coordinated state. They are not interchangeable.
68
68
 
69
- - SDK, `@abloatai/ablo` with `transport: 'http'` THE AGENTS THEMSELVES. Stateless, request/response, nothing held open; the credential is the identity. This is the main path default to it.
69
+ - SDK, `@abloatai/ablo` API-key service clients use HTTP; scoped session clients use one reconnecting WebSocket by default. The credential is the identity in both cases.
70
70
  - Coordination MCP, `@abloatai/mcp` — an agent living inside an MCP host (Claude, Cursor). Two jobs: MANAGING your Ablo the way the CLI does (`get_schema`, `list_projects`, `create_project`, `tail_logs`, `get_usage`), and claim/commit as tools over your rows. Call `get_schema` first — it is the only way to learn the model names every data tool needs. `init`, `push`, `pull`, `generate` have no tools (they touch your repo); run those in a shell.
71
71
  - `humans()` with `@abloatai/humans/react` — the interfaces a person watches agent work arrive in. The bare client is the coordination layer (commit, read, observe, claim); `humans()` is the plugin that adds the local watchable copy, live queries, and presence. It `requires: { duplex: true }`, so a `transport: 'http'` agent is rejected at construction rather than left holding a subscription that never delivers. It remains the compatibility default for omitted `plugins`; new code should install it explicitly from `@abloatai/humans`. There is no `agents()` plugin — agents are the default caller, not a special one. A browser NEVER receives the secret key; mint a session token.
72
72
  - CLI, `ablo` — scaffolding, schema push, connecting a database. Terminals and CI. Agents must run it non-interactively (see the CLI section below).
@@ -153,8 +153,8 @@ beside the rows. Pass `nextCursor` back as `cursor`, keeping `where` and
153
153
  `orderBy` the same, to walk the rest. Check `hasMore` before treating a result
154
154
  as complete.
155
155
 
156
- Workers import the same app schema and select `transport: 'http'`. The transport
157
- changes; the typed `ablo.<model>` contract does not. There is no public
156
+ Workers import the same app schema. API-key workers use HTTP; session workers
157
+ use WebSocket by default. The typed `ablo.<model>` contract does not change. There is no public
158
158
  schema-less or string-keyed model client.
159
159
 
160
160
  React reads should use selector `useAblo`: `useAblo((ablo) => ablo.weatherReports.local.get(id))` (synchronous local read, reactive in render).
@@ -269,13 +269,13 @@ one direct mutation with a typed `ablo.<model>.update(...)`, use selector
269
269
 
270
270
  A PLANE is what a credential acts on: `production` is the root branch and development/preview branches are children. Rows, the registered database, and the active schema artifact are all PER PLANE. Every `sk_` has an immutable persisted branch binding; its spelling does not distinguish root from child, so app code never passes an environment. A child copies its parent's active schema at creation and owns its artifact after that. A CHILD push does NOT reach production. Production gets models only when the reviewed deployment pushes to the root.
271
271
 
272
- Going live is three things, each done with a root-bound `sk_` key: register the production database (`ablo connect apply` — the DIRECT host, never a pooler; a pooler refuses in the words of a wrong password), push the schema AHEAD of the code that needs it, and hold the right credential per runtime (server/serverless `sk_`; browser `pk_` read-only or an `authEndpoint` minting `ek_`). `ablo login` stores an `mk_` management credential and CANNOT read/write application data or push the production schema — a production push needs a root-bound `sk_` in `ABLO_API_KEY`. Gate a deploy on `npx ablo status --json` having an EMPTY `blockers` array; each blocker carries a `problem` and the one `fix`. Your agents do NOT each hold a database connection — they talk to Ablo, and Ablo holds at most 4 connections per plane (`application_name = 'ablo-direct-writer'`) however many callers write behind them, so size the database for that number and not for your agent count. Read `deployment` for the full path.
272
+ Going live is three things, each done with a root-bound `sk_` key: register the production database (`ablo connect apply` — the DIRECT host, never a pooler; a pooler refuses in the words of a wrong password), push the schema AHEAD of the code that needs it, and hold the right credential per runtime (server/serverless `sk_`; browser `pk_` read-only or `session: { endpoint }` minting `ek_`). `ablo login` stores an `mk_` management credential and CANNOT read/write application data or push the production schema — a production push needs a root-bound `sk_` in `ABLO_API_KEY`. Gate a deploy on `npx ablo status --json` having an EMPTY `blockers` array; each blocker carries a `problem` and the one `fix`. Your agents do NOT each hold a database connection — they talk to Ablo, and Ablo holds at most 4 connections per plane (`application_name = 'ablo-direct-writer'`) however many callers write behind them, so size the database for that number and not for your agent count. Read `deployment` for the full path.
273
273
 
274
274
  ## Public Surface
275
275
 
276
276
  Import from these public paths only:
277
277
 
278
- - `@abloatai/ablo` — headless HTTP `Ablo`, errors, typed model clients, claims, durable observation, and `dataSource`.
278
+ - `@abloatai/ablo` — headless `Ablo` (API-key HTTP or session WebSocket), errors, typed model clients, claims, durable observation, and `dataSource`.
279
279
  - `@abloatai/ablo/client` — reactive WebSocket client, presence, and the human-facing materializer.
280
280
  - `@abloatai/ablo/schema` — schema DSL.
281
281
  - `@abloatai/ablo/react` — React provider and hooks.
@@ -288,10 +288,10 @@ Import from these public paths only:
288
288
 
289
289
  Do not teach `/api`, `/agent`, `/core`, `/realtime`, or internal subpaths. (`/source` and `/ai-sdk` are public.)
290
290
 
291
- `onChange` is WebSocket-only. It exists on the reactive client because that
292
- client holds an open socket and a local graph. The stateless HTTP client does
293
- not expose it at the type or runtime level; HTTP agents re-read or inspect
294
- durable logs instead.
291
+ `context().onChange` uses a one-shot SSE response for an HTTP client and reuses
292
+ the session socket for a session client. The
293
+ human-facing client also owns a socket and local graph. A session identifies the
294
+ caller; it is not itself a transport.
295
295
 
296
296
  ## CLI: agents run it NON-INTERACTIVELY
297
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.59.2",
3
+ "version": "0.60.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",
@@ -65,6 +65,11 @@
65
65
  "import": "./dist/auth.js",
66
66
  "default": "./dist/auth.js"
67
67
  },
68
+ "./sessions": {
69
+ "types": "./dist/sessions.d.ts",
70
+ "import": "./dist/sessions.js",
71
+ "default": "./dist/sessions.js"
72
+ },
68
73
  "./coordination": {
69
74
  "types": "./dist/coordination.d.ts",
70
75
  "import": "./dist/coordination.js",
@@ -115,8 +120,8 @@
115
120
  "verify:context-package": "node scripts/verify-context-package.mjs",
116
121
  "typecheck": "tsc --noEmit && tsc -p typetests/tsconfig.json && tsc -p examples/tsconfig.json",
117
122
  "test": "vitest run",
118
- "generate:errors": "tsx scripts/generate-error-docs.mts",
119
- "lint:errors": "tsx scripts/check-error-docs.mts",
123
+ "generate:errors": "tsx --conditions=@ablo/source scripts/generate-error-docs.mts",
124
+ "lint:errors": "tsx --conditions=@ablo/source scripts/check-error-docs.mts",
120
125
  "generate:pricing": "tsx scripts/generate-pricing-docs.mts",
121
126
  "lint:pricing": "tsx scripts/check-pricing-docs.mts",
122
127
  "generate:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts",
@@ -140,8 +145,8 @@
140
145
  "directory": "packages/ablo"
141
146
  },
142
147
  "dependencies": {
143
- "@abloatai/humans": "^0.59.2",
144
- "@abloatai/transaction": "^0.59.2",
148
+ "@abloatai/humans": "^0.60.0",
149
+ "@abloatai/transaction": "^0.60.0",
145
150
  "zod": "^4.4.3"
146
151
  },
147
152
  "peerDependencies": {