@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.
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 +57 -121
  10. package/dist/react/AbloProvider.js +49 -154
  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 +27 -44
  17. package/dist/react/useAblo.js +39 -74
  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 +80 -255
  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 +70 -136
  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 -48
  53. package/dist/useReactive.d.ts +0 -6
  54. package/dist/useReactive.js +0 -41
  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 -53
  60. package/src/useReactive.ts +0 -49
@@ -1,38 +1,42 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, 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
@@ -45,19 +49,16 @@ export function AbloProvider(props) {
45
49
  // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
46
50
  // resolves the identity from the client's auth.
47
51
  const [resolvedScope, setResolvedScope] = useState(null);
48
- // ── Error emitter (provider-instance scoped) ─────────────────────
49
- const errorEmitterRef = useRef(null);
50
- if (!errorEmitterRef.current) {
51
- errorEmitterRef.current = createErrorEmitter();
52
- }
53
- const errorEmitter = errorEmitterRef.current;
54
52
  // Stash callbacks in refs so a new identity each render doesn't re-run the
55
53
  // start effect (the `useEventCallback` idiom).
56
54
  const onErrorRef = useRef(onError);
57
55
  onErrorRef.current = onError;
58
- 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
  });
@@ -106,13 +107,13 @@ export function AbloProvider(props) {
106
107
  .catch((err) => {
107
108
  if (stale)
108
109
  return;
109
- errorEmitter.emit(err);
110
+ reportError(err);
110
111
  });
111
112
  return () => {
112
113
  stale = true;
113
114
  unsubscribeSession();
114
115
  };
115
- }, [engine, errorEmitter]);
116
+ }, [engine, reportError]);
116
117
  // ── beforeunload + preventUnsavedChanges ─────────────────────────
117
118
  useEffect(() => {
118
119
  if (typeof window === 'undefined')
@@ -146,13 +147,10 @@ export function AbloProvider(props) {
146
147
  schema,
147
148
  };
148
149
  }, [engine, resolvedScope, schema]);
149
- // ── Internal context (currentUserId + error subscription) ────────
150
+ // The React tree holds the same client used by core code.
150
151
  const internalValue = useMemo(() => ({
151
- currentUserId: userId ?? null,
152
- subscribeError: errorEmitter.subscribe,
153
- emitError: errorEmitter.emit,
154
152
  engine: engine,
155
- }), [userId, errorEmitter, engine]);
153
+ }), [engine]);
156
154
  // ── Render ───────────────────────────────────────────────────────
157
155
  //
158
156
  // Keep the context tree stable during startup so passthrough children retain
@@ -172,118 +170,15 @@ export function AbloProvider(props) {
172
170
  * a new "first bootstrap" cycle.
173
171
  */
174
172
  function BootstrapGate({ fallback, children, }) {
175
- const status = useSyncStatus();
173
+ const status = useAblo(ablo => ablo.status);
176
174
  const [everConnected, setEverConnected] = useState(false);
177
175
  useEffect(() => {
178
- if (status.name === 'connected' ||
179
- status.name === 'reconnecting' ||
180
- status.name === 'disconnected') {
176
+ if (status?.name === 'connected' ||
177
+ status?.name === 'reconnecting' ||
178
+ status?.name === 'disconnected') {
181
179
  setEverConnected(true);
182
180
  }
183
- }, [status.name]);
184
- const showFallback = !everConnected && status.name === 'connecting';
181
+ }, [status?.name]);
182
+ const showFallback = !everConnected && status?.name === 'connecting';
185
183
  return _jsx(_Fragment, { children: showFallback ? fallback : children });
186
184
  }
