@abloatai/humans 0.61.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 (56) 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 +0 -3
  8. package/dist/local/SyncClient.d.ts +3 -3
  9. package/dist/local/client/clientPrelude.d.ts +2 -0
  10. package/dist/local/client/clientPrelude.js +13 -1
  11. package/dist/local/client/createInternalComponents.js +2 -0
  12. package/dist/local/client/createModelOperations.d.ts +5 -0
  13. package/dist/local/client/createModelOperations.js +1 -0
  14. package/dist/local/client/reactiveEngine.d.ts +2 -2
  15. package/dist/local/client/reactiveEngine.js +9 -11
  16. package/dist/local/query/client.d.ts +3 -0
  17. package/dist/local/query/client.js +1 -1
  18. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  19. package/dist/local/sync/BootstrapFetcher.js +4 -4
  20. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  21. package/dist/local/sync/OnDemandLoader.js +1 -0
  22. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  23. package/dist/local/sync/SyncWebSocket.js +1 -36
  24. package/dist/local/sync/createClaimStream.d.ts +9 -19
  25. package/dist/local/sync/createClaimStream.js +40 -55
  26. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  27. package/dist/local/sync/socketEventWiring.js +1 -5
  28. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  29. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  30. package/dist/presence/index.d.ts +15 -0
  31. package/dist/presence/index.js +49 -0
  32. package/dist/react/AbloProvider.d.ts +3 -3
  33. package/dist/react/AbloProvider.js +4 -3
  34. package/dist/surface.d.ts +1 -1
  35. package/dist/surface.js +1 -0
  36. package/package.json +2 -2
  37. package/src/Ablo.ts +15 -6
  38. package/src/client.ts +8 -13
  39. package/src/humans.ts +3 -10
  40. package/src/local/BaseSyncedStore.ts +0 -6
  41. package/src/local/client/clientPrelude.ts +20 -0
  42. package/src/local/client/createInternalComponents.ts +2 -0
  43. package/src/local/client/createModelOperations.ts +8 -0
  44. package/src/local/client/reactiveEngine.ts +19 -14
  45. package/src/local/query/client.ts +4 -0
  46. package/src/local/sync/BootstrapFetcher.ts +13 -4
  47. package/src/local/sync/OnDemandLoader.ts +3 -0
  48. package/src/local/sync/SyncWebSocket.ts +1 -42
  49. package/src/local/sync/createClaimStream.ts +51 -71
  50. package/src/local/sync/socketEventWiring.ts +1 -8
  51. package/src/presence/index.ts +72 -0
  52. package/src/react/AbloProvider.tsx +14 -9
  53. package/src/surface.ts +1 -0
  54. package/dist/presenceStream.d.ts +0 -69
  55. package/dist/presenceStream.js +0 -200
  56. 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 participants' claims from the same
9
- * `presence_update` frames the {@link createPresenceStream} presence stream
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 — `event.activeClaims`, each stamped with
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 participants' claims from the same
9
- * `presence_update` frames the {@link createPresenceStream} presence stream
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 — `event.activeClaims`, each stamped with
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
- // (1) Inbound presence frames carry every participant's full
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, PresenceUpdate, SyncWebSocket, EventMap } from './SyncWebSocket.js';
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, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
125
+ deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
130
126
  }
