@abloatai/humans 0.63.1 → 0.64.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Ablo.d.ts +6 -0
- package/dist/client.d.ts +12 -32
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local/client/createModelOperations.d.ts +3 -3
- package/dist/local/client/createModelOperations.js +1 -1
- package/dist/local/client/reactiveEngine.js +7 -8
- package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
- package/dist/local/client/status.js +14 -0
- package/dist/local/client/storeLifecycle.js +2 -2
- package/dist/local/mutators/defineMutators.d.ts +3 -48
- package/dist/local/mutators/defineMutators.js +0 -15
- package/dist/local/storeAccess.d.ts +7 -0
- package/dist/local/storeAccess.js +6 -0
- package/dist/local/storeContract.d.ts +3 -4
- package/dist/presence/index.d.ts +2 -2
- package/dist/presence/index.js +8 -4
- package/dist/react/AbloProvider.d.ts +57 -121
- package/dist/react/AbloProvider.js +55 -160
- package/dist/react/context.d.ts +6 -45
- package/dist/react/context.js +8 -20
- package/dist/react/createAbloReact.d.ts +13 -48
- package/dist/react/createAbloReact.js +8 -54
- package/dist/react/internalContext.d.ts +1 -27
- package/dist/react/snapshot.d.ts +4 -0
- package/dist/react/snapshot.js +75 -0
- package/dist/react/useAblo.d.ts +28 -92
- package/dist/react/useAblo.js +36 -93
- package/dist/react/useAbloClient.d.ts +5 -0
- package/dist/react/useAbloClient.js +9 -0
- package/dist/react/useMutationFailure.d.ts +6 -0
- package/dist/react/useMutationFailure.js +11 -0
- package/dist/react/useMutators.d.ts +22 -19
- package/dist/react/useMutators.js +3 -3
- package/dist/react/usePresence.d.ts +9 -9
- package/dist/react/usePresence.js +8 -11
- package/dist/react/useReactive.d.ts +12 -0
- package/dist/react/useReactive.js +57 -0
- package/dist/react/useUndoScope.d.ts +16 -30
- package/dist/react/useUndoScope.js +21 -4
- package/dist/react.d.ts +9 -19
- package/dist/react.js +9 -15
- package/dist/reactRuntime.d.ts +1 -1
- package/dist/reactRuntime.js +1 -1
- package/package.json +4 -3
- package/src/Ablo.ts +7 -0
- package/src/client.ts +13 -32
- package/src/index.ts +2 -0
- package/src/local/client/createModelOperations.ts +4 -4
- package/src/local/client/reactiveEngine.ts +7 -8
- package/src/local/client/status.ts +20 -0
- package/src/local/client/storeLifecycle.ts +2 -2
- package/src/local/mutators/defineMutators.ts +3 -51
- package/src/local/storeAccess.ts +10 -0
- package/src/local/storeContract.ts +3 -4
- package/src/presence/index.ts +10 -5
- package/src/react/AbloProvider.tsx +88 -263
- package/src/react/context.ts +10 -61
- package/src/react/createAbloReact.ts +16 -125
- package/src/react/internalContext.ts +1 -27
- package/src/react/snapshot.ts +67 -0
- package/src/react/useAblo.ts +73 -209
- package/src/react/useAbloClient.ts +14 -0
- package/src/react/useMutationFailure.ts +17 -0
- package/src/react/useMutators.ts +36 -32
- package/src/react/usePresence.ts +22 -27
- package/src/react/useReactive.ts +56 -0
- package/src/react/useUndoScope.ts +24 -20
- package/src/react.ts +9 -69
- package/src/reactRuntime.ts +2 -2
- package/dist/react/ClientSideSuspense.d.ts +0 -36
- package/dist/react/ClientSideSuspense.js +0 -17
- package/dist/react/useCurrentUserId.d.ts +0 -2
- package/dist/react/useCurrentUserId.js +0 -12
- package/dist/react/useErrorListener.d.ts +0 -2
- package/dist/react/useErrorListener.js +0 -14
- package/dist/react/useMutationFailureListener.d.ts +0 -8
- package/dist/react/useMutationFailureListener.js +0 -19
- package/dist/react/useSyncStatus.js +0 -48
- package/dist/useReactive.d.ts +0 -6
- package/dist/useReactive.js +0 -41
- package/src/react/ClientSideSuspense.tsx +0 -57
- package/src/react/useCurrentUserId.ts +0 -17
- package/src/react/useErrorListener.ts +0 -22
- package/src/react/useMutationFailureListener.ts +0 -34
- package/src/react/useSyncStatus.ts +0 -53
- package/src/useReactive.ts +0 -49
|
@@ -1,140 +1,31 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* path from context to component.
|
|
9
|
-
*
|
|
10
|
-
* The app's one binding file, by convention:
|
|
11
|
-
*
|
|
12
|
-
* ```ts
|
|
13
|
-
* // lib/ablo.ts
|
|
14
|
-
* import { createAbloReact } from '@abloatai/ablo/react';
|
|
15
|
-
* import { schema } from './schema';
|
|
16
|
-
*
|
|
17
|
-
* export const { AbloProvider, useAblo } = createAbloReact(schema);
|
|
18
|
-
* ```
|
|
19
|
-
*
|
|
20
|
-
* Components then import `useAblo` from `lib/ablo` and never spell a type
|
|
21
|
-
* argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
|
|
22
|
-
* parameter is the reactive-read view of the same `S`.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { createContext, createElement, useContext, type ReactElement } from 'react';
|
|
26
|
-
import {
|
|
27
|
-
AbloProvider,
|
|
28
|
-
type AbloProviderProps,
|
|
29
|
-
} from './AbloProvider.js';
|
|
30
|
-
import {
|
|
31
|
-
useAbloImpl,
|
|
32
|
-
useAbloClientImpl,
|
|
33
|
-
type AbloSelector,
|
|
34
|
-
type ModelClientSelector,
|
|
35
|
-
type UseAbloHydratedModelResult,
|
|
36
|
-
type UseAbloModelOptions,
|
|
37
|
-
type UseAbloModelResult,
|
|
38
|
-
} from './useAblo.js';
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import { useAbloClient } from './useAbloClient.js';
|
|
5
|
+
import { useMutationFailure } from './useMutationFailure.js';
|
|
6
|
+
import { AbloProvider } from './AbloProvider.js';
|
|
7
|
+
import { useAblo } from './useAblo.js';
|
|
39
8
|
import type { AbloClient as Ablo } from '../client.js';
|
|
40
|
-
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
41
9
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
42
|
-
import {
|
|
43
|
-
usePresenceImpl,
|
|
44
|
-
type PresenceModelSelector,
|
|
45
|
-
} from './usePresence.js';
|
|
46
|
-
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
10
|
+
import { usePresence } from './usePresence.js';
|
|
47
11
|
|
|
48
|
-
/**
|
|
12
|
+
/** Shared provider and hooks specialized to one schema. */
|
|
49
13
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
useAblo: {
|
|
56
|
-
(): Ablo<S> | null;
|
|
57
|
-
<T>(select: AbloSelector<S, T>): T | undefined;
|
|
58
|
-
<T, C>(
|
|
59
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
60
|
-
id: string,
|
|
61
|
-
options: UseAbloModelOptions<T> & { readonly initial: T },
|
|
62
|
-
): UseAbloHydratedModelResult<T>;
|
|
63
|
-
<T, C>(
|
|
64
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
65
|
-
id: string,
|
|
66
|
-
options?: UseAbloModelOptions<T>,
|
|
67
|
-
): UseAbloModelResult<T>;
|
|
68
|
-
};
|
|
14
|
+
AbloProvider: (props: AbloProvider.Props<S>) => ReactElement;
|
|
15
|
+
useAblo: useAblo.Bound<S>;
|
|
16
|
+
/** Writable client for actions; useAblo(selector) supplies render snapshots. */
|
|
17
|
+
useAbloClient: () => Ablo<S> | null;
|
|
18
|
+
useMutationFailure: typeof useMutationFailure;
|
|
69
19
|
/** Declare and reactively read record presence with the same model clients. */
|
|
70
|
-
usePresence: <
|
|
71
|
-
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
72
|
-
recordId: string,
|
|
73
|
-
) => readonly PresenceSession[];
|
|
20
|
+
usePresence: usePresence.Bound<S>;
|
|
74
21
|
}
|
|
75
22
|
|
|
76
|
-
/**
|
|
77
|
-
* Bind the react surface to one schema. The schema value is taken for
|
|
78
|
-
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
79
|
-
* argument — and it is the seam where the binding's own typed context arrives
|
|
80
|
-
* when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
|
|
81
|
-
*/
|
|
23
|
+
/** Bind the existing React functions to one schema's types. */
|
|
82
24
|
export function createAbloReact<S extends SchemaRecord>(
|
|
83
25
|
schema: Schema<S>,
|
|
84
26
|
): AbloReactBinding<S> {
|
|
85
27
|
void schema;
|
|
86
28
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
// never casts; a binding hook mounted under a legacy provider (no bound
|
|
90
|
-
// provider in the tree) reads `null` here and falls through to the shared
|
|
91
|
-
// implementation's internal-context fallback.
|
|
92
|
-
const BoundClientContext = createContext<Ablo<S> | null>(null);
|
|
93
|
-
|
|
94
|
-
function BoundAbloProvider(props: AbloProviderProps<S>): ReactElement {
|
|
95
|
-
return createElement(
|
|
96
|
-
BoundClientContext.Provider,
|
|
97
|
-
{ value: props.client },
|
|
98
|
-
createElement(AbloProvider<S>, props),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function useBoundAblo(): Ablo<S> | null;
|
|
103
|
-
function useBoundAblo<T>(select: AbloSelector<S, T>): T | undefined;
|
|
104
|
-
function useBoundAblo<T, C>(
|
|
105
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
106
|
-
id: string,
|
|
107
|
-
options: UseAbloModelOptions<T> & { readonly initial: T },
|
|
108
|
-
): UseAbloHydratedModelResult<T>;
|
|
109
|
-
function useBoundAblo<T, C>(
|
|
110
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
111
|
-
id: string,
|
|
112
|
-
options?: UseAbloModelOptions<T>,
|
|
113
|
-
): UseAbloModelResult<T>;
|
|
114
|
-
function useBoundAblo<T, C>(
|
|
115
|
-
modelOrSelect?:
|
|
116
|
-
| ModelOperations<T, C>
|
|
117
|
-
| ModelClientSelector<S, T, C>
|
|
118
|
-
| AbloSelector<S, T>,
|
|
119
|
-
id?: string,
|
|
120
|
-
options?: UseAbloModelOptions<T>,
|
|
121
|
-
): Ablo<S> | null | UseAbloModelResult<T> | T | undefined {
|
|
122
|
-
const bound = useContext(BoundClientContext);
|
|
123
|
-
return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function useBoundPresence<T, C>(
|
|
127
|
-
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
128
|
-
recordId: string,
|
|
129
|
-
): readonly PresenceSession[] {
|
|
130
|
-
const bound = useContext(BoundClientContext);
|
|
131
|
-
const engine = useAbloClientImpl(bound);
|
|
132
|
-
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
AbloProvider: BoundAbloProvider,
|
|
137
|
-
useAblo: useBoundAblo,
|
|
138
|
-
usePresence: useBoundPresence,
|
|
139
|
-
};
|
|
29
|
+
// Specialize the shared functions without creating new contexts or identities.
|
|
30
|
+
return { AbloProvider, useAblo, useAbloClient, useMutationFailure, usePresence } as AbloReactBinding<S>;
|
|
140
31
|
}
|
|
@@ -4,34 +4,8 @@ import { createContext } from 'react';
|
|
|
4
4
|
import type { AbloClient as Ablo } from '../client.js';
|
|
5
5
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* The context that `<AbloProvider>` populates for its own hooks. It is kept
|
|
9
|
-
* separate from the data-hook context, which carries the store and schema,
|
|
10
|
-
* because these fields belong to the provider rather than to the store. Read
|
|
11
|
-
* them through the typed hooks such as `useCurrentUserId` and
|
|
12
|
-
* `useErrorListener` rather than reaching into this context directly.
|
|
13
|
-
*/
|
|
7
|
+
/** The provider owns only the reference to the application-owned client. */
|
|
14
8
|
export interface AbloInternalContextValue {
|
|
15
|
-
/**
|
|
16
|
-
* The application user id, when your app passed one to `<AbloProvider>`. Sync
|
|
17
|
-
* identity is derived on the server from the API key, so this is `null`
|
|
18
|
-
* unless you set it, and it is not required for sync to work.
|
|
19
|
-
*/
|
|
20
|
-
currentUserId: string | null;
|
|
21
|
-
/** Subscribe to provider-level errors: engine errors, bootstrap failures, and session issues. */
|
|
22
|
-
subscribeError: (listener: (error: Error) => void) => () => void;
|
|
23
|
-
/** Emit an error to every subscribed listener. The provider calls this for you. */
|
|
24
|
-
emitError: (error: Error) => void;
|
|
25
|
-
/**
|
|
26
|
-
* The typed `Ablo` client for this provider, or `null` until the first sync
|
|
27
|
-
* bootstrap resolves. It is held here so `useSync()` can return it without
|
|
28
|
-
* reaching into the store; the client and the store are sibling objects, and
|
|
29
|
-
* neither is derived from the other.
|
|
30
|
-
*
|
|
31
|
-
* It is typed loosely as `Ablo<SchemaRecord>` because generics do not flow
|
|
32
|
-
* through React context. `useSync<R>()` restores the precise type through its
|
|
33
|
-
* own generic; the runtime value is the fully typed client.
|
|
34
|
-
*/
|
|
35
9
|
engine: Ablo<SchemaRecord> | null;
|
|
36
10
|
}
|
|
37
11
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isObservableObject } from 'mobx';
|
|
2
|
+
import { Model } from '../local/Model.js';
|
|
3
|
+
import { getModelClientMeta } from '../local/client/createModelOperations.js';
|
|
4
|
+
|
|
5
|
+
function isRecord(value: object): boolean {
|
|
6
|
+
return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Read and detach selected data while the enclosing reaction tracks its fields. */
|
|
10
|
+
export function snapshotValue<T>(value: T): T {
|
|
11
|
+
const seen = new WeakMap<object, unknown>();
|
|
12
|
+
function visit(input: unknown): unknown {
|
|
13
|
+
if (input === null || typeof input !== 'object') return input;
|
|
14
|
+
if (seen.has(input)) 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)) return input;
|
|
18
|
+
if (input instanceof Date) {
|
|
19
|
+
const date = new Date(input.getTime());
|
|
20
|
+
seen.set(input, date);
|
|
21
|
+
return Object.freeze(date);
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(input)) {
|
|
24
|
+
const array: unknown[] = new Array(input.length);
|
|
25
|
+
seen.set(input, array);
|
|
26
|
+
input.forEach((item, index) => { array[index] = visit(item); });
|
|
27
|
+
return Object.freeze(array);
|
|
28
|
+
}
|
|
29
|
+
const source: object = input instanceof Model ? input.toReactiveSnapshot<Record<string, unknown>>() : input;
|
|
30
|
+
if (!isRecord(source)) return input;
|
|
31
|
+
const result: Record<PropertyKey, unknown> = Object.getPrototypeOf(source) === null ? Object.create(null) as Record<PropertyKey, unknown> : {};
|
|
32
|
+
seen.set(input, result);
|
|
33
|
+
// MobX's own symbols describe its administration, not selected application data.
|
|
34
|
+
const keys = isObservableObject(source) ? Object.keys(source) : Reflect.ownKeys(source);
|
|
35
|
+
for (const key of keys) {
|
|
36
|
+
Object.defineProperty(result, key, {
|
|
37
|
+
value: visit(Reflect.get(source, key)),
|
|
38
|
+
enumerable: Object.prototype.propertyIsEnumerable.call(source, key),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze(result);
|
|
42
|
+
}
|
|
43
|
+
return visit(value) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compare data snapshots, including non-enumerable schema-derived fields. */
|
|
47
|
+
export function equalSnapshots(a: unknown, b: unknown): boolean {
|
|
48
|
+
const seen = new WeakMap<object, object>();
|
|
49
|
+
function equal(left: unknown, right: unknown): boolean {
|
|
50
|
+
if (Object.is(left, right)) return true;
|
|
51
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false;
|
|
52
|
+
if (left instanceof Date || right instanceof Date) {
|
|
53
|
+
return left instanceof Date && right instanceof Date && Object.is(left.getTime(), right.getTime());
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(left) !== Array.isArray(right)) return false;
|
|
56
|
+
if (!Array.isArray(left) && (!isRecord(left) || !isRecord(right))) return false;
|
|
57
|
+
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
|
|
58
|
+
if (seen.has(left)) return seen.get(left) === right;
|
|
59
|
+
seen.set(left, right);
|
|
60
|
+
const keys = Reflect.ownKeys(left);
|
|
61
|
+
if (keys.length !== Reflect.ownKeys(right).length) return false;
|
|
62
|
+
return keys.every(key => Object.prototype.hasOwnProperty.call(right, key)
|
|
63
|
+
&& Object.prototype.propertyIsEnumerable.call(left, key) === Object.prototype.propertyIsEnumerable.call(right, key)
|
|
64
|
+
&& equal(Reflect.get(left, key), Reflect.get(right, key)));
|
|
65
|
+
}
|
|
66
|
+
return equal(a, b);
|
|
67
|
+
}
|
package/src/react/useAblo.ts
CHANGED
|
@@ -1,92 +1,39 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
4
|
+
import { useAbloClient } from './useAbloClient.js';
|
|
5
5
|
import type { AbloClient as Ablo, AbloReads } from '../client.js';
|
|
6
6
|
import type { ModelClaim } from '@abloatai/transaction/coordination';
|
|
7
7
|
import {
|
|
8
8
|
getModelClientMeta,
|
|
9
9
|
type ModelOperations,
|
|
10
10
|
} from '../local/client/createModelOperations.js';
|
|
11
|
-
import { Model } from '../local/Model.js';
|
|
12
11
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
13
|
-
import type {
|
|
14
|
-
import { useReactive } from '
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The app's resolved schema-record type. It reads your `Register` module
|
|
18
|
-
* augmentation when you declare one and falls back to the loose
|
|
19
|
-
* {@link SchemaRecord} otherwise, so `useAblo()` returns a fully typed client
|
|
20
|
-
* without you passing `<(typeof schema)['models']>` at every call site.
|
|
21
|
-
*/
|
|
22
|
-
type DefaultModels = ResolveSchema extends { models: infer M }
|
|
23
|
-
? M extends SchemaRecord
|
|
24
|
-
? M
|
|
25
|
-
: SchemaRecord
|
|
26
|
-
: SchemaRecord;
|
|
12
|
+
import type { ResolveModels as DefaultModels } from '@abloatai/transaction/types/global';
|
|
13
|
+
import { useReactive } from './useReactive.js';
|
|
27
14
|
|
|
28
15
|
const EMPTY_CLAIMS: readonly ModelClaim[] = Object.freeze([]);
|
|
29
16
|
|
|
30
|
-
|
|
31
|
-
* Restore the caller's schema generics on the context-held engine. React
|
|
32
|
-
* context erases generics (see `AbloInternalContextValue.engine`), so this is
|
|
33
|
-
* the one deliberate rebind point: the runtime value is the fully typed
|
|
34
|
-
* client, and `R` is the compile-time view the calling hook declared.
|
|
35
|
-
*/
|
|
36
|
-
function rebindEngine<R extends SchemaRecord>(engine: Ablo<SchemaRecord>): Ablo<R> {
|
|
37
|
-
return engine as Ablo<R>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The reactive-read view of a client — the identical runtime object, with
|
|
42
|
-
* model reads typed as snapshot rows, because everything a selector returns
|
|
43
|
-
* is converted through `snapshotValue` before the hook hands it back. Same
|
|
44
|
-
* generic in and out, so this compiles with no schema rebinding.
|
|
45
|
-
*/
|
|
17
|
+
// Selector results are detached snapshots with no model methods or relations.
|
|
46
18
|
function reactiveReads<R extends SchemaRecord>(engine: Ablo<R>): AbloReads<R> {
|
|
47
19
|
return engine as AbloReads<R>;
|
|
48
20
|
}
|
|
49
21
|
|
|
50
|
-
// Selectors receive the reactive-read client: model reads are typed as
|
|
51
|
-
// snapshot rows (data fields + computeds, no relation accessors), which is the
|
|
52
|
-
// shape the hook actually returns after `toReactiveSnapshot()`. This makes the
|
|
53
|
-
// selector's inferred result type honest — `row.layers` fails to compile here
|
|
54
|
-
// instead of reading `undefined` at runtime.
|
|
55
22
|
export type ModelClientSelector<R extends SchemaRecord, T, C> =
|
|
56
23
|
(ablo: AbloReads<R>) => ModelOperations<T, C>;
|
|
57
24
|
export type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
|
|
58
25
|
|
|
59
|
-
export interface UseAbloModelOptions<T> {
|
|
60
|
-
/**
|
|
61
|
-
* An initial row, usually from a server component or a route loader. The hook
|
|
62
|
-
* returns it until sync delivers a newer row for the same id.
|
|
63
|
-
*/
|
|
64
|
-
readonly initial?: T;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface UseAbloModelResult<T> {
|
|
68
|
-
/** The current row for the id, or `initial` until the row has synced. */
|
|
69
|
-
readonly data: T | undefined;
|
|
70
|
-
/** The work claims currently held on this row by any participant. */
|
|
71
|
-
readonly claims: readonly ModelClaim[];
|
|
72
|
-
/** True while another participant holds a claim — handy for disabling UI. */
|
|
73
|
-
readonly claimed: boolean;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export type UseAbloHydratedModelResult<T> =
|
|
77
|
-
Omit<UseAbloModelResult<T>, 'data'> & { readonly data: T };
|
|
78
|
-
|
|
79
26
|
function readModelResult<R extends SchemaRecord, T, C>(
|
|
80
27
|
engine: Ablo<R> | null,
|
|
81
28
|
modelClient: ModelOperations<T, C> | undefined,
|
|
82
29
|
id: string | undefined,
|
|
83
30
|
initial: T | undefined,
|
|
84
|
-
):
|
|
31
|
+
): useAblo.Result<T> {
|
|
85
32
|
if (!modelClient || id === undefined) {
|
|
86
33
|
return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
|
|
87
34
|
}
|
|
88
35
|
|
|
89
|
-
const data =
|
|
36
|
+
const data = modelClient.local.get(id) ?? initial;
|
|
90
37
|
const meta = getModelClientMeta(modelClient);
|
|
91
38
|
const claims = meta && engine
|
|
92
39
|
? engine.claims.list({ model: meta.key, id })
|
|
@@ -95,66 +42,7 @@ function readModelResult<R extends SchemaRecord, T, C>(
|
|
|
95
42
|
return { data, claims, claimed: claims.length > 0 };
|
|
96
43
|
}
|
|
97
44
|
|
|
98
|
-
/**
|
|
99
|
-
* Projects a reactive read into the value that `useReactive` caches and
|
|
100
|
-
* returns.
|
|
101
|
-
*
|
|
102
|
-
* For a `Model`, this reads the row's fields through `toReactiveSnapshot`
|
|
103
|
-
* rather than returning the instance itself. Property access is what subscribes
|
|
104
|
-
* the reaction to those fields, so the read has to happen inside this tracked
|
|
105
|
-
* function; returning the live instance without reading its fields would leave
|
|
106
|
-
* the component blind to later edits. The fresh object it produces also lets
|
|
107
|
-
* `useReactive`'s equality check detect an in-place update.
|
|
108
|
-
*/
|
|
109
|
-
function snapshotValue<T>(value: T): T {
|
|
110
|
-
if (value instanceof Model) {
|
|
111
|
-
return value.toReactiveSnapshot<T>();
|
|
112
|
-
}
|
|
113
|
-
if (Array.isArray(value)) {
|
|
114
|
-
return value.map((item) => snapshotValue(item)) as T;
|
|
115
|
-
}
|
|
116
|
-
return value;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Reads Ablo from inside an `<AbloProvider>` subtree. Called with no arguments
|
|
121
|
-
* it returns the typed client for use in callbacks and effects; called with a
|
|
122
|
-
* selector it subscribes the component to a reactive read — such as one
|
|
123
|
-
* `ablo.<model>` row — and re-renders when that read changes.
|
|
124
|
-
*
|
|
125
|
-
* You can call it with no type arguments once you declare the `Register` module
|
|
126
|
-
* augmentation (`declare module '@abloatai/ablo' { interface Register {
|
|
127
|
-
* Schema: typeof schema } }`); the default type then resolves through your
|
|
128
|
-
* schema's models, so call sites stay clean:
|
|
129
|
-
*
|
|
130
|
-
* **Prefer the binding.** `createAbloReact(schema)` captures the schema once
|
|
131
|
-
* in your app's binding file and returns a `useAblo` that needs none of the
|
|
132
|
-
* typing arrangements below — no type argument, no `Register` declaration
|
|
133
|
-
* (see `react.md`). Passing an explicit schema type argument to THIS hook is
|
|
134
|
-
* deprecated in favor of that binding; it keeps working for shared packages
|
|
135
|
-
* that cannot bind a concrete schema.
|
|
136
|
-
*
|
|
137
|
-
* ```ts
|
|
138
|
-
* // With the Register augmentation (recommended):
|
|
139
|
-
* const ablo = useAblo();
|
|
140
|
-
* if (!ablo) return <Loading />;
|
|
141
|
-
* const doc = await ablo.records.get({ id }); // observational async server read
|
|
142
|
-
*
|
|
143
|
-
* // Reactive selector (a synchronous local snapshot). The selector's reads
|
|
144
|
-
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
145
|
-
* // accessors — matching what the hook actually returns:
|
|
146
|
-
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
147
|
-
* const { claimed } = useAblo((ablo) => ablo.records, id);
|
|
148
|
-
*
|
|
149
|
-
* // Without the augmentation, pass the schema as a type argument:
|
|
150
|
-
* const ablo = useAblo<(typeof schema)['models']>();
|
|
151
|
-
* ```
|
|
152
|
-
*
|
|
153
|
-
* The no-argument form returns `null` while the engine is still bootstrapping.
|
|
154
|
-
* Branch on `null` and render a loading state — or gate on `useSyncStatus()`
|
|
155
|
-
* reaching `'connected'` — before calling model methods.
|
|
156
|
-
*/
|
|
157
|
-
export function useAblo<R extends SchemaRecord = DefaultModels>(): Ablo<R> | null;
|
|
45
|
+
/** Select a reactive snapshot or read a row with its current claims. */
|
|
158
46
|
export function useAblo<
|
|
159
47
|
R extends SchemaRecord = DefaultModels,
|
|
160
48
|
T = unknown,
|
|
@@ -164,8 +52,8 @@ export function useAblo<
|
|
|
164
52
|
export function useAblo<T, C>(
|
|
165
53
|
modelClient: ModelOperations<T, C>,
|
|
166
54
|
id: string,
|
|
167
|
-
options
|
|
168
|
-
):
|
|
55
|
+
options?: useAblo.Options<T>,
|
|
56
|
+
): useAblo.Result<T>;
|
|
169
57
|
export function useAblo<
|
|
170
58
|
R extends SchemaRecord = DefaultModels,
|
|
171
59
|
T = Record<string, unknown>,
|
|
@@ -173,57 +61,18 @@ export function useAblo<
|
|
|
173
61
|
>(
|
|
174
62
|
select: ModelClientSelector<R, T, C>,
|
|
175
63
|
id: string,
|
|
176
|
-
options
|
|
177
|
-
):
|
|
178
|
-
export function useAblo<T, C>(
|
|
179
|
-
modelClient: ModelOperations<T, C>,
|
|
180
|
-
id: string,
|
|
181
|
-
options?: UseAbloModelOptions<T>,
|
|
182
|
-
): UseAbloModelResult<T>;
|
|
64
|
+
options?: useAblo.Options<T>,
|
|
65
|
+
): useAblo.Result<T>;
|
|
183
66
|
export function useAblo<
|
|
184
67
|
R extends SchemaRecord = DefaultModels,
|
|
185
68
|
T = Record<string, unknown>,
|
|
186
69
|
C = unknown,
|
|
187
70
|
>(
|
|
188
|
-
|
|
189
|
-
id: string,
|
|
190
|
-
options?: UseAbloModelOptions<T>,
|
|
191
|
-
): UseAbloModelResult<T>;
|
|
192
|
-
export function useAblo<
|
|
193
|
-
R extends SchemaRecord = DefaultModels,
|
|
194
|
-
T = Record<string, unknown>,
|
|
195
|
-
C = unknown,
|
|
196
|
-
>(
|
|
197
|
-
modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
|
|
71
|
+
modelOrSelect: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
|
|
198
72
|
id?: string,
|
|
199
|
-
options?:
|
|
200
|
-
):
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* @internal The one implementation behind `useAblo` and the bound hooks a
|
|
206
|
-
* `createAbloReact` binding returns — written once so the reactive read path
|
|
207
|
-
* cannot fork between the global hook and a factory's.
|
|
208
|
-
*
|
|
209
|
-
* `boundClient` is a binding's own context value — typed `Ablo<S>` at the
|
|
210
|
-
* factory, so that path never rebinds and never casts. `null` means "no
|
|
211
|
-
* binding provider in this tree": the global hook always passes it, and a
|
|
212
|
-
* binding hook mounted under a legacy provider falls through to the erased
|
|
213
|
-
* internal context, which is what keeps both mounts working while the last
|
|
214
|
-
* legacy mount migrates.
|
|
215
|
-
*/
|
|
216
|
-
export function useAbloImpl<
|
|
217
|
-
R extends SchemaRecord,
|
|
218
|
-
T = Record<string, unknown>,
|
|
219
|
-
C = unknown,
|
|
220
|
-
>(
|
|
221
|
-
boundClient: Ablo<R> | null,
|
|
222
|
-
modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
|
|
223
|
-
id?: string,
|
|
224
|
-
options?: UseAbloModelOptions<T>,
|
|
225
|
-
): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
|
|
226
|
-
const engine = useAbloClientImpl(boundClient);
|
|
73
|
+
options?: useAblo.Options<T>,
|
|
74
|
+
): useAblo.Result<T> | T | undefined {
|
|
75
|
+
const engine = useAbloClient<R>();
|
|
227
76
|
const initial = options?.initial;
|
|
228
77
|
const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
|
|
229
78
|
const modelClient: ModelOperations<T, C> | undefined =
|
|
@@ -235,51 +84,66 @@ export function useAbloImpl<
|
|
|
235
84
|
? undefined
|
|
236
85
|
: modelOrSelect;
|
|
237
86
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
const
|
|
87
|
+
// The initial row is a seed for this client/model/id, not a permanent
|
|
88
|
+
// fallback: once local data has been committed to the UI, its removal must
|
|
89
|
+
// not resurrect the seed. Only committed effects change this marker.
|
|
90
|
+
// These dependencies define when the seed belongs to a different row.
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
const seed = useMemo(() => ({ received: false }), [engine, modelClient, id]);
|
|
93
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
94
|
+
if (!engine) return () => undefined;
|
|
95
|
+
return engine.claims.onChange(notify);
|
|
96
|
+
}, [engine]);
|
|
97
|
+
const value = useReactive<T | useAblo.Result<T> | undefined>(() => {
|
|
98
|
+
if (isSelectorOnly && typeof modelOrSelect === 'function') {
|
|
99
|
+
return engine ? modelOrSelect(reactiveReads<R>(engine)) as T : undefined;
|
|
100
|
+
}
|
|
101
|
+
if (modelOrSelect) {
|
|
102
|
+
return readModelResult(engine, modelClient, id, seed.received ? undefined : initial);
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}, {
|
|
106
|
+
subscribe,
|
|
107
|
+
// The same seed produces the server HTML and the first hydration render,
|
|
108
|
+
// even if the browser already has a newer row or claim in its local cache.
|
|
109
|
+
...(id !== undefined && initial !== undefined ? {
|
|
110
|
+
serverSnapshot: () => ({ data: initial, claims: EMPTY_CLAIMS, claimed: false }),
|
|
111
|
+
} : {}),
|
|
112
|
+
});
|
|
244
113
|
useEffect(() => {
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
}, [engine, id]);
|
|
114
|
+
if (id !== undefined && modelClient?.local.get(id) !== undefined) seed.received = true;
|
|
115
|
+
}, [seed, modelClient, id, value]);
|
|
248
116
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
|
|
252
|
-
return undefined;
|
|
253
|
-
}
|
|
254
|
-
// The selector runs against the real engine — reads inside it return the
|
|
255
|
-
// pool's model instances. `snapshotValue` then converts the RESULT to
|
|
256
|
-
// plain snapshot rows, which is what the selector's `AbloReads`
|
|
257
|
-
// parameter type already promised.
|
|
258
|
-
return snapshotValue(modelOrSelect(reactiveReads<R>(engine)) as T);
|
|
259
|
-
},
|
|
260
|
-
);
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
261
119
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
120
|
+
/** Type annotations belong to the operation; most callers rely on inference. */
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
122
|
+
export namespace useAblo {
|
|
123
|
+
export interface Bound<S extends SchemaRecord> {
|
|
124
|
+
<T>(select: AbloSelector<S, T>): T | undefined;
|
|
125
|
+
<T, C>(
|
|
126
|
+
model: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
127
|
+
id: string,
|
|
128
|
+
options?: Options<T>,
|
|
129
|
+
): Result<T>;
|
|
130
|
+
}
|
|
268
131
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
132
|
+
export interface Options<T> {
|
|
133
|
+
/**
|
|
134
|
+
* An initial row, usually from a server component or a route loader. The hook
|
|
135
|
+
* uses it for hydration and until a local row has been observed. A later
|
|
136
|
+
* local removal returns undefined instead of restoring this seed.
|
|
137
|
+
*/
|
|
138
|
+
readonly initial?: T;
|
|
139
|
+
}
|
|
273
140
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const engine: Ablo<R> | null =
|
|
283
|
-
boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
|
|
284
|
-
return engine;
|
|
141
|
+
export interface Result<T> {
|
|
142
|
+
/** The local row or its initial seed. Undefined is a local cache miss, not proof of server absence. */
|
|
143
|
+
readonly data: T | undefined;
|
|
144
|
+
/** The work claims currently held on this row by any participant. */
|
|
145
|
+
readonly claims: readonly ModelClaim[];
|
|
146
|
+
/** True while another participant holds a claim — handy for disabling UI. */
|
|
147
|
+
readonly claimed: boolean;
|
|
148
|
+
}
|
|
285
149
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useContext } from 'react';
|
|
4
|
+
import type { AbloClient } from '../client.js';
|
|
5
|
+
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
6
|
+
import type { ResolveModels } from '@abloatai/transaction/types/global';
|
|
7
|
+
import { AbloInternalContext } from './internalContext.js';
|
|
8
|
+
|
|
9
|
+
/** Writable client for event handlers. Available before ready(); null without a provider. */
|
|
10
|
+
export function useAbloClient<S extends SchemaRecord = ResolveModels>(): AbloClient<S> | null {
|
|
11
|
+
const client = useContext(AbloInternalContext)?.engine;
|
|
12
|
+
// React context erases the schema; the application binding restores it.
|
|
13
|
+
return client ? client as AbloClient<S> : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useEffectEvent } from 'react';
|
|
4
|
+
import type { AbloClient } from '../client.js';
|
|
5
|
+
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
6
|
+
import { useAbloClient } from './useAbloClient.js';
|
|
7
|
+
|
|
8
|
+
/** Subscribe for this component's lifetime using the latest committed listener.
|
|
9
|
+
* Replacing the provider client moves the subscription; unmount removes it.
|
|
10
|
+
*/
|
|
11
|
+
export function useMutationFailure(
|
|
12
|
+
listener: Parameters<AbloClient<SchemaRecord>['onMutationFailure']>[0],
|
|
13
|
+
): void {
|
|
14
|
+
const client = useAbloClient();
|
|
15
|
+
const onFailure = useEffectEvent(listener);
|
|
16
|
+
useEffect(() => client?.onMutationFailure(onFailure), [client]);
|
|
17
|
+
}
|