@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.
Files changed (83) 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/index.d.ts +1 -0
  7. package/dist/local/BaseSyncedStore.d.ts +2 -4
  8. package/dist/local/BaseSyncedStore.js +0 -3
  9. package/dist/local/InstanceCache.js +1 -1
  10. package/dist/local/SyncClient.d.ts +3 -3
  11. package/dist/local/client/clientPrelude.d.ts +2 -0
  12. package/dist/local/client/clientPrelude.js +13 -1
  13. package/dist/local/client/createInternalComponents.js +2 -0
  14. package/dist/local/client/createModelOperations.d.ts +26 -0
  15. package/dist/local/client/createModelOperations.js +85 -0
  16. package/dist/local/client/reactiveEngine.d.ts +2 -2
  17. package/dist/local/client/reactiveEngine.js +25 -11
  18. package/dist/local/query/client.d.ts +3 -0
  19. package/dist/local/query/client.js +1 -1
  20. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  21. package/dist/local/sync/BootstrapFetcher.js +4 -4
  22. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  23. package/dist/local/sync/OnDemandLoader.js +1 -0
  24. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  25. package/dist/local/sync/SyncWebSocket.js +1 -36
  26. package/dist/local/sync/createClaimStream.d.ts +9 -19
  27. package/dist/local/sync/createClaimStream.js +40 -55
  28. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  29. package/dist/local/sync/socketEventWiring.js +1 -5
  30. package/dist/local/transactions/mutations/MutationQueue.d.ts +6 -3
  31. package/dist/local/transactions/mutations/MutationQueue.js +36 -2
  32. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
  33. package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
  34. package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
  35. package/dist/local/transactions/mutations/failureHandling.js +1 -1
  36. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  37. package/dist/presence/index.d.ts +20 -0
  38. package/dist/presence/index.js +67 -0
  39. package/dist/presence/readActivity.d.ts +19 -0
  40. package/dist/presence/readActivity.js +122 -0
  41. package/dist/react/AbloProvider.d.ts +3 -3
  42. package/dist/react/AbloProvider.js +4 -3
  43. package/dist/react/createAbloReact.d.ts +4 -0
  44. package/dist/react/createAbloReact.js +12 -2
  45. package/dist/react/useAblo.d.ts +2 -0
  46. package/dist/react/useAblo.js +10 -5
  47. package/dist/react/usePresence.d.ts +19 -0
  48. package/dist/react/usePresence.js +28 -0
  49. package/dist/react.d.ts +1 -0
  50. package/dist/react.js +1 -0
  51. package/dist/surface.d.ts +1 -1
  52. package/dist/surface.js +2 -0
  53. package/package.json +2 -2
  54. package/src/Ablo.ts +15 -6
  55. package/src/client.ts +8 -13
  56. package/src/humans.ts +3 -10
  57. package/src/index.ts +1 -0
  58. package/src/local/BaseSyncedStore.ts +0 -6
  59. package/src/local/InstanceCache.ts +1 -1
  60. package/src/local/client/clientPrelude.ts +20 -0
  61. package/src/local/client/createInternalComponents.ts +2 -0
  62. package/src/local/client/createModelOperations.ts +140 -0
  63. package/src/local/client/reactiveEngine.ts +35 -14
  64. package/src/local/query/client.ts +4 -0
  65. package/src/local/sync/BootstrapFetcher.ts +13 -4
  66. package/src/local/sync/OnDemandLoader.ts +3 -0
  67. package/src/local/sync/SyncWebSocket.ts +1 -42
  68. package/src/local/sync/createClaimStream.ts +51 -71
  69. package/src/local/sync/socketEventWiring.ts +1 -8
  70. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  71. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  72. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  73. package/src/presence/index.ts +101 -0
  74. package/src/presence/readActivity.ts +149 -0
  75. package/src/react/AbloProvider.tsx +14 -9
  76. package/src/react/createAbloReact.ts +25 -1
  77. package/src/react/useAblo.ts +14 -6
  78. package/src/react/usePresence.ts +88 -0
  79. package/src/react.ts +4 -0
  80. package/src/surface.ts +2 -0
  81. package/dist/presenceStream.d.ts +0 -69
  82. package/dist/presenceStream.js +0 -200
  83. package/src/presenceStream.ts +0 -279