@@ -476,7 +476,7 @@ export declare class MutationQueue extends EventEmitter {
476
476
  awaitingDeltaCount: number;
477
477
  awaitingDeltaTransactions: {
478
478
  id: string;
479
- type: "update" | "create" | "delete" | "archive" | "unarchive";
479
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
480
480
  modelName: string;
481
481
  modelId: string;
482
482
  syncIdNeeded: number | undefined;
@@ -485,13 +485,13 @@ export declare class MutationQueue extends EventEmitter {
485
485
  }[];
486
486
  pendingTransactions: {
487
487
  id: string;
488
- type: "update" | "create" | "delete" | "archive" | "unarchive";
488
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
489
489
  modelName: string;
490
490
  modelId: string;
491
491
  }[];
492
492
  executingTransactions: {
493
493
  id: string;
494
- type: "update" | "create" | "delete" | "archive" | "unarchive";
494
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
495
495
  modelName: string;
496
496
  modelId: string;
497
497
  }[];
@@ -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
  }>;
@@ -0,0 +1,15 @@
1
+ import { type PresenceProjection, type PresenceProjectionEvents, type PresenceView } from '@abloatai/transaction/presence';
2
+ /** Reactive-client presence backed by the client's existing live connection. */
3
+ export interface ReactivePresence extends PresenceView {
4
+ forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
5
+ onChange(listener: () => void): () => void;
6
+ }
7
+ /** Lifecycle hooks kept inside the humans composition boundary. */
8
+ export interface AttachablePresence extends ReactivePresence {
9
+ attach(transport: PresenceProjectionEvents): void;
10
+ dispose(): void;
11
+ }
12
+ /** Framework bridge that does not consume a string key on the model namespace. */
13
+ export declare function attachPresenceToClient(client: object, presence: ReactivePresence): void;
14
+ export declare function presenceOfClient(client: object): ReactivePresence;
15
+ export declare function createPresence(transport?: PresenceProjectionEvents | null): AttachablePresence;
@@ -0,0 +1,49 @@
1
+ import { createPresenceProjection, } from '@abloatai/transaction/presence';
2
+ const clientPresence = new WeakMap();
3
+ /** Framework bridge that does not consume a string key on the model namespace. */
4
+ export function attachPresenceToClient(client, presence) {
5
+ clientPresence.set(client, presence);
6
+ }
7
+ export function presenceOfClient(client) {
8
+ const presence = clientPresence.get(client);
9
+ if (presence === undefined)
10
+ throw new Error('presence is not attached to this client');
11
+ return presence;
12
+ }
13
+ export function createPresence(transport = null) {
14
+ let projection = null;
15
+ const listeners = new Set();
16
+ let unsubscribe = null;
17
+ const notify = () => {
18
+ for (const listener of listeners)
19
+ listener();
20
+ };
21
+ const attach = (events) => {
22
+ if (projection !== null)
23
+ return;
24
+ projection = createPresenceProjection(events);
25
+ unsubscribe = projection.subscribe(notify);
26
+ notify();
27
+ };
28
+ if (transport !== null)
29
+ attach(transport);
30
+ return {
31
+ get active() { return projection?.active ?? []; },
32
+ get others() { return projection?.others ?? []; },
33
+ onChange(listener) {
34
+ listeners.add(listener);
35
+ return () => { listeners.delete(listener); };
36
+ },
37
+ attach,
38
+ forModel(model, recordId) {
39
+ return projection?.forModel(model, recordId) ?? [];
40
+ },
41
+ dispose() {
42
+ unsubscribe?.();
43
+ unsubscribe = null;
44
+ projection?.dispose();
45
+ projection = null;
46
+ listeners.clear();
47
+ },
48
+ };
49
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
3
3
  import type { AbloClient as Ablo } from '../client.js';
4
- import type { Peer } from '@abloatai/transaction/types/streams';
4
+ import type { PresenceSession } from '@abloatai/transaction/presence';
5
5
  import type { GroupScope } from '../local/sync/scopeGroups.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
7
  /**
@@ -109,7 +109,7 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
109
109
  export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
110
110
  export type { GroupScope };
111
111
  /**
112
- * Read-only presence: the OTHER participants currently visible to this
112
+ * Read-only presence: the other sessions currently visible to this
113
113
  * connection, bridged to React. This is a pure reader of the engine's
114
114
  * already-flowing presence stream; it does not mutate connection groups.
115
115
  *
@@ -127,7 +127,7 @@ export type { GroupScope };
127
127
  * const alone = !peers.some((p) => p.participantKind === 'user');
128
128
  * ```
129
129
  */
130
- export declare function usePeers(scope?: GroupScope): readonly Peer[];
130
+ export declare function usePeers(scope?: GroupScope): readonly PresenceSession[];
131
131
  /**
132
132
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
133
133
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
@@ -7,6 +7,7 @@ import { AbloInternalContext } from './internalContext.js';
7
7
  import { AbloValidationError } from '@abloatai/transaction/errors';
8
8
  import { useSyncStatus } from './useSyncStatus.js';
9
9
  import { DefaultFallback } from './DefaultFallback.js';
10
+ import { presenceOfClient } from '../presence/index.js';
10
11
  // ── Implementation ───────────────────────────────────────────────────
11
12
  /**
12
13
  * Lightweight event emitter for provider-level errors. Lives on the
@@ -201,7 +202,7 @@ function BootstrapGate({ fallback, children, }) {
201
202
  }
202
203
  const EMPTY_PRESENCE = Object.freeze([]);
203
204
  /**
204
- * Read-only presence: the OTHER participants currently visible to this
205
+ * Read-only presence: the other sessions currently visible to this
205
206
  * connection, bridged to React. This is a pure reader of the engine's
206
207
  * already-flowing presence stream; it does not mutate connection groups.
207
208
  *
@@ -232,10 +233,10 @@ export function usePeers(scope) {
232
233
  setPeers(EMPTY_PRESENCE);
233
234
  return;
234
235
  }
235
- const presence = engine.presence;
236
+ const presence = presenceOfClient(engine);
236
237
  const compute = () => groups.length === 0
237
238
  ? presence.others
238
- : presence.others.filter((p) => p.syncGroups.some((g) => groups.includes(g)));
239
+ : presence.others.filter((session) => session.activities.some(({ target }) => target.id !== undefined && groups.includes(`${target.model.toLowerCase()}:${target.id}`)));
239
240
  // Plain useState + onChange — presence changes on connect/disconnect/activity
240
241
  // only (never on cursor traffic, a separate channel), so this fires
241
242
  // rarely; a frame of stale presence is harmless.
package/dist/surface.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tuple, so it is the one list of model-verb names a generated summary can
20
20
  * describe.
21
21
  */
22
- export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "create", "update", "delete", "claim", "onChange"];
22
+ export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "create", "update", "delete", "claim", "onChange"];
23
23
  /**
24
24
  * The option keys accepted by `local.list` and `onChange`, matching the
25
25
  * keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
package/dist/surface.js CHANGED
@@ -26,6 +26,7 @@ export const PUBLIC_MODEL_VERBS = [
26
26
  'list',
27
27
  'listAll',
28
28
  'local',
29
+ 'presence',
29
30
  'create',
30
31
  'update',
31
32
  'delete',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.61.0",
87
+ "@abloatai/transaction": "^0.62.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
package/src/Ablo.ts CHANGED
@@ -99,7 +99,7 @@ export type Ablo<S extends SchemaRecord> = AbloClient<S>;
99
99
  * const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
100
100
  * ```
101
101
  *
102
- * Server-side agents, workers, and services use `@abloatai/transaction`.
102
+ * Server-side agents, workers, and services use `@abloatai/ablo`.
103
103
  */
