@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
|
@@ -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,16 +14,15 @@
|
|
|
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
|
|
|
25
22
|
import type {
|
|
26
23
|
WsTransport,
|
|
27
|
-
PresenceUpdate,
|
|
28
24
|
} from '@abloatai/transaction/transport/websocket';
|
|
25
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
29
26
|
import type {
|
|
30
27
|
ClaimOptions,
|
|
31
28
|
ClaimTarget,
|
|
@@ -79,12 +76,15 @@ function claimLabel(type: string, id: string, field?: string): string {
|
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
export interface ClaimStreamConfig {
|
|
82
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
83
|
-
participantId: string;
|
|
84
79
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
85
80
|
logger?: Logger;
|
|
86
81
|
}
|
|
87
82
|
|
|
83
|
+
export interface ClaimPresenceSource {
|
|
84
|
+
readonly others: readonly PresenceSession[];
|
|
85
|
+
onChange(listener: () => void): () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
88
|
/**
|
|
89
89
|
* How long a heartbeat waits for its `claim_heartbeat_ack` before giving up
|
|
90
90
|
* as transient (the auto-heartbeat loop's next tick retries). Comfortably
|
|
@@ -107,14 +107,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
107
107
|
*/
|
|
108
108
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
109
109
|
attach(transport: ClaimTransport): void;
|
|
110
|
-
/**
|
|
111
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
112
|
-
* be built before identity is known — a hosted client learns who it is
|
|
113
|
-
* from its credential's scope during connect — and until then the
|
|
114
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
115
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
116
|
-
*/
|
|
117
|
-
setParticipant(participant: { id: string }): void;
|
|
118
110
|
dispose(): void;
|
|
119
111
|
}
|
|
120
112
|
|
|
@@ -144,10 +136,8 @@ interface OwnGrant {
|
|
|
144
136
|
export function createClaimStream(
|
|
145
137
|
config: ClaimStreamConfig,
|
|
146
138
|
transport: ClaimTransport | null = null,
|
|
139
|
+
presence: ClaimPresenceSource | null = null,
|
|
147
140
|
): AttachableClaimStream {
|
|
148
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
149
|
-
// it is known; the own-claim filter always reads the current value.
|
|
150
|
-
let participantId = config.participantId;
|
|
151
141
|
const logger = config.logger ?? noopLogger;
|
|
152
142
|
|
|
153
143
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
@@ -247,54 +237,7 @@ export function createClaimStream(
|
|
|
247
237
|
if (attached) return;
|
|
248
238
|
attached = t;
|
|
249
239
|
|
|
250
|
-
//
|
|
251
|
-
// active-claim set. Prune previous claims by holder, then
|
|
252
|
-
// re-add from the frame — the frame is authoritative for that
|
|
253
|
-
// participant's open claims at that moment.
|
|
254
|
-
unsubs.push(
|
|
255
|
-
t.subscribe('presence_update', (event: PresenceUpdate) => {
|
|
256
|
-
if (!event.userId) return;
|
|
257
|
-
if (event.userId === participantId) return;
|
|
258
|
-
|
|
259
|
-
let mutated = false;
|
|
260
|
-
|
|
261
|
-
if (event.kind === 'leave') {
|
|
262
|
-
for (const [id, claim] of activeByClaimId) {
|
|
263
|
-
if (claim.heldBy === event.userId) {
|
|
264
|
-
activeByClaimId.delete(id);
|
|
265
|
-
mutated = true;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (mutated) notifyListeners();
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
for (const [id, claim] of activeByClaimId) {
|
|
273
|
-
if (claim.heldBy === event.userId) {
|
|
274
|
-
activeByClaimId.delete(id);
|
|
275
|
-
mutated = true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
for (const claim of event.activeClaims ?? []) {
|
|
279
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
280
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
281
|
-
// already removed the prior active entry; skipping the re-add
|
|
282
|
-
// drops it from `others`, which is what resolves a contender's
|
|
283
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
284
|
-
if (claim.status && claim.status !== 'active') continue;
|
|
285
|
-
observeForeignClaim(
|
|
286
|
-
event.userId,
|
|
287
|
-
claim,
|
|
288
|
-
event.participantKind,
|
|
289
|
-
event.isAgent,
|
|
290
|
-
);
|
|
291
|
-
mutated = true;
|
|
292
|
-
}
|
|
293
|
-
if (mutated) notifyListeners();
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
// (2) Server-side rejection frames.
|
|
240
|
+
// Server-side rejection frames.
|
|
298
241
|
unsubs.push(
|
|
299
242
|
t.subscribe('claim_rejected', (rejection) => {
|
|
300
243
|
if (!rejection.claimId) return;
|
|
@@ -476,6 +419,46 @@ export function createClaimStream(
|
|
|
476
419
|
|
|
477
420
|
if (transport) attach(transport);
|
|
478
421
|
|
|
422
|
+
const refreshPresenceClaims = (): void => {
|
|
423
|
+
if (presence === null) return;
|
|
424
|
+
activeByClaimId.clear();
|
|
425
|
+
for (const session of presence.others) {
|
|
426
|
+
for (const activity of session.activities) {
|
|
427
|
+
if (
|
|
428
|
+
activity.operation !== 'claim'
|
|
429
|
+
|| activity.source !== 'claim'
|
|
430
|
+
|| activity.target.id === undefined
|
|
431
|
+
) continue;
|
|
432
|
+
const claimId = activity.id.startsWith('claim:')
|
|
433
|
+
? activity.id.slice('claim:'.length)
|
|
434
|
+
: activity.id;
|
|
435
|
+
observeForeignClaim(
|
|
436
|
+
session.participant.id,
|
|
437
|
+
{
|
|
438
|
+
claimId,
|
|
439
|
+
entityType: activity.target.model,
|
|
440
|
+
entityId: activity.target.id,
|
|
441
|
+
...(activity.target.field !== undefined
|
|
442
|
+
? { field: activity.target.field }
|
|
443
|
+
: {}),
|
|
444
|
+
...(activity.target.fields !== undefined
|
|
445
|
+
? { fields: activity.target.fields }
|
|
446
|
+
: {}),
|
|
447
|
+
description: 'claim',
|
|
448
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
449
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
450
|
+
},
|
|
451
|
+
session.participant.kind,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
notifyListeners();
|
|
456
|
+
};
|
|
457
|
+
if (presence !== null) {
|
|
458
|
+
refreshPresenceClaims();
|
|
459
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
460
|
+
}
|
|
461
|
+
|
|
479
462
|
// ── Outbound ────────────────────────────────────────────────────
|
|
480
463
|
function sendBegin(claimId: string, claim: OwnClaim): void {
|
|
481
464
|
if (!attached?.isConnected()) return;
|
|
@@ -705,9 +688,6 @@ export function createClaimStream(
|
|
|
705
688
|
);
|
|
706
689
|
},
|
|
707
690
|
attach,
|
|
708
|
-
setParticipant(participant: { id: string }): void {
|
|
709
|
-
participantId = participant.id;
|
|
710
|
-
},
|
|
711
691
|
dispose(): void {
|
|
712
692
|
for (const off of unsubs) off();
|
|
713
693
|
unsubs.length = 0;
|
|
@@ -9,7 +9,6 @@ import type { SyncStatus } from '../storeContract.js';
|
|
|
9
9
|
import type {
|
|
10
10
|
BootstrapHint,
|
|
11
11
|
BootstrapDataEvent,
|
|
12
|
-
PresenceUpdate,
|
|
13
12
|
SyncWebSocket,
|
|
14
13
|
EventMap,
|
|
15
14
|
} from './SyncWebSocket.js';
|
|
@@ -31,7 +30,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
31
30
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
32
31
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
33
32
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
34
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
35
33
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
36
34
|
handleTerminalSessionError(error: Error): void;
|
|
37
35
|
nudgeReconnect(): void;
|
|
@@ -93,11 +91,6 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
93
91
|
deps.handleBootstrapData(data);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
97
|
-
const data = args[0];
|
|
98
|
-
deps.handlePresenceUpdate(data);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
94
|
// Error events
|
|
102
95
|
const onError = deps.syncWebSocket.subscribe('error', (error: Error) => {
|
|
103
96
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -189,7 +182,7 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
189
182
|
deps.disposers.push(
|
|
190
183
|
onConnected, onDisconnected, onReconnecting,
|
|
191
184
|
onDelta, onDeltaBatch, onBootstrapRequired,
|
|
192
|
-
onBootstrapData,
|
|
185
|
+
onBootstrapData,
|
|
193
186
|
onError, onSessionError, onHandshakeFailed, onReconnectFailed,
|
|
194
187
|
() => { deps.areaOfInterest.dispose(); },
|
|
195
188
|
);
|
|
@@ -246,6 +246,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
246
246
|
private isProcessing = false;
|
|
247
247
|
private processTimer?: NodeJS.Timeout;
|
|
248
248
|
private processScheduled = false;
|
|
249
|
+
private disposed = false;
|
|
250
|
+
private retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
249
251
|
|
|
250
252
|
// Staging area for transactions created in the same event-loop tick. Each one
|
|
251
253
|
// lands here first, then a microtask commits them together.
|
|
@@ -331,10 +333,11 @@ export class MutationQueue extends EventEmitter {
|
|
|
331
333
|
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
332
334
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
333
335
|
scheduleRetry: (delayMs) => {
|
|
336
|
+
if (this.disposed) return;
|
|
334
337
|
if (this.commitRetryTimer !== null) clearTimeout(this.commitRetryTimer);
|
|
335
338
|
this.commitRetryTimer = setTimeout(() => {
|
|
336
339
|
this.commitRetryTimer = null;
|
|
337
|
-
void this.processCommitLane();
|
|
340
|
+
if (!this.disposed) void this.processCommitLane();
|
|
338
341
|
}, delayMs);
|
|
339
342
|
},
|
|
340
343
|
emitCommitLifecycle: (event, payload) => { this.emitCommitLifecycle(event, payload); },
|
|
@@ -463,6 +466,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
463
466
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
464
467
|
rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
|
|
465
468
|
enqueue: (transaction) => { this.enqueue(transaction); },
|
|
469
|
+
scheduleRetry: (callback, delayMs) => { this.scheduleRetry(callback, delayMs); },
|
|
466
470
|
getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
|
|
467
471
|
setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
|
|
468
472
|
emit: (event, payload) => this.emit(event, payload),
|
|
@@ -968,6 +972,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
968
972
|
clientTxId = transactionId,
|
|
969
973
|
correlationId?: string,
|
|
970
974
|
): void {
|
|
975
|
+
if (this.disposed) return;
|
|
971
976
|
const previous = this.replicationLagTimeouts.get(transactionId);
|
|
972
977
|
if (previous) clearTimeout(previous);
|
|
973
978
|
this.replicationLagErrors.delete(transactionId);
|
|
@@ -1182,6 +1187,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
1182
1187
|
* {@link drainPending} resumes the work when the owner decides to drain.
|
|
1183
1188
|
*/
|
|
1184
1189
|
setConnectionState(state: 'connected' | 'disconnected'): void {
|
|
1190
|
+
if (this.disposed) return;
|
|
1185
1191
|
if (state === 'connected') {
|
|
1186
1192
|
if (this.commitOfflineGraceTimer !== null) {
|
|
1187
1193
|
clearTimeout(this.commitOfflineGraceTimer);
|
|
@@ -1400,13 +1406,15 @@ export class MutationQueue extends EventEmitter {
|
|
|
1400
1406
|
}
|
|
1401
1407
|
|
|
1402
1408
|
private scheduleProcessing(immediate = false): void {
|
|
1409
|
+
if (this.disposed) return;
|
|
1403
1410
|
scheduleProcessingExternal(this.processingSchedulerContext, immediate);
|
|
1404
1411
|
}
|
|
1405
1412
|
|
|
1406
1413
|
private async processBatch(): Promise<void> {
|
|
1414
|
+
if (this.disposed) return;
|
|
1407
1415
|
if (this.modelProcessingPromise) {
|
|
1408
1416
|
await this.modelProcessingPromise;
|
|
1409
|
-
if (this.executionQueue.length > 0) await this.processBatch();
|
|
1417
|
+
if (!this.disposed && this.executionQueue.length > 0) await this.processBatch();
|
|
1410
1418
|
return;
|
|
1411
1419
|
}
|
|
1412
1420
|
const processing = processBatch(this.batchProcessingContext);
|
|
@@ -1500,9 +1508,19 @@ export class MutationQueue extends EventEmitter {
|
|
|
1500
1508
|
// Schedule the retry-and-reconciliation wait for a transaction's confirming
|
|
1501
1509
|
// delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
|
|
1502
1510
|
private scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
|
|
1511
|
+
if (this.disposed) return;
|
|
1503
1512
|
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1504
1513
|
}
|
|
1505
1514
|
|
|
1515
|
+
private scheduleRetry(callback: () => void, delayMs: number): void {
|
|
1516
|
+
if (this.disposed) return;
|
|
1517
|
+
const timer = setTimeout(() => {
|
|
1518
|
+
this.retryTimers.delete(timer);
|
|
1519
|
+
if (!this.disposed) callback();
|
|
1520
|
+
}, delayMs);
|
|
1521
|
+
this.retryTimers.add(timer);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1506
1524
|
/**
|
|
1507
1525
|
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
1508
1526
|
* The confirming delta's timeout is handled by
|
|
@@ -1566,6 +1584,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
1566
1584
|
}
|
|
1567
1585
|
|
|
1568
1586
|
private async processCommitLane(): Promise<void> {
|
|
1587
|
+
if (this.disposed) return;
|
|
1569
1588
|
await processCommitLane(this.commitLaneContext);
|
|
1570
1589
|
}
|
|
1571
1590
|
|
|
@@ -1854,6 +1873,9 @@ export class MutationQueue extends EventEmitter {
|
|
|
1854
1873
|
* clears all timers and stored transactions, and removes event listeners.
|
|
1855
1874
|
*/
|
|
1856
1875
|
dispose(): void {
|
|
1876
|
+
if (this.disposed) return;
|
|
1877
|
+
this.disposed = true;
|
|
1878
|
+
|
|
1857
1879
|
// Cancel all active optimistic updates
|
|
1858
1880
|
for (const [, optimistic] of this.localMutationPort.updates) {
|
|
1859
1881
|
this.emit('optimistic:rollback', {
|
|
@@ -1888,6 +1910,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
1888
1910
|
clearTimeout(this.commitRetryTimer);
|
|
1889
1911
|
this.commitRetryTimer = null;
|
|
1890
1912
|
}
|
|
1913
|
+
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
1914
|
+
this.retryTimers.clear();
|
|
1891
1915
|
|
|
1892
1916
|
// Clear store
|
|
1893
1917
|
this.store.clear();
|
|
@@ -49,6 +49,7 @@ export class DeltaConfirmationTracker {
|
|
|
49
49
|
|
|
50
50
|
// Track retry attempts per transaction for exponential backoff
|
|
51
51
|
private deltaConfirmationRetries = new Map<string, number>();
|
|
52
|
+
private disposed = false;
|
|
52
53
|
|
|
53
54
|
private readonly runtime: RuntimeContext;
|
|
54
55
|
|
|
@@ -162,6 +163,7 @@ export class DeltaConfirmationTracker {
|
|
|
162
163
|
// server has already confirmed. A rollback happens only on an explicit server
|
|
163
164
|
// rejection, never on a timeout.
|
|
164
165
|
scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
|
|
166
|
+
if (this.disposed) return;
|
|
165
167
|
// Cancel any existing timeout for this transaction
|
|
166
168
|
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
167
169
|
|
|
@@ -289,6 +291,7 @@ export class DeltaConfirmationTracker {
|
|
|
289
291
|
* would keep the process alive and fire callbacks against a cleared store.
|
|
290
292
|
*/
|
|
291
293
|
dispose(): void {
|
|
294
|
+
this.disposed = true;
|
|
292
295
|
for (const timeoutHandle of this.deltaConfirmationTimeouts.values()) {
|
|
293
296
|
clearTimeout(timeoutHandle);
|
|
294
297
|
}
|
|
@@ -19,6 +19,7 @@ export interface FailureHandlingContext {
|
|
|
19
19
|
error?: Error,
|
|
20
20
|
) => Promise<void>;
|
|
21
21
|
readonly enqueue: (transaction: QueuedMutation) => void;
|
|
22
|
+
readonly scheduleRetry: (callback: () => void, delayMs: number) => void;
|
|
22
23
|
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
23
24
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
24
25
|
readonly emit: (event: string, payload: object) => boolean;
|
|
@@ -102,7 +103,7 @@ export async function handleFailure(
|
|
|
102
103
|
);
|
|
103
104
|
|
|
104
105
|
ctx.store.updateStatus(transaction.id, 'pending');
|
|
105
|
-
|
|
106
|
+
ctx.scheduleRetry(() => {
|
|
106
107
|
// The queue may have shut down or the tx may have been settled
|
|
107
108
|
// (e.g. delta-confirmed) while we backed off.
|
|
108
109
|
if (ctx.store.get(transaction.id)?.status !== 'pending') return;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPresenceProjection,
|
|
3
|
+
type PresenceProjection,
|
|
4
|
+
type PresenceProjectionEvents,
|
|
5
|
+
type PresenceView,
|
|
6
|
+
} from '@abloatai/transaction/presence';
|
|
7
|
+
import type { PresenceTarget } from '@abloatai/transaction/presence';
|
|
8
|
+
import {
|
|
9
|
+
startReadActivity,
|
|
10
|
+
type ReadActivityLifetime,
|
|
11
|
+
type ReadActivityTransport,
|
|
12
|
+
} from './readActivity.js';
|
|
13
|
+
|
|
14
|
+
type PresenceTransport = PresenceProjectionEvents & ReadActivityTransport;
|
|
15
|
+
|
|
16
|
+
/** Reactive-client presence backed by the client's existing live connection. */
|
|
17
|
+
export interface ReactivePresence extends PresenceView {
|
|
18
|
+
forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
|
|
19
|
+
onChange(listener: () => void): () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Lifecycle hooks kept inside the humans composition boundary. */
|
|
23
|
+
export interface AttachablePresence extends ReactivePresence {
|
|
24
|
+
attach(transport: PresenceTransport): void;
|
|
25
|
+
startRead(target: PresenceTarget): () => void;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const clientPresence = new WeakMap<object, ReactivePresence>();
|
|
30
|
+
|
|
31
|
+
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
32
|
+
export function attachPresenceToClient(client: object, presence: ReactivePresence): void {
|
|
33
|
+
clientPresence.set(client, presence);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function presenceOfClient(client: object): ReactivePresence {
|
|
37
|
+
const presence = clientPresence.get(client);
|
|
38
|
+
if (presence === undefined) throw new Error('presence is not attached to this client');
|
|
39
|
+
return presence;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPresence(
|
|
43
|
+
transport: PresenceTransport | null = null,
|
|
44
|
+
): AttachablePresence {
|
|
45
|
+
let projection: PresenceProjection | null = null;
|
|
46
|
+
let attachedTransport: PresenceTransport | null = null;
|
|
47
|
+
const listeners = new Set<() => void>();
|
|
48
|
+
const reads = new Set<ReadActivityLifetime>();
|
|
49
|
+
let unsubscribe: (() => void) | null = null;
|
|
50
|
+
|
|
51
|
+
const notify = (): void => {
|
|
52
|
+
for (const listener of listeners) listener();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const attach = (events: PresenceTransport): void => {
|
|
56
|
+
if (projection !== null) return;
|
|
57
|
+
attachedTransport = events;
|
|
58
|
+
projection = createPresenceProjection(events);
|
|
59
|
+
unsubscribe = projection.subscribe(notify);
|
|
60
|
+
notify();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (transport !== null) attach(transport);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
get active() { return projection?.active ?? []; },
|
|
67
|
+
get others() { return projection?.others ?? []; },
|
|
68
|
+
onChange(listener) {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
return () => { listeners.delete(listener); };
|
|
71
|
+
},
|
|
72
|
+
attach,
|
|
73
|
+
startRead(target) {
|
|
74
|
+
if (attachedTransport === null) {
|
|
75
|
+
throw new Error('presence is not attached to a duplex transport');
|
|
76
|
+
}
|
|
77
|
+
const lifetime = startReadActivity(
|
|
78
|
+
attachedTransport,
|
|
79
|
+
target,
|
|
80
|
+
() => { reads.delete(lifetime); },
|
|
81
|
+
);
|
|
82
|
+
reads.add(lifetime);
|
|
83
|
+
return () => {
|
|
84
|
+
lifetime.stop();
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
forModel(model, recordId) {
|
|
88
|
+
return projection?.forModel(model, recordId) ?? [];
|
|
89
|
+
},
|
|
90
|
+
dispose() {
|
|
91
|
+
for (const read of reads) read.dispose();
|
|
92
|
+
reads.clear();
|
|
93
|
+
unsubscribe?.();
|
|
94
|
+
unsubscribe = null;
|
|
95
|
+
projection?.dispose();
|
|
96
|
+
projection = null;
|
|
97
|
+
attachedTransport = null;
|
|
98
|
+
listeners.clear();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PresenceCommand,
|
|
3
|
+
PresenceTarget,
|
|
4
|
+
} from '@abloatai/transaction/presence';
|
|
5
|
+
import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
|
|
6
|
+
|
|
7
|
+
/** The connection slice needed to keep one session-owned read alive. */
|
|
8
|
+
export interface ReadActivityTransport {
|
|
9
|
+
isConnected(): boolean;
|
|
10
|
+
sendPresenceCommand(command: PresenceCommand): void;
|
|
11
|
+
subscribe(event: 'connected', listener: () => void): () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReadActivityLifetime {
|
|
15
|
+
/** End the read and remove it from the server, reconnecting briefly if needed. */
|
|
16
|
+
stop(): void;
|
|
17
|
+
/** Tear down local resources when the owning client itself is disposed. */
|
|
18
|
+
dispose(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const READ_TTL_MS = LEASE_TTL_MS;
|
|
22
|
+
const READ_REFRESH_MS = READ_TTL_MS / 3;
|
|
23
|
+
let fallbackActivitySequence = 0;
|
|
24
|
+
const noop = (): void => undefined;
|
|
25
|
+
|
|
26
|
+
function readActivityId(): string {
|
|
27
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
28
|
+
return `read:${crypto.randomUUID()}`;
|
|
29
|
+
}
|
|
30
|
+
fallbackActivitySequence += 1;
|
|
31
|
+
return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unref(timer: ReturnType<typeof setTimeout>): void {
|
|
35
|
+
const candidate: unknown = timer;
|
|
36
|
+
if (
|
|
37
|
+
typeof candidate === 'object' &&
|
|
38
|
+
candidate !== null &&
|
|
39
|
+
'unref' in candidate &&
|
|
40
|
+
typeof candidate.unref === 'function'
|
|
41
|
+
) {
|
|
42
|
+
(candidate as { unref(): void }).unref();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Own the lease mechanics for one declared read. React only starts and stops
|
|
48
|
+
* this lifetime; command ids, refreshes, reconnect recovery, and offline
|
|
49
|
+
* cleanup remain inside the presence subsystem.
|
|
50
|
+
*/
|
|
51
|
+
export function startReadActivity(
|
|
52
|
+
transport: ReadActivityTransport,
|
|
53
|
+
target: PresenceTarget,
|
|
54
|
+
onFinished: () => void = noop,
|
|
55
|
+
): ReadActivityLifetime {
|
|
56
|
+
const activityId = readActivityId();
|
|
57
|
+
let active = true;
|
|
58
|
+
let disposed = false;
|
|
59
|
+
let removeOnConnect: (() => void) | null = null;
|
|
60
|
+
let removalExpiry: ReturnType<typeof setTimeout> | null = null;
|
|
61
|
+
let finished = false;
|
|
62
|
+
|
|
63
|
+
const finish = (): void => {
|
|
64
|
+
if (finished) return;
|
|
65
|
+
finished = true;
|
|
66
|
+
onFinished();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const send = (command: PresenceCommand): boolean => {
|
|
70
|
+
if (!transport.isConnected()) return false;
|
|
71
|
+
try {
|
|
72
|
+
transport.sendPresenceCommand(command);
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// The socket can close between the state check and the synchronous send.
|
|
76
|
+
// Reconnect handling retries; a failure while still connected is real.
|
|
77
|
+
if (transport.isConnected()) throw error;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const upsert = (): void => {
|
|
83
|
+
if (!active || disposed) return;
|
|
84
|
+
send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const unsubscribeConnected = transport.subscribe('connected', upsert);
|
|
88
|
+
upsert();
|
|
89
|
+
|
|
90
|
+
const refreshTimer = setInterval(() => {
|
|
91
|
+
if (!active || disposed) return;
|
|
92
|
+
send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
|
|
93
|
+
}, READ_REFRESH_MS);
|
|
94
|
+
unref(refreshTimer);
|
|
95
|
+
|
|
96
|
+
const clearResources = (): void => {
|
|
97
|
+
unsubscribeConnected();
|
|
98
|
+
clearInterval(refreshTimer);
|
|
99
|
+
removeOnConnect?.();
|
|
100
|
+
removeOnConnect = null;
|
|
101
|
+
if (removalExpiry !== null) clearTimeout(removalExpiry);
|
|
102
|
+
removalExpiry = null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const stop = (): void => {
|
|
106
|
+
if (!active || disposed) return;
|
|
107
|
+
active = false;
|
|
108
|
+
unsubscribeConnected();
|
|
109
|
+
clearInterval(refreshTimer);
|
|
110
|
+
|
|
111
|
+
const remove = (): void => {
|
|
112
|
+
if (disposed) return;
|
|
113
|
+
if (!send({ type: 'read.remove', activityId })) return;
|
|
114
|
+
removeOnConnect?.();
|
|
115
|
+
removeOnConnect = null;
|
|
116
|
+
if (removalExpiry !== null) clearTimeout(removalExpiry);
|
|
117
|
+
removalExpiry = null;
|
|
118
|
+
finish();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (transport.isConnected()) {
|
|
122
|
+
remove();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If the component leaves offline, remove on the next reconnect so the
|
|
127
|
+
// resumed logical session cannot briefly show a departed viewer. Once the
|
|
128
|
+
// lease expires server-side, there is nothing left to remove.
|
|
129
|
+
removeOnConnect = transport.subscribe('connected', remove);
|
|
130
|
+
removalExpiry = setTimeout(() => {
|
|
131
|
+
removeOnConnect?.();
|
|
132
|
+
removeOnConnect = null;
|
|
133
|
+
removalExpiry = null;
|
|
134
|
+
finish();
|
|
135
|
+
}, READ_TTL_MS);
|
|
136
|
+
unref(removalExpiry);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
stop,
|
|
141
|
+
dispose() {
|
|
142
|
+
if (disposed) return;
|
|
143
|
+
disposed = true;
|
|
144
|
+
active = false;
|
|
145
|
+
clearResources();
|
|
146
|
+
finish();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from 'react';
|
|
13
13
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
14
14
|
import type { AbloClient as Ablo } from '../client.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
16
16
|
import type { GroupScope } from '../local/sync/scopeGroups.js';
|
|
17
17
|
import { resolveScopeGroups } from '../local/sync/scopeGroups.js';
|
|
18
18
|
import { SyncContext, type SyncStoreContract } from './context.js';
|
|
@@ -20,6 +20,7 @@ import { AbloInternalContext, type AbloInternalContextValue } from './internalCo
|
|
|
20
20
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
21
21
|
import { useSyncStatus } from './useSyncStatus.js';
|
|
22
22
|
import { DefaultFallback } from './DefaultFallback.js';
|
|
23
|
+
import { presenceOfClient } from '../presence/index.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Ablo umbrella provider — owns the sync engine, multiplayer, and
|
|
@@ -380,12 +381,12 @@ function BootstrapGate({
|
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
|
|
383
|
-
const EMPTY_PRESENCE: readonly
|
|
384
|
+
const EMPTY_PRESENCE: readonly PresenceSession[] = Object.freeze([]);
|
|
384
385
|
|
|
385
386
|
export type { GroupScope };
|
|
386
387
|
|
|
387
388
|
/**
|
|
388
|
-
* Read-only presence: the
|
|
389
|
+
* Read-only presence: the other sessions currently visible to this
|
|
389
390
|
* connection, bridged to React. This is a pure reader of the engine's
|
|
390
391
|
* already-flowing presence stream; it does not mutate connection groups.
|
|
391
392
|
*
|
|
@@ -403,7 +404,7 @@ export type { GroupScope };
|
|
|
403
404
|
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
404
405
|
* ```
|
|
405
406
|
*/
|
|
406
|
-
export function usePeers(scope?: GroupScope): readonly
|
|
407
|
+
export function usePeers(scope?: GroupScope): readonly PresenceSession[] {
|
|
407
408
|
const ctx = useContext(AbloInternalContext);
|
|
408
409
|
const engine = ctx?.engine ?? null;
|
|
409
410
|
|
|
@@ -414,19 +415,23 @@ export function usePeers(scope?: GroupScope): readonly Peer[] {
|
|
|
414
415
|
);
|
|
415
416
|
const groups = useMemo(() => JSON.parse(scopeKey) as string[], [scopeKey]);
|
|
416
417
|
|
|
417
|
-
const [peers, setPeers] = useState<readonly
|
|
418
|
+
const [peers, setPeers] = useState<readonly PresenceSession[]>(EMPTY_PRESENCE);
|
|
418
419
|
|
|
419
420
|
useEffect(() => {
|
|
420
421
|
if (!engine) {
|
|
421
422
|
setPeers(EMPTY_PRESENCE);
|
|
422
423
|
return;
|
|
423
424
|
}
|
|
424
|
-
const presence = engine
|
|
425
|
-
const compute = (): readonly
|
|
425
|
+
const presence = presenceOfClient(engine);
|
|
426
|
+
const compute = (): readonly PresenceSession[] =>
|
|
426
427
|
groups.length === 0
|
|
427
428
|
? presence.others
|
|
428
|
-
: presence.others.filter((
|
|
429
|
-
|
|
429
|
+
: presence.others.filter((session) =>
|
|
430
|
+
session.activities.some(({ target }) =>
|
|
431
|
+
target.id !== undefined && groups.includes(
|
|
432
|
+
`${target.model.toLowerCase()}:${target.id}`,
|
|
433
|
+
),
|
|
434
|
+
),
|
|
430
435
|
);
|
|
431
436
|
// Plain useState + onChange — presence changes on connect/disconnect/activity
|
|
432
437
|
// only (never on cursor traffic, a separate channel), so this fires
|