@abloatai/ablo 0.14.0 → 0.15.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 +39 -0
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/cli.cjs +54 -1
- package/dist/client/Ablo.d.ts +27 -3
- package/dist/client/Ablo.js +11 -4
- package/dist/client/sessionMint.js +1 -0
- package/dist/client/writeOptionsSchema.d.ts +4 -6
- package/dist/client/writeOptionsSchema.js +1 -1
- package/dist/coordination/schema.d.ts +90 -12
- package/dist/coordination/schema.js +99 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/interfaces/index.d.ts +18 -2
- package/dist/policy/types.d.ts +35 -3
- package/dist/policy/types.js +20 -7
- package/dist/server/commit.d.ts +17 -0
- package/dist/source/connector-protocol.d.ts +159 -0
- package/dist/source/connector-protocol.js +161 -0
- package/dist/source/connector.d.ts +96 -0
- package/dist/source/connector.js +264 -0
- package/dist/source/contract.d.ts +4 -6
- package/dist/source/contract.js +1 -1
- package/dist/source/index.d.ts +3 -1
- package/dist/source/index.js +6 -0
- package/dist/sync/SyncWebSocket.d.ts +32 -5
- package/dist/sync/SyncWebSocket.js +40 -6
- package/dist/transactions/TransactionQueue.d.ts +7 -1
- package/dist/transactions/TransactionQueue.js +43 -2
- package/dist/wire/frames.d.ts +21 -4
- package/docs/concurrency-convention.md +222 -0
- package/docs/coordination.md +5 -0
- package/docs/data-sources.md +41 -0
- package/package.json +6 -1
package/dist/wire/frames.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Changing any shape here is a wire-contract change — it requires
|
|
19
19
|
* coordinated client + server updates.
|
|
20
20
|
*/
|
|
21
|
-
import type { OnStaleMode } from '../coordination/index.js';
|
|
21
|
+
import type { OnStaleMode, StaleNotification, ReadDependency } from '../coordination/index.js';
|
|
22
22
|
import type { ErrorCode, RequiredCapability } from '../errors.js';
|
|
23
23
|
/**
|
|
24
24
|
* A single operation within a {@link CommitMessage} batch. The atomic unit
|
|
@@ -44,9 +44,10 @@ export interface CommitOperation {
|
|
|
44
44
|
*/
|
|
45
45
|
readAt?: number | null;
|
|
46
46
|
/**
|
|
47
|
-
* Mode on stale detection. `'reject'` (default) throws
|
|
48
|
-
* AbloStaleContextError; `'
|
|
49
|
-
* `'
|
|
47
|
+
* Mode on stale detection (non-coercion). `'reject'` (default) throws
|
|
48
|
+
* AbloStaleContextError; `'overwrite'` applies unconditionally (blind LWW);
|
|
49
|
+
* `'notify'` holds the write and returns a `StaleNotification` for the actor
|
|
50
|
+
* to resolve.
|
|
50
51
|
*/
|
|
51
52
|
onStale?: OnStaleMode | null;
|
|
52
53
|
}
|
|
@@ -84,6 +85,15 @@ export interface CommitMessage {
|
|
|
84
85
|
* `null` (the audit pane treats null as "no prompt-side context").
|
|
85
86
|
*/
|
|
86
87
|
causedByTaskId?: string | null;
|
|
88
|
+
/**
|
|
89
|
+
* Batch-level read dependencies (the STORM "did anything I looked at
|
|
90
|
+
* change?" layer). Each entry is a row (`{model,id,readAt,fields?}`) or a
|
|
91
|
+
* sync group (`{group,readAt}`) the batch's writes were premised on; the
|
|
92
|
+
* server validates none moved since `readAt` and fires the entry's
|
|
93
|
+
* `onStale` disposition over the batch. Omitted ⇒ only write-targets are
|
|
94
|
+
* checked (legacy behavior).
|
|
95
|
+
*/
|
|
96
|
+
reads?: ReadDependency[] | null;
|
|
87
97
|
};
|
|
88
98
|
}
|
|
89
99
|
/**
|
|
@@ -105,6 +115,13 @@ export interface MutationResultMessage {
|
|
|
105
115
|
status?: 'confirmed' | 'rejected';
|
|
106
116
|
lastSyncId?: number;
|
|
107
117
|
ops?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Stale-context notifications for `onStale: 'notify' ops whose
|
|
120
|
+
* premise moved concurrently. Present only on a successful ack that hit a
|
|
121
|
+
* notify-resolved conflict; the client surfaces these via the
|
|
122
|
+
* `conflict:notified` event and the commit receipt instead of rejecting.
|
|
123
|
+
*/
|
|
124
|
+
notifications?: StaleNotification[];
|
|
108
125
|
error?: {
|
|
109
126
|
code: ErrorCode;
|
|
110
127
|
message: string;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Concurrency Convention
|
|
2
|
+
|
|
3
|
+
> The governing convention for how Ablo resolves concurrent writes to shared
|
|
4
|
+
> state, and the boundaries of that convention. This is the contract; the
|
|
5
|
+
> three-layer mechanics live in [`coordination.md`](./coordination.md).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. The principle: non-coercion
|
|
10
|
+
|
|
11
|
+
**The engine surfaces the truthful current state and lets the intelligent actor —
|
|
12
|
+
agent or human — decide what to do. It does not force a resolution.**
|
|
13
|
+
|
|
14
|
+
That is the whole convention. Everything below is a consequence of it.
|
|
15
|
+
|
|
16
|
+
Classical concurrency control is *coercive*: it imposes the remedy. Two-phase
|
|
17
|
+
locking forces a block; optimistic concurrency forces an abort. Ablo's wager is
|
|
18
|
+
that the actor in the loop (an agent reasoning over the change, or a human
|
|
19
|
+
watching the row) is better placed to resolve a conflict than a fixed rule baked
|
|
20
|
+
into the storage layer. So the engine's job narrows to one thing: **report what
|
|
21
|
+
is true, on time, and get out of the way.**
|
|
22
|
+
|
|
23
|
+
There are two forms of non-coercion, and they are the same principle at two
|
|
24
|
+
moments in time:
|
|
25
|
+
|
|
26
|
+
| form | when | mechanism |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Claim** | *prospective* — before you act | reserve the row; others queue. Coordinate so the conflict never forms. |
|
|
29
|
+
| **Notification** | *in-flight* — after a concurrent change | surface the changed value; the actor resolves and re-issues. |
|
|
30
|
+
|
|
31
|
+
Use a claim when you will hold the row across a slow read→reason→write gap. Use a
|
|
32
|
+
notification when you didn't, and the premise moved under you.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. The dispositions (`onStale`)
|
|
37
|
+
|
|
38
|
+
Every guarded write (and every read dependency, §4) declares how a stale premise
|
|
39
|
+
should be handled. Three modes, split by whether they **force** an outcome:
|
|
40
|
+
|
|
41
|
+
| mode | coercive? | what the engine does | who resolves | use when |
|
|
42
|
+
|---|---|---|---|---|
|
|
43
|
+
| `notify` | **No** — surface + delegate | Holds the write (does **not** apply it); returns a `StaleNotification` with the current value. | The actor (agent or human) reconciles and re-issues. | The aligned mode: tell the actor what changed, let it solve. |
|
|
44
|
+
| `reject` | **Yes** — force-abort | Throws `AbloStaleContextError`; the batch is discarded. | The caller retries from scratch. | Hard invariants; legacy/strict callers. The current default. |
|
|
45
|
+
| `overwrite` | **Yes** — force-clobber | Overwrites blindly last-writer-wins; **no** signal. | Nobody. | You genuinely own the field and concurrent values are noise. |
|
|
46
|
+
|
|
47
|
+
> `notify` is the convention. `reject` and `overwrite` are escape hatches for the
|
|
48
|
+
> two ends — "never let this be wrong" and "never bother me." They are not the
|
|
49
|
+
> spirit; they are the boundary of it.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 3. What is checked: two footprints
|
|
54
|
+
|
|
55
|
+
A conflict is a **footprint intersection** — your operation's footprint overlaps
|
|
56
|
+
a concurrent delta. Ablo checks two footprints, and they are independent:
|
|
57
|
+
|
|
58
|
+
| footprint | declared by | question | scope |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| **Write-target** | per-op `readAt` | "did a row I'm **writing** change since I read it?" | the rows in `operations[]` |
|
|
61
|
+
| **Read-set** | batch-level `reads[]` | "did anything I **looked at** change since I read it?" | rows/groups in `reads[]`, even if not written |
|
|
62
|
+
|
|
63
|
+
The write-target check alone is the narrow case the canary anomaly defeats: an
|
|
64
|
+
agent reads `deal.stage`, writes `task.status`, and a peer moves `deal.stage` —
|
|
65
|
+
`task` never changed, so a write-target-only check waves it through. The read-set
|
|
66
|
+
closes that gap.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 4. The read-set (`reads[]`)
|
|
71
|
+
|
|
72
|
+
A commit may declare, at the batch level, the premises its writes depended on.
|
|
73
|
+
Two granularities, developer's choice per entry:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
reads: [
|
|
77
|
+
{ model: 'Slide', id: 's-1', readAt: N, fields?: ['title'] }, // ROW premise
|
|
78
|
+
{ group: 'deck:abc', readAt: N, onStale: 'notify' }, // GROUP premise
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Row** — did this specific row (optionally these fields) change? The literal
|
|
83
|
+
per-object premise.
|
|
84
|
+
- **Group** — did *anything* in this sync group change? `group` is a sync-group
|
|
85
|
+
key (`deck:abc`, `slide:s1`, `org:X`) — the same unit a participant **watches
|
|
86
|
+
and claims**. This is the more Ablo-native granularity.
|
|
87
|
+
|
|
88
|
+
**Boundary — a stale read fires over the whole batch.** A read dependency is a
|
|
89
|
+
premise for *all* the writes in the commit, so its disposition governs the batch:
|
|
90
|
+
`reject` aborts it, `notify` holds **every** write and notifies, `overwrite`
|
|
91
|
+
lets them land. Per-entry `onStale` defaults to `reject`.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 5. The notification (`StaleNotification`)
|
|
96
|
+
|
|
97
|
+
The non-coercive modes hand back data instead of throwing. The signal is
|
|
98
|
+
delivered **twice**, by design — once as a value, once as an event:
|
|
99
|
+
|
|
100
|
+
- On the **commit receipt**: `receipt.notifications` (and `CommitResult.notifications`).
|
|
101
|
+
- On the **event channel**: `conflict:notified` (mirrors `reconciliation:needed` /
|
|
102
|
+
`sync:rollback`).
|
|
103
|
+
|
|
104
|
+
Shape (canonical in `coordination/schema.ts`):
|
|
105
|
+
|
|
106
|
+
| field | meaning |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `object` | Stripe-style type tag — `'stale_notification'` |
|
|
109
|
+
| `model`, `id` | the conflicting row (for a group dep, both are the group key) |
|
|
110
|
+
| `group?` | set when this is a group-scoped notification |
|
|
111
|
+
| `readAt` | the watermark the committer reasoned against |
|
|
112
|
+
| `observedSyncId` | the newest delta on the premise — re-read at/after this |
|
|
113
|
+
| `conflictingFields` | fields that moved (empty for group / whole-entity) |
|
|
114
|
+
| `currentValues` | the live values of those fields — the premise to reconcile against (empty for group) |
|
|
115
|
+
| `writtenBy` | `{ kind, id }` of the concurrent author, reported faithfully |
|
|
116
|
+
|
|
117
|
+
Only `notify` produces a notification (the write was held). `reject` throws and
|
|
118
|
+
`overwrite` is silent — neither notifies.
|
|
119
|
+
|
|
120
|
+
### 5.1 The receive → reconcile loop
|
|
121
|
+
|
|
122
|
+
You receive the signal two ways (same payload), then re-commit against the fresh
|
|
123
|
+
watermark. The engine never re-issues for you — the actor decides.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// Trigger: a guarded write under the non-coercive mode.
|
|
127
|
+
const receipt = await ablo.task.update({
|
|
128
|
+
id, data: { status: 'blocked' },
|
|
129
|
+
readAt: myWatermark,
|
|
130
|
+
onStale: 'notify',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Receive — pull: the held write surfaces on the receipt.
|
|
134
|
+
for (const n of receipt.notifications ?? []) reconcile(n);
|
|
135
|
+
|
|
136
|
+
// Receive — push: the same StaleNotification[] fires ambiently on the socket.
|
|
137
|
+
ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(reconcile));
|
|
138
|
+
|
|
139
|
+
function reconcile(n: StaleNotification) {
|
|
140
|
+
// n.currentValues — what's actually there now (e.g. { status: 'done' })
|
|
141
|
+
// n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
|
|
142
|
+
if (!stillValid(n.currentValues)) return; // premise gone → drop the write
|
|
143
|
+
|
|
144
|
+
return ablo.task.update({
|
|
145
|
+
id: n.id,
|
|
146
|
+
data: { status: 'blocked' },
|
|
147
|
+
readAt: n.observedSyncId, // adopt the new high-water mark — this is what terminates the loop
|
|
148
|
+
onStale: 'notify',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The loop **terminates** because each retry advances `readAt` to `observedSyncId`;
|
|
154
|
+
a peer that keeps writing only ever notifies you against a *newer* baseline, never
|
|
155
|
+
the same one twice. A group read-dep reconciles identically, except `group` is set
|
|
156
|
+
and `currentValues` is empty (re-read the group).
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 6. Boundaries & invariants
|
|
161
|
+
|
|
162
|
+
What the convention **guarantees**, and where it **stops**:
|
|
163
|
+
|
|
164
|
+
1. **Engine surfaces, actor decides.** For `flag`/`merge` the engine never
|
|
165
|
+
repairs, merges, or re-plans. It reports `currentValues` and the actor (agent
|
|
166
|
+
or human) owns the resolution. The engine does not distinguish them — it is
|
|
167
|
+
actor-neutral by design.
|
|
168
|
+
|
|
169
|
+
2. **Truthfulness.** `currentValues` / `observedSyncId` reflect committed state at
|
|
170
|
+
detection time, inside the same transaction as the write. A notification is
|
|
171
|
+
never speculative.
|
|
172
|
+
|
|
173
|
+
3. **Termination (no livelock).** The monotonic `sync_id` landing order is the
|
|
174
|
+
serialization order. The stale committer always yields/recomputes — an
|
|
175
|
+
asymmetry that rules out the symmetric notify-rewrite livelock. Unbounded
|
|
176
|
+
retry is bounded by the client's reconciliation retry cap.
|
|
177
|
+
|
|
178
|
+
4. **Scope: reversible DB state only.** The convention governs writes to the
|
|
179
|
+
shared database, which are inherently reversible (prior value in
|
|
180
|
+
`sync_deltas`). **Irreversible external side-effects** (emails, payments,
|
|
181
|
+
third-party calls) are *out of scope* — the engine cannot hold or undo them,
|
|
182
|
+
so they must not be gated by `flag`/`merge`.
|
|
183
|
+
|
|
184
|
+
5. **Defaults.** A plain write (no `readAt`) is last-writer-wins with **no**
|
|
185
|
+
check. A guarded write with `readAt` but no `onStale` defaults to `reject`
|
|
186
|
+
(back-compat). *Open decision (§7).*
|
|
187
|
+
|
|
188
|
+
6. **Policy seam.** Custom `ConflictPolicy` functions see **write-target**
|
|
189
|
+
conflicts (`stale_context` / `claim_held`). **Read-set** conflicts are
|
|
190
|
+
currently resolved directly via each entry's `onStale`, not through the policy
|
|
191
|
+
seam. *Open decision (§7).*
|
|
192
|
+
|
|
193
|
+
7. **Claims win when held.** A non-holder writing to a claimed row is rejected
|
|
194
|
+
(`AbloClaimedError`) regardless of `readAt` — the prospective form takes
|
|
195
|
+
precedence over the in-flight form. Only `user`/`system` principals may
|
|
196
|
+
`bypass` a foreign claim; agents may not.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 7. Open decisions (bounded, not yet made)
|
|
201
|
+
|
|
202
|
+
These are deliberately left open; they change behavior and are the user's call.
|
|
203
|
+
|
|
204
|
+
- **Default disposition for agents.** Should an agent-participant guarded write
|
|
205
|
+
default to `flag` (philosophy-aligned: surface, don't force) instead of
|
|
206
|
+
`reject` (back-compat)? Trade-off: alignment vs. a behavior change for existing
|
|
207
|
+
agent callers.
|
|
208
|
+
- **Read-deps through the policy seam.** Should read-set conflicts also pass
|
|
209
|
+
through `ConflictPolicy` (requires a group-aware conflict shape), or stay on
|
|
210
|
+
the direct `onStale` mapping?
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 8. Out of scope
|
|
215
|
+
|
|
216
|
+
- Irreversible external side-effects (§6.4) — not gated by this convention.
|
|
217
|
+
- Cross-object *serializability proof*. The read-set is a sound premise check,
|
|
218
|
+
not a full precedence-graph guarantee; it needs declared reads to catch a
|
|
219
|
+
premise, and a caller that declares none gets only write-target checking.
|
|
220
|
+
- Identity → participant-kind mapping. `writtenBy.kind` reports whatever
|
|
221
|
+
authenticated (an `sk_` key resolves to `system`, not `agent`); how identities
|
|
222
|
+
map to kinds is a separate concern.
|
package/docs/coordination.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Coordination Reference
|
|
2
2
|
|
|
3
|
+
> **Governing convention:** [`concurrency-convention.md`](./concurrency-convention.md)
|
|
4
|
+
> — the non-coercion principle (surface state, let the actor decide), the full
|
|
5
|
+
> `onStale` taxonomy, the read-set (`reads[]`), and the boundaries. Read that for
|
|
6
|
+
> the *why* and the contract; this reference is the *how* (claim mechanics + API).
|
|
7
|
+
|
|
3
8
|
Coordinate long-running work on a row so humans and agents don't clobber each
|
|
4
9
|
other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
|
|
5
10
|
is **last-write-wins** by default. For lost-update detection, take a claim or pass
|
package/docs/data-sources.md
CHANGED
|
@@ -205,6 +205,47 @@ await ablo.weatherReports.update({
|
|
|
205
205
|
});
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
## Local development & locked-down VPC — the reverse channel
|
|
209
|
+
|
|
210
|
+
The signed route above is an **inbound webhook**: Ablo Cloud calls your HTTPS
|
|
211
|
+
endpoint. That needs a public URL — which `localhost` doesn't have, and a
|
|
212
|
+
locked-down VPC won't expose. The reverse channel fixes both. A connector you run
|
|
213
|
+
next to your database dials an **outbound** WebSocket to Ablo Cloud and serves the
|
|
214
|
+
same `commit`/`load`/`list` requests over it — the Stripe-CLI `stripe listen`
|
|
215
|
+
pattern. No tunnel, no public endpoint, and your database credentials never leave
|
|
216
|
+
your process.
|
|
217
|
+
|
|
218
|
+
It wraps the **same handler** your deployed route uses — share the options object,
|
|
219
|
+
so there's zero handler drift:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// scripts/ablo-connector.ts — run locally, or as a sidecar in a private VPC
|
|
223
|
+
import { dataSource, createSourceConnector } from '@abloatai/ablo';
|
|
224
|
+
import { sourceOptions } from '@/ablo/source'; // the same object route.ts passes
|
|
225
|
+
|
|
226
|
+
const connector = createSourceConnector({
|
|
227
|
+
apiKey: process.env.ABLO_API_KEY!, // sk_test_* for the dev loop
|
|
228
|
+
handler: dataSource(sourceOptions), // the unchanged (Request) => Response
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
await connector.run(controller.signal); // dials out, serves until aborted
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
When a connector is attached for a source, Ablo Cloud drains that source's
|
|
236
|
+
`commit`/`load`/`list` down the socket instead of POSTing the webhook; when none
|
|
237
|
+
is attached, the inbound webhook path is used unchanged. The drained requests
|
|
238
|
+
carry the **same** Standard Webhooks signature, so `dataSource` verifies them
|
|
239
|
+
exactly as on the webhook path — the transport changes, the trust model does not.
|
|
240
|
+
|
|
241
|
+
**Production / no-public-URL deploy.** By default the connector is gated to
|
|
242
|
+
`sk_test_*` keys (the dev-loop affordance). A customer who genuinely cannot expose
|
|
243
|
+
an inbound endpoint — a locked-down VPC — can run the connector as their deployed
|
|
244
|
+
production transport by opting that source into `reverseChannelProd` and using an
|
|
245
|
+
`sk_live_*` key. The inbound webhook remains the default for everyone else (it's
|
|
246
|
+
stateless and lower-latency); the reverse channel is the escape hatch for
|
|
247
|
+
no-inbound environments.
|
|
248
|
+
|
|
208
249
|
## Commit Request
|
|
209
250
|
|
|
210
251
|
When Ablo calls your Data Source, it sends a signed JSON request:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "The Collaboration Layer For AI Agents",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -88,6 +88,11 @@
|
|
|
88
88
|
"import": "./dist/keys/index.js",
|
|
89
89
|
"default": "./dist/keys/index.js"
|
|
90
90
|
},
|
|
91
|
+
"./auth": {
|
|
92
|
+
"types": "./dist/auth/index.d.ts",
|
|
93
|
+
"import": "./dist/auth/index.js",
|
|
94
|
+
"default": "./dist/auth/index.js"
|
|
95
|
+
},
|
|
91
96
|
"./environment": {
|
|
92
97
|
"types": "./dist/environment.d.ts",
|
|
93
98
|
"import": "./dist/environment.js",
|