104
104
  export function Ablo<
105
105
  const S extends SchemaRecord,
@@ -117,7 +117,15 @@ export function Ablo<const S extends SchemaRecord>(
117
117
  // resolver, the base URL, the logger, and this participant's identity —
118
118
  // and fails on a misconfiguration before anything is constructed.
119
119
  const prelude = resolveClientPrelude(options);
120
- const { internalOptions, authCredentials, logger, url, participantId, kind } = prelude;
120
+ const {
121
+ internalOptions,
122
+ authCredentials,
123
+ presenceSession,
124
+ logger,
125
+ url,
126
+ participantId,
127
+ kind,
128
+ } = prelude;
121
129
 
122
130
  // 2. The connection, built here in the composition root — before the plugin
123
131
  // list resolves, so `PluginContext.transport` carries the instance a
@@ -134,6 +142,7 @@ export function Ablo<const S extends SchemaRecord>(
134
142
  baseUrl: url,
135
143
  kind,
136
144
  getAuthToken: authCredentials.getAuthToken,
145
+ presenceSession,
137
146
  collaborationEvents: [...(internalOptions.collaborationEvents ?? [])],
138
147
  syncGroups: [...(internalOptions.syncGroups ?? [])],
139
148
  deferConnect: true,
@@ -223,7 +232,7 @@ export function Ablo<const S extends SchemaRecord>(
223
232
  //
224
233
  // One default import, with types hung underneath via namespace dots:
225
234
  // `import { Ablo } from "@abloatai/humans"` gets the factory, its return type, and
226
- // every type a typical consumer references (`Ablo.Peer`,
235
+ // every type a typical consumer references (`Ablo.PresenceSession`,
227
236
  // and so on) — all purely type-level, with zero runtime cost.
228
237
  //
229
238
  // The types still live in their canonical homes (`types/streams`, `principal`,
@@ -272,10 +281,10 @@ export namespace Ablo {
272
281
  export type Duration = _Streams.Duration;
273
282
 
274
283
  // ── Real-time multiplayer (flat — heterogeneous cluster) ──────────
275
- export type PresenceStream = _Streams.PresenceStream;
284
+ export type Presence = import('./presence/index.js').ReactivePresence;
285
+ export type PresenceSession = import('@abloatai/transaction/presence').PresenceSession;
286
+ export type PresenceActivity = import('@abloatai/transaction/presence').PresenceActivity;
276
287
  export type ClaimStream = _Streams.ClaimStream;
277
- export type Peer = _Streams.Peer;
278
- export type Activity = _Streams.Activity;
279
288
  export type Claim = _Streams.Claim;
280
289
  export type ClaimRejection = _Streams.ClaimRejection;
281
290
  export type ClaimLost = _Streams.ClaimLost;
package/src/client.ts CHANGED
@@ -18,7 +18,6 @@ import type {
18
18
  InferCreate,
19
19
  InferRow,
20
20
  } from '@abloatai/transaction/schema/schema';
21
- import type { PresenceStream } from '@abloatai/transaction/types/streams';
22
21
  import type { InstanceCache } from './local/InstanceCache.js';
23
22
  import type { SyncStoreContract } from './react/context.js';
24
23
  import type { SyncWebSocket, CoreSyncEventMap } from './local/sync/SyncWebSocket.js';
@@ -31,6 +30,7 @@ import type {
31
30
  import type { EffectiveAuthority } from '@abloatai/transaction/auth';
32
31
  import type { ReadDependency } from '@abloatai/transaction/coordination';
33
32
  import type { CapturedRow } from '@abloatai/transaction/transport/http';
33
+ import type { ReactivePresence } from './presence/index.js';
34
34
  export type { LocalReadOptions } from './local/client/resourceTypes.js';
35
35
 
36
36
  /** The typed sync engine client — one property per model in the schema */
@@ -232,20 +232,15 @@ export type AbloClient<S extends SchemaRecord> = {
232
232
  */
233
233
  readonly syncStatus: SyncStatus;
234
234
 
235
- /** The underlying schema */
236
- readonly schema: Schema<S>;
237
-
238
235
  /**
239
- * A real-time presence livestream — who else is connected on this engine's
240
- * sync groups, what they're doing, and a write surface for announcing this
241
- * user's own activity. It rides the engine's existing WebSocket; opening a
242
- * participant for presence does not open a second socket. See
243
- * {@link PresenceStream}.
244
- *
245
- * The reference is stable for the engine's lifetime — the underlying connection
246
- * is rotated on `dispose()`, but this object stays the same.
236
+ * Session-owned live activity projected from this client's existing
237
+ * connection. Use `active` for every visible activity, `others` to exclude
238
+ * this session, and `forModel(model, id?)` for a model-native view.
247
239
  */
248
- readonly presence: PresenceStream;
240
+ readonly presence: ReactivePresence;
241
+
242
+ /** The underlying schema */
243
+ readonly schema: Schema<S>;
249
244
 
250
245
  /**
251
246
  * @internal The supported coordination API is `ablo.<model>.claim`. This
package/src/humans.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { AbloPlugin, PluginContext, AppliedChange } from './plugin.js';
9
9
  import { AbloValidationError } from '@abloatai/transaction/errors';
10
- import { createPresenceStream, type AttachablePresenceStream } from './presenceStream.js';
10
+ import { createPresence, type AttachablePresence } from './presence/index.js';
11
11
  import {
12
12
  buildStoreCluster,
13
13
  kStoreCluster,
@@ -16,7 +16,7 @@ import {
16
16
  } from './local/client/storeCluster.js';
17
17
 
18
18
  export interface HumansSurface {
19
- readonly presence: AttachablePresenceStream;
19
+ readonly presence: AttachablePresence;
20
20
  readonly [kStoreCluster]?: StoreCluster;
21
21
  }
22
22
 
@@ -45,14 +45,7 @@ export function humans() {
45
45
  applyChanges = (changes) => { cluster.store.applyChangesToPool(changes); };
46
46
  }
47
47
  return {
48
- presence: createPresenceStream(
49
- {
50
- participantId: context.participant?.id ?? '',
51
- syncGroups: [...(context.syncGroups ?? [])],
52
- isAgent: context.participant?.kind === 'agent',
53
- },
54
- context.transport ?? null,
55
- ),
48
+ presence: createPresence(context.transport ?? null),
56
49
  ...(cluster ? { [kStoreCluster]: cluster } : {}),
57
50
  };
58
51
  },
@@ -35,7 +35,6 @@ import {
35
35
  type GroupRemovedPayload,
36
36
  type BootstrapHint,
37
37
  type BootstrapDataEvent,
38
- type PresenceUpdate,
39
38
  type EventMap,
40
39
  type DefaultCollaborationEvents,
41
40
  type SyncWebSocketEventMap,
@@ -238,7 +237,6 @@ export type {
238
237
  GroupRemovedPayload,
239
238
  BootstrapHint,
240
239
  BootstrapDataEvent,
241
- PresenceUpdate,
242
240
  };
243
241
 
244
242
  // deriveSyncPlanFromSchema derives a sync plan from a schema and is
@@ -1500,7 +1498,6 @@ export class BaseSyncedStore<
1500
1498
  applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
1501
1499
  handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
1502
1500
  handleBootstrapData: (data) => { this.handleBootstrapData(data); },
1503
- handlePresenceUpdate: (data) => { this.handlePresenceUpdate(data); },
1504
1501
  performCredentialRefresh: () => this.performCredentialRefresh(),
1505
1502
  handleTerminalSessionError: (error) => { this.terminalSessionLifecycle.start(error); },
1506
1503
  nudgeReconnect: () => { this.nudgeReconnect(); },
@@ -1935,9 +1932,6 @@ export class BaseSyncedStore<
1935
1932
  this.updateSyncStatus({ state: 'syncing' });
1936
1933
  }
1937
1934
 
1938
- /** Handle presence_update event. Override in subclass. */
1939
- protected handlePresenceUpdate(_data: PresenceUpdate): void {}
1940
-
1941
1935
  // ── Pending changes tracking ─────────────────────────────────────────────
1942
1936
 
1943
1937
  protected incrementPendingChanges(): void {