@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
|
@@ -19,8 +19,7 @@ import type { GroupScope } from './sync/scopeGroups.js';
|
|
|
19
19
|
/**
|
|
20
20
|
* A snapshot of the client's synchronization state, shaped for binding to UI.
|
|
21
21
|
* {@link SyncStoreContract.syncStatus} exposes a reactive instance of this, and
|
|
22
|
-
* the
|
|
23
|
-
* indicators.
|
|
22
|
+
* the client projects it into `ablo.status` for connection indicators.
|
|
24
23
|
*/
|
|
25
24
|
export interface SyncStatus {
|
|
26
25
|
state: 'idle' | 'syncing' | 'error' | 'offline' | 'reconnecting';
|
|
@@ -115,7 +114,7 @@ export interface SyncStoreContract {
|
|
|
115
114
|
* are backed by observable computeds, so reading them inside a reactive
|
|
116
115
|
* context — an observer component or a reaction — re-runs that context when
|
|
117
116
|
* the state changes. Code that prefers not to work with the reactivity system
|
|
118
|
-
* directly can read the same values through
|
|
117
|
+
* directly can read the same values through `useAblo(ablo => ablo.status)`.
|
|
119
118
|
*/
|
|
120
119
|
readonly isReady: boolean;
|
|
121
120
|
readonly isSyncing: boolean;
|
|
@@ -137,7 +136,7 @@ export interface SyncStoreContract {
|
|
|
137
136
|
pinScope?(scope: GroupScope): Promise<void>;
|
|
138
137
|
unpinScope?(scope: GroupScope): Promise<void>;
|
|
139
138
|
/**
|
|
140
|
-
* The full reactive {@link SyncStatus} record. The
|
|
139
|
+
* The full reactive {@link SyncStatus} record. The client status projection
|
|
141
140
|
* reads its fields — `state`, `progress`, `pendingChanges`, `isSessionError`,
|
|
142
141
|
* and `error` — to present the current sync state. It is part of the contract
|
|
143
142
|
* so hooks and test doubles can read or set it directly.
|
package/src/presence/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observable, runInAction } from 'mobx';
|
|
1
2
|
import {
|
|
2
3
|
createPresenceProjection,
|
|
3
4
|
type PresenceProjection,
|
|
@@ -44,11 +45,13 @@ export function createPresence(
|
|
|
44
45
|
): AttachablePresence {
|
|
45
46
|
let projection: PresenceProjection | null = null;
|
|
46
47
|
let attachedTransport: PresenceTransport | null = null;
|
|
48
|
+
const version = observable.box(0);
|
|
47
49
|
const listeners = new Set<() => void>();
|
|
48
50
|
const reads = new Set<ReadActivityLifetime>();
|
|
49
51
|
let unsubscribe: (() => void) | null = null;
|
|
50
52
|
|
|
51
53
|
const notify = (): void => {
|
|
54
|
+
runInAction(() => { version.set(version.get() + 1); });
|
|
52
55
|
for (const listener of listeners) listener();
|
|
53
56
|
};
|
|
54
57
|
|
|
@@ -63,8 +66,8 @@ export function createPresence(
|
|
|
63
66
|
if (transport !== null) attach(transport);
|
|
64
67
|
|
|
65
68
|
return {
|
|
66
|
-
get active() { return projection?.active ?? []; },
|
|
67
|
-
get others() { return projection?.others ?? []; },
|
|
69
|
+
get active() { version.get(); return projection?.active ?? []; },
|
|
70
|
+
get others() { version.get(); return projection?.others ?? []; },
|
|
68
71
|
onChange(listener) {
|
|
69
72
|
listeners.add(listener);
|
|
70
73
|
return () => { listeners.delete(listener); };
|
|
@@ -85,6 +88,7 @@ export function createPresence(
|
|
|
85
88
|
};
|
|
86
89
|
},
|
|
87
90
|
forModel(model, recordId) {
|
|
91
|
+
version.get();
|
|
88
92
|
return projection?.forModel(model, recordId) ?? [];
|
|
89
93
|
},
|
|
90
94
|
dispose() {
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
useCallback,
|
|
5
|
-
useContext,
|
|
6
5
|
useEffect,
|
|
7
6
|
useMemo,
|
|
8
7
|
useRef,
|
|
@@ -12,15 +11,11 @@ import {
|
|
|
12
11
|
} from 'react';
|
|
13
12
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
14
13
|
import type { AbloClient as Ablo } from '../client.js';
|
|
15
|
-
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
16
|
-
import type { GroupScope } from '../local/sync/scopeGroups.js';
|
|
17
|
-
import { resolveScopeGroups } from '../local/sync/scopeGroups.js';
|
|
18
14
|
import { SyncContext, type SyncStoreContract } from './context.js';
|
|
19
15
|
import { AbloInternalContext, type AbloInternalContextValue } from './internalContext.js';
|
|
20
16
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
21
|
-
import {
|
|
17
|
+
import { useAblo } from './useAblo.js';
|
|
22
18
|
import { DefaultFallback } from './DefaultFallback.js';
|
|
23
|
-
import { presenceOfClient } from '../presence/index.js';
|
|
24
19
|
|
|
25
20
|
/** Reactive binding over an application-owned client. Starts readiness,
|
|
26
21
|
* forwards errors and gates bootstrap; the application owns client disposal.
|
|
@@ -46,107 +41,19 @@ import { presenceOfClient } from '../presence/index.js';
|
|
|
46
41
|
* </AbloProvider>
|
|
47
42
|
* ```
|
|
48
43
|
*
|
|
49
|
-
* That's it for most apps.
|
|
44
|
+
* That's it for most apps. The `fallback`,
|
|
50
45
|
* `preventUnsavedChanges`, and `on*` props are opt-in app glue; and the
|
|
51
46
|
* block tagged "Optional DI (advanced)" below is escape-hatch wiring for
|
|
52
47
|
* tests and platform builders — if you don't recognize a prop there, you
|
|
53
48
|
* don't need it.
|
|
54
49
|
*/
|
|
55
|
-
export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
56
|
-
/**
|
|
57
|
-
* A prebuilt {@link Ablo} client — **the only way to configure the engine.**
|
|
58
|
-
* Construct it yourself with `Ablo({ schema, apiKey, ... })` and pass the
|
|
59
|
-
* instance: the CLIENT owns auth, the credential lifecycle, transport, and
|
|
60
|
-
* connection; this provider is the thin REACTIVE binding over it (context,
|
|
61
|
-
* the bootstrap gate, error/session forwarding).
|
|
62
|
-
*
|
|
63
|
-
* Memoize it (build it once, e.g. with `useMemo` or module scope) — a new
|
|
64
|
-
* instance each render re-keys the bootstrap gate and tears down the socket.
|
|
65
|
-
*/
|
|
66
|
-
client: Ablo<R>;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The app user id, surfaced via `useCurrentUserId()` for app-owned fields.
|
|
70
|
-
* Purely informational for the React tree — sync identity is resolved by the
|
|
71
|
-
* client from its auth, not from this. Optional.
|
|
72
|
-
*/
|
|
73
|
-
userId?: string;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Block tab close while there are unsynced local writes (the standard
|
|
77
|
-
* `beforeunload` prompt). Browsers ignore custom messages — don't pass one.
|
|
78
|
-
*/
|
|
79
|
-
preventUnsavedChanges?: boolean;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Fired after the client has completed its terminal authentication cleanup
|
|
83
|
-
* (or surfaced a cleanup failure). Use it for app side effects such as a
|
|
84
|
-
* redirect to sign-in or clearing analytics identity.
|
|
85
|
-
*/
|
|
86
|
-
onSessionExpired?: () => void | Promise<void>;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Fired on any error the provider surfaces (engine/WebSocket/bootstrap). For
|
|
90
|
-
* Sentry/Datadog. React-only consumers can use `useErrorListener()` instead.
|
|
91
|
-
*/
|
|
92
|
-
onError?: (error: Error) => void;
|
|
93
|
-
|
|
94
|
-
/** @internal placeholder so the old WS-URL prop shape doesn't silently leak in. */
|
|
95
|
-
url?: never;
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Rendered in place of `children` during the *first* bootstrap pass —
|
|
99
|
-
* while the engine is actively transitioning from `initial` →
|
|
100
|
-
* `connected` and has never successfully connected before. Once the
|
|
101
|
-
* engine reaches `connected` the gate latches open for the lifetime
|
|
102
|
-
* of this provider instance; transient `reconnecting` / `needs-auth`
|
|
103
|
-
* states do NOT re-show the fallback (the app's own UI handles those
|
|
104
|
-
* by then).
|
|
105
|
-
*
|
|
106
|
-
* Defaults to `<DefaultFallback />` — a neutral theme-adaptive
|
|
107
|
-
* spinner that uses `currentColor`, ships with zero design-system
|
|
108
|
-
* dependencies, and self-centers in a full-parent container. Pass
|
|
109
|
-
* your own `<Skeleton />` for a branded loading UX. Pass `null` to
|
|
110
|
-
* render nothing during bootstrap. Pass the string literal
|
|
111
|
-
* `"passthrough"` to opt out of the gate entirely — children render
|
|
112
|
-
* immediately and consumers are responsible for their own gating
|
|
113
|
-
* (`<ClientSideSuspense>` or manual `useSyncStatus()` checks).
|
|
114
|
-
* Useful for pages that mount debug helpers, error boundaries, or
|
|
115
|
-
* analytics that must run pre-ready.
|
|
116
|
-
*/
|
|
117
|
-
fallback?: ReactNode | 'passthrough';
|
|
118
|
-
|
|
119
|
-
children: ReactNode;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
50
|
// ── Implementation ───────────────────────────────────────────────────
|
|
123
51
|
|
|
124
|
-
/**
|
|
125
|
-
* Lightweight event emitter for provider-level errors. Lives on the
|
|
126
|
-
* provider instance (ref-based) so `useErrorListener` subscriptions
|
|
127
|
-
* survive re-renders without thrashing.
|
|
128
|
-
*/
|
|
129
|
-
function createErrorEmitter() {
|
|
130
|
-
const listeners = new Set<(err: Error) => void>();
|
|
131
|
-
return {
|
|
132
|
-
subscribe(fn: (err: Error) => void): () => void {
|
|
133
|
-
listeners.add(fn);
|
|
134
|
-
return () => { listeners.delete(fn); };
|
|
135
|
-
},
|
|
136
|
-
emit(err: Error): void {
|
|
137
|
-
for (const fn of listeners) {
|
|
138
|
-
try { fn(err); } catch {}
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
52
|
export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
|
|
145
|
-
props:
|
|
53
|
+
props: AbloProvider.Props<R>,
|
|
146
54
|
): React.ReactElement {
|
|
147
55
|
const {
|
|
148
56
|
client,
|
|
149
|
-
userId,
|
|
150
57
|
preventUnsavedChanges,
|
|
151
58
|
onSessionExpired,
|
|
152
59
|
onError,
|
|
@@ -168,20 +75,13 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
|
|
|
168
75
|
// resolves the identity from the client's auth.
|
|
169
76
|
const [resolvedScope, setResolvedScope] = useState<{ engine: typeof engine; account: string | null } | null>(null);
|
|
170
77
|
|
|
171
|
-
// ── Error emitter (provider-instance scoped) ─────────────────────
|
|
172
|
-
const errorEmitterRef = useRef<ReturnType<typeof createErrorEmitter> | null>(null);
|
|
173
|
-
if (!errorEmitterRef.current) {
|
|
174
|
-
errorEmitterRef.current = createErrorEmitter();
|
|
175
|
-
}
|
|
176
|
-
const errorEmitter = errorEmitterRef.current;
|
|
177
|
-
|
|
178
78
|
// Stash callbacks in refs so a new identity each render doesn't re-run the
|
|
179
79
|
// start effect (the `useEventCallback` idiom).
|
|
180
80
|
const onErrorRef = useRef(onError);
|
|
181
81
|
onErrorRef.current = onError;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}, [
|
|
82
|
+
const reportError = useCallback((error: Error) => {
|
|
83
|
+
try { onErrorRef.current?.(error); } catch { /* Error reporting must not interrupt session cleanup. */ }
|
|
84
|
+
}, []);
|
|
185
85
|
const onSessionExpiredRef = useRef(onSessionExpired);
|
|
186
86
|
onSessionExpiredRef.current = onSessionExpired;
|
|
187
87
|
|
|
@@ -206,15 +106,15 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
|
|
|
206
106
|
let stale = false;
|
|
207
107
|
|
|
208
108
|
const unsubscribeSession = engine.onSessionError((err) => {
|
|
209
|
-
|
|
109
|
+
reportError(err);
|
|
210
110
|
void (async () => {
|
|
211
111
|
try {
|
|
212
112
|
await onSessionExpiredRef.current?.();
|
|
213
113
|
} catch (hookErr) {
|
|
214
|
-
|
|
114
|
+
reportError(hookErr as Error);
|
|
215
115
|
}
|
|
216
116
|
})().catch(() => {
|
|
217
|
-
//
|
|
117
|
+
// This was
|
|
218
118
|
// already the error-reporting path, so swallow rather than surface
|
|
219
119
|
// an unhandled rejection loop.
|
|
220
120
|
});
|
|
@@ -231,14 +131,14 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
|
|
|
231
131
|
})
|
|
232
132
|
.catch((err) => {
|
|
233
133
|
if (stale) return;
|
|
234
|
-
|
|
134
|
+
reportError(err as Error);
|
|
235
135
|
});
|
|
236
136
|
|
|
237
137
|
return () => {
|
|
238
138
|
stale = true;
|
|
239
139
|
unsubscribeSession();
|
|
240
140
|
};
|
|
241
|
-
}, [engine,
|
|
141
|
+
}, [engine, reportError]);
|
|
242
142
|
|
|
243
143
|
// ── beforeunload + preventUnsavedChanges ─────────────────────────
|
|
244
144
|
|
|
@@ -275,14 +175,11 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
|
|
|
275
175
|
};
|
|
276
176
|
}, [engine, resolvedScope, schema]);
|
|
277
177
|
|
|
278
|
-
//
|
|
178
|
+
// The React tree holds the same client used by core code.
|
|
279
179
|
|
|
280
180
|
const internalValue = useMemo<AbloInternalContextValue>(() => ({
|
|
281
|
-
currentUserId: userId ?? null,
|
|
282
|
-
subscribeError: errorEmitter.subscribe,
|
|
283
|
-
emitError: errorEmitter.emit,
|
|
284
181
|
engine: engine as Ablo<SchemaRecord>,
|
|
285
|
-
}), [
|
|
182
|
+
}), [engine]);
|
|
286
183
|
|
|
287
184
|
// ── Render ───────────────────────────────────────────────────────
|
|
288
185
|
//
|
|
@@ -323,155 +220,83 @@ function BootstrapGate({
|
|
|
323
220
|
readonly fallback: ReactNode;
|
|
324
221
|
readonly children: ReactNode;
|
|
325
222
|
}): ReactNode {
|
|
326
|
-
const status =
|
|
223
|
+
const status = useAblo(ablo => ablo.status);
|
|
327
224
|
const [everConnected, setEverConnected] = useState(false);
|
|
328
225
|
|
|
329
226
|
useEffect(() => {
|
|
330
227
|
if (
|
|
331
|
-
status
|
|
332
|
-
status
|
|
333
|
-
status
|
|
228
|
+
status?.name === 'connected' ||
|
|
229
|
+
status?.name === 'reconnecting' ||
|
|
230
|
+
status?.name === 'disconnected'
|
|
334
231
|
) {
|
|
335
232
|
setEverConnected(true);
|
|
336
233
|
}
|
|
337
|
-
}, [status
|
|
234
|
+
}, [status?.name]);
|
|
338
235
|
|
|
339
|
-
const showFallback = !everConnected && status
|
|
236
|
+
const showFallback = !everConnected && status?.name === 'connecting';
|
|
340
237
|
return <>{showFallback ? fallback : children}</>;
|
|
341
238
|
}
|
|
342
239
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
export
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ── Escape-hatches: raw engine/store access ──────────────────────────
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
410
|
-
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
411
|
-
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
412
|
-
*
|
|
413
|
-
* The generic parameter narrows the return type to your schema's
|
|
414
|
-
* model record so call sites get typed `sync.items.findMany()` /
|
|
415
|
-
* `sync.sections.create(...)` without a cast at the call site:
|
|
416
|
-
*
|
|
417
|
-
* ```ts
|
|
418
|
-
* const sync = useSync<(typeof schema)['models']>();
|
|
419
|
-
* ```
|
|
420
|
-
*
|
|
421
|
-
* The runtime value is the exact engine the provider constructed;
|
|
422
|
-
* the generic just widens the compile-time type.
|
|
423
|
-
*/
|
|
424
|
-
export function useSync<R extends SchemaRecord = SchemaRecord>(): Ablo<R> {
|
|
425
|
-
const ctx = useContext(AbloInternalContext);
|
|
426
|
-
if (!ctx) {
|
|
427
|
-
throw new AbloValidationError(
|
|
428
|
-
'useSync: no <AbloProvider> mounted above this component.',
|
|
429
|
-
{ code: 'no_ablo_provider' },
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
if (!ctx.engine) {
|
|
433
|
-
throw new AbloValidationError(
|
|
434
|
-
'useSync: the sync engine has not yet initialized. Wrap your ' +
|
|
435
|
-
'consumer in <ClientSideSuspense> or guard on useSyncStatus().',
|
|
436
|
-
{ code: 'sync_not_ready' },
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
return rebindProviderEngine(ctx.engine);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function rebindProviderEngine<R extends SchemaRecord>(
|
|
443
|
-
engine: Ablo<SchemaRecord>,
|
|
444
|
-
): Ablo<R> {
|
|
445
|
-
return engine as Ablo<R>;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Returns the underlying `SyncStoreContract` (the BaseSyncedStore).
|
|
450
|
-
* Most consumers should prefer the typed hooks (`useQuery` etc.); this
|
|
451
|
-
* is for advanced cases like direct InstanceCache access or custom
|
|
452
|
-
* reactive bridges. Throws if the provider hasn't mounted the store
|
|
453
|
-
* yet — wrap consumers in `<ClientSideSuspense>` to gate correctly.
|
|
454
|
-
*
|
|
455
|
-
* The generic parameter lets consumers widen the return type to a
|
|
456
|
-
* concrete `BaseSyncedStore<...>` subclass if they track one:
|
|
457
|
-
*
|
|
458
|
-
* ```ts
|
|
459
|
-
* type AppStore = BaseSyncedStore<AppEvents, typeof schema>;
|
|
460
|
-
* const store = useSyncStore<AppStore>(); // no cast needed at call site
|
|
461
|
-
* ```
|
|
462
|
-
*
|
|
463
|
-
* The runtime value is always the concrete store the SDK constructed,
|
|
464
|
-
* so widening the type is safe. The bounded generic (`T extends
|
|
465
|
-
* SyncStoreContract`) keeps the widening honest.
|
|
466
|
-
*/
|
|
467
|
-
export function useSyncStore<T extends SyncStoreContract = SyncStoreContract>(): T {
|
|
468
|
-
const sync = useContext(SyncContext);
|
|
469
|
-
if (!sync?.store) {
|
|
470
|
-
throw new AbloValidationError(
|
|
471
|
-
'useSyncStore: the sync engine has not yet initialized. Wrap ' +
|
|
472
|
-
'consumers in <ClientSideSuspense> or guard on useSyncStatus().',
|
|
473
|
-
{ code: 'sync_not_ready' },
|
|
474
|
-
);
|
|
240
|
+
/** Props for wrappers around the provider, using the same schema parameter. */
|
|
241
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
242
|
+
export namespace AbloProvider {
|
|
243
|
+
export interface Props<R extends SchemaRecord = SchemaRecord> {
|
|
244
|
+
/**
|
|
245
|
+
* A prebuilt {@link Ablo} client — **the only way to configure the engine.**
|
|
246
|
+
* Construct it yourself with `Ablo({ schema, apiKey, ... })` and pass the
|
|
247
|
+
* instance: the CLIENT owns auth, the credential lifecycle, transport, and
|
|
248
|
+
* connection; this provider is the thin REACTIVE binding over it (context,
|
|
249
|
+
* the bootstrap gate, error/session forwarding).
|
|
250
|
+
*
|
|
251
|
+
* Memoize it (build it once, e.g. with `useMemo` or module scope) — a new
|
|
252
|
+
* instance each render re-keys the bootstrap gate and tears down the socket.
|
|
253
|
+
*/
|
|
254
|
+
client: Ablo<R>;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Block tab close while there are unsynced local writes (the standard
|
|
258
|
+
* `beforeunload` prompt). Browsers ignore custom messages — don't pass one.
|
|
259
|
+
*/
|
|
260
|
+
preventUnsavedChanges?: boolean;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fired after the client has completed its terminal authentication cleanup
|
|
264
|
+
* (or surfaced a cleanup failure). Use it for app side effects such as a
|
|
265
|
+
* redirect to sign-in or clearing analytics identity.
|
|
266
|
+
*/
|
|
267
|
+
onSessionExpired?: () => void | Promise<void>;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fired on any error the provider surfaces (engine/WebSocket/bootstrap). For
|
|
271
|
+
* Sentry/Datadog or application error UI.
|
|
272
|
+
*/
|
|
273
|
+
onError?: (error: Error) => void;
|
|
274
|
+
|
|
275
|
+
/** @internal placeholder so the old WS-URL prop shape doesn't silently leak in. */
|
|
276
|
+
url?: never;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Rendered in place of `children` during the *first* bootstrap pass —
|
|
280
|
+
* while the engine is actively transitioning from `initial` →
|
|
281
|
+
* `connected` and has never successfully connected before. Once the
|
|
282
|
+
* engine reaches `connected` the gate latches open for the lifetime
|
|
283
|
+
* of this provider instance; transient `reconnecting` / `needs-auth`
|
|
284
|
+
* states do NOT re-show the fallback (the app's own UI handles those
|
|
285
|
+
* by then).
|
|
286
|
+
*
|
|
287
|
+
* Defaults to `<DefaultFallback />` — a neutral theme-adaptive
|
|
288
|
+
* spinner that uses `currentColor`, ships with zero design-system
|
|
289
|
+
* dependencies, and self-centers in a full-parent container. Pass
|
|
290
|
+
* your own `<Skeleton />` for a branded loading UX. Pass `null` to
|
|
291
|
+
* render nothing during bootstrap. Pass the string literal
|
|
292
|
+
* `"passthrough"` to opt out of the gate entirely — children render
|
|
293
|
+
* immediately and consumers are responsible for their own gating
|
|
294
|
+
* (for example, `useAblo(ablo => ablo.status)` checks).
|
|
295
|
+
* Useful for pages that mount debug helpers, error boundaries, or
|
|
296
|
+
* analytics that must run pre-ready.
|
|
297
|
+
*/
|
|
298
|
+
fallback?: ReactNode | 'passthrough';
|
|
299
|
+
|
|
300
|
+
children: ReactNode;
|
|
475
301
|
}
|
|
476
|
-
return sync.store as T;
|
|
477
302
|
}
|
|
@@ -1,55 +1,31 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* a schema-bound shape with no module augmentation or generic parameters at call sites,
|
|
7
|
-
* and — once the legacy generic erasure retires — no casts anywhere on the
|
|
8
|
-
* path from context to component.
|
|
4
|
+
* Capture schema inference once while reusing module-level React functions.
|
|
5
|
+
* This helper creates no components, hooks, contexts or client instances.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ```ts
|
|
13
|
-
* // lib/ablo.ts
|
|
14
|
-
* import { createAbloReact } from '@abloatai/ablo/react';
|
|
15
|
-
* import { schema } from './schema';
|
|
16
|
-
*
|
|
17
|
-
* export const { AbloProvider, useAblo } = createAbloReact(schema);
|
|
18
|
-
* ```
|
|
19
|
-
*
|
|
20
|
-
* Components then import `useAblo` from `lib/ablo` and never spell a type
|
|
21
|
-
* argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
|
|
22
|
-
* parameter is the reactive-read view of the same `S`.
|
|
7
|
+
* Define the app binding at module scope:
|
|
8
|
+
* `export const { AbloProvider, useAblo, usePresence } = createAbloReact(schema)`.
|
|
23
9
|
*/
|
|
24
10
|
|
|
25
|
-
import {
|
|
11
|
+
import type { ReactElement } from 'react';
|
|
12
|
+
import { AbloProvider } from './AbloProvider.js';
|
|
26
13
|
import {
|
|
27
|
-
|
|
28
|
-
type AbloProviderProps,
|
|
29
|
-
} from './AbloProvider.js';
|
|
30
|
-
import {
|
|
31
|
-
useAbloImpl,
|
|
32
|
-
useAbloClientImpl,
|
|
14
|
+
useAblo,
|
|
33
15
|
type AbloSelector,
|
|
34
16
|
type ModelClientSelector,
|
|
35
|
-
type UseAbloHydratedModelResult,
|
|
36
|
-
type UseAbloModelOptions,
|
|
37
|
-
type UseAbloModelResult,
|
|
38
17
|
} from './useAblo.js';
|
|
39
18
|
import type { AbloClient as Ablo } from '../client.js';
|
|
40
19
|
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
41
20
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
42
|
-
import {
|
|
43
|
-
usePresenceImpl,
|
|
44
|
-
type PresenceModelSelector,
|
|
45
|
-
} from './usePresence.js';
|
|
21
|
+
import { usePresence, type PresenceModelSelector } from './usePresence.js';
|
|
46
22
|
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
47
23
|
|
|
48
24
|
/** What a binding returns: the provider and the hook, with `S` fixed. */
|
|
49
25
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
50
26
|
/** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
|
|
51
27
|
* no per-app generics. */
|
|
52
|
-
AbloProvider: (props:
|
|
28
|
+
AbloProvider: (props: AbloProvider.Props<S>) => ReactElement;
|
|
53
29
|
/** `useAblo` with the schema bound — the same overloads as the global
|
|
54
30
|
* hook, minus the type arguments. */
|
|
55
31
|
useAblo: {
|
|
@@ -58,13 +34,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
58
34
|
<T, C>(
|
|
59
35
|
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
60
36
|
id: string,
|
|
61
|
-
options
|
|
62
|
-
):
|
|
63
|
-
<T, C>(
|
|
64
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
65
|
-
id: string,
|
|
66
|
-
options?: UseAbloModelOptions<T>,
|
|
67
|
-
): UseAbloModelResult<T>;
|
|
37
|
+
options?: useAblo.Options<T>,
|
|
38
|
+
): useAblo.Result<T>;
|
|
68
39
|
};
|
|
69
40
|
/** Declare and reactively read record presence with the same model clients. */
|
|
70
41
|
usePresence: <T, C>(
|
|
@@ -73,68 +44,16 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
73
44
|
) => readonly PresenceSession[];
|
|
74
45
|
}
|
|
75
46
|
|
|
76
|
-
/**
|
|
77
|
-
* Bind the react surface to one schema. The schema value is taken for
|
|
78
|
-
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
79
|
-
* argument — and it is the seam where the binding's own typed context arrives
|
|
80
|
-
* when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
|
|
81
|
-
*/
|
|
47
|
+
/** Bind the existing React functions to one schema's types. */
|
|
82
48
|
export function createAbloReact<S extends SchemaRecord>(
|
|
83
49
|
schema: Schema<S>,
|
|
84
50
|
): AbloReactBinding<S> {
|
|
85
51
|
void schema;
|
|
86
52
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
function BoundAbloProvider(props: AbloProviderProps<S>): ReactElement {
|
|
95
|
-
return createElement(
|
|
96
|
-
BoundClientContext.Provider,
|
|
97
|
-
{ value: props.client },
|
|
98
|
-
createElement(AbloProvider<S>, props),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function useBoundAblo(): Ablo<S> | null;
|
|
103
|
-
function useBoundAblo<T>(select: AbloSelector<S, T>): T | undefined;
|
|
104
|
-
function useBoundAblo<T, C>(
|
|
105
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
106
|
-
id: string,
|
|
107
|
-
options: UseAbloModelOptions<T> & { readonly initial: T },
|
|
108
|
-
): UseAbloHydratedModelResult<T>;
|
|
109
|
-
function useBoundAblo<T, C>(
|
|
110
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
111
|
-
id: string,
|
|
112
|
-
options?: UseAbloModelOptions<T>,
|
|
113
|
-
): UseAbloModelResult<T>;
|
|
114
|
-
function useBoundAblo<T, C>(
|
|
115
|
-
modelOrSelect?:
|
|
116
|
-
| ModelOperations<T, C>
|
|
117
|
-
| ModelClientSelector<S, T, C>
|
|
118
|
-
| AbloSelector<S, T>,
|
|
119
|
-
id?: string,
|
|
120
|
-
options?: UseAbloModelOptions<T>,
|
|
121
|
-
): Ablo<S> | null | UseAbloModelResult<T> | T | undefined {
|
|
122
|
-
const bound = useContext(BoundClientContext);
|
|
123
|
-
return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function useBoundPresence<T, C>(
|
|
127
|
-
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
128
|
-
recordId: string,
|
|
129
|
-
): readonly PresenceSession[] {
|
|
130
|
-
const bound = useContext(BoundClientContext);
|
|
131
|
-
const engine = useAbloClientImpl(bound);
|
|
132
|
-
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
AbloProvider: BoundAbloProvider,
|
|
137
|
-
useAblo: useBoundAblo,
|
|
138
|
-
usePresence: useBoundPresence,
|
|
139
|
-
};
|
|
53
|
+
// TypeScript cannot partially specialize the generic overloads, so this
|
|
54
|
+
// assertion binds their schema parameter. Positive and negative consumer
|
|
55
|
+
// type tests verify the specialization; no runtime value changes.
|
|
56
|
+
// Specialize types only. Every binding uses the same module-level functions,
|
|
57
|
+
// so calling this helper again cannot change component identity or reset state.
|
|
58
|
+
return { AbloProvider, useAblo, usePresence } as AbloReactBinding<S>;
|
|
140
59
|
}
|