@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.
- package/dist/index.d.ts +1 -0
- package/dist/local/InstanceCache.js +1 -1
- package/dist/local/client/createModelOperations.d.ts +21 -0
- package/dist/local/client/createModelOperations.js +84 -0
- package/dist/local/client/reactiveEngine.js +16 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -0
- package/dist/local/transactions/mutations/MutationQueue.js +36 -2
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
- package/dist/local/transactions/mutations/failureHandling.js +1 -1
- package/dist/presence/index.d.ts +7 -2
- package/dist/presence/index.js +18 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +2 -0
- package/dist/react/useAblo.js +10 -5
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/createModelOperations.ts +132 -0
- package/src/local/client/reactiveEngine.ts +16 -0
- package/src/local/transactions/mutations/MutationQueue.ts +26 -2
- package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
- package/src/local/transactions/mutations/failureHandling.ts +2 -1
- package/src/presence/index.ts +32 -3
- package/src/presence/readActivity.ts +149 -0
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +14 -6
- package/src/react/usePresence.ts +88 -0
- package/src/react.ts +4 -0
- package/src/surface.ts +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
|
-
|
|
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
|
}
|
package/src/react/useAblo.ts
CHANGED
|
@@ -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
|
|
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,
|