@abloatai/ablo 0.10.1 → 0.11.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/README.md +2 -1
- package/dist/BaseSyncedStore.d.ts +75 -0
- package/dist/BaseSyncedStore.js +193 -8
- package/dist/Database.d.ts +10 -2
- package/dist/Database.js +15 -1
- package/dist/SyncClient.d.ts +12 -1
- package/dist/SyncClient.js +110 -26
- package/dist/agent/Agent.d.ts +9 -9
- package/dist/agent/Agent.js +16 -16
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +2 -2
- package/dist/agent/types.d.ts +1 -1
- package/dist/agent/types.js +1 -1
- package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
- package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
- package/dist/ai-sdk/coordination-context.d.ts +9 -9
- package/dist/ai-sdk/coordination-context.js +8 -8
- package/dist/ai-sdk/index.d.ts +1 -1
- package/dist/ai-sdk/index.js +1 -1
- package/dist/ai-sdk/wrap.d.ts +4 -4
- package/dist/ai-sdk/wrap.js +4 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/cli.cjs +254 -48
- package/dist/client/Ablo.d.ts +30 -63
- package/dist/client/Ablo.js +108 -102
- package/dist/client/ApiClient.d.ts +6 -5
- package/dist/client/ApiClient.js +83 -62
- package/dist/client/createModelProxy.d.ts +16 -54
- package/dist/client/createModelProxy.js +44 -16
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/httpClient.js +1 -1
- package/dist/client/index.d.ts +3 -3
- package/dist/client/writeOptionsSchema.d.ts +4 -4
- package/dist/client/writeOptionsSchema.js +4 -4
- package/dist/coordination/schema.d.ts +249 -38
- package/dist/coordination/schema.js +172 -39
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +4 -4
- package/dist/errorCodes.d.ts +9 -9
- package/dist/errorCodes.js +15 -15
- package/dist/errors.d.ts +51 -2
- package/dist/errors.js +94 -5
- package/dist/interfaces/index.d.ts +8 -4
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/types.d.ts +13 -13
- package/dist/policy/types.js +8 -8
- package/dist/react/AbloProvider.d.ts +51 -4
- package/dist/react/AbloProvider.js +95 -11
- package/dist/react/context.d.ts +26 -9
- package/dist/react/context.js +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +4 -4
- package/dist/react/useAblo.js +5 -5
- package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
- package/dist/react/useClaim.js +42 -0
- package/dist/schema/index.js +1 -1
- package/dist/schema/sugar.d.ts +3 -3
- package/dist/schema/sugar.js +3 -3
- package/dist/schema/sync-delta-wire.d.ts +8 -8
- package/dist/server/commit.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.d.ts +162 -0
- package/dist/sync/AreaOfInterestManager.js +233 -0
- package/dist/sync/BootstrapHelper.d.ts +9 -1
- package/dist/sync/BootstrapHelper.js +15 -5
- package/dist/sync/NetworkProbe.d.ts +1 -1
- package/dist/sync/NetworkProbe.js +1 -1
- package/dist/sync/SyncWebSocket.d.ts +59 -25
- package/dist/sync/SyncWebSocket.js +123 -26
- package/dist/sync/awaitClaimGrant.d.ts +40 -0
- package/dist/sync/awaitClaimGrant.js +86 -0
- package/dist/sync/createClaimStream.d.ts +34 -0
- package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
- package/dist/sync/createPresenceStream.js +3 -2
- package/dist/sync/participants.d.ts +10 -10
- package/dist/sync/participants.js +17 -10
- package/dist/sync/schemas.d.ts +8 -8
- package/dist/transactions/TransactionQueue.d.ts +12 -0
- package/dist/transactions/TransactionQueue.js +126 -8
- package/dist/types/global.d.ts +10 -10
- package/dist/types/global.js +3 -3
- package/dist/types/index.d.ts +9 -7
- package/dist/types/index.js +2 -2
- package/dist/types/streams.d.ts +114 -98
- package/dist/types/streams.js +1 -1
- package/dist/utils/asyncIterator.d.ts +1 -1
- package/dist/utils/asyncIterator.js +1 -1
- package/dist/wire/frames.d.ts +2 -2
- package/package.json +3 -2
- package/dist/react/useIntent.js +0 -42
- package/dist/sync/awaitIntentGrant.d.ts +0 -40
- package/dist/sync/awaitIntentGrant.js +0 -62
- package/dist/sync/createIntentStream.d.ts +0 -34
package/dist/errors.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import { errorCodeSpec, classifyRecovery } from './errorCodes.js';
|
|
23
|
+
import { wireClaimSummarySchema, descriptionFromMeta, } from './coordination/schema.js';
|
|
23
24
|
export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errorCodes.js';
|
|
24
25
|
// ── AbloError hierarchy — the typed error surface ────────────────────
|
|
25
26
|
/** Common shape for all errors thrown by this SDK. */
|
|
@@ -160,6 +161,49 @@ export class AbloStaleContextError extends AbloError {
|
|
|
160
161
|
this.conflicts = options.conflicts;
|
|
161
162
|
}
|
|
162
163
|
}
|
|
164
|
+
function claimAction(claim) {
|
|
165
|
+
return claim?.action;
|
|
166
|
+
}
|
|
167
|
+
function claimDescription(claim) {
|
|
168
|
+
if (!claim)
|
|
169
|
+
return undefined;
|
|
170
|
+
if ('description' in claim && typeof claim.description === 'string') {
|
|
171
|
+
return claim.description;
|
|
172
|
+
}
|
|
173
|
+
const meta = 'target' in claim ? claim.target?.meta ?? claim.meta : claim.meta;
|
|
174
|
+
return descriptionFromMeta(meta);
|
|
175
|
+
}
|
|
176
|
+
function claimExpiresAt(claim) {
|
|
177
|
+
return claim?.expiresAt;
|
|
178
|
+
}
|
|
179
|
+
function claimActor(claim, fallback) {
|
|
180
|
+
if (claim && 'actor' in claim && typeof claim.actor === 'string') {
|
|
181
|
+
return claim.actor;
|
|
182
|
+
}
|
|
183
|
+
return fallback;
|
|
184
|
+
}
|
|
185
|
+
function secondsUntil(ms, now = Date.now()) {
|
|
186
|
+
if (ms === undefined || !Number.isFinite(ms))
|
|
187
|
+
return undefined;
|
|
188
|
+
return Math.max(0, Math.ceil((ms - now) / 1000));
|
|
189
|
+
}
|
|
190
|
+
export function formatClaimedErrorMessage(args) {
|
|
191
|
+
const holder = claimActor(args.claim, args.heldBy);
|
|
192
|
+
const action = claimAction(args.claim);
|
|
193
|
+
const description = claimDescription(args.claim);
|
|
194
|
+
const expiresIn = secondsUntil(claimExpiresAt(args.claim));
|
|
195
|
+
if (!holder && !action && !description) {
|
|
196
|
+
return args.fallback ?? `Model row is claimed: ${args.targetLabel}.`;
|
|
197
|
+
}
|
|
198
|
+
const actor = holder ?? 'another participant';
|
|
199
|
+
const actionPart = action ? ` (${action})` : '';
|
|
200
|
+
const descriptionPart = description ? `: ${description}` : '';
|
|
201
|
+
const expiresPart = expiresIn !== undefined ? ` - expires in ${expiresIn}s` : '';
|
|
202
|
+
const policyPart = args.policyReason
|
|
203
|
+
? ` Policy reason: ${args.policyReason}.`
|
|
204
|
+
: '';
|
|
205
|
+
return `Claimed by ${actor}${actionPart}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
|
|
206
|
+
}
|
|
163
207
|
/**
|
|
164
208
|
* The target entity is currently claimed by another participant and the caller
|
|
165
209
|
* asked the SDK not to read/write through that claim.
|
|
@@ -176,6 +220,30 @@ export class AbloClaimedError extends AbloError {
|
|
|
176
220
|
this.claims = options.claims;
|
|
177
221
|
}
|
|
178
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* The `/`-joined human label for a claim target — `model/id/field`, dropping
|
|
225
|
+
* absent parts, falling back to `'target'`. The one place this join lived in
|
|
226
|
+
* three copies (client `Ablo`, HTTP `ApiClient`, `awaitClaimGrant`).
|
|
227
|
+
*/
|
|
228
|
+
export function claimTargetLabel(target) {
|
|
229
|
+
return [target.model, target.id, target.field].filter(Boolean).join('/') || 'target';
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Build the {@link AbloClaimedError} for a contended `ablo.<model>` write — the
|
|
233
|
+
* single factory shared by the realtime client (`Ablo`) and the HTTP client
|
|
234
|
+
* (`ApiClient`), which carried byte-identical copies. The first claim is the
|
|
235
|
+
* holder whose metadata shapes the message.
|
|
236
|
+
*/
|
|
237
|
+
export function claimedError(target, claims, code) {
|
|
238
|
+
const label = claimTargetLabel(target);
|
|
239
|
+
const holder = claims[0];
|
|
240
|
+
return new AbloClaimedError(formatClaimedErrorMessage({
|
|
241
|
+
targetLabel: label,
|
|
242
|
+
heldBy: holder?.actor,
|
|
243
|
+
claim: holder,
|
|
244
|
+
fallback: `Model row is claimed: ${label} held by another participant.`,
|
|
245
|
+
}), { code, claims });
|
|
246
|
+
}
|
|
179
247
|
/**
|
|
180
248
|
* A scoped credential was denied — either the key is unknown / revoked /
|
|
181
249
|
* expired (`capability_invalid`), or the connection's resolved scope
|
|
@@ -282,6 +350,10 @@ const NestedErrorShapeSchema = z
|
|
|
282
350
|
message: OptionalWireStringSchema,
|
|
283
351
|
field: OptionalWireStringSchema,
|
|
284
352
|
requiredCapability: RequiredCapabilityWireSchema.optional().catch(undefined),
|
|
353
|
+
heldBy: OptionalWireStringSchema,
|
|
354
|
+
policyReason: OptionalWireStringSchema,
|
|
355
|
+
heldByClaim: wireClaimSummarySchema.optional().catch(undefined),
|
|
356
|
+
claims: z.array(wireClaimSummarySchema).optional().catch(undefined),
|
|
285
357
|
})
|
|
286
358
|
.passthrough();
|
|
287
359
|
const ErrorFieldSchema = z
|
|
@@ -298,6 +370,10 @@ const ErrorBodyShapeSchema = z
|
|
|
298
370
|
reason: OptionalWireStringSchema,
|
|
299
371
|
message: OptionalWireStringSchema,
|
|
300
372
|
requiredCapability: RequiredCapabilityWireSchema.optional().catch(undefined),
|
|
373
|
+
heldBy: OptionalWireStringSchema,
|
|
374
|
+
policyReason: OptionalWireStringSchema,
|
|
375
|
+
heldByClaim: wireClaimSummarySchema.optional().catch(undefined),
|
|
376
|
+
claims: z.array(wireClaimSummarySchema).optional().catch(undefined),
|
|
301
377
|
})
|
|
302
378
|
.passthrough();
|
|
303
379
|
function parseErrorBodyShape(body) {
|
|
@@ -341,7 +417,7 @@ export function toAbloError(err) {
|
|
|
341
417
|
* hierarchy and loses `code`/`httpStatus`/retryability.
|
|
342
418
|
*/
|
|
343
419
|
export function errorFromWire(message, opts = {}) {
|
|
344
|
-
const { code, requestId, requiredCapability } = opts;
|
|
420
|
+
const { code, requestId, requiredCapability, claims } = opts;
|
|
345
421
|
// Effective status: an explicit HTTP status wins; otherwise fall back to
|
|
346
422
|
// the code's canonical status from the registry (undefined for unknown /
|
|
347
423
|
// forward-compat codes, which then map to the base AbloError).
|
|
@@ -349,7 +425,7 @@ export function errorFromWire(message, opts = {}) {
|
|
|
349
425
|
// Wire boundary: an incoming code is an arbitrary string (a newer server
|
|
350
426
|
// may send a code this SDK predates). Cast to ErrorCode here — the one
|
|
351
427
|
// sanctioned crossing — so internal producers stay statically checked.
|
|
352
|
-
const publicCode = (code === '
|
|
428
|
+
const publicCode = (code === 'claim_conflict' ? 'claim_conflict' : code);
|
|
353
429
|
const baseOpts = { code: publicCode, httpStatus, requestId };
|
|
354
430
|
// ── Code-first specials (transport-independent) ──────────────────────
|
|
355
431
|
// A scoped credential was denied — route through CapabilityError so callers
|
|
@@ -360,8 +436,8 @@ export function errorFromWire(message, opts = {}) {
|
|
|
360
436
|
// Claim enforcement (rides 409): the target entity is held by another
|
|
361
437
|
// participant. Discriminate on code BEFORE the generic 409→idempotency
|
|
362
438
|
// mapping so a claim rejection surfaces as AbloClaimedError.
|
|
363
|
-
if (code === '
|
|
364
|
-
return new AbloClaimedError(message, baseOpts);
|
|
439
|
+
if (code === 'claim_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
|
|
440
|
+
return new AbloClaimedError(message, { ...baseOpts, claims });
|
|
365
441
|
}
|
|
366
442
|
// A write whose `readAt` watermark went stale — callers re-read and retry.
|
|
367
443
|
if (code === 'stale_context') {
|
|
@@ -404,7 +480,20 @@ export function translateHttpError(status, body, requestId) {
|
|
|
404
480
|
flatError ??
|
|
405
481
|
(typeof body === 'string' ? body : `HTTP ${status}`);
|
|
406
482
|
const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
|
|
407
|
-
|
|
483
|
+
const claims = parsed.claims ??
|
|
484
|
+
nested?.claims ??
|
|
485
|
+
(parsed.heldByClaim
|
|
486
|
+
? [parsed.heldByClaim]
|
|
487
|
+
: nested?.heldByClaim
|
|
488
|
+
? [nested.heldByClaim]
|
|
489
|
+
: undefined);
|
|
490
|
+
return errorFromWire(message, {
|
|
491
|
+
code,
|
|
492
|
+
httpStatus: status,
|
|
493
|
+
requestId,
|
|
494
|
+
requiredCapability,
|
|
495
|
+
claims,
|
|
496
|
+
});
|
|
408
497
|
}
|
|
409
498
|
/**
|
|
410
499
|
* Whether an HTTP error body carries a code {@link translateHttpError} can read
|
|
@@ -160,13 +160,17 @@ export interface MutationOptions {
|
|
|
160
160
|
wait?: 'queued' | 'confirmed';
|
|
161
161
|
readAt?: number | null;
|
|
162
162
|
onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
|
|
163
|
-
|
|
163
|
+
/** Claim-pin attribution: the id (or `{ id }`) of the claim this write
|
|
164
|
+
* belongs to. Distinct from the `claim` HANDLE on the model write params —
|
|
165
|
+
* this is the low-level reference the commit carries to bypass the holder's
|
|
166
|
+
* own pin. (Was `intent` before the claim-vocabulary unification.) */
|
|
167
|
+
claimRef?: string | {
|
|
164
168
|
readonly id: string;
|
|
165
169
|
} | null;
|
|
166
170
|
/**
|
|
167
171
|
* Dormant agent-task lineage field, forwarded as the wire-level
|
|
168
172
|
* `causedByTaskId`. Turns/tasks were removed from the SDK; nothing
|
|
169
|
-
* populates this anymore (write attribution rides on the claim
|
|
173
|
+
* populates this anymore (write attribution rides on the claim
|
|
170
174
|
* id). Kept optional for wire-compat; always `null` from the client.
|
|
171
175
|
*/
|
|
172
176
|
causedByTaskId?: string | null;
|
|
@@ -175,9 +179,9 @@ export interface MutationOptions {
|
|
|
175
179
|
* The `MutationOptions` subset carried per-write through the offline
|
|
176
180
|
* transaction lane (SyncClient → TransactionQueue → wire operation).
|
|
177
181
|
* ONE shared type so the proxy's public params, the queue, and the wire
|
|
178
|
-
* can never narrow each other silently again — `wait` and `
|
|
182
|
+
* can never narrow each other silently again — `wait` and `claim` are
|
|
179
183
|
* deliberately absent because they resolve client-side before staging
|
|
180
|
-
* (`wait` at the proxy's confirmation await, `
|
|
184
|
+
* (`wait` at the proxy's confirmation await, `claim` server-side via
|
|
181
185
|
* the active lease on the entity).
|
|
182
186
|
*/
|
|
183
187
|
export type WriteOptions = Pick<MutationOptions, 'readAt' | 'onStale' | 'idempotencyKey' | 'label'>;
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
|
-
export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict,
|
|
18
|
+
export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from './types.js';
|
|
19
19
|
export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Conflict policy — the engine detects, the policy decides.
|
|
3
3
|
*
|
|
4
4
|
* Two conflict shapes today: `stale_context` (a write whose `readAt`
|
|
5
|
-
* is older than the latest delta on the target) and `
|
|
5
|
+
* is older than the latest delta on the target) and `claim_held`
|
|
6
6
|
* (a participant claims a target someone else is already claiming).
|
|
7
7
|
* Adding new shapes is additive on the discriminated union.
|
|
8
8
|
*/
|
|
9
9
|
import type { ParticipantRef } from '../types/streams.js';
|
|
10
|
-
export type ConflictKind = 'stale_context' | '
|
|
10
|
+
export type ConflictKind = 'stale_context' | 'claim_held';
|
|
11
11
|
/** Fields shared by every conflict shape. */
|
|
12
12
|
interface ConflictBase {
|
|
13
13
|
readonly committer: ParticipantRef;
|
|
@@ -40,19 +40,19 @@ export interface StaleContextConflict extends ConflictBase {
|
|
|
40
40
|
*/
|
|
41
41
|
readonly conflictingFields?: readonly string[];
|
|
42
42
|
}
|
|
43
|
-
export interface
|
|
44
|
-
readonly kind: '
|
|
43
|
+
export interface ClaimHeldConflict extends ConflictBase {
|
|
44
|
+
readonly kind: 'claim_held';
|
|
45
45
|
readonly heldBy: ParticipantRef;
|
|
46
|
-
readonly
|
|
46
|
+
readonly claimId: string;
|
|
47
47
|
readonly entityType: string;
|
|
48
48
|
readonly entityId: string;
|
|
49
|
-
/** Holder's
|
|
49
|
+
/** Holder's claim expiry (ms since epoch). */
|
|
50
50
|
readonly expiresAt: number;
|
|
51
51
|
/**
|
|
52
52
|
* The committer's granted capability operations (the key's allowlist). A
|
|
53
53
|
* policy is a pure function of the conflict value, so it can only authorize
|
|
54
54
|
* on what's carried here — this is what lets a policy express "preempt iff
|
|
55
|
-
* the committer holds `
|
|
55
|
+
* the committer holds `claim.preempt`" (see `capabilityPreemptPolicy`).
|
|
56
56
|
* Empty for a human session with no allowlist.
|
|
57
57
|
*/
|
|
58
58
|
readonly committerOperations: readonly string[];
|
|
@@ -61,7 +61,7 @@ export interface IntentHeldConflict extends ConflictBase {
|
|
|
61
61
|
* The discriminated union the policy receives. Switch on `.kind` to
|
|
62
62
|
* narrow to the variant.
|
|
63
63
|
*/
|
|
64
|
-
export type Conflict = StaleContextConflict |
|
|
64
|
+
export type Conflict = StaleContextConflict | ClaimHeldConflict;
|
|
65
65
|
/** What the policy returns. */
|
|
66
66
|
export type ConflictDecision = {
|
|
67
67
|
readonly action: 'reject';
|
|
@@ -72,8 +72,8 @@ export type ConflictDecision = {
|
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
74
|
* Evict the current holder and grant the target to the committer. Only
|
|
75
|
-
* meaningful for an `
|
|
76
|
-
* the holder receives an `
|
|
75
|
+
* meaningful for an `claim_held` conflict at claim time (`claim_begin`):
|
|
76
|
+
* the holder receives an `claim_lost` (reason `'preempted'`) and the
|
|
77
77
|
* preemptor takes the lease, jumping ahead of any FIFO waiters. This is the
|
|
78
78
|
* authorization seam for preemption — a policy returns `preempt` only for a
|
|
79
79
|
* committer it deems higher-priority (e.g. a supervisor over its sub-agents,
|
|
@@ -100,12 +100,12 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
|
|
|
100
100
|
/**
|
|
101
101
|
* Default: reject every conflict. Safe fallback when no custom policy
|
|
102
102
|
* is wired — the engine never silently allows a stale or
|
|
103
|
-
*
|
|
103
|
+
* claim-conflicting write through.
|
|
104
104
|
*/
|
|
105
105
|
export declare const defaultPolicy: ConflictPolicy;
|
|
106
106
|
/**
|
|
107
|
-
* Capability-gated preemption. An `
|
|
108
|
-
* committer holds the `
|
|
107
|
+
* Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
|
|
108
|
+
* committer holds the `claim.preempt` operation in its capability allowlist
|
|
109
109
|
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
110
110
|
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
111
111
|
* global to let a privileged identity jump a held entity without a bespoke
|
package/dist/policy/types.js
CHANGED
|
@@ -2,31 +2,31 @@
|
|
|
2
2
|
* Conflict policy — the engine detects, the policy decides.
|
|
3
3
|
*
|
|
4
4
|
* Two conflict shapes today: `stale_context` (a write whose `readAt`
|
|
5
|
-
* is older than the latest delta on the target) and `
|
|
5
|
+
* is older than the latest delta on the target) and `claim_held`
|
|
6
6
|
* (a participant claims a target someone else is already claiming).
|
|
7
7
|
* Adding new shapes is additive on the discriminated union.
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
10
|
* Default: reject every conflict. Safe fallback when no custom policy
|
|
11
11
|
* is wired — the engine never silently allows a stale or
|
|
12
|
-
*
|
|
12
|
+
* claim-conflicting write through.
|
|
13
13
|
*/
|
|
14
14
|
export const defaultPolicy = (conflict) => ({
|
|
15
15
|
action: 'reject',
|
|
16
|
-
reason: conflict.kind === 'stale_context' ? 'stale_context' : '
|
|
16
|
+
reason: conflict.kind === 'stale_context' ? 'stale_context' : 'claim_conflict',
|
|
17
17
|
});
|
|
18
18
|
/**
|
|
19
|
-
* Capability-gated preemption. An `
|
|
20
|
-
* committer holds the `
|
|
19
|
+
* Capability-gated preemption. An `claim_held` conflict is PREEMPTED when the
|
|
20
|
+
* committer holds the `claim.preempt` operation in its capability allowlist
|
|
21
21
|
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
22
22
|
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
23
23
|
* global to let a privileged identity jump a held entity without a bespoke
|
|
24
24
|
* policy. The authorization is the capability, not an identity string.
|
|
25
25
|
*/
|
|
26
26
|
export const capabilityPreemptPolicy = (conflict) => {
|
|
27
|
-
if (conflict.kind === '
|
|
28
|
-
conflict.committerOperations.includes('
|
|
29
|
-
return { action: 'preempt', reason: 'capability:
|
|
27
|
+
if (conflict.kind === 'claim_held' &&
|
|
28
|
+
conflict.committerOperations.includes('claim.preempt')) {
|
|
29
|
+
return { action: 'preempt', reason: 'capability:claim.preempt' };
|
|
30
30
|
}
|
|
31
31
|
return defaultPolicy(conflict);
|
|
32
32
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { SchemaRecord } from '../schema/schema.js';
|
|
3
3
|
import { Ablo } from '../client/Ablo.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { ActiveClaim, Peer } from '../types/streams.js';
|
|
5
5
|
import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
|
|
6
6
|
import { type SyncStoreContract } from './context.js';
|
|
7
7
|
/**
|
|
@@ -128,6 +128,29 @@ export interface UseParticipantOptions {
|
|
|
128
128
|
readonly autoRefreshThresholdSeconds?: number | null;
|
|
129
129
|
/** Tear down + don't re-join while true. */
|
|
130
130
|
readonly paused?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Acquire a write-claim CLAIM on the scope, in addition to read interest.
|
|
133
|
+
*
|
|
134
|
+
* Default `false`: opening a scope subscribes the connection to its deltas
|
|
135
|
+
* (read interest, via `update_subscription`) but does NOT claim it — a
|
|
136
|
+
* viewer is not a claimant. Set `true` when the participant intends to
|
|
137
|
+
* WRITE (editing a deck, an agent staking work): the claim is sent so peers
|
|
138
|
+
* observe it, and the scope is pinned so it stays subscribed (never warms)
|
|
139
|
+
* for as long as the claim is held.
|
|
140
|
+
*/
|
|
141
|
+
readonly claim?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Backfill the scope's CURRENT state into the pool on enter, in addition to
|
|
144
|
+
* tailing live changes.
|
|
145
|
+
*
|
|
146
|
+
* Default `false`: entering a scope subscribes to its FUTURE deltas only — if
|
|
147
|
+
* the scope's rows aren't already loaded, the view is empty until something
|
|
148
|
+
* changes. Set `true` when opening an entity that may not be loaded yet (a
|
|
149
|
+
* deep-linked deck, a never-opened sheet) so its current rows are fetched and
|
|
150
|
+
* injected once, then kept fresh by the live tail. The fetch is single-flight
|
|
151
|
+
* and runs once per group; a failure soft-fails (the live tail still flows).
|
|
152
|
+
*/
|
|
153
|
+
readonly hydrate?: boolean;
|
|
131
154
|
}
|
|
132
155
|
/** @deprecated Use `ParticipantStatus`. */
|
|
133
156
|
export type MeshParticipantStatus = ParticipantStatus;
|
|
@@ -135,8 +158,8 @@ export interface UseParticipantReturn {
|
|
|
135
158
|
readonly participant: EngineParticipant | null;
|
|
136
159
|
/** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
|
|
137
160
|
readonly peers: ReadonlyArray<Peer>;
|
|
138
|
-
/** Active
|
|
139
|
-
readonly claims: ReadonlyArray<
|
|
161
|
+
/** Active claim claims by peers (`participant.claims.others`), bridged to React. */
|
|
162
|
+
readonly claims: ReadonlyArray<ActiveClaim>;
|
|
140
163
|
readonly status: ParticipantStatus;
|
|
141
164
|
readonly error: Error | null;
|
|
142
165
|
}
|
|
@@ -146,11 +169,35 @@ export interface UseParticipantReturn {
|
|
|
146
169
|
* flips to true.
|
|
147
170
|
*
|
|
148
171
|
* The returned `participant` is an `EngineParticipant` — `.presence`
|
|
149
|
-
* + `.
|
|
172
|
+
* + `.claims` only — backed by the engine's existing socket. For
|
|
150
173
|
* headless-bot patterns (a separate identity in the same browser
|
|
151
174
|
* tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
|
|
152
175
|
*/
|
|
153
176
|
export declare function useParticipant(opts: UseParticipantOptions): UseParticipantReturn;
|
|
177
|
+
/**
|
|
178
|
+
* Read-only presence: the OTHER participants currently visible to this
|
|
179
|
+
* connection, bridged to React. Unlike {@link useParticipant}, this does
|
|
180
|
+
* NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
|
|
181
|
+
* it is a pure reader of the engine's already-flowing presence stream.
|
|
182
|
+
*
|
|
183
|
+
* Pass `scope` to narrow to the peers on that scope's sync group(s); omit
|
|
184
|
+
* it to get everyone on the engine's groups. Membership is driven entirely
|
|
185
|
+
* by the presence channel (set server-side on connect, independent of any
|
|
186
|
+
* cursor/collaboration traffic), so reading it never affects what the
|
|
187
|
+
* connection is subscribed to and can't deadlock against a gated channel.
|
|
188
|
+
*
|
|
189
|
+
* Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
|
|
190
|
+
* broadcasts while alone — when some OTHER mount already owns the scope's
|
|
191
|
+
* read interest (scope `leave` is not reference-counted, so a second
|
|
192
|
+
* `useParticipant` on the same scope would warm-drop the owner's
|
|
193
|
+
* subscription on unmount).
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* const peers = usePeers({ slideDecks: deckId });
|
|
197
|
+
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
export declare function usePeers(scope?: ParticipantScope): ReadonlyArray<Peer>;
|
|
154
201
|
/**
|
|
155
202
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
156
203
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
@@ -205,7 +205,7 @@ const EMPTY_INTENTS = Object.freeze([]);
|
|
|
205
205
|
* flips to true.
|
|
206
206
|
*
|
|
207
207
|
* The returned `participant` is an `EngineParticipant` — `.presence`
|
|
208
|
-
* + `.
|
|
208
|
+
* + `.claims` only — backed by the engine's existing socket. For
|
|
209
209
|
* headless-bot patterns (a separate identity in the same browser
|
|
210
210
|
* tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
|
|
211
211
|
*/
|
|
@@ -224,18 +224,21 @@ export function useParticipant(opts) {
|
|
|
224
224
|
// Reference-stable participant facade — same socket as entity sync,
|
|
225
225
|
// so there is no `connect()` / `disconnect()` lifecycle here. The
|
|
226
226
|
// engine manages the connection; the hook is a thin window onto its
|
|
227
|
-
// already-attached presence +
|
|
227
|
+
// already-attached presence + claim streams.
|
|
228
228
|
const participant = useMemo(() => {
|
|
229
229
|
if (!engine)
|
|
230
230
|
return null;
|
|
231
|
-
return { presence: engine.presence,
|
|
231
|
+
return { presence: engine.presence, claims: engine.claims };
|
|
232
232
|
}, [engine]);
|
|
233
233
|
// Status maps to the engine's sync state. `connecting` while the
|
|
234
234
|
// engine bootstraps; `connected` once `engine.ready()` resolves and
|
|
235
235
|
// any scoped participant claim has acked; `error` if the claim
|
|
236
236
|
// fails; `disconnected` while paused or before the engine exists.
|
|
237
237
|
const syncStatus = useSyncStatus();
|
|
238
|
-
|
|
238
|
+
// Only a write-claim participant waits on a claim ack. A pure reader
|
|
239
|
+
// (the default) is `connected` as soon as the engine is — its read
|
|
240
|
+
// interest is fire-and-forget `update_subscription`, not a claim.
|
|
241
|
+
const needsClaim = !!opts.claim && scopedSyncGroups.length > 0;
|
|
239
242
|
const status = paused || !engine
|
|
240
243
|
? 'disconnected'
|
|
241
244
|
: claimError
|
|
@@ -248,16 +251,45 @@ export function useParticipant(opts) {
|
|
|
248
251
|
? 'disconnected'
|
|
249
252
|
: 'connecting';
|
|
250
253
|
const error = claimError;
|
|
254
|
+
// ── Read interest (always) ───────────────────────────────────────
|
|
255
|
+
// Subscribe the connection to the scope's sync groups while mounted +
|
|
256
|
+
// connected — the area-of-interest navigation primitive. No claim, no
|
|
257
|
+
// TTL: a viewer just receives the scope's deltas. Hysteresis (warm TTL)
|
|
258
|
+
// lives in the store's AreaOfInterestManager, so a quick unmount/remount
|
|
259
|
+
// (tab flip) doesn't re-bootstrap.
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const scope = opts.scope;
|
|
262
|
+
if (paused || !engine || !scope || scopedSyncGroups.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
if (syncStatus.name !== 'connected')
|
|
265
|
+
return;
|
|
266
|
+
const store = engine._store;
|
|
267
|
+
// `hydrate` backfills the scope's current state after subscribing
|
|
268
|
+
// (store handles subscribe-first ordering + single-flight). leaveScope
|
|
269
|
+
// only moves read interest; the hydrated rows stay in the pool.
|
|
270
|
+
void store.enterScope?.(scope, { hydrate: opts.hydrate });
|
|
271
|
+
return () => {
|
|
272
|
+
void store.leaveScope?.(scope);
|
|
273
|
+
};
|
|
274
|
+
// scopeKey is the stable proxy for the resolved groups; same idiom as
|
|
275
|
+
// the claim effect below.
|
|
276
|
+
}, [engine, paused, scopeKey, syncStatus.name, opts.hydrate]);
|
|
277
|
+
// ── Write claim (opt-in: `claim: true`) ─────────────────────────
|
|
278
|
+
// A claim is the write-claim primitive — distinct from read interest
|
|
279
|
+
// above. Only sent when the caller opts in; it makes peers observe the
|
|
280
|
+
// claim and pins the scope so it never warms while held.
|
|
251
281
|
useEffect(() => {
|
|
252
282
|
setClaimError(null);
|
|
253
283
|
setClaimConnected(false);
|
|
254
|
-
|
|
284
|
+
const scope = opts.scope;
|
|
285
|
+
if (paused || !engine || !opts.claim || !scope || scopedSyncGroups.length === 0)
|
|
255
286
|
return;
|
|
256
287
|
if (syncStatus.name !== 'connected')
|
|
257
288
|
return;
|
|
258
289
|
const ws = engine._ws;
|
|
259
290
|
if (!ws)
|
|
260
291
|
return;
|
|
292
|
+
const store = engine._store;
|
|
261
293
|
let cancelled = false;
|
|
262
294
|
const claimId = createParticipantClaimId();
|
|
263
295
|
ws.sendClaim(claimId, scopedSyncGroups, {
|
|
@@ -272,12 +304,15 @@ export function useParticipant(opts) {
|
|
|
272
304
|
setClaimError(err instanceof Error ? err : new Error(String(err)));
|
|
273
305
|
}
|
|
274
306
|
});
|
|
307
|
+
// Prominence: hold the scope subscribed for as long as the claim lives.
|
|
308
|
+
void store.pinScope?.(scope);
|
|
275
309
|
return () => {
|
|
276
310
|
cancelled = true;
|
|
277
311
|
ws.sendRelease(claimId);
|
|
312
|
+
void store.unpinScope?.(scope);
|
|
278
313
|
};
|
|
279
|
-
}, [engine, paused, scopeKey, syncStatus.name, opts.ttlSeconds]);
|
|
280
|
-
// Bridge the engine's presence +
|
|
314
|
+
}, [engine, paused, scopeKey, syncStatus.name, opts.ttlSeconds, opts.claim]);
|
|
315
|
+
// Bridge the engine's presence + claims streams into React state.
|
|
281
316
|
// Plain useState + useEffect is sufficient — mid-frame tearing on a
|
|
282
317
|
// peer list is harmless (users won't notice one frame of stale
|
|
283
318
|
// presence). Queries and sync status use useSyncExternalStore
|
|
@@ -291,16 +326,16 @@ export function useParticipant(opts) {
|
|
|
291
326
|
return;
|
|
292
327
|
}
|
|
293
328
|
setPeers(participant.presence.others);
|
|
294
|
-
setClaims(participant.
|
|
329
|
+
setClaims(participant.claims.others);
|
|
295
330
|
const unsubPresence = participant.presence.onChange(() => {
|
|
296
331
|
setPeers(participant.presence.others);
|
|
297
332
|
});
|
|
298
|
-
const
|
|
299
|
-
setClaims(participant.
|
|
333
|
+
const unsubClaims = participant.claims.onChange(() => {
|
|
334
|
+
setClaims(participant.claims.others);
|
|
300
335
|
});
|
|
301
336
|
return () => {
|
|
302
337
|
unsubPresence();
|
|
303
|
-
|
|
338
|
+
unsubClaims();
|
|
304
339
|
};
|
|
305
340
|
}, [participant, paused]);
|
|
306
341
|
// `opts.as`, `opts.agent`, `opts.idempotencyKey`, and
|
|
@@ -309,6 +344,55 @@ export function useParticipant(opts) {
|
|
|
309
344
|
// active: it opens a multiplexed claim on the engine WebSocket.
|
|
310
345
|
return { participant, peers, claims, status, error };
|
|
311
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Read-only presence: the OTHER participants currently visible to this
|
|
349
|
+
* connection, bridged to React. Unlike {@link useParticipant}, this does
|
|
350
|
+
* NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
|
|
351
|
+
* it is a pure reader of the engine's already-flowing presence stream.
|
|
352
|
+
*
|
|
353
|
+
* Pass `scope` to narrow to the peers on that scope's sync group(s); omit
|
|
354
|
+
* it to get everyone on the engine's groups. Membership is driven entirely
|
|
355
|
+
* by the presence channel (set server-side on connect, independent of any
|
|
356
|
+
* cursor/collaboration traffic), so reading it never affects what the
|
|
357
|
+
* connection is subscribed to and can't deadlock against a gated channel.
|
|
358
|
+
*
|
|
359
|
+
* Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
|
|
360
|
+
* broadcasts while alone — when some OTHER mount already owns the scope's
|
|
361
|
+
* read interest (scope `leave` is not reference-counted, so a second
|
|
362
|
+
* `useParticipant` on the same scope would warm-drop the owner's
|
|
363
|
+
* subscription on unmount).
|
|
364
|
+
*
|
|
365
|
+
* ```ts
|
|
366
|
+
* const peers = usePeers({ slideDecks: deckId });
|
|
367
|
+
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
export function usePeers(scope) {
|
|
371
|
+
const ctx = useContext(AbloInternalContext);
|
|
372
|
+
const engine = ctx?.engine ?? null;
|
|
373
|
+
// Resolve scope → groups through the schema (same idiom as useParticipant).
|
|
374
|
+
// The stringified, sorted key is the stable effect dependency.
|
|
375
|
+
const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
|
|
376
|
+
const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
377
|
+
const [peers, setPeers] = useState(EMPTY_PRESENCE);
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (!engine) {
|
|
380
|
+
setPeers(EMPTY_PRESENCE);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const presence = engine.presence;
|
|
384
|
+
const compute = () => groups.length === 0
|
|
385
|
+
? presence.others
|
|
386
|
+
: presence.others.filter((p) => p.syncGroups.some((g) => groups.includes(g)));
|
|
387
|
+
// Plain useState + onChange — presence changes on join/leave/activity
|
|
388
|
+
// only (never on cursor traffic, a separate channel), so this fires
|
|
389
|
+
// rarely; a frame of stale presence is harmless (same rationale as
|
|
390
|
+
// useParticipant's peers bridge).
|
|
391
|
+
setPeers(compute());
|
|
392
|
+
return presence.onChange(() => setPeers(compute()));
|
|
393
|
+
}, [engine, scopeKey]);
|
|
394
|
+
return peers;
|
|
395
|
+
}
|
|
312
396
|
// ── Escape-hatches: raw engine/store access ──────────────────────────
|
|
313
397
|
/**
|
|
314
398
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
package/dist/react/context.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { QueryView, QueryViewOptions } from '../core/QueryView.js';
|
|
|
5
5
|
import type { ViewRegistry } from '../core/ViewRegistry.js';
|
|
6
6
|
import type { Schema } from '../schema/schema.js';
|
|
7
7
|
import type { SyncStatus } from '../BaseSyncedStore.js';
|
|
8
|
+
import type { ParticipantScope } from '../sync/participants.js';
|
|
8
9
|
/**
|
|
9
10
|
* A single LOCAL mutation as observed off the commit stream — the substrate
|
|
10
11
|
* the undo system records from. One is emitted per local create/update/
|
|
@@ -93,6 +94,22 @@ export interface SyncStoreContract {
|
|
|
93
94
|
readonly isReconnecting: boolean;
|
|
94
95
|
readonly isError: boolean;
|
|
95
96
|
readonly hasUnsyncedChanges: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Area-of-interest (dynamic read subscription). `enterScope`/`leaveScope`
|
|
99
|
+
* move the connection's read interest as the user navigates (open/close a
|
|
100
|
+
* deck, sheet, doc); `pinScope`/`unpinScope` express prominence (an active
|
|
101
|
+
* claim keeps a group subscribed). Each resolves the scope through the same
|
|
102
|
+
* resolver the claim path uses, so read interest and write claims agree on
|
|
103
|
+
* the sync-group string. Optional so minimal test doubles can omit them;
|
|
104
|
+
* no-ops before the socket exists. The concrete store (`BaseSyncedStore`)
|
|
105
|
+
* forwards to its `AreaOfInterestManager`.
|
|
106
|
+
*/
|
|
107
|
+
enterScope?(scope: ParticipantScope, opts?: {
|
|
108
|
+
hydrate?: boolean;
|
|
109
|
+
}): Promise<void>;
|
|
110
|
+
leaveScope?(scope: ParticipantScope): Promise<void>;
|
|
111
|
+
pinScope?(scope: ParticipantScope): Promise<void>;
|
|
112
|
+
unpinScope?(scope: ParticipantScope): Promise<void>;
|
|
96
113
|
/**
|
|
97
114
|
* Raw MobX-observable `SyncStatus` record. `useSyncStatus()` reads
|
|
98
115
|
* `state`, `progress`, `pendingChanges`, `isSessionError`, `error`
|
|
@@ -131,13 +148,13 @@ export interface SyncReactContext {
|
|
|
131
148
|
*/
|
|
132
149
|
presence?: unknown;
|
|
133
150
|
/**
|
|
134
|
-
* Optional
|
|
135
|
-
* plug a function that turns an
|
|
151
|
+
* Optional claim initiator. Same pattern as presence — consumers
|
|
152
|
+
* plug a function that turns an claim claim into a handle they
|
|
136
153
|
* control (WebSocket send, optimistic local update, whatever).
|
|
137
|
-
* `
|
|
138
|
-
* from `interface Register {
|
|
154
|
+
* `useClaim(name)` returns a typed invoker for the named claim
|
|
155
|
+
* from `interface Register { Claims: ... }`.
|
|
139
156
|
*/
|
|
140
|
-
|
|
157
|
+
beginClaim?: (claimName: string, claim: unknown) => unknown;
|
|
141
158
|
}
|
|
142
159
|
export declare const SyncContext: import("react").Context<SyncReactContext | null>;
|
|
143
160
|
/**
|
|
@@ -168,10 +185,10 @@ export interface SyncProviderProps {
|
|
|
168
185
|
*/
|
|
169
186
|
presence?: unknown;
|
|
170
187
|
/**
|
|
171
|
-
* Optional
|
|
172
|
-
* {@link SyncReactContext.
|
|
188
|
+
* Optional claim initiator for `useClaim()`. See
|
|
189
|
+
* {@link SyncReactContext.beginClaim}.
|
|
173
190
|
*/
|
|
174
|
-
|
|
191
|
+
beginClaim?: (claimName: string, claim: unknown) => unknown;
|
|
175
192
|
children?: ReactNode;
|
|
176
193
|
}
|
|
177
194
|
/**
|
|
@@ -189,4 +206,4 @@ export interface SyncProviderProps {
|
|
|
189
206
|
* );
|
|
190
207
|
* }
|
|
191
208
|
*/
|
|
192
|
-
export declare function SyncProvider({ store, organizationId, schema, presence,
|
|
209
|
+
export declare function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
|
package/dist/react/context.js
CHANGED
|
@@ -30,6 +30,6 @@ export function useSyncContext() {
|
|
|
30
30
|
* );
|
|
31
31
|
* }
|
|
32
32
|
*/
|
|
33
|
-
export function SyncProvider({ store, organizationId, schema, presence,
|
|
34
|
-
return createElement(SyncContext.Provider, { value: { store, organizationId, schema, presence,
|
|
33
|
+
export function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }) {
|
|
34
|
+
return createElement(SyncContext.Provider, { value: { store, organizationId, schema, presence, beginClaim } }, children);
|
|
35
35
|
}
|