@abloatai/humans 0.60.0 → 0.62.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.
Files changed (99) hide show
  1. package/dist/Ablo.d.ts +4 -4
  2. package/dist/Ablo.js +2 -1
  3. package/dist/client.d.ts +7 -12
  4. package/dist/humans.d.ts +2 -2
  5. package/dist/humans.js +2 -6
  6. package/dist/local/BaseSyncedStore.d.ts +2 -4
  7. package/dist/local/BaseSyncedStore.js +5 -8
  8. package/dist/local/Model.js +46 -56
  9. package/dist/local/NetworkMonitor.js +2 -0
  10. package/dist/local/RuntimeContext.js +2 -0
  11. package/dist/local/SyncClient.d.ts +8 -32
  12. package/dist/local/SyncClient.js +26 -99
  13. package/dist/local/client/clientPrelude.d.ts +2 -0
  14. package/dist/local/client/clientPrelude.js +13 -1
  15. package/dist/local/client/createInternalComponents.js +2 -0
  16. package/dist/local/client/createModelOperations.d.ts +5 -0
  17. package/dist/local/client/createModelOperations.js +7 -4
  18. package/dist/local/client/reactiveEngine.d.ts +2 -2
  19. package/dist/local/client/reactiveEngine.js +9 -11
  20. package/dist/local/fileUploads.d.ts +27 -0
  21. package/dist/local/fileUploads.js +55 -0
  22. package/dist/local/query/client.d.ts +3 -0
  23. package/dist/local/query/client.js +1 -1
  24. package/dist/local/stores/syncAction.d.ts +1 -1
  25. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  26. package/dist/local/sync/BootstrapFetcher.js +4 -4
  27. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  28. package/dist/local/sync/OnDemandLoader.js +1 -0
  29. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  30. package/dist/local/sync/SyncWebSocket.js +1 -36
  31. package/dist/local/sync/contextOnChange.js +1 -1
  32. package/dist/local/sync/createClaimStream.d.ts +9 -19
  33. package/dist/local/sync/createClaimStream.js +41 -56
  34. package/dist/local/sync/deltaPipeline.js +12 -6
  35. package/dist/local/sync/schemas.d.ts +2 -2
  36. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  37. package/dist/local/sync/socketEventWiring.js +1 -5
  38. package/dist/local/transactions/localMutation.js +3 -3
  39. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  40. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  41. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  42. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  43. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  44. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  45. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  46. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  47. package/dist/local/transactions/mutations/localMutation.js +3 -3
  48. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  49. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  50. package/dist/presence/index.d.ts +15 -0
  51. package/dist/presence/index.js +49 -0
  52. package/dist/react/AbloProvider.d.ts +3 -3
  53. package/dist/react/AbloProvider.js +4 -3
  54. package/dist/react/useErrorListener.js +1 -1
  55. package/dist/react/useMutationFailureListener.js +1 -1
  56. package/dist/surface.d.ts +1 -1
  57. package/dist/surface.js +1 -0
  58. package/package.json +3 -4
  59. package/src/Ablo.ts +15 -6
  60. package/src/client.ts +8 -13
  61. package/src/humans.ts +3 -10
  62. package/src/local/BaseSyncedStore.ts +5 -11
  63. package/src/local/Model.ts +45 -55
  64. package/src/local/NetworkMonitor.ts +2 -0
  65. package/src/local/RuntimeContext.ts +2 -0
  66. package/src/local/SyncClient.ts +33 -127
  67. package/src/local/client/clientPrelude.ts +20 -0
  68. package/src/local/client/createInternalComponents.ts +2 -0
  69. package/src/local/client/createModelOperations.ts +17 -6
  70. package/src/local/client/reactiveEngine.ts +19 -14
  71. package/src/local/fileUploads.ts +97 -0
  72. package/src/local/query/client.ts +4 -0
  73. package/src/local/sync/BootstrapFetcher.ts +13 -4
  74. package/src/local/sync/OnDemandLoader.ts +3 -0
  75. package/src/local/sync/SyncWebSocket.ts +1 -42
  76. package/src/local/sync/contextOnChange.ts +1 -1
  77. package/src/local/sync/createClaimStream.ts +52 -72
  78. package/src/local/sync/deltaPipeline.ts +10 -6
  79. package/src/local/sync/socketEventWiring.ts +1 -8
  80. package/src/local/transactions/localMutation.ts +3 -3
  81. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  82. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  83. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  84. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  85. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  86. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  87. package/src/local/transactions/mutations/localMutation.ts +3 -3
  88. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  89. package/src/presence/index.ts +72 -0
  90. package/src/react/AbloProvider.tsx +14 -9
  91. package/src/react/useErrorListener.ts +1 -1
  92. package/src/react/useMutationFailureListener.ts +1 -1
  93. package/src/surface.ts +1 -0
  94. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  95. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  96. package/dist/presenceStream.d.ts +0 -69
  97. package/dist/presenceStream.js +0 -200
  98. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
  99. package/src/presenceStream.ts +0 -279
