@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.
@@ -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, _opts) {
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 (Array.isArray(data))
36
- return { ...live, nodes: data };
37
- return { ...live, node: (data ?? null) };
38
- }, [node, data, pending, error, refetch]);
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, deps) {
51
- const [state, setState] = useState({ pending: run !== null, error: undefined });
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) => ({ ...s, pending: true, error: undefined }));
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
74
- }, deps);
75
- return { ...state, refetch };
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
  }
@@ -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 `id tree path` recorded (the write capability's index).
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;AAyCD,YAAY,EAAE,YAAY,EAAE,CAAA"}
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"}
@@ -1,6 +1,6 @@
1
1
  import { createSchemaBoundView } from '@astrale-os/kernel-client';
2
2
  import { tagSchema } from './tag.js';
3
- import { recordPath, writableFor } from './write.js';
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, pathIndex } = writableFor(kernel, memory);
22
- const binding = tagBinding(createSchemaBoundView(writable, schema), schema, pathIndex);
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's `id tree path` is recorded (the write cap's index); the
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, pathIndex) {
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
- recordPath(pathIndex, node);
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
- recordPath(pathIndex, node);
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, pathIndex);
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
+ }
@@ -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 pathIndex: Map<string, string>;
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 `id tree path` so the write cap can address it. */
14
- export declare function recordPath(pathIndex: Map<string, string>, node: unknown): void;
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;AAElE,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,MAAM,CAAC,CAAA;CACxC;AAGD,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,cAAc,CAQpF;AAkDD,8EAA8E;AAC9E,wBAAgB,UAAU,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAK9E"}
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"}
@@ -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 pathIndex = new Map();
6
- entry = { writable: withMemoryWrites(kernel, memory, pathIndex), pathIndex };
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 indexes entities by tree path — so an `@<id>` ref is translated
15
- * through `pathIndex` (populated at bind time). A ref that is NOT indexed (a node
16
- * that was never read through a hook) falls back to the raw kernel-client floor:
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, pathIndex) {
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 path = treePathFor(pathIndex, ref);
25
- return path !== null ? memory.update(path, props) : rawUpdate(cls, ref, props);
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
- /** Clone the complete session capability object before overlaying memory writes. */
32
+ /** Overlay memory writes without materializing a possibly virtual session surface. */
33
33
  function extendCapabilities(base, extension) {
34
- const result = Object.create(Object.getPrototypeOf(base));
35
- Object.defineProperties(result, Object.getOwnPropertyDescriptors(base));
36
- Object.defineProperties(result, Object.getOwnPropertyDescriptors(extension));
37
- return result;
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 treePathFor(pathIndex, ref) {
43
+ function indexedNode(nodeIndex, ref) {
41
44
  if (typeof ref !== 'string' || !ref.startsWith('@'))
42
45
  return null;
43
- return pathIndex.get(ref.slice(1)) ?? null;
46
+ return nodeIndex.get(ref.slice(1)) ?? null;
44
47
  }
45
- /** Record a bound node's `id tree path` so the write cap can address it. */
46
- export function recordPath(pathIndex, node) {
47
- const n = node;
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
- pathIndex.set(n.id, n.path.raw);
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",
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.6 <1.0.0",
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.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
- _opts?: ReadOptions,
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(isIdleInput(node) ? null : run, [
54
- node?.id ?? null,
55
- edge,
56
- side,
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 (Array.isArray(data)) return { ...live, nodes: data as readonly AnyBoundNode<S>[] }
63
- return { ...live, node: (data ?? null) as AnyBoundNode<S> | null }
64
- }, [node, data, pending, error, refetch])
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
- deps: readonly unknown[],
103
+ key: string,
104
+ keepPrevious: boolean,
86
105
  ): AsyncState<T> & { refetch(): Promise<void> } {
87
- const [state, setState] = useState<AsyncState<T>>({ pending: run !== null, error: undefined })
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) => ({ ...s, pending: true, error: undefined }))
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) setState({ data, pending: false, error: undefined })
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) setState((s) => ({ data: s.data, pending: false, error }))
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
113
- }, deps)
114
-
115
- return { ...state, refetch }
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
  }
@@ -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 `id tree path` recorded (the write capability's index).
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 { recordPath, writableFor } from './write.js'
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, pathIndex } = writableFor(kernel, memory)
44
- const binding = tagBinding(createSchemaBoundView(writable, schema), schema, pathIndex)
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's `id tree path` is recorded (the write cap's index); the
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
- pathIndex: Map<string, string>,
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
- recordPath(pathIndex, node)
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
- recordPath(pathIndex, node)
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, pathIndex)
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 }
@@ -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 pathIndex: Map<string, string>
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 pathIndex = new Map<string, string>()
30
- entry = { writable: withMemoryWrites(kernel, memory, pathIndex), pathIndex }
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 indexes entities by tree path — so an `@<id>` ref is translated
40
- * through `pathIndex` (populated at bind time). A ref that is NOT indexed (a node
41
- * that was never read through a hook) falls back to the raw kernel-client floor:
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
- pathIndex: Map<string, string>,
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 path = treePathFor(pathIndex, ref)
58
- return path !== null ? memory.update(path, props) : rawUpdate(cls, ref, props)
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
- /** Clone the complete session capability object before overlaying memory writes. */
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
- const result = Object.create(Object.getPrototypeOf(base)) as Base & Extension
73
- Object.defineProperties(result, Object.getOwnPropertyDescriptors(base))
74
- Object.defineProperties(result, Object.getOwnPropertyDescriptors(extension))
75
- return result
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 treePathFor(pathIndex: Map<string, string>, ref: PathLike): string | null {
83
+ function indexedNode(nodeIndex: Map<string, Node>, ref: PathLike): Node | null {
80
84
  if (typeof ref !== 'string' || !ref.startsWith('@')) return null
81
- return pathIndex.get(ref.slice(1)) ?? null
85
+ return nodeIndex.get(ref.slice(1)) ?? null
82
86
  }
83
87
 
84
- /** Record a bound node's `id tree path` so the write cap can address it. */
85
- export function recordPath(pathIndex: Map<string, string>, node: unknown): void {
86
- const n = node as { id?: unknown; path?: { raw?: unknown } } | null
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
- pathIndex.set(n.id, n.path.raw)
94
+ nodeIndex.set(n.id, candidate as Node)
89
95
  }
90
96
  }