@abloatai/ablo 0.10.1 → 0.11.1
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 +34 -0
- package/README.md +63 -23
- 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 +369 -67
- package/dist/client/Ablo.d.ts +30 -63
- package/dist/client/Ablo.js +124 -103
- package/dist/client/ApiClient.d.ts +6 -5
- package/dist/client/ApiClient.js +86 -62
- package/dist/client/auth.d.ts +9 -4
- package/dist/client/auth.js +40 -5
- package/dist/client/createModelProxy.d.ts +41 -54
- package/dist/client/createModelProxy.js +123 -20
- 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 +16 -16
- 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/schema.d.ts +3 -3
- 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 +23 -0
- package/dist/transactions/TransactionQueue.js +186 -12
- package/dist/types/global.d.ts +18 -13
- package/dist/types/global.js +11 -6
- 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/docs/api.md +3 -3
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +13 -3
- package/docs/data-sources.md +29 -9
- package/docs/migration.md +40 -0
- package/docs/quickstart.md +61 -33
- package/docs/react.md +46 -0
- package/llms-full.txt +25 -8
- package/llms.txt +11 -9
- 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
|
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
13
|
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
|
+
import { subscriptionAckPayloadSchema } from '../coordination/schema.js';
|
|
14
15
|
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
@@ -105,6 +106,15 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
105
106
|
* over a multiplexed connection.
|
|
106
107
|
*/
|
|
107
108
|
pendingClaims = new Map();
|
|
109
|
+
/**
|
|
110
|
+
* In-flight `update_subscription` frames awaiting `subscription_ack`.
|
|
111
|
+
* A FIFO queue rather than a keyed Map because the wire ack carries no
|
|
112
|
+
* correlation id — the server applies subscription updates in receive
|
|
113
|
+
* order and acks in the same order, so `shift()` on ack matches the
|
|
114
|
+
* oldest pending request. (Read-interest changes are infrequent and
|
|
115
|
+
* usually settle before the next one, so depth is ~1 in practice.)
|
|
116
|
+
*/
|
|
117
|
+
pendingSubscriptions = [];
|
|
108
118
|
constructor(options) {
|
|
109
119
|
super();
|
|
110
120
|
// Construct WebSocket URL from base Go server URL
|
|
@@ -430,6 +440,37 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
430
440
|
}
|
|
431
441
|
break;
|
|
432
442
|
}
|
|
443
|
+
case 'subscription_ack': {
|
|
444
|
+
// Ack for a prior `update_subscription`. The wire carries no
|
|
445
|
+
// correlation id, so FIFO-match against the oldest pending
|
|
446
|
+
// request — the server applies and acks subscription updates
|
|
447
|
+
// in receive order. Validated through the canonical zod schema
|
|
448
|
+
// (mirrors how the Hub validates inbound frames).
|
|
449
|
+
const pending = this.pendingSubscriptions.shift();
|
|
450
|
+
if (!pending)
|
|
451
|
+
break;
|
|
452
|
+
clearTimeout(pending.timeout);
|
|
453
|
+
const parsed = subscriptionAckPayloadSchema.safeParse(message.payload);
|
|
454
|
+
if (!parsed.success) {
|
|
455
|
+
// Unreadable ack — resolve the pending request as a failure
|
|
456
|
+
// rather than hang it until timeout.
|
|
457
|
+
pending.reject(errorFromWire('malformed subscription_ack from server', {
|
|
458
|
+
code: 'malformed_subscription',
|
|
459
|
+
}));
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
const ack = parsed.data;
|
|
463
|
+
if (ack.success) {
|
|
464
|
+
// Keep the reconnect URL aligned with current interest: a
|
|
465
|
+
// reconnect re-subscribes from `this.options.syncGroups`.
|
|
466
|
+
this.options.syncGroups = ack.syncGroups;
|
|
467
|
+
pending.resolve({ syncGroups: ack.syncGroups });
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
pending.reject(errorFromWire(ack.error?.message ?? 'update_subscription rejected by server', { code: ack.error?.code ?? 'malformed_subscription' }));
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
433
474
|
case 'claim_expired': {
|
|
434
475
|
// Server-initiated expiry notification. Emit as a typed
|
|
435
476
|
// event so consumers can react (re-claim with a fresh
|
|
@@ -441,39 +482,39 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
441
482
|
}
|
|
442
483
|
break;
|
|
443
484
|
}
|
|
444
|
-
case '
|
|
445
|
-
// Server denied an `
|
|
485
|
+
case 'claim_rejected': {
|
|
486
|
+
// Server denied an `claim_begin` because the target is
|
|
446
487
|
// already claimed by another participant. Forward the
|
|
447
|
-
// payload as-is — the
|
|
488
|
+
// payload as-is — the ClaimStream consumer interprets
|
|
448
489
|
// the conflict shape (peerId, target, etc.).
|
|
449
|
-
this.emit('
|
|
490
|
+
this.emit('claim_rejected', message.payload ?? {});
|
|
450
491
|
break;
|
|
451
492
|
}
|
|
452
|
-
case '
|
|
493
|
+
case 'claim_acquired': {
|
|
453
494
|
// Opt-in fair queue: the target was free, so the lease is ours
|
|
454
|
-
// immediately (no waiting). Payload carries {
|
|
455
|
-
this.emit('
|
|
495
|
+
// immediately (no waiting). Payload carries { claimId, target }.
|
|
496
|
+
this.emit('claim_acquired', message.payload ?? {});
|
|
456
497
|
break;
|
|
457
498
|
}
|
|
458
|
-
case '
|
|
499
|
+
case 'claim_queue': {
|
|
459
500
|
// Per-entity wait-queue snapshot for reactive `queue(id)`.
|
|
460
|
-
this.emit('
|
|
501
|
+
this.emit('claim_queue', message.payload ?? {});
|
|
461
502
|
break;
|
|
462
503
|
}
|
|
463
|
-
case '
|
|
504
|
+
case 'claim_queued': {
|
|
464
505
|
// Opt-in fair queue: our claim is waiting in line. Payload
|
|
465
|
-
// carries {
|
|
466
|
-
this.emit('
|
|
506
|
+
// carries { claimId, target, position }.
|
|
507
|
+
this.emit('claim_queued', message.payload ?? {});
|
|
467
508
|
break;
|
|
468
509
|
}
|
|
469
|
-
case '
|
|
510
|
+
case 'claim_granted': {
|
|
470
511
|
// Our queued claim reached the head — the lease is now ours.
|
|
471
|
-
this.emit('
|
|
512
|
+
this.emit('claim_granted', message.payload ?? {});
|
|
472
513
|
break;
|
|
473
514
|
}
|
|
474
|
-
case '
|
|
515
|
+
case 'claim_lost': {
|
|
475
516
|
// A held/granted claim was taken from us (TTL lapse, revoke).
|
|
476
|
-
this.emit('
|
|
517
|
+
this.emit('claim_lost', message.payload ?? {});
|
|
477
518
|
break;
|
|
478
519
|
}
|
|
479
520
|
case 'delta': {
|
|
@@ -585,6 +626,17 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
585
626
|
}
|
|
586
627
|
this.pendingClaims.clear();
|
|
587
628
|
}
|
|
629
|
+
// Cancel in-flight subscription updates — the reconnect handshake
|
|
630
|
+
// re-sends `options.syncGroups` (the last acked interest) in the
|
|
631
|
+
// upgrade URL, so a pending change that never acked is simply
|
|
632
|
+
// retried by the caller against the fresh connection.
|
|
633
|
+
if (this.pendingSubscriptions.length > 0) {
|
|
634
|
+
for (const pending of this.pendingSubscriptions) {
|
|
635
|
+
clearTimeout(pending.timeout);
|
|
636
|
+
pending.reject(new AbloConnectionError(`WebSocket closed while update_subscription was in flight (code=${event.code})`));
|
|
637
|
+
}
|
|
638
|
+
this.pendingSubscriptions = [];
|
|
639
|
+
}
|
|
588
640
|
// Check for session-related close codes
|
|
589
641
|
// 1008 = Policy Violation (often auth)
|
|
590
642
|
// 4001 = Unauthorized (custom)
|
|
@@ -702,7 +754,6 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
702
754
|
type: 'ack',
|
|
703
755
|
payload: {
|
|
704
756
|
lastSyncId: syncId,
|
|
705
|
-
versions: this.versionVector,
|
|
706
757
|
},
|
|
707
758
|
});
|
|
708
759
|
}
|
|
@@ -887,13 +938,13 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
887
938
|
sendRelease(claimId) {
|
|
888
939
|
// Cancel any in-flight claim that hadn't acked yet — the user
|
|
889
940
|
// changed their mind. Without this the timer would eventually
|
|
890
|
-
// reject; doing it now matches the user's
|
|
941
|
+
// reject; doing it now matches the user's claim immediately.
|
|
891
942
|
const pending = this.pendingClaims.get(claimId);
|
|
892
943
|
if (pending) {
|
|
893
944
|
clearTimeout(pending.timeout);
|
|
894
945
|
this.pendingClaims.delete(claimId);
|
|
895
946
|
pending.reject(new AbloError(`claim ${claimId} released before ack`, {
|
|
896
|
-
code: '
|
|
947
|
+
code: 'claim_wait_aborted',
|
|
897
948
|
httpStatus: 409,
|
|
898
949
|
}));
|
|
899
950
|
}
|
|
@@ -906,6 +957,56 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
906
957
|
// Idempotent contract — silent failure is acceptable here.
|
|
907
958
|
}
|
|
908
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* Move this connection's READ interest — replace the connection-level
|
|
962
|
+
* sync groups mid-session as the user opens/closes entities. This is the
|
|
963
|
+
* area-of-interest (AOI) navigation primitive: the server fans out
|
|
964
|
+
* deltas only for groups currently in view, instead of the frozen set
|
|
965
|
+
* chosen at connect.
|
|
966
|
+
*
|
|
967
|
+
* Full-set replace semantics — pass the complete new group list, not a
|
|
968
|
+
* delta. Resolves with the server's effective set once `subscription_ack`
|
|
969
|
+
* arrives; rejects (typed) on a scope denial (a restricted `rk_` key
|
|
970
|
+
* requesting a group outside its allowlist), timeout, or disconnect. On
|
|
971
|
+
* success the new set is recorded as `options.syncGroups` so a later
|
|
972
|
+
* reconnect re-subscribes to current interest, not the connect-time set.
|
|
973
|
+
*
|
|
974
|
+
* Distinct from {@link sendClaim} (write-claim, per-op, TTL'd) — this is
|
|
975
|
+
* the read side and carries no capability token of its own; it's bounded
|
|
976
|
+
* by the connection credential's grant.
|
|
977
|
+
*/
|
|
978
|
+
updateSubscription(syncGroups, options) {
|
|
979
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
980
|
+
return Promise.reject(this.notConnectedError('update_subscription'));
|
|
981
|
+
}
|
|
982
|
+
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
983
|
+
return new Promise((resolve, reject) => {
|
|
984
|
+
const entry = {
|
|
985
|
+
resolve,
|
|
986
|
+
reject,
|
|
987
|
+
timeout: setTimeout(() => {
|
|
988
|
+
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
989
|
+
if (idx !== -1)
|
|
990
|
+
this.pendingSubscriptions.splice(idx, 1);
|
|
991
|
+
reject(new AbloConnectionError(`update_subscription timed out after ${timeoutMs}ms`, { code: 'wait_for_timeout' }));
|
|
992
|
+
}, timeoutMs),
|
|
993
|
+
};
|
|
994
|
+
this.pendingSubscriptions.push(entry);
|
|
995
|
+
try {
|
|
996
|
+
this.ws.send(JSON.stringify({
|
|
997
|
+
type: 'update_subscription',
|
|
998
|
+
payload: { syncGroups: [...syncGroups] },
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
clearTimeout(entry.timeout);
|
|
1003
|
+
const idx = this.pendingSubscriptions.indexOf(entry);
|
|
1004
|
+
if (idx !== -1)
|
|
1005
|
+
this.pendingSubscriptions.splice(idx, 1);
|
|
1006
|
+
reject(toAbloError(error));
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
909
1010
|
/**
|
|
910
1011
|
* Compatibility setter for direct SyncWebSocket users. The SDK-owned
|
|
911
1012
|
* `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
|
|
@@ -1287,9 +1388,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1287
1388
|
this.send({
|
|
1288
1389
|
type: 'sync_request',
|
|
1289
1390
|
payload: {
|
|
1290
|
-
cursor: this.syncCursor,
|
|
1291
|
-
versions: this.versionVector,
|
|
1292
|
-
// Always send lastSyncId to ensure server uses client's current position
|
|
1391
|
+
cursor: this.syncCursor, // Always send lastSyncId to ensure server uses client's current position
|
|
1293
1392
|
lastSyncId: this.lastSyncId,
|
|
1294
1393
|
capabilities: capsArr,
|
|
1295
1394
|
},
|
|
@@ -1309,9 +1408,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1309
1408
|
this.send({
|
|
1310
1409
|
type: 'bootstrap_request',
|
|
1311
1410
|
payload: {
|
|
1312
|
-
entities: entities || [],
|
|
1313
|
-
versions: this.versionVector,
|
|
1314
|
-
capabilities: this.options.capabilities,
|
|
1411
|
+
entities: entities || [], capabilities: this.options.capabilities,
|
|
1315
1412
|
},
|
|
1316
1413
|
});
|
|
1317
1414
|
}
|
|
@@ -1416,7 +1513,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1416
1513
|
*
|
|
1417
1514
|
* Wire frame (apps/sync-server/src/hub/types.ts PresenceUpdateMessage):
|
|
1418
1515
|
* { type: 'presence_update', payload: { kind, userId, status,
|
|
1419
|
-
* syncGroups, activity, isAgent, timestamp,
|
|
1516
|
+
* syncGroups, activity, isAgent, timestamp, activeClaims } }
|
|
1420
1517
|
*/
|
|
1421
1518
|
handlePresenceUpdate(message) {
|
|
1422
1519
|
const event =
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `awaitClaimGrant` — 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/claims`, or `claim_queued` over WS). The grant is then
|
|
6
|
+
* PUSHED later over the WS as `claim_granted` when the claim reaches the head.
|
|
7
|
+
* This resolves once that frame arrives for our `claimId` — so the caller's
|
|
8
|
+
* `claim` promise stays pending (event-driven; no poll, no race) until it's
|
|
9
|
+
* actually our turn. Rejects on `claim_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: 'claim_acquired' | 'claim_granted' | 'claim_lost' | 'claim_queued' | 'claim_rejected', handler: (payload: Record<string, unknown>) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface ClaimGrantInfo {
|
|
19
|
+
/**
|
|
20
|
+
* True when the grant arrived as `claim_granted` — i.e. the target was
|
|
21
|
+
* HELD when we asked and we waited in the FIFO line behind the holder.
|
|
22
|
+
* False for the immediate `claim_acquired` (target was free).
|
|
23
|
+
*
|
|
24
|
+
* Callers use this to know the row may have changed while we queued:
|
|
25
|
+
* claim VISIBILITY is entity-scoped (org-wide subscriptions receive no
|
|
26
|
+
* presence/claim fan-out — see Hub.broadcastPresenceChange), so the
|
|
27
|
+
* local coordination snapshot cannot be trusted to detect "we waited".
|
|
28
|
+
* The grant frame itself is the authoritative signal.
|
|
29
|
+
*/
|
|
30
|
+
readonly waited: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function awaitClaimGrant(transport: GrantTransport, claimId: string, options?: {
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Backpressure: reject instead of waiting if, when we join the line, the
|
|
36
|
+
* server reports `position >= maxQueueDepth` (i.e. that many claims are
|
|
37
|
+
* already ahead of us). Omit to wait however deep the queue is.
|
|
38
|
+
*/
|
|
39
|
+
maxQueueDepth?: number;
|
|
40
|
+
}): Promise<ClaimGrantInfo>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `awaitClaimGrant` — 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/claims`, or `claim_queued` over WS). The grant is then
|
|
6
|
+
* PUSHED later over the WS as `claim_granted` when the claim reaches the head.
|
|
7
|
+
* This resolves once that frame arrives for our `claimId` — so the caller's
|
|
8
|
+
* `claim` promise stays pending (event-driven; no poll, no race) until it's
|
|
9
|
+
* actually our turn. Rejects on `claim_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, formatClaimedErrorMessage, claimTargetLabel, } from '../errors.js';
|
|
16
|
+
export function awaitClaimGrant(transport, claimId, 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
|
+
// The target was free → `claim_acquired` (immediate); it was contended,
|
|
28
|
+
// we waited in line, and reached the head → `claim_granted`. Either frame
|
|
29
|
+
// means the lease is now ours; `waited` records which path it was.
|
|
30
|
+
unsubs.push(transport.subscribe('claim_acquired', (p) => {
|
|
31
|
+
if (p?.claimId === claimId)
|
|
32
|
+
settle(() => resolve({ waited: false }));
|
|
33
|
+
}));
|
|
34
|
+
unsubs.push(transport.subscribe('claim_granted', (p) => {
|
|
35
|
+
if (p?.claimId === claimId)
|
|
36
|
+
settle(() => resolve({ waited: true }));
|
|
37
|
+
}));
|
|
38
|
+
if (options?.maxQueueDepth !== undefined) {
|
|
39
|
+
const max = options.maxQueueDepth;
|
|
40
|
+
unsubs.push(transport.subscribe('claim_queued', (p) => {
|
|
41
|
+
if (p?.claimId !== claimId)
|
|
42
|
+
return;
|
|
43
|
+
const position = typeof p.position === 'number' ? p.position : 0;
|
|
44
|
+
if (position >= max) {
|
|
45
|
+
settle(() => reject(new AbloClaimedError(`Claim queue for ${claimId} is ${position} deep (max ${max}).`, { code: 'queue_too_deep' })));
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
unsubs.push(transport.subscribe('claim_rejected', (p) => {
|
|
50
|
+
const rejection = p;
|
|
51
|
+
if (rejection.claimId !== claimId)
|
|
52
|
+
return;
|
|
53
|
+
const target = rejection.target
|
|
54
|
+
? claimTargetLabel({
|
|
55
|
+
model: rejection.target.entityType,
|
|
56
|
+
id: rejection.target.entityId,
|
|
57
|
+
field: rejection.target.field,
|
|
58
|
+
})
|
|
59
|
+
: claimId;
|
|
60
|
+
settle(() => reject(new AbloClaimedError(formatClaimedErrorMessage({
|
|
61
|
+
targetLabel: target,
|
|
62
|
+
heldBy: rejection.heldBy,
|
|
63
|
+
claim: rejection.heldByClaim,
|
|
64
|
+
policyReason: rejection.policyReason,
|
|
65
|
+
fallback: `Claim rejected for ${target}.`,
|
|
66
|
+
}), {
|
|
67
|
+
code: rejection.reason === 'conflict'
|
|
68
|
+
? 'claim_conflict'
|
|
69
|
+
: 'claim_lease_unavailable',
|
|
70
|
+
claims: rejection.heldByClaim ? [rejection.heldByClaim] : undefined,
|
|
71
|
+
})));
|
|
72
|
+
}));
|
|
73
|
+
unsubs.push(transport.subscribe('claim_lost', (p) => {
|
|
74
|
+
if (p?.claimId === claimId) {
|
|
75
|
+
settle(() => reject(new AbloClaimedError(`Claim lost while queued for ${claimId}.`, {
|
|
76
|
+
code: 'claim_lost',
|
|
77
|
+
})));
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
if (options?.timeoutMs && options.timeoutMs > 0) {
|
|
81
|
+
timer = setTimeout(() => {
|
|
82
|
+
settle(() => reject(new AbloClaimedError(`Timed out waiting for the queue grant on claim ${claimId}.`, { code: 'grant_timeout' })));
|
|
83
|
+
}, options.timeoutMs);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-driven ClaimStream factory.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `createPresenceStream` — built directly on `SyncWebSocket`,
|
|
5
|
+
* no SyncAgent wrapper. Claims derive their `others` view from the
|
|
6
|
+
* same `presence_update` frames the presence stream consumes (the
|
|
7
|
+
* Hub piggybacks `activeClaims` on every presence frame). Outbound
|
|
8
|
+
* announce/revoke ride the same socket via `claim_begin` /
|
|
9
|
+
* `claim_abandon` frames.
|
|
10
|
+
*
|
|
11
|
+
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
|
+
* • Outbound: `{ type: 'claim_begin', payload: { claimId,
|
|
13
|
+
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
+
* • Outbound: `{ type: 'claim_abandon', payload: { claimId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
16
|
+
* • Inbound (via presence): `event.activeClaims: Claim[]`
|
|
17
|
+
* stamped with `declaredAt`, `expiresAt`.
|
|
18
|
+
* • Inbound: `claim_rejected` event with conflict metadata.
|
|
19
|
+
*
|
|
20
|
+
* After the dual-engine collapse (step #36), this is the only
|
|
21
|
+
* ClaimStream factory in the SDK; the older compatibility path
|
|
22
|
+
* deletes.
|
|
23
|
+
*/
|
|
24
|
+
import type { SyncWebSocket } from './SyncWebSocket.js';
|
|
25
|
+
import type { ClaimStream } from '../types/streams.js';
|
|
26
|
+
export interface ClaimStreamConfig {
|
|
27
|
+
/** Identity used to filter our own active claims out of `others`. */
|
|
28
|
+
participantId: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AttachableClaimStream extends ClaimStream {
|
|
31
|
+
attach(transport: SyncWebSocket): void;
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
export declare function createClaimStream(config: ClaimStreamConfig, transport?: SyncWebSocket | null): AttachableClaimStream;
|