@abloatai/humans 0.63.1 → 0.64.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 -0
- package/dist/client.d.ts +11 -31
- package/dist/local/client/reactiveEngine.js +4 -5
- package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
- package/dist/local/client/status.js +14 -0
- package/dist/local/client/storeLifecycle.js +2 -2
- package/dist/local/storeContract.d.ts +3 -4
- package/dist/presence/index.js +6 -2
- package/dist/react/AbloProvider.d.ts +57 -121
- package/dist/react/AbloProvider.js +49 -154
- package/dist/react/createAbloReact.d.ts +10 -32
- package/dist/react/createAbloReact.js +10 -54
- package/dist/react/internalContext.d.ts +3 -20
- package/dist/react/snapshot.d.ts +4 -0
- package/dist/react/snapshot.js +75 -0
- package/dist/react/useAblo.d.ts +27 -44
- package/dist/react/useAblo.js +39 -74
- package/dist/react/useMutators.d.ts +20 -17
- package/dist/react/usePresence.js +7 -6
- package/dist/react/useReactive.d.ts +12 -0
- package/dist/react/useReactive.js +57 -0
- package/dist/react/useUndoScope.d.ts +15 -29
- package/dist/react/useUndoScope.js +17 -0
- package/dist/react.d.ts +7 -19
- package/dist/react.js +7 -15
- package/package.json +4 -3
- package/src/Ablo.ts +5 -0
- package/src/client.ts +12 -31
- package/src/local/client/reactiveEngine.ts +4 -5
- package/src/local/client/status.ts +20 -0
- package/src/local/client/storeLifecycle.ts +2 -2
- package/src/local/storeContract.ts +3 -4
- package/src/presence/index.ts +6 -2
- package/src/react/AbloProvider.tsx +80 -255
- package/src/react/createAbloReact.ts +18 -99
- package/src/react/internalContext.ts +3 -20
- package/src/react/snapshot.ts +67 -0
- package/src/react/useAblo.ts +70 -136
- package/src/react/useMutators.ts +30 -26
- package/src/react/usePresence.ts +7 -12
- package/src/react/useReactive.ts +56 -0
- package/src/react/useUndoScope.ts +18 -14
- package/src/react.ts +7 -69
- package/dist/react/ClientSideSuspense.d.ts +0 -36
- package/dist/react/ClientSideSuspense.js +0 -17
- package/dist/react/useCurrentUserId.d.ts +0 -2
- package/dist/react/useCurrentUserId.js +0 -12
- package/dist/react/useErrorListener.d.ts +0 -2
- package/dist/react/useErrorListener.js +0 -14
- package/dist/react/useMutationFailureListener.d.ts +0 -8
- package/dist/react/useMutationFailureListener.js +0 -19
- package/dist/react/useSyncStatus.js +0 -48
- package/dist/useReactive.d.ts +0 -6
- package/dist/useReactive.js +0 -41
- package/src/react/ClientSideSuspense.tsx +0 -57
- package/src/react/useCurrentUserId.ts +0 -17
- package/src/react/useErrorListener.ts +0 -22
- package/src/react/useMutationFailureListener.ts +0 -34
- package/src/react/useSyncStatus.ts +0 -53
- package/src/useReactive.ts +0 -49
|
@@ -1,38 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback,
|
|
4
|
-
import { resolveScopeGroups } from '../local/sync/scopeGroups.js';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, createContext, } from 'react';
|
|
5
4
|
import { SyncContext } from './context.js';
|
|
6
5
|
import { AbloInternalContext } from './internalContext.js';
|
|
7
6
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
8
|
-
import {
|
|
7
|
+
import { useAblo } from './useAblo.js';
|
|
9
8
|
import { DefaultFallback } from './DefaultFallback.js';
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** Reactive binding over an application-owned client. Starts readiness,
|
|
10
|
+
* forwards errors and gates bootstrap; the application owns client disposal.
|
|
11
|
+
*/
|
|
12
|
+
// ── Props ────────────────────────────────────────────────────────────
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Props for `<AbloProvider>`.
|
|
15
|
+
*
|
|
16
|
+
* The one required prop is a prebuilt {@link Ablo} client — the client
|
|
17
|
+
* owns auth and the credential lifecycle; this provider is the reactive
|
|
18
|
+
* binding over it:
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* // Build once at module scope — a new instance per render tears down the socket.
|
|
22
|
+
* // The endpoint string points at your session-mint route (`ablo init`
|
|
23
|
+
* // scaffolds it); the SDK fetches it and keeps the token fresh.
|
|
24
|
+
* const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
|
|
25
|
+
*
|
|
26
|
+
* <AbloProvider client={ablo}>
|
|
27
|
+
* <App />
|
|
28
|
+
* </AbloProvider>
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* That's it for most apps. The `fallback`,
|
|
32
|
+
* `preventUnsavedChanges`, and `on*` props are opt-in app glue; and the
|
|
33
|
+
* block tagged "Optional DI (advanced)" below is escape-hatch wiring for
|
|
34
|
+
* tests and platform builders — if you don't recognize a prop there, you
|
|
35
|
+
* don't need it.
|
|
16
36
|
*/
|
|
17
|
-
|
|
18
|
-
const listeners = new Set();
|
|
19
|
-
return {
|
|
20
|
-
subscribe(fn) {
|
|
21
|
-
listeners.add(fn);
|
|
22
|
-
return () => { listeners.delete(fn); };
|
|
23
|
-
},
|
|
24
|
-
emit(err) {
|
|
25
|
-
for (const fn of listeners) {
|
|
26
|
-
try {
|
|
27
|
-
fn(err);
|
|
28
|
-
}
|
|
29
|
-
catch { }
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
37
|
+
// ── Implementation ───────────────────────────────────────────────────
|
|
34
38
|
export function AbloProvider(props) {
|
|
35
|
-
const { client,
|
|
39
|
+
const { client, preventUnsavedChanges, onSessionExpired, onError, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
36
40
|
// The client IS the engine — synchronous, never null. This provider is a
|
|
37
41
|
// REACTIVE binding over it (context + bootstrap gate + error/session
|
|
38
42
|
// forwarding); it does NOT construct, configure, or own the connection. The
|
|
@@ -45,19 +49,16 @@ export function AbloProvider(props) {
|
|
|
45
49
|
// Account scope isn't a prop — read it from `_store.orgId` once `ready()`
|
|
46
50
|
// resolves the identity from the client's auth.
|
|
47
51
|
const [resolvedScope, setResolvedScope] = useState(null);
|
|
48
|
-
// ── Error emitter (provider-instance scoped) ─────────────────────
|
|
49
|
-
const errorEmitterRef = useRef(null);
|
|
50
|
-
if (!errorEmitterRef.current) {
|
|
51
|
-
errorEmitterRef.current = createErrorEmitter();
|
|
52
|
-
}
|
|
53
|
-
const errorEmitter = errorEmitterRef.current;
|
|
54
52
|
// Stash callbacks in refs so a new identity each render doesn't re-run the
|
|
55
53
|
// start effect (the `useEventCallback` idiom).
|
|
56
54
|
const onErrorRef = useRef(onError);
|
|
57
55
|
onErrorRef.current = onError;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
const reportError = useCallback((error) => {
|
|
57
|
+
try {
|
|
58
|
+
onErrorRef.current?.(error);
|
|
59
|
+
}
|
|
60
|
+
catch { /* Error reporting must not interrupt session cleanup. */ }
|
|
61
|
+
}, []);
|
|
61
62
|
const onSessionExpiredRef = useRef(onSessionExpired);
|
|
62
63
|
onSessionExpiredRef.current = onSessionExpired;
|
|
63
64
|
// Re-key the bootstrap gate when the client INSTANCE changes — a genuinely new
|
|
@@ -79,16 +80,16 @@ export function AbloProvider(props) {
|
|
|
79
80
|
useEffect(() => {
|
|
80
81
|
let stale = false;
|
|
81
82
|
const unsubscribeSession = engine.onSessionError((err) => {
|
|
82
|
-
|
|
83
|
+
reportError(err);
|
|
83
84
|
void (async () => {
|
|
84
85
|
try {
|
|
85
86
|
await onSessionExpiredRef.current?.();
|
|
86
87
|
}
|
|
87
88
|
catch (hookErr) {
|
|
88
|
-
|
|
89
|
+
reportError(hookErr);
|
|
89
90
|
}
|
|
90
91
|
})().catch(() => {
|
|
91
|
-
//
|
|
92
|
+
// This was
|
|
92
93
|
// already the error-reporting path, so swallow rather than surface
|
|
93
94
|
// an unhandled rejection loop.
|
|
94
95
|
});
|
|
@@ -106,13 +107,13 @@ export function AbloProvider(props) {
|
|
|
106
107
|
.catch((err) => {
|
|
107
108
|
if (stale)
|
|
108
109
|
return;
|
|
109
|
-
|
|
110
|
+
reportError(err);
|
|
110
111
|
});
|
|
111
112
|
return () => {
|
|
112
113
|
stale = true;
|
|
113
114
|
unsubscribeSession();
|
|
114
115
|
};
|
|
115
|
-
}, [engine,
|
|
116
|
+
}, [engine, reportError]);
|
|
116
117
|
// ── beforeunload + preventUnsavedChanges ─────────────────────────
|
|
117
118
|
useEffect(() => {
|
|
118
119
|
if (typeof window === 'undefined')
|
|
@@ -146,13 +147,10 @@ export function AbloProvider(props) {
|
|
|
146
147
|
schema,
|
|
147
148
|
};
|
|
148
149
|
}, [engine, resolvedScope, schema]);
|
|
149
|
-
//
|
|
150
|
+
// The React tree holds the same client used by core code.
|
|
150
151
|
const internalValue = useMemo(() => ({
|
|
151
|
-
currentUserId: userId ?? null,
|
|
152
|
-
subscribeError: errorEmitter.subscribe,
|
|
153
|
-
emitError: errorEmitter.emit,
|
|
154
152
|
engine: engine,
|
|
155
|
-
}), [
|
|
153
|
+
}), [engine]);
|
|
156
154
|
// ── Render ───────────────────────────────────────────────────────
|
|
157
155
|
//
|
|
158
156
|
// Keep the context tree stable during startup so passthrough children retain
|
|
@@ -172,118 +170,15 @@ export function AbloProvider(props) {
|
|
|
172
170
|
* a new "first bootstrap" cycle.
|
|
173
171
|
*/
|
|
174
172
|
function BootstrapGate({ fallback, children, }) {
|
|
175
|
-
const status =
|
|
173
|
+
const status = useAblo(ablo => ablo.status);
|
|
176
174
|
const [everConnected, setEverConnected] = useState(false);
|
|
177
175
|
useEffect(() => {
|
|
178
|
-
if (status
|
|
179
|
-
status
|
|
180
|
-
status
|
|
176
|
+
if (status?.name === 'connected' ||
|
|
177
|
+
status?.name === 'reconnecting' ||
|
|
178
|
+
status?.name === 'disconnected') {
|
|
181
179
|
setEverConnected(true);
|
|
182
180
|
}
|
|
183
|
-
}, [status
|
|
184
|
-
const showFallback = !everConnected && status
|
|
181
|
+
}, [status?.name]);
|
|
182
|
+
const showFallback = !everConnected && status?.name === 'connecting';
|
|
185
183
|
return _jsx(_Fragment, { children: showFallback ? fallback : children });
|
|
186
184
|
}
|
|
187
|
-
const EMPTY_PRESENCE = Object.freeze([]);
|
|
188
|
-
/**
|
|
189
|
-
* Read-only presence: the other sessions currently visible to this
|
|
190
|
-
* connection, bridged to React. This is a pure reader of the engine's
|
|
191
|
-
* already-flowing presence stream; it does not mutate connection groups.
|
|
192
|
-
*
|
|
193
|
-
* Pass `scope` to narrow to the peers on that scope's sync group(s); omit
|
|
194
|
-
* it to get everyone on the engine's groups. Membership is driven entirely
|
|
195
|
-
* by the presence channel (set server-side on connect, independent of any
|
|
196
|
-
* cursor/collaboration traffic), so reading it never affects what the
|
|
197
|
-
* connection is subscribed to and can't deadlock against a gated channel.
|
|
198
|
-
*
|
|
199
|
-
* Use this to answer "is anyone else here?", for example to suppress
|
|
200
|
-
* live-cursor broadcasts while alone.
|
|
201
|
-
*
|
|
202
|
-
* ```ts
|
|
203
|
-
* const peers = usePeers({ reports: reportId });
|
|
204
|
-
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
205
|
-
* ```
|
|
206
|
-
*/
|
|
207
|
-
export function usePeers(scope) {
|
|
208
|
-
const ctx = useContext(AbloInternalContext);
|
|
209
|
-
const engine = ctx?.engine ?? null;
|
|
210
|
-
// Resolve scope → groups through the schema.
|
|
211
|
-
// The stringified, sorted key is the stable effect dependency.
|
|
212
|
-
const scopeKey = JSON.stringify(resolveScopeGroups(scope, engine?.schema).sort());
|
|
213
|
-
const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
214
|
-
const [peers, setPeers] = useState(EMPTY_PRESENCE);
|
|
215
|
-
useEffect(() => {
|
|
216
|
-
if (!engine) {
|
|
217
|
-
setPeers(EMPTY_PRESENCE);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const presence = presenceOfClient(engine);
|
|
221
|
-
const compute = () => groups.length === 0
|
|
222
|
-
? presence.others
|
|
223
|
-
: presence.others.filter((session) => session.activities.some(({ target }) => target.id !== undefined && groups.includes(`${target.model.toLowerCase()}:${target.id}`)));
|
|
224
|
-
// Plain useState + onChange — presence changes on connect/disconnect/activity
|
|
225
|
-
// only (never on cursor traffic, a separate channel), so this fires
|
|
226
|
-
// rarely; a frame of stale presence is harmless.
|
|
227
|
-
setPeers(compute());
|
|
228
|
-
return presence.onChange(() => { setPeers(compute()); });
|
|
229
|
-
}, [engine, groups, scopeKey]);
|
|
230
|
-
return peers;
|
|
231
|
-
}
|
|
232
|
-
// ── Escape-hatches: raw engine/store access ──────────────────────────
|
|
233
|
-
/**
|
|
234
|
-
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
235
|
-
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
236
|
-
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
237
|
-
*
|
|
238
|
-
* The generic parameter narrows the return type to your schema's
|
|
239
|
-
* model record so call sites get typed `sync.items.findMany()` /
|
|
240
|
-
* `sync.sections.create(...)` without a cast at the call site:
|
|
241
|
-
*
|
|
242
|
-
* ```ts
|
|
243
|
-
* const sync = useSync<(typeof schema)['models']>();
|
|
244
|
-
* ```
|
|
245
|
-
*
|
|
246
|
-
* The runtime value is the exact engine the provider constructed;
|
|
247
|
-
* the generic just widens the compile-time type.
|
|
248
|
-
*/
|
|
249
|
-
export function useSync() {
|
|
250
|
-
const ctx = useContext(AbloInternalContext);
|
|
251
|
-
if (!ctx) {
|
|
252
|
-
throw new AbloValidationError('useSync: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
|
|
253
|
-
}
|
|
254
|
-
if (!ctx.engine) {
|
|
255
|
-
throw new AbloValidationError('useSync: the sync engine has not yet initialized. Wrap your ' +
|
|
256
|
-
'consumer in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
|
|
257
|
-
}
|
|
258
|
-
return rebindProviderEngine(ctx.engine);
|
|
259
|
-
}
|
|
260
|
-
function rebindProviderEngine(engine) {
|
|
261
|
-
return engine;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Returns the underlying `SyncStoreContract` (the BaseSyncedStore).
|
|
265
|
-
* Most consumers should prefer the typed hooks (`useQuery` etc.); this
|
|
266
|
-
* is for advanced cases like direct InstanceCache access or custom
|
|
267
|
-
* reactive bridges. Throws if the provider hasn't mounted the store
|
|
268
|
-
* yet — wrap consumers in `<ClientSideSuspense>` to gate correctly.
|
|
269
|
-
*
|
|
270
|
-
* The generic parameter lets consumers widen the return type to a
|
|
271
|
-
* concrete `BaseSyncedStore<...>` subclass if they track one:
|
|
272
|
-
*
|
|
273
|
-
* ```ts
|
|
274
|
-
* type AppStore = BaseSyncedStore<AppEvents, typeof schema>;
|
|
275
|
-
* const store = useSyncStore<AppStore>(); // no cast needed at call site
|
|
276
|
-
* ```
|
|
277
|
-
*
|
|
278
|
-
* The runtime value is always the concrete store the SDK constructed,
|
|
279
|
-
* so widening the type is safe. The bounded generic (`T extends
|
|
280
|
-
* SyncStoreContract`) keeps the widening honest.
|
|
281
|
-
*/
|
|
282
|
-
export function useSyncStore() {
|
|
283
|
-
const sync = useContext(SyncContext);
|
|
284
|
-
if (!sync?.store) {
|
|
285
|
-
throw new AbloValidationError('useSyncStore: the sync engine has not yet initialized. Wrap ' +
|
|
286
|
-
'consumers in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
|
|
287
|
-
}
|
|
288
|
-
return sync.store;
|
|
289
|
-
}
|
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* a schema-bound shape with no module augmentation or generic parameters at call sites,
|
|
5
|
-
* and — once the legacy generic erasure retires — no casts anywhere on the
|
|
6
|
-
* path from context to component.
|
|
2
|
+
* Capture schema inference once while reusing module-level React functions.
|
|
3
|
+
* This helper creates no components, hooks, contexts or client instances.
|
|
7
4
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* ```ts
|
|
11
|
-
* // lib/ablo.ts
|
|
12
|
-
* import { createAbloReact } from '@abloatai/ablo/react';
|
|
13
|
-
* import { schema } from './schema';
|
|
14
|
-
*
|
|
15
|
-
* export const { AbloProvider, useAblo } = createAbloReact(schema);
|
|
16
|
-
* ```
|
|
17
|
-
*
|
|
18
|
-
* Components then import `useAblo` from `lib/ablo` and never spell a type
|
|
19
|
-
* argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
|
|
20
|
-
* parameter is the reactive-read view of the same `S`.
|
|
5
|
+
* Define the app binding at module scope:
|
|
6
|
+
* `export const { AbloProvider, useAblo, usePresence } = createAbloReact(schema)`.
|
|
21
7
|
*/
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { type AbloSelector, type ModelClientSelector
|
|
8
|
+
import type { ReactElement } from 'react';
|
|
9
|
+
import { AbloProvider } from './AbloProvider.js';
|
|
10
|
+
import { useAblo, type AbloSelector, type ModelClientSelector } from './useAblo.js';
|
|
25
11
|
import type { AbloClient as Ablo } from '../client.js';
|
|
26
12
|
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
27
13
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
@@ -31,24 +17,16 @@ import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
|
31
17
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
32
18
|
/** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
|
|
33
19
|
* no per-app generics. */
|
|
34
|
-
AbloProvider: (props:
|
|
20
|
+
AbloProvider: (props: AbloProvider.Props<S>) => ReactElement;
|
|
35
21
|
/** `useAblo` with the schema bound — the same overloads as the global
|
|
36
22
|
* hook, minus the type arguments. */
|
|
37
23
|
useAblo: {
|
|
38
24
|
(): Ablo<S> | null;
|
|
39
25
|
<T>(select: AbloSelector<S, T>): T | undefined;
|
|
40
|
-
<T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options
|
|
41
|
-
readonly initial: T;
|
|
42
|
-
}): UseAbloHydratedModelResult<T>;
|
|
43
|
-
<T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
|
|
26
|
+
<T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: useAblo.Options<T>): useAblo.Result<T>;
|
|
44
27
|
};
|
|
45
28
|
/** Declare and reactively read record presence with the same model clients. */
|
|
46
29
|
usePresence: <T, C>(modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>, recordId: string) => readonly PresenceSession[];
|
|
47
30
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Bind the react surface to one schema. The schema value is taken for
|
|
50
|
-
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
51
|
-
* argument — and it is the seam where the binding's own typed context arrives
|
|
52
|
-
* when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
|
|
53
|
-
*/
|
|
31
|
+
/** Bind the existing React functions to one schema's types. */
|
|
54
32
|
export declare function createAbloReact<S extends SchemaRecord>(schema: Schema<S>): AbloReactBinding<S>;
|
|
@@ -1,58 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* and — once the legacy generic erasure retires — no casts anywhere on the
|
|
7
|
-
* path from context to component.
|
|
8
|
-
*
|
|
9
|
-
* The app's one binding file, by convention:
|
|
10
|
-
*
|
|
11
|
-
* ```ts
|
|
12
|
-
* // lib/ablo.ts
|
|
13
|
-
* import { createAbloReact } from '@abloatai/ablo/react';
|
|
14
|
-
* import { schema } from './schema';
|
|
15
|
-
*
|
|
16
|
-
* export const { AbloProvider, useAblo } = createAbloReact(schema);
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Components then import `useAblo` from `lib/ablo` and never spell a type
|
|
20
|
-
* argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
|
|
21
|
-
* parameter is the reactive-read view of the same `S`.
|
|
22
|
-
*/
|
|
23
|
-
import { createContext, createElement, useContext } from 'react';
|
|
24
|
-
import { AbloProvider, } from './AbloProvider.js';
|
|
25
|
-
import { useAbloImpl, useAbloClientImpl, } from './useAblo.js';
|
|
26
|
-
import { usePresenceImpl, } from './usePresence.js';
|
|
27
|
-
/**
|
|
28
|
-
* Bind the react surface to one schema. The schema value is taken for
|
|
29
|
-
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
30
|
-
* argument — and it is the seam where the binding's own typed context arrives
|
|
31
|
-
* when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
|
|
32
|
-
*/
|
|
2
|
+
import { AbloProvider } from './AbloProvider.js';
|
|
3
|
+
import { useAblo, } from './useAblo.js';
|
|
4
|
+
import { usePresence } from './usePresence.js';
|
|
5
|
+
/** Bind the existing React functions to one schema's types. */
|
|
33
6
|
export function createAbloReact(schema) {
|
|
34
7
|
void schema;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
function BoundAbloProvider(props) {
|
|
42
|
-
return createElement(BoundClientContext.Provider, { value: props.client }, createElement((AbloProvider), props));
|
|
43
|
-
}
|
|
44
|
-
function useBoundAblo(modelOrSelect, id, options) {
|
|
45
|
-
const bound = useContext(BoundClientContext);
|
|
46
|
-
return useAbloImpl(bound, modelOrSelect, id, options);
|
|
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
|
-
};
|
|
8
|
+
// TypeScript cannot partially specialize the generic overloads, so this
|
|
9
|
+
// assertion binds their schema parameter. Positive and negative consumer
|
|
10
|
+
// type tests verify the specialization; no runtime value changes.
|
|
11
|
+
// Specialize types only. Every binding uses the same module-level functions,
|
|
12
|
+
// so calling this helper again cannot change component identity or reset state.
|
|
13
|
+
return { AbloProvider, useAblo, usePresence };
|
|
58
14
|
}
|
|
@@ -1,31 +1,14 @@
|
|
|
1
1
|
import type { AbloClient as Ablo } from '../client.js';
|
|
2
2
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
3
|
-
/**
|
|
4
|
-
* The context that `<AbloProvider>` populates for its own hooks. It is kept
|
|
5
|
-
* separate from the data-hook context, which carries the store and schema,
|
|
6
|
-
* because these fields belong to the provider rather than to the store. Read
|
|
7
|
-
* them through the typed hooks such as `useCurrentUserId` and
|
|
8
|
-
* `useErrorListener` rather than reaching into this context directly.
|
|
9
|
-
*/
|
|
3
|
+
/** The provider owns only the reference to the application-owned client. */
|
|
10
4
|
export interface AbloInternalContextValue {
|
|
11
5
|
/**
|
|
12
|
-
* The
|
|
13
|
-
* identity is derived on the server from the API key, so this is `null`
|
|
14
|
-
* unless you set it, and it is not required for sync to work.
|
|
15
|
-
*/
|
|
16
|
-
currentUserId: string | null;
|
|
17
|
-
/** Subscribe to provider-level errors: engine errors, bootstrap failures, and session issues. */
|
|
18
|
-
subscribeError: (listener: (error: Error) => void) => () => void;
|
|
19
|
-
/** Emit an error to every subscribed listener. The provider calls this for you. */
|
|
20
|
-
emitError: (error: Error) => void;
|
|
21
|
-
/**
|
|
22
|
-
* The typed `Ablo` client for this provider, or `null` until the first sync
|
|
23
|
-
* bootstrap resolves. It is held here so `useSync()` can return it without
|
|
6
|
+
* The typed `Ablo` client for this provider, available before bootstrap resolves. It is held here so `useAblo()` can return it without
|
|
24
7
|
* reaching into the store; the client and the store are sibling objects, and
|
|
25
8
|
* neither is derived from the other.
|
|
26
9
|
*
|
|
27
10
|
* It is typed loosely as `Ablo<SchemaRecord>` because generics do not flow
|
|
28
|
-
* through React context. `
|
|
11
|
+
* through React context. `useAblo<R>()` restores the precise type through its
|
|
29
12
|
* own generic; the runtime value is the fully typed client.
|
|
30
13
|
*/
|
|
31
14
|
engine: Ablo<SchemaRecord> | null;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Read and detach selected data while the enclosing reaction tracks its fields. */
|
|
2
|
+
export declare function snapshotValue<T>(value: T): T;
|
|
3
|
+
/** Compare data snapshots, including non-enumerable schema-derived fields. */
|
|
4
|
+
export declare function equalSnapshots(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isObservableObject } from 'mobx';
|
|
2
|
+
import { Model } from '../local/Model.js';
|
|
3
|
+
import { getModelClientMeta } from '../local/client/createModelOperations.js';
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
|
|
6
|
+
}
|
|
7
|
+
/** Read and detach selected data while the enclosing reaction tracks its fields. */
|
|
8
|
+
export function snapshotValue(value) {
|
|
9
|
+
const seen = new WeakMap();
|
|
10
|
+
function visit(input) {
|
|
11
|
+
if (input === null || typeof input !== 'object')
|
|
12
|
+
return input;
|
|
13
|
+
if (seen.has(input))
|
|
14
|
+
return seen.get(input);
|
|
15
|
+
// A selected model namespace is an API handle, not row data. Preserve its
|
|
16
|
+
// identity so it can still be passed to presence and other core operations.
|
|
17
|
+
if (getModelClientMeta(input))
|
|
18
|
+
return input;
|
|
19
|
+
if (input instanceof Date) {
|
|
20
|
+
const date = new Date(input.getTime());
|
|
21
|
+
seen.set(input, date);
|
|
22
|
+
return Object.freeze(date);
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(input)) {
|
|
25
|
+
const array = new Array(input.length);
|
|
26
|
+
seen.set(input, array);
|
|
27
|
+
input.forEach((item, index) => { array[index] = visit(item); });
|
|
28
|
+
return Object.freeze(array);
|
|
29
|
+
}
|
|
30
|
+
const source = input instanceof Model ? input.toReactiveSnapshot() : input;
|
|
31
|
+
if (!isRecord(source))
|
|
32
|
+
return input;
|
|
33
|
+
const result = Object.getPrototypeOf(source) === null ? Object.create(null) : {};
|
|
34
|
+
seen.set(input, result);
|
|
35
|
+
// MobX's own symbols describe its administration, not selected application data.
|
|
36
|
+
const keys = isObservableObject(source) ? Object.keys(source) : Reflect.ownKeys(source);
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
Object.defineProperty(result, key, {
|
|
39
|
+
value: visit(Reflect.get(source, key)),
|
|
40
|
+
enumerable: Object.prototype.propertyIsEnumerable.call(source, key),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(result);
|
|
44
|
+
}
|
|
45
|
+
return visit(value);
|
|
46
|
+
}
|
|
47
|
+
/** Compare data snapshots, including non-enumerable schema-derived fields. */
|
|
48
|
+
export function equalSnapshots(a, b) {
|
|
49
|
+
const seen = new WeakMap();
|
|
50
|
+
function equal(left, right) {
|
|
51
|
+
if (Object.is(left, right))
|
|
52
|
+
return true;
|
|
53
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
if (left instanceof Date || right instanceof Date) {
|
|
56
|
+
return left instanceof Date && right instanceof Date && Object.is(left.getTime(), right.getTime());
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(left) !== Array.isArray(right))
|
|
59
|
+
return false;
|
|
60
|
+
if (!Array.isArray(left) && (!isRecord(left) || !isRecord(right)))
|
|
61
|
+
return false;
|
|
62
|
+
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right))
|
|
63
|
+
return false;
|
|
64
|
+
if (seen.has(left))
|
|
65
|
+
return seen.get(left) === right;
|
|
66
|
+
seen.set(left, right);
|
|
67
|
+
const keys = Reflect.ownKeys(left);
|
|
68
|
+
if (keys.length !== Reflect.ownKeys(right).length)
|
|
69
|
+
return false;
|
|
70
|
+
return keys.every(key => Object.prototype.hasOwnProperty.call(right, key)
|
|
71
|
+
&& Object.prototype.propertyIsEnumerable.call(left, key) === Object.prototype.propertyIsEnumerable.call(right, key)
|
|
72
|
+
&& equal(Reflect.get(left, key), Reflect.get(right, key)));
|
|
73
|
+
}
|
|
74
|
+
return equal(a, b);
|
|
75
|
+
}
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -14,24 +14,6 @@ type DefaultModels = ResolveSchema extends {
|
|
|
14
14
|
} ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
|
|
15
15
|
export type ModelClientSelector<R extends SchemaRecord, T, C> = (ablo: AbloReads<R>) => ModelOperations<T, C>;
|
|
16
16
|
export type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
|
|
17
|
-
export interface UseAbloModelOptions<T> {
|
|
18
|
-
/**
|
|
19
|
-
* An initial row, usually from a server component or a route loader. The hook
|
|
20
|
-
* returns it until sync delivers a newer row for the same id.
|
|
21
|
-
*/
|
|
22
|
-
readonly initial?: T;
|
|
23
|
-
}
|
|
24
|
-
export interface UseAbloModelResult<T> {
|
|
25
|
-
/** The current row for the id, or `initial` until the row has synced. */
|
|
26
|
-
readonly data: T | undefined;
|
|
27
|
-
/** The work claims currently held on this row by any participant. */
|
|
28
|
-
readonly claims: readonly ModelClaim[];
|
|
29
|
-
/** True while another participant holds a claim — handy for disabling UI. */
|
|
30
|
-
readonly claimed: boolean;
|
|
31
|
-
}
|
|
32
|
-
export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'> & {
|
|
33
|
-
readonly data: T;
|
|
34
|
-
};
|
|
35
17
|
/**
|
|
36
18
|
* Reads Ablo from inside an `<AbloProvider>` subtree. Called with no arguments
|
|
37
19
|
* it returns the typed client for use in callbacks and effects; called with a
|
|
@@ -66,33 +48,34 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
|
|
|
66
48
|
* const ablo = useAblo<(typeof schema)['models']>();
|
|
67
49
|
* ```
|
|
68
50
|
*
|
|
69
|
-
* The
|
|
70
|
-
*
|
|
71
|
-
*
|
|
51
|
+
* The client and its status are available during provider startup. Select
|
|
52
|
+
* `ablo.status` to display connection state; await `ablo.ready()` before
|
|
53
|
+
* operations that require an initialized client. Without a provider, the
|
|
54
|
+
* no-argument form returns `null` and selectors return `undefined`.
|
|
72
55
|
*/
|
|
73
56
|
export declare function useAblo<R extends SchemaRecord = DefaultModels>(): Ablo<R> | null;
|
|
74
57
|
export declare function useAblo<R extends SchemaRecord = DefaultModels, T = unknown>(select: AbloSelector<R, T>): T | undefined;
|
|
75
|
-
export declare function useAblo<T, C>(modelClient: ModelOperations<T, C>, id: string, options
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
export declare function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
58
|
+
export declare function useAblo<T, C>(modelClient: ModelOperations<T, C>, id: string, options?: useAblo.Options<T>): useAblo.Result<T>;
|
|
59
|
+
export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: ModelClientSelector<R, T, C>, id: string, options?: useAblo.Options<T>): useAblo.Result<T>;
|
|
60
|
+
/** @internal Resolve the nearest provider's client through one schema rebind. */
|
|
61
|
+
export declare function useAbloClient<R extends SchemaRecord>(): Ablo<R> | null;
|
|
62
|
+
/** Type annotations belong to the operation; most callers rely on inference. */
|
|
63
|
+
export declare namespace useAblo {
|
|
64
|
+
interface Options<T> {
|
|
65
|
+
/**
|
|
66
|
+
* An initial row, usually from a server component or a route loader. The hook
|
|
67
|
+
* uses it for hydration and until a local row has been observed. A later
|
|
68
|
+
* local removal returns undefined instead of restoring this seed.
|
|
69
|
+
*/
|
|
70
|
+
readonly initial?: T;
|
|
71
|
+
}
|
|
72
|
+
interface Result<T> {
|
|
73
|
+
/** The local row or its initial seed. Undefined is a local cache miss, not proof of server absence. */
|
|
74
|
+
readonly data: T | undefined;
|
|
75
|
+
/** The work claims currently held on this row by any participant. */
|
|
76
|
+
readonly claims: readonly ModelClaim[];
|
|
77
|
+
/** True while another participant holds a claim — handy for disabling UI. */
|
|
78
|
+
readonly claimed: boolean;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
98
81
|
export {};
|