@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/BaseSyncedStore.js +39 -32
  3. package/dist/Database.d.ts +1 -1
  4. package/dist/auth/index.d.ts +4 -0
  5. package/dist/auth/index.js +1 -0
  6. package/dist/batching/index.d.ts +57 -0
  7. package/dist/batching/index.js +150 -0
  8. package/dist/cli.cjs +63 -1
  9. package/dist/client/Ablo.d.ts +43 -28
  10. package/dist/client/Ablo.js +12 -5
  11. package/dist/client/auth.js +11 -0
  12. package/dist/client/createModelProxy.d.ts +33 -8
  13. package/dist/client/createModelProxy.js +4 -4
  14. package/dist/client/sessionMint.js +1 -0
  15. package/dist/client/writeOptionsSchema.d.ts +4 -6
  16. package/dist/client/writeOptionsSchema.js +1 -1
  17. package/dist/coordination/schema.d.ts +90 -12
  18. package/dist/coordination/schema.js +99 -4
  19. package/dist/errorCodes.d.ts +3 -1
  20. package/dist/errorCodes.js +10 -1
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +9 -0
  23. package/dist/interfaces/index.d.ts +18 -2
  24. package/dist/policy/types.d.ts +35 -3
  25. package/dist/policy/types.js +20 -7
  26. package/dist/server/commit.d.ts +17 -0
  27. package/dist/source/connector-protocol.d.ts +159 -0
  28. package/dist/source/connector-protocol.js +161 -0
  29. package/dist/source/connector.d.ts +96 -0
  30. package/dist/source/connector.js +264 -0
  31. package/dist/source/contract.d.ts +4 -6
  32. package/dist/source/contract.js +1 -1
  33. package/dist/source/index.d.ts +3 -1
  34. package/dist/source/index.js +6 -0
  35. package/dist/sync/SyncWebSocket.d.ts +32 -5
  36. package/dist/sync/SyncWebSocket.js +40 -6
  37. package/dist/transactions/TransactionQueue.d.ts +7 -1
  38. package/dist/transactions/TransactionQueue.js +43 -2
  39. package/dist/wire/frames.d.ts +21 -4
  40. package/docs/api.md +6 -5
  41. package/docs/concurrency-convention.md +222 -0
  42. package/docs/coordination.md +16 -11
  43. package/docs/data-sources.md +41 -0
  44. package/docs/react.md +69 -0
  45. package/package.json +11 -1
@@ -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; `'force'` applies unconditionally. `'flag'` /
49
- * `'merge'` are reserved, not yet implemented.
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
- | `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
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
- | `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
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
- "action": "editing",
139
+ "reason": "editing",
139
140
  "heldBy": "agent:report-writer",
140
141
  "participantKind": "agent",
141
- "expiresAt": "1716580000000"
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.action;
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.
@@ -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
- | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
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` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
80
- | `expiresAt` | `string` | 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. |
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
- "action": "editing",
92
+ "reason": "editing",
88
93
  "heldBy": "agent:forecaster",
89
94
  "participantKind": "agent",
90
- "createdAt": "1748160000000",
91
- "expiresAt": "1748160030000"
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.action` | `string` | no | Phase shown to observers (default `'editing'`). |
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.wait` | `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). |
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.action}`);
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
- "action": "editing",
227
+ "reason": "editing",
223
228
  "heldBy": "agent:forecaster",
224
229
  "participantKind": "agent",
225
- "expiresAt": "1748160030000"
230
+ "expiresAt": 1748160030000
226
231
  }
227
232
  ```
228
233
 
@@ -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.13.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",