@abloatai/ablo 0.5.1 → 0.6.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 +16 -0
- package/README.md +217 -122
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `awaitIntentGrant` — the client side of the fair-queue handover.
|
|
3
|
+
*
|
|
4
|
+
* When a `claim` is contended, the server enqueues it and replies `queued`
|
|
5
|
+
* (HTTP 202 on `/v1/intents`, or `intent_queued` over WS). The grant is then
|
|
6
|
+
* PUSHED later over the WS as `intent_granted` when the claim reaches the head.
|
|
7
|
+
* This resolves once that frame arrives for our `intentId` — so the caller's
|
|
8
|
+
* `claim` promise stays pending (event-driven; no poll, no race) until it's
|
|
9
|
+
* actually our turn. Rejects on `intent_lost` (surfaced as `claim_lost`: the claim was taken away — TTL
|
|
10
|
+
* lapse on disconnect, revoke) or an optional timeout.
|
|
11
|
+
*
|
|
12
|
+
* Takes only a minimal `{ subscribe }` transport so it unit-tests against a
|
|
13
|
+
* fake; `SyncWebSocket` satisfies it structurally.
|
|
14
|
+
*/
|
|
15
|
+
export interface GrantTransport {
|
|
16
|
+
subscribe(event: 'intent_acquired' | 'intent_granted' | 'intent_lost' | 'intent_queued', handler: (payload: Record<string, unknown>) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function awaitIntentGrant(transport: GrantTransport, intentId: string, options?: {
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Backpressure: reject instead of waiting if, when we join the line, the
|
|
22
|
+
* server reports `position >= maxQueueDepth` (i.e. that many claims are
|
|
23
|
+
* already ahead of us). Omit to wait however deep the queue is.
|
|
24
|
+
*/
|
|
25
|
+
maxQueueDepth?: number;
|
|
26
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `awaitIntentGrant` — the client side of the fair-queue handover.
|
|
3
|
+
*
|
|
4
|
+
* When a `claim` is contended, the server enqueues it and replies `queued`
|
|
5
|
+
* (HTTP 202 on `/v1/intents`, or `intent_queued` over WS). The grant is then
|
|
6
|
+
* PUSHED later over the WS as `intent_granted` when the claim reaches the head.
|
|
7
|
+
* This resolves once that frame arrives for our `intentId` — so the caller's
|
|
8
|
+
* `claim` promise stays pending (event-driven; no poll, no race) until it's
|
|
9
|
+
* actually our turn. Rejects on `intent_lost` (surfaced as `claim_lost`: the claim was taken away — TTL
|
|
10
|
+
* lapse on disconnect, revoke) or an optional timeout.
|
|
11
|
+
*
|
|
12
|
+
* Takes only a minimal `{ subscribe }` transport so it unit-tests against a
|
|
13
|
+
* fake; `SyncWebSocket` satisfies it structurally.
|
|
14
|
+
*/
|
|
15
|
+
import { AbloClaimedError } from '../errors.js';
|
|
16
|
+
export function awaitIntentGrant(transport, intentId, options) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const unsubs = [];
|
|
19
|
+
let timer;
|
|
20
|
+
const settle = (fn) => {
|
|
21
|
+
if (timer)
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
for (const u of unsubs)
|
|
24
|
+
u();
|
|
25
|
+
fn();
|
|
26
|
+
};
|
|
27
|
+
const onGrant = (p) => {
|
|
28
|
+
if (p?.intentId === intentId)
|
|
29
|
+
settle(resolve);
|
|
30
|
+
};
|
|
31
|
+
// The target was free → `intent_acquired` (immediate); it was contended,
|
|
32
|
+
// we waited in line, and reached the head → `intent_granted`. Either frame
|
|
33
|
+
// means the lease is now ours, so one await covers both grant paths.
|
|
34
|
+
unsubs.push(transport.subscribe('intent_acquired', onGrant));
|
|
35
|
+
unsubs.push(transport.subscribe('intent_granted', onGrant));
|
|
36
|
+
if (options?.maxQueueDepth !== undefined) {
|
|
37
|
+
const max = options.maxQueueDepth;
|
|
38
|
+
unsubs.push(transport.subscribe('intent_queued', (p) => {
|
|
39
|
+
if (p?.intentId !== intentId)
|
|
40
|
+
return;
|
|
41
|
+
const position = typeof p.position === 'number' ? p.position : 0;
|
|
42
|
+
if (position >= max) {
|
|
43
|
+
settle(() => reject(new AbloClaimedError(`Claim queue for ${intentId} is ${position} deep (max ${max}).`, { code: 'queue_too_deep' })));
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
unsubs.push(transport.subscribe('intent_lost', (p) => {
|
|
48
|
+
if (p?.intentId === intentId) {
|
|
49
|
+
settle(() => reject(new AbloClaimedError(`Claim lost while queued for ${intentId}.`, {
|
|
50
|
+
code: 'claim_lost',
|
|
51
|
+
})));
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
if (options?.timeoutMs && options.timeoutMs > 0) {
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
settle(() => reject(new AbloClaimedError(`Timed out waiting for the queue grant on claim ${intentId}.`, { code: 'grant_timeout' })));
|
|
57
|
+
}, options.timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -29,6 +29,12 @@ export function createIntentStream(config, transport = null) {
|
|
|
29
29
|
let intentsSnapshot = Object.freeze([]);
|
|
30
30
|
// ── State: our own open intents (for re-announce on reconnect) ───
|
|
31
31
|
const ownIntents = new Map();
|
|
32
|
+
// ── State: per-entity wait queues, from `intent_queue` frames ────
|
|
33
|
+
// Keyed `type:id`; the value is the FIFO line of queued intents. Powers
|
|
34
|
+
// the reactive `queue(target)` read — who's waiting and what they intend.
|
|
35
|
+
const queueByEntity = new Map();
|
|
36
|
+
const entityKey = (type, id) => `${type}:${id}`;
|
|
37
|
+
const EMPTY_QUEUE = Object.freeze([]);
|
|
32
38
|
// ── Subscribers ──────────────────────────────────────────────────
|
|
33
39
|
const listeners = new Set();
|
|
34
40
|
const rejectionListeners = new Set();
|
|
@@ -125,6 +131,21 @@ export function createIntentStream(config, transport = null) {
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
}));
|
|
134
|
+
// (2b) Per-entity wait-queue snapshots. The server fans the full line
|
|
135
|
+
// out on every queue mutation; we replace our cached line for that
|
|
136
|
+
// entity and notify so `queue(target)` reads reactively.
|
|
137
|
+
unsubs.push(t.subscribe('intent_queue', (payload) => {
|
|
138
|
+
const p = payload;
|
|
139
|
+
if (!p.target?.type || !p.target.id)
|
|
140
|
+
return;
|
|
141
|
+
const key = entityKey(p.target.type, p.target.id);
|
|
142
|
+
const line = Array.isArray(p.queue) ? p.queue : [];
|
|
143
|
+
if (line.length === 0)
|
|
144
|
+
queueByEntity.delete(key);
|
|
145
|
+
else
|
|
146
|
+
queueByEntity.set(key, Object.freeze([...line]));
|
|
147
|
+
notifyListeners();
|
|
148
|
+
}));
|
|
128
149
|
// (3) On reconnect, re-announce every open self-claim — the
|
|
129
150
|
// server's intent state is in-memory and is lost across
|
|
130
151
|
// restarts. Without this, peers would see our claims vanish
|
|
@@ -153,13 +174,24 @@ export function createIntentStream(config, transport = null) {
|
|
|
153
174
|
field: intent.field,
|
|
154
175
|
meta: intent.meta,
|
|
155
176
|
estimatedMs: intent.estimatedMs,
|
|
177
|
+
queue: intent.queue,
|
|
156
178
|
},
|
|
157
179
|
});
|
|
158
180
|
}
|
|
159
|
-
function sendAbandon(intentId) {
|
|
181
|
+
function sendAbandon(intentId, intent) {
|
|
160
182
|
if (!attached?.isConnected())
|
|
161
183
|
return;
|
|
162
|
-
|
|
184
|
+
// Carry the target so the server can dequeue us if we were only *waiting*
|
|
185
|
+
// (a queued claim isn't in the holder set it would otherwise scan). Held
|
|
186
|
+
// claims are found by intentId regardless; the target is harmless there.
|
|
187
|
+
attached.send({
|
|
188
|
+
type: 'intent_abandon',
|
|
189
|
+
payload: {
|
|
190
|
+
intentId,
|
|
191
|
+
entityType: intent?.entityType,
|
|
192
|
+
entityId: intent?.entityId,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
163
195
|
}
|
|
164
196
|
function mintHandle(args) {
|
|
165
197
|
const intentId = crypto.randomUUID();
|
|
@@ -173,6 +205,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
173
205
|
meta: args.meta,
|
|
174
206
|
action: args.action,
|
|
175
207
|
estimatedMs,
|
|
208
|
+
queue: args.queue,
|
|
176
209
|
};
|
|
177
210
|
ownIntents.set(intentId, intent);
|
|
178
211
|
sendBegin(intentId, intent);
|
|
@@ -182,7 +215,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
182
215
|
return;
|
|
183
216
|
revoked = true;
|
|
184
217
|
ownIntents.delete(intentId);
|
|
185
|
-
sendAbandon(intentId);
|
|
218
|
+
sendAbandon(intentId, intent);
|
|
186
219
|
};
|
|
187
220
|
return {
|
|
188
221
|
id: intentId,
|
|
@@ -209,12 +242,17 @@ export function createIntentStream(config, transport = null) {
|
|
|
209
242
|
meta: resolved.meta,
|
|
210
243
|
action: opts?.reason ?? 'editing',
|
|
211
244
|
ttl: opts?.ttl,
|
|
245
|
+
queue: opts?.queue,
|
|
212
246
|
});
|
|
213
247
|
},
|
|
214
248
|
get others() {
|
|
215
249
|
return intentsSnapshot;
|
|
216
250
|
},
|
|
217
|
-
|
|
251
|
+
queueFor(target) {
|
|
252
|
+
const ref = resolveTarget(target);
|
|
253
|
+
return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
|
|
254
|
+
},
|
|
255
|
+
onChange: (listener) => {
|
|
218
256
|
listeners.add(listener);
|
|
219
257
|
return () => {
|
|
220
258
|
listeners.delete(listener);
|
|
@@ -243,6 +281,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
243
281
|
rejectionListeners.clear();
|
|
244
282
|
activeByIntentId.clear();
|
|
245
283
|
ownIntents.clear();
|
|
284
|
+
queueByEntity.clear();
|
|
246
285
|
intentsSnapshot = Object.freeze([]);
|
|
247
286
|
attached = null;
|
|
248
287
|
},
|
|
@@ -164,7 +164,7 @@ export function createPresenceStream(config, transport = null) {
|
|
|
164
164
|
return othersSnapshot;
|
|
165
165
|
},
|
|
166
166
|
othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
|
|
167
|
-
|
|
167
|
+
onChange: (listener) => {
|
|
168
168
|
listeners.add(listener);
|
|
169
169
|
return () => {
|
|
170
170
|
listeners.delete(listener);
|
|
@@ -54,7 +54,7 @@ export interface ScopedPresence {
|
|
|
54
54
|
editing(detail?: string): void;
|
|
55
55
|
editing(target: PresenceTarget, detail?: string): void;
|
|
56
56
|
idle(): void;
|
|
57
|
-
|
|
57
|
+
onChange(listener: () => void): () => void;
|
|
58
58
|
}
|
|
59
59
|
export interface ScopedClaimOptions {
|
|
60
60
|
/** Override the participant's focus target for this one claim. */
|
|
@@ -76,7 +76,7 @@ export interface ScopedIntents {
|
|
|
76
76
|
*/
|
|
77
77
|
claim(opts?: ScopedClaimOptions): Claim;
|
|
78
78
|
onRejected(listener: Parameters<IntentStream['onRejected']>[0]): () => void;
|
|
79
|
-
|
|
79
|
+
onChange(listener: () => void): () => void;
|
|
80
80
|
}
|
|
81
81
|
export interface ParticipantFocusOptions {
|
|
82
82
|
readonly activity?: 'reading' | 'viewing' | 'editing' | false;
|
|
@@ -218,8 +218,8 @@ function createJoinedParticipant(args) {
|
|
|
218
218
|
idle() {
|
|
219
219
|
args.presence.idle();
|
|
220
220
|
},
|
|
221
|
-
|
|
222
|
-
return args.presence.
|
|
221
|
+
onChange(listener) {
|
|
222
|
+
return args.presence.onChange(listener);
|
|
223
223
|
},
|
|
224
224
|
};
|
|
225
225
|
const track = (handle) => {
|
|
@@ -252,8 +252,8 @@ function createJoinedParticipant(args) {
|
|
|
252
252
|
onRejected(listener) {
|
|
253
253
|
return args.intents.onRejected(listener);
|
|
254
254
|
},
|
|
255
|
-
|
|
256
|
-
return args.intents.
|
|
255
|
+
onChange(listener) {
|
|
256
|
+
return args.intents.onChange(listener);
|
|
257
257
|
},
|
|
258
258
|
};
|
|
259
259
|
const leave = () => {
|
package/dist/types/global.d.ts
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Type registration point for SDK consumers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `usePresence`, `useIntent` — reads its types from the
|
|
7
|
-
* No generics at call sites
|
|
4
|
+
* A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
|
|
5
|
+
* augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
|
|
6
|
+
* `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
|
|
7
|
+
* resolved registration. No generics at call sites, no `schema` arg per call.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and every consumer of the resolved types below picks up the augmentation
|
|
15
|
-
* automatically.
|
|
9
|
+
* Registration is done via **module augmentation** of `@abloatai/ablo` —
|
|
10
|
+
* the same pattern TanStack Router uses for its `Register` interface. The brand
|
|
11
|
+
* lives in the module specifier, so the interface is just `Register` (not a
|
|
12
|
+
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
|
+
* in the compilation can augment it and every resolver below picks it up.
|
|
16
14
|
*
|
|
17
15
|
* Consumer example:
|
|
18
16
|
*
|
|
19
17
|
* ```ts
|
|
20
|
-
* // apps/your-app/src/ablo
|
|
18
|
+
* // apps/your-app/src/ablo.d.ts
|
|
21
19
|
* import type { schema } from './your-schema';
|
|
22
20
|
*
|
|
23
|
-
* declare
|
|
24
|
-
* interface
|
|
21
|
+
* declare module '@abloatai/ablo' {
|
|
22
|
+
* interface Register {
|
|
25
23
|
* Schema: typeof schema;
|
|
26
24
|
* Presence: { cursor: { x: number; y: number } | null };
|
|
27
25
|
* Intents: { editLayer: { layerId: string } };
|
|
@@ -31,17 +29,15 @@
|
|
|
31
29
|
* export {};
|
|
32
30
|
* ```
|
|
33
31
|
*
|
|
34
|
-
* If `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* If `Register` is never augmented, every resolver falls back to
|
|
33
|
+
* {@link DefaultSyncShape} — a loose shape that keeps consumers compiling
|
|
34
|
+
* without typed benefits until they opt in.
|
|
37
35
|
*/
|
|
38
36
|
/**
|
|
39
|
-
* Default fallback shapes used when the consumer hasn't
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* without producing a typed entity shape. Once the consumer augments the
|
|
44
|
-
* global, every resolver below picks up the augmented types automatically.
|
|
37
|
+
* Default fallback shapes used when the consumer hasn't augmented
|
|
38
|
+
* {@link Register}. `DefaultSyncShape.Schema` is intentionally structural — it
|
|
39
|
+
* carries `{ models: Record<string, unknown> }` so hooks can still validate the
|
|
40
|
+
* model key argument against *something*, just without a typed entity shape.
|
|
45
41
|
*/
|
|
46
42
|
export interface DefaultSyncShape {
|
|
47
43
|
readonly Schema: {
|
|
@@ -53,54 +49,49 @@ export interface DefaultSyncShape {
|
|
|
53
49
|
readonly id: string;
|
|
54
50
|
};
|
|
55
51
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
52
|
+
/**
|
|
53
|
+
* The registration interface. Consumers augment it via
|
|
54
|
+
* `declare module '@abloatai/ablo' { interface Register { Schema: ...; … } }`.
|
|
55
|
+
* Empty by default — every SDK resolver falls back to {@link DefaultSyncShape}
|
|
56
|
+
* when an expected key is absent. Exported from the package root so the module
|
|
57
|
+
* augmentation merges into this declaration.
|
|
58
|
+
*/
|
|
59
|
+
export interface Register {
|
|
65
60
|
}
|
|
66
61
|
/**
|
|
67
|
-
* The consumer's schema, or the default shape if
|
|
68
|
-
*
|
|
69
|
-
* returned from queries/mutations.
|
|
62
|
+
* The consumer's schema, or the default shape if unregistered. Hooks use this
|
|
63
|
+
* to type their model-key argument and infer the entity type returned.
|
|
70
64
|
*/
|
|
71
|
-
export type ResolveSchema =
|
|
65
|
+
export type ResolveSchema = Register extends {
|
|
72
66
|
Schema: infer S;
|
|
73
67
|
} ? S extends {
|
|
74
68
|
models: Record<string, unknown>;
|
|
75
69
|
} ? S : DefaultSyncShape['Schema'] : DefaultSyncShape['Schema'];
|
|
76
70
|
/**
|
|
77
|
-
* The consumer's presence shape, or the default
|
|
78
|
-
*
|
|
79
|
-
* the consumer wants to broadcast per session.
|
|
71
|
+
* The consumer's presence shape, or the default if unregistered. Used by
|
|
72
|
+
* `usePresence`. Free-form — any serializable JSON broadcast per session.
|
|
80
73
|
*/
|
|
81
|
-
export type ResolvePresence =
|
|
74
|
+
export type ResolvePresence = Register extends {
|
|
82
75
|
Presence: infer P;
|
|
83
76
|
} ? P : DefaultSyncShape['Presence'];
|
|
84
77
|
/**
|
|
85
|
-
* The consumer's intent vocabulary, or the default if
|
|
86
|
-
*
|
|
87
|
-
*
|
|
78
|
+
* The consumer's intent vocabulary, or the default if unregistered. Keys are
|
|
79
|
+
* intent names; values are the claim payload for each intent. Used by
|
|
80
|
+
* `useIntent(intentName)`.
|
|
88
81
|
*/
|
|
89
|
-
export type ResolveIntents =
|
|
82
|
+
export type ResolveIntents = Register extends {
|
|
90
83
|
Intents: infer I;
|
|
91
84
|
} ? I : DefaultSyncShape['Intents'];
|
|
92
85
|
/**
|
|
93
|
-
* The consumer's user-metadata shape, or the default if
|
|
94
|
-
*
|
|
95
|
-
* the SDK doesn't validate this.
|
|
86
|
+
* The consumer's user-metadata shape, or the default if unregistered. Carries
|
|
87
|
+
* identity info the consumer trusts from their auth layer — not SDK-validated.
|
|
96
88
|
*/
|
|
97
|
-
export type ResolveUserMeta =
|
|
89
|
+
export type ResolveUserMeta = Register extends {
|
|
98
90
|
UserMeta: infer U;
|
|
99
91
|
} ? U : DefaultSyncShape['UserMeta'];
|
|
100
92
|
/**
|
|
101
|
-
* The keys of the consumer's schema models. `useQuery(modelKey)` narrows
|
|
102
|
-
*
|
|
103
|
-
* compile time.
|
|
93
|
+
* The keys of the consumer's schema models. `useQuery(modelKey)` narrows its
|
|
94
|
+
* first argument to this union, so unknown key literals fail at compile time.
|
|
104
95
|
*/
|
|
105
96
|
export type ResolveModelKey = ResolveSchema extends {
|
|
106
97
|
models: infer M;
|
package/dist/types/global.js
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Type registration point for SDK consumers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `usePresence`, `useIntent` — reads its types from the
|
|
7
|
-
* No generics at call sites
|
|
4
|
+
* A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
|
|
5
|
+
* augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
|
|
6
|
+
* `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
|
|
7
|
+
* resolved registration. No generics at call sites, no `schema` arg per call.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and every consumer of the resolved types below picks up the augmentation
|
|
15
|
-
* automatically.
|
|
9
|
+
* Registration is done via **module augmentation** of `@abloatai/ablo` —
|
|
10
|
+
* the same pattern TanStack Router uses for its `Register` interface. The brand
|
|
11
|
+
* lives in the module specifier, so the interface is just `Register` (not a
|
|
12
|
+
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
|
+
* in the compilation can augment it and every resolver below picks it up.
|
|
16
14
|
*
|
|
17
15
|
* Consumer example:
|
|
18
16
|
*
|
|
19
17
|
* ```ts
|
|
20
|
-
* // apps/your-app/src/ablo
|
|
18
|
+
* // apps/your-app/src/ablo.d.ts
|
|
21
19
|
* import type { schema } from './your-schema';
|
|
22
20
|
*
|
|
23
|
-
* declare
|
|
24
|
-
* interface
|
|
21
|
+
* declare module '@abloatai/ablo' {
|
|
22
|
+
* interface Register {
|
|
25
23
|
* Schema: typeof schema;
|
|
26
24
|
* Presence: { cursor: { x: number; y: number } | null };
|
|
27
25
|
* Intents: { editLayer: { layerId: string } };
|
|
@@ -31,8 +29,8 @@
|
|
|
31
29
|
* export {};
|
|
32
30
|
* ```
|
|
33
31
|
*
|
|
34
|
-
* If `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* If `Register` is never augmented, every resolver falls back to
|
|
33
|
+
* {@link DefaultSyncShape} — a loose shape that keeps consumers compiling
|
|
34
|
+
* without typed benefits until they opt in.
|
|
37
35
|
*/
|
|
38
36
|
export {};
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -175,7 +175,7 @@ export interface PresenceStream {
|
|
|
175
175
|
/**
|
|
176
176
|
* Reactive view of every OTHER participant's current activity on
|
|
177
177
|
* this participant's sync groups. Reads return the current snapshot;
|
|
178
|
-
* pair with `
|
|
178
|
+
* pair with `onChange(listener)` below to get notified on changes.
|
|
179
179
|
*
|
|
180
180
|
* An LLM pipeline can include `presence.others` in its system prompt
|
|
181
181
|
* so the model literally reasons with knowledge of what other
|
|
@@ -209,7 +209,7 @@ export interface PresenceStream {
|
|
|
209
209
|
* });
|
|
210
210
|
* ```
|
|
211
211
|
*/
|
|
212
|
-
|
|
212
|
+
onChange(listener: () => void): () => void;
|
|
213
213
|
/**
|
|
214
214
|
* Async-iterable view of the peer roster. Each iteration yields the
|
|
215
215
|
* current `others` snapshot on every mutation — so the consumer
|
|
@@ -371,6 +371,15 @@ export interface ClaimOptions extends IntentOptions {
|
|
|
371
371
|
* app-specific phases.
|
|
372
372
|
*/
|
|
373
373
|
readonly reason?: string;
|
|
374
|
+
/**
|
|
375
|
+
* Join the server's fair FIFO queue on contention instead of being
|
|
376
|
+
* rejected. The grant arrives asynchronously (`intent_acquired` if the
|
|
377
|
+
* target was free, `intent_granted` once promoted to the head of the line).
|
|
378
|
+
* The low-level `claim` returns its handle immediately regardless; callers
|
|
379
|
+
* that need to *wait* for the grant use the awaiting wrappers
|
|
380
|
+
* (`ablo.<model>.claim`), which pair this flag with `awaitIntentGrant`.
|
|
381
|
+
*/
|
|
382
|
+
readonly queue?: boolean;
|
|
374
383
|
}
|
|
375
384
|
export interface IntentStream {
|
|
376
385
|
/**
|
|
@@ -394,6 +403,14 @@ export interface IntentStream {
|
|
|
394
403
|
* below to get notified on change.
|
|
395
404
|
*/
|
|
396
405
|
readonly others: ReadonlyArray<ActiveIntent>;
|
|
406
|
+
/**
|
|
407
|
+
* Reactive view of the wait queue on one target — the FIFO line of
|
|
408
|
+
* `status: 'queued'` intents behind the current holder, each with its
|
|
409
|
+
* `action`, `heldBy`, and `position`. Synced from the server's per-entity
|
|
410
|
+
* `intent_queue` frame; empty when no one's waiting. Pair with
|
|
411
|
+
* `subscribe(...)` for change notifications.
|
|
412
|
+
*/
|
|
413
|
+
queueFor(target: PresenceTarget): readonly Intent[];
|
|
397
414
|
/**
|
|
398
415
|
* Framework-agnostic reactivity. Same contract as
|
|
399
416
|
* `PresenceStream.subscribe` — register a listener fired on every
|
|
@@ -401,7 +418,7 @@ export interface IntentStream {
|
|
|
401
418
|
* returns an unsubscribe fn. Use `useSyncExternalStore` in React or
|
|
402
419
|
* `autorun` in MobX.
|
|
403
420
|
*/
|
|
404
|
-
|
|
421
|
+
onChange(listener: () => void): () => void;
|
|
405
422
|
/**
|
|
406
423
|
* Observe server-side intent rejections. Fires when the server
|
|
407
424
|
* rejects an `intents.writing(...)` / `announce(...)` call because
|
|
@@ -494,8 +511,13 @@ export interface ActiveIntent extends IntentDeclaration {
|
|
|
494
511
|
readonly announcedAt: string;
|
|
495
512
|
readonly expiresAt: string;
|
|
496
513
|
}
|
|
497
|
-
/**
|
|
498
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Every lifecycle state of a coordination intent, in one enum.
|
|
516
|
+
* `active` = the current holder (the lock). `queued` = waiting in the FIFO
|
|
517
|
+
* line behind the holder (carries `position`). The terminal states drop the
|
|
518
|
+
* intent from the synced set.
|
|
519
|
+
*/
|
|
520
|
+
export type IntentStatus = 'active' | 'queued' | 'committed' | 'expired' | 'canceled';
|
|
499
521
|
/** Options for waiting on a target to become free. */
|
|
500
522
|
export interface IntentWaitOptions {
|
|
501
523
|
readonly timeout?: number;
|
|
@@ -509,10 +531,11 @@ export interface IntentWaitOptions {
|
|
|
509
531
|
*
|
|
510
532
|
* Deliberately omits a Stripe-style `next_action`: a contender's only
|
|
511
533
|
* response is "wait until free, then re-read", and the runtime performs
|
|
512
|
-
* that uniformly
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
* object exists
|
|
534
|
+
* that uniformly — `claim` serializes behind the holder via the server
|
|
535
|
+
* FIFO queue (or low-level `intents.waitFor` to wait without claiming), and the
|
|
536
|
+
* stale-context guard forces the re-read. Encoding a constant instruction
|
|
537
|
+
* the engine always takes would be the kind of ceremony this object exists
|
|
538
|
+
* to remove.
|
|
516
539
|
*/
|
|
517
540
|
export interface Intent {
|
|
518
541
|
readonly object: 'intent';
|
|
@@ -532,4 +555,9 @@ export interface Intent {
|
|
|
532
555
|
readonly createdAt?: string;
|
|
533
556
|
/** Ms-epoch the server auto-expires it if the holder doesn't finish. */
|
|
534
557
|
readonly expiresAt: string;
|
|
558
|
+
/**
|
|
559
|
+
* 0-based place in the FIFO line — present only when `status: 'queued'`
|
|
560
|
+
* (`0` = next in line behind the holder). Absent for the active holder.
|
|
561
|
+
*/
|
|
562
|
+
readonly position?: number;
|
|
535
563
|
}
|