@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.
Files changed (60) hide show
  1. package/dist/Ablo.d.ts +4 -0
  2. package/dist/client.d.ts +11 -31
  3. package/dist/local/client/reactiveEngine.js +4 -5
  4. package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
  5. package/dist/local/client/status.js +14 -0
  6. package/dist/local/client/storeLifecycle.js +2 -2
  7. package/dist/local/storeContract.d.ts +3 -4
  8. package/dist/presence/index.js +6 -2
  9. package/dist/react/AbloProvider.d.ts +59 -139
  10. package/dist/react/AbloProvider.js +60 -181
  11. package/dist/react/createAbloReact.d.ts +10 -32
  12. package/dist/react/createAbloReact.js +10 -54
  13. package/dist/react/internalContext.d.ts +3 -20
  14. package/dist/react/snapshot.d.ts +4 -0
  15. package/dist/react/snapshot.js +75 -0
  16. package/dist/react/useAblo.d.ts +28 -45
  17. package/dist/react/useAblo.js +39 -77
  18. package/dist/react/useMutators.d.ts +20 -17
  19. package/dist/react/usePresence.js +7 -6
  20. package/dist/react/useReactive.d.ts +12 -0
  21. package/dist/react/useReactive.js +57 -0
  22. package/dist/react/useUndoScope.d.ts +15 -29
  23. package/dist/react/useUndoScope.js +17 -0
  24. package/dist/react.d.ts +7 -19
  25. package/dist/react.js +7 -15
  26. package/package.json +4 -3
  27. package/src/Ablo.ts +5 -0
  28. package/src/client.ts +12 -31
  29. package/src/local/client/reactiveEngine.ts +4 -5
  30. package/src/local/client/status.ts +20 -0
  31. package/src/local/client/storeLifecycle.ts +2 -2
  32. package/src/local/storeContract.ts +3 -4
  33. package/src/presence/index.ts +6 -2
  34. package/src/react/AbloProvider.tsx +94 -309
  35. package/src/react/createAbloReact.ts +18 -99
  36. package/src/react/internalContext.ts +3 -20
  37. package/src/react/snapshot.ts +67 -0
  38. package/src/react/useAblo.ts +71 -140
  39. package/src/react/useMutators.ts +30 -26
  40. package/src/react/usePresence.ts +7 -12
  41. package/src/react/useReactive.ts +56 -0
  42. package/src/react/useUndoScope.ts +18 -14
  43. package/src/react.ts +7 -69
  44. package/dist/react/ClientSideSuspense.d.ts +0 -36
  45. package/dist/react/ClientSideSuspense.js +0 -17
  46. package/dist/react/useCurrentUserId.d.ts +0 -2
  47. package/dist/react/useCurrentUserId.js +0 -12
  48. package/dist/react/useErrorListener.d.ts +0 -2
  49. package/dist/react/useErrorListener.js +0 -14
  50. package/dist/react/useMutationFailureListener.d.ts +0 -8
  51. package/dist/react/useMutationFailureListener.js +0 -19
  52. package/dist/react/useSyncStatus.js +0 -37
  53. package/dist/useReactive.d.ts +0 -6
  54. package/dist/useReactive.js +0 -43
  55. package/src/react/ClientSideSuspense.tsx +0 -57
  56. package/src/react/useCurrentUserId.ts +0 -17
  57. package/src/react/useErrorListener.ts +0 -22
  58. package/src/react/useMutationFailureListener.ts +0 -34
  59. package/src/react/useSyncStatus.ts +0 -42
  60. 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, useContext, useEffect, useMemo, useRef, useState, createContext, } from 'react';
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 { useSyncStatus } from './useSyncStatus.js';
7
+ import { useAblo } from './useAblo.js';
9
8
  import { DefaultFallback } from './DefaultFallback.js';
10
- import { presenceOfClient } from '../presence/index.js';
11
- // ── Implementation ───────────────────────────────────────────────────
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
- * Lightweight event emitter for provider-level errors. Lives on the
14
- * provider instance (ref-based) so `useErrorListener` subscriptions
15
- * survive re-renders without thrashing.
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
- function createErrorEmitter() {
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, userId, preventUnsavedChanges, onSessionExpired, onError, fallback = _jsx(DefaultFallback, {}), children, } = props;
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 [resolvedAccountScope, setResolvedAccountScope] = 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;
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
- useEffect(() => {
59
- return errorEmitter.subscribe((err) => onErrorRef.current?.(err));
60
- }, [errorEmitter]);
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
- errorEmitter.emit(err);
83
+ reportError(err);
83
84
  void (async () => {
84
85
  try {
85
86
  await onSessionExpiredRef.current?.();
86
87
  }
87
88
  catch (hookErr) {
88
- errorEmitter.emit(hookErr);
89
+ reportError(hookErr);
89
90
  }
90
91
  })().catch(() => {
91
- // Only a throwing errorEmitter subscriber can land here — it was
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
- setResolvedAccountScope(engine._store.orgId ?? null);
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
- errorEmitter.emit(err);
110
+ reportError(err);
107
111
  });
108
112
  return () => {
109
113
  stale = true;
110
114
  unsubscribeSession();
111
115
  };
112
- }, [engine, errorEmitter]);
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 = resolvedAccountScope ??
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, resolvedAccountScope, schema]);
146
- // ── Internal context (currentUserId + error subscription) ────────
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
- }), [userId, errorEmitter, engine]);
153
+ }), [engine]);
153
154
  // ── Render ───────────────────────────────────────────────────────
154
155
  //
155
- // Two-phase gate (see `BootstrapGate` below for the latch logic):
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
- const initialFallback = passthrough ? children : fallback;
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 on `engineState.key` in the parent so engine rotations
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 = useSyncStatus();
173
+ const status = useAblo(ablo => ablo.status);
192
174
  const [everConnected, setEverConnected] = useState(false);
193
175
  useEffect(() => {
194
- if (status.name === 'connected' ||
195
- status.name === 'reconnecting' ||
196
- status.name === 'disconnected') {
176
+ if (status?.name === 'connected' ||
177
+ status?.name === 'reconnecting' ||
178
+ status?.name === 'disconnected') {
197
179
  setEverConnected(true);
198
180
  }
199
- }, [status.name]);
200
- const showFallback = !everConnected && status.name === 'connecting';
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
- * The typed react binding — the schema generic is captured ONCE, at a factory
3
- * call in app code, and every hook the factory returns is born typed. This is
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
- * The app's one binding file, by convention:
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 { type ReactElement } from 'react';
23
- import { type AbloProviderProps } from './AbloProvider.js';
24
- import { type AbloSelector, type ModelClientSelector, type UseAbloHydratedModelResult, type UseAbloModelOptions, type UseAbloModelResult } from './useAblo.js';
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: AbloProviderProps<S>) => ReactElement;
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: UseAbloModelOptions<T> & {
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
- * The typed react binding — the schema generic is captured ONCE, at a factory
4
- * call in app code, and every hook the factory returns is born typed. This is
5
- * a schema-bound shape with no module augmentation or generic parameters at call sites,
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
- // The binding's own context — created here, AFTER the schema generic is
36
- // known, so it is typed `Ablo<S>` from birth. A hook that reads it never rebinds and
37
- // never casts; a binding hook mounted under a legacy provider (no bound
38
- // provider in the tree) reads `null` here and falls through to the shared
39
- // implementation's internal-context fallback.
40
- const BoundClientContext = createContext(null);
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 application user id, when your app passed one to `<AbloProvider>`. Sync
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. `useSync<R>()` restores the precise type through its
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
+ }