@abloatai/ablo 0.13.0 → 0.14.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 +10 -0
- package/dist/BaseSyncedStore.js +39 -32
- package/dist/batching/index.d.ts +57 -0
- package/dist/batching/index.js +150 -0
- package/dist/cli.cjs +9 -0
- package/dist/client/Ablo.d.ts +16 -25
- package/dist/client/Ablo.js +1 -1
- package/dist/client/auth.js +11 -0
- package/dist/client/createModelProxy.d.ts +33 -8
- package/dist/client/createModelProxy.js +4 -4
- package/dist/errorCodes.d.ts +3 -1
- package/dist/errorCodes.js +10 -1
- package/docs/api.md +6 -5
- package/docs/coordination.md +11 -11
- package/docs/react.md +69 -0
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Claim API consistency + coordination docs
|
|
8
|
+
- **React:** document `useWatch` (scoped presence + read-interest, with `claim`/`hydrate`/`paused` options) and `usePeers` (read-only presence) — previously exported but undocumented.
|
|
9
|
+
- **HTTP claim surface:** `HttpClaimApi` is now a mechanically derived async projection of the reactive `ClaimApi` (`AwaitedClaimMethod`), so the two transports can never drift. No behavior change — the only difference remains the `Promise` wrapper that statelessness forces on `state`/`queue`/`reorder`.
|
|
10
|
+
- **Naming:** unified the claim read verb to `state` across every layer (the internal `ModelCollaboration.observe` is now `state`, matching the public `ablo.<model>.claim.state({ id })`).
|
|
11
|
+
- **Docs:** corrected the `Claim` object reference — the field is `reason` (serialized on the wire as `action`), and `createdAt`/`expiresAt` are `number` (epoch-ms), not strings; corrected the claim options to `reason` and `queue`.
|
|
12
|
+
|
|
3
13
|
## 0.13.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -781,47 +781,54 @@ export class BaseSyncedStore {
|
|
|
781
781
|
startCredentialLifecycle(getToken) {
|
|
782
782
|
this.stopCredentialLifecycle();
|
|
783
783
|
this.setCredentialRefresher(getToken);
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
784
|
+
// Re-mint through the SAME single-flight path the FSM's reactive probe uses
|
|
785
|
+
// (`performCredentialRefresh`) rather than calling `getToken()` directly. Two
|
|
786
|
+
// wins over the old direct call:
|
|
787
|
+
// - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
|
|
788
|
+
// roll share one in-flight promise — no double-mint thrash.
|
|
789
|
+
// - The tri-state is HONOURED. The old code did `if (token) {…}` and
|
|
790
|
+
// dropped a `null` on the floor — a zombie session that re-minted on
|
|
791
|
+
// every tab focus and logged "signing out" forever without ever signing
|
|
792
|
+
// out. `session_error` now drives the FSM to actually expire.
|
|
787
793
|
const refresh = async () => {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
// connection to re-probe with the fresh key. Same two steps the
|
|
794
|
-
// engine's `setAuthToken` wrapper performs.
|
|
795
|
-
this.auth?.setAuthToken(token);
|
|
796
|
-
this.nudgeReconnect();
|
|
797
|
-
}
|
|
794
|
+
const outcome = await this.performCredentialRefresh();
|
|
795
|
+
if (outcome === 'refreshed') {
|
|
796
|
+
// Fresh key already pushed into the credential source by
|
|
797
|
+
// `performCredentialRefresh`; nudge a parked connection to re-probe.
|
|
798
|
+
this.nudgeReconnect();
|
|
798
799
|
}
|
|
799
|
-
|
|
800
|
-
//
|
|
800
|
+
else if (outcome === 'session_error') {
|
|
801
|
+
// The long-lived login is gone (mint answered 401/403). Surface it —
|
|
802
|
+
// the proactive path's job is to report this, not hide it. A no-op in
|
|
803
|
+
// FSM states that don't accept the event (the probe converges on
|
|
804
|
+
// sign-out there anyway); `session_expired`'s onEnter owns the log.
|
|
805
|
+
this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
801
806
|
}
|
|
807
|
+
// 'network_error' → transient (offline / mint hiccup); the next timer tick
|
|
808
|
+
// or the FSM's own probe retries. Never sign out for it.
|
|
802
809
|
};
|
|
803
810
|
// Comfortably inside the 15m `ek_` TTL; a missed (background-throttled) tick
|
|
804
|
-
// is recovered by the next, or by the reactive probe.
|
|
811
|
+
// is recovered by the next, or by the reactive probe. The timer is the sole
|
|
812
|
+
// proactive PRE-ROLL — it keeps the key warm ahead of expiry even while the
|
|
813
|
+
// socket sits healthy-`connected` (a state the FSM never probes unprompted).
|
|
805
814
|
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
806
815
|
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
|
807
816
|
const teardowns = [() => clearInterval(timer)];
|
|
808
817
|
if (typeof window !== 'undefined') {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
document.addEventListener('visibilitychange', onVisible);
|
|
824
|
-
teardowns.push(() => document.removeEventListener('visibilitychange', onVisible));
|
|
818
|
+
// OS-wake (desktop only): the Electron shell bridges `powerMonitor`
|
|
819
|
+
// 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
|
|
820
|
+
// still owns, because `visibilitychange` does NOT fire on wake-from-sleep
|
|
821
|
+
// and — unlike `online`/`visibilitychange` — the ConnectionManager's own
|
|
822
|
+
// browser listeners (`setupBrowserListeners`) don't cover wake.
|
|
823
|
+
//
|
|
824
|
+
// The `online` and `visibilitychange` listeners that used to live here
|
|
825
|
+
// were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
|
|
826
|
+
// through this exact credential path, so registering them here too only
|
|
827
|
+
// fired a second, null-swallowing mint per focus — the "session-key
|
|
828
|
+
// POSTed on every tab focus" spam in the console.
|
|
829
|
+
const onWake = () => void refresh();
|
|
830
|
+
window.addEventListener('ablo:wake', onWake);
|
|
831
|
+
teardowns.push(() => window.removeEventListener('ablo:wake', onWake));
|
|
825
832
|
}
|
|
826
833
|
this.credentialLifecycleTeardown = () => {
|
|
827
834
|
for (const t of teardowns)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/batching` — a dependency-free batch-coalescing primitive.
|
|
3
|
+
*
|
|
4
|
+
* Accumulate items issued close together (the canonical case: a synchronous
|
|
5
|
+
* burst, e.g. `Promise.all([ a(), b(), c() ])` in one event-loop tick) and
|
|
6
|
+
* dispatch them as ONE atomic batch instead of one call each. This is the
|
|
7
|
+
* scheduling essence of Ablo's `TransactionQueue` (and Linear's sync engine) —
|
|
8
|
+
* microtask same-tick staging, size/cost/delay flush triggers, and in-flight
|
|
9
|
+
* backpressure — distilled to a pure state machine with NO dependency on
|
|
10
|
+
* models, MobX, IndexedDB, or the wire. Consumers inject the actual dispatch.
|
|
11
|
+
*
|
|
12
|
+
* Guarantees:
|
|
13
|
+
* - a batch is ONE `dispatchBatch(items)` call → **atomic** (all-or-nothing).
|
|
14
|
+
* - on dispatch failure, **every** enqueued promise in that batch rejects
|
|
15
|
+
* with the same error.
|
|
16
|
+
* - items dispatch in enqueue order (optionally reordered by `compare` just
|
|
17
|
+
* before a batch is cut); batches run FIFO under a `maxInFlight` cap.
|
|
18
|
+
*
|
|
19
|
+
* The slides-sdk wraps this to coalesce `commits.create` calls; the stateful
|
|
20
|
+
* `TransactionQueue` MAY adopt it later (it would supply `compare` for FK
|
|
21
|
+
* ordering and keep its merge/confirm/retry logic in its own hooks).
|
|
22
|
+
*/
|
|
23
|
+
export interface BatchSchedulerOptions<T> {
|
|
24
|
+
/** Master switch. When false, every `enqueue` dispatches solo immediately. Default true. */
|
|
25
|
+
readonly enabled?: boolean;
|
|
26
|
+
/** Coalescing window in ms. `0` (default) → flush on the next microtask (zero added latency). */
|
|
27
|
+
readonly windowMs?: number;
|
|
28
|
+
/** Max items per batch before a forced flush. Default 256. */
|
|
29
|
+
readonly maxBatchSize?: number;
|
|
30
|
+
/** Max accumulated `costOf` per batch before a forced flush. Default `Infinity` (disabled). */
|
|
31
|
+
readonly maxBatchCost?: number;
|
|
32
|
+
/** Per-item cost used by `maxBatchCost` (e.g. serialized bytes). Default `() => 0`. */
|
|
33
|
+
readonly costOf?: (item: T) => number;
|
|
34
|
+
/** Max dispatches in flight at once (backpressure). Default 1 → strictly ordered. */
|
|
35
|
+
readonly maxInFlight?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface BatchSchedulerHooks<T, R> {
|
|
38
|
+
/** The single dispatch for one batch. One call → atomic at this layer. */
|
|
39
|
+
dispatchBatch(items: T[]): Promise<R>;
|
|
40
|
+
/**
|
|
41
|
+
* Optional ordering applied to the staged items immediately before a batch
|
|
42
|
+
* is cut (e.g. FK-priority). Omit for FIFO. Does not affect which items share
|
|
43
|
+
* a batch — only their order within the dispatched array.
|
|
44
|
+
*/
|
|
45
|
+
compare?(a: T, b: T): number;
|
|
46
|
+
}
|
|
47
|
+
export interface BatchScheduler<T, R> {
|
|
48
|
+
/** Stage one item; resolves with its batch's dispatch result, or rejects with the batch error. */
|
|
49
|
+
enqueue(item: T): Promise<R>;
|
|
50
|
+
/** Stage an item that must dispatch in its OWN batch (e.g. it carries an explicit idempotency key). */
|
|
51
|
+
enqueueSolo(item: T): Promise<R>;
|
|
52
|
+
/** Force-flush the pending batch and resolve once everything in flight has settled. */
|
|
53
|
+
flush(): Promise<void>;
|
|
54
|
+
/** Stop scheduling and clear timers. Pending/in-flight promises still settle. */
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
57
|
+
export declare function createBatchScheduler<T, R>(hooks: BatchSchedulerHooks<T, R>, options?: BatchSchedulerOptions<T>): BatchScheduler<T, R>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/batching` — a dependency-free batch-coalescing primitive.
|
|
3
|
+
*
|
|
4
|
+
* Accumulate items issued close together (the canonical case: a synchronous
|
|
5
|
+
* burst, e.g. `Promise.all([ a(), b(), c() ])` in one event-loop tick) and
|
|
6
|
+
* dispatch them as ONE atomic batch instead of one call each. This is the
|
|
7
|
+
* scheduling essence of Ablo's `TransactionQueue` (and Linear's sync engine) —
|
|
8
|
+
* microtask same-tick staging, size/cost/delay flush triggers, and in-flight
|
|
9
|
+
* backpressure — distilled to a pure state machine with NO dependency on
|
|
10
|
+
* models, MobX, IndexedDB, or the wire. Consumers inject the actual dispatch.
|
|
11
|
+
*
|
|
12
|
+
* Guarantees:
|
|
13
|
+
* - a batch is ONE `dispatchBatch(items)` call → **atomic** (all-or-nothing).
|
|
14
|
+
* - on dispatch failure, **every** enqueued promise in that batch rejects
|
|
15
|
+
* with the same error.
|
|
16
|
+
* - items dispatch in enqueue order (optionally reordered by `compare` just
|
|
17
|
+
* before a batch is cut); batches run FIFO under a `maxInFlight` cap.
|
|
18
|
+
*
|
|
19
|
+
* The slides-sdk wraps this to coalesce `commits.create` calls; the stateful
|
|
20
|
+
* `TransactionQueue` MAY adopt it later (it would supply `compare` for FK
|
|
21
|
+
* ordering and keep its merge/confirm/retry logic in its own hooks).
|
|
22
|
+
*/
|
|
23
|
+
export function createBatchScheduler(hooks, options) {
|
|
24
|
+
const enabled = options?.enabled ?? true;
|
|
25
|
+
const windowMs = options?.windowMs ?? 0;
|
|
26
|
+
const maxBatchSize = options?.maxBatchSize ?? 256;
|
|
27
|
+
const maxBatchCost = options?.maxBatchCost ?? Infinity;
|
|
28
|
+
const costOf = options?.costOf ?? (() => 0);
|
|
29
|
+
const maxInFlight = options?.maxInFlight ?? 1;
|
|
30
|
+
let pending = null;
|
|
31
|
+
let timer = null;
|
|
32
|
+
let microtaskScheduled = false;
|
|
33
|
+
const ready = [];
|
|
34
|
+
let inFlight = 0;
|
|
35
|
+
let idleWaiters = [];
|
|
36
|
+
let disposed = false;
|
|
37
|
+
function scheduleFlush() {
|
|
38
|
+
if (microtaskScheduled || timer)
|
|
39
|
+
return;
|
|
40
|
+
if (windowMs > 0) {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
timer = null;
|
|
43
|
+
flushPending();
|
|
44
|
+
}, windowMs);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
microtaskScheduled = true;
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
microtaskScheduled = false;
|
|
50
|
+
flushPending();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function flushPending() {
|
|
55
|
+
if (timer) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timer = null;
|
|
58
|
+
}
|
|
59
|
+
microtaskScheduled = false;
|
|
60
|
+
if (!pending)
|
|
61
|
+
return;
|
|
62
|
+
if (hooks.compare)
|
|
63
|
+
pending.items.sort(hooks.compare);
|
|
64
|
+
ready.push(pending);
|
|
65
|
+
pending = null;
|
|
66
|
+
pump();
|
|
67
|
+
}
|
|
68
|
+
function pump() {
|
|
69
|
+
while (inFlight < maxInFlight && ready.length > 0) {
|
|
70
|
+
const batch = ready.shift();
|
|
71
|
+
if (!batch)
|
|
72
|
+
break;
|
|
73
|
+
inFlight++;
|
|
74
|
+
let dispatched;
|
|
75
|
+
try {
|
|
76
|
+
dispatched = hooks.dispatchBatch(batch.items);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
dispatched = Promise.reject(error);
|
|
80
|
+
}
|
|
81
|
+
dispatched
|
|
82
|
+
.then((result) => {
|
|
83
|
+
for (const d of batch.deferreds)
|
|
84
|
+
d.resolve(result);
|
|
85
|
+
}, (error) => {
|
|
86
|
+
for (const d of batch.deferreds)
|
|
87
|
+
d.reject(error);
|
|
88
|
+
})
|
|
89
|
+
.finally(() => {
|
|
90
|
+
inFlight--;
|
|
91
|
+
pump();
|
|
92
|
+
notifyIdleIfDrained();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function notifyIdleIfDrained() {
|
|
97
|
+
if (inFlight === 0 && ready.length === 0 && idleWaiters.length > 0) {
|
|
98
|
+
const waiters = idleWaiters;
|
|
99
|
+
idleWaiters = [];
|
|
100
|
+
for (const w of waiters)
|
|
101
|
+
w();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function enqueueSolo(item) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
ready.push({ items: [item], deferreds: [{ resolve, reject }], cost: costOf(item) });
|
|
107
|
+
pump();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function enqueue(item) {
|
|
111
|
+
if (disposed)
|
|
112
|
+
return Promise.reject(new Error('batch scheduler disposed'));
|
|
113
|
+
if (!enabled)
|
|
114
|
+
return enqueueSolo(item);
|
|
115
|
+
const cost = costOf(item);
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const deferred = { resolve, reject };
|
|
118
|
+
// Rollover: if appending would blow a cap, flush the current batch first.
|
|
119
|
+
if (pending && (pending.items.length + 1 > maxBatchSize || pending.cost + cost > maxBatchCost)) {
|
|
120
|
+
flushPending();
|
|
121
|
+
}
|
|
122
|
+
if (!pending)
|
|
123
|
+
pending = { items: [], deferreds: [], cost: 0 };
|
|
124
|
+
pending.items.push(item);
|
|
125
|
+
pending.deferreds.push(deferred);
|
|
126
|
+
pending.cost += cost;
|
|
127
|
+
if (pending.items.length >= maxBatchSize || pending.cost >= maxBatchCost) {
|
|
128
|
+
flushPending();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
scheduleFlush();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function flush() {
|
|
136
|
+
flushPending();
|
|
137
|
+
while (ready.length > 0 || inFlight > 0) {
|
|
138
|
+
await new Promise((resolve) => idleWaiters.push(resolve));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function dispose() {
|
|
142
|
+
disposed = true;
|
|
143
|
+
if (timer) {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
timer = null;
|
|
146
|
+
}
|
|
147
|
+
microtaskScheduled = false;
|
|
148
|
+
}
|
|
149
|
+
return { enqueue, enqueueSolo, flush, dispose };
|
|
150
|
+
}
|
package/dist/cli.cjs
CHANGED
|
@@ -276903,9 +276903,18 @@ var ERROR_CODES = {
|
|
|
276903
276903
|
// ── quota / rate limit (429) ──────────────────────────────────────
|
|
276904
276904
|
quota_exceeded: wire("rate_limit", 429, true, "The organization exceeded its configured usage quota."),
|
|
276905
276905
|
connection_limit_exceeded: wire("rate_limit", 429, true, "Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain."),
|
|
276906
|
+
// Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
|
|
276907
|
+
// the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
|
|
276908
|
+
// so one noisy key backs off without affecting the rest of the org. The
|
|
276909
|
+
// `Retry-After` header carries the bucket-refill delay.
|
|
276910
|
+
rate_limit_exceeded: wire("rate_limit", 429, true, "This API key is sending requests too quickly; slow down and retry after the indicated delay."),
|
|
276906
276911
|
// ── server (5xx) ───────────────────────────────────────────────────
|
|
276907
276912
|
internal_error: wire("server", 500, true, "An unexpected server error occurred."),
|
|
276908
276913
|
quota_lookup_failed: wire("server", 503, true, "The quota decision could not be loaded."),
|
|
276914
|
+
// The per-key rate-limiter backend (Redis) was unreachable and the API is
|
|
276915
|
+
// configured to FAIL CLOSED on that path, so the request was rejected rather
|
|
276916
|
+
// than admitted unchecked. Retryable: the next attempt re-probes the backend.
|
|
276917
|
+
rate_limiter_unavailable: wire("server", 503, true, "The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly."),
|
|
276909
276918
|
turn_open_failed: wire("server", 500, true, "The agent turn failed to open."),
|
|
276910
276919
|
turn_close_failed: wire("server", 500, true, "The agent turn failed to close cleanly."),
|
|
276911
276920
|
// ── client-only invariants (never serialized) ──────────────────────
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
|
|
|
28
28
|
import type { SyncGroupInput } from '../schema/roles.js';
|
|
29
29
|
import { type SyncStatus } from '../BaseSyncedStore.js';
|
|
30
30
|
import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
|
|
31
|
-
import type { ClaimHandle, Duration
|
|
31
|
+
import type { ClaimHandle, Duration } from '../types/streams.js';
|
|
32
32
|
import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
|
|
33
33
|
import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
|
|
34
34
|
/**
|
|
@@ -371,7 +371,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
371
371
|
* `claim({ id })` — durable claim handle for coordinated writes
|
|
372
372
|
*/
|
|
373
373
|
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
|
|
374
|
-
import type { ModelOperations, ClaimOptions, ClaimParams,
|
|
374
|
+
import type { ModelOperations, ClaimOptions, ClaimParams, ClaimReadApi, AwaitedClaimMethod, ServerReadOptions } from './createModelProxy.js';
|
|
375
375
|
export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
|
|
376
376
|
export type CommitWait = 'queued' | 'confirmed';
|
|
377
377
|
export interface ModelRead<T = Record<string, unknown>> {
|
|
@@ -473,30 +473,21 @@ export interface ModelMutationOptions extends ClaimedOptions {
|
|
|
473
473
|
* The HTTP/stateless claim surface. Normal tools usually put `claim` directly
|
|
474
474
|
* on the write (`update({ id, data, claim })`) and let the SDK release it. Use
|
|
475
475
|
* this namespace for multi-step handles and coordination screens.
|
|
476
|
+
*
|
|
477
|
+
* Same surface as the reactive {@link ClaimApi}, but every read is a server
|
|
478
|
+
* round-trip, so `state`/`queue`/`reorder` are **awaited** here (the WebSocket
|
|
479
|
+
* client resolves them synchronously from its local pool — which is what lets
|
|
480
|
+
* `useAblo((ablo) => ablo.x.claim.state({ id }))` work inside a React render; a
|
|
481
|
+
* stateless client has no pool to read, so the `Promise` is unavoidable).
|
|
482
|
+
*
|
|
483
|
+
* Mechanically DERIVED from `ClaimReadApi` via {@link AwaitedClaimMethod} so the
|
|
484
|
+
* two transports can never drift: the ONLY difference is the uniform `Promise`
|
|
485
|
+
* wrapper that statelessness forces. `claim({ id })` is identical (already async
|
|
486
|
+
* on both); `state`/`queue`/`reorder`/`release` are the awaited form.
|
|
476
487
|
*/
|
|
477
|
-
export
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
/** Release a manual claim you hold. */
|
|
481
|
-
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
482
|
-
/**
|
|
483
|
-
* Current holder of the lease on a row, or `null` when free. For UI badges,
|
|
484
|
-
* preflight checks, and operators.
|
|
485
|
-
*/
|
|
486
|
-
state(params: ClaimLookupParams<T>): Promise<Claim | null>;
|
|
487
|
-
/**
|
|
488
|
-
* FIFO wait line behind the holder. Advanced: useful for operator UIs and
|
|
489
|
-
* schedulers.
|
|
490
|
-
*/
|
|
491
|
-
queue(params: ClaimLookupParams<T>): Promise<{
|
|
492
|
-
readonly object: 'list';
|
|
493
|
-
readonly data: readonly Claim[];
|
|
494
|
-
}>;
|
|
495
|
-
/**
|
|
496
|
-
* Re-rank the wait line. Advanced and permission-gated.
|
|
497
|
-
*/
|
|
498
|
-
reorder(params: ClaimReorderParams<T>): Promise<void>;
|
|
499
|
-
}
|
|
488
|
+
export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<ClaimHandle<T>>) & {
|
|
489
|
+
[K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
|
|
490
|
+
};
|
|
500
491
|
export interface ModelClient<T = Record<string, unknown>> {
|
|
501
492
|
/**
|
|
502
493
|
* Single-row read over HTTP. **Returns an envelope, not the bare row** — the
|
package/dist/client/Ablo.js
CHANGED
|
@@ -1325,7 +1325,7 @@ export function Ablo(options) {
|
|
|
1325
1325
|
}),
|
|
1326
1326
|
queue: (target) => publicClaims.queueFor({ type: target.model, id: target.id }),
|
|
1327
1327
|
reorder: (target, order) => publicClaims.reorder({ type: target.model, id: target.id }, order),
|
|
1328
|
-
|
|
1328
|
+
state: (target) => {
|
|
1329
1329
|
// The live claim stream only tracks *open* (active) claims;
|
|
1330
1330
|
// terminal states (committed / expired / canceled) drop out of
|
|
1331
1331
|
// the list entirely — exactly the ephemeral coordination model.
|
package/dist/client/auth.js
CHANGED
|
@@ -54,6 +54,14 @@ export function resolveDatabaseUrl(input) {
|
|
|
54
54
|
* explicit option instead of flipping their mode for them. Warns once per process
|
|
55
55
|
* so it never spams, and falls back to `console.warn` when no logger is supplied
|
|
56
56
|
* (the `transport: 'api'` client has none).
|
|
57
|
+
*
|
|
58
|
+
* Suppressed entirely on the hosted/token path: if an `apiKey` resolves (option
|
|
59
|
+
* or `ABLO_API_KEY` env), the caller has chosen the hosted capability-token /
|
|
60
|
+
* Data Source transport, which is mutually exclusive with direct `databaseUrl`
|
|
61
|
+
* mode. A `DATABASE_URL` sitting in that environment is unrelated infra (Prisma,
|
|
62
|
+
* Drizzle, the sync-server) — never an omitted option — so nudging would be a
|
|
63
|
+
* false positive. This is the first-party hosted app's exact shape, where the
|
|
64
|
+
* stray nudge otherwise reaches end-user desktop logs.
|
|
57
65
|
*/
|
|
58
66
|
let warnedDatabaseUrlEnvIgnored = false;
|
|
59
67
|
export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
@@ -61,6 +69,9 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
|
61
69
|
return;
|
|
62
70
|
if (input.options.databaseUrl != null)
|
|
63
71
|
return;
|
|
72
|
+
// Hosted/token path → DATABASE_URL is unrelated infra, not an omitted option.
|
|
73
|
+
if (resolveApiKey(input) != null)
|
|
74
|
+
return;
|
|
64
75
|
const envUrl = input.env.DATABASE_URL;
|
|
65
76
|
if (typeof envUrl !== 'string' || envUrl.length === 0)
|
|
66
77
|
return;
|
|
@@ -109,8 +109,13 @@ export interface ModelCollaboration<T> {
|
|
|
109
109
|
* `null` when the target is free. The wiring site computes it because
|
|
110
110
|
* only it knows the local participant id (needed to distinguish "I
|
|
111
111
|
* hold it" from "someone else holds it").
|
|
112
|
+
*
|
|
113
|
+
* Named `state` to match the public `ablo.<model>.claim.state({ id })` read —
|
|
114
|
+
* one verb for "who holds this" across every claim surface; the only
|
|
115
|
+
* difference is this internal contract takes an explicit `{ model, id }`
|
|
116
|
+
* target because it isn't bound to a single model.
|
|
112
117
|
*/
|
|
113
|
-
|
|
118
|
+
state(target: {
|
|
114
119
|
model: string;
|
|
115
120
|
id: string;
|
|
116
121
|
}): Claim | null;
|
|
@@ -202,10 +207,11 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
202
207
|
* work-distribution dedup ("if someone else has this job, skip it") where
|
|
203
208
|
* waiting would mean double-processing.
|
|
204
209
|
*
|
|
205
|
-
* Named `queue` to match every other claim surface
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
210
|
+
* Named `queue` to match every other claim surface — `ablo.<model>.claim`
|
|
211
|
+
* on both the WS and HTTP clients (take-a-claim is the callable `claim({ id
|
|
212
|
+
* })` on both; the HTTP reads are just awaited) and the wire. The high-level
|
|
213
|
+
* typed claim defaults it ON because it serializes writers; the low-level
|
|
214
|
+
* lease and HTTP default it OFF — they return/resolve immediately and can't
|
|
209
215
|
* transparently wait for a grant.
|
|
210
216
|
*/
|
|
211
217
|
queue?: boolean;
|
|
@@ -276,9 +282,18 @@ export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
|
276
282
|
* handle. `state`, `queue`, and `reorder` are coordination reads/scheduler
|
|
277
283
|
* controls for UI and operators.
|
|
278
284
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Coordination reads + scheduler controls on a claim namespace, in their
|
|
287
|
+
* REACTIVE (synchronous) form — `state`/`queue`/`reorder` resolve against the
|
|
288
|
+
* local pool with no round-trip, which is what lets `useAblo((ablo) =>
|
|
289
|
+
* ablo.x.claim.state({ id }))` read coordination state inside a React render.
|
|
290
|
+
*
|
|
291
|
+
* This is the single source of truth for the claim read surface: the stateless
|
|
292
|
+
* HTTP client exposes the *awaited* projection of EXACTLY these methods
|
|
293
|
+
* (`HttpClaimApi` in `Ablo.ts`, derived via {@link AwaitedClaimMethod}), so the
|
|
294
|
+
* two transports can never drift — edit a signature here and HTTP follows.
|
|
295
|
+
*/
|
|
296
|
+
export interface ClaimReadApi<T = Record<string, unknown>> {
|
|
282
297
|
/**
|
|
283
298
|
* Current holder for a row, or `null` when free. Use this for UI badges and
|
|
284
299
|
* preflight checks, not for the normal write path.
|
|
@@ -299,6 +314,16 @@ export interface ClaimApi<T> {
|
|
|
299
314
|
/** Release a manual claim handle early. Single-write claims auto-release. */
|
|
300
315
|
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
301
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* The awaited form of a claim method: a synchronous return becomes a `Promise`,
|
|
319
|
+
* an already-async one (`release`) is left untouched. Used to derive the
|
|
320
|
+
* stateless HTTP claim surface from the reactive {@link ClaimReadApi}.
|
|
321
|
+
*/
|
|
322
|
+
export type AwaitedClaimMethod<F> = F extends (...args: infer A) => infer R ? R extends Promise<unknown> ? (...args: A) => R : (...args: A) => Promise<R> : F;
|
|
323
|
+
export interface ClaimApi<T> extends ClaimReadApi<T> {
|
|
324
|
+
/** Take a claim and get an explicit held-work handle back. */
|
|
325
|
+
(params: ClaimParams<T>): Promise<ClaimHandle<T>>;
|
|
326
|
+
}
|
|
302
327
|
export interface ModelRetrieveParams extends ServerRetrieveOptions {
|
|
303
328
|
readonly id: string;
|
|
304
329
|
}
|
|
@@ -141,7 +141,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
141
141
|
// Is someone ELSE already on this target? Read the local coordination
|
|
142
142
|
// snapshot up front — it decides whether we'll need to re-read after the
|
|
143
143
|
// claim (a free / already-mine target can't have changed under us).
|
|
144
|
-
const held = collaboration.
|
|
144
|
+
const held = collaboration.state({ model: wireModel, id });
|
|
145
145
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
146
146
|
const failFast = options?.queue === false;
|
|
147
147
|
// Fail-fast (`queue: false`): if another participant already holds it,
|
|
@@ -215,7 +215,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
215
215
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
216
216
|
const reason = options?.reason ?? 'editing';
|
|
217
217
|
// The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
|
|
218
|
-
// report (`
|
|
218
|
+
// report (`state` maps `held.target.model` → `type`), so a holder and a
|
|
219
219
|
// peer see the SAME target.type for one row — the wire model token.
|
|
220
220
|
const selfTarget = {
|
|
221
221
|
type: wireModel,
|
|
@@ -271,7 +271,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
271
271
|
// presence. Soft + fire-and-forget — never blocks or rejects the read.
|
|
272
272
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
273
273
|
// Self-awareness: the server excludes a holder's OWN presence frames and
|
|
274
|
-
// the client skips them, so `
|
|
274
|
+
// the client skips them, so `state` returns null for a row WE hold.
|
|
275
275
|
// Synthesize the active claim for self from the stored lease so the
|
|
276
276
|
// holder sees its own claim (the JSDoc contract on `claim.state`).
|
|
277
277
|
const own = activeClaims.get(params.id);
|
|
@@ -287,7 +287,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
287
287
|
expiresAt: own.expiresAt,
|
|
288
288
|
};
|
|
289
289
|
}
|
|
290
|
-
return collaboration?.
|
|
290
|
+
return collaboration?.state({ model: wireModel, id: params.id }) ?? null;
|
|
291
291
|
},
|
|
292
292
|
queue(params) {
|
|
293
293
|
return {
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
38
38
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
39
39
|
*/
|
|
40
|
-
export declare const ERROR_CONTRACT_VERSION = "2026-06-
|
|
40
|
+
export declare const ERROR_CONTRACT_VERSION = "2026-06-20";
|
|
41
41
|
/** Coarse grouping for metrics dashboards and docs sectioning. */
|
|
42
42
|
export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'claim' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
|
|
43
43
|
/**
|
|
@@ -239,8 +239,10 @@ export declare const ERROR_CODES: {
|
|
|
239
239
|
readonly ws_not_ready: ErrorCodeSpec;
|
|
240
240
|
readonly quota_exceeded: ErrorCodeSpec;
|
|
241
241
|
readonly connection_limit_exceeded: ErrorCodeSpec;
|
|
242
|
+
readonly rate_limit_exceeded: ErrorCodeSpec;
|
|
242
243
|
readonly internal_error: ErrorCodeSpec;
|
|
243
244
|
readonly quota_lookup_failed: ErrorCodeSpec;
|
|
245
|
+
readonly rate_limiter_unavailable: ErrorCodeSpec;
|
|
244
246
|
readonly turn_open_failed: ErrorCodeSpec;
|
|
245
247
|
readonly turn_close_failed: ErrorCodeSpec;
|
|
246
248
|
readonly invalid_options: ErrorCodeSpec;
|
package/dist/errorCodes.js
CHANGED
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
38
38
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
39
39
|
*/
|
|
40
|
-
export const ERROR_CONTRACT_VERSION = '2026-06-
|
|
40
|
+
export const ERROR_CONTRACT_VERSION = '2026-06-20';
|
|
41
41
|
/**
|
|
42
42
|
* The closed taxonomy of *how a failure recovers* — one rung above the raw
|
|
43
43
|
* `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
|
|
@@ -258,9 +258,18 @@ export const ERROR_CODES = {
|
|
|
258
258
|
// ── quota / rate limit (429) ──────────────────────────────────────
|
|
259
259
|
quota_exceeded: wire('rate_limit', 429, true, 'The organization exceeded its configured usage quota.'),
|
|
260
260
|
connection_limit_exceeded: wire('rate_limit', 429, true, 'Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain.'),
|
|
261
|
+
// Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
|
|
262
|
+
// the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
|
|
263
|
+
// so one noisy key backs off without affecting the rest of the org. The
|
|
264
|
+
// `Retry-After` header carries the bucket-refill delay.
|
|
265
|
+
rate_limit_exceeded: wire('rate_limit', 429, true, 'This API key is sending requests too quickly; slow down and retry after the indicated delay.'),
|
|
261
266
|
// ── server (5xx) ───────────────────────────────────────────────────
|
|
262
267
|
internal_error: wire('server', 500, true, 'An unexpected server error occurred.'),
|
|
263
268
|
quota_lookup_failed: wire('server', 503, true, 'The quota decision could not be loaded.'),
|
|
269
|
+
// The per-key rate-limiter backend (Redis) was unreachable and the API is
|
|
270
|
+
// configured to FAIL CLOSED on that path, so the request was rejected rather
|
|
271
|
+
// than admitted unchecked. Retryable: the next attempt re-probes the backend.
|
|
272
|
+
rate_limiter_unavailable: wire('server', 503, true, 'The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly.'),
|
|
264
273
|
turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
|
|
265
274
|
turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
|
|
266
275
|
// ── client-only invariants (never serialized) ──────────────────────
|
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({
|
package/docs/coordination.md
CHANGED
|
@@ -72,23 +72,23 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
72
72
|
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
73
73
|
| `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
74
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
75
|
-
| `
|
|
75
|
+
| `reason` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
|
|
76
76
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
77
77
|
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
78
78
|
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
79
|
-
| `createdAt` | `
|
|
80
|
-
| `expiresAt` | `
|
|
79
|
+
| `createdAt` | `number?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
80
|
+
| `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
81
|
|
|
82
82
|
```jsonc
|
|
83
83
|
{
|
|
84
84
|
"id": "claim_8fJ2",
|
|
85
85
|
"status": "active",
|
|
86
86
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
87
|
-
"
|
|
87
|
+
"reason": "editing",
|
|
88
88
|
"heldBy": "agent:forecaster",
|
|
89
89
|
"participantKind": "agent",
|
|
90
|
-
"createdAt":
|
|
91
|
-
"expiresAt":
|
|
90
|
+
"createdAt": 1748160000000,
|
|
91
|
+
"expiresAt": 1748160030000
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -129,9 +129,9 @@ so two claimers can't both think they won.
|
|
|
129
129
|
| name | type | required | description |
|
|
130
130
|
|---|---|---|---|
|
|
131
131
|
| `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
|
|
132
|
-
| `options.
|
|
132
|
+
| `options.reason` | `string` | no | Phase shown to observers (default `'editing'`). Serialized on the wire as `action`. |
|
|
133
133
|
| `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
|
|
134
|
-
| `options.
|
|
134
|
+
| `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
135
|
| `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
136
|
| `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
137
|
|
|
@@ -209,7 +209,7 @@ is free.
|
|
|
209
209
|
|
|
210
210
|
```ts
|
|
211
211
|
const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
212
|
-
if (who) console.log(`${who.heldBy} is ${who.
|
|
212
|
+
if (who) console.log(`${who.heldBy} is ${who.reason}`);
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
Returns the active claim state when the row is held, or `null` when it's free:
|
|
@@ -219,10 +219,10 @@ Returns the active claim state when the row is held, or `null` when it's free:
|
|
|
219
219
|
"id": "claim_8fJ2",
|
|
220
220
|
"status": "active",
|
|
221
221
|
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
222
|
-
"
|
|
222
|
+
"reason": "editing",
|
|
223
223
|
"heldBy": "agent:forecaster",
|
|
224
224
|
"participantKind": "agent",
|
|
225
|
-
"expiresAt":
|
|
225
|
+
"expiresAt": 1748160030000
|
|
226
226
|
}
|
|
227
227
|
```
|
|
228
228
|
|
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.14.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",
|