@selvajs/ui 5.0.1 → 6.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -9
  2. package/dist/components/compute/ComputeApp.svelte +36 -12
  3. package/dist/components/compute/ComputeApp.svelte.d.ts +14 -3
  4. package/dist/components/primitives/textarea/textarea.svelte +1 -2
  5. package/dist/components/viewer/SceneManager.svelte +75 -160
  6. package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
  7. package/dist/components/viewer/Viewer.svelte +22 -5
  8. package/dist/compute/useSolveSession.svelte.d.ts +12 -0
  9. package/dist/compute/useSolveSession.svelte.js +79 -0
  10. package/dist/external/storage.d.ts +1 -15
  11. package/dist/external/storage.js +4 -54
  12. package/dist/index.d.ts +4 -3
  13. package/dist/index.js +7 -4
  14. package/dist/public.d.ts +4 -3
  15. package/dist/public.js +9 -5
  16. package/package.json +9 -4
  17. package/src/lib/components/compute/ComputeApp.svelte +36 -12
  18. package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
  19. package/src/lib/components/viewer/SceneManager.svelte +75 -160
  20. package/src/lib/components/viewer/Viewer.svelte +22 -5
  21. package/src/lib/compute/useSolveSession.svelte.ts +86 -0
  22. package/src/lib/external/storage.ts +12 -64
  23. package/src/lib/index.ts +15 -5
  24. package/src/lib/public.ts +17 -6
  25. package/dist/compute/computeThrottle.svelte.d.ts +0 -24
  26. package/dist/compute/computeThrottle.svelte.js +0 -82
  27. package/dist/compute/createSolveSession.svelte.d.ts +0 -66
  28. package/dist/compute/createSolveSession.svelte.js +0 -159
  29. package/dist/compute/solve-session-core.d.ts +0 -54
  30. package/dist/compute/solve-session-core.js +0 -87
  31. package/dist/compute/solveMemo.d.ts +0 -22
  32. package/dist/compute/solveMemo.js +0 -124
  33. package/dist/types/solveFn.d.ts +0 -25
  34. package/dist/types/solveFn.js +0 -1
  35. package/src/lib/compute/computeThrottle.svelte.ts +0 -109
  36. package/src/lib/compute/computeThrottle.test.ts +0 -106
  37. package/src/lib/compute/createSolveSession.svelte.ts +0 -239
  38. package/src/lib/compute/createSolveSession.test.ts +0 -168
  39. package/src/lib/compute/solve-session-core.test.ts +0 -153
  40. package/src/lib/compute/solve-session-core.ts +0 -122
  41. package/src/lib/compute/solveMemo.test.ts +0 -220
  42. package/src/lib/compute/solveMemo.ts +0 -140
  43. package/src/lib/external/storage.test.ts +0 -99
  44. package/src/lib/schema/dynamic-value-list.test.ts +0 -150
  45. package/src/lib/schema/param-exporter.test.ts +0 -136
  46. package/src/lib/schema/traversal.test.ts +0 -92
  47. package/src/lib/schema/visibility-rules.test.ts +0 -173
  48. package/src/lib/types/solveFn.ts +0 -29
