@abloatai/humans 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Ablo.d.ts +4 -0
- package/dist/client.d.ts +11 -31
- package/dist/local/client/reactiveEngine.js +4 -5
- package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
- package/dist/local/client/status.js +14 -0
- package/dist/local/client/storeLifecycle.js +2 -2
- package/dist/local/storeContract.d.ts +3 -4
- package/dist/presence/index.js +6 -2
- package/dist/react/AbloProvider.d.ts +59 -139
- package/dist/react/AbloProvider.js +60 -181
- package/dist/react/createAbloReact.d.ts +10 -32
- package/dist/react/createAbloReact.js +10 -54
- package/dist/react/internalContext.d.ts +3 -20
- package/dist/react/snapshot.d.ts +4 -0
- package/dist/react/snapshot.js +75 -0
- package/dist/react/useAblo.d.ts +28 -45
- package/dist/react/useAblo.js +39 -77
- package/dist/react/useMutators.d.ts +20 -17
- package/dist/react/usePresence.js +7 -6
- package/dist/react/useReactive.d.ts +12 -0
- package/dist/react/useReactive.js +57 -0
- package/dist/react/useUndoScope.d.ts +15 -29
- package/dist/react/useUndoScope.js +17 -0
- package/dist/react.d.ts +7 -19
- package/dist/react.js +7 -15
- package/package.json +4 -3
- package/src/Ablo.ts +5 -0
- package/src/client.ts +12 -31
- package/src/local/client/reactiveEngine.ts +4 -5
- package/src/local/client/status.ts +20 -0
- package/src/local/client/storeLifecycle.ts +2 -2
- package/src/local/storeContract.ts +3 -4
- package/src/presence/index.ts +6 -2
- package/src/react/AbloProvider.tsx +94 -309
- package/src/react/createAbloReact.ts +18 -99
- package/src/react/internalContext.ts +3 -20
- package/src/react/snapshot.ts +67 -0
- package/src/react/useAblo.ts +71 -140
- package/src/react/useMutators.ts +30 -26
- package/src/react/usePresence.ts +7 -12
- package/src/react/useReactive.ts +56 -0
- package/src/react/useUndoScope.ts +18 -14
- package/src/react.ts +7 -69
- package/dist/react/ClientSideSuspense.d.ts +0 -36
- package/dist/react/ClientSideSuspense.js +0 -17
- package/dist/react/useCurrentUserId.d.ts +0 -2
- package/dist/react/useCurrentUserId.js +0 -12
- package/dist/react/useErrorListener.d.ts +0 -2
- package/dist/react/useErrorListener.js +0 -14
- package/dist/react/useMutationFailureListener.d.ts +0 -8
- package/dist/react/useMutationFailureListener.js +0 -19
- package/dist/react/useSyncStatus.js +0 -37
- package/dist/useReactive.d.ts +0 -6
- package/dist/useReactive.js +0 -43
- 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 -42
- package/src/useReactive.ts +0 -51
|
@@ -1,55 +1,31 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* a schema-bound shape with no module augmentation or generic parameters at call sites,
|
|
7
|
-
* and — once the legacy generic erasure retires — no casts anywhere on the
|
|
8
|
-
* path from context to component.
|
|
4
|
+
* Capture schema inference once while reusing module-level React functions.
|
|
5
|
+
* This helper creates no components, hooks, contexts or client instances.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ```ts
|
|
13
|
-
* // lib/ablo.ts
|
|
14
|
-
* import { createAbloReact } from '@abloatai/ablo/react';
|
|
15
|
-
* import { schema } from './schema';
|
|
16
|
-
*
|
|
17
|
-
* export const { AbloProvider, useAblo } = createAbloReact(schema);
|
|
18
|
-
* ```
|
|
19
|
-
*
|
|
20
|
-
* Components then import `useAblo` from `lib/ablo` and never spell a type
|
|
21
|
-
* argument; `useAblo()` is `Ablo<S> | null`, and a selector's `ablo`
|
|
22
|
-
* parameter is the reactive-read view of the same `S`.
|
|
7
|
+
* Define the app binding at module scope:
|
|
8
|
+
* `export const { AbloProvider, useAblo, usePresence } = createAbloReact(schema)`.
|
|
23
9
|
*/
|
|
24
10
|
|
|
25
|
-
import {
|
|
11
|
+
import type { ReactElement } from 'react';
|
|
12
|
+
import { AbloProvider } from './AbloProvider.js';
|
|
26
13
|
import {
|
|
27
|
-
|
|
28
|
-
type AbloProviderProps,
|
|
29
|
-
} from './AbloProvider.js';
|
|
30
|
-
import {
|
|
31
|
-
useAbloImpl,
|
|
32
|
-
useAbloClientImpl,
|
|
14
|
+
useAblo,
|
|
33
15
|
type AbloSelector,
|
|
34
16
|
type ModelClientSelector,
|
|
35
|
-
type UseAbloHydratedModelResult,
|
|
36
|
-
type UseAbloModelOptions,
|
|
37
|
-
type UseAbloModelResult,
|
|
38
17
|
} from './useAblo.js';
|
|
39
18
|
import type { AbloClient as Ablo } from '../client.js';
|
|
40
19
|
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
41
20
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
42
|
-
import {
|
|
43
|
-
usePresenceImpl,
|
|
44
|
-
type PresenceModelSelector,
|
|
45
|
-
} from './usePresence.js';
|
|
21
|
+
import { usePresence, type PresenceModelSelector } from './usePresence.js';
|
|
46
22
|
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
47
23
|
|
|
48
24
|
/** What a binding returns: the provider and the hook, with `S` fixed. */
|
|
49
25
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
50
26
|
/** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
|
|
51
27
|
* no per-app generics. */
|
|
52
|
-
AbloProvider: (props:
|
|
28
|
+
AbloProvider: (props: AbloProvider.Props<S>) => ReactElement;
|
|
53
29
|
/** `useAblo` with the schema bound — the same overloads as the global
|
|
54
30
|
* hook, minus the type arguments. */
|
|
55
31
|
useAblo: {
|
|
@@ -58,13 +34,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
58
34
|
<T, C>(
|
|
59
35
|
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
60
36
|
id: string,
|
|
61
|
-
options
|
|
62
|
-
):
|
|
63
|
-
<T, C>(
|
|
64
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
65
|
-
id: string,
|
|
66
|
-
options?: UseAbloModelOptions<T>,
|
|
67
|
-
): UseAbloModelResult<T>;
|
|
37
|
+
options?: useAblo.Options<T>,
|
|
38
|
+
): useAblo.Result<T>;
|
|
68
39
|
};
|
|
69
40
|
/** Declare and reactively read record presence with the same model clients. */
|
|
70
41
|
usePresence: <T, C>(
|
|
@@ -73,68 +44,16 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
73
44
|
) => readonly PresenceSession[];
|
|
74
45
|
}
|
|
75
46
|
|
|
76
|
-
/**
|
|
77
|
-
* Bind the react surface to one schema. The schema value is taken for
|
|
78
|
-
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
79
|
-
* argument — and it is the seam where the binding's own typed context arrives
|
|
80
|
-
* when the legacy erasure retires (docs/plans/typed-react-binding.md, step 3).
|
|
81
|
-
*/
|
|
47
|
+
/** Bind the existing React functions to one schema's types. */
|
|
82
48
|
export function createAbloReact<S extends SchemaRecord>(
|
|
83
49
|
schema: Schema<S>,
|
|
84
50
|
): AbloReactBinding<S> {
|
|
85
51
|
void schema;
|
|
86
52
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
function BoundAbloProvider(props: AbloProviderProps<S>): ReactElement {
|
|
95
|
-
return createElement(
|
|
96
|
-
BoundClientContext.Provider,
|
|
97
|
-
{ value: props.client },
|
|
98
|
-
createElement(AbloProvider<S>, props),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function useBoundAblo(): Ablo<S> | null;
|
|
103
|
-
function useBoundAblo<T>(select: AbloSelector<S, T>): T | undefined;
|
|
104
|
-
function useBoundAblo<T, C>(
|
|
105
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
106
|
-
id: string,
|
|
107
|
-
options: UseAbloModelOptions<T> & { readonly initial: T },
|
|
108
|
-
): UseAbloHydratedModelResult<T>;
|
|
109
|
-
function useBoundAblo<T, C>(
|
|
110
|
-
modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>,
|
|
111
|
-
id: string,
|
|
112
|
-
options?: UseAbloModelOptions<T>,
|
|
113
|
-
): UseAbloModelResult<T>;
|
|
114
|
-
function useBoundAblo<T, C>(
|
|
115
|
-
modelOrSelect?:
|
|
116
|
-
| ModelOperations<T, C>
|
|
117
|
-
| ModelClientSelector<S, T, C>
|
|
118
|
-
| AbloSelector<S, T>,
|
|
119
|
-
id?: string,
|
|
120
|
-
options?: UseAbloModelOptions<T>,
|
|
121
|
-
): Ablo<S> | null | UseAbloModelResult<T> | T | undefined {
|
|
122
|
-
const bound = useContext(BoundClientContext);
|
|
123
|
-
return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function useBoundPresence<T, C>(
|
|
127
|
-
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
128
|
-
recordId: string,
|
|
129
|
-
): readonly PresenceSession[] {
|
|
130
|
-
const bound = useContext(BoundClientContext);
|
|
131
|
-
const engine = useAbloClientImpl(bound);
|
|
132
|
-
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
AbloProvider: BoundAbloProvider,
|
|
137
|
-
useAblo: useBoundAblo,
|
|
138
|
-
usePresence: useBoundPresence,
|
|
139
|
-
};
|
|
53
|
+
// TypeScript cannot partially specialize the generic overloads, so this
|
|
54
|
+
// assertion binds their schema parameter. Positive and negative consumer
|
|
55
|
+
// type tests verify the specialization; no runtime value changes.
|
|
56
|
+
// Specialize types only. Every binding uses the same module-level functions,
|
|
57
|
+
// so calling this helper again cannot change component identity or reset state.
|
|
58
|
+
return { AbloProvider, useAblo, usePresence } as AbloReactBinding<S>;
|
|
140
59
|
}
|
|
@@ -4,32 +4,15 @@ 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
9
|
/**
|
|
16
|
-
* The
|
|
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
|
|
10
|
+
* The typed `Ablo` client for this provider, available before bootstrap resolves. It is held here so `useAblo()` can return it without
|
|
28
11
|
* reaching into the store; the client and the store are sibling objects, and
|
|
29
12
|
* neither is derived from the other.
|
|
30
13
|
*
|
|
31
14
|
* It is typed loosely as `Ablo<SchemaRecord>` because generics do not flow
|
|
32
|
-
* through React context. `
|
|
15
|
+
* through React context. `useAblo<R>()` restores the precise type through its
|
|
33
16
|
* own generic; the runtime value is the fully typed client.
|
|
34
17
|
*/
|
|
35
18
|
engine: Ablo<SchemaRecord> | null;
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useContext, useEffect,
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
|
4
4
|
import { AbloInternalContext } from './internalContext.js';
|
|
5
5
|
import type { AbloClient as Ablo, AbloReads } from '../client.js';
|
|
6
6
|
import type { ModelClaim } from '@abloatai/transaction/coordination';
|
|
@@ -8,10 +8,9 @@ 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
12
|
import type { ResolveSchema } from '@abloatai/transaction/types/global';
|
|
14
|
-
import { useReactive } from '
|
|
13
|
+
import { useReactive } from './useReactive.js';
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* The app's resolved schema-record type. It reads your `Register` module
|
|
@@ -56,37 +55,17 @@ export type ModelClientSelector<R extends SchemaRecord, T, C> =
|
|
|
56
55
|
(ablo: AbloReads<R>) => ModelOperations<T, C>;
|
|
57
56
|
export type AbloSelector<R extends SchemaRecord, T> = (ablo: AbloReads<R>) => T;
|
|
58
57
|
|
|
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
58
|
function readModelResult<R extends SchemaRecord, T, C>(
|
|
80
59
|
engine: Ablo<R> | null,
|
|
81
60
|
modelClient: ModelOperations<T, C> | undefined,
|
|
82
61
|
id: string | undefined,
|
|
83
62
|
initial: T | undefined,
|
|
84
|
-
):
|
|
63
|
+
): useAblo.Result<T> {
|
|
85
64
|
if (!modelClient || id === undefined) {
|
|
86
65
|
return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
|
|
87
66
|
}
|
|
88
67
|
|
|
89
|
-
const data =
|
|
68
|
+
const data = modelClient.local.get(id) ?? initial;
|
|
90
69
|
const meta = getModelClientMeta(modelClient);
|
|
91
70
|
const claims = meta && engine
|
|
92
71
|
? engine.claims.list({ model: meta.key, id })
|
|
@@ -95,27 +74,6 @@ function readModelResult<R extends SchemaRecord, T, C>(
|
|
|
95
74
|
return { data, claims, claimed: claims.length > 0 };
|
|
96
75
|
}
|
|
97
76
|
|
|
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
77
|
/**
|
|
120
78
|
* Reads Ablo from inside an `<AbloProvider>` subtree. Called with no arguments
|
|
121
79
|
* it returns the typed client for use in callbacks and effects; called with a
|
|
@@ -144,15 +102,16 @@ function snapshotValue<T>(value: T): T {
|
|
|
144
102
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
145
103
|
* // accessors — matching what the hook actually returns:
|
|
146
104
|
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
147
|
-
* const
|
|
105
|
+
* const { claimed } = useAblo((ablo) => ablo.records, id);
|
|
148
106
|
*
|
|
149
107
|
* // Without the augmentation, pass the schema as a type argument:
|
|
150
108
|
* const ablo = useAblo<(typeof schema)['models']>();
|
|
151
109
|
* ```
|
|
152
110
|
*
|
|
153
|
-
* The
|
|
154
|
-
*
|
|
155
|
-
*
|
|
111
|
+
* The client and its status are available during provider startup. Select
|
|
112
|
+
* `ablo.status` to display connection state; await `ablo.ready()` before
|
|
113
|
+
* operations that require an initialized client. Without a provider, the
|
|
114
|
+
* no-argument form returns `null` and selectors return `undefined`.
|
|
156
115
|
*/
|
|
157
116
|
export function useAblo<R extends SchemaRecord = DefaultModels>(): Ablo<R> | null;
|
|
158
117
|
export function useAblo<
|
|
@@ -164,22 +123,8 @@ export function useAblo<
|
|
|
164
123
|
export function useAblo<T, C>(
|
|
165
124
|
modelClient: ModelOperations<T, C>,
|
|
166
125
|
id: string,
|
|
167
|
-
options
|
|
168
|
-
):
|
|
169
|
-
export function useAblo<
|
|
170
|
-
R extends SchemaRecord = DefaultModels,
|
|
171
|
-
T = Record<string, unknown>,
|
|
172
|
-
C = unknown,
|
|
173
|
-
>(
|
|
174
|
-
select: ModelClientSelector<R, T, C>,
|
|
175
|
-
id: string,
|
|
176
|
-
options: UseAbloModelOptions<T> & { readonly initial: T },
|
|
177
|
-
): UseAbloHydratedModelResult<T>;
|
|
178
|
-
export function useAblo<T, C>(
|
|
179
|
-
modelClient: ModelOperations<T, C>,
|
|
180
|
-
id: string,
|
|
181
|
-
options?: UseAbloModelOptions<T>,
|
|
182
|
-
): UseAbloModelResult<T>;
|
|
126
|
+
options?: useAblo.Options<T>,
|
|
127
|
+
): useAblo.Result<T>;
|
|
183
128
|
export function useAblo<
|
|
184
129
|
R extends SchemaRecord = DefaultModels,
|
|
185
130
|
T = Record<string, unknown>,
|
|
@@ -187,8 +132,8 @@ export function useAblo<
|
|
|
187
132
|
>(
|
|
188
133
|
select: ModelClientSelector<R, T, C>,
|
|
189
134
|
id: string,
|
|
190
|
-
options?:
|
|
191
|
-
):
|
|
135
|
+
options?: useAblo.Options<T>,
|
|
136
|
+
): useAblo.Result<T>;
|
|
192
137
|
export function useAblo<
|
|
193
138
|
R extends SchemaRecord = DefaultModels,
|
|
194
139
|
T = Record<string, unknown>,
|
|
@@ -196,34 +141,9 @@ export function useAblo<
|
|
|
196
141
|
>(
|
|
197
142
|
modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>,
|
|
198
143
|
id?: string,
|
|
199
|
-
options?:
|
|
200
|
-
): Ablo<R> | null |
|
|
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);
|
|
144
|
+
options?: useAblo.Options<T>,
|
|
145
|
+
): Ablo<R> | null | useAblo.Result<T> | T | undefined {
|
|
146
|
+
const engine = useAbloClient<R>();
|
|
227
147
|
const initial = options?.initial;
|
|
228
148
|
const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
|
|
229
149
|
const modelClient: ModelOperations<T, C> | undefined =
|
|
@@ -235,54 +155,65 @@ export function useAbloImpl<
|
|
|
235
155
|
? undefined
|
|
236
156
|
: modelOrSelect;
|
|
237
157
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
158
|
+
// The initial row is a seed for this client/model/id, not a permanent
|
|
159
|
+
// fallback: once local data has been committed to the UI, its removal must
|
|
160
|
+
// not resurrect the seed. Only committed effects change this marker.
|
|
161
|
+
// These dependencies define when the seed belongs to a different row.
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
const seed = useMemo(() => ({ received: false }), [engine, modelClient, id]);
|
|
164
|
+
const reading = modelOrSelect !== undefined;
|
|
165
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
166
|
+
if (!engine || !reading) return () => undefined;
|
|
167
|
+
return engine.claims.onChange(notify);
|
|
168
|
+
}, [engine, reading]);
|
|
169
|
+
const value = useReactive<T | useAblo.Result<T> | undefined>(() => {
|
|
170
|
+
if (isSelectorOnly && typeof modelOrSelect === 'function') {
|
|
171
|
+
return engine ? modelOrSelect(reactiveReads<R>(engine)) as T : undefined;
|
|
172
|
+
}
|
|
173
|
+
if (modelOrSelect) {
|
|
174
|
+
return readModelResult(engine, modelClient, id, seed.received ? undefined : initial);
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}, {
|
|
178
|
+
subscribe,
|
|
179
|
+
// The same seed produces the server HTML and the first hydration render,
|
|
180
|
+
// even if the browser already has a newer row or claim in its local cache.
|
|
181
|
+
...(id !== undefined && initial !== undefined ? {
|
|
182
|
+
serverSnapshot: () => ({ data: initial, claims: EMPTY_CLAIMS, claimed: false }),
|
|
183
|
+
} : {}),
|
|
184
|
+
});
|
|
247
185
|
useEffect(() => {
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
}, [engine, id]);
|
|
251
|
-
|
|
252
|
-
const selected = useReactive<T | undefined>(
|
|
253
|
-
() => {
|
|
254
|
-
if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
|
-
// The selector runs against the real engine — reads inside it return the
|
|
258
|
-
// pool's model instances. `snapshotValue` then converts the RESULT to
|
|
259
|
-
// plain snapshot rows, which is what the selector's `AbloReads`
|
|
260
|
-
// parameter type already promised.
|
|
261
|
-
return snapshotValue(modelOrSelect(reactiveReads<R>(engine)) as T);
|
|
262
|
-
},
|
|
263
|
-
);
|
|
186
|
+
if (id !== undefined && modelClient?.local.get(id) !== undefined) seed.received = true;
|
|
187
|
+
}, [seed, modelClient, id, value]);
|
|
264
188
|
|
|
265
|
-
|
|
266
|
-
() => {
|
|
267
|
-
void claimVersion;
|
|
268
|
-
return readModelResult(engine, modelClient, id, initial);
|
|
269
|
-
},
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
if (isSelectorOnly) return selected;
|
|
273
|
-
if (modelOrSelect) return modelResult;
|
|
189
|
+
if (isSelectorOnly || modelOrSelect) return value;
|
|
274
190
|
return engine;
|
|
275
191
|
}
|
|
276
192
|
|
|
277
|
-
/** @internal Resolve the
|
|
278
|
-
export function
|
|
279
|
-
boundClient: Ablo<R> | null,
|
|
280
|
-
): Ablo<R> | null {
|
|
193
|
+
/** @internal Resolve the nearest provider's client through one schema rebind. */
|
|
194
|
+
export function useAbloClient<R extends SchemaRecord>(): Ablo<R> | null {
|
|
281
195
|
const ctx = useContext(AbloInternalContext);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
196
|
+
return ctx?.engine ? rebindEngine<R>(ctx.engine) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Type annotations belong to the operation; most callers rely on inference. */
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
201
|
+
export namespace useAblo {
|
|
202
|
+
export interface Options<T> {
|
|
203
|
+
/**
|
|
204
|
+
* An initial row, usually from a server component or a route loader. The hook
|
|
205
|
+
* uses it for hydration and until a local row has been observed. A later
|
|
206
|
+
* local removal returns undefined instead of restoring this seed.
|
|
207
|
+
*/
|
|
208
|
+
readonly initial?: T;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface Result<T> {
|
|
212
|
+
/** The local row or its initial seed. Undefined is a local cache miss, not proof of server absence. */
|
|
213
|
+
readonly data: T | undefined;
|
|
214
|
+
/** The work claims currently held on this row by any participant. */
|
|
215
|
+
readonly claims: readonly ModelClaim[];
|
|
216
|
+
/** True while another participant holds a claim — handy for disabling UI. */
|
|
217
|
+
readonly claimed: boolean;
|
|
218
|
+
}
|
|
288
219
|
}
|
package/src/react/useMutators.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getContext } from '../local/context.js';
|
|
|
26
26
|
* If a mutator throws, the error propagates to the caller and any writes it
|
|
27
27
|
* already dispatched stay in place — there is no automatic rollback. Wrap the
|
|
28
28
|
* call in your own try/catch and issue compensating writes when you need to
|
|
29
|
-
* undo a partial change, or pass an `undoScope` (see {@link
|
|
29
|
+
* undo a partial change, or pass an `undoScope` (see {@link useMutators.Options})
|
|
30
30
|
* to record inverses for undo and redo.
|
|
31
31
|
*/
|
|
32
32
|
|
|
@@ -50,28 +50,12 @@ export type InvokerFor<F> = F extends (options: infer O) => Promise<infer R>
|
|
|
50
50
|
* The hook's return shape: same tree as the input `MutatorDefs`, every leaf
|
|
51
51
|
* rewritten to its invoker form.
|
|
52
52
|
*/
|
|
53
|
-
export type MutatorInvokers<M> = {
|
|
54
|
-
[K in keyof M]: {
|
|
55
|
-
[N in keyof M[K]]: InvokerFor<M[K][N]>;
|
|
56
|
-
};
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Options passed to `useMutators`. When `undoScope` is set, every mutator
|
|
61
|
-
* invocation is wrapped in a `RecordingMutation` and its inverses are
|
|
62
|
-
* pushed to the scope as one undo entry.
|
|
63
|
-
*/
|
|
64
|
-
export interface UseMutatorsOptions<S extends Schema> {
|
|
65
|
-
/** Target undo scope for recording inverses. Omit to disable recording. */
|
|
66
|
-
undoScope?: UndoScope<S>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
/** Mutator invokers (explicit schema arg). */
|
|
70
54
|
export function useMutators<S extends Schema, M extends MutatorDefs<S>>(
|
|
71
55
|
schema: S,
|
|
72
56
|
mutators: M,
|
|
73
|
-
options?:
|
|
74
|
-
):
|
|
57
|
+
options?: useMutators.Options<S>,
|
|
58
|
+
): useMutators.Result<M>;
|
|
75
59
|
|
|
76
60
|
/** Mutator invokers via the `Register` module augmentation. Schema comes
|
|
77
61
|
* from the `SyncProvider`'s context; the mutator tree is typed against
|
|
@@ -80,14 +64,14 @@ export function useMutators<
|
|
|
80
64
|
M extends ResolveSchema extends Schema ? MutatorDefs<ResolveSchema> : MutatorDefs<Schema>,
|
|
81
65
|
>(
|
|
82
66
|
mutators: M,
|
|
83
|
-
options?:
|
|
84
|
-
):
|
|
67
|
+
options?: useMutators.Options<ResolveSchema extends Schema ? ResolveSchema : Schema>,
|
|
68
|
+
): useMutators.Result<M>;
|
|
85
69
|
|
|
86
70
|
export function useMutators(
|
|
87
71
|
schemaOrMutators: Schema | MutatorDefs<Schema>,
|
|
88
|
-
mutatorsOrOptions?: MutatorDefs<Schema> |
|
|
89
|
-
maybeOptions?:
|
|
90
|
-
):
|
|
72
|
+
mutatorsOrOptions?: MutatorDefs<Schema> | useMutators.Options<Schema>,
|
|
73
|
+
maybeOptions?: useMutators.Options<Schema>,
|
|
74
|
+
): useMutators.Result<MutatorDefs<Schema>> {
|
|
91
75
|
const { store, organizationId, schema: ctxSchema } = useSyncContext();
|
|
92
76
|
|
|
93
77
|
// Disambiguate: explicit-schema path has the schema object in first slot;
|
|
@@ -101,7 +85,7 @@ export function useMutators(
|
|
|
101
85
|
const schema = isExplicit ? (schemaOrMutators as Schema) : ctxSchema;
|
|
102
86
|
const mutators = (isExplicit ? mutatorsOrOptions : schemaOrMutators) as MutatorDefs<Schema>;
|
|
103
87
|
const options = (isExplicit ? maybeOptions : mutatorsOrOptions) as
|
|
104
|
-
|
|
|
88
|
+
| useMutators.Options<Schema>
|
|
105
89
|
| undefined;
|
|
106
90
|
|
|
107
91
|
if (!schema) {
|
|
@@ -115,7 +99,7 @@ export function useMutators(
|
|
|
115
99
|
|
|
116
100
|
const { undoScope } = options ?? {};
|
|
117
101
|
|
|
118
|
-
return useMemo<
|
|
102
|
+
return useMemo<useMutators.Result<MutatorDefs<Schema>>>(() => {
|
|
119
103
|
const out: Record<string, Record<string, (args: unknown) => Promise<unknown>>> = {};
|
|
120
104
|
|
|
121
105
|
for (const modelKey of Object.keys(mutators)) {
|
|
@@ -182,3 +166,23 @@ export function useMutators(
|
|
|
182
166
|
return out;
|
|
183
167
|
}, [schema, mutators, store, organizationId, undoScope]);
|
|
184
168
|
}
|
|
169
|
+
|
|
170
|
+
/** Optional annotations for custom mutation bindings. */
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
172
|
+
export namespace useMutators {
|
|
173
|
+
export type Result<M> = {
|
|
174
|
+
[K in keyof M]: {
|
|
175
|
+
[N in keyof M[K]]: InvokerFor<M[K][N]>;
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Options passed to `useMutators`. When `undoScope` is set, every mutator
|
|
181
|
+
* invocation is wrapped in a `RecordingMutation` and its inverses are
|
|
182
|
+
* pushed to the scope as one undo entry.
|
|
183
|
+
*/
|
|
184
|
+
export interface Options<S extends Schema> {
|
|
185
|
+
/** Target undo scope for recording inverses. Omit to disable recording. */
|
|
186
|
+
undoScope?: UndoScope<S>;
|
|
187
|
+
}
|
|
188
|
+
}
|