@abloatai/humans 0.62.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 (40) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/local/InstanceCache.js +1 -1
  3. package/dist/local/client/createModelOperations.d.ts +21 -0
  4. package/dist/local/client/createModelOperations.js +84 -0
  5. package/dist/local/client/reactiveEngine.js +16 -0
  6. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -0
  7. package/dist/local/transactions/mutations/MutationQueue.js +36 -2
  8. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
  9. package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
  10. package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
  11. package/dist/local/transactions/mutations/failureHandling.js +1 -1
  12. package/dist/presence/index.d.ts +7 -2
  13. package/dist/presence/index.js +18 -0
  14. package/dist/presence/readActivity.d.ts +19 -0
  15. package/dist/presence/readActivity.js +122 -0
  16. package/dist/react/createAbloReact.d.ts +4 -0
  17. package/dist/react/createAbloReact.js +12 -2
  18. package/dist/react/useAblo.d.ts +2 -0
  19. package/dist/react/useAblo.js +10 -5
  20. package/dist/react/usePresence.d.ts +19 -0
  21. package/dist/react/usePresence.js +28 -0
  22. package/dist/react.d.ts +1 -0
  23. package/dist/react.js +1 -0
  24. package/dist/surface.d.ts +1 -1
  25. package/dist/surface.js +1 -0
  26. package/package.json +2 -2
  27. package/src/index.ts +1 -0
  28. package/src/local/InstanceCache.ts +1 -1
  29. package/src/local/client/createModelOperations.ts +132 -0
  30. package/src/local/client/reactiveEngine.ts +16 -0
  31. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  32. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  33. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  34. package/src/presence/index.ts +32 -3
  35. package/src/presence/readActivity.ts +149 -0
  36. package/src/react/createAbloReact.ts +25 -1
  37. package/src/react/useAblo.ts +14 -6
  38. package/src/react/usePresence.ts +88 -0
  39. package/src/react.ts +4 -0
  40. package/src/surface.ts +1 -0
