@abloatai/humans 0.62.0 → 0.63.1
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/AbloProvider.d.ts +2 -18
- package/dist/react/AbloProvider.js +11 -27
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +3 -1
- package/dist/react/useAblo.js +13 -11
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react/useSyncStatus.js +14 -3
- 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/dist/useReactive.js +1 -3
- 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/AbloProvider.tsx +14 -54
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +18 -13
- package/src/react/usePresence.ts +88 -0
- package/src/react/useSyncStatus.ts +14 -3
- package/src/react.ts +4 -0
- package/src/surface.ts +1 -0
- package/src/useReactive.ts +1 -3
|
@@ -44,7 +44,7 @@ export function AbloProvider(props) {
|
|
|
44
44
|
const schema = engine.schema;
|
|
45
45
|
// Account scope isn't a prop — read it from `_store.orgId` once `ready()`
|
|
46
46
|
// resolves the identity from the client's auth.
|
|
47
|
-
const [
|
|
47
|
+
const [resolvedScope, setResolvedScope] = useState(null);
|
|
48
48
|
// ── Error emitter (provider-instance scoped) ─────────────────────
|
|
49
49
|
const errorEmitterRef = useRef(null);
|
|
50
50
|
if (!errorEmitterRef.current) {
|
|
@@ -98,7 +98,10 @@ export function AbloProvider(props) {
|
|
|
98
98
|
.then(() => {
|
|
99
99
|
if (stale)
|
|
100
100
|
return;
|
|
101
|
-
|
|
101
|
+
setResolvedScope({
|
|
102
|
+
engine,
|
|
103
|
+
account: engine._store.orgId ?? null,
|
|
104
|
+
});
|
|
102
105
|
})
|
|
103
106
|
.catch((err) => {
|
|
104
107
|
if (stale)
|
|
@@ -133,7 +136,7 @@ export function AbloProvider(props) {
|
|
|
133
136
|
// unknown until `ready()` resolves identity — so `syncValue` is null until
|
|
134
137
|
// then, which drives the initial fallback below.
|
|
135
138
|
const syncValue = useMemo(() => {
|
|
136
|
-
const currentAccountScope =
|
|
139
|
+
const currentAccountScope = (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
|
|
137
140
|
engine._store.orgId;
|
|
138
141
|
if (!currentAccountScope)
|
|
139
142
|
return null;
|
|
@@ -142,7 +145,7 @@ export function AbloProvider(props) {
|
|
|
142
145
|
organizationId: currentAccountScope,
|
|
143
146
|
schema,
|
|
144
147
|
};
|
|
145
|
-
}, [engine,
|
|
148
|
+
}, [engine, resolvedScope, schema]);
|
|
146
149
|
// ── Internal context (currentUserId + error subscription) ────────
|
|
147
150
|
const internalValue = useMemo(() => ({
|
|
148
151
|
currentUserId: userId ?? null,
|
|
@@ -152,28 +155,10 @@ export function AbloProvider(props) {
|
|
|
152
155
|
}), [userId, errorEmitter, engine]);
|
|
153
156
|
// ── Render ───────────────────────────────────────────────────────
|
|
154
157
|
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// 1. Engine is null on first render (constructed in the effect
|
|
158
|
-
// above, not in render). We render `fallback` directly — there
|
|
159
|
-
// is no SyncContext to read status from, and by definition the
|
|
160
|
-
// engine hasn't started bootstrapping.
|
|
161
|
-
// 2. Engine exists. Mount SyncContext. `BootstrapGate` then reads
|
|
162
|
-
// `useSyncStatus()` and shows `fallback` only during the very
|
|
163
|
-
// first `connecting` transition; children render on every
|
|
164
|
-
// subsequent state change, including reconnects and auth
|
|
165
|
-
// failures (the app's own UI handles those).
|
|
166
|
-
//
|
|
167
|
-
// `fallback === 'passthrough'` short-circuits both branches — children
|
|
168
|
-
// render immediately without any gate, restoring pre-gate behavior
|
|
169
|
-
// for consumers who need debug helpers / error boundaries / analytics
|
|
170
|
-
// to mount before the engine is ready.
|
|
158
|
+
// Keep the context tree stable during startup so passthrough children retain
|
|
159
|
+
// their component state when authenticated row scope becomes available.
|
|
171
160
|
const passthrough = fallback === 'passthrough';
|
|
172
|
-
|
|
173
|
-
if (!syncValue) {
|
|
174
|
-
return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: initialFallback }));
|
|
175
|
-
}
|
|
176
|
-
return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) }) }));
|
|
161
|
+
return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : syncValue ? (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) : fallback }) }));
|
|
177
162
|
}
|
|
178
163
|
/**
|
|
179
164
|
* Internal gate that renders `fallback` only during the very first
|
|
@@ -183,8 +168,7 @@ export function AbloProvider(props) {
|
|
|
183
168
|
* re-show the fallback, because by then the app has already rendered
|
|
184
169
|
* once and its own reconnect UI should take over.
|
|
185
170
|
*
|
|
186
|
-
* Re-keyed
|
|
187
|
-
* (userId/org/url change) reset the latch — a new engine genuinely IS
|
|
171
|
+
* Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
|
|
188
172
|
* a new "first bootstrap" cycle.
|
|
189
173
|
*/
|
|
190
174
|
function BootstrapGate({ fallback, children, }) {
|
|
@@ -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
|
@@ -60,7 +60,7 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
|
|
|
60
60
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
61
61
|
* // accessors — matching what the hook actually returns:
|
|
62
62
|
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
63
|
-
* const
|
|
63
|
+
* const { claimed } = useAblo((ablo) => ablo.records, id);
|
|
64
64
|
*
|
|
65
65
|
* // Without the augmentation, pass the schema as a type argument:
|
|
66
66
|
* const ablo = useAblo<(typeof schema)['models']>();
|
|
@@ -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
|
|
@@ -86,12 +82,9 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
|
|
|
86
82
|
: modelOrSelect;
|
|
87
83
|
// Claims arrive through an event emitter (engine.claims), not through MobX, so
|
|
88
84
|
// the useReactive reactions below cannot track them; we bridge changes with a
|
|
89
|
-
// setState bump instead.
|
|
90
|
-
// claims
|
|
91
|
-
//
|
|
92
|
-
// re-render and recompute it on every claim or presence change anywhere — a
|
|
93
|
-
// real storm during AI editing or live collaboration — for a value that cannot
|
|
94
|
-
// change.
|
|
85
|
+
// setState bump instead. Subscribe the model-row form (`id !== undefined`)
|
|
86
|
+
// to claims. Selector-only reads track MobX model data; callers displaying
|
|
87
|
+
// ownership use the row form's `claims` / `claimed` result.
|
|
95
88
|
const [claimVersion, setClaimVersion] = useState(0);
|
|
96
89
|
useEffect(() => {
|
|
97
90
|
if (!engine || id === undefined)
|
|
@@ -118,3 +111,12 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
|
|
|
118
111
|
return modelResult;
|
|
119
112
|
return engine;
|
|
120
113
|
}
|
|
114
|
+
/** @internal Resolve the bound or legacy provider client through one rebind seam. */
|
|
115
|
+
export function useAbloClientImpl(boundClient) {
|
|
116
|
+
const ctx = useContext(AbloInternalContext);
|
|
117
|
+
// The bound client wins — it is already `Ablo<R>`, no rebinding. The
|
|
118
|
+
// fallback is the ONE remaining schema rebind in the SDK; it retires with
|
|
119
|
+
// the last legacy provider mount (docs/plans/typed-react-binding.md).
|
|
120
|
+
const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
|
|
121
|
+
return engine;
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useCallback } from 'react';
|
|
3
|
-
import {
|
|
2
|
+
import { useCallback, useContext } from 'react';
|
|
3
|
+
import { SyncContext, } from './context.js';
|
|
4
|
+
import { AbloInternalContext } from './internalContext.js';
|
|
5
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
4
6
|
import { useReactive } from '../useReactive.js';
|
|
5
7
|
/** Reactively exposes the local store's connection and confirmation status. */
|
|
6
8
|
export function useSyncStatus() {
|
|
7
|
-
const
|
|
9
|
+
const provider = useContext(AbloInternalContext);
|
|
10
|
+
const sync = useContext(SyncContext);
|
|
11
|
+
// Status does not require authenticated row scope. The client exists before
|
|
12
|
+
// ready() resolves, including inside passthrough children and custom fallbacks.
|
|
13
|
+
const store = provider?.engine?._store ?? sync?.store;
|
|
14
|
+
if (!store) {
|
|
15
|
+
throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
|
|
16
|
+
code: 'sync_context_missing_provider',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
8
19
|
const compute = useCallback(() => deriveStatus(store), [store]);
|
|
9
20
|
return useReactive(compute, sameSnapshot);
|
|
10
21
|
}
|
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", "presence", "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/dist/useReactive.js
CHANGED
|
@@ -11,7 +11,6 @@ export function useReactive(compute, equals = defaultEquals) {
|
|
|
11
11
|
const computeRef = useRef(compute);
|
|
12
12
|
const equalsRef = useRef(equals);
|
|
13
13
|
const snapshotRef = useRef(null);
|
|
14
|
-
const versionRef = useRef(0);
|
|
15
14
|
equalsRef.current = equals;
|
|
16
15
|
if (snapshotRef.current === null) {
|
|
17
16
|
snapshotRef.current = { value: compute() };
|
|
@@ -20,7 +19,6 @@ export function useReactive(compute, equals = defaultEquals) {
|
|
|
20
19
|
const next = compute();
|
|
21
20
|
if (!equals(snapshotRef.current.value, next)) {
|
|
22
21
|
snapshotRef.current = { value: next };
|
|
23
|
-
versionRef.current++;
|
|
24
22
|
}
|
|
25
23
|
}
|
|
26
24
|
computeRef.current = compute;
|
|
@@ -30,7 +28,7 @@ export function useReactive(compute, equals = defaultEquals) {
|
|
|
30
28
|
snapshotRef.current = { value: next };
|
|
31
29
|
onChange();
|
|
32
30
|
}
|
|
33
|
-
}), [
|
|
31
|
+
}), [compute]);
|
|
34
32
|
const getSnapshot = useCallback(() => snapshotRef.current.value, []);
|
|
35
33
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
36
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.1",
|
|
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.1",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
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,
|
|
@@ -1257,7 +1257,7 @@ export class InstanceCache {
|
|
|
1257
1257
|
}
|
|
1258
1258
|
|
|
1259
1259
|
private startGC(): void {
|
|
1260
|
-
if (this.gcTimer) return;
|
|
1260
|
+
if (this.gcTimer || this.config.gcInterval <= 0) return;
|
|
1261
1261
|
this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
|
|
1262
1262
|
// Don't hold a headless Node process open just for pool GC — without
|
|
1263
1263
|
// this, an agent that never calls disconnect() can never exit. No-op in
|
|
@@ -144,6 +144,13 @@ import type {
|
|
|
144
144
|
} from '@abloatai/transaction/transport/http';
|
|
145
145
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
146
146
|
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
147
|
+
import type {
|
|
148
|
+
CollaborationEventContext,
|
|
149
|
+
ModelEventEnvelope,
|
|
150
|
+
ModelEventInput,
|
|
151
|
+
ModelEventTarget,
|
|
152
|
+
} from '@abloatai/transaction/collaboration';
|
|
153
|
+
import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
|
|
147
154
|
import {
|
|
148
155
|
capturePointRead,
|
|
149
156
|
prepareReadSet,
|
|
@@ -152,10 +159,16 @@ import {
|
|
|
152
159
|
|
|
153
160
|
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
154
161
|
const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
|
|
162
|
+
const ignoreBestEffortScopeFailure = (): undefined => undefined;
|
|
155
163
|
|
|
156
164
|
export interface ModelClientMeta {
|
|
157
165
|
readonly key: string;
|
|
158
166
|
readonly typename: string;
|
|
167
|
+
readonly presence?: {
|
|
168
|
+
get(recordId: string): readonly PresenceSession[];
|
|
169
|
+
subscribe(listener: () => void): () => void;
|
|
170
|
+
read(recordId: string): () => void;
|
|
171
|
+
};
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
const modelClientMeta = new WeakMap<object, ModelClientMeta>();
|
|
@@ -180,6 +193,13 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
180
193
|
export interface ModelCollaboration {
|
|
181
194
|
/** Session projections already held by this client's one presence store. */
|
|
182
195
|
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
196
|
+
/** Subscribe once to the connection-owned presence projection. */
|
|
197
|
+
onPresenceChange(listener: () => void): () => void;
|
|
198
|
+
/** Start one session-owned read activity and return its cleanup. */
|
|
199
|
+
startReadPresence(target: EntityHalf): () => void;
|
|
200
|
+
modelEventTarget(recordId: string): ModelEventTarget;
|
|
201
|
+
sendModelEvent(input: ModelEventInput): void;
|
|
202
|
+
onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
|
|
183
203
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
184
204
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
185
205
|
/**
|
|
@@ -281,6 +301,8 @@ export interface ModelCollaboration {
|
|
|
281
301
|
* test doubles can omit it.
|
|
282
302
|
*/
|
|
283
303
|
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
304
|
+
/** Release read interest previously acquired through {@link enterScope}. */
|
|
305
|
+
leaveScope?(scope: Record<string, string>): void | Promise<void>;
|
|
284
306
|
/**
|
|
285
307
|
* Pins a scope's sync group(s) — write intent: a row this client holds an
|
|
286
308
|
* active claim on stays subscribed regardless of navigation. Same
|
|
@@ -342,6 +364,9 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
342
364
|
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
343
365
|
presence(recordId?: string): readonly PresenceSession[];
|
|
344
366
|
|
|
367
|
+
/** Lossy, model-record-addressed application events such as cursor or selection. */
|
|
368
|
+
events: ModelEvents;
|
|
369
|
+
|
|
345
370
|
/**
|
|
346
371
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
347
372
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -377,6 +402,50 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
377
402
|
): () => void;
|
|
378
403
|
}
|
|
379
404
|
|
|
405
|
+
export interface ModelEvents {
|
|
406
|
+
send(
|
|
407
|
+
recordId: string,
|
|
408
|
+
event: string,
|
|
409
|
+
payload: Readonly<Record<string, unknown>>,
|
|
410
|
+
): void;
|
|
411
|
+
subscribe(
|
|
412
|
+
recordId: string,
|
|
413
|
+
event: string,
|
|
414
|
+
handler: (
|
|
415
|
+
payload: Readonly<Record<string, unknown>>,
|
|
416
|
+
context: CollaborationEventContext,
|
|
417
|
+
) => void,
|
|
418
|
+
): () => void;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function subscribeInModelScope(
|
|
422
|
+
collaboration: ModelCollaboration,
|
|
423
|
+
scope: Record<string, string>,
|
|
424
|
+
subscribe: () => () => void,
|
|
425
|
+
): () => void {
|
|
426
|
+
let stopped = false;
|
|
427
|
+
let entered = false;
|
|
428
|
+
let unsubscribe: (() => void) | null = null;
|
|
429
|
+
|
|
430
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
431
|
+
.then(() => {
|
|
432
|
+
entered = true;
|
|
433
|
+
if (stopped) {
|
|
434
|
+
void collaboration.leaveScope?.(scope);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
unsubscribe = subscribe();
|
|
438
|
+
})
|
|
439
|
+
.catch(ignoreBestEffortScopeFailure);
|
|
440
|
+
|
|
441
|
+
return () => {
|
|
442
|
+
if (stopped) return;
|
|
443
|
+
stopped = true;
|
|
444
|
+
unsubscribe?.();
|
|
445
|
+
if (entered) void collaboration.leaveScope?.(scope);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
380
449
|
/**
|
|
381
450
|
* Everything reachable as `ablo.<model>` on a reactive client.
|
|
382
451
|
*
|
|
@@ -1411,6 +1480,52 @@ export function createModelOperations<T, C>(
|
|
|
1411
1480
|
|
|
1412
1481
|
presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
1413
1482
|
|
|
1483
|
+
events: {
|
|
1484
|
+
send(recordId, event, payload) {
|
|
1485
|
+
if (!collaboration) return;
|
|
1486
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
1487
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload });
|
|
1488
|
+
if (!parsed.success) {
|
|
1489
|
+
throw new AbloValidationError('Invalid model event.', {
|
|
1490
|
+
code: 'invalid_request',
|
|
1491
|
+
param: 'event',
|
|
1492
|
+
cause: parsed.error,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
const scope = { [schemaKey]: recordId };
|
|
1496
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
1497
|
+
.then(() => { collaboration.sendModelEvent(parsed.data); })
|
|
1498
|
+
.finally(() => { void collaboration.leaveScope?.(scope); });
|
|
1499
|
+
},
|
|
1500
|
+
subscribe(recordId, event, handler) {
|
|
1501
|
+
if (!collaboration) return () => undefined;
|
|
1502
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
1503
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
|
|
1504
|
+
if (!parsed.success) {
|
|
1505
|
+
throw new AbloValidationError('Invalid model event subscription.', {
|
|
1506
|
+
code: 'invalid_request',
|
|
1507
|
+
param: 'event',
|
|
1508
|
+
cause: parsed.error,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
const scope = { [schemaKey]: recordId };
|
|
1512
|
+
return subscribeInModelScope(collaboration, scope, () =>
|
|
1513
|
+
collaboration.onModelEvent((incoming) => {
|
|
1514
|
+
if (
|
|
1515
|
+
incoming.target.model !== target.model ||
|
|
1516
|
+
incoming.target.id !== target.id ||
|
|
1517
|
+
incoming.target.syncGroup !== target.syncGroup ||
|
|
1518
|
+
incoming.event !== parsed.data.event
|
|
1519
|
+
) return;
|
|
1520
|
+
handler(incoming.payload, {
|
|
1521
|
+
sender: incoming.sender,
|
|
1522
|
+
sentAt: incoming.sentAt,
|
|
1523
|
+
});
|
|
1524
|
+
}),
|
|
1525
|
+
);
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1414
1529
|
get,
|
|
1415
1530
|
read,
|
|
1416
1531
|
|
|
@@ -1666,6 +1781,23 @@ export function createModelOperations<T, C>(
|
|
|
1666
1781
|
modelClientMeta.set(operations, {
|
|
1667
1782
|
key: schemaKey,
|
|
1668
1783
|
typename: registeredModelName,
|
|
1784
|
+
...(collaboration
|
|
1785
|
+
? {
|
|
1786
|
+
presence: {
|
|
1787
|
+
get: (recordId: string) => collaboration.presence(registeredModelName, recordId),
|
|
1788
|
+
subscribe: (listener: () => void) => collaboration.onPresenceChange(listener),
|
|
1789
|
+
read: (recordId: string) => {
|
|
1790
|
+
const scope = { [schemaKey]: recordId };
|
|
1791
|
+
return subscribeInModelScope(collaboration, scope, () =>
|
|
1792
|
+
collaboration.startReadPresence({
|
|
1793
|
+
model: registeredModelName,
|
|
1794
|
+
id: recordId,
|
|
1795
|
+
}),
|
|
1796
|
+
);
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
}
|
|
1800
|
+
: {}),
|
|
1669
1801
|
});
|
|
1670
1802
|
|
|
1671
1803
|
return operations;
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
prepareReadSet,
|
|
79
79
|
} from '@abloatai/transaction/internal/read-set';
|
|
80
80
|
import { contextOnChange } from '../sync/contextOnChange.js';
|
|
81
|
+
import { resolveScopeGroups } from '../sync/scopeGroups.js';
|
|
81
82
|
import type { ReadDependency } from '@abloatai/transaction/coordination';
|
|
82
83
|
import type { CapturedRow } from '@abloatai/transaction/transport/http';
|
|
83
84
|
|
|
@@ -575,6 +576,20 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
575
576
|
hydration,
|
|
576
577
|
{
|
|
577
578
|
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
579
|
+
onPresenceChange: (listener) => presenceStream.onChange(listener),
|
|
580
|
+
startReadPresence: (target) => presenceStream.startRead(target),
|
|
581
|
+
modelEventTarget: (recordId) => {
|
|
582
|
+
const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
|
|
583
|
+
if (syncGroup === undefined) {
|
|
584
|
+
throw new AbloValidationError('A model event requires a record scope.', {
|
|
585
|
+
code: 'invalid_request',
|
|
586
|
+
param: 'recordId',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return { model: registeredModelName, id: recordId, syncGroup };
|
|
590
|
+
},
|
|
591
|
+
sendModelEvent: (input) => { transport.sendModelEvent(input); },
|
|
592
|
+
onModelEvent: (listener) => transport.subscribe('model_event', listener),
|
|
578
593
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
579
594
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
580
595
|
// only runs when someone actually writes a batch.
|
|
@@ -617,6 +632,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
617
632
|
// stay fire-and-forget. It's soft either way — the store swallows
|
|
618
633
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
619
634
|
enterScope: (scope) => store.enterScope(scope),
|
|
635
|
+
leaveScope: (scope) => store.leaveScope(scope),
|
|
620
636
|
pinScope: (scope) => store.pinScope(scope),
|
|
621
637
|
},
|
|
622
638
|
readSetContext,
|