@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,9 +1,8 @@
1
1
  'use client';
2
- import { useContext, useEffect, useState } from 'react';
2
+ import { useCallback, useContext, useEffect, useMemo } from 'react';
3
3
  import { AbloInternalContext } from './internalContext.js';
4
4
  import { getModelClientMeta, } from '../local/client/createModelOperations.js';
5
- import { Model } from '../local/Model.js';
6
- import { useReactive } from '../useReactive.js';
5
+ import { useReactive } from './useReactive.js';
7
6
  const EMPTY_CLAIMS = Object.freeze([]);
8
7
  /**
9
8
  * Restore the caller's schema generics on the context-held engine. React
@@ -27,50 +26,15 @@ function readModelResult(engine, modelClient, id, initial) {
27
26
  if (!modelClient || id === undefined) {
28
27
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
29
28
  }
30
- const data = snapshotValue(modelClient.local.get(id) ?? initial);
29
+ const data = modelClient.local.get(id) ?? initial;
31
30
  const meta = getModelClientMeta(modelClient);
32
31
  const claims = meta && engine
33
32
  ? engine.claims.list({ model: meta.key, id })
34
33
  : EMPTY_CLAIMS;
35
34
  return { data, claims, claimed: claims.length > 0 };
36
35
  }
37
- /**
38
- * Projects a reactive read into the value that `useReactive` caches and
39
- * returns.
40
- *
41
- * For a `Model`, this reads the row's fields through `toReactiveSnapshot`
42
- * rather than returning the instance itself. Property access is what subscribes
43
- * the reaction to those fields, so the read has to happen inside this tracked
44
- * function; returning the live instance without reading its fields would leave
45
- * the component blind to later edits. The fresh object it produces also lets
46
- * `useReactive`'s equality check detect an in-place update.
47
- */
48
- function snapshotValue(value) {
49
- if (value instanceof Model) {
50
- return value.toReactiveSnapshot();
51
- }
52
- if (Array.isArray(value)) {
53
- return value.map((item) => snapshotValue(item));
54
- }
55
- return value;
56
- }
57
36
  export function useAblo(modelOrSelect, id, options) {
58
- return useAbloImpl(null, modelOrSelect, id, options);
59
- }
60
- /**
61
- * @internal The one implementation behind `useAblo` and the bound hooks a
62
- * `createAbloReact` binding returns — written once so the reactive read path
63
- * cannot fork between the global hook and a factory's.
64
- *
65
- * `boundClient` is a binding's own context value — typed `Ablo<S>` at the
66
- * factory, so that path never rebinds and never casts. `null` means "no
67
- * binding provider in this tree": the global hook always passes it, and a
68
- * binding hook mounted under a legacy provider falls through to the erased
69
- * internal context, which is what keeps both mounts working while the last
70
- * legacy mount migrates.
71
- */
72
- export function useAbloImpl(boundClient, modelOrSelect, id, options) {
73
- const engine = useAbloClientImpl(boundClient);
37
+ const engine = useAbloClient();
74
38
  const initial = options?.initial;
75
39
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
76
40
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
@@ -80,43 +44,44 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
80
44
  : typeof modelOrSelect === 'function'
81
45
  ? undefined
82
46
  : modelOrSelect;
83
- // Claims arrive through an event emitter (engine.claims), not through MobX, so
84
- // the useReactive reactions below cannot track them; we bridge changes with a
85
- // setState bump instead. Subscribe the model-row form (`id !== undefined`)
86
- // to claims. Selector-only reads track MobX model data; callers displaying
87
- // ownership use the row form's `claims` / `claimed` result.
88
- const [claimVersion, setClaimVersion] = useState(0);
89
- useEffect(() => {
90
- if (!engine || id === undefined)
91
- return;
92
- return engine.claims.onChange(() => { setClaimVersion((version) => version + 1); });
93
- }, [engine, id]);
94
- const selected = useReactive(() => {
95
- if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
96
- return undefined;
47
+ // The initial row is a seed for this client/model/id, not a permanent
48
+ // fallback: once local data has been committed to the UI, its removal must
49
+ // not resurrect the seed. Only committed effects change this marker.
50
+ // These dependencies define when the seed belongs to a different row.
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ const seed = useMemo(() => ({ received: false }), [engine, modelClient, id]);
53
+ const reading = modelOrSelect !== undefined;
54
+ const subscribe = useCallback((notify) => {
55
+ if (!engine || !reading)
56
+ return () => undefined;
57
+ return engine.claims.onChange(notify);
58
+ }, [engine, reading]);
59
+ const value = useReactive(() => {
60
+ if (isSelectorOnly && typeof modelOrSelect === 'function') {
61
+ return engine ? modelOrSelect(reactiveReads(engine)) : undefined;
97
62
  }
98
- // The selector runs against the real engine — reads inside it return the
99
- // pool's model instances. `snapshotValue` then converts the RESULT to
100
- // plain snapshot rows, which is what the selector's `AbloReads`
101
- // parameter type already promised.
102
- return snapshotValue(modelOrSelect(reactiveReads(engine)));
103
- });
104
- const modelResult = useReactive(() => {
105
- void claimVersion;
106
- return readModelResult(engine, modelClient, id, initial);
63
+ if (modelOrSelect) {
64
+ return readModelResult(engine, modelClient, id, seed.received ? undefined : initial);
65
+ }
66
+ return undefined;
67
+ }, {
68
+ subscribe,
69
+ // The same seed produces the server HTML and the first hydration render,
70
+ // even if the browser already has a newer row or claim in its local cache.
71
+ ...(id !== undefined && initial !== undefined ? {
72
+ serverSnapshot: () => ({ data: initial, claims: EMPTY_CLAIMS, claimed: false }),
73
+ } : {}),
107
74
  });
108
- if (isSelectorOnly)
109
- return selected;
110
- if (modelOrSelect)
111
- return modelResult;
75
+ useEffect(() => {
76
+ if (id !== undefined && modelClient?.local.get(id) !== undefined)
77
+ seed.received = true;
78
+ }, [seed, modelClient, id, value]);
79
+ if (isSelectorOnly || modelOrSelect)
80
+ return value;
112
81
  return engine;
113
82
  }
114
- /** @internal Resolve the bound or legacy provider client through one rebind seam. */
115
- export function useAbloClientImpl(boundClient) {
83
+ /** @internal Resolve the nearest provider's client through one schema rebind. */
84
+ export function useAbloClient() {
116
85
  const ctx = useContext(AbloInternalContext);
117
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
118
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
119
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
120
- const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
121
- return engine;
86
+ return ctx?.engine ? rebindEngine(ctx.engine) : null;
122
87
  }
@@ -14,7 +14,7 @@ import type { ResolveSchema } from '@abloatai/transaction/types/global';
14
14
  * If a mutator throws, the error propagates to the caller and any writes it
15
15
  * already dispatched stay in place — there is no automatic rollback. Wrap the
16
16
  * call in your own try/catch and issue compensating writes when you need to
17
- * undo a partial change, or pass an `undoScope` (see {@link UseMutatorsOptions})
17
+ * undo a partial change, or pass an `undoScope` (see {@link useMutators.Options})
18
18
  * to record inverses for undo and redo.
19
19
  */
20
20
  /**
@@ -34,23 +34,26 @@ export type InvokerFor<F> = F extends (options: infer O) => Promise<infer R> ? O
34
34
  * The hook's return shape: same tree as the input `MutatorDefs`, every leaf
35
35
  * rewritten to its invoker form.
36
36
  */
37
- export type MutatorInvokers<M> = {
38
- [K in keyof M]: {
39
- [N in keyof M[K]]: InvokerFor<M[K][N]>;
40
- };
41
- };
42
- /**
43
- * Options passed to `useMutators`. When `undoScope` is set, every mutator
44
- * invocation is wrapped in a `RecordingMutation` and its inverses are
45
- * pushed to the scope as one undo entry.
46
- */
47
- export interface UseMutatorsOptions<S extends Schema> {
48
- /** Target undo scope for recording inverses. Omit to disable recording. */
49
- undoScope?: UndoScope<S>;
50
- }
51
37
  /** Mutator invokers (explicit schema arg). */
52
- export declare function useMutators<S extends Schema, M extends MutatorDefs<S>>(schema: S, mutators: M, options?: UseMutatorsOptions<S>): MutatorInvokers<M>;
38
+ export declare function useMutators<S extends Schema, M extends MutatorDefs<S>>(schema: S, mutators: M, options?: useMutators.Options<S>): useMutators.Result<M>;
53
39
  /** Mutator invokers via the `Register` module augmentation. Schema comes
54
40
  * from the `SyncProvider`'s context; the mutator tree is typed against
55
41
  * `ResolveSchema` at the call site. */
56
- export declare function useMutators<M extends ResolveSchema extends Schema ? MutatorDefs<ResolveSchema> : MutatorDefs<Schema>>(mutators: M, options?: UseMutatorsOptions<ResolveSchema extends Schema ? ResolveSchema : Schema>): MutatorInvokers<M>;
42
+ export declare function useMutators<M extends ResolveSchema extends Schema ? MutatorDefs<ResolveSchema> : MutatorDefs<Schema>>(mutators: M, options?: useMutators.Options<ResolveSchema extends Schema ? ResolveSchema : Schema>): useMutators.Result<M>;
43
+ /** Optional annotations for custom mutation bindings. */
44
+ export declare namespace useMutators {
45
+ type Result<M> = {
46
+ [K in keyof M]: {
47
+ [N in keyof M[K]]: InvokerFor<M[K][N]>;
48
+ };
49
+ };
50
+ /**
51
+ * Options passed to `useMutators`. When `undoScope` is set, every mutator
52
+ * invocation is wrapped in a `RecordingMutation` and its inverses are
53
+ * pushed to the scope as one undo entry.
54
+ */
55
+ interface Options<S extends Schema> {
56
+ /** Target undo scope for recording inverses. Omit to disable recording. */
57
+ undoScope?: UndoScope<S>;
58
+ }
59
+ }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
- import { useEffect, useState } from 'react';
2
+ import { useCallback, useEffect } from 'react';
3
3
  import { AbloValidationError } from '@abloatai/transaction/errors';
4
4
  import { getModelClientMeta, } from '../local/client/createModelOperations.js';
5
- import { useAbloClientImpl } from './useAblo.js';
5
+ import { useAbloClient } from './useAblo.js';
6
+ import { useReactive } from './useReactive.js';
6
7
  export function usePresence(modelOrSelect, recordId) {
7
- const engine = useAbloClientImpl(null);
8
+ const engine = useAbloClient();
8
9
  return usePresenceImpl(engine, modelOrSelect, recordId);
9
10
  }
10
11
  /** @internal Shared by the global hook and schema-bound React factory. */
@@ -21,8 +22,8 @@ export function usePresenceImpl(engine, modelOrSelect, recordId) {
21
22
  if (modelClient !== null && presence === undefined) {
22
23
  throw new AbloValidationError('usePresence requires a model from the reactive Ablo client.', { code: 'invalid_request', param: 'modelClient' });
23
24
  }
24
- const [, render] = useState(0);
25
- useEffect(() => presence?.subscribe(() => { render((version) => version + 1); }), [presence]);
25
+ const subscribe = useCallback((notify) => presence?.subscribe(notify) ?? (() => undefined), [presence]);
26
+ const sessions = useReactive(() => presence?.get(recordId) ?? [], { subscribe });
26
27
  useEffect(() => presence?.read(recordId), [presence, recordId]);
27
- return presence?.get(recordId) ?? [];
28
+ return sessions;
28
29
  }
@@ -0,0 +1,12 @@
1
+ interface ObservationOptions<T> {
2
+ equals?: (a: T, b: T) => boolean;
3
+ subscribe?: (listener: () => void) => () => void;
4
+ serverSnapshot?: () => T;
5
+ }
6
+ /**
7
+ * Each render's computation has its own observation. A suspended render cannot
8
+ * replace the computation used by the committed tree's subscription. Reactions
9
+ * start only on subscription, so abandoned renders retain no store observers.
10
+ */
11
+ export declare function useReactive<T>(compute: () => T, options?: ObservationOptions<T>): T;
12
+ export {};
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
3
+ import { reaction } from 'mobx';
4
+ import { equalSnapshots, snapshotValue } from './snapshot.js';
5
+ /**
6
+ * Each render's computation has its own observation. A suspended render cannot
7
+ * replace the computation used by the committed tree's subscription. Reactions
8
+ * start only on subscription, so abandoned renders retain no store observers.
9
+ */
10
+ export function useReactive(compute, options = {}) {
11
+ const { equals = equalSnapshots, subscribe, serverSnapshot = compute } = options;
12
+ const committed = useRef(null);
13
+ const observation = useMemo(() => {
14
+ let cached = committed.current;
15
+ let server = null;
16
+ const failed = Symbol('selector error');
17
+ const read = () => {
18
+ const next = snapshotValue(compute());
19
+ if (cached === null || !equals(cached.value, next))
20
+ cached = { value: next };
21
+ return cached.value;
22
+ };
23
+ return {
24
+ read,
25
+ readServer: () => {
26
+ server ??= { value: snapshotValue(serverSnapshot()) };
27
+ return server.value;
28
+ },
29
+ subscribe: (notify) => {
30
+ const stop = reaction(() => {
31
+ // Notify React of selector failures; React re-reads and delivers the
32
+ // exception to its error boundary instead of MobX swallowing it.
33
+ try {
34
+ return read();
35
+ }
36
+ catch {
37
+ return failed;
38
+ }
39
+ }, () => { notify(); });
40
+ let stopExternal;
41
+ try {
42
+ stopExternal = subscribe?.(notify);
43
+ }
44
+ catch (error) {
45
+ stop();
46
+ throw error;
47
+ }
48
+ return () => { stop(); stopExternal?.(); };
49
+ },
50
+ };
51
+ }, [compute, equals, subscribe, serverSnapshot]);
52
+ // read() recomputes from the actual store, including React's consistency
53
+ // checks between render and commit, while equal snapshots retain identity.
54
+ const value = useSyncExternalStore(observation.subscribe, observation.read, observation.readServer);
55
+ useEffect(() => { committed.current = { value }; }, [value]);
56
+ return value;
57
+ }
@@ -1,34 +1,20 @@
1
1
  import type { Schema } from '@abloatai/transaction/schema/schema';
2
2
  import { type UndoScope, type UndoScopeOptions } from '../local/mutators/UndoManager.js';
3
3
  import type { ResolveSchema } from '@abloatai/transaction/types/global';
4
- /**
5
- * Provides per-surface undo and redo for mutator invocations. Each named scope
6
- * owns an independent undo/redo stack, so different parts of your app — a main
7
- * editor, a sidebar form — can undo separately without stepping on each other.
8
- *
9
- * Wire the returned `scope` into `useMutators(schema, mutators, { undoScope:
10
- * scope })` and those invocations become recorded. `undo()` and `redo()` replay
11
- * the captured inverses and forwards as new transactions that do not record
12
- * themselves; the manager moves the entry between the two stacks explicitly.
13
- *
14
- * @example
15
- * const { undo, redo, canUndo, canRedo, scope } = useUndoScope('report-editor');
16
- * const mutate = useMutators(schema, reportMutators, { undoScope: scope });
17
- *
18
- * // Cmd+Z handler
19
- * useHotkey('mod+z', () => { if (canUndo) void undo(); });
20
- */
21
- export interface UseUndoScopeResult<S extends Schema> {
22
- /** Pass to `useMutators(..., { undoScope })` to enable recording. */
23
- scope: UndoScope<S>;
24
- undo: () => Promise<void>;
25
- redo: () => Promise<void>;
26
- canUndo: boolean;
27
- canRedo: boolean;
28
- /** Drop history. Use after sync errors / auth context changes. */
29
- clear: () => void;
30
- }
31
4
  /** Per-surface undo/redo (explicit schema arg). */
32
- export declare function useUndoScope<S extends Schema>(schema: S, name: string, options?: UndoScopeOptions): UseUndoScopeResult<S>;
5
+ export declare function useUndoScope<S extends Schema>(schema: S, name: string, options?: UndoScopeOptions): useUndoScope.Result<S>;
33
6
  /** Per-surface undo/redo via the `Register` module augmentation. */
34
- export declare function useUndoScope(name: string, options?: UndoScopeOptions): UseUndoScopeResult<ResolveSchema extends Schema ? ResolveSchema : Schema>;
7
+ export declare function useUndoScope(name: string, options?: UndoScopeOptions): useUndoScope.Result<ResolveSchema extends Schema ? ResolveSchema : Schema>;
8
+ /** The state returned by an undo scope; inferred for ordinary hook calls. */
9
+ export declare namespace useUndoScope {
10
+ interface Result<S extends Schema> {
11
+ /** Pass to `useMutators(..., { undoScope })` to enable recording. */
12
+ scope: UndoScope<S>;
13
+ undo: () => Promise<void>;
14
+ redo: () => Promise<void>;
15
+ canUndo: boolean;
16
+ canRedo: boolean;
17
+ /** Drop history. Use after sync errors / auth context changes. */
18
+ clear: () => void;
19
+ }
20
+ }
@@ -3,6 +3,23 @@ import { useEffect, useMemo, useState } from 'react';
3
3
  import { UndoManager, } from '../local/mutators/UndoManager.js';
4
4
  import { useSyncContext } from './context.js';
5
5
  import { AbloValidationError } from '@abloatai/transaction/errors';
6
+ /**
7
+ * Provides per-surface undo and redo for mutator invocations. Each named scope
8
+ * owns an independent undo/redo stack, so different parts of your app — a main
9
+ * editor, a sidebar form — can undo separately without stepping on each other.
10
+ *
11
+ * Wire the returned `scope` into `useMutators(schema, mutators, { undoScope:
12
+ * scope })` and those invocations become recorded. `undo()` and `redo()` replay
13
+ * the captured inverses and forwards as new transactions that do not record
14
+ * themselves; the manager moves the entry between the two stacks explicitly.
15
+ *
16
+ * @example
17
+ * const { undo, redo, canUndo, canRedo, scope } = useUndoScope('report-editor');
18
+ * const mutate = useMutators(schema, reportMutators, { undoScope: scope });
19
+ *
20
+ * // Cmd+Z handler
21
+ * useHotkey('mod+z', () => { if (canUndo) void undo(); });
22
+ */
6
23
  // Module-level weak registry: `SyncStoreContract` → `UndoManager`.
7
24
  // A single app wiring through one SyncProvider shares one manager across
8
25
  // every useUndoScope call, so scopes with the same name are identity-equal.
package/dist/react.d.ts CHANGED
@@ -1,19 +1,7 @@
1
- /** React bindings for the optional human-facing local-state package. */
2
- export { useReactive } from './useReactive.js';
3
- export { useCurrentUserId } from './react/useCurrentUserId.js';
4
- export { useErrorListener } from './react/useErrorListener.js';
5
- export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
- export { usePresence, type PresenceModelSelector, } from './react/usePresence.js';
7
- export { useMutationFailureListener, type MutationFailurePayload, } from './react/useMutationFailureListener.js';
8
- export { AbloProvider, usePeers, useSync, useSyncStore, type AbloProviderProps, type GroupScope, } from './react/AbloProvider.js';
9
- export { ClientSideSuspense, type ClientSideSuspenseProps, } from './react/ClientSideSuspense.js';
10
- export { DefaultFallback } from './react/DefaultFallback.js';
11
- export { createAbloReact, type AbloReactBinding, } from './react/createAbloReact.js';
12
- export { useAblo, type UseAbloHydratedModelResult, type UseAbloModelOptions, type UseAbloModelResult, } from './react/useAblo.js';
13
- export { useMutators, type InvokerFor, type MutatorInvokers, type UseMutatorsOptions, } from './react/useMutators.js';
14
- export { useUndoScope, type UseUndoScopeResult, } from './react/useUndoScope.js';
15
- export type { DefaultSyncShape, ResolveSchema, ResolveUserMeta, ResolveClaimMeta, ResolveModelKey, } from '@abloatai/transaction/types/global';
16
- export { ModelScope } from '@abloatai/transaction/types';
17
- export type { SyncStoreContract } from './react/context.js';
18
- export type { MutateActions } from './local/mutators/mutateActions.js';
19
- export type { ReaderActions, ReaderFindOptions, } from './local/mutators/readerActions.js';
1
+ /** React owns context, subscriptions and component lifetimes over core Ablo. */
2
+ export { AbloProvider } from './react/AbloProvider.js';
3
+ export { createAbloReact } from './react/createAbloReact.js';
4
+ export { useAblo } from './react/useAblo.js';
5
+ export { usePresence } from './react/usePresence.js';
6
+ export { useMutators } from './react/useMutators.js';
7
+ export { useUndoScope } from './react/useUndoScope.js';
package/dist/react.js CHANGED
@@ -1,15 +1,7 @@
1
- /** React bindings for the optional human-facing local-state package. */
2
- export { useReactive } from './useReactive.js';
3
- export { useCurrentUserId } from './react/useCurrentUserId.js';
4
- export { useErrorListener } from './react/useErrorListener.js';
5
- export { useSyncStatus } from './react/useSyncStatus.js';
6
- export { usePresence, } from './react/usePresence.js';
7
- export { useMutationFailureListener, } from './react/useMutationFailureListener.js';
8
- export { AbloProvider, usePeers, useSync, useSyncStore, } from './react/AbloProvider.js';
9
- export { ClientSideSuspense, } from './react/ClientSideSuspense.js';
10
- export { DefaultFallback } from './react/DefaultFallback.js';
11
- export { createAbloReact, } from './react/createAbloReact.js';
12
- export { useAblo, } from './react/useAblo.js';
13
- export { useMutators, } from './react/useMutators.js';
14
- export { useUndoScope, } from './react/useUndoScope.js';
15
- export { ModelScope } from '@abloatai/transaction/types';
1
+ /** React owns context, subscriptions and component lifetimes over core Ablo. */
2
+ export { AbloProvider } from './react/AbloProvider.js';
3
+ export { createAbloReact } from './react/createAbloReact.js';
4
+ export { useAblo } from './react/useAblo.js';
5
+ export { usePresence } from './react/usePresence.js';
6
+ export { useMutators } from './react/useMutators.js';
7
+ export { useUndoScope } from './react/useUndoScope.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.63.1",
3
+ "version": "0.64.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -71,7 +71,8 @@
71
71
  "lint:eslint": "eslint . --cache --suppressions-location eslint-suppressions.json",
72
72
  "check:boundary": "node scripts/check-boundary.mjs",
73
73
  "test:integration": "npm run build && node scripts/check-package-integration.mjs",
74
- "lint:pkg": "publint"
74
+ "lint:pkg": "publint",
75
+ "typecheck:react": "tsc -p typetests/binding/tsconfig.json"
75
76
  },
76
77
  "publishConfig": {
77
78
  "access": "public",
@@ -84,7 +85,7 @@
84
85
  "directory": "packages/humans"
85
86
  },
86
87
  "dependencies": {
87
- "@abloatai/transaction": "^0.63.1",
88
+ "@abloatai/transaction": "^0.64.0",
88
89
  "mobx": "^6.13.7",
89
90
  "uuid": "^11.1.0",
90
91
  "zod": "^4.4.3"
package/src/Ablo.ts CHANGED
@@ -264,6 +264,11 @@ import type * as _Global from '@abloatai/transaction/types/global';
264
264
  */
265
265
  // eslint-disable-next-line @typescript-eslint/no-namespace
266
266
  export namespace Ablo {
267
+ /** Payload delivered by the core client's onMutationFailure subscription. */
268
+ export type MutationFailure = Parameters<Parameters<AbloClient<SchemaRecord>['onMutationFailure']>[0]>[0];
269
+ /** Current client lifecycle, also selected through React's useAblo. */
270
+ export type Status = import('./local/client/status.js').ClientStatus;
271
+
267
272
  // ── Factory options ────────────────────────────────────────────────
268
273
  export type Options<S extends SchemaRecord = SchemaRecord> = AbloOptions<S>;
269
274
  /**
package/src/client.ts CHANGED
@@ -19,9 +19,9 @@ import type {
19
19
  InferRow,
20
20
  } from '@abloatai/transaction/schema/schema';
21
21
  import type { InstanceCache } from './local/InstanceCache.js';
22
- import type { SyncStoreContract } from './react/context.js';
22
+ import type { SyncStoreContract } from './local/storeContract.js';
23
23
  import type { SyncWebSocket, CoreSyncEventMap } from './local/sync/SyncWebSocket.js';
24
- import type { SyncStatus } from './local/BaseSyncedStore.js';
24
+ import type { ClientStatus } from './local/client/status.js';
25
25
  import type { ModelOperations } from './local/client/createModelOperations.js';
26
26
  import type {
27
27
  ClaimResource,
@@ -39,7 +39,10 @@ export type AbloClient<S extends SchemaRecord> = {
39
39
  Model<Schema<S>, K>,
40
40
  InferCreate<Schema<S>, K>
41
41
  >;
42
- } & {
42
+ } & AbloCore<S>;
43
+
44
+ /** Core members stay intact even when the model schema is not registered. */
45
+ interface AbloCore<S extends SchemaRecord> {
43
46
  /**
44
47
  * Wait for the sync engine to finish its initial bootstrap.
45
48
  * Resolves once entity data is loaded and the WebSocket is connected.
@@ -65,7 +68,7 @@ export type AbloClient<S extends SchemaRecord> = {
65
68
  * acknowledged everything before continuing — for example, before
66
69
  * navigating away, before triggering a server-side workflow, or in tests.
67
70
  *
68
- * Resolves when `syncStatus.pendingChanges` reaches 0. If the engine is
71
+ * Resolves when all pending local changes are confirmed. If the engine is
69
72
  * offline, this waits until reconnect + flush completes.
70
73
  *
71
74
  * ```ts
@@ -205,32 +208,10 @@ export type AbloClient<S extends SchemaRecord> = {
205
208
  waitForConfirmation(modelName: string, modelId: string): Promise<void>;
206
209
 
207
210
  /**
208
- * Reactive sync status — a MobX observable.
209
- *
210
- * Single source of truth for "what's the sync engine doing?" Contains:
211
- * - `state`: `'idle' | 'syncing' | 'error' | 'offline' | 'reconnecting'`
212
- * - `progress`: 0-100 for bootstrap progress
213
- * - `error?`: Error object when `state === 'error'`
214
- * - `pendingChanges`: Number of unconfirmed mutations in the queue
215
- * - `lastSyncAt?`: Timestamp of the last successful delta processing
216
- * - `offlineSince?`: When the connection dropped
217
- * - `isSessionError`: True when the error requires re-authentication
218
- *
219
- * React components using `observer()` re-render automatically when
220
- * any field changes — no manual subscription or polling needed.
221
- *
222
- * ```tsx
223
- * import { observer } from 'mobx-react-lite';
224
- *
225
- * const SyncIndicator = observer(() => {
226
- * if (sync.syncStatus.state === 'syncing') return <Spinner />;
227
- * if (sync.syncStatus.state === 'error') return <Error msg={sync.syncStatus.error} />;
228
- * if (sync.syncStatus.state === 'offline') return <OfflineBadge />;
229
- * return null;
230
- * });
231
- * ```
211
+ * Current connection and confirmation state. Available before ready().
212
+ * React reads the same value with `useAblo(ablo => ablo.status)`.
232
213
  */
233
- readonly syncStatus: SyncStatus;
214
+ readonly status: ClientStatus;
234
215
 
235
216
  /**
236
217
  * Session-owned live activity projected from this client's existing
@@ -290,7 +271,7 @@ export type AbloClient<S extends SchemaRecord> = {
290
271
  * no window in which this is absent and nothing needs to guard for one.
291
272
  */
292
273
  readonly _ws: SyncWebSocket;
293
- };
274
+ }
294
275
 
295
276
  /**
296
277
  * The reactive-read client a `useAblo` selector receives. The same surface as
@@ -301,7 +282,7 @@ export type AbloClient<S extends SchemaRecord> = {
301
282
  * compile error here instead of a silent runtime `undefined`; compose
302
283
  * relations through selectors or hooks that resolve the pool's instance.
303
284
  */
304
- export type AbloReads<S extends SchemaRecord> = Omit<AbloClient<S>, keyof S & string> & {
285
+ export type AbloReads<S extends SchemaRecord> = AbloCore<S> & {
305
286
  readonly [K in keyof S & string]: ModelOperations<
306
287
  InferRow<Schema<S>, K>,
307
288
  InferCreate<Schema<S>, K>
@@ -29,6 +29,7 @@ import {
29
29
  streamTarget,
30
30
  subTarget,
31
31
  } from '@abloatai/transaction/coordination';
32
+ import { readStatus } from './status.js';
32
33
  import { validateAbloOptions } from './validateAbloOptions.js';
33
34
  import type { StoreCluster } from './storeCluster.js';
34
35
  import { startStoreLifecycle } from './storeLifecycle.js';
@@ -842,11 +843,9 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
842
843
  return store.waitForConfirmation(modelName, modelId);
843
844
  },
844
845
 
845
- // Expose the store's MobX observable directly — single source of truth.
846
- // React components using observer() will re-render automatically on
847
- // any state change (syncing, error, offline, pendingChanges, progress).
848
- get syncStatus() {
849
- return store.syncStatus;
846
+ // One core lifecycle projection; React selects the same observable reads.
847
+ get status() {
848
+ return readStatus(store);
850
849
  },
851
850
 
852
851
  // The humans capability owns the connection-backed presence projection.
@@ -0,0 +1,20 @@
1
+ import type { SyncStoreContract } from '../storeContract.js';
2
+
3
+ /** The client lifecycle, shared by every framework binding. */
4
+ export type ClientStatus =
5
+ | { readonly name: 'initial' }
6
+ | { readonly name: 'connecting'; readonly progress: number }
7
+ | { readonly name: 'connected'; readonly hasUnsyncedChanges: boolean }
8
+ | { readonly name: 'reconnecting'; readonly reason?: string }
9
+ | { readonly name: 'disconnected'; readonly reason?: string }
10
+ | { readonly name: 'needs-auth' };
11
+
12
+ export function readStatus(store: SyncStoreContract): ClientStatus {
13
+ const { state, progress, pendingChanges, isSessionError, error } = store.syncStatus;
14
+ if (isSessionError) return { name: 'needs-auth' };
15
+ if (state === 'reconnecting') return { name: 'reconnecting', reason: error?.message };
16
+ if (state === 'offline') return { name: 'disconnected', reason: 'offline' };
17
+ if (state === 'error') return { name: 'disconnected', reason: error?.message };
18
+ if (store.isReady) return { name: 'connected', hasUnsyncedChanges: pendingChanges > 0 };
19
+ return { name: 'connecting', progress };
20
+ }
@@ -301,7 +301,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
301
301
  //
302
302
  // The store.initialize() generator updates store.syncStatus as it
303
303
  // progresses (syncing → idle on success, error on failure), so the
304
- // consumer's `sync.syncStatus` observable reflects real-time state.
304
+ // consumer's `ablo.status` projection reflects real-time state.
305
305
  // Resolve bootstrap mode: explicit option wins; otherwise
306
306
  // agents default to 'none' (transactional participant — see
307
307
  // option doc) and everyone else defaults to 'full'.
@@ -386,7 +386,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
386
386
  if (!validationError && internalOptions.autoStart) {
387
387
  void ready().catch(() => {
388
388
  // Error is captured in store.syncStatus; consumers should check
389
- // `sync.syncStatus.state === 'error'` to detect failures.
389
+ // `ablo.status.name === 'disconnected'` to detect failures.
390
390
  });
391
391
  }
392
392