@astrale-os/shell-react 0.2.3 → 0.2.5
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/graph/query/neighbors.hook.js +49 -21
- package/dist/schema/binding.d.ts +1 -1
- package/dist/schema/binding.d.ts.map +1 -1
- package/dist/schema/binding.js +52 -8
- package/dist/schema/write.d.ts +4 -3
- package/dist/schema/write.d.ts.map +1 -1
- package/dist/schema/write.js +24 -19
- package/package.json +3 -3
- package/src/graph/query/neighbors.hook.ts +62 -19
- package/src/schema/binding.ts +67 -9
- package/src/schema/write.ts +26 -20
|
@@ -12,8 +12,10 @@ export function useOut(node, edge, opts) {
|
|
|
12
12
|
export function useIn(node, edge, opts) {
|
|
13
13
|
return useEndpoint(node, edge, 'in', opts);
|
|
14
14
|
}
|
|
15
|
-
function useEndpoint(node, edge, side,
|
|
15
|
+
function useEndpoint(node, edge, side, opts) {
|
|
16
16
|
const schema = isIdleInput(node) ? undefined : schemaOf(node);
|
|
17
|
+
const single = schema !== undefined && isSingleEndpoint(schema, edge, side);
|
|
18
|
+
const key = `${node?.id ?? ''}\u0000${edge}\u0000${side}`;
|
|
17
19
|
const run = useCallback(async () => {
|
|
18
20
|
if (isIdleInput(node))
|
|
19
21
|
return null;
|
|
@@ -23,22 +25,26 @@ function useEndpoint(node, edge, side, _opts) {
|
|
|
23
25
|
return result;
|
|
24
26
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
27
|
}, [node?.id, edge, side]);
|
|
26
|
-
const { data, pending, error, refetch } = useAsyncRead(isIdleInput(node) ? null : run,
|
|
27
|
-
node?.id ?? null,
|
|
28
|
-
edge,
|
|
29
|
-
side,
|
|
30
|
-
]);
|
|
28
|
+
const { data, pending, error, refetch } = useAsyncRead(isIdleInput(node) ? null : run, key, opts?.keepPrevious === true);
|
|
31
29
|
return useMemo(() => {
|
|
32
30
|
const live = { pending, error, live: false, seq: 0, refetch };
|
|
33
31
|
if (isIdleInput(node))
|
|
34
32
|
return { ...live, node: null, nodes: EMPTY };
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
if (single) {
|
|
34
|
+
const value = Array.isArray(data) ? data[0] : data;
|
|
35
|
+
return { ...live, node: (value ?? null) };
|
|
36
|
+
}
|
|
37
|
+
const values = Array.isArray(data) ? data : data === null || data === undefined ? EMPTY : [data];
|
|
38
|
+
return { ...live, nodes: values };
|
|
39
|
+
}, [node, data, pending, error, refetch, single]);
|
|
39
40
|
}
|
|
40
41
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
41
42
|
const EMPTY = Object.freeze([]);
|
|
43
|
+
function isSingleEndpoint(schema, edge, side) {
|
|
44
|
+
const definition = schema.classes[edge];
|
|
45
|
+
const cardinality = (side === 'out' ? definition?.to : definition?.from)?.cardinality;
|
|
46
|
+
return cardinality === '1' || cardinality === '0..1';
|
|
47
|
+
}
|
|
42
48
|
function tagEndpoints(result, schema) {
|
|
43
49
|
if (Array.isArray(result))
|
|
44
50
|
for (const r of result)
|
|
@@ -47,30 +53,52 @@ function tagEndpoints(result, schema) {
|
|
|
47
53
|
tagSchema(result, schema);
|
|
48
54
|
}
|
|
49
55
|
/** One-shot async read with pending/error/refetch and stale-response guarding. */
|
|
50
|
-
function useAsyncRead(run,
|
|
51
|
-
const [state, setState] = useState({
|
|
56
|
+
function useAsyncRead(run, key, keepPrevious) {
|
|
57
|
+
const [state, setState] = useState({
|
|
58
|
+
pending: run !== null,
|
|
59
|
+
error: undefined,
|
|
60
|
+
key: run === null ? null : key,
|
|
61
|
+
});
|
|
52
62
|
const runRef = useRef(run);
|
|
53
63
|
runRef.current = run;
|
|
64
|
+
const keyRef = useRef(key);
|
|
65
|
+
keyRef.current = key;
|
|
66
|
+
const keepPreviousRef = useRef(keepPrevious);
|
|
67
|
+
keepPreviousRef.current = keepPrevious;
|
|
54
68
|
const seq = useRef(0);
|
|
55
69
|
const refetch = useCallback(() => {
|
|
56
70
|
const current = runRef.current;
|
|
71
|
+
const currentKey = keyRef.current;
|
|
57
72
|
if (current === null) {
|
|
58
|
-
setState({ pending: false, error: undefined, data: undefined });
|
|
73
|
+
setState({ pending: false, error: undefined, data: undefined, key: null });
|
|
59
74
|
return Promise.resolve();
|
|
60
75
|
}
|
|
61
76
|
const my = ++seq.current;
|
|
62
|
-
setState((s) => ({
|
|
77
|
+
setState((s) => ({
|
|
78
|
+
data: s.key === currentKey || keepPreviousRef.current === true ? s.data : undefined,
|
|
79
|
+
pending: true,
|
|
80
|
+
error: undefined,
|
|
81
|
+
key: currentKey,
|
|
82
|
+
}));
|
|
63
83
|
return current().then((data) => {
|
|
64
|
-
if (my === seq.current)
|
|
65
|
-
setState({ data, pending: false, error: undefined });
|
|
84
|
+
if (my === seq.current) {
|
|
85
|
+
setState({ data, pending: false, error: undefined, key: currentKey });
|
|
86
|
+
}
|
|
66
87
|
}, (error) => {
|
|
67
|
-
if (my === seq.current)
|
|
68
|
-
setState((s) => ({ data: s.data, pending: false, error }));
|
|
88
|
+
if (my === seq.current) {
|
|
89
|
+
setState((s) => ({ data: s.data, pending: false, error, key: currentKey }));
|
|
90
|
+
}
|
|
69
91
|
});
|
|
70
92
|
}, []);
|
|
71
93
|
useEffect(() => {
|
|
72
94
|
void refetch();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return {
|
|
95
|
+
}, [key, refetch]);
|
|
96
|
+
const current = state.key === key;
|
|
97
|
+
return {
|
|
98
|
+
data: current || keepPrevious ? state.data : undefined,
|
|
99
|
+
pending: run !== null && (!current || state.pending),
|
|
100
|
+
error: current ? state.error : undefined,
|
|
101
|
+
key: current ? state.key : null,
|
|
102
|
+
refetch,
|
|
103
|
+
};
|
|
76
104
|
}
|
package/dist/schema/binding.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `createSchemaBoundView` per (session, schema) so a binding — and the BoundNodes
|
|
5
5
|
* it mints — keep referential identity across renders. Each binding is wrapped so
|
|
6
6
|
* every BoundNode it hands back is schema-tagged (the traversal hooks read it
|
|
7
|
-
* back) and its
|
|
7
|
+
* back) and its raw node recorded (the write capability's index).
|
|
8
8
|
*/
|
|
9
9
|
import type { FnMap, NodeRefInput, SchemaBoundView } from '@astrale-os/kernel-client';
|
|
10
10
|
import type { GraphMemory } from '@astrale-os/kernel-client/store';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"binding.d.ts","sourceRoot":"","sources":["../../src/schema/binding.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AAIpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAQ5D;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EACzC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,CAAC,EACT,MAAM,EAAE,WAAW,GAClB,eAAe,CAAC,CAAC,EAAE,KAAK,CAAC,CAa3B;
|
|
1
|
+
{"version":3,"file":"binding.d.ts","sourceRoot":"","sources":["../../src/schema/binding.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AAIpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAQ5D;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EACzC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,CAAC,EACT,MAAM,EAAE,WAAW,GAClB,eAAe,CAAC,CAAC,EAAE,KAAK,CAAC,CAa3B;AAmGD,YAAY,EAAE,YAAY,EAAE,CAAA"}
|
package/dist/schema/binding.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSchemaBoundView } from '@astrale-os/kernel-client';
|
|
2
2
|
import { tagSchema } from './tag.js';
|
|
3
|
-
import {
|
|
3
|
+
import { recordNode, writableFor } from './write.js';
|
|
4
4
|
/** One tagged binding per (kernel, schema) — shared across the whole tree. */
|
|
5
5
|
const bindingCache = new WeakMap();
|
|
6
6
|
/**
|
|
@@ -18,38 +18,82 @@ export function bindingFor(kernel, schema, memory) {
|
|
|
18
18
|
const cached = bySchema.get(schema);
|
|
19
19
|
if (cached !== undefined)
|
|
20
20
|
return cached;
|
|
21
|
-
const { writable,
|
|
22
|
-
const binding = tagBinding(createSchemaBoundView(writable, schema), schema,
|
|
21
|
+
const { writable, nodeIndex } = writableFor(kernel, memory);
|
|
22
|
+
const binding = tagBinding(createSchemaBoundView(writable, schema), schema, nodeIndex);
|
|
23
23
|
bySchema.set(schema, binding);
|
|
24
24
|
return binding;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Wrap a binding so `bind`/`bindAny`/`as` outputs carry a schema tag, and every
|
|
28
|
-
* bound wire node
|
|
28
|
+
* bound wire node is recorded with its class and path (the write cap's index); the
|
|
29
29
|
* class handles and every other member pass straight through.
|
|
30
30
|
*/
|
|
31
|
-
function tagBinding(binding, schema,
|
|
31
|
+
function tagBinding(binding, schema, nodeIndex) {
|
|
32
|
+
let taggedClasses;
|
|
32
33
|
const handler = {
|
|
33
34
|
get: (target, prop, receiver) => {
|
|
34
35
|
const value = Reflect.get(target, prop, receiver);
|
|
36
|
+
if (prop === 'node' || prop === 'create') {
|
|
37
|
+
return async (...args) => tagBoundResult(await value(...args), schema, nodeIndex);
|
|
38
|
+
}
|
|
35
39
|
if (prop === 'bind') {
|
|
36
40
|
return (type, node, opts) => {
|
|
37
|
-
|
|
41
|
+
recordNode(nodeIndex, node);
|
|
38
42
|
return tagSchema(value(type, node, opts), schema);
|
|
39
43
|
};
|
|
40
44
|
}
|
|
41
45
|
if (prop === 'bindAny') {
|
|
42
46
|
return (node) => {
|
|
43
|
-
|
|
47
|
+
recordNode(nodeIndex, node);
|
|
44
48
|
return tagSchema(value(node), schema);
|
|
45
49
|
};
|
|
46
50
|
}
|
|
51
|
+
if (prop === 'classes') {
|
|
52
|
+
taggedClasses ??= tagClassHandles(value, schema, nodeIndex);
|
|
53
|
+
return taggedClasses;
|
|
54
|
+
}
|
|
47
55
|
if (prop === 'as') {
|
|
48
56
|
const as = value;
|
|
49
|
-
return (credential) => tagBinding(as(credential), schema,
|
|
57
|
+
return (credential) => tagBinding(as(credential), schema, nodeIndex);
|
|
58
|
+
}
|
|
59
|
+
if (prop === 'withSchema') {
|
|
60
|
+
return (next) => tagBinding(value(next), next, nodeIndex);
|
|
50
61
|
}
|
|
51
62
|
return value;
|
|
52
63
|
},
|
|
53
64
|
};
|
|
54
65
|
return new Proxy(binding, handler);
|
|
55
66
|
}
|
|
67
|
+
function tagClassHandles(classes, schema, nodeIndex) {
|
|
68
|
+
const handles = new Map();
|
|
69
|
+
return new Proxy(classes, {
|
|
70
|
+
get(target, prop, receiver) {
|
|
71
|
+
const cached = handles.get(prop);
|
|
72
|
+
if (cached !== undefined)
|
|
73
|
+
return cached;
|
|
74
|
+
const handle = Reflect.get(target, prop, receiver);
|
|
75
|
+
if (typeof handle !== 'function')
|
|
76
|
+
return handle;
|
|
77
|
+
const tagged = new Proxy(handle, {
|
|
78
|
+
get(handleTarget, member, handleReceiver) {
|
|
79
|
+
const value = Reflect.get(handleTarget, member, handleReceiver);
|
|
80
|
+
if (member !== 'get')
|
|
81
|
+
return value;
|
|
82
|
+
return async (...args) => tagBoundResult(await value(...args), schema, nodeIndex);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
handles.set(prop, tagged);
|
|
86
|
+
return tagged;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function tagBoundResult(result, schema, nodeIndex) {
|
|
91
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
92
|
+
for (const node of nodes) {
|
|
93
|
+
if (node !== null && typeof node === 'object') {
|
|
94
|
+
recordNode(nodeIndex, node);
|
|
95
|
+
tagSchema(node, schema);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
package/dist/schema/write.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GraphMemory } from '@astrale-os/kernel-client/store';
|
|
2
|
+
import type { Node } from '@astrale-os/kernel-core';
|
|
2
3
|
import type { SessionView } from '../auth/act-as.context.js';
|
|
3
4
|
/**
|
|
4
5
|
* One memory-backed writable kernel per (effective) session view, plus the
|
|
@@ -7,9 +8,9 @@ import type { SessionView } from '../auth/act-as.context.js';
|
|
|
7
8
|
*/
|
|
8
9
|
export interface WritableKernel {
|
|
9
10
|
readonly writable: SessionView;
|
|
10
|
-
readonly
|
|
11
|
+
readonly nodeIndex: Map<string, Node>;
|
|
11
12
|
}
|
|
12
13
|
export declare function writableFor(kernel: SessionView, memory: GraphMemory): WritableKernel;
|
|
13
|
-
/** Record a bound node's
|
|
14
|
-
export declare function
|
|
14
|
+
/** Record a bound node's raw value so the write cap retains its class and tree path. */
|
|
15
|
+
export declare function recordNode(nodeIndex: Map<string, Node>, node: unknown): void;
|
|
15
16
|
//# sourceMappingURL=write.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../src/schema/write.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAA;
|
|
1
|
+
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../src/schema/write.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAA;AAClE,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAE5D;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;CACtC;AAGD,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,cAAc,CAQpF;AAqDD,wFAAwF;AACxF,wBAAgB,UAAU,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAO5E"}
|
package/dist/schema/write.js
CHANGED
|
@@ -2,8 +2,8 @@ const writableCache = new WeakMap();
|
|
|
2
2
|
export function writableFor(kernel, memory) {
|
|
3
3
|
let entry = writableCache.get(kernel);
|
|
4
4
|
if (entry === undefined) {
|
|
5
|
-
const
|
|
6
|
-
entry = { writable: withMemoryWrites(kernel, memory,
|
|
5
|
+
const nodeIndex = new Map();
|
|
6
|
+
entry = { writable: withMemoryWrites(kernel, memory, nodeIndex), nodeIndex };
|
|
7
7
|
writableCache.set(kernel, entry);
|
|
8
8
|
}
|
|
9
9
|
return entry;
|
|
@@ -11,41 +11,46 @@ export function writableFor(kernel, memory) {
|
|
|
11
11
|
/**
|
|
12
12
|
* Wrap a session view so `updateNode`/`createNode` route through the memory's
|
|
13
13
|
* optimistic write shorthands. `BoundNode.update` addresses a node by `@<id>`,
|
|
14
|
-
* but the memory
|
|
15
|
-
* through `
|
|
16
|
-
* that was never
|
|
14
|
+
* but the memory needs the node's class and tree path — so an `@<id>` ref is translated
|
|
15
|
+
* through `nodeIndex` (populated at bind time). A ref that is NOT indexed (a node
|
|
16
|
+
* that was never bound) falls back to the raw kernel-client floor:
|
|
17
17
|
* it still dispatches, just without the overlay — matching the imperative floor.
|
|
18
18
|
*/
|
|
19
|
-
function withMemoryWrites(kernel, memory,
|
|
19
|
+
function withMemoryWrites(kernel, memory, nodeIndex) {
|
|
20
20
|
const base = kernel;
|
|
21
21
|
const rawUpdate = base.updateNode.bind(base);
|
|
22
22
|
return extendCapabilities(kernel, {
|
|
23
23
|
updateNode: (cls, ref, props) => {
|
|
24
|
-
const
|
|
25
|
-
return
|
|
24
|
+
const node = indexedNode(nodeIndex, ref);
|
|
25
|
+
return node !== null ? memory.update(node, props) : rawUpdate(cls, ref, props);
|
|
26
26
|
},
|
|
27
27
|
createNode: (cls, at, props) => memory.create(cls, at, props),
|
|
28
28
|
// Credential rebind stays on the raw kernel-client floor (§6 note).
|
|
29
29
|
as: (cred) => base.as(cred),
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/** Overlay memory writes without materializing a possibly virtual session surface. */
|
|
33
33
|
function extendCapabilities(base, extension) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
return new Proxy(base, {
|
|
35
|
+
get(target, prop, receiver) {
|
|
36
|
+
return Object.prototype.hasOwnProperty.call(extension, prop)
|
|
37
|
+
? Reflect.get(extension, prop, extension)
|
|
38
|
+
: Reflect.get(target, prop, receiver);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
38
41
|
}
|
|
39
42
|
/** The tree path an `@<id>` write ref maps to, via the bind-time index; else null. */
|
|
40
|
-
function
|
|
43
|
+
function indexedNode(nodeIndex, ref) {
|
|
41
44
|
if (typeof ref !== 'string' || !ref.startsWith('@'))
|
|
42
45
|
return null;
|
|
43
|
-
return
|
|
46
|
+
return nodeIndex.get(ref.slice(1)) ?? null;
|
|
44
47
|
}
|
|
45
|
-
/** Record a bound node's
|
|
46
|
-
export function
|
|
47
|
-
const
|
|
48
|
+
/** Record a bound node's raw value so the write cap retains its class and tree path. */
|
|
49
|
+
export function recordNode(nodeIndex, node) {
|
|
50
|
+
const wrapped = node;
|
|
51
|
+
const candidate = wrapped?.raw !== null && typeof wrapped?.raw === 'object' ? wrapped.raw : node;
|
|
52
|
+
const n = candidate;
|
|
48
53
|
if (n !== null && typeof n.id === 'string' && typeof n.path?.raw === 'string') {
|
|
49
|
-
|
|
54
|
+
nodeIndex.set(n.id, candidate);
|
|
50
55
|
}
|
|
51
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrale-os/shell-react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Astrale shell-react — the React face of the shell (provider, hooks, view components)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@astrale-domains/shell-schema": ">=0.3.2 <1.0.0",
|
|
29
|
-
"@astrale-os/kernel-client": ">=0.4.
|
|
29
|
+
"@astrale-os/kernel-client": ">=0.4.7 <1.0.0",
|
|
30
30
|
"@astrale-os/kernel-core": ">=0.7.5 <1.0.0",
|
|
31
31
|
"@astrale-os/kernel-dsl": ">=0.1.4 <1.0.0",
|
|
32
32
|
"jose": "^6.2.3",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"react-dom": "^19.2.0",
|
|
45
45
|
"typescript": "~6.0.1-rc",
|
|
46
46
|
"vitest": "^3.2.4",
|
|
47
|
-
"@astrale-os/shell": "0.3.
|
|
47
|
+
"@astrale-os/shell": "0.3.4"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@astrale-os/shell": ">=0.2.0 <1.0.0",
|
|
@@ -39,9 +39,11 @@ function useEndpoint<S extends Schema>(
|
|
|
39
39
|
node: AnyBoundNode<S> | null | undefined,
|
|
40
40
|
edge: EdgeNames<S>,
|
|
41
41
|
side: 'out' | 'in',
|
|
42
|
-
|
|
42
|
+
opts?: ReadOptions,
|
|
43
43
|
): EndpointEnvelope<S> {
|
|
44
44
|
const schema = isIdleInput(node) ? undefined : schemaOf(node)
|
|
45
|
+
const single = schema !== undefined && isSingleEndpoint(schema, edge, side)
|
|
46
|
+
const key = `${node?.id ?? ''}\u0000${edge}\u0000${side}`
|
|
45
47
|
const run = useCallback(async (): Promise<unknown> => {
|
|
46
48
|
if (isIdleInput(node)) return null
|
|
47
49
|
const result = await (side === 'out' ? node.links.out(edge) : node.links.in(edge))
|
|
@@ -50,24 +52,39 @@ function useEndpoint<S extends Schema>(
|
|
|
50
52
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
53
|
}, [node?.id, edge, side])
|
|
52
54
|
|
|
53
|
-
const { data, pending, error, refetch } = useAsyncRead(
|
|
54
|
-
node
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const { data, pending, error, refetch } = useAsyncRead(
|
|
56
|
+
isIdleInput(node) ? null : run,
|
|
57
|
+
key,
|
|
58
|
+
opts?.keepPrevious === true,
|
|
59
|
+
)
|
|
58
60
|
|
|
59
61
|
return useMemo<EndpointEnvelope<S>>(() => {
|
|
60
62
|
const live: ReadState = { pending, error, live: false, seq: 0, refetch }
|
|
61
63
|
if (isIdleInput(node)) return { ...live, node: null, nodes: EMPTY }
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
if (single) {
|
|
65
|
+
const value = Array.isArray(data) ? data[0] : data
|
|
66
|
+
return { ...live, node: (value ?? null) as AnyBoundNode<S> | null }
|
|
67
|
+
}
|
|
68
|
+
const values = Array.isArray(data) ? data : data === null || data === undefined ? EMPTY : [data]
|
|
69
|
+
return { ...live, nodes: values as readonly AnyBoundNode<S>[] }
|
|
70
|
+
}, [node, data, pending, error, refetch, single])
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
68
74
|
|
|
69
75
|
const EMPTY: readonly never[] = Object.freeze([])
|
|
70
76
|
|
|
77
|
+
function isSingleEndpoint(schema: Schema, edge: string, side: 'out' | 'in'): boolean {
|
|
78
|
+
const definition = schema.classes[edge] as
|
|
79
|
+
| {
|
|
80
|
+
readonly from?: { readonly cardinality?: string }
|
|
81
|
+
readonly to?: { readonly cardinality?: string }
|
|
82
|
+
}
|
|
83
|
+
| undefined
|
|
84
|
+
const cardinality = (side === 'out' ? definition?.to : definition?.from)?.cardinality
|
|
85
|
+
return cardinality === '1' || cardinality === '0..1'
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
function tagEndpoints(result: unknown, schema: Schema): void {
|
|
72
89
|
if (Array.isArray(result)) for (const r of result) tagSchema(r, schema)
|
|
73
90
|
else if (result !== null && result !== undefined) tagSchema(result, schema)
|
|
@@ -77,40 +94,66 @@ interface AsyncState<T> {
|
|
|
77
94
|
data?: T
|
|
78
95
|
pending: boolean
|
|
79
96
|
error: unknown
|
|
97
|
+
key: string | null
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
/** One-shot async read with pending/error/refetch and stale-response guarding. */
|
|
83
101
|
function useAsyncRead<T>(
|
|
84
102
|
run: (() => Promise<T>) | null,
|
|
85
|
-
|
|
103
|
+
key: string,
|
|
104
|
+
keepPrevious: boolean,
|
|
86
105
|
): AsyncState<T> & { refetch(): Promise<void> } {
|
|
87
|
-
const [state, setState] = useState<AsyncState<T>>({
|
|
106
|
+
const [state, setState] = useState<AsyncState<T>>({
|
|
107
|
+
pending: run !== null,
|
|
108
|
+
error: undefined,
|
|
109
|
+
key: run === null ? null : key,
|
|
110
|
+
})
|
|
88
111
|
const runRef = useRef(run)
|
|
89
112
|
runRef.current = run
|
|
113
|
+
const keyRef = useRef(key)
|
|
114
|
+
keyRef.current = key
|
|
115
|
+
const keepPreviousRef = useRef(keepPrevious)
|
|
116
|
+
keepPreviousRef.current = keepPrevious
|
|
90
117
|
const seq = useRef(0)
|
|
91
118
|
|
|
92
119
|
const refetch = useCallback((): Promise<void> => {
|
|
93
120
|
const current = runRef.current
|
|
121
|
+
const currentKey = keyRef.current
|
|
94
122
|
if (current === null) {
|
|
95
|
-
setState({ pending: false, error: undefined, data: undefined })
|
|
123
|
+
setState({ pending: false, error: undefined, data: undefined, key: null })
|
|
96
124
|
return Promise.resolve()
|
|
97
125
|
}
|
|
98
126
|
const my = ++seq.current
|
|
99
|
-
setState((s) => ({
|
|
127
|
+
setState((s) => ({
|
|
128
|
+
data: s.key === currentKey || keepPreviousRef.current === true ? s.data : undefined,
|
|
129
|
+
pending: true,
|
|
130
|
+
error: undefined,
|
|
131
|
+
key: currentKey,
|
|
132
|
+
}))
|
|
100
133
|
return current().then(
|
|
101
134
|
(data) => {
|
|
102
|
-
if (my === seq.current)
|
|
135
|
+
if (my === seq.current) {
|
|
136
|
+
setState({ data, pending: false, error: undefined, key: currentKey })
|
|
137
|
+
}
|
|
103
138
|
},
|
|
104
139
|
(error: unknown) => {
|
|
105
|
-
if (my === seq.current)
|
|
140
|
+
if (my === seq.current) {
|
|
141
|
+
setState((s) => ({ data: s.data, pending: false, error, key: currentKey }))
|
|
142
|
+
}
|
|
106
143
|
},
|
|
107
144
|
)
|
|
108
145
|
}, [])
|
|
109
146
|
|
|
110
147
|
useEffect(() => {
|
|
111
148
|
void refetch()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return {
|
|
149
|
+
}, [key, refetch])
|
|
150
|
+
|
|
151
|
+
const current = state.key === key
|
|
152
|
+
return {
|
|
153
|
+
data: current || keepPrevious ? state.data : undefined,
|
|
154
|
+
pending: run !== null && (!current || state.pending),
|
|
155
|
+
error: current ? state.error : undefined,
|
|
156
|
+
key: current ? state.key : null,
|
|
157
|
+
refetch,
|
|
158
|
+
}
|
|
116
159
|
}
|
package/src/schema/binding.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `createSchemaBoundView` per (session, schema) so a binding — and the BoundNodes
|
|
5
5
|
* it mints — keep referential identity across renders. Each binding is wrapped so
|
|
6
6
|
* every BoundNode it hands back is schema-tagged (the traversal hooks read it
|
|
7
|
-
* back) and its
|
|
7
|
+
* back) and its raw node recorded (the write capability's index).
|
|
8
8
|
*/
|
|
9
9
|
import type { FnMap, NodeRefInput, SchemaBoundView } from '@astrale-os/kernel-client'
|
|
10
10
|
import type { GraphMemory } from '@astrale-os/kernel-client/store'
|
|
@@ -16,7 +16,7 @@ import { createSchemaBoundView } from '@astrale-os/kernel-client'
|
|
|
16
16
|
import type { SessionView } from '../auth/act-as.context.js'
|
|
17
17
|
|
|
18
18
|
import { tagSchema } from './tag.js'
|
|
19
|
-
import {
|
|
19
|
+
import { recordNode, writableFor } from './write.js'
|
|
20
20
|
|
|
21
21
|
/** One tagged binding per (kernel, schema) — shared across the whole tree. */
|
|
22
22
|
const bindingCache = new WeakMap<object, WeakMap<Schema, unknown>>()
|
|
@@ -40,28 +40,37 @@ export function bindingFor<S extends Schema>(
|
|
|
40
40
|
const cached = bySchema.get(schema)
|
|
41
41
|
if (cached !== undefined) return cached as SchemaBoundView<S, FnMap>
|
|
42
42
|
|
|
43
|
-
const { writable,
|
|
44
|
-
const binding = tagBinding(createSchemaBoundView(writable, schema), schema,
|
|
43
|
+
const { writable, nodeIndex } = writableFor(kernel, memory)
|
|
44
|
+
const binding = tagBinding(createSchemaBoundView(writable, schema), schema, nodeIndex)
|
|
45
45
|
bySchema.set(schema, binding)
|
|
46
46
|
return binding
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Wrap a binding so `bind`/`bindAny`/`as` outputs carry a schema tag, and every
|
|
51
|
-
* bound wire node
|
|
51
|
+
* bound wire node is recorded with its class and path (the write cap's index); the
|
|
52
52
|
* class handles and every other member pass straight through.
|
|
53
53
|
*/
|
|
54
54
|
function tagBinding<S extends Schema>(
|
|
55
55
|
binding: SchemaBoundView<S, FnMap>,
|
|
56
56
|
schema: S,
|
|
57
|
-
|
|
57
|
+
nodeIndex: Map<string, Node>,
|
|
58
58
|
): SchemaBoundView<S, FnMap> {
|
|
59
|
+
let taggedClasses: unknown
|
|
59
60
|
const handler: ProxyHandler<SchemaBoundView<S, FnMap>> = {
|
|
60
61
|
get: (target, prop, receiver) => {
|
|
61
62
|
const value = Reflect.get(target, prop, receiver) as unknown
|
|
63
|
+
if (prop === 'node' || prop === 'create') {
|
|
64
|
+
return async (...args: unknown[]) =>
|
|
65
|
+
tagBoundResult(
|
|
66
|
+
await (value as (...callArgs: unknown[]) => Promise<unknown>)(...args),
|
|
67
|
+
schema,
|
|
68
|
+
nodeIndex,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
62
71
|
if (prop === 'bind') {
|
|
63
72
|
return (type: string, node: Node, opts?: unknown) => {
|
|
64
|
-
|
|
73
|
+
recordNode(nodeIndex, node)
|
|
65
74
|
return tagSchema(
|
|
66
75
|
(value as (t: string, n: Node, o?: unknown) => unknown)(type, node, opts),
|
|
67
76
|
schema,
|
|
@@ -70,14 +79,22 @@ function tagBinding<S extends Schema>(
|
|
|
70
79
|
}
|
|
71
80
|
if (prop === 'bindAny') {
|
|
72
81
|
return (node: Node) => {
|
|
73
|
-
|
|
82
|
+
recordNode(nodeIndex, node)
|
|
74
83
|
return tagSchema((value as (n: Node) => unknown)(node), schema)
|
|
75
84
|
}
|
|
76
85
|
}
|
|
86
|
+
if (prop === 'classes') {
|
|
87
|
+
taggedClasses ??= tagClassHandles(value as object, schema, nodeIndex)
|
|
88
|
+
return taggedClasses
|
|
89
|
+
}
|
|
77
90
|
if (prop === 'as') {
|
|
78
91
|
const as = value as SchemaBoundView<S, FnMap>['as']
|
|
79
92
|
return (credential: Parameters<typeof as>[0]) =>
|
|
80
|
-
tagBinding(as(credential), schema,
|
|
93
|
+
tagBinding(as(credential), schema, nodeIndex)
|
|
94
|
+
}
|
|
95
|
+
if (prop === 'withSchema') {
|
|
96
|
+
return <S2 extends Schema>(next: S2) =>
|
|
97
|
+
tagBinding((value as SchemaBoundView<S, FnMap>['withSchema'])(next), next, nodeIndex)
|
|
81
98
|
}
|
|
82
99
|
return value
|
|
83
100
|
},
|
|
@@ -85,4 +102,45 @@ function tagBinding<S extends Schema>(
|
|
|
85
102
|
return new Proxy(binding, handler)
|
|
86
103
|
}
|
|
87
104
|
|
|
105
|
+
function tagClassHandles<S extends Schema>(
|
|
106
|
+
classes: object,
|
|
107
|
+
schema: S,
|
|
108
|
+
nodeIndex: Map<string, Node>,
|
|
109
|
+
): object {
|
|
110
|
+
const handles = new Map<PropertyKey, unknown>()
|
|
111
|
+
return new Proxy(classes, {
|
|
112
|
+
get(target, prop, receiver) {
|
|
113
|
+
const cached = handles.get(prop)
|
|
114
|
+
if (cached !== undefined) return cached
|
|
115
|
+
const handle = Reflect.get(target, prop, receiver) as unknown
|
|
116
|
+
if (typeof handle !== 'function') return handle
|
|
117
|
+
const tagged = new Proxy(handle, {
|
|
118
|
+
get(handleTarget, member, handleReceiver) {
|
|
119
|
+
const value = Reflect.get(handleTarget, member, handleReceiver) as unknown
|
|
120
|
+
if (member !== 'get') return value
|
|
121
|
+
return async (...args: unknown[]) =>
|
|
122
|
+
tagBoundResult(
|
|
123
|
+
await (value as (...callArgs: unknown[]) => Promise<unknown>)(...args),
|
|
124
|
+
schema,
|
|
125
|
+
nodeIndex,
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
handles.set(prop, tagged)
|
|
130
|
+
return tagged
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tagBoundResult<R>(result: R, schema: Schema, nodeIndex: Map<string, Node>): R {
|
|
136
|
+
const nodes = Array.isArray(result) ? result : [result]
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if (node !== null && typeof node === 'object') {
|
|
139
|
+
recordNode(nodeIndex, node)
|
|
140
|
+
tagSchema(node, schema)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
88
146
|
export type { NodeRefInput }
|
package/src/schema/write.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { CredentialInput } from '@astrale-os/kernel-client'
|
|
10
10
|
import type { PathLike, Properties } from '@astrale-os/kernel-client/graph'
|
|
11
11
|
import type { GraphMemory } from '@astrale-os/kernel-client/store'
|
|
12
|
+
import type { Node } from '@astrale-os/kernel-core'
|
|
12
13
|
|
|
13
14
|
import type { SessionView } from '../auth/act-as.context.js'
|
|
14
15
|
|
|
@@ -19,15 +20,15 @@ import type { SessionView } from '../auth/act-as.context.js'
|
|
|
19
20
|
*/
|
|
20
21
|
export interface WritableKernel {
|
|
21
22
|
readonly writable: SessionView
|
|
22
|
-
readonly
|
|
23
|
+
readonly nodeIndex: Map<string, Node>
|
|
23
24
|
}
|
|
24
25
|
const writableCache = new WeakMap<object, WritableKernel>()
|
|
25
26
|
|
|
26
27
|
export function writableFor(kernel: SessionView, memory: GraphMemory): WritableKernel {
|
|
27
28
|
let entry = writableCache.get(kernel)
|
|
28
29
|
if (entry === undefined) {
|
|
29
|
-
const
|
|
30
|
-
entry = { writable: withMemoryWrites(kernel, memory,
|
|
30
|
+
const nodeIndex = new Map<string, Node>()
|
|
31
|
+
entry = { writable: withMemoryWrites(kernel, memory, nodeIndex), nodeIndex }
|
|
31
32
|
writableCache.set(kernel, entry)
|
|
32
33
|
}
|
|
33
34
|
return entry
|
|
@@ -36,15 +37,15 @@ export function writableFor(kernel: SessionView, memory: GraphMemory): WritableK
|
|
|
36
37
|
/**
|
|
37
38
|
* Wrap a session view so `updateNode`/`createNode` route through the memory's
|
|
38
39
|
* optimistic write shorthands. `BoundNode.update` addresses a node by `@<id>`,
|
|
39
|
-
* but the memory
|
|
40
|
-
* through `
|
|
41
|
-
* that was never
|
|
40
|
+
* but the memory needs the node's class and tree path — so an `@<id>` ref is translated
|
|
41
|
+
* through `nodeIndex` (populated at bind time). A ref that is NOT indexed (a node
|
|
42
|
+
* that was never bound) falls back to the raw kernel-client floor:
|
|
42
43
|
* it still dispatches, just without the overlay — matching the imperative floor.
|
|
43
44
|
*/
|
|
44
45
|
function withMemoryWrites(
|
|
45
46
|
kernel: SessionView,
|
|
46
47
|
memory: GraphMemory,
|
|
47
|
-
|
|
48
|
+
nodeIndex: Map<string, Node>,
|
|
48
49
|
): SessionView {
|
|
49
50
|
const base = kernel as unknown as {
|
|
50
51
|
updateNode(cls: PathLike, ref: PathLike, props: Properties): Promise<void>
|
|
@@ -54,8 +55,8 @@ function withMemoryWrites(
|
|
|
54
55
|
const rawUpdate = base.updateNode.bind(base)
|
|
55
56
|
return extendCapabilities(kernel, {
|
|
56
57
|
updateNode: (cls: PathLike, ref: PathLike, props: Properties): Promise<void> => {
|
|
57
|
-
const
|
|
58
|
-
return
|
|
58
|
+
const node = indexedNode(nodeIndex, ref)
|
|
59
|
+
return node !== null ? memory.update(node, props) : rawUpdate(cls, ref, props)
|
|
59
60
|
},
|
|
60
61
|
createNode: (cls: PathLike, at: PathLike, props?: Properties): Promise<string> =>
|
|
61
62
|
memory.create(cls, at, props),
|
|
@@ -64,27 +65,32 @@ function withMemoryWrites(
|
|
|
64
65
|
})
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/** Overlay memory writes without materializing a possibly virtual session surface. */
|
|
68
69
|
function extendCapabilities<Base extends object, Extension extends object>(
|
|
69
70
|
base: Base,
|
|
70
71
|
extension: Extension,
|
|
71
72
|
): Base & Extension {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
return new Proxy(base as Base & Extension, {
|
|
74
|
+
get(target, prop, receiver) {
|
|
75
|
+
return Object.prototype.hasOwnProperty.call(extension, prop)
|
|
76
|
+
? Reflect.get(extension, prop, extension)
|
|
77
|
+
: Reflect.get(target, prop, receiver)
|
|
78
|
+
},
|
|
79
|
+
})
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/** The tree path an `@<id>` write ref maps to, via the bind-time index; else null. */
|
|
79
|
-
function
|
|
83
|
+
function indexedNode(nodeIndex: Map<string, Node>, ref: PathLike): Node | null {
|
|
80
84
|
if (typeof ref !== 'string' || !ref.startsWith('@')) return null
|
|
81
|
-
return
|
|
85
|
+
return nodeIndex.get(ref.slice(1)) ?? null
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
/** Record a bound node's
|
|
85
|
-
export function
|
|
86
|
-
const
|
|
88
|
+
/** Record a bound node's raw value so the write cap retains its class and tree path. */
|
|
89
|
+
export function recordNode(nodeIndex: Map<string, Node>, node: unknown): void {
|
|
90
|
+
const wrapped = node as { raw?: unknown } | null
|
|
91
|
+
const candidate = wrapped?.raw !== null && typeof wrapped?.raw === 'object' ? wrapped.raw : node
|
|
92
|
+
const n = candidate as { id?: unknown; path?: { raw?: unknown } } | null
|
|
87
93
|
if (n !== null && typeof n.id === 'string' && typeof n.path?.raw === 'string') {
|
|
88
|
-
|
|
94
|
+
nodeIndex.set(n.id, candidate as Node)
|
|
89
95
|
}
|
|
90
96
|
}
|