@@ -12,6 +12,12 @@ export interface QueueCoalescingContext {
12
12
  }
13
13
 
14
14
  export function enqueueTransaction(ctx: QueueCoalescingContext, transaction: QueuedMutation): void {
15
+ // Only the pending state may cross into the execution owner. A late timer,
16
+ // reconnect callback, or stale staging callback must not resurrect a row
17
+ // that is already executing or terminal, and repeated triggers must not put
18
+ // the same source mutation into the queue twice.
19
+ if (transaction.status !== 'pending') return;
20
+ if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id)) return;
15
21
  ctx.ensureDerivedFields(transaction);
16
22
  const modelKey = `${transaction.modelName}:${transaction.modelId}`;
17
23
  if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {
@@ -0,0 +1,72 @@
1
+ import {
2
+ createPresenceProjection,
3
+ type PresenceProjection,
4
+ type PresenceProjectionEvents,
5
+ type PresenceView,
6
+ } from '@abloatai/transaction/presence';
7
+
8
+ /** Reactive-client presence backed by the client's existing live connection. */
9
+ export interface ReactivePresence extends PresenceView {
10
+ forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
11
+ onChange(listener: () => void): () => void;
12
+ }
13
+
14
+ /** Lifecycle hooks kept inside the humans composition boundary. */
15
+ export interface AttachablePresence extends ReactivePresence {
16
+ attach(transport: PresenceProjectionEvents): void;
17
+ dispose(): void;
18
+ }
19
+
20
+ const clientPresence = new WeakMap<object, ReactivePresence>();
21
+
22
+ /** Framework bridge that does not consume a string key on the model namespace. */
23
+ export function attachPresenceToClient(client: object, presence: ReactivePresence): void {
24
+ clientPresence.set(client, presence);
25
+ }
26
+
27
+ export function presenceOfClient(client: object): ReactivePresence {
28
+ const presence = clientPresence.get(client);
29
+ if (presence === undefined) throw new Error('presence is not attached to this client');
30
+ return presence;
31
+ }
32
+
33
+ export function createPresence(
34
+ transport: PresenceProjectionEvents | null = null,
35
+ ): AttachablePresence {
36
+ let projection: PresenceProjection | null = null;
37
+ const listeners = new Set<() => void>();
38
+ let unsubscribe: (() => void) | null = null;
39
+
40
+ const notify = (): void => {
41
+ for (const listener of listeners) listener();
42
+ };
43
+
44
+ const attach = (events: PresenceProjectionEvents): void => {
45
+ if (projection !== null) return;
46
+ projection = createPresenceProjection(events);
47
+ unsubscribe = projection.subscribe(notify);
48
+ notify();
49
+ };
50
+
51
+ if (transport !== null) attach(transport);
52
+
53
+ return {
54
+ get active() { return projection?.active ?? []; },
55
+ get others() { return projection?.others ?? []; },
56
+ onChange(listener) {
57
+ listeners.add(listener);
58
+ return () => { listeners.delete(listener); };
59
+ },
60
+ attach,
61
+ forModel(model, recordId) {
62
+ return projection?.forModel(model, recordId) ?? [];
63
+ },
64
+ dispose() {
65
+ unsubscribe?.();
66
+ unsubscribe = null;
67
+ projection?.dispose();
68
+ projection = null;
69
+ listeners.clear();
70
+ },
71
+ };
72
+ }
@@ -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 { Peer } from '@abloatai/transaction/types/streams';
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 Peer[] = Object.freeze([]);
384
+ const EMPTY_PRESENCE: readonly PresenceSession[] = Object.freeze([]);
384
385
 
385
386
  export type { GroupScope };
386
387
 
387
388
  /**
388
- * Read-only presence: the OTHER participants currently visible to this
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 Peer[] {
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 Peer[]>(EMPTY_PRESENCE);
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.presence;
425
- const compute = (): readonly Peer[] =>
425
+ const presence = presenceOfClient(engine);
426
+ const compute = (): readonly PresenceSession[] =>
426
427
  groups.length === 0
427
428
  ? presence.others
428
- : presence.others.filter((p) =>
429
- p.syncGroups.some((g) => groups.includes(g)),
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
@@ -16,7 +16,7 @@ export function useErrorListener(listener: (error: Error) => void): void {
16
16
  const listenerRef = useRef(listener);
17
17
  listenerRef.current = listener;
18
18
  useEffect(
19
- () => context.subscribeError((error) => listenerRef.current(error)),
19
+ () => context.subscribeError((error) => { listenerRef.current(error); }),
20
20
  [context],
21
21
  );
22
22
  }
@@ -29,6 +29,6 @@ export function useMutationFailureListener(
29
29
  useEffect(() => {
30
30
  const engine = context.engine;
31
31
  if (!engine) return;
32
- return engine.onMutationFailure((payload) => listenerRef.current(payload));
32
+ return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
33
33
  }, [context, context.engine]);
34
34
  }
package/src/surface.ts CHANGED
@@ -37,6 +37,7 @@ export const PUBLIC_MODEL_VERBS = [
37
37
  'list',
38
38
  'listAll',
39
39
  'local',
40
+ 'presence',
40
41
  'create',
41
42
  'update',
42
43
  'delete',
@@ -1,33 +0,0 @@
1
- import type { RuntimeContext } from '../../RuntimeContext.js';
2
- import type { QueuedMutation } from './commitPayload.js';
3
- import type { MutationStore } from './MutationStore.js';
4
- import type { OptimisticUpdateEntry } from './localMutation.js';
5
- import type { MutationCommitResult } from '@abloatai/transaction/commit';
6
- import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
7
- export interface PendingDrainContext {
8
- readonly runtime: RuntimeContext;
9
- readonly config: {
10
- deltaConfirmationTimeout: number;
11
- };
12
- readonly store: MutationStore;
13
- readonly executionQueue: QueuedMutation[];
14
- readonly optimisticUpdates: Map<string, OptimisticUpdateEntry>;
15
- readonly assertDurableReplayOpen: () => void;
16
- readonly processCommitLane: () => Promise<void>;
17
- readonly takePendingDrainBatch: (pending: QueuedMutation[]) => QueuedMutation[];
18
- readonly ensureCommitEnvelope: (batch: QueuedMutation[]) => string;
19
- readonly ensureDerivedFields: (transaction: QueuedMutation) => void;
20
- readonly sourceMutationIdsFor: (batch: readonly QueuedMutation[]) => string[];
21
- readonly sealDurableCommit: (input: Parameters<typeof import('./commitTransport.js').sealDurableCommit>[1]) => Promise<DurableCommitEnvelope>;
22
- readonly assertEnvelopeInsideReplayWindow: (envelope: DurableCommitEnvelope) => void;
23
- readonly parseMutationCommitResult: (value: Awaited<ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']>>) => MutationCommitResult;
24
- readonly dispatchCommitBounded: (...args: Parameters<import('../../interfaces/index.js').MutationExecutor['commit']>) => ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']>;
25
- readonly persistDurableCommitAcceptance: (envelope: DurableCommitEnvelope, result: MutationCommitResult) => Promise<DurableCommitEnvelope>;
26
- readonly removeDurableCommit: (idempotencyKey: string) => Promise<void>;
27
- readonly scheduleReplicationLagTimeout: (transactionId: string, clientTxId?: string, correlationId?: string) => void;
28
- readonly scheduleDeltaConfirmationTimeout: (transaction: QueuedMutation, timeoutMs: number) => void;
29
- readonly enqueue: (transaction: QueuedMutation) => void;
30
- readonly recentDeltaCorrelations: Map<string, number>;
31
- readonly emit: (event: string, payload: object) => boolean;
32
- }
33
- export declare function drainPendingConfirmations(ctx: PendingDrainContext): Promise<void>;
@@ -1,117 +0,0 @@
1
- import { applyWriteOptions, collectQueuedReads, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
2
- export async function drainPendingConfirmations(ctx) {
3
- ctx.assertDurableReplayOpen();
4
- // Kick the commit lane too: atomic envelopes from `commits.create()` may
5
- // have been left at the head of the lane while the connection was down.
6
- // Fire-and-forget; processCommitLane serializes itself.
7
- void ctx.processCommitLane();
8
- // Collect pending transactions in created order
9
- const pending = ctx.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
10
- if (pending.length === 0)
11
- return;
12
- const pendingIds = new Set(pending.map((tx) => tx.id));
13
- // These rows may already be waiting behind the normal batch timer. The
14
- // reconnect fast path takes ownership of them for this attempt so the same
15
- // transaction cannot dispatch concurrently through both paths.
16
- const retainedQueue = ctx.executionQueue.filter((tx) => !pendingIds.has(tx.id));
17
- ctx.executionQueue.splice(0, ctx.executionQueue.length, ...retainedQueue);
18
- const remaining = [...pending];
19
- while (remaining.length > 0) {
20
- const batch = ctx.takePendingDrainBatch(remaining);
21
- if (batch.length === 0)
22
- break;
23
- const batchIds = new Set(batch.map((tx) => tx.id));
24
- const nextRemaining = remaining.filter((tx) => !batchIds.has(tx.id));
25
- try {
26
- const idempotencyKey = ctx.ensureCommitEnvelope(batch);
27
- const projectedOperations = batch.map((tx) => {
28
- ctx.ensureDerivedFields(tx);
29
- return applyWriteOptions({
30
- type: TX_TYPE_TO_MUTATION_OP[tx.type],
31
- model: tx.modelKey,
32
- id: tx.modelId,
33
- input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
34
- transactionId: tx.id,
35
- }, tx);
36
- });
37
- const durableEnvelope = await ctx.sealDurableCommit({
38
- idempotencyKey,
39
- origin: 'model_batch',
40
- operations: projectedOperations,
41
- sourceMutationIds: ctx.sourceMutationIdsFor(batch),
42
- commitOptions: { reads: collectQueuedReads(batch) },
43
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
44
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
45
- sequence: batch[0]?.commitEnvelope?.sequence,
46
- });
47
- ctx.assertEnvelopeInsideReplayWindow(durableEnvelope);
48
- const result = ctx.parseMutationCommitResult(await ctx.dispatchCommitBounded(durableEnvelope.operations, {
49
- idempotencyKey,
50
- ...(durableEnvelope.commitOptions.reads !== undefined
51
- ? { reads: durableEnvelope.commitOptions.reads }
52
- : {}),
53
- }));
54
- await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
55
- if (result.status === 'queued') {
56
- // Reconnect flushes use the same accepted-vs-confirmed contract as
57
- // the normal lane. A queued source receipt retains the envelope and
58
- // waits for exact correlation; it is never promoted by the reconnect
59
- // shortcut itself.
60
- for (const tx of batch) {
61
- tx.requiresCorrelatedDelta = true;
62
- tx.syncIdNeededForCompletion = undefined;
63
- tx.correlationId = result.correlationId;
64
- const echoSyncId = result.correlationId
65
- ? ctx.recentDeltaCorrelations.get(result.correlationId)
66
- : undefined;
67
- if (echoSyncId !== undefined) {
68
- ctx.store.updateStatus(tx.id, 'completed');
69
- ctx.emit('transaction:completed', tx);
70
- ctx.emit(`transaction:completed:${tx.id}`, tx);
71
- ctx.optimisticUpdates.delete(tx.id);
72
- continue;
73
- }
74
- ctx.store.updateStatus(tx.id, 'awaiting_delta');
75
- ctx.scheduleReplicationLagTimeout(tx.id, idempotencyKey, result.correlationId);
76
- ctx.scheduleDeltaConfirmationTimeout(tx, ctx.config.deltaConfirmationTimeout);
77
- }
78
- if (batch.every((tx) => tx.status === 'completed')) {
79
- await ctx.removeDurableCommit(idempotencyKey);
80
- }
81
- }
82
- else {
83
- await ctx.removeDurableCommit(idempotencyKey);
84
- // Mark this request envelope as completed before moving to the next.
85
- for (const tx of batch) {
86
- ctx.store.updateStatus(tx.id, 'completed');
87
- ctx.emit('transaction:completed', tx);
88
- ctx.emit(`transaction:completed:${tx.id}`, tx);
89
- ctx.optimisticUpdates.delete(tx.id);
90
- }
91
- }
92
- ctx.runtime.logger.debug('txn:commit', 0, {
93
- count: batch.length,
94
- lastSyncId: result.lastSyncId,
95
- });
96
- remaining.splice(0, remaining.length, ...nextRemaining);
97
- }
98
- catch (err) {
99
- // If one request fails, hand it and every later request back to the
100
- // normal lane. Their envelopes stay attached for safe retry.
101
- const networkUnavailable = !ctx.runtime.onlineStatus.isOnline();
102
- const isNetworkError = err instanceof Error &&
103
- (err.message.includes('Failed to fetch') ||
104
- err.message.includes('Network request failed') ||
105
- err.message.includes('NetworkError'));
106
- if (!networkUnavailable || !isNetworkError) {
107
- ctx.runtime.observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
108
- error: err instanceof Error ? err.message : String(err),
109
- });
110
- }
111
- for (const tx of [...batch, ...nextRemaining]) {
112
- ctx.enqueue(tx);
113
- }
114
- return;
115
- }
116
- }
117
- }
@@ -1,69 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import type { WsTransport } from '@abloatai/transaction/transport/websocket';
26
- import type { PresenceStream } from '@abloatai/transaction/types/streams';
27
- import type { ParticipantKind } from '@abloatai/transaction/types/participant';
28
- /**
29
- * The wire capability the presence stream actually uses: subscribe to typed
30
- * inbound frames, check liveness, and send outbound frames. The duplex
31
- * `WsTransport` satisfies it — the same port shape the claim stream depends
32
- * on — so the stream can attach to whatever connection the host built,
33
- * without naming the engine's subclass.
34
- */
35
- export type PresenceTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
36
- export interface PresenceStreamConfig {
37
- /** Identity used to filter our own echoed frames out of `others`. */
38
- participantId: string;
39
- /** Optional human label for the self entry. */
40
- label?: string;
41
- /** Sync groups the participant is broadcasting on. Used for the
42
- * initial `self` entry and for `othersIn(...)` filtering. */
43
- syncGroups: readonly string[];
44
- /** Marks `self` as an agent. Server is the source of truth for
45
- * peers' `isAgent`, but `self` is local — caller decides. */
46
- isAgent?: boolean;
47
- }
48
- /** PresenceStream extended with engine-lifecycle hooks. */
49
- export interface AttachablePresenceStream extends PresenceStream {
50
- /** Wire the stream to a now-ready transport. Calls before this are
51
- * buffered (self mutations only — no wire send). Idempotent. */
52
- attach(transport: PresenceTransport): void;
53
- /**
54
- * Seeds the participant identity once the host resolves it. The stream can
55
- * be built before identity is known — a hosted client learns who it is
56
- * from its credential's scope during connect — and until then the
57
- * construction-time values (possibly empty) would leave the `self` entry
58
- * blank and let the participant's own echoed frames into `others`. Updates
59
- * the `self` entry in place, so held references see the resolved identity.
60
- */
61
- setParticipant(participant: {
62
- id: string;
63
- kind?: ParticipantKind;
64
- syncGroups?: readonly string[];
65
- }): void;
66
- /** Tear down listeners. Stream object stays usable as a no-op. */
67
- dispose(): void;
68
- }
69
- export declare function createPresenceStream(config: PresenceStreamConfig, transport?: PresenceTransport | null): AttachablePresenceStream;
@@ -1,200 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import { asyncIteratorFrom } from '@abloatai/transaction/utils/asyncIterator';
26
- import { participantKindFromWire } from '@abloatai/transaction/coordination/schema';
27
- import { isTargetTuple, subTarget, wireTarget } from '@abloatai/transaction/coordination';
28
- export function createPresenceStream(config, transport = null) {
29
- const { label, syncGroups, isAgent = false } = config;
30
- // Mutable: the host seeds the resolved identity via `setParticipant` once
31
- // it is known; the own-echo filter always reads the current value.
32
- let participantId = config.participantId;
33
- // ── Self ─────────────────────────────────────────────────────────
34
- const self = {
35
- participantKind: isAgent ? 'agent' : 'user',
36
- participantId,
37
- label,
38
- syncGroups: [...syncGroups],
39
- activity: { entityType: 'Unknown', entityId: '', action: 'idle' },
40
- lastActive: new Date().toISOString(),
41
- };
42
- // ── Others ───────────────────────────────────────────────────────
43
- const othersById = new Map();
44
- let othersSnapshot = Object.freeze([]);
45
- const listeners = new Set();
46
- const notifyListeners = () => {
47
- othersSnapshot = Object.freeze(Array.from(othersById.values()));
48
- for (const l of listeners) {
49
- try {
50
- l();
51
- }
52
- catch {
53
- /* one bad listener doesn't break the others */
54
- }
55
- }
56
- };
57
- // ── Wire wiring ──────────────────────────────────────────────────
58
- let attached = null;
59
- const unsubs = [];
60
- function attach(t) {
61
- if (attached)
62
- return; // idempotent
63
- attached = t;
64
- // Reconnect: clear roster (Hub sends fresh snapshot), re-announce
65
- // own activity (peers don't auto-learn about us across reconnects).
66
- unsubs.push(t.subscribe('connected', () => {
67
- if (othersById.size > 0) {
68
- othersById.clear();
69
- othersSnapshot = Object.freeze([]);
70
- notifyListeners();
71
- }
72
- if (self.activity.entityId)
73
- sendUpdate(self.activity);
74
- }));
75
- // Inbound presence frames arrive in the wire vocabulary
76
- // (userId / isAgent / timestamp); translate them into the shape this
77
- // stream exposes (participantId / participantKind / lastActive).
78
- unsubs.push(t.subscribe('presence_update', (event) => {
79
- if (event.userId === participantId)
80
- return; // own echo
81
- if (!event.userId)
82
- return;
83
- switch (event.kind) {
84
- case 'leave':
85
- if (othersById.delete(event.userId))
86
- notifyListeners();
87
- return;
88
- // No `undefined` arm: every site that builds a presence frame stamps
89
- // `kind`, and the schema now says so, so an unlabelled frame is not a
90
- // shape the transport can hand us.
91
- case 'enter':
92
- case 'update': {
93
- const entry = {
94
- participantKind: participantKindFromWire(event.participantKind, event.isAgent),
95
- participantId: event.userId,
96
- syncGroups: event.syncGroups ?? [],
97
- activity: event.activity
98
- ? {
99
- ...wireTarget(event.activity),
100
- ...subTarget(event.activity),
101
- action: event.activity.action,
102
- ...(event.activity.detail !== undefined
103
- ? { detail: event.activity.detail }
104
- : {}),
105
- }
106
- : { entityType: 'Unknown', entityId: '', action: event.status },
107
- lastActive: event.timestamp
108
- ? new Date(event.timestamp).toISOString()
109
- : new Date().toISOString(),
110
- };
111
- othersById.set(event.userId, entry);
112
- notifyListeners();
113
- return;
114
- }
115
- }
116
- }));
117
- // If self was already mutated before attach, broadcast it now.
118
- if (self.activity.entityId)
119
- sendUpdate(self.activity);
120
- }
121
- if (transport)
122
- attach(transport);
123
- // ── Outbound ────────────────────────────────────────────────────
124
- // Do not include `isAgent` in the payload. The server derives it
125
- // authoritatively from the connection's identity, and letting a client
126
- // self-declare it once caused human sessions to broadcast as agents to peers.
127
- function sendUpdate(activity) {
128
- if (!attached?.isConnected())
129
- return; // no-op until connected
130
- attached.send({
131
- type: 'presence_update',
132
- payload: { status: 'online', activity },
133
- });
134
- }
135
- function doUpdate(activity) {
136
- self.activity = activity;
137
- self.lastActive = new Date().toISOString();
138
- sendUpdate(activity);
139
- }
140
- function resolveTarget(target) {
141
- if (isTargetTuple(target)) {
142
- return { entityType: target[0], entityId: target[1], action: 'unknown' };
143
- }
144
- return {
145
- ...wireTarget(target),
146
- ...subTarget(target),
147
- action: 'unknown',
148
- };
149
- }
150
- const withVerb = (action) => (target, detail) => {
151
- doUpdate({ ...resolveTarget(target), action, detail });
152
- };
153
- return {
154
- self,
155
- update: doUpdate,
156
- editing: withVerb('editing'),
157
- reading: withVerb('reading'),
158
- viewing: withVerb('viewing'),
159
- idle: () => {
160
- doUpdate({ entityType: 'Unknown', entityId: '', action: 'idle' });
161
- },
162
- get others() {
163
- return othersSnapshot;
164
- },
165
- othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
166
- onChange: (listener) => {
167
- listeners.add(listener);
168
- return () => {
169
- listeners.delete(listener);
170
- };
171
- },
172
- [Symbol.asyncIterator]() {
173
- return asyncIteratorFrom((onChange) => {
174
- listeners.add(onChange);
175
- return () => {
176
- listeners.delete(onChange);
177
- };
178
- }, () => othersSnapshot);
179
- },
180
- attach,
181
- setParticipant(participant) {
182
- participantId = participant.id;
183
- const writable = self;
184
- writable.participantId = participant.id;
185
- if (participant.kind)
186
- writable.participantKind = participant.kind;
187
- if (participant.syncGroups)
188
- writable.syncGroups = [...participant.syncGroups];
189
- },
190
- dispose() {
191
- for (const off of unsubs)
192
- off();
193
- unsubs.length = 0;
194
- listeners.clear();
195
- othersById.clear();
196
- othersSnapshot = Object.freeze([]);
197
- attached = null;
198
- },
199
- };
200
- }