@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.
- package/dist/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/index.d.ts +1 -0
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +0 -3
- package/dist/local/InstanceCache.js +1 -1
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +26 -0
- package/dist/local/client/createModelOperations.js +85 -0
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +25 -11
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +40 -55
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- package/dist/local/transactions/mutations/MutationQueue.d.ts +6 -3
- 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/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +20 -0
- package/dist/presence/index.js +67 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- 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 +2 -0
- package/package.json +2 -2
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/index.ts +1 -0
- package/src/local/BaseSyncedStore.ts +0 -6
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +140 -0
- package/src/local/client/reactiveEngine.ts +35 -14
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/createClaimStream.ts +51 -71
- package/src/local/sync/socketEventWiring.ts +1 -8
- 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 +101 -0
- package/src/presence/readActivity.ts +149 -0
- package/src/react/AbloProvider.tsx +14 -9
- 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 +2 -0
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
236
|
+
const presence = presenceOfClient(engine);
|
|
236
237
|
const compute = () => groups.length === 0
|
|
237
238
|
? presence.others
|
|
238
|
-
: presence.others.filter((
|
|
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
|
-
|
|
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
|
}
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/react/useAblo.js
CHANGED
|
@@ -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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "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.
|
|
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/
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
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 {
|