@@ -1,109 +0,0 @@
1
- /**
2
- * Compute throttle utility for managing async compute requests.
3
- *
4
- * - Only one request in-flight at a time
5
- * - Latest values always win (no queue)
6
- * - AbortController support to cancel stale requests
7
- * - Configurable timeout with automatic abort
8
- */
9
- interface ComputeThrottleOptions {
10
- timeout?: number;
11
- }
12
-
13
- /**
14
- * Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
15
- * Used only by callers without a deployment-specific limit (e.g. plugin-ui
16
- * over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
17
- * server config via `ComputeApp`'s `solveTimeoutMs` prop.
18
- */
19
- const DEFAULT_TIMEOUT_MS = 60_000;
20
-
21
- /**
22
- * Creates a throttled compute handler that keeps only one request in-flight.
23
- * If a new value arrives while one is running, it overwrites the single pending
24
- * slot — older pending values are dropped. The pending value runs once the
25
- * current request finishes (or aborts).
26
- */
27
- export function createComputeThrottle<T>(
28
- computeFn: (values: T, signal: AbortSignal) => Promise<void>,
29
- options: ComputeThrottleOptions = {}
30
- ): {
31
- trigger: (values: T) => void;
32
- readonly isComputing: boolean;
33
- readonly hasPending: boolean;
34
- cancel: () => void;
35
- } {
36
- const { timeout = DEFAULT_TIMEOUT_MS } = options;
37
-
38
- let isComputing = $state(false);
39
- let pendingValues = $state<T | null>(null);
40
- let currentAbortController: AbortController | null = null;
41
-
42
- function abortCurrent() {
43
- currentAbortController?.abort();
44
- currentAbortController = null;
45
- }
46
-
47
- async function executeCompute(values: T) {
48
- abortCurrent();
49
-
50
- currentAbortController = new AbortController();
51
- const { signal } = currentAbortController;
52
- const timeoutId = setTimeout(() => {
53
- // Cleared in `finally` on every other path, so firing means a genuine
54
- // timeout — the only signal for it (the abort itself is swallowed below).
55
- console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
56
- currentAbortController?.abort();
57
- }, timeout);
58
-
59
- isComputing = true;
60
- try {
61
- await computeFn(values, signal);
62
- } catch (err) {
63
- // AbortError is expected (timeout or cancel). Non-abort errors must be
64
- // handled inside computeFn — re-throwing here would produce an unhandled
65
- // rejection because executeCompute is always called fire-and-forget.
66
- if (!(err instanceof Error) || (err.name !== 'AbortError' && err.name !== 'TimeoutError')) {
67
- console.error('[computeThrottle] unhandled error in computeFn:', err);
68
- }
69
- } finally {
70
- clearTimeout(timeoutId);
71
- isComputing = false;
72
- currentAbortController = null;
73
-
74
- if (pendingValues !== null) {
75
- const next = pendingValues;
76
- pendingValues = null;
77
- executeCompute(next);
78
- }
79
- }
80
- }
81
-
82
- function trigger(values: T) {
83
- if (isComputing) {
84
- if (pendingValues !== null) {
85
- // Latest-wins: the previously-queued values are dropped, not solved.
86
- console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
87
- }
88
- pendingValues = values;
89
- } else {
90
- executeCompute(values);
91
- }
92
- }
93
-
94
- function cancel() {
95
- pendingValues = null;
96
- abortCurrent();
97
- }
98
-
99
- return {
100
- trigger,
101
- get isComputing() {
102
- return isComputing;
103
- },
104
- get hasPending() {
105
- return pendingValues !== null;
106
- },
107
- cancel
108
- };
109
- }
@@ -1,106 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { createComputeThrottle } from './computeThrottle.svelte';
3
-
4
- // Pins the throttle state machine: single-in-flight, latest-pending-wins, abort on
5
- // re-trigger, timeout -> abort, and cancel clearing the pending slot. These are the
6
- // non-obvious paths (the finally-block re-entry, the abort cleanup) where bugs hide.
7
- // `computeThrottle.svelte.ts` is a rune module, so these run under the svelte vitest
8
- // plugin (see vitest.config.ts).
9
-
10
- // A controllable computeFn: each call exposes a resolver so the test drives when a
11
- // solve "finishes", plus records the values and the signal it was given.
12
- function deferredComputeFn() {
13
- const calls: { values: unknown; signal: AbortSignal; resolve: () => void }[] = [];
14
- const fn = (values: unknown, signal: AbortSignal) =>
15
- new Promise<void>((resolve) => {
16
- calls.push({ values, signal, resolve });
17
- });
18
- return { fn, calls };
19
- }
20
-
21
- const tick = () => new Promise((r) => setTimeout(r, 0));
22
-
23
- describe('createComputeThrottle', () => {
24
- it('runs immediately when idle and reports isComputing across the call', async () => {
25
- const { fn, calls } = deferredComputeFn();
26
- const t = createComputeThrottle<number>(fn);
27
-
28
- expect(t.isComputing).toBe(false);
29
- t.trigger(1);
30
- expect(t.isComputing).toBe(true);
31
- expect(calls).toHaveLength(1);
32
- expect(calls[0].values).toBe(1);
33
-
34
- calls[0].resolve();
35
- await tick();
36
- expect(t.isComputing).toBe(false);
37
- expect(t.hasPending).toBe(false);
38
- });
39
-
40
- it('keeps only one in flight; latest pending value wins, intermediates dropped', async () => {
41
- const { fn, calls } = deferredComputeFn();
42
- const t = createComputeThrottle<number>(fn);
43
-
44
- t.trigger(1); // runs
45
- t.trigger(2); // queued
46
- t.trigger(3); // overwrites the single pending slot — 2 is dropped
47
- expect(calls).toHaveLength(1);
48
- expect(t.hasPending).toBe(true);
49
-
50
- calls[0].resolve(); // first finishes -> pending (3) runs
51
- await tick();
52
- expect(calls).toHaveLength(2);
53
- expect(calls[1].values).toBe(3); // not 2
54
- expect(t.hasPending).toBe(false);
55
- });
56
-
57
- it('aborts the in-flight request when a new one starts (after the pending re-entry)', async () => {
58
- const { fn, calls } = deferredComputeFn();
59
- const t = createComputeThrottle<number>(fn);
60
-
61
- t.trigger(1);
62
- t.trigger(2); // pending
63
- const firstSignal = calls[0].signal;
64
-
65
- calls[0].resolve(); // finishes -> re-enters executeCompute(2), which aborts the (already-cleared) prior
66
- await tick();
67
- // The second run gets a fresh, non-aborted signal.
68
- expect(calls).toHaveLength(2);
69
- expect(calls[1].signal.aborted).toBe(false);
70
- expect(firstSignal).not.toBe(calls[1].signal);
71
- });
72
-
73
- it('cancel() clears the pending slot and aborts the in-flight signal', async () => {
74
- const { fn, calls } = deferredComputeFn();
75
- const t = createComputeThrottle<number>(fn);
76
-
77
- t.trigger(1);
78
- t.trigger(2); // pending
79
- expect(t.hasPending).toBe(true);
80
-
81
- t.cancel();
82
- expect(t.hasPending).toBe(false);
83
- expect(calls[0].signal.aborted).toBe(true);
84
-
85
- // Resolving the aborted call must NOT start the (now-cleared) pending one.
86
- calls[0].resolve();
87
- await tick();
88
- expect(calls).toHaveLength(1);
89
- });
90
-
91
- describe('timeout', () => {
92
- beforeEach(() => vi.useFakeTimers());
93
- afterEach(() => vi.useRealTimers());
94
-
95
- it('aborts the in-flight request when the timeout elapses', async () => {
96
- const { fn, calls } = deferredComputeFn();
97
- const t = createComputeThrottle<number>(fn, { timeout: 1000 });
98
-
99
- t.trigger(1);
100
- expect(calls[0].signal.aborted).toBe(false);
101
-
102
- vi.advanceTimersByTime(1000);
103
- expect(calls[0].signal.aborted).toBe(true);
104
- });
105
- });
106
- });
@@ -1,239 +0,0 @@
1
- // Reactive Solve Session (see CONTEXT.md): the $state-backed shell over the pure
2
- // transition logic in solve-session-core.ts. It owns the live values/flags, delegates
3
- // every transition to the core, and drives solves through a transport-agnostic
4
- // SolveDriver. A completed solve re-enters via report().
5
-
6
- import type { UISchema } from '@selvajs/schemas';
7
- import { readExternalValue } from '../external/storage';
8
- import type { SolveFn, SolveResult } from '../types/solveFn';
9
- import { createComputeThrottle } from './computeThrottle.svelte';
10
- import { createSolveMemo } from './solveMemo';
11
- import {
12
- buildInitialValues,
13
- makeInitialFlags,
14
- applyValueChange,
15
- applySolveResult,
16
- pickInputValues,
17
- type SolveSessionState
18
- } from './solve-session-core';
19
-
20
- /**
21
- * The transport behind a Solve Session. Knows how to start and cancel a solve and
22
- * reports its in-flight state. It does NOT return outputs — those come back via the
23
- * session's report() so push transports (WebSocket) fit without contortion.
24
- */
25
- export interface SolveDriver {
26
- solve(values: Record<string, unknown>): void;
27
- cancel(): void;
28
- readonly isSolving: boolean;
29
- /**
30
- * Drops any cached solve results the driver holds. Optional — only drivers with a
31
- * client-side memo (the request/response driver) implement it. Called on rebuild so a
32
- * definition swap can't serve a stale result from a prior definition's input space.
33
- */
34
- clearCache?(): void;
35
- }
36
-
37
- export interface SolveSession {
38
- readonly values: Record<string, unknown>;
39
- readonly error: string;
40
- readonly computeErrors: string[];
41
- readonly computeWarnings: string[];
42
- readonly meshes: unknown[];
43
- readonly hasPendingChanges: boolean;
44
- readonly hasNeverSolved: boolean;
45
- readonly isSolving: boolean;
46
- /**
47
- * Records a value change and dispatches a solve unless the schema is manual-solve.
48
- * `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
49
- * value list pruning a vanished selection), so the output can't lag behind the new value.
50
- */
51
- setValue(id: string, value: unknown, forceSolve?: boolean): void;
52
- /** Explicit "calculate" — dispatches a solve with the current values. */
53
- solve(): void;
54
- /** Merges incoming values, then solves (auto) or marks dirty (manual). */
55
- loadValues(incoming: Record<string, unknown>): void;
56
- /** Re-seed for a new active definition: rebuild values, clear outputs, gate the solve. */
57
- rebuild(schema: UISchema, scopeKey: string): void;
58
- /** Feed a completed solve result back into the session (called by the driver/host). */
59
- report(result: SolveResult): void;
60
- /** Report a solve failure (transport/solver error) — surfaces in `error`. */
61
- reportError(message: string): void;
62
- }
63
-
64
- export interface SolveSessionArgs {
65
- schema: UISchema;
66
- scopeKey: string;
67
- driver: SolveDriver;
68
- }
69
-
70
- export function createSolveSession(args: SolveSessionArgs): SolveSession {
71
- let currentSchema = args.schema;
72
-
73
- const flags = makeInitialFlags(currentSchema?.instanceSolve);
74
- const state = $state<SolveSessionState>({
75
- values: buildInitialValues(currentSchema, args.scopeKey, readExternalValue),
76
- error: '',
77
- computeErrors: [],
78
- computeWarnings: [],
79
- meshes: [],
80
- pendingValues: {},
81
- hasPendingChanges: flags.hasPendingChanges,
82
- hasNeverSolved: flags.hasNeverSolved
83
- });
84
-
85
- function dispatch() {
86
- // Input values only: outputs merged into state.values (for widgets that read
87
- // them, e.g. dynamic value lists) must not be echoed back to the transport.
88
- args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
89
- }
90
-
91
- return {
92
- get values() {
93
- return state.values;
94
- },
95
- get error() {
96
- return state.error;
97
- },
98
- get computeErrors() {
99
- return state.computeErrors;
100
- },
101
- get computeWarnings() {
102
- return state.computeWarnings;
103
- },
104
- get meshes() {
105
- return state.meshes;
106
- },
107
- get hasPendingChanges() {
108
- return state.hasPendingChanges;
109
- },
110
- get hasNeverSolved() {
111
- return state.hasNeverSolved;
112
- },
113
- get isSolving() {
114
- return args.driver.isSolving;
115
- },
116
-
117
- setValue(id, value, forceSolve = false) {
118
- const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
119
- if (shouldSolve || forceSolve) {
120
- // A forced solve reconciles the deferred output; clear the dirty flags it raised.
121
- if (forceSolve && !shouldSolve) {
122
- state.pendingValues = {};
123
- state.hasPendingChanges = false;
124
- }
125
- dispatch();
126
- }
127
- },
128
-
129
- solve() {
130
- dispatch();
131
- },
132
-
133
- loadValues(incoming) {
134
- Object.assign(state.values, incoming);
135
- if (currentSchema?.instanceSolve !== false) {
136
- dispatch();
137
- } else {
138
- state.hasPendingChanges = true;
139
- }
140
- },
141
-
142
- rebuild(schema, scopeKey) {
143
- currentSchema = schema;
144
- // Drop the driver's result memo: the new definition has its own input space, so
145
- // a matching input key from the prior definition must not serve its stale result.
146
- args.driver.clearCache?.();
147
- state.meshes = [];
148
- state.error = '';
149
- state.computeErrors = [];
150
- state.computeWarnings = [];
151
- state.pendingValues = {};
152
- state.values = buildInitialValues(schema, scopeKey, readExternalValue);
153
- const f = makeInitialFlags(schema?.instanceSolve);
154
- state.hasPendingChanges = f.hasPendingChanges;
155
- state.hasNeverSolved = f.hasNeverSolved;
156
- if (schema && Object.keys(state.values).length > 0 && schema.instanceSolve !== false) {
157
- dispatch();
158
- }
159
- },
160
-
161
- report(result) {
162
- applySolveResult(state, result);
163
- },
164
-
165
- reportError(message) {
166
- state.error = message;
167
- }
168
- };
169
- }
170
-
171
- /** The slice of a SolveSession a driver feeds completed/failed solves back into. */
172
- export interface SolveReporter {
173
- report(result: SolveResult): void;
174
- reportError(message: string): void;
175
- }
176
-
177
- /**
178
- * Request/response Solve Driver: wraps createComputeThrottle around a SolveFn and feeds
179
- * the resolved result back through the reporter. One solve in flight at a time; the
180
- * latest triggered values win. Used by ComputeApp (Rhino.Compute over HTTP).
181
- *
182
- * Because the session and driver reference each other, the host passes the reporter
183
- * lazily (`() => session`) so it can construct the session with the driver in hand.
184
- */
185
- export function createRequestResponseDriver(
186
- onSolve: SolveFn,
187
- getReporter: () => SolveReporter,
188
- options: { timeout?: number } = {}
189
- ): SolveDriver {
190
- // M2: a small LRU memoizing completed solves by their input values. A slider dragged
191
- // back to a value already solved this session reports instantly without a network
192
- // round-trip. The check lives inside the throttled computeFn so the throttle's
193
- // latest-wins ordering still holds — a hit only serves after the throttle picks these
194
- // values as the ones to run.
195
- const memo = createSolveMemo();
196
-
197
- const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
198
- const cached = memo.get(values);
199
- if (cached !== undefined) {
200
- if (signal.aborted) return;
201
- getReporter().report(cached);
202
- return;
203
- }
204
- try {
205
- const result = await onSolve(values, signal);
206
- if (signal.aborted) {
207
- // Discarded on purpose (superseded/cancelled) — never memoized or reported.
208
- console.debug('[Compute/session] solve completed after abort — result discarded');
209
- return;
210
- }
211
- memo.set(values, result);
212
- getReporter().report(result);
213
- } catch (err) {
214
- if (signal.aborted) {
215
- console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
216
- return;
217
- }
218
- // reportError only sets reactive state; without this line a transport
219
- // failure leaves no console trace at all.
220
- console.warn('[Compute/session] solve failed:', err);
221
- getReporter().reportError(err instanceof Error ? err.message : String(err));
222
- }
223
- }, options);
224
-
225
- return {
226
- solve(values) {
227
- throttle.trigger(values);
228
- },
229
- cancel() {
230
- throttle.cancel();
231
- },
232
- get isSolving() {
233
- return throttle.isComputing;
234
- },
235
- clearCache() {
236
- memo.clear();
237
- }
238
- };
239
- }
@@ -1,168 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import {
3
- createSolveSession,
4
- createRequestResponseDriver,
5
- type SolveDriver,
6
- type SolveReporter
7
- } from './createSolveSession.svelte';
8
- import type { UISchema } from '@selvajs/schemas';
9
- import type { SolveResult } from '../types/solveFn';
10
-
11
- // Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
12
- // added for dynamic-value-list reconciliation. The pure transition logic is pinned in
13
- // solve-session-core.test.ts; this file pins how the rune shell turns those decisions into
14
- // driver.solve() calls. Rune module, so it runs under the svelte vitest plugin.
15
-
16
- function schema(instanceSolve?: boolean): UISchema {
17
- return {
18
- id: 'test',
19
- name: 'Test',
20
- instanceSolve,
21
- inputs: [{ id: 'a', paramId: 'a', paramType: 'text', default: 'x' }],
22
- outputs: [{ id: 'out' }],
23
- layout: { type: 'flat', groups: [{ items: [] }] }
24
- } as unknown as UISchema;
25
- }
26
-
27
- // Records every solve dispatch with the snapshot of values it received.
28
- function recordingDriver(): SolveDriver & { solves: Record<string, unknown>[] } {
29
- const solves: Record<string, unknown>[] = [];
30
- return {
31
- solves,
32
- solve(values) {
33
- solves.push(values);
34
- },
35
- cancel() {},
36
- get isSolving() {
37
- return false;
38
- }
39
- };
40
- }
41
-
42
- describe('createSolveSession.setValue', () => {
43
- it('auto-solve mode dispatches on every value change', () => {
44
- const driver = recordingDriver();
45
- const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
46
- session.setValue('a', 'y');
47
- expect(driver.solves.length).toBe(1);
48
- expect(driver.solves[0].a).toBe('y');
49
- });
50
-
51
- it('manual mode defers: marks pending, no dispatch', () => {
52
- const driver = recordingDriver();
53
- const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
54
- session.setValue('a', 'y');
55
- expect(driver.solves.length).toBe(0);
56
- expect(session.hasPendingChanges).toBe(true);
57
- });
58
-
59
- it('forceSolve dispatches even in manual mode and clears the dirty flags', () => {
60
- const driver = recordingDriver();
61
- const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
62
- // Reconcile a system-initiated change (e.g. a pruned dynamic-list selection).
63
- session.setValue('a', 'reconciled', true);
64
- expect(driver.solves.length).toBe(1);
65
- expect(driver.solves[0].a).toBe('reconciled');
66
- // The forced solve produces the matching output, so nothing is left pending.
67
- expect(session.hasPendingChanges).toBe(false);
68
- });
69
-
70
- it('forceSolve in auto mode still dispatches exactly once', () => {
71
- const driver = recordingDriver();
72
- const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
73
- session.setValue('a', 'y', true);
74
- expect(driver.solves.length).toBe(1);
75
- });
76
-
77
- it('never echoes output-keyed values back to the driver', () => {
78
- const driver = recordingDriver();
79
- const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
80
- // A solve result merges outputs into the session's values map (how widgets
81
- // like dynamic value lists read them) — e.g. a multi-MB options payload.
82
- session.report({ outputs: { out: { options: { huge: 'payload' } } } });
83
- session.setValue('a', 'y');
84
- expect(driver.solves.length).toBe(1);
85
- expect(driver.solves[0].a).toBe('y');
86
- // The output entry lives in session.values for widgets…
87
- expect(session.values.out).toBeDefined();
88
- // …but must not travel back through the transport.
89
- expect(driver.solves[0]).not.toHaveProperty('out');
90
- });
91
- });
92
-
93
- // M2: the request/response driver's client-side result memo. Verifies a slider returning
94
- // to a solved value serves from memory (no onSolve call) and that a definition rebuild
95
- // drops the memo so a stale result can't cross the swap.
96
- describe('createRequestResponseDriver — client memo', () => {
97
- // Collects reported results so the memo hit/miss can be observed without a session.
98
- function collectingReporter(): SolveReporter & { reports: SolveResult[]; errors: string[] } {
99
- const reports: SolveResult[] = [];
100
- const errors: string[] = [];
101
- return {
102
- reports,
103
- errors,
104
- report: (r) => reports.push(r),
105
- reportError: (m) => errors.push(m)
106
- };
107
- }
108
-
109
- // Lets the throttle's fire-and-forget executeCompute settle.
110
- const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
111
-
112
- it('serves a repeated input from the memo without calling onSolve again', async () => {
113
- const onSolve = vi.fn(
114
- async (values: Record<string, unknown>): Promise<SolveResult> => ({
115
- outputs: { echo: values.a }
116
- })
117
- );
118
- const reporter = collectingReporter();
119
- const driver = createRequestResponseDriver(onSolve, () => reporter);
120
-
121
- driver.solve({ a: 1 });
122
- await flush();
123
- driver.solve({ a: 2 });
124
- await flush();
125
- driver.solve({ a: 1 }); // repeat — should hit the memo
126
- await flush();
127
-
128
- expect(onSolve).toHaveBeenCalledTimes(2); // only the two distinct inputs
129
- expect(reporter.reports).toHaveLength(3); // but all three solves reported
130
- expect(reporter.reports[2]).toEqual({ outputs: { echo: 1 } });
131
- });
132
-
133
- it('clearCache drops the memo so the next identical solve re-runs', async () => {
134
- const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
135
- const reporter = collectingReporter();
136
- const driver = createRequestResponseDriver(onSolve, () => reporter);
137
-
138
- driver.solve({ a: 1 });
139
- await flush();
140
- driver.clearCache?.();
141
- driver.solve({ a: 1 }); // memo cleared → real solve again
142
- await flush();
143
-
144
- expect(onSolve).toHaveBeenCalledTimes(2);
145
- });
146
-
147
- it('session.rebuild clears the driver memo (no cross-definition stale hit)', async () => {
148
- const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
149
- const reporter = collectingReporter();
150
- let clears = 0;
151
- // Wrap the real driver to observe clearCache being invoked from rebuild.
152
- const base = createRequestResponseDriver(onSolve, () => reporter);
153
- const driver: SolveDriver = {
154
- solve: base.solve,
155
- cancel: base.cancel,
156
- get isSolving() {
157
- return base.isSolving;
158
- },
159
- clearCache() {
160
- clears += 1;
161
- base.clearCache?.();
162
- }
163
- };
164
- const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
165
- session.rebuild(schema(true), 's2');
166
- expect(clears).toBe(1);
167
- });
168
- });