@abloatai/ablo 0.13.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 +49 -0
- package/dist/BaseSyncedStore.js +39 -32
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/batching/index.d.ts +57 -0
- package/dist/batching/index.js +150 -0
- package/dist/cli.cjs +63 -1
- package/dist/client/Ablo.d.ts +43 -28
- package/dist/client/Ablo.js +12 -5
- package/dist/client/auth.js +11 -0
- package/dist/client/createModelProxy.d.ts +33 -8
- package/dist/client/createModelProxy.js +4 -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/errorCodes.d.ts +3 -1
- package/dist/errorCodes.js +10 -1
- 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/api.md +6 -5
- package/docs/concurrency-convention.md +222 -0
- package/docs/coordination.md +16 -11
- package/docs/data-sources.md +41 -0
- package/docs/react.md +69 -0
- package/package.json +11 -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;
|
package/docs/api.md
CHANGED
|
@@ -124,10 +124,11 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
124
124
|
| `id` | string | Unique identifier for the claim. |
|
|
125
125
|
| `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
|
|
126
126
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
127
|
-
| `
|
|
127
|
+
| `reason` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
|
|
128
128
|
| `heldBy` | string | Participant id holding the claim. |
|
|
129
129
|
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
130
|
-
| `
|
|
130
|
+
| `createdAt` | number? | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
131
|
+
| `expiresAt` | number | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
|
|
131
132
|
|
|
132
133
|
```json
|
|
133
134
|
{
|
|
@@ -135,10 +136,10 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
135
136
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
136
137
|
"status": "active",
|
|
137
138
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
138
|
-
"
|
|
139
|
+
"reason": "editing",
|
|
139
140
|
"heldBy": "agent:report-writer",
|
|
140
141
|
"participantKind": "agent",
|
|
141
|
-
"expiresAt":
|
|
142
|
+
"expiresAt": 1716580000000
|
|
142
143
|
}
|
|
143
144
|
```
|
|
144
145
|
|
|
@@ -175,7 +176,7 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
|
|
|
175
176
|
const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
176
177
|
if (claim) {
|
|
177
178
|
claim.heldBy;
|
|
178
|
-
claim.
|
|
179
|
+
claim.reason;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
const handle = await ablo.weatherReports.claim({
|
|
@@ -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
|
|
@@ -72,23 +77,23 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
72
77
|
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
73
78
|
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
|
|
74
79
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
75
|
-
| `
|
|
80
|
+
| `reason` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
|
|
76
81
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
77
82
|
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
78
83
|
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
79
|
-
| `createdAt` | `
|
|
80
|
-
| `expiresAt` | `
|
|
84
|
+
| `createdAt` | `number?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
85
|
+
| `expiresAt` | `number` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
|
|
81
86
|
|
|
82
87
|
```jsonc
|
|
83
88
|
{
|
|
84
89
|
"id": "claim_8fJ2",
|
|
85
90
|
"status": "active",
|
|
86
91
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
87
|
-
"
|
|
92
|
+
"reason": "editing",
|
|
88
93
|
"heldBy": "agent:forecaster",
|
|
89
94
|
"participantKind": "agent",
|
|
90
|
-
"createdAt":
|
|
91
|
-
"expiresAt":
|
|
95
|
+
"createdAt": 1748160000000,
|
|
96
|
+
"expiresAt": 1748160030000
|
|
92
97
|
}
|
|
93
98
|
```
|
|
94
99
|
|
|
@@ -129,9 +134,9 @@ so two claimers can't both think they won.
|
|
|
129
134
|
| name | type | required | description |
|
|
130
135
|
|---|---|---|---|
|
|
131
136
|
| `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
|
|
132
|
-
| `options.
|
|
137
|
+
| `options.reason` | `string` | no | Phase shown to observers (default `'editing'`). Serialized on the wire as `action`. |
|
|
133
138
|
| `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
|
|
134
|
-
| `options.
|
|
139
|
+
| `options.queue` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
|
|
135
140
|
| `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
|
|
136
141
|
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
137
142
|
|
|
@@ -209,7 +214,7 @@ is free.
|
|
|
209
214
|
|
|
210
215
|
```ts
|
|
211
216
|
const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
212
|
-
if (who) console.log(`${who.heldBy} is ${who.
|
|
217
|
+
if (who) console.log(`${who.heldBy} is ${who.reason}`);
|
|
213
218
|
```
|
|
214
219
|
|
|
215
220
|
Returns the active claim state when the row is held, or `null` when it's free:
|
|
@@ -219,10 +224,10 @@ Returns the active claim state when the row is held, or `null` when it's free:
|
|
|
219
224
|
"id": "claim_8fJ2",
|
|
220
225
|
"status": "active",
|
|
221
226
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
222
|
-
"
|
|
227
|
+
"reason": "editing",
|
|
223
228
|
"heldBy": "agent:forecaster",
|
|
224
229
|
"participantKind": "agent",
|
|
225
|
-
"expiresAt":
|
|
230
|
+
"expiresAt": 1748160030000
|
|
226
231
|
}
|
|
227
232
|
```
|
|
228
233
|
|
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/docs/react.md
CHANGED
|
@@ -224,6 +224,75 @@ function wired into the provider (bound to your transport). If no `beginClaim`
|
|
|
224
224
|
is wired, the returned invoker throws `AbloValidationError` with code
|
|
225
225
|
`claim_not_wired`.
|
|
226
226
|
|
|
227
|
+
## useWatch — scoped presence + read interest
|
|
228
|
+
|
|
229
|
+
`useWatch` is the React form of `ablo.<model>.watch`. It joins multiplayer for a
|
|
230
|
+
scope on the engine's existing socket (one TCP connection, N logical
|
|
231
|
+
sub-syncgroup participants) and returns the reactive participant facade. Use it
|
|
232
|
+
when a mount should both *see* who else is on an entity and, optionally, declare
|
|
233
|
+
write interest in it.
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
'use client';
|
|
237
|
+
|
|
238
|
+
import { useWatch } from '@abloatai/ablo/react';
|
|
239
|
+
|
|
240
|
+
export function DeckPresence({ deckId }: { deckId: string }) {
|
|
241
|
+
const { peers, claims, status } = useWatch({
|
|
242
|
+
scope: { slideDecks: deckId },
|
|
243
|
+
claim: true, // I intend to write — pin the scope + let peers observe the claim
|
|
244
|
+
hydrate: true, // backfill the deck's current rows if not already loaded
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (status !== 'joined') return <span>connecting…</span>;
|
|
248
|
+
return <span>{peers.length} other{peers.length === 1 ? '' : 's'} here</span>;
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Options (`UseWatchOptions`):
|
|
253
|
+
|
|
254
|
+
| Option | Default | Effect |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| `scope` | — | Model-form scope (`{ slideDecks: id }`), resolved through the schema. Omit for engine-wide. |
|
|
257
|
+
| `claim` | `false` | Acquire a write-claim on the scope (sent so peers observe it; pins the scope so it never warm-drops while held). A viewer is not a claimant — leave `false` for read-only. |
|
|
258
|
+
| `hydrate` | `false` | Backfill the scope's current rows into the pool once on enter, then keep them fresh via the live tail. Set `true` for deep-linked / never-opened entities. Single-flight; soft-fails. |
|
|
259
|
+
| `ttlSeconds` | — | Lease TTL for the scope claim. |
|
|
260
|
+
| `paused` | `false` | Tear down and don't re-join while true. |
|
|
261
|
+
|
|
262
|
+
Returns (`UseWatchReturn`): `{ participant, peers, claims, status, error }`.
|
|
263
|
+
`peers` is everyone else on the scope's sync groups; `claims` is their active
|
|
264
|
+
write-claims; `status` is the join lifecycle. Auto-cleans up on unmount or when
|
|
265
|
+
`paused` flips true.
|
|
266
|
+
|
|
267
|
+
## usePeers — read-only presence
|
|
268
|
+
|
|
269
|
+
`usePeers` is a *pure reader* of the presence stream already flowing on the
|
|
270
|
+
connection. Unlike `useWatch`, it does **not** enter/leave a scope (no
|
|
271
|
+
`update_subscription`, no warm-TTL churn) — so reading it never changes what the
|
|
272
|
+
connection is subscribed to.
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
'use client';
|
|
276
|
+
|
|
277
|
+
import { usePeers } from '@abloatai/ablo/react';
|
|
278
|
+
|
|
279
|
+
export function CursorBroadcaster({ deckId }: { deckId: string }) {
|
|
280
|
+
const peers = usePeers({ slideDecks: deckId });
|
|
281
|
+
const alone = !peers.some((p) => p.participantKind === 'user');
|
|
282
|
+
// suppress live-cursor broadcasts while alone
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Pass `scope` to narrow to a sync group's peers, or omit it for everyone on the
|
|
287
|
+
engine's groups. Returns `ReadonlyArray<Peer>`, where each `Peer` carries
|
|
288
|
+
`participantKind` (`'user' | 'agent' | 'system'`), `participantId`, optional
|
|
289
|
+
`label`, `syncGroups`, `activity`, `lastActive`, and optional `activeClaims`.
|
|
290
|
+
|
|
291
|
+
Reach for `usePeers` (not a second `useWatch`) when some **other** mount already
|
|
292
|
+
owns the scope's read interest — scope `leave` is not reference-counted, so a
|
|
293
|
+
second `useWatch` on the same scope would warm-drop the owner's subscription on
|
|
294
|
+
unmount.
|
|
295
|
+
|
|
227
296
|
## Next.js
|
|
228
297
|
|
|
229
298
|
The Next.js [App Router landing](/nextjs) walks through Server Components
|
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",
|
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
"import": "./dist/core/index.js",
|
|
44
44
|
"default": "./dist/core/index.js"
|
|
45
45
|
},
|
|
46
|
+
"./batching": {
|
|
47
|
+
"types": "./dist/batching/index.d.ts",
|
|
48
|
+
"import": "./dist/batching/index.js",
|
|
49
|
+
"default": "./dist/batching/index.js"
|
|
50
|
+
},
|
|
46
51
|
"./agent": {
|
|
47
52
|
"types": "./dist/agent/index.d.ts",
|
|
48
53
|
"import": "./dist/agent/index.js",
|
|
@@ -83,6 +88,11 @@
|
|
|
83
88
|
"import": "./dist/keys/index.js",
|
|
84
89
|
"default": "./dist/keys/index.js"
|
|
85
90
|
},
|
|
91
|
+
"./auth": {
|
|
92
|
+
"types": "./dist/auth/index.d.ts",
|
|
93
|
+
"import": "./dist/auth/index.js",
|
|
94
|
+
"default": "./dist/auth/index.js"
|
|
95
|
+
},
|
|
86
96
|
"./environment": {
|
|
87
97
|
"types": "./dist/environment.d.ts",
|
|
88
98
|
"import": "./dist/environment.js",
|