@abloatai/ablo 0.39.0 → 0.40.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 +35 -0
- package/docs/concurrency-convention.md +108 -17
- package/docs/groups.md +46 -4
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.40.0
|
|
4
|
+
|
|
5
|
+
### The price is a contract, and the pricing page derives from it
|
|
6
|
+
|
|
7
|
+
Ablo's pricing now lives in one module: the tiers, the rate card, and the
|
|
8
|
+
arithmetic that turns a month of usage into a bill. Each tier has a monthly
|
|
9
|
+
floor, and metered usage is charged against that floor rather than added to
|
|
10
|
+
it, so an organization pays the greater of the two and never both. Commits,
|
|
11
|
+
reads, and claim creates roll into one metered axis. Concurrent connections
|
|
12
|
+
are a capacity reservation, so they are capped rather than billed.
|
|
13
|
+
|
|
14
|
+
The published pricing page is generated from the same contract the meter
|
|
15
|
+
charges against, and a CI gate fails any change that would let the page and
|
|
16
|
+
the invoice disagree. The dashboard gains a billing page that shows the
|
|
17
|
+
period's usage and what it costs.
|
|
18
|
+
|
|
19
|
+
### Tracks that refuse to write on a stale belief
|
|
20
|
+
|
|
21
|
+
A durable track can now carry `onStale: 'reject'`. The tracker's next commit
|
|
22
|
+
is refused until it has observed the change it was tracking, so an agent
|
|
23
|
+
cannot write on the basis of a row it has been shown to be stale. The default
|
|
24
|
+
stays `notify`, which reports the movement on the receipt and lets the commit
|
|
25
|
+
through. Group premises now name the concrete row that moved rather than the
|
|
26
|
+
group alone, so reconciliation starts from the exact conflict instead of a
|
|
27
|
+
re-read of everything the group covers.
|
|
28
|
+
|
|
29
|
+
### A CLI that fails in plain language
|
|
30
|
+
|
|
31
|
+
Every command that talks to Ablo's control plane goes through one typed HTTP
|
|
32
|
+
client. Failures arrive as named `cli_` error codes with plain-language
|
|
33
|
+
messages: a missing API key says how to log in, a missing connection string
|
|
34
|
+
says where the CLI looked, an unreachable database says what refused the
|
|
35
|
+
dial. Setting `ABLO_JSON=1` switches command output to a machine-readable
|
|
36
|
+
form for agents and scripts.
|
|
37
|
+
|
|
3
38
|
## 0.39.0
|
|
4
39
|
|
|
5
40
|
### Schema-relative session grants
|
|
@@ -104,19 +104,38 @@ delivered **twice**, by design — once as a value, once as an event:
|
|
|
104
104
|
- On the **event channel**: `conflict:notified` (mirrors `reconciliation:needed` /
|
|
105
105
|
`sync:rollback`).
|
|
106
106
|
|
|
107
|
-
Shape (canonical in `coordination/schema.ts`):
|
|
107
|
+
Shape (canonical in `coordination/schema.ts`): one advisory in two scopes, told
|
|
108
|
+
apart by `scope`, the same way `reads[]` entries come at two granularities.
|
|
109
|
+
|
|
110
|
+
Both scopes carry:
|
|
108
111
|
|
|
109
112
|
| field | meaning |
|
|
110
113
|
|---|---|
|
|
111
114
|
| `object` | Stripe-style type tag: `'stale_notification'` |
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
115
|
+
| `scope` | `'row'` or `'group'` — which premise granularity fired |
|
|
116
|
+
| `target` | **the row that moved**, and which parts: `{ model, id, fields }`. The same locator a claim names its subject with |
|
|
114
117
|
| `readAt` | the watermark the committer reasoned against |
|
|
115
118
|
| `observedSyncId` | the newest delta on the premise: re-read at/after this |
|
|
116
|
-
| `conflictingFields` | fields that moved (empty for group / whole-entity) |
|
|
117
|
-
| `currentValues` | the live values of those fields: the premise to reconcile against (empty for group) |
|
|
118
119
|
| `writtenBy` | `{ kind, id }` of the concurrent author, reported faithfully |
|
|
119
120
|
|
|
121
|
+
`scope: 'row'` adds:
|
|
122
|
+
|
|
123
|
+
| field | meaning |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `currentValues` | the live values of `target.fields`: the premise to reconcile against |
|
|
126
|
+
|
|
127
|
+
`scope: 'group'` adds:
|
|
128
|
+
|
|
129
|
+
| field | meaning |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `group` | the group premise that fired (`report:abc`) |
|
|
132
|
+
| `propagation?` | how `target` reached `group`: `{ via, through }` |
|
|
133
|
+
| `changed?` | how much of the group moved: `{ count, sample, truncated }` |
|
|
134
|
+
|
|
135
|
+
`target` names the row that changed in **both** scopes. A group notification
|
|
136
|
+
reports the moved row there and the premise it broke in `group`, so the two are
|
|
137
|
+
never the same field.
|
|
138
|
+
|
|
120
139
|
Only `notify` produces a notification (the write was held). `reject` throws and
|
|
121
140
|
`overwrite` is silent — neither notifies.
|
|
122
141
|
|
|
@@ -134,18 +153,25 @@ const receipt = await ablo.task.update({
|
|
|
134
153
|
});
|
|
135
154
|
|
|
136
155
|
// Receive — pull: the held write surfaces on the receipt.
|
|
137
|
-
for (const n of receipt.notifications ?? [])
|
|
156
|
+
for (const n of receipt.notifications ?? []) resolve(n);
|
|
138
157
|
|
|
139
158
|
// Receive — push: the same StaleNotification[] fires ambiently on the socket.
|
|
140
|
-
ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(
|
|
141
|
-
|
|
142
|
-
function
|
|
159
|
+
ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(resolve));
|
|
160
|
+
|
|
161
|
+
function resolve(n: StaleNotification) {
|
|
162
|
+
// n.target — the row that moved, and which fields (both scopes)
|
|
163
|
+
// n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
|
|
164
|
+
if (n.scope === 'group') {
|
|
165
|
+
// "something in report:abc moved" — but `target` says WHICH row, so this is
|
|
166
|
+
// a one-row re-read, not a re-read of the group.
|
|
167
|
+
// n.propagation?.via — 'self' | 'parent' | 'transitive'
|
|
168
|
+
return refreshRow(n.target.model, n.target.id, n.observedSyncId);
|
|
169
|
+
}
|
|
143
170
|
// n.currentValues — what's actually there now (e.g. { status: 'done' })
|
|
144
|
-
// n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
|
|
145
171
|
if (!stillValid(n.currentValues)) return; // premise gone → drop the write
|
|
146
172
|
|
|
147
173
|
return ablo.task.update({
|
|
148
|
-
id: n.id,
|
|
174
|
+
id: n.target.id,
|
|
149
175
|
data: { status: 'blocked' },
|
|
150
176
|
readAt: n.observedSyncId, // adopt the new high-water mark — this is what terminates the loop
|
|
151
177
|
onStale: 'notify',
|
|
@@ -155,8 +181,65 @@ function reconcile(n: StaleNotification) {
|
|
|
155
181
|
|
|
156
182
|
The loop **terminates** because each retry advances `readAt` to `observedSyncId`;
|
|
157
183
|
a peer that keeps writing only ever notifies you against a *newer* baseline, never
|
|
158
|
-
the same one twice.
|
|
159
|
-
|
|
184
|
+
the same one twice.
|
|
185
|
+
|
|
186
|
+
### 5.2 Reading a group notification
|
|
187
|
+
|
|
188
|
+
A group premise says "anything in this group", so the notification has to answer
|
|
189
|
+
"what, exactly?" or the only correct response is to re-read the whole group —
|
|
190
|
+
which on a busy group is how a reconcile loop turns into a large, repeated read.
|
|
191
|
+
|
|
192
|
+
Four fields answer it:
|
|
193
|
+
|
|
194
|
+
- **`target`** — the row that moved and the fields that changed on it. Usually a
|
|
195
|
+
one-row re-read.
|
|
196
|
+
- **`group`** — the premise that fired.
|
|
197
|
+
- **`propagation`** — how the row reached the group. `via: 'self'` means the row
|
|
198
|
+
*is* the group's scope root; `'parent'` means one containment edge below it;
|
|
199
|
+
`'transitive'` means further up, with `through` listing the intermediate
|
|
200
|
+
models (`['slides', 'decks']`).
|
|
201
|
+
- **`changed`** — how much moved, in **distinct rows** rather than deltas.
|
|
202
|
+
`count` is the total, `sample` names the most recently changed (capped), and
|
|
203
|
+
`truncated` says whether the sample tells the whole story.
|
|
204
|
+
|
|
205
|
+
`changed` is what lets an actor decline. One row moved, re-read one row; twelve
|
|
206
|
+
thousand moved, stop reasoning and resync the group — or abandon:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
if (n.changed && n.changed.count > 50) return null; // too much moved — abandon the write
|
|
210
|
+
for (const row of n.changed?.sample ?? []) await refresh(row.model, row.id);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`propagation` is absent when the commit path has no record-group spec for the
|
|
214
|
+
model, which is the one case where the server knows a group moved without
|
|
215
|
+
knowing the route. Treat its absence as "re-read the group", the old behavior.
|
|
216
|
+
|
|
217
|
+
### 5.3 Gating on what you know
|
|
218
|
+
|
|
219
|
+
The notification tells you a belief moved. Whether that should *stop you writing*
|
|
220
|
+
is a separate decision, and it belongs on the belief, not on the write.
|
|
221
|
+
|
|
222
|
+
A `track` is a standing premise — what you are watching, with a watermark for
|
|
223
|
+
when you last looked. Its disposition says what a move does to your next commit:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await ablo.documents.track({ id: 's-1' }); // notify — report it
|
|
227
|
+
await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // reject — gate my next write
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`reject` refuses your next commit while that belief is stale, **even if the
|
|
231
|
+
commit writes an unrelated row**. The gate is on what you *know*, not on what you
|
|
232
|
+
are touching — which is the case a read→reason→write agent actually has.
|
|
233
|
+
|
|
234
|
+
It is enforced at the commit chokepoint, so it is a guarantee rather than a
|
|
235
|
+
convention. And the gate takes a key: it does not reopen on its own, because an
|
|
236
|
+
agent retrying blindly would land exactly the write `reject` was asked to
|
|
237
|
+
prevent. Re-read, then re-register the track to say you have. See
|
|
238
|
+
[Groups](./groups.md#reporting-or-gating-onstale).
|
|
239
|
+
|
|
240
|
+
A row that goes stale twice is telling you something: the read→reason→write gap
|
|
241
|
+
wants the *prospective* guard, not the in-flight one. Escalate to a claim
|
|
242
|
+
(§1) rather than raising `retries`.
|
|
160
243
|
|
|
161
244
|
---
|
|
162
245
|
|
|
@@ -173,10 +256,18 @@ What the convention **guarantees**, and where it **stops**:
|
|
|
173
256
|
detection time, inside the same transaction as the write. A notification is
|
|
174
257
|
never speculative.
|
|
175
258
|
|
|
176
|
-
3. **
|
|
177
|
-
serialization order. The stale committer
|
|
178
|
-
asymmetry that rules out the symmetric
|
|
179
|
-
|
|
259
|
+
3. **No livelock, which is not the same as termination.** The monotonic
|
|
260
|
+
`sync_id` landing order is the serialization order. The stale committer
|
|
261
|
+
always yields/recomputes — an asymmetry that rules out the symmetric
|
|
262
|
+
notify-rewrite livelock, because each round adopts a newer `observedSyncId`
|
|
263
|
+
and no baseline is ever reasoned against twice.
|
|
264
|
+
|
|
265
|
+
It does **not** rule out starvation. A peer writing faster than your
|
|
266
|
+
read→decide→write gap keeps winning, and the engine never re-issues on your
|
|
267
|
+
behalf, so the rounds are yours and they are unbounded. Progress in the
|
|
268
|
+
watermark is not progress in the work — and for an agent, each round is a
|
|
269
|
+
model call. `update(id, fn)` bounds it for you and hands the conflict to
|
|
270
|
+
your updater (§5.3); a hand-rolled loop must bound itself.
|
|
180
271
|
|
|
181
272
|
4. **Scope: reversible DB state only.** The convention governs writes to the
|
|
182
273
|
shared database, which are inherently reversible (prior value in
|
package/docs/groups.md
CHANGED
|
@@ -64,6 +64,7 @@ await ablo.documents.track({ id: 's-1' });
|
|
|
64
64
|
| Who else is working here? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket |
|
|
65
65
|
| Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
|
|
66
66
|
| Has anything I read moved since? | `track` | On your next commit's receipt |
|
|
67
|
+
| Stop me writing if it has | `track` with `onStale: 'reject'` | Refuses that commit, at the chokepoint |
|
|
67
68
|
|
|
68
69
|
Two distinctions do most of the work here.
|
|
69
70
|
|
|
@@ -231,10 +232,51 @@ await ablo.documents.update({
|
|
|
231
232
|
```
|
|
232
233
|
|
|
233
234
|
So `reads` is the premise for the commit in hand; `track` is a standing
|
|
234
|
-
subscription that outlives it. Both speak the same notification vocabulary
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
subscription that outlives it. Both speak the same notification vocabulary.
|
|
236
|
+
Delivery is on your next commit's receipt; a track does not yet push out of band
|
|
237
|
+
between commits.
|
|
238
|
+
|
|
239
|
+
### Reporting, or gating: `onStale`
|
|
240
|
+
|
|
241
|
+
A track says what a moved belief should do to your **next write**. Same enum the
|
|
242
|
+
`reads` premise carries, minus the one mode that cannot apply:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
await ablo.documents.track({ id: 's-1' }); // notify (default)
|
|
246
|
+
await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // gate
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **`notify`** — the change rides your next receipt and the commit proceeds. You
|
|
250
|
+
decide what it means.
|
|
251
|
+
- **`reject`** — your next commit is **refused** while that belief is stale, even
|
|
252
|
+
if it writes an unrelated row. The gate is on what you *know*, not on what you
|
|
253
|
+
are touching.
|
|
254
|
+
|
|
255
|
+
`reject` is for an actor that must not act on a stale picture — an agent that
|
|
256
|
+
read a row, reasoned for minutes, and is about to write based on what it
|
|
257
|
+
believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather
|
|
258
|
+
than a convention: the write cannot land.
|
|
259
|
+
|
|
260
|
+
The gate takes a key. It does **not** reopen on its own, because an agent that
|
|
261
|
+
retried blindly would land exactly the write `reject` was asked to prevent. You
|
|
262
|
+
re-read, then re-register the track to say so:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
try {
|
|
266
|
+
await ablo.tasks.update({ id, data });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err.code === 'stale_context') {
|
|
269
|
+
const fresh = await ablo.documents.retrieve({ id: 's-1' }); // read
|
|
270
|
+
await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // acknowledge
|
|
271
|
+
await ablo.tasks.update({ id, data: reconsider(fresh) }); // now it lands
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
A track-only call is never gated, so acknowledging is always reachable.
|
|
277
|
+
|
|
278
|
+
`overwrite` is not offered: it means "apply my write anyway", and a track guards
|
|
279
|
+
no write of its own to apply.
|
|
238
280
|
|
|
239
281
|
---
|
|
240
282
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
|
@@ -104,6 +104,8 @@
|
|
|
104
104
|
"test": "vitest run",
|
|
105
105
|
"generate:errors": "tsx scripts/generate-error-docs.mts",
|
|
106
106
|
"lint:errors": "tsx scripts/check-error-docs.mts",
|
|
107
|
+
"generate:pricing": "tsx scripts/generate-pricing-docs.mts",
|
|
108
|
+
"lint:pricing": "tsx scripts/check-pricing-docs.mts",
|
|
107
109
|
"generate:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts",
|
|
108
110
|
"lint:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts --check",
|
|
109
111
|
"validate:openapi": "redocly lint ../../docs/ablo/public/openapi.json --extends=recommended --skip-rule=no-server-example.com",
|
|
@@ -122,8 +124,8 @@
|
|
|
122
124
|
"directory": "packages/ablo"
|
|
123
125
|
},
|
|
124
126
|
"dependencies": {
|
|
125
|
-
"@abloatai/humans": "^0.
|
|
126
|
-
"@abloatai/transaction": "^0.
|
|
127
|
+
"@abloatai/humans": "^0.40.0",
|
|
128
|
+
"@abloatai/transaction": "^0.40.0"
|
|
127
129
|
},
|
|
128
130
|
"peerDependencies": {
|
|
129
131
|
"ai": "^6.0.0 || ^7.0.0",
|