@abloatai/humans 0.63.0 → 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 +59 -139
- package/dist/react/AbloProvider.js +60 -181
- 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 +28 -45
- package/dist/react/useAblo.js +39 -77
- 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 +94 -309
- 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 +71 -140
- 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 -37
- package/dist/useReactive.d.ts +0 -6
- package/dist/useReactive.js +0 -43
- 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 -42
- package/src/useReactive.ts +0 -51
|
@@ -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
|
|
@@ -44,20 +48,17 @@ export function AbloProvider(props) {
|
|
|
44
48
|
const schema = engine.schema;
|
|
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
|
-
const [
|
|
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;
|
|
51
|
+
const [resolvedScope, setResolvedScope] = useState(null);
|
|
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
|
});
|
|
@@ -98,18 +99,21 @@ export function AbloProvider(props) {
|
|
|
98
99
|
.then(() => {
|
|
99
100
|
if (stale)
|
|
100
101
|
return;
|
|
101
|
-
|
|
102
|
+
setResolvedScope({
|
|
103
|
+
engine,
|
|
104
|
+
account: engine._store.orgId ?? null,
|
|
105
|
+
});
|
|
102
106
|
})
|
|
103
107
|
.catch((err) => {
|
|
104
108
|
if (stale)
|
|
105
109
|
return;
|
|
106
|
-
|
|
110
|
+
reportError(err);
|
|
107
111
|
});
|
|
108
112
|
return () => {
|
|
109
113
|
stale = true;
|
|
110
114
|
unsubscribeSession();
|
|
111
115
|
};
|
|
112
|
-
}, [engine,
|
|
116
|
+
}, [engine, reportError]);
|
|
113
117
|
// ── beforeunload + preventUnsavedChanges ─────────────────────────
|
|
114
118
|
useEffect(() => {
|
|
115
119
|
if (typeof window === 'undefined')
|
|
@@ -133,7 +137,7 @@ export function AbloProvider(props) {
|
|
|
133
137
|
// unknown until `ready()` resolves identity — so `syncValue` is null until
|
|
134
138
|
// then, which drives the initial fallback below.
|
|
135
139
|
const syncValue = useMemo(() => {
|
|
136
|
-
const currentAccountScope =
|
|
140
|
+
const currentAccountScope = (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
|
|
137
141
|
engine._store.orgId;
|
|
138
142
|
if (!currentAccountScope)
|
|
139
143
|
return null;
|
|
@@ -142,38 +146,17 @@ export function AbloProvider(props) {
|
|
|
142
146
|
organizationId: currentAccountScope,
|
|
143
147
|
schema,
|
|
144
148
|
};
|
|
145
|
-
}, [engine,
|
|
146
|
-
//
|
|
149
|
+
}, [engine, resolvedScope, schema]);
|
|
150
|
+
// The React tree holds the same client used by core code.
|
|
147
151
|
const internalValue = useMemo(() => ({
|
|
148
|
-
currentUserId: userId ?? null,
|
|
149
|
-
subscribeError: errorEmitter.subscribe,
|
|
150
|
-
emitError: errorEmitter.emit,
|
|
151
152
|
engine: engine,
|
|
152
|
-
}), [
|
|
153
|
+
}), [engine]);
|
|
153
154
|
// ── Render ───────────────────────────────────────────────────────
|
|
154
155
|
//
|
|
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.
|
|
156
|
+
// Keep the context tree stable during startup so passthrough children retain
|
|
157
|
+
// their component state when authenticated row scope becomes available.
|
|
171
158
|
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)) }) }));
|
|
159
|
+
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
160
|
}
|
|
178
161
|
/**
|
|
179
162
|
* Internal gate that renders `fallback` only during the very first
|
|
@@ -183,123 +166,19 @@ export function AbloProvider(props) {
|
|
|
183
166
|
* re-show the fallback, because by then the app has already rendered
|
|
184
167
|
* once and its own reconnect UI should take over.
|
|
185
168
|
*
|
|
186
|
-
* Re-keyed
|
|
187
|
-
* (userId/org/url change) reset the latch — a new engine genuinely IS
|
|
169
|
+
* Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
|
|
188
170
|
* a new "first bootstrap" cycle.
|
|
189
171
|
*/
|
|
190
172
|
function BootstrapGate({ fallback, children, }) {
|
|
191
|
-
const status =
|
|
173
|
+
const status = useAblo(ablo => ablo.status);
|
|
192
174
|
const [everConnected, setEverConnected] = useState(false);
|
|
193
175
|
useEffect(() => {
|
|
194
|
-
if (status
|
|
195
|
-
status
|
|
196
|
-
status
|
|
176
|
+
if (status?.name === 'connected' ||
|
|
177
|
+
status?.name === 'reconnecting' ||
|
|
178
|
+
status?.name === 'disconnected') {
|
|
197
179
|
setEverConnected(true);
|
|
198
180
|
}
|
|
199
|
-
}, [status
|
|
200
|
-
const showFallback = !everConnected && status
|
|
181
|
+
}, [status?.name]);
|
|
182
|
+
const showFallback = !everConnected && status?.name === 'connecting';
|
|
201
183
|
return _jsx(_Fragment, { children: showFallback ? fallback : children });
|
|
202
184
|
}
|
|
203
|
-
const EMPTY_PRESENCE = Object.freeze([]);
|
|
204
|
-
/**
|
|
205
|
-
* Read-only presence: the other sessions currently visible to this
|
|
206
|
-
* connection, bridged to React. This is a pure reader of the engine's
|
|
207
|
-
* already-flowing presence stream; it does not mutate connection groups.
|
|
208
|
-
*
|
|
209
|
-
* Pass `scope` to narrow to the peers on that scope's sync group(s); omit
|
|
210
|
-
* it to get everyone on the engine's groups. Membership is driven entirely
|
|
211
|
-
* by the presence channel (set server-side on connect, independent of any
|
|
212
|
-
* cursor/collaboration traffic), so reading it never affects what the
|
|
213
|
-
* connection is subscribed to and can't deadlock against a gated channel.
|
|
214
|
-
*
|
|
215
|
-
* Use this to answer "is anyone else here?", for example to suppress
|
|
216
|
-
* live-cursor broadcasts while alone.
|
|
217
|
-
*
|
|
218
|
-
* ```ts
|
|
219
|
-
* const peers = usePeers({ reports: reportId });
|
|
220
|
-
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
221
|
-
* ```
|
|
222
|
-
*/
|
|
223
|
-
export function usePeers(scope) {
|
|
224
|
-
const ctx = useContext(AbloInternalContext);
|
|
225
|
-
const engine = ctx?.engine ?? null;
|
|
226
|
-
// Resolve scope → groups through the schema.
|
|
227
|
-
// The stringified, sorted key is the stable effect dependency.
|
|
228
|
-
const scopeKey = JSON.stringify(resolveScopeGroups(scope, engine?.schema).sort());
|
|
229
|
-
const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
230
|
-
const [peers, setPeers] = useState(EMPTY_PRESENCE);
|
|
231
|
-
useEffect(() => {
|
|
232
|
-
if (!engine) {
|
|
233
|
-
setPeers(EMPTY_PRESENCE);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
const presence = presenceOfClient(engine);
|
|
237
|
-
const compute = () => groups.length === 0
|
|
238
|
-
? presence.others
|
|
239
|
-
: presence.others.filter((session) => session.activities.some(({ target }) => target.id !== undefined && groups.includes(`${target.model.toLowerCase()}:${target.id}`)));
|
|
240
|
-
// Plain useState + onChange — presence changes on connect/disconnect/activity
|
|
241
|
-
// only (never on cursor traffic, a separate channel), so this fires
|
|
242
|
-
// rarely; a frame of stale presence is harmless.
|
|
243
|
-
setPeers(compute());
|
|
244
|
-
return presence.onChange(() => { setPeers(compute()); });
|
|
245
|
-
}, [engine, groups, scopeKey]);
|
|
246
|
-
return peers;
|
|
247
|
-
}
|
|
248
|
-
// ── Escape-hatches: raw engine/store access ──────────────────────────
|
|
249
|
-
/**
|
|
250
|
-
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
251
|
-
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
252
|
-
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
253
|
-
*
|
|
254
|
-
* The generic parameter narrows the return type to your schema's
|
|
255
|
-
* model record so call sites get typed `sync.items.findMany()` /
|
|
256
|
-
* `sync.sections.create(...)` without a cast at the call site:
|
|
257
|
-
*
|
|
258
|
-
* ```ts
|
|
259
|
-
* const sync = useSync<(typeof schema)['models']>();
|
|
260
|
-
* ```
|
|
261
|
-
*
|
|
262
|
-
* The runtime value is the exact engine the provider constructed;
|
|
263
|
-
* the generic just widens the compile-time type.
|
|
264
|
-
*/
|
|
265
|
-
export function useSync() {
|
|
266
|
-
const ctx = useContext(AbloInternalContext);
|
|
267
|
-
if (!ctx) {
|
|
268
|
-
throw new AbloValidationError('useSync: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
|
|
269
|
-
}
|
|
270
|
-
if (!ctx.engine) {
|
|
271
|
-
throw new AbloValidationError('useSync: the sync engine has not yet initialized. Wrap your ' +
|
|
272
|
-
'consumer in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
|
|
273
|
-
}
|
|
274
|
-
return rebindProviderEngine(ctx.engine);
|
|
275
|
-
}
|
|
276
|
-
function rebindProviderEngine(engine) {
|
|
277
|
-
return engine;
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Returns the underlying `SyncStoreContract` (the BaseSyncedStore).
|
|
281
|
-
* Most consumers should prefer the typed hooks (`useQuery` etc.); this
|
|
282
|
-
* is for advanced cases like direct InstanceCache access or custom
|
|
283
|
-
* reactive bridges. Throws if the provider hasn't mounted the store
|
|
284
|
-
* yet — wrap consumers in `<ClientSideSuspense>` to gate correctly.
|
|
285
|
-
*
|
|
286
|
-
* The generic parameter lets consumers widen the return type to a
|
|
287
|
-
* concrete `BaseSyncedStore<...>` subclass if they track one:
|
|
288
|
-
*
|
|
289
|
-
* ```ts
|
|
290
|
-
* type AppStore = BaseSyncedStore<AppEvents, typeof schema>;
|
|
291
|
-
* const store = useSyncStore<AppStore>(); // no cast needed at call site
|
|
292
|
-
* ```
|
|
293
|
-
*
|
|
294
|
-
* The runtime value is always the concrete store the SDK constructed,
|
|
295
|
-
* so widening the type is safe. The bounded generic (`T extends
|
|
296
|
-
* SyncStoreContract`) keeps the widening honest.
|
|
297
|
-
*/
|
|
298
|
-
export function useSyncStore() {
|
|
299
|
-
const sync = useContext(SyncContext);
|
|
300
|
-
if (!sync?.store) {
|
|
301
|
-
throw new AbloValidationError('useSyncStore: the sync engine has not yet initialized. Wrap ' +
|
|
302
|
-
'consumers in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
|
|
303
|
-
}
|
|
304
|
-
return sync.store;
|
|
305
|
-
}
|
|
@@ -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
|
+
}
|