@@ -0,0 +1,149 @@
1
+ import type {
2
+ PresenceCommand,
3
+ PresenceTarget,
4
+ } from '@abloatai/transaction/presence';
5
+ import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
6
+
7
+ /** The connection slice needed to keep one session-owned read alive. */
8
+ export interface ReadActivityTransport {
9
+ isConnected(): boolean;
10
+ sendPresenceCommand(command: PresenceCommand): void;
11
+ subscribe(event: 'connected', listener: () => void): () => void;
12
+ }
13
+
14
+ export interface ReadActivityLifetime {
15
+ /** End the read and remove it from the server, reconnecting briefly if needed. */
16
+ stop(): void;
17
+ /** Tear down local resources when the owning client itself is disposed. */
18
+ dispose(): void;
19
+ }
20
+
21
+ const READ_TTL_MS = LEASE_TTL_MS;
22
+ const READ_REFRESH_MS = READ_TTL_MS / 3;
23
+ let fallbackActivitySequence = 0;
24
+ const noop = (): void => undefined;
25
+
26
+ function readActivityId(): string {
27
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
28
+ return `read:${crypto.randomUUID()}`;
29
+ }
30
+ fallbackActivitySequence += 1;
31
+ return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
32
+ }
33
+
34
+ function unref(timer: ReturnType<typeof setTimeout>): void {
35
+ const candidate: unknown = timer;
36
+ if (
37
+ typeof candidate === 'object' &&
38
+ candidate !== null &&
39
+ 'unref' in candidate &&
40
+ typeof candidate.unref === 'function'
41
+ ) {
42
+ (candidate as { unref(): void }).unref();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Own the lease mechanics for one declared read. React only starts and stops
48
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
49
+ * cleanup remain inside the presence subsystem.
50
+ */
51
+ export function startReadActivity(
52
+ transport: ReadActivityTransport,
53
+ target: PresenceTarget,
54
+ onFinished: () => void = noop,
55
+ ): ReadActivityLifetime {
56
+ const activityId = readActivityId();
57
+ let active = true;
58
+ let disposed = false;
59
+ let removeOnConnect: (() => void) | null = null;
60
+ let removalExpiry: ReturnType<typeof setTimeout> | null = null;
61
+ let finished = false;
62
+
63
+ const finish = (): void => {
64
+ if (finished) return;
65
+ finished = true;
66
+ onFinished();
67
+ };
68
+
69
+ const send = (command: PresenceCommand): boolean => {
70
+ if (!transport.isConnected()) return false;
71
+ try {
72
+ transport.sendPresenceCommand(command);
73
+ return true;
74
+ } catch (error) {
75
+ // The socket can close between the state check and the synchronous send.
76
+ // Reconnect handling retries; a failure while still connected is real.
77
+ if (transport.isConnected()) throw error;
78
+ return false;
79
+ }
80
+ };
81
+
82
+ const upsert = (): void => {
83
+ if (!active || disposed) return;
84
+ send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
85
+ };
86
+
87
+ const unsubscribeConnected = transport.subscribe('connected', upsert);
88
+ upsert();
89
+
90
+ const refreshTimer = setInterval(() => {
91
+ if (!active || disposed) return;
92
+ send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
93
+ }, READ_REFRESH_MS);
94
+ unref(refreshTimer);
95
+
96
+ const clearResources = (): void => {
97
+ unsubscribeConnected();
98
+ clearInterval(refreshTimer);
99
+ removeOnConnect?.();
100
+ removeOnConnect = null;
101
+ if (removalExpiry !== null) clearTimeout(removalExpiry);
102
+ removalExpiry = null;
103
+ };
104
+
105
+ const stop = (): void => {
106
+ if (!active || disposed) return;
107
+ active = false;
108
+ unsubscribeConnected();
109
+ clearInterval(refreshTimer);
110
+
111
+ const remove = (): void => {
112
+ if (disposed) return;
113
+ if (!send({ type: 'read.remove', activityId })) return;
114
+ removeOnConnect?.();
115
+ removeOnConnect = null;
116
+ if (removalExpiry !== null) clearTimeout(removalExpiry);
117
+ removalExpiry = null;
118
+ finish();
119
+ };
120
+
121
+ if (transport.isConnected()) {
122
+ remove();
123
+ return;
124
+ }
125
+
126
+ // If the component leaves offline, remove on the next reconnect so the
127
+ // resumed logical session cannot briefly show a departed viewer. Once the
128
+ // lease expires server-side, there is nothing left to remove.
129
+ removeOnConnect = transport.subscribe('connected', remove);
130
+ removalExpiry = setTimeout(() => {
131
+ removeOnConnect?.();
132
+ removeOnConnect = null;
133
+ removalExpiry = null;
134
+ finish();
135
+ }, READ_TTL_MS);
136
+ unref(removalExpiry);
137
+ };
138
+
139
+ return {
140
+ stop,
141
+ dispose() {
142
+ if (disposed) return;
143
+ disposed = true;
144
+ active = false;
145
+ clearResources();
146
+ finish();
147
+ },
148
+ };
149
+ }
@@ -29,6 +29,7 @@ import {
29
29
  } from './AbloProvider.js';
30
30
  import {
31
31
  useAbloImpl,
32
+ useAbloClientImpl,
32
33
  type AbloSelector,
33
34
  type ModelClientSelector,
34
35
  type UseAbloHydratedModelResult,
@@ -38,6 +39,11 @@ import {
38
39
  import type { AbloClient as Ablo } from '../client.js';
39
40
  import type { ModelOperations } from '../local/client/createModelOperations.js';
40
41
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
42
+ import {
43
+ usePresenceImpl,
44
+ type PresenceModelSelector,
45
+ } from './usePresence.js';
46
+ import type { PresenceSession } from '@abloatai/transaction/presence';
41
47
 
42
48
  /** What a binding returns: the provider and the hook, with `S` fixed. */
43
49
  export interface AbloReactBinding<S extends SchemaRecord> {
@@ -60,6 +66,11 @@ export interface AbloReactBinding<S extends SchemaRecord> {
60
66
  options?: UseAbloModelOptions<T>,
61
67
  ): UseAbloModelResult<T>;
62
68
  };
69
+ /** Declare and reactively read record presence with the same model clients. */
70
+ usePresence: <T, C>(
71
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
72
+ recordId: string,
73
+ ) => readonly PresenceSession[];
63
74
  }
64
75
 
65
76
  /**
@@ -112,5 +123,18 @@ export function createAbloReact<S extends SchemaRecord>(
112
123
  return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
113
124
  }
114
125
 
115
- return { AbloProvider: BoundAbloProvider, useAblo: useBoundAblo };
126
+ function useBoundPresence<T, C>(
127
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
128
+ recordId: string,
129
+ ): readonly PresenceSession[] {
130
+ const bound = useContext(BoundClientContext);
131
+ const engine = useAbloClientImpl(bound);
132
+ return usePresenceImpl(engine, modelOrSelect, recordId);
133
+ }
134
+
135
+ return {
136
+ AbloProvider: BoundAbloProvider,
137
+ useAblo: useBoundAblo,
138
+ usePresence: useBoundPresence,
139
+ };
116
140
  }
@@ -223,12 +223,7 @@ export function useAbloImpl<
223
223
  id?: string,
224
224
  options?: UseAbloModelOptions<T>,
225
225
  ): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
226
- const ctx = useContext(AbloInternalContext);
227
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
228
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
229
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
230
- const engine: Ablo<R> | null =
231
- boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
226
+ const engine = useAbloClientImpl(boundClient);
232
227
  const initial = options?.initial;
233
228
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
234
229
  const modelClient: ModelOperations<T, C> | undefined =
@@ -278,3 +273,16 @@ export function useAbloImpl<
278
273
  if (modelOrSelect) return modelResult;
279
274
  return engine;
280
275
  }
276
+
277
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
278
+ export function useAbloClientImpl<R extends SchemaRecord>(
279
+ boundClient: Ablo<R> | null,
280
+ ): Ablo<R> | null {
281
+ const ctx = useContext(AbloInternalContext);
282
+ // The bound client wins — it is already `Ablo<R>`, no rebinding. The
283
+ // fallback is the ONE remaining schema rebind in the SDK; it retires with
284
+ // the last legacy provider mount (docs/plans/typed-react-binding.md).
285
+ const engine: Ablo<R> | null =
286
+ boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
287
+ return engine;
288
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { PresenceSession } from '@abloatai/transaction/presence';
5
+ import { AbloValidationError } from '@abloatai/transaction/errors';
6
+ import {
7
+ getModelClientMeta,
8
+ type ModelOperations,
9
+ } from '../local/client/createModelOperations.js';
10
+ import type { AbloClient as Ablo } from '../client.js';
11
+ import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
12
+ import type { ResolveSchema } from '@abloatai/transaction/types/global';
13
+ import { useAbloClientImpl } from './useAblo.js';
14
+
15
+ type DefaultModels = ResolveSchema extends { models: infer M }
16
+ ? M extends SchemaRecord
17
+ ? M
18
+ : SchemaRecord
19
+ : SchemaRecord;
20
+
21
+ export type PresenceModelSelector<R extends SchemaRecord, T, C> =
22
+ (ablo: Ablo<R>) => ModelOperations<T, C>;
23
+
24
+ /**
25
+ * Declare that this component is reading one row and return every live session
26
+ * reading or otherwise acting on that row. Ablo owns the lease, refresh,
27
+ * reconnect, and cleanup mechanics for the component's lifetime.
28
+ */
29
+ export function usePresence<T, C>(
30
+ modelClient: ModelOperations<T, C>,
31
+ recordId: string,
32
+ ): readonly PresenceSession[];
33
+ export function usePresence<
34
+ R extends SchemaRecord = DefaultModels,
35
+ T = Record<string, unknown>,
36
+ C = unknown,
37
+ >(
38
+ select: PresenceModelSelector<R, T, C>,
39
+ recordId: string,
40
+ ): readonly PresenceSession[];
41
+ export function usePresence<
42
+ R extends SchemaRecord = DefaultModels,
43
+ T = Record<string, unknown>,
44
+ C = unknown,
45
+ >(
46
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
47
+ recordId: string,
48
+ ): readonly PresenceSession[] {
49
+ const engine = useAbloClientImpl<R>(null);
50
+ return usePresenceImpl(engine, modelOrSelect, recordId);
51
+ }
52
+
53
+ /** @internal Shared by the global hook and schema-bound React factory. */
54
+ export function usePresenceImpl<R extends SchemaRecord, T, C>(
55
+ engine: Ablo<R> | null,
56
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
57
+ recordId: string,
58
+ ): readonly PresenceSession[] {
59
+ if (recordId.length === 0) {
60
+ throw new AbloValidationError(
61
+ 'usePresence requires a non-empty record id.',
62
+ { code: 'invalid_request', param: 'recordId' },
63
+ );
64
+ }
65
+ const modelClient = typeof modelOrSelect === 'function'
66
+ ? engine
67
+ ? modelOrSelect(engine)
68
+ : null
69
+ : modelOrSelect;
70
+ const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
71
+ if (modelClient !== null && presence === undefined) {
72
+ throw new AbloValidationError(
73
+ 'usePresence requires a model from the reactive Ablo client.',
74
+ { code: 'invalid_request', param: 'modelClient' },
75
+ );
76
+ }
77
+
78
+ const [, render] = useState(0);
79
+
80
+ useEffect(
81
+ () => presence?.subscribe(() => { render((version) => version + 1); }),
82
+ [presence],
83
+ );
84
+
85
+ useEffect(() => presence?.read(recordId), [presence, recordId]);
86
+
87
+ return presence?.get(recordId) ?? [];
88
+ }
package/src/react.ts CHANGED
@@ -3,6 +3,10 @@ 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 {
7
+ usePresence,
8
+ type PresenceModelSelector,
9
+ } from './react/usePresence.js';
6
10
  export {
7
11
  useMutationFailureListener,
8
12
  type MutationFailurePayload,
package/src/surface.ts CHANGED
@@ -38,6 +38,7 @@ export const PUBLIC_MODEL_VERBS = [
38
38
  'listAll',
39
39
  'local',
40
40
  'presence',
41
+ 'events',
41
42
  'create',
42
43
  'update',
43
44
  'delete',