@abloatai/ablo 0.5.1 → 0.7.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 +61 -0
- package/README.md +248 -124
- package/dist/BaseSyncedStore.d.ts +3 -3
- package/dist/BaseSyncedStore.js +3 -3
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +91 -93
- package/dist/client/Ablo.js +122 -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 +116 -90
- package/dist/client/createModelProxy.js +128 -128
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +5 -5
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +59 -14
- package/dist/errors.js +73 -12
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -12
- 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/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +14 -6
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +167 -0
- package/dist/schema/diff.js +280 -0
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +9 -3
- package/dist/schema/index.js +14 -2
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +10 -69
- package/dist/schema/schema.js +58 -24
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +96 -0
- package/dist/schema/serialize.js +231 -0
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- 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.d.ts +2 -1
- package/dist/sync/createIntentStream.js +89 -5
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +9 -18
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +90 -42
- package/docs/api-keys.md +44 -0
- package/docs/api.md +72 -173
- package/docs/audit.md +5 -5
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +42 -43
- package/docs/coordination.md +343 -0
- package/docs/data-sources.md +16 -16
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +38 -36
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +34 -56
- package/docs/identity.md +529 -0
- package/docs/index.md +18 -24
- package/docs/integration-guide.md +130 -144
- package/docs/interaction-model.md +32 -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 +74 -24
- package/docs/roadmap.md +17 -7
- package/llms.txt +34 -39
- package/package.json +8 -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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy — the single source of truth for how a model's rows are scoped to a
|
|
3
|
+
* tenant. This replaces three scattered mechanisms (a hardcoded
|
|
4
|
+
* `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
|
|
5
|
+
* one Zod discriminated union, resolved in one place and consumed everywhere
|
|
6
|
+
* (provision/RLS, introspection, runtime, CLI).
|
|
7
|
+
*
|
|
8
|
+
* Why a union: every consumer used to re-derive "how is this table scoped?" from
|
|
9
|
+
* a flag plus a literal — a missed branch was a silent cross-tenant scoping bug.
|
|
10
|
+
* A discriminated union makes the `switch` exhaustive, so the type system holds
|
|
11
|
+
* the isolation boundary, and the physical column name lives in exactly one
|
|
12
|
+
* place (the `column` variant) instead of being hardcoded across the codebase.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** Default physical tenancy column. The ONLY place this literal is canonical. */
|
|
16
|
+
export const DEFAULT_ORG_COLUMN = 'organization_id';
|
|
17
|
+
/**
|
|
18
|
+
* Scope a table's rows through a parent table (for rows that carry no tenancy
|
|
19
|
+
* column of their own — e.g. `slide_layers` → slide → deck → org).
|
|
20
|
+
*/
|
|
21
|
+
export const scopedViaRefSchema = z.object({
|
|
22
|
+
/** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
|
|
23
|
+
localKey: z.string().min(1),
|
|
24
|
+
/** Parent table name (e.g. `'team'`). */
|
|
25
|
+
parentTable: z.string().min(1),
|
|
26
|
+
/** Column on the parent that `localKey` references. Default `'id'`. */
|
|
27
|
+
parentKey: z.string().min(1).optional(),
|
|
28
|
+
/** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
|
|
29
|
+
parentOrgColumn: z.string().min(1).optional(),
|
|
30
|
+
});
|
|
31
|
+
/** How a model's rows are scoped to a tenant. */
|
|
32
|
+
export const tenancySchema = z.discriminatedUnion('kind', [
|
|
33
|
+
/** Row-local tenancy column (default name `organization_id`, overridable). */
|
|
34
|
+
z.object({ kind: z.literal('column'), column: z.string().min(1) }),
|
|
35
|
+
/** Scoped through a parent table's tenancy. */
|
|
36
|
+
z.object({ kind: z.literal('parent'), via: scopedViaRefSchema }),
|
|
37
|
+
/** Not tenant-scoped (global / reference data). */
|
|
38
|
+
z.object({ kind: z.literal('none') }),
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
|
|
42
|
+
* at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
|
|
43
|
+
* `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
|
|
44
|
+
* column (default or `orgColumn`).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveTenancy(input) {
|
|
47
|
+
if (input.tenancy)
|
|
48
|
+
return input.tenancy;
|
|
49
|
+
if (input.scopedVia)
|
|
50
|
+
return { kind: 'parent', via: input.scopedVia };
|
|
51
|
+
if (input.orgScoped === false)
|
|
52
|
+
return { kind: 'none' };
|
|
53
|
+
return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
|
|
54
|
+
}
|
|
55
|
+
/** The physical tenancy column for a column-scoped model, else `null`. */
|
|
56
|
+
export function tenancyColumn(t) {
|
|
57
|
+
return t.kind === 'column' ? t.column : null;
|
|
58
|
+
}
|
|
@@ -113,6 +113,8 @@ export declare class HydrationCoordinator {
|
|
|
113
113
|
private hydrateExpanded;
|
|
114
114
|
private persistToIdb;
|
|
115
115
|
private resolveTypename;
|
|
116
|
+
private columnizeField;
|
|
117
|
+
private columnizeClause;
|
|
116
118
|
}
|
|
117
119
|
/**
|
|
118
120
|
* Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
|
|
@@ -161,10 +161,10 @@ export class HydrationCoordinator {
|
|
|
161
161
|
const firstOrder = orderEntries[0];
|
|
162
162
|
const query = {
|
|
163
163
|
model: typename,
|
|
164
|
-
where: clauses.map((c) => columnizeClause(c)),
|
|
164
|
+
where: clauses.map((c) => this.columnizeClause(modelName, c)),
|
|
165
165
|
...(firstOrder
|
|
166
166
|
? {
|
|
167
|
-
orderBy:
|
|
167
|
+
orderBy: this.columnizeField(modelName, firstOrder[0]),
|
|
168
168
|
order: firstOrder[1] ?? 'asc',
|
|
169
169
|
}
|
|
170
170
|
: {}),
|
|
@@ -256,6 +256,27 @@ export class HydrationCoordinator {
|
|
|
256
256
|
.models?.[modelName];
|
|
257
257
|
return def?.typename ?? modelName;
|
|
258
258
|
}
|
|
259
|
+
columnizeField(modelName, field) {
|
|
260
|
+
const fields = this.opts.schema.models?.[modelName]?.fields;
|
|
261
|
+
if (fields) {
|
|
262
|
+
const direct = fields[field]?.column;
|
|
263
|
+
if (direct)
|
|
264
|
+
return direct;
|
|
265
|
+
for (const [fieldName, meta] of Object.entries(fields)) {
|
|
266
|
+
const conventional = columnize(fieldName);
|
|
267
|
+
if (field === fieldName || field === conventional || field === meta.column) {
|
|
268
|
+
return meta.column ?? conventional;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return /[A-Z]/.test(field) ? columnize(field) : field;
|
|
273
|
+
}
|
|
274
|
+
columnizeClause(modelName, clause) {
|
|
275
|
+
const finalCol = this.columnizeField(modelName, clause[0]);
|
|
276
|
+
if (clause.length === 2)
|
|
277
|
+
return [finalCol, clause[1]];
|
|
278
|
+
return [finalCol, clause[1], clause[2]];
|
|
279
|
+
}
|
|
259
280
|
}
|
|
260
281
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
261
282
|
function stableKey(modelName, clauses, orderBy, limit) {
|
|
@@ -350,21 +371,6 @@ export function normalizeWhere(where) {
|
|
|
350
371
|
}
|
|
351
372
|
return [];
|
|
352
373
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Apply `columnize` to the column name of a wire-bound clause so the
|
|
355
|
-
* server sees `slide_id` instead of `slideId`. Tuple-form clauses from
|
|
356
|
-
* callers are passed through unchanged — they already supply the wire
|
|
357
|
-
* column name (matches what existing `postQuery` consumers do).
|
|
358
|
-
*/
|
|
359
|
-
function columnizeClause(clause) {
|
|
360
|
-
const col = clause[0];
|
|
361
|
-
// If the column already looks snake_case (no uppercase letters), assume
|
|
362
|
-
// the caller is already using server-side naming. Otherwise camelize→snake.
|
|
363
|
-
const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
|
|
364
|
-
if (clause.length === 2)
|
|
365
|
-
return [finalCol, clause[1]];
|
|
366
|
-
return [finalCol, clause[1], clause[2]];
|
|
367
|
-
}
|
|
368
374
|
/** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
|
|
369
375
|
function extractEqClauses(clauses) {
|
|
370
376
|
const out = {};
|
|
@@ -261,6 +261,23 @@ export interface CoreSyncEventMap {
|
|
|
261
261
|
* Payload mirrors the wire frame's `payload`.
|
|
262
262
|
*/
|
|
263
263
|
intent_rejected: [Record<string, unknown>];
|
|
264
|
+
/**
|
|
265
|
+
* Fair-queue frames (opt-in `queue: true` on `intent_begin`). `intent_acquired`
|
|
266
|
+
* means the target was free and the lease is ours immediately; `intent_queued`
|
|
267
|
+
* means the claim is waiting in line (carries `position`); `intent_granted`
|
|
268
|
+
* means it reached the head and the lease is now ours; `intent_lost` means a
|
|
269
|
+
* held/granted claim was taken away (TTL lapse on disconnect, revoke).
|
|
270
|
+
*/
|
|
271
|
+
/**
|
|
272
|
+
* Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
|
|
273
|
+
* entry `status: 'queued'` + `position`. Broadcast to entity peers on every
|
|
274
|
+
* queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
|
|
275
|
+
*/
|
|
276
|
+
intent_queue: [Record<string, unknown>];
|
|
277
|
+
intent_acquired: [Record<string, unknown>];
|
|
278
|
+
intent_queued: [Record<string, unknown>];
|
|
279
|
+
intent_granted: [Record<string, unknown>];
|
|
280
|
+
intent_lost: [Record<string, unknown>];
|
|
264
281
|
}
|
|
265
282
|
/**
|
|
266
283
|
* Collaboration event — app-specific real-time events (selection, cursors, etc.)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
|
-
import { CapabilityError, SyncSessionError } from '../errors.js';
|
|
13
|
+
import { AbloClaimedError, CapabilityError, SyncSessionError, } from '../errors.js';
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
16
|
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
@@ -368,6 +368,24 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
368
368
|
errorCode === 'capability_invalid') {
|
|
369
369
|
pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
|
|
370
370
|
}
|
|
371
|
+
else if (errorCode === 'intent_conflict' ||
|
|
372
|
+
errorCode === 'claim_conflict' ||
|
|
373
|
+
errorCode === 'entity_claimed') {
|
|
374
|
+
// Claim enforcement: another participant holds a live claim on
|
|
375
|
+
// a targeted entity. Two server layers reject this — the Hub's
|
|
376
|
+
// pre-commit lease check (`intent_conflict`, the code that
|
|
377
|
+
// reaches clients in practice) and `executeCommit`'s deeper
|
|
378
|
+
// guard (`entity_claimed`). Both mean "claimed", so both route
|
|
379
|
+
// through the typed AbloClaimedError, letting callers
|
|
380
|
+
// `instanceof AbloClaimedError` (or read `e.type` across worker
|
|
381
|
+
// boundaries) and wait/bypass — symmetric with the
|
|
382
|
+
// CapabilityError branch above, and with the HTTP commit path
|
|
383
|
+
// (`translateHttpError`).
|
|
384
|
+
pending.reject(new AbloClaimedError(errorMessage, {
|
|
385
|
+
code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
|
|
386
|
+
httpStatus: 409,
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
371
389
|
else {
|
|
372
390
|
const rejection = new Error(errorMessage);
|
|
373
391
|
if (errorCode)
|
|
@@ -448,6 +466,33 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
448
466
|
this.emit('intent_rejected', message.payload ?? {});
|
|
449
467
|
break;
|
|
450
468
|
}
|
|
469
|
+
case 'intent_acquired': {
|
|
470
|
+
// Opt-in fair queue: the target was free, so the lease is ours
|
|
471
|
+
// immediately (no waiting). Payload carries { intentId, target }.
|
|
472
|
+
this.emit('intent_acquired', message.payload ?? {});
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case 'intent_queue': {
|
|
476
|
+
// Per-entity wait-queue snapshot for reactive `queue(id)`.
|
|
477
|
+
this.emit('intent_queue', message.payload ?? {});
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case 'intent_queued': {
|
|
481
|
+
// Opt-in fair queue: our claim is waiting in line. Payload
|
|
482
|
+
// carries { intentId, target, position }.
|
|
483
|
+
this.emit('intent_queued', message.payload ?? {});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case 'intent_granted': {
|
|
487
|
+
// Our queued claim reached the head — the lease is now ours.
|
|
488
|
+
this.emit('intent_granted', message.payload ?? {});
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case 'intent_lost': {
|
|
492
|
+
// A held/granted claim was taken from us (TTL lapse, revoke).
|
|
493
|
+
this.emit('intent_lost', message.payload ?? {});
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
451
496
|
case 'delta': {
|
|
452
497
|
const p = message.payload;
|
|
453
498
|
if (p?.actionType || p?.modelName) {
|
|
@@ -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
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|
|
@@ -29,9 +30,16 @@ export function createIntentStream(config, transport = null) {
|
|
|
29
30
|
let intentsSnapshot = Object.freeze([]);
|
|
30
31
|
// ── State: our own open intents (for re-announce on reconnect) ───
|
|
31
32
|
const ownIntents = new Map();
|
|
33
|
+
// ── State: per-entity wait queues, from `intent_queue` frames ────
|
|
34
|
+
// Keyed `type:id`; the value is the FIFO line of queued intents. Powers
|
|
35
|
+
// the reactive `queue(target)` read — who's waiting and what they intend.
|
|
36
|
+
const queueByEntity = new Map();
|
|
37
|
+
const entityKey = (type, id) => `${type}:${id}`;
|
|
38
|
+
const EMPTY_QUEUE = Object.freeze([]);
|
|
32
39
|
// ── Subscribers ──────────────────────────────────────────────────
|
|
33
40
|
const listeners = new Set();
|
|
34
41
|
const rejectionListeners = new Set();
|
|
42
|
+
const lostListeners = new Set();
|
|
35
43
|
const notifyListeners = () => {
|
|
36
44
|
intentsSnapshot = Object.freeze(Array.from(activeByIntentId.values()));
|
|
37
45
|
for (const l of listeners) {
|
|
@@ -125,6 +133,39 @@ export function createIntentStream(config, transport = null) {
|
|
|
125
133
|
}
|
|
126
134
|
}
|
|
127
135
|
}));
|
|
136
|
+
// (2a) Server-side LOSS frames — you held it, then lost it (preempted /
|
|
137
|
+
// expired). Distinct from a rejection (a claim the server refused).
|
|
138
|
+
unsubs.push(t.subscribe('intent_lost', (payload) => {
|
|
139
|
+
const lost = payload;
|
|
140
|
+
if (!lost.intentId)
|
|
141
|
+
return;
|
|
142
|
+
// Drop the lost own-claim so reconnect doesn't re-announce a lease we
|
|
143
|
+
// no longer hold.
|
|
144
|
+
ownIntents.delete(lost.intentId);
|
|
145
|
+
for (const l of lostListeners) {
|
|
146
|
+
try {
|
|
147
|
+
l(lost);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* isolate */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
// (2b) Per-entity wait-queue snapshots. The server fans the full line
|
|
155
|
+
// out on every queue mutation; we replace our cached line for that
|
|
156
|
+
// entity and notify so `queue(target)` reads reactively.
|
|
157
|
+
unsubs.push(t.subscribe('intent_queue', (payload) => {
|
|
158
|
+
const p = payload;
|
|
159
|
+
if (!p.target?.type || !p.target.id)
|
|
160
|
+
return;
|
|
161
|
+
const key = entityKey(p.target.type, p.target.id);
|
|
162
|
+
const line = Array.isArray(p.queue) ? p.queue : [];
|
|
163
|
+
if (line.length === 0)
|
|
164
|
+
queueByEntity.delete(key);
|
|
165
|
+
else
|
|
166
|
+
queueByEntity.set(key, Object.freeze([...line]));
|
|
167
|
+
notifyListeners();
|
|
168
|
+
}));
|
|
128
169
|
// (3) On reconnect, re-announce every open self-claim — the
|
|
129
170
|
// server's intent state is in-memory and is lost across
|
|
130
171
|
// restarts. Without this, peers would see our claims vanish
|
|
@@ -153,13 +194,38 @@ export function createIntentStream(config, transport = null) {
|
|
|
153
194
|
field: intent.field,
|
|
154
195
|
meta: intent.meta,
|
|
155
196
|
estimatedMs: intent.estimatedMs,
|
|
197
|
+
queue: intent.queue,
|
|
156
198
|
},
|
|
157
199
|
});
|
|
158
200
|
}
|
|
159
|
-
function
|
|
201
|
+
function sendReorder(entityType, entityId, order) {
|
|
160
202
|
if (!attached?.isConnected())
|
|
161
203
|
return;
|
|
162
|
-
attached.send({
|
|
204
|
+
attached.send({
|
|
205
|
+
type: 'intent_reorder',
|
|
206
|
+
payload: {
|
|
207
|
+
entityType,
|
|
208
|
+
entityId,
|
|
209
|
+
// The wire shape identifies a waiter by heldBy + intentId; map the
|
|
210
|
+
// ergonomic `Intent[]` (what `queueFor` returns) down to that.
|
|
211
|
+
order: order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function sendAbandon(intentId, intent) {
|
|
216
|
+
if (!attached?.isConnected())
|
|
217
|
+
return;
|
|
218
|
+
// Carry the target so the server can dequeue us if we were only *waiting*
|
|
219
|
+
// (a queued claim isn't in the holder set it would otherwise scan). Held
|
|
220
|
+
// claims are found by intentId regardless; the target is harmless there.
|
|
221
|
+
attached.send({
|
|
222
|
+
type: 'intent_abandon',
|
|
223
|
+
payload: {
|
|
224
|
+
intentId,
|
|
225
|
+
entityType: intent?.entityType,
|
|
226
|
+
entityId: intent?.entityId,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
163
229
|
}
|
|
164
230
|
function mintHandle(args) {
|
|
165
231
|
const intentId = crypto.randomUUID();
|
|
@@ -173,6 +239,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
173
239
|
meta: args.meta,
|
|
174
240
|
action: args.action,
|
|
175
241
|
estimatedMs,
|
|
242
|
+
queue: args.queue,
|
|
176
243
|
};
|
|
177
244
|
ownIntents.set(intentId, intent);
|
|
178
245
|
sendBegin(intentId, intent);
|
|
@@ -182,7 +249,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
182
249
|
return;
|
|
183
250
|
revoked = true;
|
|
184
251
|
ownIntents.delete(intentId);
|
|
185
|
-
sendAbandon(intentId);
|
|
252
|
+
sendAbandon(intentId, intent);
|
|
186
253
|
};
|
|
187
254
|
return {
|
|
188
255
|
id: intentId,
|
|
@@ -209,12 +276,21 @@ export function createIntentStream(config, transport = null) {
|
|
|
209
276
|
meta: resolved.meta,
|
|
210
277
|
action: opts?.reason ?? 'editing',
|
|
211
278
|
ttl: opts?.ttl,
|
|
279
|
+
queue: opts?.queue,
|
|
212
280
|
});
|
|
213
281
|
},
|
|
214
282
|
get others() {
|
|
215
283
|
return intentsSnapshot;
|
|
216
284
|
},
|
|
217
|
-
|
|
285
|
+
queueFor(target) {
|
|
286
|
+
const ref = resolveTarget(target);
|
|
287
|
+
return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
|
|
288
|
+
},
|
|
289
|
+
reorder(target, order) {
|
|
290
|
+
const ref = resolveTarget(target);
|
|
291
|
+
sendReorder(ref.type, ref.id, order);
|
|
292
|
+
},
|
|
293
|
+
onChange: (listener) => {
|
|
218
294
|
listeners.add(listener);
|
|
219
295
|
return () => {
|
|
220
296
|
listeners.delete(listener);
|
|
@@ -226,6 +302,12 @@ export function createIntentStream(config, transport = null) {
|
|
|
226
302
|
rejectionListeners.delete(listener);
|
|
227
303
|
};
|
|
228
304
|
},
|
|
305
|
+
onLost: (listener) => {
|
|
306
|
+
lostListeners.add(listener);
|
|
307
|
+
return () => {
|
|
308
|
+
lostListeners.delete(listener);
|
|
309
|
+
};
|
|
310
|
+
},
|
|
229
311
|
[Symbol.asyncIterator]() {
|
|
230
312
|
return asyncIteratorFrom((onChange) => {
|
|
231
313
|
listeners.add(onChange);
|
|
@@ -241,8 +323,10 @@ export function createIntentStream(config, transport = null) {
|
|
|
241
323
|
unsubs.length = 0;
|
|
242
324
|
listeners.clear();
|
|
243
325
|
rejectionListeners.clear();
|
|
326
|
+
lostListeners.clear();
|
|
244
327
|
activeByIntentId.clear();
|
|
245
328
|
ownIntents.clear();
|
|
329
|
+
queueByEntity.clear();
|
|
246
330
|
intentsSnapshot = Object.freeze([]);
|
|
247
331
|
attached = null;
|
|
248
332
|
},
|
|
@@ -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;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scopeKindOf } from '../schema/model.js';
|
|
1
2
|
export function createParticipantManager(config) {
|
|
2
3
|
return {
|
|
3
4
|
async join(input, overrides) {
|
|
@@ -74,17 +75,13 @@ export function resolveParticipantSyncGroups(scope, schema) {
|
|
|
74
75
|
}
|
|
75
76
|
export function syncGroupFromEntityRef(ref, schema) {
|
|
76
77
|
const match = findModelForEntityRef(ref, schema);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return `${ref.type.toLowerCase()}:${ref.id}`;
|
|
78
|
+
const kind = match ? scopeKindOf(match.def, match.key) : undefined;
|
|
79
|
+
return `${kind ?? ref.type.toLowerCase()}:${ref.id}`;
|
|
81
80
|
}
|
|
82
81
|
function syncGroupFromSchemaKey(schemaKey, id, schema) {
|
|
83
82
|
const def = schema?.models?.[schemaKey];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
return `${schemaKey}:${id}`;
|
|
83
|
+
const kind = def ? scopeKindOf(def, schemaKey) : undefined;
|
|
84
|
+
return `${kind ?? schemaKey}:${id}`;
|
|
88
85
|
}
|
|
89
86
|
function findModelForEntityRef(ref, schema) {
|
|
90
87
|
if (!schema?.models)
|
|
@@ -98,12 +95,6 @@ function findModelForEntityRef(ref, schema) {
|
|
|
98
95
|
}
|
|
99
96
|
return null;
|
|
100
97
|
}
|
|
101
|
-
function renderSyncGroupFormat(format, values) {
|
|
102
|
-
return format.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, key) => {
|
|
103
|
-
const value = values[key];
|
|
104
|
-
return value === undefined ? `{${key}}` : value;
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
98
|
export function parseParticipantTtlSeconds(value) {
|
|
108
99
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
109
100
|
return value;
|
|
@@ -218,8 +209,8 @@ function createJoinedParticipant(args) {
|
|
|
218
209
|
idle() {
|
|
219
210
|
args.presence.idle();
|
|
220
211
|
},
|
|
221
|
-
|
|
222
|
-
return args.presence.
|
|
212
|
+
onChange(listener) {
|
|
213
|
+
return args.presence.onChange(listener);
|
|
223
214
|
},
|
|
224
215
|
};
|
|
225
216
|
const track = (handle) => {
|
|
@@ -252,8 +243,8 @@ function createJoinedParticipant(args) {
|
|
|
252
243
|
onRejected(listener) {
|
|
253
244
|
return args.intents.onRejected(listener);
|
|
254
245
|
},
|
|
255
|
-
|
|
256
|
-
return args.intents.
|
|
246
|
+
onChange(listener) {
|
|
247
|
+
return args.intents.onChange(listener);
|
|
257
248
|
},
|
|
258
249
|
};
|
|
259
250
|
const leave = () => {
|