@abloatai/ablo 0.14.0 → 0.15.1
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 +51 -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 +34 -3
- package/dist/client/Ablo.js +11 -4
- package/dist/client/ApiClient.js +3 -0
- 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/errors.d.ts +19 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +10 -1
- package/dist/interfaces/index.d.ts +23 -2
- package/dist/policy/types.d.ts +35 -3
- package/dist/policy/types.js +20 -7
- package/dist/server/commit.d.ts +26 -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 +28 -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
|
@@ -409,6 +409,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
409
409
|
executingCount = 0;
|
|
410
410
|
// Optimistic update tracking
|
|
411
411
|
optimisticUpdates = new Map();
|
|
412
|
+
// Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
|
|
413
|
+
// transaction id, populated from the commit ack and drained by
|
|
414
|
+
// `waitForCommitReceipt` so the receipt carries the self-heal signal.
|
|
415
|
+
commitNotifications = new Map();
|
|
412
416
|
// LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
|
|
413
417
|
// Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
|
|
414
418
|
deltaConfirmationTimeouts = new Map();
|
|
@@ -1127,6 +1131,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1127
1131
|
const result = await this.mutationExecutor.commit(operations);
|
|
1128
1132
|
const lastSyncId = result?.lastSyncId ?? 0;
|
|
1129
1133
|
this.noteAck(lastSyncId);
|
|
1134
|
+
// Notify-instead-of-abort: the server returned stale-context
|
|
1135
|
+
// notifications for `onStale: 'notify'` ops whose premise moved.
|
|
1136
|
+
// Every notified op was HELD (not written) — its optimistic state
|
|
1137
|
+
// must be rolled back here; no delta will ever confirm it. We also
|
|
1138
|
+
// stamp the signal so `waitForCommitReceipt` carries it onto the
|
|
1139
|
+
// receipt.
|
|
1140
|
+
const notifications = result?.notifications;
|
|
1141
|
+
const heldIds = new Set((notifications ?? []).map((n) => n.id));
|
|
1142
|
+
for (const { tx } of batchOps) {
|
|
1143
|
+
const txNotifs = notifications?.filter((n) => n.id === tx.modelId);
|
|
1144
|
+
if (txNotifs && txNotifs.length > 0) {
|
|
1145
|
+
this.commitNotifications.set(tx.id, txNotifs);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1130
1148
|
// Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
|
|
1131
1149
|
if (lastSyncId === 0) {
|
|
1132
1150
|
getContext().observability.captureCommitZeroSyncId({
|
|
@@ -1137,6 +1155,19 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1137
1155
|
// LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
|
|
1138
1156
|
// Transactions will be confirmed when any delta with id >= lastSyncId arrives
|
|
1139
1157
|
for (const { tx } of batchOps) {
|
|
1158
|
+
// Held op ('notify'): the server withheld the write, so no delta
|
|
1159
|
+
// will confirm it. Roll back the optimistic update (server
|
|
1160
|
+
// wins) and complete the transaction now — the agent self-heals
|
|
1161
|
+
// from the notification rather than waiting out the delta
|
|
1162
|
+
// timeout. The receipt still resolves (commit succeeded).
|
|
1163
|
+
if (heldIds.has(tx.modelId)) {
|
|
1164
|
+
await this.rollbackOptimistic(tx, 'conflict_server_wins');
|
|
1165
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
1166
|
+
this.emit('transaction:completed', tx);
|
|
1167
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1168
|
+
this.optimisticUpdates.delete(tx.id);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1140
1171
|
tx.syncIdNeededForCompletion = lastSyncId;
|
|
1141
1172
|
// Safety net: when lastSyncId is 0, DELETE transactions should be confirmed
|
|
1142
1173
|
// immediately. DELETEs are idempotent — if no delta was emitted, the entity
|
|
@@ -1528,6 +1559,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1528
1559
|
kind: 'commit',
|
|
1529
1560
|
operations: [...operations],
|
|
1530
1561
|
causedByTaskId: options.causedByTaskId ?? null,
|
|
1562
|
+
...(options.reads ? { reads: options.reads } : {}),
|
|
1531
1563
|
status: 'pending',
|
|
1532
1564
|
createdAt: Date.now(),
|
|
1533
1565
|
attempts: 0,
|
|
@@ -1565,6 +1597,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1565
1597
|
const result = await this.mutationExecutor.commit(tx.operations, {
|
|
1566
1598
|
idempotencyKey: tx.id,
|
|
1567
1599
|
causedByTaskId: tx.causedByTaskId ?? undefined,
|
|
1600
|
+
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1568
1601
|
});
|
|
1569
1602
|
tx.lastSyncId = result?.lastSyncId ?? 0;
|
|
1570
1603
|
this.noteAck(tx.lastSyncId);
|
|
@@ -1610,10 +1643,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1610
1643
|
* of `ablo.commits.create()`.
|
|
1611
1644
|
*/
|
|
1612
1645
|
waitForCommitReceipt(clientTxId) {
|
|
1646
|
+
// Drain any stale-context notifications stamped for this tx on the ack.
|
|
1647
|
+
const drainNotifications = () => {
|
|
1648
|
+
const n = this.commitNotifications.get(clientTxId);
|
|
1649
|
+
if (!n)
|
|
1650
|
+
return undefined;
|
|
1651
|
+
this.commitNotifications.delete(clientTxId);
|
|
1652
|
+
return n.length > 0 ? n : undefined;
|
|
1653
|
+
};
|
|
1613
1654
|
return new Promise((resolve, reject) => {
|
|
1614
1655
|
const existing = this.commitStore.get(clientTxId);
|
|
1615
1656
|
if (existing?.status === 'completed') {
|
|
1616
|
-
resolve({ lastSyncId: existing.lastSyncId ?? 0 });
|
|
1657
|
+
resolve({ lastSyncId: existing.lastSyncId ?? 0, notifications: drainNotifications() });
|
|
1617
1658
|
return;
|
|
1618
1659
|
}
|
|
1619
1660
|
if (existing?.status === 'failed' && existing.error) {
|
|
@@ -1622,7 +1663,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1622
1663
|
}
|
|
1623
1664
|
const onCompleted = (tx) => {
|
|
1624
1665
|
cleanup();
|
|
1625
|
-
resolve({ lastSyncId: tx.lastSyncId ?? 0 });
|
|
1666
|
+
resolve({ lastSyncId: tx.lastSyncId ?? 0, notifications: drainNotifications() });
|
|
1626
1667
|
};
|
|
1627
1668
|
const onFailed = ({ error }) => {
|
|
1628
1669
|
cleanup();
|
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,20 @@ 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[];
|
|
125
|
+
/**
|
|
126
|
+
* Ids of UPDATE/DELETE targets that matched ZERO rows (don't exist or are
|
|
127
|
+
* outside the org). Present (non-empty) only when a write missed. The
|
|
128
|
+
* client turns this into a loud `AbloNotFoundError` for the affected
|
|
129
|
+
* caller instead of treating the no-op as success.
|
|
130
|
+
*/
|
|
131
|
+
missingIds?: string[];
|
|
108
132
|
error?: {
|
|
109
133
|
code: ErrorCode;
|
|
110
134
|
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.1",
|
|
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",
|