@@ -0,0 +1,20 @@
1
+ import { type PresenceProjection, type PresenceProjectionEvents, type PresenceView } from '@abloatai/transaction/presence';
2
+ import type { PresenceTarget } from '@abloatai/transaction/presence';
3
+ import { type ReadActivityTransport } from './readActivity.js';
4
+ type PresenceTransport = PresenceProjectionEvents & ReadActivityTransport;
5
+ /** Reactive-client presence backed by the client's existing live connection. */
6
+ export interface ReactivePresence extends PresenceView {
7
+ forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
8
+ onChange(listener: () => void): () => void;
9
+ }
10
+ /** Lifecycle hooks kept inside the humans composition boundary. */
11
+ export interface AttachablePresence extends ReactivePresence {
12
+ attach(transport: PresenceTransport): void;
13
+ startRead(target: PresenceTarget): () => void;
14
+ dispose(): void;
15
+ }
16
+ /** Framework bridge that does not consume a string key on the model namespace. */
17
+ export declare function attachPresenceToClient(client: object, presence: ReactivePresence): void;
18
+ export declare function presenceOfClient(client: object): ReactivePresence;
19
+ export declare function createPresence(transport?: PresenceTransport | null): AttachablePresence;
20
+ export {};
@@ -0,0 +1,67 @@
1
+ import { createPresenceProjection, } from '@abloatai/transaction/presence';
2
+ import { startReadActivity, } from './readActivity.js';
3
+ const clientPresence = new WeakMap();
4
+ /** Framework bridge that does not consume a string key on the model namespace. */
5
+ export function attachPresenceToClient(client, presence) {
6
+ clientPresence.set(client, presence);
7
+ }
8
+ export function presenceOfClient(client) {
9
+ const presence = clientPresence.get(client);
10
+ if (presence === undefined)
11
+ throw new Error('presence is not attached to this client');
12
+ return presence;
13
+ }
14
+ export function createPresence(transport = null) {
15
+ let projection = null;
16
+ let attachedTransport = null;
17
+ const listeners = new Set();
18
+ const reads = new Set();
19
+ let unsubscribe = null;
20
+ const notify = () => {
21
+ for (const listener of listeners)
22
+ listener();
23
+ };
24
+ const attach = (events) => {
25
+ if (projection !== null)
26
+ return;
27
+ attachedTransport = events;
28
+ projection = createPresenceProjection(events);
29
+ unsubscribe = projection.subscribe(notify);
30
+ notify();
31
+ };
32
+ if (transport !== null)
33
+ attach(transport);
34
+ return {
35
+ get active() { return projection?.active ?? []; },
36
+ get others() { return projection?.others ?? []; },
37
+ onChange(listener) {
38
+ listeners.add(listener);
39
+ return () => { listeners.delete(listener); };
40
+ },
41
+ attach,
42
+ startRead(target) {
43
+ if (attachedTransport === null) {
44
+ throw new Error('presence is not attached to a duplex transport');
45
+ }
46
+ const lifetime = startReadActivity(attachedTransport, target, () => { reads.delete(lifetime); });
47
+ reads.add(lifetime);
48
+ return () => {
49
+ lifetime.stop();
50
+ };
51
+ },
52
+ forModel(model, recordId) {
53
+ return projection?.forModel(model, recordId) ?? [];
54
+ },
55
+ dispose() {
56
+ for (const read of reads)
57
+ read.dispose();
58
+ reads.clear();
59
+ unsubscribe?.();
60
+ unsubscribe = null;
61
+ projection?.dispose();
62
+ projection = null;
63
+ attachedTransport = null;
64
+ listeners.clear();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,19 @@
1
+ import type { PresenceCommand, PresenceTarget } from '@abloatai/transaction/presence';
2
+ /** The connection slice needed to keep one session-owned read alive. */
3
+ export interface ReadActivityTransport {
4
+ isConnected(): boolean;
5
+ sendPresenceCommand(command: PresenceCommand): void;
6
+ subscribe(event: 'connected', listener: () => void): () => void;
7
+ }
8
+ export interface ReadActivityLifetime {
9
+ /** End the read and remove it from the server, reconnecting briefly if needed. */
10
+ stop(): void;
11
+ /** Tear down local resources when the owning client itself is disposed. */
12
+ dispose(): void;
13
+ }
14
+ /**
15
+ * Own the lease mechanics for one declared read. React only starts and stops
16
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
17
+ * cleanup remain inside the presence subsystem.
18
+ */
19
+ export declare function startReadActivity(transport: ReadActivityTransport, target: PresenceTarget, onFinished?: () => void): ReadActivityLifetime;
@@ -0,0 +1,122 @@
1
+ import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
2
+ const READ_TTL_MS = LEASE_TTL_MS;
3
+ const READ_REFRESH_MS = READ_TTL_MS / 3;
4
+ let fallbackActivitySequence = 0;
5
+ const noop = () => undefined;
6
+ function readActivityId() {
7
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
8
+ return `read:${crypto.randomUUID()}`;
9
+ }
10
+ fallbackActivitySequence += 1;
11
+ return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
12
+ }
13
+ function unref(timer) {
14
+ const candidate = timer;
15
+ if (typeof candidate === 'object' &&
16
+ candidate !== null &&
17
+ 'unref' in candidate &&
18
+ typeof candidate.unref === 'function') {
19
+ candidate.unref();
20
+ }
21
+ }
22
+ /**
23
+ * Own the lease mechanics for one declared read. React only starts and stops
24
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
25
+ * cleanup remain inside the presence subsystem.
26
+ */
27
+ export function startReadActivity(transport, target, onFinished = noop) {
28
+ const activityId = readActivityId();
29
+ let active = true;
30
+ let disposed = false;
31
+ let removeOnConnect = null;
32
+ let removalExpiry = null;
33
+ let finished = false;
34
+ const finish = () => {
35
+ if (finished)
36
+ return;
37
+ finished = true;
38
+ onFinished();
39
+ };
40
+ const send = (command) => {
41
+ if (!transport.isConnected())
42
+ return false;
43
+ try {
44
+ transport.sendPresenceCommand(command);
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ // The socket can close between the state check and the synchronous send.
49
+ // Reconnect handling retries; a failure while still connected is real.
50
+ if (transport.isConnected())
51
+ throw error;
52
+ return false;
53
+ }
54
+ };
55
+ const upsert = () => {
56
+ if (!active || disposed)
57
+ return;
58
+ send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
59
+ };
60
+ const unsubscribeConnected = transport.subscribe('connected', upsert);
61
+ upsert();
62
+ const refreshTimer = setInterval(() => {
63
+ if (!active || disposed)
64
+ return;
65
+ send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
66
+ }, READ_REFRESH_MS);
67
+ unref(refreshTimer);
68
+ const clearResources = () => {
69
+ unsubscribeConnected();
70
+ clearInterval(refreshTimer);
71
+ removeOnConnect?.();
72
+ removeOnConnect = null;
73
+ if (removalExpiry !== null)
74
+ clearTimeout(removalExpiry);
75
+ removalExpiry = null;
76
+ };
77
+ const stop = () => {
78
+ if (!active || disposed)
79
+ return;
80
+ active = false;
81
+ unsubscribeConnected();
82
+ clearInterval(refreshTimer);
83
+ const remove = () => {
84
+ if (disposed)
85
+ return;
86
+ if (!send({ type: 'read.remove', activityId }))
87
+ return;
88
+ removeOnConnect?.();
89
+ removeOnConnect = null;
90
+ if (removalExpiry !== null)
91
+ clearTimeout(removalExpiry);
92
+ removalExpiry = null;
93
+ finish();
94
+ };
95
+ if (transport.isConnected()) {
96
+ remove();
97
+ return;
98
+ }
99
+ // If the component leaves offline, remove on the next reconnect so the
100
+ // resumed logical session cannot briefly show a departed viewer. Once the
101
+ // lease expires server-side, there is nothing left to remove.
102
+ removeOnConnect = transport.subscribe('connected', remove);
103
+ removalExpiry = setTimeout(() => {
104
+ removeOnConnect?.();
105
+ removeOnConnect = null;
106
+ removalExpiry = null;
107
+ finish();
108
+ }, READ_TTL_MS);
109
+ unref(removalExpiry);
110
+ };
111
+ return {
112
+ stop,
113
+ dispose() {
114
+ if (disposed)
115
+ return;
116
+ disposed = true;
117
+ active = false;
118
+ clearResources();
119
+ finish();
120
+ },
121
+ };
122
+ }
@@ -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.
@@ -25,6 +25,8 @@ import { type AbloSelector, type ModelClientSelector, type UseAbloHydratedModelR
25
25
  import type { AbloClient as Ablo } from '../client.js';
26
26
  import type { ModelOperations } from '../local/client/createModelOperations.js';
27
27
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
28
+ import { type PresenceModelSelector } from './usePresence.js';
29
+ import type { PresenceSession } from '@abloatai/transaction/presence';
28
30
  /** What a binding returns: the provider and the hook, with `S` fixed. */
29
31
  export interface AbloReactBinding<S extends SchemaRecord> {
30
32
  /** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
@@ -40,6 +42,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
40
42
  }): UseAbloHydratedModelResult<T>;
41
43
  <T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
42
44
  };
45
+ /** Declare and reactively read record presence with the same model clients. */
46
+ usePresence: <T, C>(modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>, recordId: string) => readonly PresenceSession[];
43
47
  }
44
48
  /**
45
49
  * Bind the react surface to one schema. The schema value is taken for
@@ -22,7 +22,8 @@
22
22
  */
23
23
  import { createContext, createElement, useContext } from 'react';
24
24
  import { AbloProvider, } from './AbloProvider.js';
25
- import { useAbloImpl, } from './useAblo.js';
25
+ import { useAbloImpl, useAbloClientImpl, } from './useAblo.js';
26
+ import { usePresenceImpl, } from './usePresence.js';
26
27
  /**
27
28
  * Bind the react surface to one schema. The schema value is taken for
28
29
  * inference — write `createAbloReact(schema)`, never a hand-spelled type
@@ -44,5 +45,14 @@ export function createAbloReact(schema) {
44
45
  const bound = useContext(BoundClientContext);
45
46
  return useAbloImpl(bound, modelOrSelect, id, options);
46
47
  }
47
- return { AbloProvider: BoundAbloProvider, useAblo: useBoundAblo };
48
+ function useBoundPresence(modelOrSelect, recordId) {
49
+ const bound = useContext(BoundClientContext);
50
+ const engine = useAbloClientImpl(bound);
51
+ return usePresenceImpl(engine, modelOrSelect, recordId);
52
+ }
53
+ return {
54
+ AbloProvider: BoundAbloProvider,
55
+ useAblo: useBoundAblo,
56
+ usePresence: useBoundPresence,
57
+ };
48
58
  }
@@ -93,4 +93,6 @@ export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Reco
93
93
  * legacy mount migrates.
94
94
  */
95
95
  export declare function useAbloImpl<R extends SchemaRecord, T = Record<string, unknown>, C = unknown>(boundClient: Ablo<R> | null, modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>, id?: string, options?: UseAbloModelOptions<T>): Ablo<R> | null | UseAbloModelResult<T> | T | undefined;
96
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
97
+ export declare function useAbloClientImpl<R extends SchemaRecord>(boundClient: Ablo<R> | null): Ablo<R> | null;
96
98
  export {};
@@ -70,11 +70,7 @@ export function useAblo(modelOrSelect, id, options) {
70
70
  * legacy mount migrates.
71
71
  */
72
72
  export function useAbloImpl(boundClient, modelOrSelect, id, options) {
73
- const ctx = useContext(AbloInternalContext);
74
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
75
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
76
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
77
- const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
73
+ const engine = useAbloClientImpl(boundClient);
78
74
  const initial = options?.initial;
79
75
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
80
76
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
@@ -118,3 +114,12 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
118
114
  return modelResult;
119
115
  return engine;
120
116
  }
117
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
118
+ export function useAbloClientImpl(boundClient) {
119
+ const ctx = useContext(AbloInternalContext);
120
+ // The bound client wins — it is already `Ablo<R>`, no rebinding. The
121
+ // fallback is the ONE remaining schema rebind in the SDK; it retires with
122
+ // the last legacy provider mount (docs/plans/typed-react-binding.md).
123
+ const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
124
+ return engine;
125
+ }
@@ -0,0 +1,19 @@
1
+ import type { PresenceSession } from '@abloatai/transaction/presence';
2
+ import { type ModelOperations } from '../local/client/createModelOperations.js';
3
+ import type { AbloClient as Ablo } from '../client.js';
4
+ import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
5
+ import type { ResolveSchema } from '@abloatai/transaction/types/global';
6
+ type DefaultModels = ResolveSchema extends {
7
+ models: infer M;
8
+ } ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
9
+ export type PresenceModelSelector<R extends SchemaRecord, T, C> = (ablo: Ablo<R>) => ModelOperations<T, C>;
10
+ /**
11
+ * Declare that this component is reading one row and return every live session
12
+ * reading or otherwise acting on that row. Ablo owns the lease, refresh,
13
+ * reconnect, and cleanup mechanics for the component's lifetime.
14
+ */
15
+ export declare function usePresence<T, C>(modelClient: ModelOperations<T, C>, recordId: string): readonly PresenceSession[];
16
+ export declare function usePresence<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
17
+ /** @internal Shared by the global hook and schema-bound React factory. */
18
+ export declare function usePresenceImpl<R extends SchemaRecord, T, C>(engine: Ablo<R> | null, modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
19
+ export {};
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { AbloValidationError } from '@abloatai/transaction/errors';
4
+ import { getModelClientMeta, } from '../local/client/createModelOperations.js';
5
+ import { useAbloClientImpl } from './useAblo.js';
6
+ export function usePresence(modelOrSelect, recordId) {
7
+ const engine = useAbloClientImpl(null);
8
+ return usePresenceImpl(engine, modelOrSelect, recordId);
9
+ }
10
+ /** @internal Shared by the global hook and schema-bound React factory. */
11
+ export function usePresenceImpl(engine, modelOrSelect, recordId) {
12
+ if (recordId.length === 0) {
13
+ throw new AbloValidationError('usePresence requires a non-empty record id.', { code: 'invalid_request', param: 'recordId' });
14
+ }
15
+ const modelClient = typeof modelOrSelect === 'function'
16
+ ? engine
17
+ ? modelOrSelect(engine)
18
+ : null
19
+ : modelOrSelect;
20
+ const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
21
+ if (modelClient !== null && presence === undefined) {
22
+ throw new AbloValidationError('usePresence requires a model from the reactive Ablo client.', { code: 'invalid_request', param: 'modelClient' });
23
+ }
24
+ const [, render] = useState(0);
25
+ useEffect(() => presence?.subscribe(() => { render((version) => version + 1); }), [presence]);
26
+ useEffect(() => presence?.read(recordId), [presence, recordId]);
27
+ return presence?.get(recordId) ?? [];
28
+ }
package/dist/react.d.ts CHANGED
@@ -3,6 +3,7 @@ export { useReactive } from './useReactive.js';
3
3
  export { useCurrentUserId } from './react/useCurrentUserId.js';
4
4
  export { useErrorListener } from './react/useErrorListener.js';
5
5
  export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
+ export { usePresence, type PresenceModelSelector, } from './react/usePresence.js';
6
7
  export { useMutationFailureListener, type MutationFailurePayload, } from './react/useMutationFailureListener.js';
7
8
  export { AbloProvider, usePeers, useSync, useSyncStore, type AbloProviderProps, type GroupScope, } from './react/AbloProvider.js';
8
9
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './react/ClientSideSuspense.js';
package/dist/react.js CHANGED
@@ -3,6 +3,7 @@ export { useReactive } from './useReactive.js';
3
3
  export { useCurrentUserId } from './react/useCurrentUserId.js';
4
4
  export { useErrorListener } from './react/useErrorListener.js';
5
5
  export { useSyncStatus } from './react/useSyncStatus.js';
6
+ export { usePresence, } from './react/usePresence.js';
6
7
  export { useMutationFailureListener, } from './react/useMutationFailureListener.js';
7
8
  export { AbloProvider, usePeers, useSync, useSyncStore, } from './react/AbloProvider.js';
8
9
  export { ClientSideSuspense, } from './react/ClientSideSuspense.js';
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", "events", "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,8 @@ export const PUBLIC_MODEL_VERBS = [
26
26
  'list',
27
27
  'listAll',
28
28
  'local',
29
+ 'presence',
30
+ 'events',
29
31
  'create',
30
32
  'update',
31
33
  'delete',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.61.0",
3
+ "version": "0.63.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.63.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
  },
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type {
9
9
  } from './Ablo.js';
10
10
  export { humans, type HumansSurface } from './humans.js';
11
11
  export type { AbloClient } from './client.js';
12
+ export type { CollaborationEventContext } from '@abloatai/transaction/collaboration';
12
13
  export type {
13
14
  AbloPlugin,
14
15
  MergedSurface,
@@ -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 {