187
- const EMPTY_PRESENCE = Object.freeze([]);
188
- /**
189
- * Read-only presence: the other sessions currently visible to this
190
- * connection, bridged to React. This is a pure reader of the engine's
191
- * already-flowing presence stream; it does not mutate connection groups.
192
- *
193
- * Pass `scope` to narrow to the peers on that scope's sync group(s); omit
194
- * it to get everyone on the engine's groups. Membership is driven entirely
195
- * by the presence channel (set server-side on connect, independent of any
196
- * cursor/collaboration traffic), so reading it never affects what the
197
- * connection is subscribed to and can't deadlock against a gated channel.
198
- *
199
- * Use this to answer "is anyone else here?", for example to suppress
200
- * live-cursor broadcasts while alone.
201
- *
202
- * ```ts
203
- * const peers = usePeers({ reports: reportId });
204
- * const alone = !peers.some((p) => p.participantKind === 'user');
205
- * ```
206
- */
207
- export function usePeers(scope) {
208
- const ctx = useContext(AbloInternalContext);
209
- const engine = ctx?.engine ?? null;
210
- // Resolve scope → groups through the schema.
211
- // The stringified, sorted key is the stable effect dependency.
212
- const scopeKey = JSON.stringify(resolveScopeGroups(scope, engine?.schema).sort());
213
- const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
214
- const [peers, setPeers] = useState(EMPTY_PRESENCE);
215
- useEffect(() => {
216
- if (!engine) {
217
- setPeers(EMPTY_PRESENCE);
218
- return;
219
- }
220
- const presence = presenceOfClient(engine);
221
- const compute = () => groups.length === 0
222
- ? presence.others
223
- : presence.others.filter((session) => session.activities.some(({ target }) => target.id !== undefined && groups.includes(`${target.model.toLowerCase()}:${target.id}`)));
224
- // Plain useState + onChange — presence changes on connect/disconnect/activity
225
- // only (never on cursor traffic, a separate channel), so this fires
226
- // rarely; a frame of stale presence is harmless.
227
- setPeers(compute());
228
- return presence.onChange(() => { setPeers(compute()); });
229
- }, [engine, groups, scopeKey]);
230
- return peers;
231
- }
232
- // ── Escape-hatches: raw engine/store access ──────────────────────────
233
- /**
234
- * Returns the raw `SyncEngine` proxy. Typically you want the typed
235
- * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
236
- * where you need direct access (e.g., `sync.items.onChange(cb)`).
237
- *
238
- * The generic parameter narrows the return type to your schema's
239
- * model record so call sites get typed `sync.items.findMany()` /
240
- * `sync.sections.create(...)` without a cast at the call site:
241
- *
242
- * ```ts
243
- * const sync = useSync<(typeof schema)['models']>();
244
- * ```
245
- *
246
- * The runtime value is the exact engine the provider constructed;
247
- * the generic just widens the compile-time type.
248
- */
249
- export function useSync() {
250
- const ctx = useContext(AbloInternalContext);
251
- if (!ctx) {
252
- throw new AbloValidationError('useSync: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
253
- }
254
- if (!ctx.engine) {
255
- throw new AbloValidationError('useSync: the sync engine has not yet initialized. Wrap your ' +
256
- 'consumer in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
257
- }
258
- return rebindProviderEngine(ctx.engine);
259
- }
260
- function rebindProviderEngine(engine) {
261
- return engine;
262
- }
263
- /**
264
- * Returns the underlying `SyncStoreContract` (the BaseSyncedStore).
265
- * Most consumers should prefer the typed hooks (`useQuery` etc.); this
266
- * is for advanced cases like direct InstanceCache access or custom
267
- * reactive bridges. Throws if the provider hasn't mounted the store
268
- * yet — wrap consumers in `<ClientSideSuspense>` to gate correctly.
269
- *
270
- * The generic parameter lets consumers widen the return type to a
271
- * concrete `BaseSyncedStore<...>` subclass if they track one:
272
- *
273
- * ```ts
274
- * type AppStore = BaseSyncedStore<AppEvents, typeof schema>;
275
- * const store = useSyncStore<AppStore>(); // no cast needed at call site
276
- * ```
277
- *
278
- * The runtime value is always the concrete store the SDK constructed,
279
- * so widening the type is safe. The bounded generic (`T extends
280
- * SyncStoreContract`) keeps the widening honest.
281
- */
282
- export function useSyncStore() {
283
- const sync = useContext(SyncContext);
284
- if (!sync?.store) {
285
- throw new AbloValidationError('useSyncStore: the sync engine has not yet initialized. Wrap ' +
286
- 'consumers in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
287
- }
288
- return sync.store;
289
- }
@@ -1,27 +1,13 @@
1
1
  /**
2
- * 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
+ }
@@ -14,24 +14,6 @@ type DefaultModels = ResolveSchema extends {
14
14
  } ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
15
15
  export type ModelClientSelector<R extends SchemaRecord, T, C> = (ablo: AbloReads<R>) => ModelOperations<T, C>;
16
16
  export type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
17
- export interface UseAbloModelOptions<T> {
18
- /**
19
- * An initial row, usually from a server component or a route loader. The hook
20
- * returns it until sync delivers a newer row for the same id.
21
- */
22
- readonly initial?: T;
23
- }
24
- export interface UseAbloModelResult<T> {
25
- /** The current row for the id, or `initial` until the row has synced. */
26
- readonly data: T | undefined;
27
- /** The work claims currently held on this row by any participant. */
28
- readonly claims: readonly ModelClaim[];
29
- /** True while another participant holds a claim — handy for disabling UI. */
30
- readonly claimed: boolean;
31
- }
32
- export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'> & {
33
- readonly data: T;
34
- };
35
17
  /**
36
18
  * Reads Ablo from inside an `<AbloProvider>` subtree. Called with no arguments
37
19
  * it returns the typed client for use in callbacks and effects; called with a
@@ -66,33 +48,34 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
66
48
  * const ablo = useAblo<(typeof schema)['models']>();
67
49
  * ```
68
50
  *
69
- * The no-argument form returns `null` while the engine is still bootstrapping.
70
- * Branch on `null` and render a loading state — or gate on `useSyncStatus()`
71
- * reaching `'connected'` — before calling model methods.
51
+ * The client and its status are available during provider startup. Select
52
+ * `ablo.status` to display connection state; await `ablo.ready()` before
53
+ * operations that require an initialized client. Without a provider, the
54
+ * no-argument form returns `null` and selectors return `undefined`.
72
55
  */
73
56
  export declare function useAblo<R extends SchemaRecord = DefaultModels>(): Ablo<R> | null;
74
57
  export declare function useAblo<R extends SchemaRecord = DefaultModels, T = unknown>(select: AbloSelector<R, T>): T | undefined;
75
- export declare function useAblo<T, C>(modelClient: ModelOperations<T, C>, id: string, options: UseAbloModelOptions<T> & {
76
- readonly initial: T;
77
- }): UseAbloHydratedModelResult<T>;
78
- export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: ModelClientSelector<R, T, C>, id: string, options: UseAbloModelOptions<T> & {
79
- readonly initial: T;
80
- }): UseAbloHydratedModelResult<T>;
81
- export declare function useAblo<T, C>(modelClient: ModelOperations<T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
82
- export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: ModelClientSelector<R, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
83
- /**
84
- * @internal The one implementation behind `useAblo` and the bound hooks a
85
- * `createAbloReact` binding returns — written once so the reactive read path
86
- * cannot fork between the global hook and a factory's.
87
- *
88
- * `boundClient` is a binding's own context value — typed `Ablo<S>` at the
89
- * factory, so that path never rebinds and never casts. `null` means "no
90
- * binding provider in this tree": the global hook always passes it, and a
91
- * binding hook mounted under a legacy provider falls through to the erased
92
- * internal context, which is what keeps both mounts working while the last
93
- * legacy mount migrates.
94
- */
95
- export declare function useAbloImpl<R extends SchemaRecord, T = Record<string, unknown>, C = unknown>(boundClient: Ablo<R> | null, modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>, id?: string, options?: UseAbloModelOptions<T>): Ablo<R> | null | UseAbloModelResult<T> | T | undefined;
96
- /** @internal Resolve the bound or legacy provider client through one rebind seam. */
97
- export declare function useAbloClientImpl<R extends SchemaRecord>(boundClient: Ablo<R> | null): Ablo<R> | null;
58
+ export declare function useAblo<T, C>(modelClient: ModelOperations<T, C>, id: string, options?: useAblo.Options<T>): useAblo.Result<T>;
59
+ export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: ModelClientSelector<R, T, C>, id: string, options?: useAblo.Options<T>): useAblo.Result<T>;
60
+ /** @internal Resolve the nearest provider's client through one schema rebind. */
61
+ export declare function useAbloClient<R extends SchemaRecord>(): Ablo<R> | null;
62
+ /** Type annotations belong to the operation; most callers rely on inference. */
63
+ export declare namespace useAblo {
64
+ interface Options<T> {
65
+ /**
66
+ * An initial row, usually from a server component or a route loader. The hook
67
+ * uses it for hydration and until a local row has been observed. A later
68
+ * local removal returns undefined instead of restoring this seed.
69
+ */
70
+ readonly initial?: T;
71
+ }
72
+ interface Result<T> {
73
+ /** The local row or its initial seed. Undefined is a local cache miss, not proof of server absence. */
74
+ readonly data: T | undefined;
75
+ /** The work claims currently held on this row by any participant. */
76
+ readonly claims: readonly ModelClaim[];
77
+ /** True while another participant holds a claim — handy for disabling UI. */
78
+ readonly claimed: boolean;
79
+ }
80
+ }
98
81
  export {};