@abloatai/ablo 0.11.2 → 0.12.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 +15 -0
- package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
- package/dist/ai-sdk/claim-broadcast.js +2 -2
- package/dist/ai-sdk/wrap.d.ts +5 -4
- package/dist/ai-sdk/wrap.js +3 -3
- package/dist/cli.cjs +3 -1
- package/dist/client/Ablo.d.ts +25 -3
- package/dist/client/Ablo.js +5 -5
- package/dist/client/ApiClient.js +26 -11
- package/dist/client/createModelProxy.d.ts +15 -7
- package/dist/client/createModelProxy.js +12 -12
- package/dist/coordination/schema.d.ts +1 -1
- package/dist/coordination/schema.js +3 -1
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +6 -1
- package/dist/react/AbloProvider.d.ts +11 -7
- package/dist/react/AbloProvider.js +9 -5
- package/dist/react/context.d.ts +9 -14
- package/dist/react/context.js +10 -15
- package/dist/react/index.d.ts +8 -4
- package/dist/react/index.js +8 -4
- package/dist/react/useMutators.js +3 -2
- package/dist/react/useUndoScope.js +3 -2
- package/dist/schema/model.d.ts +10 -3
- package/dist/sync/createClaimStream.js +5 -4
- package/dist/sync/participants.js +1 -1
- package/dist/types/streams.d.ts +17 -7
- package/docs/migration.md +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Canonicalize the claim API to one vocabulary, plus DX fixes (breaking).
|
|
8
|
+
- BREAKING: claim phase field `action` → `reason` on every claim surface
|
|
9
|
+
(`Claim`, `ClaimHandle`, `ClaimCreateOptions`, `ModelClaim`, ...). The wire
|
|
10
|
+
is unchanged (still `action`, healed on read) — no server redeploy needed.
|
|
11
|
+
- BREAKING: claim contention flag `wait` → `queue` (one word everywhere).
|
|
12
|
+
- BREAKING: React hook `useParticipant` → `useWatch` (aligns with `ablo.<model>.watch`).
|
|
13
|
+
- `ClaimDeclaration.ttlSeconds` is now `number` (was a `Duration`).
|
|
14
|
+
- Docs: `retrieve` HTTP envelope (`.data`/`.stamp`) called out; `syncGroups`
|
|
15
|
+
reworded (provisional, not deprecated); `orgScoped` cross-tenant security
|
|
16
|
+
warning; React error strings point at `<AbloProvider>`.
|
|
17
|
+
|
|
3
18
|
## 0.11.2
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
|
@@ -58,10 +58,11 @@ export interface ClaimBroadcastMiddlewareOptions<R extends SchemaRecord = Schema
|
|
|
58
58
|
/** Target entity. Null skips the broadcast (purely conversational). */
|
|
59
59
|
readonly target: ClaimTarget | null;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
* `'edit'`, `'read'`, `'review'`, `'generate'`. Default `'edit'`.
|
|
61
|
+
* Human-readable phase describing what the agent is doing. Convention:
|
|
62
|
+
* `'edit'`, `'read'`, `'review'`, `'generate'`. Default `'edit'`. The same
|
|
63
|
+
* `reason` field used on every claim surface.
|
|
63
64
|
*/
|
|
64
|
-
readonly
|
|
65
|
+
readonly reason?: string;
|
|
65
66
|
/**
|
|
66
67
|
* Peer-visible explanation of the specific work this model call is about to
|
|
67
68
|
* perform. Surfaces to other agents through `ActiveClaim.description`.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
export function claimBroadcastMiddleware(options) {
|
|
31
31
|
const { agent, target } = options;
|
|
32
|
-
const
|
|
32
|
+
const reason = options.reason ?? 'edit';
|
|
33
33
|
const description = options.description;
|
|
34
34
|
const openClaim = () => {
|
|
35
35
|
if (!agent || !target)
|
|
@@ -42,7 +42,7 @@ export function claimBroadcastMiddleware(options) {
|
|
|
42
42
|
field: target.field,
|
|
43
43
|
meta: target.meta,
|
|
44
44
|
}, {
|
|
45
|
-
reason
|
|
45
|
+
reason,
|
|
46
46
|
description,
|
|
47
47
|
ttl: target.estimatedMs ?? 60_000,
|
|
48
48
|
});
|
package/dist/ai-sdk/wrap.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* model: anthropic('claude-opus-4-7'),
|
|
15
15
|
* agent,
|
|
16
16
|
* target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
|
|
17
|
-
*
|
|
17
|
+
* reason: 'renaming',
|
|
18
18
|
* description: 'Renaming the deck title to match the project brief.',
|
|
19
19
|
* });
|
|
20
20
|
*
|
|
@@ -40,10 +40,11 @@ export interface WrapWithMultiplayerOptions {
|
|
|
40
40
|
/** Target entity. Null = pass-through wrap. */
|
|
41
41
|
readonly target: ClaimTarget | null;
|
|
42
42
|
/**
|
|
43
|
-
* Optional
|
|
44
|
-
* Convention: `'edit'`, `'read'`, `'review'`, `'generate'`.
|
|
43
|
+
* Optional human-readable phase for the broadcast. Default `'edit'`.
|
|
44
|
+
* Convention: `'edit'`, `'read'`, `'review'`, `'generate'`. The same
|
|
45
|
+
* `reason` field used on every claim surface.
|
|
45
46
|
*/
|
|
46
|
-
readonly
|
|
47
|
+
readonly reason?: string;
|
|
47
48
|
/**
|
|
48
49
|
* Peer-visible explanation of the specific work this model call is about to
|
|
49
50
|
* perform. Other agents receive it in their coordination context.
|
package/dist/ai-sdk/wrap.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* model: anthropic('claude-opus-4-7'),
|
|
15
15
|
* agent,
|
|
16
16
|
* target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
|
|
17
|
-
*
|
|
17
|
+
* reason: 'renaming',
|
|
18
18
|
* description: 'Renaming the deck title to match the project brief.',
|
|
19
19
|
* });
|
|
20
20
|
*
|
|
@@ -31,12 +31,12 @@ import { wrapLanguageModel } from 'ai';
|
|
|
31
31
|
import { claimBroadcastMiddleware, } from './claim-broadcast.js';
|
|
32
32
|
import { coordinationContextMiddleware } from './coordination-context.js';
|
|
33
33
|
export function wrapWithMultiplayer(options) {
|
|
34
|
-
const { model, agent, target,
|
|
34
|
+
const { model, agent, target, reason, description, excludeClaimIds, extraMiddleware } = options;
|
|
35
35
|
return wrapLanguageModel({
|
|
36
36
|
model,
|
|
37
37
|
middleware: [
|
|
38
38
|
coordinationContextMiddleware({ agent, target, excludeClaimIds }),
|
|
39
|
-
claimBroadcastMiddleware({ agent, target,
|
|
39
|
+
claimBroadcastMiddleware({ agent, target, reason, description }),
|
|
40
40
|
...(extraMiddleware ?? []),
|
|
41
41
|
],
|
|
42
42
|
});
|
package/dist/cli.cjs
CHANGED
|
@@ -277121,7 +277121,9 @@ var modelClaimSchema = import_zod3.z.object({
|
|
|
277121
277121
|
id: import_zod3.z.string(),
|
|
277122
277122
|
actor: import_zod3.z.string(),
|
|
277123
277123
|
participantKind: wireParticipantKindSchema,
|
|
277124
|
-
|
|
277124
|
+
/** Human-readable phase (`'editing'`). The public SDK field; the WS/HTTP
|
|
277125
|
+
* wire carries the same value as `action` (healed on read). */
|
|
277126
|
+
reason: import_zod3.z.string(),
|
|
277125
277127
|
description: import_zod3.z.string().optional(),
|
|
277126
277128
|
field: import_zod3.z.string().optional(),
|
|
277127
277129
|
status: import_zod3.z.enum(["active", "queued"]).optional(),
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -332,8 +332,14 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
332
332
|
*/
|
|
333
333
|
configOverrides?: Partial<SyncEngineConfig>;
|
|
334
334
|
/**
|
|
335
|
-
*
|
|
336
|
-
*
|
|
335
|
+
* Sync groups (entity scopes) this client subscribes to. **Provisional, not
|
|
336
|
+
* deprecated** — pick the right lane: normally the server derives these from
|
|
337
|
+
* the apiKey's scope, but passing them is still REQUIRED today in any config
|
|
338
|
+
* where the key doesn't resolve them (omitting yields a `degenerate
|
|
339
|
+
* syncGroups` warning and a zero-fan-out client). Keep passing it explicitly
|
|
340
|
+
* until the server-derived path ships in Phase 3, at which point it becomes a
|
|
341
|
+
* true no-op and is removed. Build values with `syncGroup(kind, id)` from
|
|
342
|
+
* `@abloatai/ablo/schema`.
|
|
337
343
|
*/
|
|
338
344
|
syncGroups?: string[];
|
|
339
345
|
/**
|
|
@@ -388,7 +394,9 @@ export interface ModelReadOptions extends ClaimedOptions {
|
|
|
388
394
|
}
|
|
389
395
|
export interface ClaimCreateOptions {
|
|
390
396
|
readonly target: ModelTarget;
|
|
391
|
-
|
|
397
|
+
/** Human-readable phase shown to peers — `'editing'`, `'writing'`. The same
|
|
398
|
+
* word on every claim surface; serialized on the wire as `action`. */
|
|
399
|
+
readonly reason: string;
|
|
392
400
|
readonly ttl?: Duration;
|
|
393
401
|
/**
|
|
394
402
|
* Join the server's fair FIFO queue when the target is already claimed,
|
|
@@ -490,6 +498,20 @@ export interface HttpClaimApi<T> {
|
|
|
490
498
|
reorder(params: ClaimReorderParams<T>): Promise<void>;
|
|
491
499
|
}
|
|
492
500
|
export interface ModelClient<T = Record<string, unknown>> {
|
|
501
|
+
/**
|
|
502
|
+
* Single-row read over HTTP. **Returns an envelope, not the bare row** — the
|
|
503
|
+
* row is on `.data`, alongside the `.stamp` watermark (for stale-context
|
|
504
|
+
* guards on the following write) and any active `.claims`. A stateless HTTP
|
|
505
|
+
* client can't synthesize the watermark from a local snapshot, so the
|
|
506
|
+
* envelope is load-bearing here (the WebSocket client's `retrieve` returns
|
|
507
|
+
* `T | undefined` because it reads from the hydrated pool).
|
|
508
|
+
*
|
|
509
|
+
* ```ts
|
|
510
|
+
* const deal = await ablo.deals.retrieve({ id });
|
|
511
|
+
* deal.data?.recommendation; // ← the row is on .data
|
|
512
|
+
* deal.stamp; // watermark — pass to the next write's readAt
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
493
515
|
retrieve(params: ModelReadOptions & {
|
|
494
516
|
readonly id: string;
|
|
495
517
|
}): Promise<ModelRead<T>>;
|
package/dist/client/Ablo.js
CHANGED
|
@@ -1124,7 +1124,7 @@ export function Ablo(options) {
|
|
|
1124
1124
|
id: claim.id,
|
|
1125
1125
|
actor: claim.heldBy,
|
|
1126
1126
|
participantKind: claim.participantKind,
|
|
1127
|
-
|
|
1127
|
+
reason: claim.reason,
|
|
1128
1128
|
...(description ? { description } : {}),
|
|
1129
1129
|
field: claim.target.field,
|
|
1130
1130
|
status: 'active',
|
|
@@ -1144,7 +1144,7 @@ export function Ablo(options) {
|
|
|
1144
1144
|
id: claim.id,
|
|
1145
1145
|
actor: claim.heldBy,
|
|
1146
1146
|
participantKind: claim.participantKind,
|
|
1147
|
-
|
|
1147
|
+
reason: claim.reason,
|
|
1148
1148
|
...(claim.description ? { description: claim.description } : {}),
|
|
1149
1149
|
field: claim.target.field,
|
|
1150
1150
|
status: 'queued',
|
|
@@ -1246,7 +1246,7 @@ export function Ablo(options) {
|
|
|
1246
1246
|
return {
|
|
1247
1247
|
object: 'claim',
|
|
1248
1248
|
claimId: claim.claimId,
|
|
1249
|
-
|
|
1249
|
+
reason: claim.reason,
|
|
1250
1250
|
target: claim.target,
|
|
1251
1251
|
waited,
|
|
1252
1252
|
release,
|
|
@@ -1265,7 +1265,7 @@ export function Ablo(options) {
|
|
|
1265
1265
|
field: claimOptions.target.field,
|
|
1266
1266
|
meta: claimOptions.target.meta,
|
|
1267
1267
|
}, {
|
|
1268
|
-
reason: claimOptions.
|
|
1268
|
+
reason: claimOptions.reason,
|
|
1269
1269
|
ttl: claimOptions.ttl,
|
|
1270
1270
|
queue: claimOptions.queue,
|
|
1271
1271
|
});
|
|
@@ -1348,7 +1348,7 @@ export function Ablo(options) {
|
|
|
1348
1348
|
...(held.target.field ? { field: held.target.field } : {}),
|
|
1349
1349
|
...(held.target.meta ? { meta: held.target.meta } : {}),
|
|
1350
1350
|
},
|
|
1351
|
-
|
|
1351
|
+
reason: held.reason,
|
|
1352
1352
|
heldBy: held.actor,
|
|
1353
1353
|
participantKind: held.participantKind,
|
|
1354
1354
|
expiresAt: held.expiresAt,
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -11,6 +11,18 @@ import { registerDataSource } from './registerDataSource.js';
|
|
|
11
11
|
import { toSeconds } from '../utils/duration.js';
|
|
12
12
|
import { mintSession } from './sessionMint.js';
|
|
13
13
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
14
|
+
/**
|
|
15
|
+
* The `/v1/claims` and model-query routes still emit the wire field `action`
|
|
16
|
+
* for the claim phase; the public `Claim` / `ModelClaim` expose it as `reason`.
|
|
17
|
+
* Heal on read so the SDK shape is consistent without a coordinated server
|
|
18
|
+
* deploy — `reason ?? action`. When the server adopts `reason`, this is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
function healClaimPhase(claim) {
|
|
21
|
+
const raw = claim;
|
|
22
|
+
if (raw.reason !== undefined)
|
|
23
|
+
return claim;
|
|
24
|
+
return { ...claim, reason: raw.action ?? 'editing' };
|
|
25
|
+
}
|
|
14
26
|
const DEFAULT_AGENT_LEASE = '10m';
|
|
15
27
|
export function createProtocolClient(options) {
|
|
16
28
|
const env = readProcessEnv();
|
|
@@ -173,8 +185,8 @@ export function createProtocolClient(options) {
|
|
|
173
185
|
const suffix = params.toString();
|
|
174
186
|
const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
|
|
175
187
|
return {
|
|
176
|
-
active: body.claims ?? [],
|
|
177
|
-
queue: body.queue ?? [],
|
|
188
|
+
active: (body.claims ?? []).map(healClaimPhase),
|
|
189
|
+
queue: (body.queue ?? []).map(healClaimPhase),
|
|
178
190
|
};
|
|
179
191
|
}
|
|
180
192
|
function delay(ms, signal) {
|
|
@@ -374,7 +386,8 @@ export function createProtocolClient(options) {
|
|
|
374
386
|
body: JSON.stringify({
|
|
375
387
|
claimId,
|
|
376
388
|
target: claimOptions.target,
|
|
377
|
-
action
|
|
389
|
+
// Wire field stays `action`; public option is `reason`.
|
|
390
|
+
action: claimOptions.reason,
|
|
378
391
|
ttl: claimOptions.ttl,
|
|
379
392
|
queue: claimOptions.queue,
|
|
380
393
|
}),
|
|
@@ -400,7 +413,7 @@ export function createProtocolClient(options) {
|
|
|
400
413
|
return {
|
|
401
414
|
object: 'claim',
|
|
402
415
|
claimId: id,
|
|
403
|
-
|
|
416
|
+
reason: claimOptions.reason,
|
|
404
417
|
target: claimOptions.target,
|
|
405
418
|
release,
|
|
406
419
|
revoke: () => {
|
|
@@ -451,7 +464,7 @@ export function createProtocolClient(options) {
|
|
|
451
464
|
return {
|
|
452
465
|
data,
|
|
453
466
|
stamp: query.stamp ?? 0,
|
|
454
|
-
claims: query.claims ?? [],
|
|
467
|
+
claims: (query.claims ?? []).map(healClaimPhase),
|
|
455
468
|
};
|
|
456
469
|
}
|
|
457
470
|
/**
|
|
@@ -533,13 +546,14 @@ export function createProtocolClient(options) {
|
|
|
533
546
|
const body = await requestJson(claimPath(params.id), {
|
|
534
547
|
method: 'POST',
|
|
535
548
|
body: JSON.stringify({
|
|
536
|
-
|
|
549
|
+
// Wire field stays `action`; public option is `reason`.
|
|
550
|
+
action: params.reason ?? 'editing',
|
|
537
551
|
...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
|
|
538
552
|
...(params.description !== undefined ? { description: params.description } : {}),
|
|
539
553
|
...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
|
|
540
|
-
// `
|
|
554
|
+
// `queue` (default true) → queue behind the holder; false → fail-fast
|
|
541
555
|
// with AbloClaimedError (work-distribution dedup).
|
|
542
|
-
queue: params.
|
|
556
|
+
queue: params.queue ?? true,
|
|
543
557
|
}),
|
|
544
558
|
});
|
|
545
559
|
if (body.status === 'queued') {
|
|
@@ -565,7 +579,7 @@ export function createProtocolClient(options) {
|
|
|
565
579
|
...(params.range ? { range: params.range } : {}),
|
|
566
580
|
...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
|
|
567
581
|
},
|
|
568
|
-
|
|
582
|
+
reason: params.reason ?? 'editing',
|
|
569
583
|
...(params.description ? { description: params.description } : {}),
|
|
570
584
|
data,
|
|
571
585
|
release,
|
|
@@ -580,11 +594,12 @@ export function createProtocolClient(options) {
|
|
|
580
594
|
release: releaseClaim,
|
|
581
595
|
state: async (params) => {
|
|
582
596
|
const res = await claimsForEntity(params);
|
|
583
|
-
|
|
597
|
+
const first = res.claims?.[0];
|
|
598
|
+
return first ? healClaimPhase(first) : null;
|
|
584
599
|
},
|
|
585
600
|
queue: async (params) => {
|
|
586
601
|
const res = await claimsForEntity(params);
|
|
587
|
-
return { object: 'list', data: res.queue ?? [] };
|
|
602
|
+
return { object: 'list', data: (res.queue ?? []).map(healClaimPhase) };
|
|
588
603
|
},
|
|
589
604
|
reorder: async (params) => {
|
|
590
605
|
await requestJson(`${claimPath(params.id)}/reorder`, {
|
|
@@ -90,7 +90,8 @@ export interface ModelCollaboration<T> {
|
|
|
90
90
|
range?: TargetRange;
|
|
91
91
|
meta?: Record<string, unknown>;
|
|
92
92
|
};
|
|
93
|
-
action
|
|
93
|
+
/** Human-readable phase (`'editing'`); wire field is `action`. */
|
|
94
|
+
reason: string;
|
|
94
95
|
ttl?: Duration;
|
|
95
96
|
/**
|
|
96
97
|
* Block on the server's fair FIFO queue when the target is held, rather
|
|
@@ -178,8 +179,9 @@ export interface ModelCollaboration<T> {
|
|
|
178
179
|
createWatch?(modelKey: string, ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
|
|
179
180
|
}
|
|
180
181
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
181
|
-
/**
|
|
182
|
-
|
|
182
|
+
/** Human-readable phase shown to observers while held. Defaults to
|
|
183
|
+
* `'editing'`. The same word on every claim surface; wire field is `action`. */
|
|
184
|
+
reason?: string;
|
|
183
185
|
/** Peer-visible explanation of the work being performed. */
|
|
184
186
|
description?: string;
|
|
185
187
|
/** Field-level target, for fine-grained claimed-state badges. */
|
|
@@ -199,8 +201,14 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
199
201
|
* `AbloClaimedError` instead of waiting (claim-or-skip). Use `false` for
|
|
200
202
|
* work-distribution dedup ("if someone else has this job, skip it") where
|
|
201
203
|
* waiting would mean double-processing.
|
|
204
|
+
*
|
|
205
|
+
* Named `queue` to match every other claim surface (low-level
|
|
206
|
+
* `claims.claim`, HTTP `claim.create`, and the wire). The high-level typed
|
|
207
|
+
* claim defaults it ON because it serializes writers; the low-level lease
|
|
208
|
+
* and HTTP default it OFF — they return/resolve immediately and can't
|
|
209
|
+
* transparently wait for a grant.
|
|
202
210
|
*/
|
|
203
|
-
|
|
211
|
+
queue?: boolean;
|
|
204
212
|
/**
|
|
205
213
|
* Backpressure: willing to queue, but not behind too many. If the server
|
|
206
214
|
* reports `position >= maxQueueDepth` when we join the line, reject with
|
|
@@ -226,7 +234,7 @@ export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLo
|
|
|
226
234
|
* ```ts
|
|
227
235
|
* const claim = await ablo.weatherReports.claim({
|
|
228
236
|
* id: 'report_stockholm',
|
|
229
|
-
*
|
|
237
|
+
* reason: 'forecasting',
|
|
230
238
|
* description: 'Fetching current weather before writing the forecast.',
|
|
231
239
|
* });
|
|
232
240
|
* try {
|
|
@@ -258,7 +266,7 @@ export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
|
258
266
|
* data: { title },
|
|
259
267
|
* claim: {
|
|
260
268
|
* field: 'title',
|
|
261
|
-
*
|
|
269
|
+
* reason: 'renaming',
|
|
262
270
|
* description: 'Renaming the task to match the project brief.',
|
|
263
271
|
* },
|
|
264
272
|
* });
|
|
@@ -379,7 +387,7 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
379
387
|
* ```ts
|
|
380
388
|
* const claim = await ablo.weatherReports.claim({
|
|
381
389
|
* id: 'report_stockholm',
|
|
382
|
-
*
|
|
390
|
+
* reason: 'forecasting',
|
|
383
391
|
* description: 'Fetching fresh weather before updating the report.',
|
|
384
392
|
* });
|
|
385
393
|
* const weather = await getWeather(claim.data.location);
|
|
@@ -77,7 +77,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
77
77
|
// `release({ id })` and `update({ id, data })` find the lease + snapshot a `claim({ id })`
|
|
78
78
|
// took — no per-call handle. Released on dispose, explicit release, or TTL.
|
|
79
79
|
//
|
|
80
|
-
// `target` / `
|
|
80
|
+
// `target` / `reason` / `expiresAt` are kept alongside the lease so
|
|
81
81
|
// `claim.state` can synthesize a self-claim: the server excludes a holder's
|
|
82
82
|
// own presence frames, so the local proxy is the ONLY place that knows "I
|
|
83
83
|
// hold this." `expiresAt` is the client's best estimate from the requested
|
|
@@ -103,7 +103,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
103
103
|
id: claim.id,
|
|
104
104
|
actor: claim.heldBy,
|
|
105
105
|
participantKind: claim.participantKind,
|
|
106
|
-
|
|
106
|
+
reason: claim.reason,
|
|
107
107
|
...(description ? { description } : {}),
|
|
108
108
|
field: claim.target.field,
|
|
109
109
|
status: claim.status,
|
|
@@ -143,8 +143,8 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
143
143
|
// claim (a free / already-mine target can't have changed under us).
|
|
144
144
|
const held = collaboration.observe({ model: wireModel, id });
|
|
145
145
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
146
|
-
const failFast = options?.
|
|
147
|
-
// Fail-fast (`
|
|
146
|
+
const failFast = options?.queue === false;
|
|
147
|
+
// Fail-fast (`queue: false`): if another participant already holds it,
|
|
148
148
|
// reject now instead of queuing. Best-effort at the client (a racing
|
|
149
149
|
// claim not yet synced into our snapshot slips through here) — the
|
|
150
150
|
// commit-time claim guard is the authoritative backstop that rejects
|
|
@@ -176,7 +176,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
176
176
|
// in the group when `createClaim` lands. Awaited because the broadcast
|
|
177
177
|
// ordering depends on it; still soft (the store swallows reconcile errors).
|
|
178
178
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
179
|
-
// Acquire the lease. Default (`
|
|
179
|
+
// Acquire the lease. Default (`queue` !== false) goes through the server's
|
|
180
180
|
// fair FIFO queue — `queue: true` resolves only once the lease is genuinely
|
|
181
181
|
// ours, blocking behind any current holder, with no TOCTOU gap (the server
|
|
182
182
|
// orders contenders). Fail-fast skips the queue: we already rejected an
|
|
@@ -190,7 +190,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
190
190
|
...(options?.range ? { range: options.range } : {}),
|
|
191
191
|
...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
|
|
192
192
|
},
|
|
193
|
-
|
|
193
|
+
reason: options?.reason ?? 'editing',
|
|
194
194
|
ttl: options?.ttl,
|
|
195
195
|
queue: !failFast,
|
|
196
196
|
maxQueueDepth: options?.maxQueueDepth,
|
|
@@ -213,7 +213,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
213
213
|
model = objectPool.get(id) ?? model;
|
|
214
214
|
}
|
|
215
215
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
216
|
-
const
|
|
216
|
+
const reason = options?.reason ?? 'editing';
|
|
217
217
|
// The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
|
|
218
218
|
// report (`observe` 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.
|
|
@@ -231,7 +231,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
231
231
|
lease,
|
|
232
232
|
snapshot,
|
|
233
233
|
target: selfTarget,
|
|
234
|
-
|
|
234
|
+
reason,
|
|
235
235
|
expiresAt,
|
|
236
236
|
});
|
|
237
237
|
const target = {
|
|
@@ -248,7 +248,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
248
248
|
claimId: lease.claimId,
|
|
249
249
|
readAt: snapshot.stamp,
|
|
250
250
|
target,
|
|
251
|
-
|
|
251
|
+
reason,
|
|
252
252
|
...(options?.description ? { description: options.description } : {}),
|
|
253
253
|
data: modelAsRow(model),
|
|
254
254
|
release,
|
|
@@ -281,7 +281,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
281
281
|
id: own.lease.claimId,
|
|
282
282
|
status: 'active',
|
|
283
283
|
target: own.target,
|
|
284
|
-
|
|
284
|
+
reason: own.reason,
|
|
285
285
|
heldBy: collaboration?.selfParticipantId ?? '',
|
|
286
286
|
participantKind: collaboration?.selfParticipantKind ?? 'user',
|
|
287
287
|
expiresAt: own.expiresAt,
|
|
@@ -381,9 +381,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
381
381
|
...(claim.range ? { range: claim.range } : {}),
|
|
382
382
|
...(claimMeta(claim) ? { meta: claimMeta(claim) } : {}),
|
|
383
383
|
},
|
|
384
|
-
|
|
384
|
+
reason: claim.reason ?? 'creating',
|
|
385
385
|
ttl: claim.ttl,
|
|
386
|
-
queue: claim.
|
|
386
|
+
queue: claim.queue !== false,
|
|
387
387
|
maxQueueDepth: claim.maxQueueDepth,
|
|
388
388
|
});
|
|
389
389
|
}
|
|
@@ -281,7 +281,7 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
|
|
|
281
281
|
agent: "agent";
|
|
282
282
|
system: "system";
|
|
283
283
|
}>>;
|
|
284
|
-
|
|
284
|
+
reason: z.ZodString;
|
|
285
285
|
description: z.ZodOptional<z.ZodString>;
|
|
286
286
|
field: z.ZodOptional<z.ZodString>;
|
|
287
287
|
status: z.ZodOptional<z.ZodEnum<{
|
|
@@ -203,7 +203,9 @@ export const modelClaimSchema = z
|
|
|
203
203
|
id: z.string(),
|
|
204
204
|
actor: z.string(),
|
|
205
205
|
participantKind: wireParticipantKindSchema,
|
|
206
|
-
|
|
206
|
+
/** Human-readable phase (`'editing'`). The public SDK field; the WS/HTTP
|
|
207
|
+
* wire carries the same value as `action` (healed on read). */
|
|
208
|
+
reason: z.string(),
|
|
207
209
|
description: z.string().optional(),
|
|
208
210
|
field: z.string().optional(),
|
|
209
211
|
status: z.enum(['active', 'queued']).optional(),
|
package/dist/errors.d.ts
CHANGED
|
@@ -161,7 +161,9 @@ export interface ClaimContext {
|
|
|
161
161
|
readonly claimId?: string;
|
|
162
162
|
readonly actor?: string;
|
|
163
163
|
readonly participantKind?: ParticipantKind;
|
|
164
|
-
|
|
164
|
+
/** Human-readable phase the holder is in (`'editing'`). Matches the public
|
|
165
|
+
* claim surface; the wire summary carries the same value as `action`. */
|
|
166
|
+
readonly reason?: string;
|
|
165
167
|
readonly description?: string;
|
|
166
168
|
readonly field?: string;
|
|
167
169
|
readonly status?: string;
|
package/dist/errors.js
CHANGED
|
@@ -162,7 +162,12 @@ export class AbloStaleContextError extends AbloError {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
function claimAction(claim) {
|
|
165
|
-
|
|
165
|
+
if (!claim)
|
|
166
|
+
return undefined;
|
|
167
|
+
// The public `ClaimContext` exposes the phase as `reason`; the wire
|
|
168
|
+
// `WireClaimSummary` projection still carries it under `action`. Read both.
|
|
169
|
+
const c = claim;
|
|
170
|
+
return c.reason ?? c.action;
|
|
166
171
|
}
|
|
167
172
|
function claimDescription(claim) {
|
|
168
173
|
if (!claim)
|
|
@@ -15,7 +15,7 @@ import { type SyncStoreContract } from './context.js';
|
|
|
15
15
|
* - **One component, one import.** Consumers write the provider
|
|
16
16
|
* once at the root; nothing else needs to plumb the engine.
|
|
17
17
|
* - **Multiplayer is default.** React consumers are always browsers doing
|
|
18
|
-
* multiplayer UI, so `
|
|
18
|
+
* multiplayer UI, so `useWatch()` / `useAblo()` are always
|
|
19
19
|
* available. No opt-in prop.
|
|
20
20
|
* - **Declarative props for app glue.** `preventUnsavedChanges`,
|
|
21
21
|
* `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
|
|
@@ -114,11 +114,11 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
114
114
|
export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
|
|
115
115
|
export type { EngineParticipant, ParticipantScope, ParticipantStatus };
|
|
116
116
|
/**
|
|
117
|
-
* Options for `
|
|
117
|
+
* Options for `useWatch`. The hook reuses the engine's single
|
|
118
118
|
* WebSocket and opens a scoped claim on it when `scope` is provided:
|
|
119
119
|
* one TCP connection, N logical sub-syncgroup participants.
|
|
120
120
|
*/
|
|
121
|
-
export interface
|
|
121
|
+
export interface UseWatchOptions {
|
|
122
122
|
readonly scope?: ParticipantScope;
|
|
123
123
|
readonly ttlSeconds?: number | string | null;
|
|
124
124
|
/** Tear down + don't re-join while true. */
|
|
@@ -149,7 +149,7 @@ export interface UseParticipantOptions {
|
|
|
149
149
|
}
|
|
150
150
|
/** @deprecated Use `ParticipantStatus`. */
|
|
151
151
|
export type MeshParticipantStatus = ParticipantStatus;
|
|
152
|
-
export interface
|
|
152
|
+
export interface UseWatchReturn {
|
|
153
153
|
readonly participant: EngineParticipant | null;
|
|
154
154
|
/** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
|
|
155
155
|
readonly peers: ReadonlyArray<Peer>;
|
|
@@ -163,15 +163,19 @@ export interface UseParticipantReturn {
|
|
|
163
163
|
* lifecycle status. Auto-cleans up on unmount or when `paused`
|
|
164
164
|
* flips to true.
|
|
165
165
|
*
|
|
166
|
+
* `useWatch` is the React form of `ablo.<model>.watch` — scope-level
|
|
167
|
+
* read-interest + presence; returns the reactive participant facade
|
|
168
|
+
* (peers/claims/status).
|
|
169
|
+
*
|
|
166
170
|
* The returned `participant` is an `EngineParticipant` — `.presence`
|
|
167
171
|
* + `.claims` only — backed by the engine's existing socket. For
|
|
168
172
|
* headless-bot patterns (a separate identity in the same browser
|
|
169
173
|
* tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
|
|
170
174
|
*/
|
|
171
|
-
export declare function
|
|
175
|
+
export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
|
|
172
176
|
/**
|
|
173
177
|
* Read-only presence: the OTHER participants currently visible to this
|
|
174
|
-
* connection, bridged to React. Unlike {@link
|
|
178
|
+
* connection, bridged to React. Unlike {@link useWatch}, this does
|
|
175
179
|
* NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
|
|
176
180
|
* it is a pure reader of the engine's already-flowing presence stream.
|
|
177
181
|
*
|
|
@@ -184,7 +188,7 @@ export declare function useParticipant(opts: UseParticipantOptions): UseParticip
|
|
|
184
188
|
* Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
|
|
185
189
|
* broadcasts while alone — when some OTHER mount already owns the scope's
|
|
186
190
|
* read interest (scope `leave` is not reference-counted, so a second
|
|
187
|
-
* `
|
|
191
|
+
* `useWatch` on the same scope would warm-drop the owner's
|
|
188
192
|
* subscription on unmount).
|
|
189
193
|
*
|
|
190
194
|
* ```ts
|
|
@@ -204,12 +204,16 @@ const EMPTY_INTENTS = Object.freeze([]);
|
|
|
204
204
|
* lifecycle status. Auto-cleans up on unmount or when `paused`
|
|
205
205
|
* flips to true.
|
|
206
206
|
*
|
|
207
|
+
* `useWatch` is the React form of `ablo.<model>.watch` — scope-level
|
|
208
|
+
* read-interest + presence; returns the reactive participant facade
|
|
209
|
+
* (peers/claims/status).
|
|
210
|
+
*
|
|
207
211
|
* The returned `participant` is an `EngineParticipant` — `.presence`
|
|
208
212
|
* + `.claims` only — backed by the engine's existing socket. For
|
|
209
213
|
* headless-bot patterns (a separate identity in the same browser
|
|
210
214
|
* tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
|
|
211
215
|
*/
|
|
212
|
-
export function
|
|
216
|
+
export function useWatch(opts) {
|
|
213
217
|
const ctx = useContext(AbloInternalContext);
|
|
214
218
|
const engine = ctx?.engine ?? null;
|
|
215
219
|
const { paused = false } = opts;
|
|
@@ -342,7 +346,7 @@ export function useParticipant(opts) {
|
|
|
342
346
|
}
|
|
343
347
|
/**
|
|
344
348
|
* Read-only presence: the OTHER participants currently visible to this
|
|
345
|
-
* connection, bridged to React. Unlike {@link
|
|
349
|
+
* connection, bridged to React. Unlike {@link useWatch}, this does
|
|
346
350
|
* NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
|
|
347
351
|
* it is a pure reader of the engine's already-flowing presence stream.
|
|
348
352
|
*
|
|
@@ -355,7 +359,7 @@ export function useParticipant(opts) {
|
|
|
355
359
|
* Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
|
|
356
360
|
* broadcasts while alone — when some OTHER mount already owns the scope's
|
|
357
361
|
* read interest (scope `leave` is not reference-counted, so a second
|
|
358
|
-
* `
|
|
362
|
+
* `useWatch` on the same scope would warm-drop the owner's
|
|
359
363
|
* subscription on unmount).
|
|
360
364
|
*
|
|
361
365
|
* ```ts
|
|
@@ -366,7 +370,7 @@ export function useParticipant(opts) {
|
|
|
366
370
|
export function usePeers(scope) {
|
|
367
371
|
const ctx = useContext(AbloInternalContext);
|
|
368
372
|
const engine = ctx?.engine ?? null;
|
|
369
|
-
// Resolve scope → groups through the schema (same idiom as
|
|
373
|
+
// Resolve scope → groups through the schema (same idiom as useWatch).
|
|
370
374
|
// The stringified, sorted key is the stable effect dependency.
|
|
371
375
|
const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
|
|
372
376
|
const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
@@ -383,7 +387,7 @@ export function usePeers(scope) {
|
|
|
383
387
|
// Plain useState + onChange — presence changes on join/leave/activity
|
|
384
388
|
// only (never on cursor traffic, a separate channel), so this fires
|
|
385
389
|
// rarely; a frame of stale presence is harmless (same rationale as
|
|
386
|
-
//
|
|
390
|
+
// useWatch's peers bridge).
|
|
387
391
|
setPeers(compute());
|
|
388
392
|
return presence.onChange(() => setPeers(compute()));
|
|
389
393
|
}, [engine, scopeKey]);
|
package/dist/react/context.d.ts
CHANGED
|
@@ -140,8 +140,9 @@ export interface SyncReactContext {
|
|
|
140
140
|
}
|
|
141
141
|
export declare const SyncContext: import("react").Context<SyncReactContext | null>;
|
|
142
142
|
/**
|
|
143
|
-
* Access the sync store from React components.
|
|
144
|
-
*
|
|
143
|
+
* Access the sync store from React components. The context is provided by
|
|
144
|
+
* `<AbloProvider>` (which renders the internal {@link SyncProvider}); public
|
|
145
|
+
* consumers wire `<AbloProvider client={ablo}>`, never this directly.
|
|
145
146
|
*/
|
|
146
147
|
export declare function useSyncContext(): SyncReactContext;
|
|
147
148
|
/**
|
|
@@ -162,18 +163,12 @@ export interface SyncProviderProps {
|
|
|
162
163
|
children?: ReactNode;
|
|
163
164
|
}
|
|
164
165
|
/**
|
|
165
|
-
* SyncProvider
|
|
166
|
-
* (useModel, useModels, useMutations) can
|
|
166
|
+
* SyncProvider — the INTERNAL low-level provider that wires a built sync store
|
|
167
|
+
* into React so SDK hooks (useModel, useModels, useMutations) can reach it.
|
|
167
168
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* return (
|
|
173
|
-
* <SyncProvider store={syncStore} organizationId={orgId}>
|
|
174
|
-
* <YourApp />
|
|
175
|
-
* </SyncProvider>
|
|
176
|
-
* );
|
|
177
|
-
* }
|
|
169
|
+
* Public consumers do NOT use this directly (it is not exported from
|
|
170
|
+
* `@abloatai/ablo/react`). `<AbloProvider client={ablo}>` constructs the
|
|
171
|
+
* store from your `Ablo({ schema, apiKey })` client and renders this provider
|
|
172
|
+
* underneath — reach for `<AbloProvider>`.
|
|
178
173
|
*/
|
|
179
174
|
export declare function SyncProvider({ store, organizationId, schema, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
|
package/dist/react/context.js
CHANGED
|
@@ -3,32 +3,27 @@ import { createContext, createElement, useContext } from 'react';
|
|
|
3
3
|
import { AbloValidationError } from '../errors.js';
|
|
4
4
|
export const SyncContext = createContext(null);
|
|
5
5
|
/**
|
|
6
|
-
* Access the sync store from React components.
|
|
7
|
-
*
|
|
6
|
+
* Access the sync store from React components. The context is provided by
|
|
7
|
+
* `<AbloProvider>` (which renders the internal {@link SyncProvider}); public
|
|
8
|
+
* consumers wire `<AbloProvider client={ablo}>`, never this directly.
|
|
8
9
|
*/
|
|
9
10
|
export function useSyncContext() {
|
|
10
11
|
const ctx = useContext(SyncContext);
|
|
11
12
|
if (!ctx) {
|
|
12
|
-
throw new AbloValidationError('
|
|
13
|
+
throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
|
|
13
14
|
code: 'sync_context_missing_provider',
|
|
14
15
|
});
|
|
15
16
|
}
|
|
16
17
|
return ctx;
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
|
-
* SyncProvider
|
|
20
|
-
* (useModel, useModels, useMutations) can
|
|
20
|
+
* SyncProvider — the INTERNAL low-level provider that wires a built sync store
|
|
21
|
+
* into React so SDK hooks (useModel, useModels, useMutations) can reach it.
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* return (
|
|
27
|
-
* <SyncProvider store={syncStore} organizationId={orgId}>
|
|
28
|
-
* <YourApp />
|
|
29
|
-
* </SyncProvider>
|
|
30
|
-
* );
|
|
31
|
-
* }
|
|
23
|
+
* Public consumers do NOT use this directly (it is not exported from
|
|
24
|
+
* `@abloatai/ablo/react`). `<AbloProvider client={ablo}>` constructs the
|
|
25
|
+
* store from your `Ablo({ schema, apiKey })` client and renders this provider
|
|
26
|
+
* underneath — reach for `<AbloProvider>`.
|
|
32
27
|
*/
|
|
33
28
|
export function SyncProvider({ store, organizationId, schema, children, }) {
|
|
34
29
|
return createElement(SyncContext.Provider, { value: { store, organizationId, schema } }, children);
|
package/dist/react/index.d.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* @abloatai/ablo/react — React bindings (v0.3.0)
|
|
3
3
|
*
|
|
4
4
|
* Umbrella provider:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* const ablo = Ablo({ schema, apiKey }) // build once — module scope or useMemo
|
|
6
|
+
* <AbloProvider client={ablo} fallback={<Skeleton/>}>
|
|
7
|
+
* — `client` is the only required prop (construct it yourself; the provider
|
|
8
|
+
* is the thin reactive binding, like `<Elements stripe={...}>`). `userId`
|
|
9
|
+
* is optional + informational. Owns sync engine + multiplayer lifecycle;
|
|
10
|
+
* the `fallback` prop
|
|
7
11
|
* gates children on first bootstrap. Pass `fallback="passthrough"`
|
|
8
12
|
* to disable the gate.
|
|
9
13
|
* <ClientSideSuspense fallback={<Skeleton/>}> — NESTED gate inside an
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
*
|
|
28
32
|
* Multiplayer (always available — `<AbloProvider>` always constructs a client):
|
|
29
33
|
* useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
|
|
30
|
-
*
|
|
34
|
+
* useWatch({ scope }) — join multiplayer for a scope, get peers/claims
|
|
31
35
|
*
|
|
32
36
|
* ── Breaking changes from v0.2.x ───────────────────────────────────
|
|
33
37
|
* Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
* migration notes in CHANGELOG.md.
|
|
42
46
|
*/
|
|
43
47
|
export type { DefaultSyncShape, ResolveSchema, ResolvePresence, ResolveClaims, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
|
|
44
|
-
export { AbloProvider,
|
|
48
|
+
export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseWatchOptions, type UseWatchReturn, type MeshParticipantStatus, } from './AbloProvider.js';
|
|
45
49
|
export { ClientSideSuspense, type ClientSideSuspenseProps, } from './ClientSideSuspense.js';
|
|
46
50
|
export { DefaultFallback } from './DefaultFallback.js';
|
|
47
51
|
export type { SyncStoreContract } from './context.js';
|
package/dist/react/index.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* @abloatai/ablo/react — React bindings (v0.3.0)
|
|
3
3
|
*
|
|
4
4
|
* Umbrella provider:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* const ablo = Ablo({ schema, apiKey }) // build once — module scope or useMemo
|
|
6
|
+
* <AbloProvider client={ablo} fallback={<Skeleton/>}>
|
|
7
|
+
* — `client` is the only required prop (construct it yourself; the provider
|
|
8
|
+
* is the thin reactive binding, like `<Elements stripe={...}>`). `userId`
|
|
9
|
+
* is optional + informational. Owns sync engine + multiplayer lifecycle;
|
|
10
|
+
* the `fallback` prop
|
|
7
11
|
* gates children on first bootstrap. Pass `fallback="passthrough"`
|
|
8
12
|
* to disable the gate.
|
|
9
13
|
* <ClientSideSuspense fallback={<Skeleton/>}> — NESTED gate inside an
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
*
|
|
28
32
|
* Multiplayer (always available — `<AbloProvider>` always constructs a client):
|
|
29
33
|
* useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
|
|
30
|
-
*
|
|
34
|
+
* useWatch({ scope }) — join multiplayer for a scope, get peers/claims
|
|
31
35
|
*
|
|
32
36
|
* ── Breaking changes from v0.2.x ───────────────────────────────────
|
|
33
37
|
* Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
* migration notes in CHANGELOG.md.
|
|
42
46
|
*/
|
|
43
47
|
// ── Umbrella provider + lifecycle hooks ────────────────────────────
|
|
44
|
-
export { AbloProvider,
|
|
48
|
+
export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
|
|
45
49
|
export { ClientSideSuspense, } from './ClientSideSuspense.js';
|
|
46
50
|
export { DefaultFallback } from './DefaultFallback.js';
|
|
47
51
|
// ── Status + errors + identity ─────────────────────────────────────
|
|
@@ -17,8 +17,9 @@ export function useMutators(schemaOrMutators, mutatorsOrOptions, maybeOptions) {
|
|
|
17
17
|
const mutators = (isExplicit ? mutatorsOrOptions : schemaOrMutators);
|
|
18
18
|
const options = (isExplicit ? maybeOptions : mutatorsOrOptions);
|
|
19
19
|
if (!schema) {
|
|
20
|
-
throw new AbloValidationError('useMutators: no schema available. Pass the schema as the first arg ' +
|
|
21
|
-
'or
|
|
20
|
+
throw new AbloValidationError('useMutators: no schema available. Pass the schema as the first arg, ' +
|
|
21
|
+
'or build the <AbloProvider> above with `Ablo({ schema })` so the ' +
|
|
22
|
+
'zero-arg overload can read it from context.', { code: 'mutators_schema_missing' });
|
|
22
23
|
}
|
|
23
24
|
const { undoScope } = options ?? {};
|
|
24
25
|
return useMemo(() => {
|
|
@@ -34,8 +34,9 @@ export function useUndoScope(schemaOrName, nameOrOptions, maybeOptions) {
|
|
|
34
34
|
const name = isExplicit ? nameOrOptions : schemaOrName;
|
|
35
35
|
const options = (isExplicit ? maybeOptions : nameOrOptions);
|
|
36
36
|
if (!schema) {
|
|
37
|
-
throw new AbloValidationError('useUndoScope: no schema available. Pass the schema as the first arg ' +
|
|
38
|
-
'or
|
|
37
|
+
throw new AbloValidationError('useUndoScope: no schema available. Pass the schema as the first arg, ' +
|
|
38
|
+
'or build the <AbloProvider> above with `Ablo({ schema })` so the ' +
|
|
39
|
+
'zero-arg overload can read it from context.', { code: 'undo_scope_schema_missing' });
|
|
39
40
|
}
|
|
40
41
|
const scope = useMemo(() => {
|
|
41
42
|
// Store is the identity for the manager — one per SyncProvider.
|
package/dist/schema/model.d.ts
CHANGED
|
@@ -95,9 +95,16 @@ export interface ModelOptions {
|
|
|
95
95
|
*/
|
|
96
96
|
tableName?: string;
|
|
97
97
|
/**
|
|
98
|
-
* Whether this model's table has an organization_id column.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* Whether this model's table has an `organization_id` column. Default: true.
|
|
99
|
+
* When false, the bootstrap/read query omits the `WHERE organization_id = $1`
|
|
100
|
+
* tenant filter for this model.
|
|
101
|
+
*
|
|
102
|
+
* ⚠ SECURITY — `orgScoped: false` makes the table GLOBALLY READABLE: every
|
|
103
|
+
* client of every tenant sees every row. It is ONLY correct for genuinely
|
|
104
|
+
* tenant-less tables (the `organizations` table itself, global lookups). If
|
|
105
|
+
* rows belong to a tenant through a foreign key but this table has no
|
|
106
|
+
* `organization_id` of its own, use {@link scopedVia} INSTEAD — reaching for
|
|
107
|
+
* `orgScoped: false` there silently exposes the entire table cross-tenant.
|
|
101
108
|
*/
|
|
102
109
|
orgScoped?: boolean;
|
|
103
110
|
/**
|
|
@@ -192,7 +192,8 @@ export function createClaimStream(config, transport = null) {
|
|
|
192
192
|
entityId: claim.entityId,
|
|
193
193
|
path: claim.path,
|
|
194
194
|
range: claim.range,
|
|
195
|
-
action
|
|
195
|
+
// Wire field stays `action` (coordination schema); source is `reason`.
|
|
196
|
+
action: claim.reason,
|
|
196
197
|
field: claim.field,
|
|
197
198
|
meta: claim.meta,
|
|
198
199
|
estimatedMs: claim.estimatedMs,
|
|
@@ -244,7 +245,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
244
245
|
range: args.range,
|
|
245
246
|
field: args.field,
|
|
246
247
|
meta: args.meta,
|
|
247
|
-
|
|
248
|
+
reason: args.reason,
|
|
248
249
|
estimatedMs,
|
|
249
250
|
queue: args.queue,
|
|
250
251
|
};
|
|
@@ -261,7 +262,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
261
262
|
return {
|
|
262
263
|
object: 'claim',
|
|
263
264
|
claimId,
|
|
264
|
-
|
|
265
|
+
reason: args.reason,
|
|
265
266
|
target: {
|
|
266
267
|
model: args.entityType,
|
|
267
268
|
id: args.entityId,
|
|
@@ -294,7 +295,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
294
295
|
range: resolved.range,
|
|
295
296
|
field: resolved.field,
|
|
296
297
|
meta: withDescription(resolved.meta, opts?.description),
|
|
297
|
-
|
|
298
|
+
reason: opts?.reason ?? 'editing',
|
|
298
299
|
ttl: opts?.ttl,
|
|
299
300
|
queue: opts?.queue,
|
|
300
301
|
});
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -360,7 +360,7 @@ export interface ClaimStream {
|
|
|
360
360
|
/**
|
|
361
361
|
* Reactive view of the wait queue on one target — the FIFO line of
|
|
362
362
|
* `status: 'queued'` claims behind the current holder, each with its
|
|
363
|
-
* `
|
|
363
|
+
* `reason`, `heldBy`, and `position`. Synced from the server's per-entity
|
|
364
364
|
* `claim_queue` frame; empty when no one's waiting. Pair with
|
|
365
365
|
* `subscribe(...)` for change notifications.
|
|
366
366
|
*/
|
|
@@ -457,10 +457,12 @@ export interface ClaimDeclaration {
|
|
|
457
457
|
/** Human-readable reason — "rewriting title" / "restyling chart". */
|
|
458
458
|
readonly reason: string;
|
|
459
459
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
460
|
+
* Seconds remaining until the server auto-expires this claim. An OUTPUT
|
|
461
|
+
* field carrying a concrete countdown, so it's a plain `number` — distinct
|
|
462
|
+
* from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
|
|
463
|
+
* from `expiresAt - now`.
|
|
462
464
|
*/
|
|
463
|
-
readonly ttlSeconds?:
|
|
465
|
+
readonly ttlSeconds?: number;
|
|
464
466
|
}
|
|
465
467
|
/**
|
|
466
468
|
* Handle returned from `announce(...)` / `analyzing(...)` / etc.
|
|
@@ -507,7 +509,13 @@ export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposabl
|
|
|
507
509
|
readonly range?: TargetRange;
|
|
508
510
|
readonly meta?: Record<string, unknown>;
|
|
509
511
|
};
|
|
510
|
-
|
|
512
|
+
/**
|
|
513
|
+
* The human-readable phase this claim represents — `'editing'`, `'writing'`,
|
|
514
|
+
* `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
|
|
515
|
+
* distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
|
|
516
|
+
* to `'editing'`. Serialized on the wire as `action`.
|
|
517
|
+
*/
|
|
518
|
+
readonly reason: string;
|
|
511
519
|
readonly description?: string;
|
|
512
520
|
/** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
|
|
513
521
|
readonly data?: T;
|
|
@@ -565,8 +573,10 @@ export interface Claim {
|
|
|
565
573
|
readonly status: ClaimStatus;
|
|
566
574
|
/** What is being coordinated. */
|
|
567
575
|
readonly target: EntityRef;
|
|
568
|
-
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`.
|
|
569
|
-
|
|
576
|
+
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
|
|
577
|
+
* field on every claim surface; distinct from the CRUD operation. Serialized
|
|
578
|
+
* on the wire as `action`. */
|
|
579
|
+
readonly reason: string;
|
|
570
580
|
/** Peer-visible explanation of the work being performed. */
|
|
571
581
|
readonly description?: string;
|
|
572
582
|
/** Participant holding it. */
|
package/docs/migration.md
CHANGED
|
@@ -284,7 +284,8 @@ One provider component now owns the full React lifecycle. `<SyncProvider>`,
|
|
|
284
284
|
- <SyncProvider store={sync._store} organizationId={orgId}>
|
|
285
285
|
- <AbloProvider ablo={ablo}>{children}</AbloProvider>
|
|
286
286
|
- </SyncProvider>
|
|
287
|
-
+
|
|
287
|
+
+ const ablo = Ablo({ schema, apiKey });
|
|
288
|
+
+ <AbloProvider client={ablo}>
|
|
288
289
|
+ {children}
|
|
289
290
|
+ </AbloProvider>
|
|
290
291
|
```
|