@abloatai/humans 0.61.0 → 0.63.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/dist/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/index.d.ts +1 -0
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +0 -3
- package/dist/local/InstanceCache.js +1 -1
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +26 -0
- package/dist/local/client/createModelOperations.js +85 -0
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +25 -11
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +40 -55
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- package/dist/local/transactions/mutations/MutationQueue.d.ts +6 -3
- package/dist/local/transactions/mutations/MutationQueue.js +36 -2
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
- package/dist/local/transactions/mutations/failureHandling.js +1 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +20 -0
- package/dist/presence/index.js +67 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +2 -0
- package/dist/react/useAblo.js +10 -5
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/package.json +2 -2
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/index.ts +1 -0
- package/src/local/BaseSyncedStore.ts +0 -6
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +140 -0
- package/src/local/client/reactiveEngine.ts +35 -14
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/createClaimStream.ts +51 -71
- package/src/local/sync/socketEventWiring.ts +1 -8
- package/src/local/transactions/mutations/MutationQueue.ts +26 -2
- package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
- package/src/local/transactions/mutations/failureHandling.ts +2 -1
- package/src/presence/index.ts +101 -0
- package/src/presence/readActivity.ts +149 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +14 -6
- package/src/react/usePresence.ts +88 -0
- package/src/react.ts +4 -0
- package/src/surface.ts +2 -0
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/presenceStream.ts +0 -279
|
@@ -172,7 +172,7 @@ export class BootstrapFetcher {
|
|
|
172
172
|
try {
|
|
173
173
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
174
174
|
method: 'GET',
|
|
175
|
-
headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken),
|
|
175
|
+
headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken, this.options.presenceSession),
|
|
176
176
|
});
|
|
177
177
|
if (!res.ok)
|
|
178
178
|
throw new Error(`schema read-back ${res.status}`);
|
|
@@ -570,7 +570,7 @@ export class BootstrapFetcher {
|
|
|
570
570
|
// conditional revalidation (If-None-Match) implement it at their own
|
|
571
571
|
// level where they own the cache-key namespace. The 304 branch below
|
|
572
572
|
// remains defensively in place for when a caller enables revalidation.
|
|
573
|
-
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
|
|
573
|
+
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken, this.options.presenceSession);
|
|
574
574
|
const controller = new AbortController();
|
|
575
575
|
this.activeControllers.set(controller, 'bootstrap');
|
|
576
576
|
try {
|
|
@@ -738,7 +738,7 @@ export class BootstrapFetcher {
|
|
|
738
738
|
'Content-Type': 'application/json',
|
|
739
739
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
740
740
|
Pragma: 'no-cache',
|
|
741
|
-
}, this.options.authToken),
|
|
741
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
742
742
|
signal: controller.signal,
|
|
743
743
|
cache: 'no-store', // Force browser to not cache
|
|
744
744
|
});
|
|
@@ -797,7 +797,7 @@ export class BootstrapFetcher {
|
|
|
797
797
|
method: 'GET',
|
|
798
798
|
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
799
799
|
'Content-Type': 'application/json',
|
|
800
|
-
}, this.options.authToken),
|
|
800
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
801
801
|
signal: controller.signal,
|
|
802
802
|
});
|
|
803
803
|
}
|
|
@@ -37,6 +37,7 @@ import type { LoadWhere, WhereClause } from '../query/types.js';
|
|
|
37
37
|
import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
|
|
38
38
|
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
39
39
|
import type { LogPositionPort } from '../logPosition.js';
|
|
40
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
40
41
|
export interface OnDemandLoaderOptions {
|
|
41
42
|
readonly objectPool: InstanceCache;
|
|
42
43
|
/**
|
|
@@ -53,6 +54,7 @@ export interface OnDemandLoaderOptions {
|
|
|
53
54
|
* propagate without re-instantiating the coordinator.
|
|
54
55
|
*/
|
|
55
56
|
readonly getAuthToken?: () => string | null;
|
|
57
|
+
readonly presenceSession?: PresenceSessionSource;
|
|
56
58
|
/** @deprecated Use `getAuthToken`. */
|
|
57
59
|
readonly getCapabilityToken?: () => string | null;
|
|
58
60
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
@@ -452,6 +452,7 @@ export class OnDemandLoader {
|
|
|
452
452
|
getAuthToken: this.authTokenProvider ?? undefined,
|
|
453
453
|
recoverCredential: this.credentialRecovery ?? undefined,
|
|
454
454
|
runtime: this.opts.runtime,
|
|
455
|
+
presenceSession: this.opts.presenceSession,
|
|
455
456
|
}, { queries: [query] });
|
|
456
457
|
const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
|
|
457
458
|
const evidence = result.evidence?.[0] ?? [];
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { type ClientSyncDelta } from '@abloatai/transaction/observation';
|
|
13
13
|
import { WsTransport, type WsTransportOptions, type EventMap, type DefaultCollaborationEvents } from '@abloatai/transaction/transport/websocket';
|
|
14
14
|
export type { CommitAck } from './commitFrames.js';
|
|
15
|
-
export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent,
|
|
15
|
+
export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent, CoreSyncEventMap, DefaultCollaborationEvents, EventMap, SyncWebSocketEventMap, } from '@abloatai/transaction/transport/websocket';
|
|
16
16
|
/**
|
|
17
17
|
* The wire delta the client receives. It is inferred from the canonical
|
|
18
18
|
* `clientSyncDeltaSchema` so the client and server share one contract rather
|
|
@@ -67,7 +67,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
67
67
|
protected resumeCursor(): string;
|
|
68
68
|
/**
|
|
69
69
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
70
|
-
* heartbeat start:
|
|
70
|
+
* heartbeat start: tell the server where we left off,
|
|
71
71
|
* request the deltas we missed, and start the catch-up poll.
|
|
72
72
|
*/
|
|
73
73
|
protected onOpened(): void;
|
|
@@ -121,19 +121,6 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
121
121
|
* Public wrapper for sending ack from outside the class
|
|
122
122
|
*/
|
|
123
123
|
acknowledge(syncId: number): void;
|
|
124
|
-
/**
|
|
125
|
-
* Send presence update to server.
|
|
126
|
-
* Use this for:
|
|
127
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
128
|
-
* - Manual status changes (away, custom status)
|
|
129
|
-
*
|
|
130
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
131
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
132
|
-
*
|
|
133
|
-
* @param status - "online", "away", or custom status string
|
|
134
|
-
* @param customStatus - Optional custom status message
|
|
135
|
-
*/
|
|
136
|
-
sendPresenceUpdate(status?: 'online' | 'away' | 'offline', customStatus?: string): void;
|
|
137
124
|
/**
|
|
138
125
|
* Stop the periodic catchup interval
|
|
139
126
|
*/
|
|
@@ -53,13 +53,10 @@ export class SyncWebSocket extends WsTransport {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
56
|
-
* heartbeat start:
|
|
56
|
+
* heartbeat start: tell the server where we left off,
|
|
57
57
|
* request the deltas we missed, and start the catch-up poll.
|
|
58
58
|
*/
|
|
59
59
|
onOpened() {
|
|
60
|
-
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
61
|
-
// this improves localTime accuracy by providing the user's actual timezone)
|
|
62
|
-
this.sendPresenceUpdate('online');
|
|
63
60
|
// Immediately request incremental sync based on our stored cursor.
|
|
64
61
|
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
65
62
|
// rejection hole (the catch never sees it); route failures through
|
|
@@ -224,38 +221,6 @@ export class SyncWebSocket extends WsTransport {
|
|
|
224
221
|
acknowledge(syncId) {
|
|
225
222
|
this.sendAck(syncId);
|
|
226
223
|
}
|
|
227
|
-
/**
|
|
228
|
-
* Send presence update to server.
|
|
229
|
-
* Use this for:
|
|
230
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
231
|
-
* - Manual status changes (away, custom status)
|
|
232
|
-
*
|
|
233
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
234
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
235
|
-
*
|
|
236
|
-
* @param status - "online", "away", or custom status string
|
|
237
|
-
* @param customStatus - Optional custom status message
|
|
238
|
-
*/
|
|
239
|
-
sendPresenceUpdate(status = 'online', customStatus) {
|
|
240
|
-
if (!this.isConnected())
|
|
241
|
-
return;
|
|
242
|
-
const timezone = (() => {
|
|
243
|
-
try {
|
|
244
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
return 'UTC';
|
|
248
|
-
}
|
|
249
|
-
})();
|
|
250
|
-
this.send({
|
|
251
|
-
type: 'presence_update',
|
|
252
|
-
payload: {
|
|
253
|
-
status,
|
|
254
|
-
timezone,
|
|
255
|
-
...(customStatus ? { customStatus } : {}),
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
224
|
/**
|
|
260
225
|
* Stop the periodic catchup interval
|
|
261
226
|
*/
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,12 +14,12 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
24
21
|
import type { WsTransport } from '@abloatai/transaction/transport/websocket';
|
|
22
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
25
23
|
import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '@abloatai/transaction/types/streams';
|
|
26
24
|
import { type Logger } from '@abloatai/transaction/logger';
|
|
27
25
|
/**
|
|
@@ -34,11 +32,13 @@ import { type Logger } from '@abloatai/transaction/logger';
|
|
|
34
32
|
*/
|
|
35
33
|
export type ClaimTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
|
|
36
34
|
export interface ClaimStreamConfig {
|
|
37
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
38
|
-
participantId: string;
|
|
39
35
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
40
36
|
logger?: Logger;
|
|
41
37
|
}
|
|
38
|
+
export interface ClaimPresenceSource {
|
|
39
|
+
readonly others: readonly PresenceSession[];
|
|
40
|
+
onChange(listener: () => void): () => void;
|
|
41
|
+
}
|
|
42
42
|
export interface AttachableClaimStream extends ClaimStream {
|
|
43
43
|
/**
|
|
44
44
|
* Mints the local handle and sends its `claim_begin` frame. The handle is a
|
|
@@ -54,16 +54,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
54
54
|
*/
|
|
55
55
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
56
56
|
attach(transport: ClaimTransport): void;
|
|
57
|
-
/**
|
|
58
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
59
|
-
* be built before identity is known — a hosted client learns who it is
|
|
60
|
-
* from its credential's scope during connect — and until then the
|
|
61
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
62
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
63
|
-
*/
|
|
64
|
-
setParticipant(participant: {
|
|
65
|
-
id: string;
|
|
66
|
-
}): void;
|
|
67
57
|
dispose(): void;
|
|
68
58
|
}
|
|
69
|
-
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null): AttachableClaimStream;
|
|
59
|
+
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null, presence?: ClaimPresenceSource | null): AttachableClaimStream;
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,8 +14,7 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
@@ -40,10 +37,7 @@ function claimLabel(type, id, field) {
|
|
|
40
37
|
* above a round trip, comfortably below the ttl/3 beat cadence.
|
|
41
38
|
*/
|
|
42
39
|
const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
|
|
43
|
-
export function createClaimStream(config, transport = null) {
|
|
44
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
45
|
-
// it is known; the own-claim filter always reads the current value.
|
|
46
|
-
let participantId = config.participantId;
|
|
40
|
+
export function createClaimStream(config, transport = null, presence = null) {
|
|
47
41
|
const logger = config.logger ?? noopLogger;
|
|
48
42
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
49
43
|
const activeByClaimId = new Map();
|
|
@@ -113,48 +107,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
113
107
|
if (attached)
|
|
114
108
|
return;
|
|
115
109
|
attached = t;
|
|
116
|
-
//
|
|
117
|
-
// active-claim set. Prune previous claims by holder, then
|
|
118
|
-
// re-add from the frame — the frame is authoritative for that
|
|
119
|
-
// participant's open claims at that moment.
|
|
120
|
-
unsubs.push(t.subscribe('presence_update', (event) => {
|
|
121
|
-
if (!event.userId)
|
|
122
|
-
return;
|
|
123
|
-
if (event.userId === participantId)
|
|
124
|
-
return;
|
|
125
|
-
let mutated = false;
|
|
126
|
-
if (event.kind === 'leave') {
|
|
127
|
-
for (const [id, claim] of activeByClaimId) {
|
|
128
|
-
if (claim.heldBy === event.userId) {
|
|
129
|
-
activeByClaimId.delete(id);
|
|
130
|
-
mutated = true;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (mutated)
|
|
134
|
-
notifyListeners();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
for (const [id, claim] of activeByClaimId) {
|
|
138
|
-
if (claim.heldBy === event.userId) {
|
|
139
|
-
activeByClaimId.delete(id);
|
|
140
|
-
mutated = true;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (const claim of event.activeClaims ?? []) {
|
|
144
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
145
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
146
|
-
// already removed the prior active entry; skipping the re-add
|
|
147
|
-
// drops it from `others`, which is what resolves a contender's
|
|
148
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
149
|
-
if (claim.status && claim.status !== 'active')
|
|
150
|
-
continue;
|
|
151
|
-
observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
|
|
152
|
-
mutated = true;
|
|
153
|
-
}
|
|
154
|
-
if (mutated)
|
|
155
|
-
notifyListeners();
|
|
156
|
-
}));
|
|
157
|
-
// (2) Server-side rejection frames.
|
|
110
|
+
// Server-side rejection frames.
|
|
158
111
|
unsubs.push(t.subscribe('claim_rejected', (rejection) => {
|
|
159
112
|
if (!rejection.claimId)
|
|
160
113
|
return;
|
|
@@ -290,6 +243,41 @@ export function createClaimStream(config, transport = null) {
|
|
|
290
243
|
}
|
|
291
244
|
if (transport)
|
|
292
245
|
attach(transport);
|
|
246
|
+
const refreshPresenceClaims = () => {
|
|
247
|
+
if (presence === null)
|
|
248
|
+
return;
|
|
249
|
+
activeByClaimId.clear();
|
|
250
|
+
for (const session of presence.others) {
|
|
251
|
+
for (const activity of session.activities) {
|
|
252
|
+
if (activity.operation !== 'claim'
|
|
253
|
+
|| activity.source !== 'claim'
|
|
254
|
+
|| activity.target.id === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
const claimId = activity.id.startsWith('claim:')
|
|
257
|
+
? activity.id.slice('claim:'.length)
|
|
258
|
+
: activity.id;
|
|
259
|
+
observeForeignClaim(session.participant.id, {
|
|
260
|
+
claimId,
|
|
261
|
+
entityType: activity.target.model,
|
|
262
|
+
entityId: activity.target.id,
|
|
263
|
+
...(activity.target.field !== undefined
|
|
264
|
+
? { field: activity.target.field }
|
|
265
|
+
: {}),
|
|
266
|
+
...(activity.target.fields !== undefined
|
|
267
|
+
? { fields: activity.target.fields }
|
|
268
|
+
: {}),
|
|
269
|
+
description: 'claim',
|
|
270
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
271
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
272
|
+
}, session.participant.kind);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
notifyListeners();
|
|
276
|
+
};
|
|
277
|
+
if (presence !== null) {
|
|
278
|
+
refreshPresenceClaims();
|
|
279
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
280
|
+
}
|
|
293
281
|
// ── Outbound ────────────────────────────────────────────────────
|
|
294
282
|
function sendBegin(claimId, claim) {
|
|
295
283
|
if (!attached?.isConnected())
|
|
@@ -475,9 +463,6 @@ export function createClaimStream(config, transport = null) {
|
|
|
475
463
|
}, () => claimsSnapshot);
|
|
476
464
|
},
|
|
477
465
|
attach,
|
|
478
|
-
setParticipant(participant) {
|
|
479
|
-
participantId = participant.id;
|
|
480
|
-
},
|
|
481
466
|
dispose() {
|
|
482
467
|
for (const off of unsubs)
|
|
483
468
|
off();
|
|
@@ -5,7 +5,7 @@ import type { InstanceCache } from '../InstanceCache.js';
|
|
|
5
5
|
import type { ConnectionManager } from './ConnectionManager.js';
|
|
6
6
|
import type { SubscriptionManager } from './SubscriptionManager.js';
|
|
7
7
|
import type { SyncStatus } from '../storeContract.js';
|
|
8
|
-
import type { BootstrapHint, BootstrapDataEvent,
|
|
8
|
+
import type { BootstrapHint, BootstrapDataEvent, SyncWebSocket, EventMap } from './SyncWebSocket.js';
|
|
9
9
|
import type { SyncDelta } from './SyncWebSocket.js';
|
|
10
10
|
export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>> {
|
|
11
11
|
syncWebSocket: SyncWebSocket<TCollaboration>;
|
|
@@ -23,7 +23,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
23
23
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
24
24
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
25
25
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
26
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
27
26
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
28
27
|
handleTerminalSessionError(error: Error): void;
|
|
29
28
|
nudgeReconnect(): void;
|
|
@@ -43,10 +43,6 @@ export function wireSocketEvents(deps) {
|
|
|
43
43
|
const data = args[0];
|
|
44
44
|
deps.handleBootstrapData(data);
|
|
45
45
|
});
|
|
46
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
47
|
-
const data = args[0];
|
|
48
|
-
deps.handlePresenceUpdate(data);
|
|
49
|
-
});
|
|
50
46
|
// Error events
|
|
51
47
|
const onError = deps.syncWebSocket.subscribe('error', (error) => {
|
|
52
48
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -126,5 +122,5 @@ export function wireSocketEvents(deps) {
|
|
|
126
122
|
deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
|
|
127
123
|
deps.updateSyncStatus({ state: 'reconnecting' });
|
|
128
124
|
});
|
|
129
|
-
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData,
|
|
125
|
+
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
|
|
130
126
|
}
|
|
@@ -116,6 +116,8 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
116
116
|
private isProcessing;
|
|
117
117
|
private processTimer?;
|
|
118
118
|
private processScheduled;
|
|
119
|
+
private disposed;
|
|
120
|
+
private retryTimers;
|
|
119
121
|
private createdTransactions;
|
|
120
122
|
private commitScheduled;
|
|
121
123
|
private inFlightByModel;
|
|
@@ -343,6 +345,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
343
345
|
*/
|
|
344
346
|
onDeltaReceived(syncId: number, _transactionId?: string, correlationId?: string): void;
|
|
345
347
|
private scheduleDeltaConfirmationTimeout;
|
|
348
|
+
private scheduleRetry;
|
|
346
349
|
/**
|
|
347
350
|
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
348
351
|
* The confirming delta's timeout is handled by
|
|
@@ -476,7 +479,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
476
479
|
awaitingDeltaCount: number;
|
|
477
480
|
awaitingDeltaTransactions: {
|
|
478
481
|
id: string;
|
|
479
|
-
type: "
|
|
482
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
480
483
|
modelName: string;
|
|
481
484
|
modelId: string;
|
|
482
485
|
syncIdNeeded: number | undefined;
|
|
@@ -485,13 +488,13 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
485
488
|
}[];
|
|
486
489
|
pendingTransactions: {
|
|
487
490
|
id: string;
|
|
488
|
-
type: "
|
|
491
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
489
492
|
modelName: string;
|
|
490
493
|
modelId: string;
|
|
491
494
|
}[];
|
|
492
495
|
executingTransactions: {
|
|
493
496
|
id: string;
|
|
494
|
-
type: "
|
|
497
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
495
498
|
modelName: string;
|
|
496
499
|
modelId: string;
|
|
497
500
|
}[];
|
|
@@ -74,6 +74,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
74
74
|
isProcessing = false;
|
|
75
75
|
processTimer;
|
|
76
76
|
processScheduled = false;
|
|
77
|
+
disposed = false;
|
|
78
|
+
retryTimers = new Set();
|
|
77
79
|
// Staging area for transactions created in the same event-loop tick. Each one
|
|
78
80
|
// lands here first, then a microtask commits them together.
|
|
79
81
|
createdTransactions = [];
|
|
@@ -149,11 +151,14 @@ export class MutationQueue extends EventEmitter {
|
|
|
149
151
|
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
150
152
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
151
153
|
scheduleRetry: (delayMs) => {
|
|
154
|
+
if (this.disposed)
|
|
155
|
+
return;
|
|
152
156
|
if (this.commitRetryTimer !== null)
|
|
153
157
|
clearTimeout(this.commitRetryTimer);
|
|
154
158
|
this.commitRetryTimer = setTimeout(() => {
|
|
155
159
|
this.commitRetryTimer = null;
|
|
156
|
-
|
|
160
|
+
if (!this.disposed)
|
|
161
|
+
void this.processCommitLane();
|
|
157
162
|
}, delayMs);
|
|
158
163
|
},
|
|
159
164
|
emitCommitLifecycle: (event, payload) => { this.emitCommitLifecycle(event, payload); },
|
|
@@ -276,6 +281,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
276
281
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
277
282
|
rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
|
|
278
283
|
enqueue: (transaction) => { this.enqueue(transaction); },
|
|
284
|
+
scheduleRetry: (callback, delayMs) => { this.scheduleRetry(callback, delayMs); },
|
|
279
285
|
getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
|
|
280
286
|
setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
|
|
281
287
|
emit: (event, payload) => this.emit(event, payload),
|
|
@@ -655,6 +661,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
655
661
|
* optimistic state, or remove the durable replay envelope.
|
|
656
662
|
*/
|
|
657
663
|
scheduleReplicationLagTimeout(transactionId, clientTxId = transactionId, correlationId) {
|
|
664
|
+
if (this.disposed)
|
|
665
|
+
return;
|
|
658
666
|
const previous = this.replicationLagTimeouts.get(transactionId);
|
|
659
667
|
if (previous)
|
|
660
668
|
clearTimeout(previous);
|
|
@@ -846,6 +854,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
846
854
|
* {@link drainPending} resumes the work when the owner decides to drain.
|
|
847
855
|
*/
|
|
848
856
|
setConnectionState(state) {
|
|
857
|
+
if (this.disposed)
|
|
858
|
+
return;
|
|
849
859
|
if (state === 'connected') {
|
|
850
860
|
if (this.commitOfflineGraceTimer !== null) {
|
|
851
861
|
clearTimeout(this.commitOfflineGraceTimer);
|
|
@@ -1013,12 +1023,16 @@ export class MutationQueue extends EventEmitter {
|
|
|
1013
1023
|
enqueueTransaction(this.queueCoalescingContext, transaction);
|
|
1014
1024
|
}
|
|
1015
1025
|
scheduleProcessing(immediate = false) {
|
|
1026
|
+
if (this.disposed)
|
|
1027
|
+
return;
|
|
1016
1028
|
scheduleProcessingExternal(this.processingSchedulerContext, immediate);
|
|
1017
1029
|
}
|
|
1018
1030
|
async processBatch() {
|
|
1031
|
+
if (this.disposed)
|
|
1032
|
+
return;
|
|
1019
1033
|
if (this.modelProcessingPromise) {
|
|
1020
1034
|
await this.modelProcessingPromise;
|
|
1021
|
-
if (this.executionQueue.length > 0)
|
|
1035
|
+
if (!this.disposed && this.executionQueue.length > 0)
|
|
1022
1036
|
await this.processBatch();
|
|
1023
1037
|
return;
|
|
1024
1038
|
}
|
|
@@ -1103,8 +1117,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
1103
1117
|
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1104
1118
|
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1105
1119
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1120
|
+
if (this.disposed)
|
|
1121
|
+
return;
|
|
1106
1122
|
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1107
1123
|
}
|
|
1124
|
+
scheduleRetry(callback, delayMs) {
|
|
1125
|
+
if (this.disposed)
|
|
1126
|
+
return;
|
|
1127
|
+
const timer = setTimeout(() => {
|
|
1128
|
+
this.retryTimers.delete(timer);
|
|
1129
|
+
if (!this.disposed)
|
|
1130
|
+
callback();
|
|
1131
|
+
}, delayMs);
|
|
1132
|
+
this.retryTimers.add(timer);
|
|
1133
|
+
}
|
|
1108
1134
|
/**
|
|
1109
1135
|
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1110
1136
|
* The confirming delta's timeout is handled by
|
|
@@ -1156,6 +1182,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
1156
1182
|
return enqueueCommit(this.commitApiContext, clientTxId, operations, options);
|
|
1157
1183
|
}
|
|
1158
1184
|
async processCommitLane() {
|
|
1185
|
+
if (this.disposed)
|
|
1186
|
+
return;
|
|
1159
1187
|
await processCommitLane(this.commitLaneContext);
|
|
1160
1188
|
}
|
|
1161
1189
|
waitForCommitReceipt(clientTxId) {
|
|
@@ -1388,6 +1416,9 @@ export class MutationQueue extends EventEmitter {
|
|
|
1388
1416
|
* clears all timers and stored transactions, and removes event listeners.
|
|
1389
1417
|
*/
|
|
1390
1418
|
dispose() {
|
|
1419
|
+
if (this.disposed)
|
|
1420
|
+
return;
|
|
1421
|
+
this.disposed = true;
|
|
1391
1422
|
// Cancel all active optimistic updates
|
|
1392
1423
|
for (const [, optimistic] of this.localMutationPort.updates) {
|
|
1393
1424
|
this.emit('optimistic:rollback', {
|
|
@@ -1419,6 +1450,9 @@ export class MutationQueue extends EventEmitter {
|
|
|
1419
1450
|
clearTimeout(this.commitRetryTimer);
|
|
1420
1451
|
this.commitRetryTimer = null;
|
|
1421
1452
|
}
|
|
1453
|
+
for (const timer of this.retryTimers)
|
|
1454
|
+
clearTimeout(timer);
|
|
1455
|
+
this.retryTimers.clear();
|
|
1422
1456
|
// Clear store
|
|
1423
1457
|
this.store.clear();
|
|
1424
1458
|
this.localMutationPort.updates.clear();
|
|
@@ -40,6 +40,7 @@ export declare class DeltaConfirmationTracker {
|
|
|
40
40
|
private static readonly DELTA_MAX_TIMEOUT_MS;
|
|
41
41
|
private deltaConfirmationTimeouts;
|
|
42
42
|
private deltaConfirmationRetries;
|
|
43
|
+
private disposed;
|
|
43
44
|
private readonly runtime;
|
|
44
45
|
constructor(ctx: DeltaConfirmationContext);
|
|
45
46
|
/** Applied-cursor alias, kept so the read sites below stay legible. */
|
|
@@ -20,6 +20,7 @@ export class DeltaConfirmationTracker {
|
|
|
20
20
|
deltaConfirmationTimeouts = new Map();
|
|
21
21
|
// Track retry attempts per transaction for exponential backoff
|
|
22
22
|
deltaConfirmationRetries = new Map();
|
|
23
|
+
disposed = false;
|
|
23
24
|
runtime;
|
|
24
25
|
constructor(ctx) {
|
|
25
26
|
this.ctx = ctx;
|
|
@@ -113,6 +114,8 @@ export class DeltaConfirmationTracker {
|
|
|
113
114
|
// server has already confirmed. A rollback happens only on an explicit server
|
|
114
115
|
// rejection, never on a timeout.
|
|
115
116
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
117
|
+
if (this.disposed)
|
|
118
|
+
return;
|
|
116
119
|
// Cancel any existing timeout for this transaction
|
|
117
120
|
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
118
121
|
// Deliberately not an async callback: the body is fully synchronous, and
|
|
@@ -226,6 +229,7 @@ export class DeltaConfirmationTracker {
|
|
|
226
229
|
* would keep the process alive and fire callbacks against a cleared store.
|
|
227
230
|
*/
|
|
228
231
|
dispose() {
|
|
232
|
+
this.disposed = true;
|
|
229
233
|
for (const timeoutHandle of this.deltaConfirmationTimeouts.values()) {
|
|
230
234
|
clearTimeout(timeoutHandle);
|
|
231
235
|
}
|
|
@@ -9,6 +9,7 @@ export interface FailureHandlingContext {
|
|
|
9
9
|
readonly isPermanentError: (error: Error) => boolean;
|
|
10
10
|
readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
|
|
11
11
|
readonly enqueue: (transaction: QueuedMutation) => void;
|
|
12
|
+
readonly scheduleRetry: (callback: () => void, delayMs: number) => void;
|
|
12
13
|
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
13
14
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
14
15
|
readonly emit: (event: string, payload: object) => boolean;
|
|
@@ -54,7 +54,7 @@ export async function handleFailure(ctx, transaction, error) {
|
|
|
54
54
|
// stall unrelated commits.
|
|
55
55
|
const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
|
|
56
56
|
ctx.store.updateStatus(transaction.id, 'pending');
|
|
57
|
-
|
|
57
|
+
ctx.scheduleRetry(() => {
|
|
58
58
|
// The queue may have shut down or the tx may have been settled
|
|
59
59
|
// (e.g. delta-confirmed) while we backed off.
|
|
60
60
|
if (ctx.store.get(transaction.id)?.status !== 'pending')
|
|
@@ -27,8 +27,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
27
27
|
export declare const persistedTransactionSchema: z.ZodObject<{
|
|
28
28
|
id: z.ZodString;
|
|
29
29
|
type: z.ZodEnum<{
|
|
30
|
-
update: "update";
|
|
31
30
|
create: "create";
|
|
31
|
+
update: "update";
|
|
32
32
|
delete: "delete";
|
|
33
33
|
archive: "archive";
|
|
34
34
|
unarchive: "unarchive";
|
|
@@ -95,8 +95,8 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
|
|
|
95
95
|
export declare const persistedMutationSchema: z.ZodObject<{
|
|
96
96
|
mutationId: z.ZodOptional<z.ZodString>;
|
|
97
97
|
type: z.ZodEnum<{
|
|
98
|
-
update: "update";
|
|
99
98
|
create: "create";
|
|
99
|
+
update: "update";
|
|
100
100
|
delete: "delete";
|
|
101
101
|
archive: "archive";
|
|
102
102
|
}>;
|
|
@@ -138,8 +138,8 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
|
138
138
|
type: z.ZodLiteral<"pending_mutation">;
|
|
139
139
|
mutation: z.ZodObject<{
|
|
140
140
|
type: z.ZodEnum<{
|
|
141
|
-
update: "update";
|
|
142
141
|
create: "create";
|
|
142
|
+
update: "update";
|
|
143
143
|
delete: "delete";
|
|
144
144
|
archive: "archive";
|
|
145
145
|
}>;
|
|
@@ -180,8 +180,8 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
|
180
180
|
type: z.ZodLiteral<"pending_mutation">;
|
|
181
181
|
mutation: z.ZodObject<{
|
|
182
182
|
type: z.ZodEnum<{
|
|
183
|
-
update: "update";
|
|
184
183
|
create: "create";
|
|
184
|
+
update: "update";
|
|
185
185
|
delete: "delete";
|
|
186
186
|
archive: "archive";
|
|
187
187
|
}>;
|