@selvajs/ui 5.0.0 → 6.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/compute/ComputeApp.svelte +15 -7
- package/dist/components/compute/ComputeApp.svelte.d.ts +1 -1
- package/dist/components/primitives/alert/alert.svelte.d.ts +8 -8
- package/dist/components/primitives/badge/badge.svelte.d.ts +14 -14
- package/dist/components/primitives/button/button.svelte.d.ts +41 -41
- package/dist/components/primitives/button-group/button-group.svelte.d.ts +8 -8
- package/dist/components/primitives/field/field.svelte.d.ts +5 -5
- package/dist/components/primitives/textarea/textarea.svelte +1 -2
- package/dist/components/viewer/SceneManager.svelte +75 -160
- package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
- package/dist/components/viewer/Viewer.svelte +20 -3
- package/dist/compute/useSolveSession.svelte.d.ts +12 -0
- package/dist/compute/useSolveSession.svelte.js +76 -0
- package/dist/external/storage.d.ts +1 -15
- package/dist/external/storage.js +4 -54
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7 -4
- package/dist/public.d.ts +4 -3
- package/dist/public.js +9 -5
- package/package.json +17 -15
- package/src/lib/components/compute/ComputeApp.svelte +15 -7
- package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
- package/src/lib/components/viewer/SceneManager.svelte +75 -160
- package/src/lib/components/viewer/Viewer.svelte +20 -3
- package/src/lib/compute/mesh-policy-wiring.test.ts +72 -0
- package/src/lib/compute/useSolveSession.svelte.ts +83 -0
- package/src/lib/external/storage.ts +12 -64
- package/src/lib/index.ts +15 -5
- package/src/lib/public.ts +17 -6
- package/dist/compute/computeThrottle.svelte.d.ts +0 -24
- package/dist/compute/computeThrottle.svelte.js +0 -82
- package/dist/compute/createSolveSession.svelte.d.ts +0 -66
- package/dist/compute/createSolveSession.svelte.js +0 -159
- package/dist/compute/solve-session-core.d.ts +0 -54
- package/dist/compute/solve-session-core.js +0 -87
- package/dist/compute/solveMemo.d.ts +0 -22
- package/dist/compute/solveMemo.js +0 -124
- package/dist/types/solveFn.d.ts +0 -25
- package/dist/types/solveFn.js +0 -1
- package/src/lib/compute/computeThrottle.svelte.ts +0 -109
- package/src/lib/compute/computeThrottle.test.ts +0 -106
- package/src/lib/compute/createSolveSession.svelte.ts +0 -239
- package/src/lib/compute/createSolveSession.test.ts +0 -168
- package/src/lib/compute/solve-session-core.test.ts +0 -153
- package/src/lib/compute/solve-session-core.ts +0 -122
- package/src/lib/compute/solveMemo.test.ts +0 -220
- package/src/lib/compute/solveMemo.ts +0 -140
- package/src/lib/external/storage.test.ts +0 -99
- package/src/lib/types/solveFn.ts +0 -29
package/dist/types/solveFn.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Result returned from a solve operation.
|
|
3
|
-
*
|
|
4
|
-
* @property outputs - Key-value pairs of computed results
|
|
5
|
-
* @property meshes - Optional array of 3D mesh data generated during computation
|
|
6
|
-
* @property errors - Optional array of error messages that occurred
|
|
7
|
-
* @property warnings - Optional array of warning messages from the computation
|
|
8
|
-
*/
|
|
9
|
-
export interface SolveResult {
|
|
10
|
-
outputs: Record<string, unknown>;
|
|
11
|
-
meshes?: any[];
|
|
12
|
-
errors?: string[];
|
|
13
|
-
warnings?: string[];
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Function type for running a computation with given input values.
|
|
17
|
-
*
|
|
18
|
-
* Implementations should listen to the abort signal and clean up resources
|
|
19
|
-
* when the signal is triggered (e.g., when the user cancels the operation).
|
|
20
|
-
*
|
|
21
|
-
* @param values - Input parameters for the computation
|
|
22
|
-
* @param signal - AbortSignal to cancel ongoing operations
|
|
23
|
-
* @returns Promise resolving to the computation result
|
|
24
|
-
*/
|
|
25
|
-
export type SolveFn = (values: Record<string, unknown>, signal: AbortSignal) => Promise<SolveResult>;
|
package/dist/types/solveFn.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -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
|
-
});
|