@selvajs/solve 0.2.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.
@@ -0,0 +1,407 @@
1
+ import { GrasshopperClient, SolveScheduler, GrasshopperComputeResponse, SolveDefinition, DataTree, InputParam } from '@selvajs/compute';
2
+ import { SchemaInput } from '@selvajs/schemas';
3
+ export { S as SolveInput } from './solve-input-CqYtetTA.cjs';
4
+
5
+ /**
6
+ * Opaque identity of a compute server — construct only via `serverIdentity()`.
7
+ * Kept opaque so "a server is now a pool of URLs behind one id" stays additive.
8
+ */
9
+ type ServerIdentity = string & {
10
+ readonly __brand: 'ServerIdentity';
11
+ };
12
+ /** Minimal resolved-server shape the cache needs (a subset of the app's `ComputeServerConfig`). */
13
+ interface ResolvedServer {
14
+ id: string;
15
+ serverUrl: string;
16
+ /** Sent as `RhinoComputeKey`. */
17
+ apiKey?: string;
18
+ }
19
+ /** Derive the opaque cache identity from a resolved server. Identity is the `id`. */
20
+ declare function serverIdentity(server: Pick<ResolvedServer, 'id'>): ServerIdentity;
21
+ interface CachedClient {
22
+ client: GrasshopperClient;
23
+ scheduler: SolveScheduler;
24
+ /**
25
+ * Last Server-Timing decode/solve/encode, written by onServerTiming.
26
+ * The scheduler runs up to `maxConcurrentSolves` at once, so `last` alone
27
+ * can't be trusted per-request: callers snapshot `seq` before their solve
28
+ * and only attribute `last` to themselves if exactly one write happened
29
+ * since (see the guard in `runSolvePipeline`), dropping it otherwise
30
+ * rather than risk misattributing another request's timing.
31
+ */
32
+ rhinoTiming: {
33
+ last: {
34
+ decode: number;
35
+ solve: number;
36
+ encode: number;
37
+ } | null;
38
+ seq: number;
39
+ };
40
+ /**
41
+ * Same snapshot-and-attribute pattern as `rhinoTiming`, for the scheduler's
42
+ * onSettle cache verdict. `seq` increments on every settle (success or
43
+ * error); `last` is written only on success.
44
+ */
45
+ solveMeta: {
46
+ last: {
47
+ fromCache: boolean;
48
+ definitionReuploaded?: boolean;
49
+ } | null;
50
+ seq: number;
51
+ };
52
+ }
53
+ /** Config injected by the consuming app, so this module stays env-agnostic and testable. */
54
+ interface ClientCacheConfig {
55
+ /** Per-solve timeout forwarded to the scheduler (`ComputeLimits.maxSolveDurationMs`). */
56
+ maxSolveDurationMs: number;
57
+ /** Ask Rhino.Compute to cache solve results and return them on identical repeats. */
58
+ cachesolve: boolean;
59
+ /** Also cache solves that reported GH errors (only meaningful with `cachesolve`). */
60
+ cacheerroredsolves: boolean;
61
+ /** Reference large definitions by server cache key (pointer) instead of re-uploading. */
62
+ reuseServerDefinitionCache: boolean;
63
+ /**
64
+ * Max in-flight solves per compute server (scheduler `maxConcurrent`; excess
65
+ * FIFO-queues). Size to the server's `compute.geometry` child count
66
+ * (`ComputeLimits.computeMaxConcurrentSolves`).
67
+ */
68
+ maxConcurrentSolves: number;
69
+ /**
70
+ * Backpressure — max solves that may WAIT in the FIFO queue (excludes the
71
+ * in-flight `maxConcurrentSolves`). `0` = unbounded (`ComputeLimits.
72
+ * computeMaxQueueDepth`); a full queue sheds new solves with `QUEUE_FULL`.
73
+ */
74
+ maxQueueDepth: number;
75
+ /**
76
+ * Backpressure — max ms a solve may sit queued before executing; `0` = no
77
+ * deadline (`ComputeLimits.computeQueueWaitMs`). A too-long wait sheds with
78
+ * `QUEUE_TIMEOUT`.
79
+ */
80
+ queueWaitMs: number;
81
+ /**
82
+ * Byte budget for this client's in-process solve cache, evicted LRU alongside
83
+ * its entry-count cap. Applies per warm client — total worst-case heap is this
84
+ * × `maxCachedClients`. `0` disables the cache entirely
85
+ * (`ComputeLimits.computeSolveCacheBytes`, env `COMPUTE_SOLVE_CACHE_MB`).
86
+ */
87
+ responseCacheMaxBytes: number;
88
+ /** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
89
+ debug: boolean;
90
+ /** VERBOSE lib-level logging (full solve request/response incl. geometry). */
91
+ debugVerbose: boolean;
92
+ /** Max distinct warm clients before the LRU evicts the oldest. Default 16. */
93
+ maxCachedClients?: number;
94
+ /** Sink for the concise debug lines (the app wires `console.log`). */
95
+ onDebugLog?: (message: string) => void;
96
+ }
97
+ interface ClientCache {
98
+ /**
99
+ * Get (or create) the warm client + scheduler for a resolved compute server,
100
+ * keyed by its `id`. `definitionGuid`, when present, is stamped as the
101
+ * `X-Selva-Definition` header on this client's outbound solve/IO requests —
102
+ * inert routing/telemetry metadata until a pool router exists.
103
+ */
104
+ getClient(server: ResolvedServer, opts?: {
105
+ definitionGuid?: string;
106
+ }): Promise<CachedClient>;
107
+ /** Dispose and drop the warm client for `id`, so the next request rebuilds against fresh connection details. */
108
+ evict(id: string | ServerIdentity): void;
109
+ /**
110
+ * Solve-cache counters summed across every warm client. `warmClients` is
111
+ * reported alongside since each client owns its own cache. Counters die
112
+ * with the client that owns them, so totals can fall over time.
113
+ */
114
+ solveCacheStats(): SolveCacheStats;
115
+ /** Dispose every warm client. Test seam / shutdown hook. */
116
+ disposeAll(): void;
117
+ }
118
+ /** Aggregate solve-cache counters across the warm clients (see `solveCacheStats`). */
119
+ interface SolveCacheStats {
120
+ warmClients: number;
121
+ entries: number;
122
+ bytes: number;
123
+ hits: number;
124
+ misses: number;
125
+ /** Entries dropped under size/byte pressure (not TTL expiry or replacement). */
126
+ evictions: number;
127
+ }
128
+ declare function createClientCache(config: ClientCacheConfig): ClientCache;
129
+
130
+ /**
131
+ * In-process cache of `.gh` definition bytes, keyed by immutable **version id**.
132
+ *
133
+ * Turns an eager `storage.get(fileKey)` on every solve request into a lazy
134
+ * `DefinitionRef.load()`: the scheduler calls `load()` only when an upload is
135
+ * unavoidable (a pointer-known re-solve never touches storage or this cache).
136
+ *
137
+ * The key MUST be the version id, never a `fileKey` — a delete-latest-then-
138
+ * reupload can reuse a `fileKey` for different content, which would silently
139
+ * serve one version's bytes for another with no diagnostic. Callers pass
140
+ * `version.id`, and the returned `DefinitionRef.key` is that same id, so the
141
+ * scheduler's own result/pointer cache keys on the same immutable identity.
142
+ *
143
+ * Eviction is LRU by total byte budget, not entry count — definitions range
144
+ * from tens of KB to hundreds of MB, so a count cap would either pin
145
+ * gigabytes or evict uselessly. `COMPUTE_DEFINITION_CACHE_MB` sets the
146
+ * budget (see `createDefinitionByteCache`). No TTL: version ids are
147
+ * immutable, so a cached entry can never go stale. An entry larger than the
148
+ * whole budget is served but never retained.
149
+ */
150
+ /**
151
+ * Per-`getOrLoad` outcome, observable after the scheduler has (or hasn't)
152
+ * called `load()` — lets the solve pipeline emit a `def_bytes` Server-Timing
153
+ * verdict (`skipped` / `hit` / `miss`).
154
+ */
155
+ interface ByteRefOutcome {
156
+ loaded: boolean;
157
+ /** When `loaded`, whether the bytes came from a warm cache entry. */
158
+ fromCache: boolean;
159
+ }
160
+ /** A definition reference shaped for `@selvajs/compute`'s `DefinitionRef`. */
161
+ interface ByteCacheRef {
162
+ key: string;
163
+ load: () => Promise<Uint8Array>;
164
+ outcome: ByteRefOutcome;
165
+ }
166
+ /** Hit/miss/eviction counters for observability (Server-Timing, admin debug). */
167
+ interface ByteCacheStats {
168
+ hits: number;
169
+ misses: number;
170
+ /** Entries dropped by the byte-budget LRU. */
171
+ evictions: number;
172
+ entries: number;
173
+ bytes: number;
174
+ }
175
+ interface DefinitionByteCache {
176
+ /**
177
+ * `DefinitionRef` whose `load()` serves `versionId`'s bytes from the cache
178
+ * when warm, otherwise calls `load` and caches the result. Cheap to build
179
+ * and moves no bytes until `load()` runs.
180
+ */
181
+ getOrLoad(versionId: string, load: () => Promise<Uint8Array>): ByteCacheRef;
182
+ stats(): ByteCacheStats;
183
+ /** Test seam / eviction on definition delete if ever needed. */
184
+ clear(): void;
185
+ }
186
+ /**
187
+ * @param maxBytes total retained-byte budget; `0` (or negative) disables caching
188
+ * entirely — every `load()` calls the loader and nothing is retained.
189
+ */
190
+ declare function createDefinitionByteCache(maxBytes: number): DefinitionByteCache;
191
+
192
+ /**
193
+ * Transport-agnostic solve pipeline: given an already-resolved solve context
194
+ * (`.gh` bytes, input params + user values, a warm `SolveScheduler`), runs the
195
+ * framework-free half of a solve and returns a discriminated {@link SolveOutcome}
196
+ * instead of throwing for expected failures. Nothing here touches auth, the
197
+ * database, share tokens, rate limits, or metric sinks — those stay app policy
198
+ * in the route that calls this.
199
+ */
200
+
201
+ /** Compute-response contract version. Bump (and document the change) whenever the envelope's shape changes in a way a consumer could observe. */
202
+ declare const COMPUTE_CONTRACT_VERSION: 1;
203
+ declare const COMPUTE_VERSION_HEADER = "X-Selva-Compute-Version";
204
+ type PipelineInput = SchemaInput & {
205
+ minimum?: number;
206
+ maximum?: number;
207
+ stepSize?: number;
208
+ };
209
+ interface SolvePipelineArgs {
210
+ /**
211
+ * The definition to solve. Either raw `.gh` bytes, or a `DefinitionRef` (from
212
+ * the definition-byte cache) whose bytes the scheduler materializes ONLY when
213
+ * an upload is unavoidable — a pointer-known solve of a `DefinitionRef` moves
214
+ * zero bytes.
215
+ */
216
+ definitionSource: SolveDefinition;
217
+ /**
218
+ * When `definitionSource` is a byte-cache `DefinitionRef`, its mutable outcome
219
+ * so the pipeline can emit the `def_bytes` Server-Timing verdict. Omit for
220
+ * raw-bytes solves.
221
+ */
222
+ byteRefOutcome?: ByteRefOutcome;
223
+ /** Persisted input params; only those with a `paramType` are sent to the solve. */
224
+ inputs: PipelineInput[];
225
+ /**
226
+ * A tree the caller already built with {@link buildSolveInputTree}. When present
227
+ * the pipeline skips its own build and solves this exact object.
228
+ *
229
+ * Needed whenever the caller coalesces concurrent solves: a single-flight key
230
+ * derived from raw `{inputs, values}` would split two requests that transform
231
+ * to the same tree, which is the identity the scheduler actually caches on.
232
+ */
233
+ inputTree?: DataTree[];
234
+ /** User-chosen values keyed by input id; missing keys fall back to the schema default. */
235
+ values: Record<string, unknown>;
236
+ client: CachedClient;
237
+ responseMaxBytes: number;
238
+ /** Used only to phrase the timeout message — the scheduler enforces the deadline itself. */
239
+ maxSolveDurationMs: number;
240
+ /** Client's `Accept-Encoding`; gzip is applied only when it advertises `gzip`. */
241
+ acceptEncoding: string;
242
+ /**
243
+ * Request abort signal, forwarded to the scheduler so a client disconnect
244
+ * cancels the upstream compute call. Its `aborted` flag also disambiguates a
245
+ * client disconnect from the scheduler's own deadline firing.
246
+ */
247
+ signal: AbortSignal;
248
+ /**
249
+ * Wall-clock origin (`performance.now()` captured at the top of the request)
250
+ * so the pipeline's `load`/`total` phase timings line up with the caller's
251
+ * pre-solve prep.
252
+ */
253
+ loadStartMs: number;
254
+ /** Pre-solve "load" phase duration the caller already measured (auth + DB + fetch). */
255
+ defLoadMs: number;
256
+ /**
257
+ * Pre-solve prep sub-phase marks (`[label, ms]`), surfaced verbatim as `p_*`
258
+ * Server-Timing entries.
259
+ */
260
+ prepMarks?: [string, number][];
261
+ }
262
+ /** Phase timings the pipeline measured; the caller uses them for debug logging. */
263
+ interface SolvePhaseMetrics {
264
+ treeBuildMs: number;
265
+ solveMs: number;
266
+ serializeMs: number;
267
+ gzipMs: number;
268
+ serverTotalMs: number;
269
+ serializedBytes: number;
270
+ /** Null when compression was skipped. */
271
+ compressedBytes: number | null;
272
+ }
273
+ /** A ready-to-send response: body + headers + the solve result + phase metrics. */
274
+ interface SolveEnvelope {
275
+ /** A gzip `Uint8Array` when `encoding === 'gzip'`, else the JSON string. */
276
+ body: string | Uint8Array;
277
+ encoding?: 'gzip';
278
+ headers: Record<string, string>;
279
+ result: GrasshopperComputeResponse;
280
+ metrics: SolvePhaseMetrics;
281
+ }
282
+ /**
283
+ * Discriminated result. `ok` carries the envelope; every other variant names an
284
+ * expected failure the transport maps to a status code (the app route maps
285
+ * timeout→504, client_abort→499, too_large→413, shed→503+Retry-After;
286
+ * `compute_error` re-surfaces the original error for the generic 500/503 path).
287
+ * `durationMs` on the error variants is the solve wall time up to the failure,
288
+ * for the metric record.
289
+ */
290
+ type SolveOutcome = {
291
+ kind: 'ok';
292
+ envelope: SolveEnvelope;
293
+ solveMs: number;
294
+ errorCount: number;
295
+ warningCount: number;
296
+ } | {
297
+ kind: 'timeout';
298
+ durationMs: number;
299
+ message: string;
300
+ } | {
301
+ kind: 'client_abort';
302
+ durationMs: number;
303
+ } | {
304
+ kind: 'too_large';
305
+ }
306
+ /**
307
+ * Scheduler backpressure shed the solve before it executed — the per-server
308
+ * queue was full (`QUEUE_FULL`) or it sat queued past the wait deadline
309
+ * (`QUEUE_TIMEOUT`). Retryable: the route maps this to 503 + `Retry-After`.
310
+ * `retryAfterSeconds` is a suggested backoff hint for the client.
311
+ */
312
+ | {
313
+ kind: 'shed';
314
+ durationMs: number;
315
+ reason: 'queue_full' | 'queue_timeout';
316
+ retryAfterSeconds: number;
317
+ message: string;
318
+ } | {
319
+ kind: 'compute_error';
320
+ durationMs: number;
321
+ error: unknown;
322
+ };
323
+ /**
324
+ * Build the transformed input tree — the exact object handed to the scheduler.
325
+ * `runSolvePipeline` calls this itself; a caller only needs it directly to see
326
+ * the tree before the pipeline runs (see {@link SolvePipelineArgs.inputTree}).
327
+ */
328
+ declare function buildSolveInputTree(inputs: PipelineInput[], values: Record<string, unknown>): DataTree[];
329
+ declare function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOutcome>;
330
+ /**
331
+ * Re-key a coalesced envelope to a single waiter's `Accept-Encoding`.
332
+ *
333
+ * The single-flight coalescer runs ONE pipeline execution for N identical
334
+ * concurrent solves and hands every waiter the same {@link SolveEnvelope}. That
335
+ * envelope's wire form is baked from the FIRST caller's `Accept-Encoding`, so a
336
+ * later waiter with a different `Accept-Encoding` would otherwise get a body
337
+ * labelled with the wrong encoding (`Vary` can't help — this is one object
338
+ * shared across waiters, not a cache lookup). Encoding is deliberately not part
339
+ * of the coalesce key, so mixed clients still coalesce; this adapts the shared
340
+ * result to each waiter instead.
341
+ *
342
+ * Only the correctness-critical direction is adapted: a gzip envelope served to
343
+ * a non-gzip waiter is gunzipped back to JSON. The reverse (plain JSON to a
344
+ * gzip-capable waiter) is left uncompressed — correct, just not maximally small.
345
+ */
346
+ declare function adaptEnvelopeToEncoding(envelope: SolveEnvelope, acceptEncoding: string): {
347
+ body: string | Uint8Array;
348
+ headers: Record<string, string>;
349
+ };
350
+
351
+ /**
352
+ * Adapt a persisted `SchemaInput` into `@selvajs/compute`'s raw `InputParamSchema`
353
+ * and let its `processInput` produce the typed `InputParam`. `values`/`acceptedFormats`
354
+ * are absent on `SchemaInput` (they live on the layout item's config), so
355
+ * valueList/file inputs fall back to carrying the selected value with no option
356
+ * list — the same value that reaches Grasshopper.
357
+ */
358
+ declare function transformInputParameter(input: SchemaInput & {
359
+ minimum?: number;
360
+ maximum?: number;
361
+ stepSize?: number;
362
+ }, value: unknown): InputParam;
363
+
364
+ /**
365
+ * In-process single-flight — dogpile protection for every solve.
366
+ *
367
+ * Deliberately NOT conditional on a result cache being configured: the dogpile is
368
+ * worst precisely when nothing else is caching (N concurrent identical solves each
369
+ * paying a full Rhino round trip — the scheduler has no in-flight coalescing of its
370
+ * own, and Rhino's `cachesolve` still costs a round trip per repeat). Gating this on
371
+ * cache configuration, as an earlier version did, turned it off in exactly the
372
+ * deployments most exposed to a cold-key stampede (hot public definition + a deploy).
373
+ *
374
+ * In-process only, sitting above `ISolveResultCache` (per app instance). A
375
+ * cross-instance lease (Redis `SET NX`) is a later backend capability.
376
+ *
377
+ * Correctness note: the shared promise resolves to ONE value for all waiters, so
378
+ * the wrapped work must return an immutable / independently-serializable result
379
+ * (the solve pipeline's envelope is safe to share by reference here because the
380
+ * app serializes it per response).
381
+ */
382
+ interface SolveCacheSingleFlight {
383
+ /**
384
+ * Run `work` under `key`, coalescing concurrent identical calls: the first
385
+ * caller executes, overlapping callers for the same key await the same promise
386
+ * and result, and the key is freed as soon as it settles.
387
+ *
388
+ * `onWaiterJoined` fires on the OWNER's call each time another caller joins its
389
+ * flight — ownership changes what the owner's abort signal means (a solo run
390
+ * may cancel on its own client's disconnect, a shared one may not, or it would
391
+ * 499 every waiter). Never fires for a joining caller, and never after `work`
392
+ * settles.
393
+ */
394
+ run<T>(key: string, work: () => Promise<T>, onWaiterJoined?: () => void): Promise<T>;
395
+ /** Number of keys currently in flight (observability / tests). */
396
+ inFlight(): number;
397
+ }
398
+ interface SolveCacheSingleFlightOptions {
399
+ /**
400
+ * Fired when a caller joins an already-in-flight key instead of running its
401
+ * own work — the coalescing win this module exists for, otherwise invisible.
402
+ */
403
+ onJoin?: (key: string) => void;
404
+ }
405
+ declare function createSolveCacheSingleFlight(options?: SolveCacheSingleFlightOptions): SolveCacheSingleFlight;
406
+
407
+ export { type ByteCacheRef, type ByteCacheStats, type ByteRefOutcome, COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, type CachedClient, type ClientCache, type ClientCacheConfig, type DefinitionByteCache, type PipelineInput, type ResolvedServer, type ServerIdentity, type SolveCacheSingleFlight, type SolveCacheSingleFlightOptions, type SolveCacheStats, type SolveEnvelope, type SolveOutcome, type SolvePhaseMetrics, type SolvePipelineArgs, adaptEnvelopeToEncoding, buildSolveInputTree, createClientCache, createDefinitionByteCache, createSolveCacheSingleFlight, runSolvePipeline, serverIdentity, transformInputParameter };