@selvajs/server 0.2.0-beta.3 → 0.2.0-beta.4

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 (145) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/compute/client-cache.d.ts.map +1 -1
  4. package/dist/compute/client-cache.js +6 -0
  5. package/dist/compute/client-cache.js.map +1 -1
  6. package/dist/compute/safe-url.d.ts.map +1 -1
  7. package/dist/compute/safe-url.js +16 -0
  8. package/dist/compute/safe-url.js.map +1 -1
  9. package/dist/compute/solve-cache-single-flight.d.ts +8 -1
  10. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
  11. package/dist/compute/solve-cache-single-flight.js +4 -2
  12. package/dist/compute/solve-cache-single-flight.js.map +1 -1
  13. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  14. package/dist/compute/solve-pipeline.js +3 -1
  15. package/dist/compute/solve-pipeline.js.map +1 -1
  16. package/package.json +14 -18
  17. package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
  18. package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
  19. package/dist/access/__tests__/project-access-input.test.js +0 -146
  20. package/dist/access/__tests__/project-access-input.test.js.map +0 -1
  21. package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
  22. package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
  23. package/dist/compute/__tests__/client-cache.test.js +0 -218
  24. package/dist/compute/__tests__/client-cache.test.js.map +0 -1
  25. package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
  26. package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
  27. package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
  28. package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
  29. package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
  30. package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
  31. package/dist/compute/__tests__/rate-limit.test.js +0 -98
  32. package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
  33. package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
  34. package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
  35. package/dist/compute/__tests__/remote-definition.test.js +0 -114
  36. package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
  37. package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
  38. package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
  39. package/dist/compute/__tests__/safe-url.test.js +0 -71
  40. package/dist/compute/__tests__/safe-url.test.js.map +0 -1
  41. package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
  42. package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
  43. package/dist/compute/__tests__/solve-cache.test.js +0 -234
  44. package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
  45. package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
  46. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
  47. package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
  48. package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
  49. package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
  50. package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
  51. package/dist/compute/__tests__/transform-input.test.js +0 -120
  52. package/dist/compute/__tests__/transform-input.test.js.map +0 -1
  53. package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
  54. package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
  55. package/dist/definitions/__tests__/definition-service.test.js +0 -95
  56. package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
  57. package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
  58. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
  59. package/dist/definitions/__tests__/load-for-render.test.js +0 -196
  60. package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
  61. package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
  62. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
  63. package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
  64. package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
  65. package/dist/http/__tests__/body-size.test.d.ts +0 -2
  66. package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
  67. package/dist/http/__tests__/body-size.test.js +0 -24
  68. package/dist/http/__tests__/body-size.test.js.map +0 -1
  69. package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
  70. package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
  71. package/dist/http/__tests__/route-classifier.test.js +0 -54
  72. package/dist/http/__tests__/route-classifier.test.js.map +0 -1
  73. package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
  74. package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
  75. package/dist/http/__tests__/safe-redirect.test.js +0 -44
  76. package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
  77. package/dist/http/__tests__/security-headers.test.d.ts +0 -2
  78. package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
  79. package/dist/http/__tests__/security-headers.test.js +0 -24
  80. package/dist/http/__tests__/security-headers.test.js.map +0 -1
  81. package/dist/ops/__tests__/semver.test.d.ts +0 -2
  82. package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
  83. package/dist/ops/__tests__/semver.test.js +0 -38
  84. package/dist/ops/__tests__/semver.test.js.map +0 -1
  85. package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
  86. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
  87. package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
  88. package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
  89. package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
  90. package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
  91. package/dist/tokens/__tests__/token-codec.test.js +0 -50
  92. package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
  93. package/src/access/__tests__/project-access-input.test.ts +0 -175
  94. package/src/access/index.ts +0 -9
  95. package/src/access/project-access-input.ts +0 -145
  96. package/src/compute/__tests__/client-cache.test.ts +0 -244
  97. package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
  98. package/src/compute/__tests__/rate-limit.test.ts +0 -106
  99. package/src/compute/__tests__/remote-definition.test.ts +0 -140
  100. package/src/compute/__tests__/safe-url.test.ts +0 -72
  101. package/src/compute/__tests__/solve-cache.test.ts +0 -268
  102. package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
  103. package/src/compute/__tests__/transform-input.test.ts +0 -162
  104. package/src/compute/client-cache.ts +0 -321
  105. package/src/compute/definition-byte-cache.ts +0 -168
  106. package/src/compute/index.ts +0 -82
  107. package/src/compute/limits.ts +0 -237
  108. package/src/compute/memory-solve-cache.ts +0 -0
  109. package/src/compute/rate-limit.ts +0 -110
  110. package/src/compute/remote-definition.ts +0 -149
  111. package/src/compute/safe-url.ts +0 -186
  112. package/src/compute/solve-cache-envelope.ts +0 -98
  113. package/src/compute/solve-cache-key.ts +0 -85
  114. package/src/compute/solve-cache-single-flight.ts +0 -59
  115. package/src/compute/solve-pipeline.ts +0 -603
  116. package/src/compute/transform-input.ts +0 -66
  117. package/src/definitions/__tests__/definition-service.test.ts +0 -131
  118. package/src/definitions/__tests__/load-for-render.test.ts +0 -244
  119. package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
  120. package/src/definitions/definition-service.ts +0 -247
  121. package/src/definitions/index.ts +0 -22
  122. package/src/definitions/load-for-render.ts +0 -272
  123. package/src/definitions/schema-extraction.ts +0 -117
  124. package/src/errors/SentryErrorReporter.ts +0 -88
  125. package/src/errors/index.ts +0 -3
  126. package/src/http/__tests__/body-size.test.ts +0 -27
  127. package/src/http/__tests__/route-classifier.test.ts +0 -60
  128. package/src/http/__tests__/safe-redirect.test.ts +0 -47
  129. package/src/http/__tests__/security-headers.test.ts +0 -29
  130. package/src/http/body-size.ts +0 -24
  131. package/src/http/index.ts +0 -10
  132. package/src/http/redirect.ts +0 -16
  133. package/src/http/route-classifier.ts +0 -74
  134. package/src/http/security-headers.ts +0 -42
  135. package/src/index.ts +0 -27
  136. package/src/ops/__tests__/semver.test.ts +0 -42
  137. package/src/ops/index.ts +0 -3
  138. package/src/ops/semver.ts +0 -60
  139. package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
  140. package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
  141. package/src/providers/create-selva-providers.ts +0 -241
  142. package/src/providers/index.ts +0 -12
  143. package/src/tokens/__tests__/token-codec.test.ts +0 -63
  144. package/src/tokens/index.ts +0 -8
  145. package/src/tokens/token-codec.ts +0 -86
@@ -1,162 +0,0 @@
1
- /**
2
- * Tests for `transformInputParameter` — the adapter that turns a persisted `SchemaInput`
3
- * (plus the user's chosen value) into the `@selvajs/compute` `InputParam` that
4
- * `TreeBuilder.fromInputParams` serializes for the solve.
5
- *
6
- * The adapter delegates to the package's `processInput`, so these assert the
7
- * package-aligned behavior: correct `paramType` for every type (not the old
8
- * coerce-everything-to-Text), and `default` carrying the user's value. Where a value/
9
- * default is absent, `processInput` leaves `default` undefined and `fromInputParams`
10
- * omits the input entirely — letting Grasshopper use its own internal default rather
11
- * than forcing `''`/`false`.
12
- */
13
-
14
- import { describe, it, expect } from 'vitest';
15
- import { transformInputParameter } from '../transform-input.js';
16
- import type { SchemaInput } from '@selvajs/schemas';
17
-
18
- type Input = SchemaInput & { minimum?: number; maximum?: number; stepSize?: number };
19
-
20
- function input(partial: Partial<Input>): Input {
21
- return {
22
- id: 'id-1',
23
- nickname: 'Param',
24
- paramType: 'text',
25
- ...partial
26
- } as Input;
27
- }
28
-
29
- describe('transformInputParameter — identity fields', () => {
30
- it('carries id, name, nickname, description through', () => {
31
- const out = transformInputParameter(
32
- input({ id: 'guid-x', nickname: 'Width', description: 'the width' }),
33
- 5
34
- );
35
- expect(out.id).toBe('guid-x');
36
- expect(out.name).toBe('Width');
37
- expect(out.nickname).toBe('Width');
38
- expect(out.description).toBe('the width');
39
- });
40
-
41
- it('defaults a blank nickname to null and an absent description to empty string', () => {
42
- const out = transformInputParameter(input({ nickname: '', description: undefined }), 'v');
43
- expect(out.nickname).toBeNull();
44
- expect(out.description).toBe('');
45
- });
46
- });
47
-
48
- describe('transformInputParameter — number / integer', () => {
49
- it('maps number with the user value winning over the schema default', () => {
50
- const out = transformInputParameter(
51
- input({ paramType: 'number', default: 1, minimum: 0, maximum: 10, stepSize: 0.5 }),
52
- 7
53
- );
54
- expect(out).toMatchObject({ paramType: 'Number', default: 7, minimum: 0, maximum: 10 });
55
- });
56
-
57
- it('falls back to the schema default when no value is supplied', () => {
58
- const out = transformInputParameter(input({ paramType: 'number', default: 3 }), undefined);
59
- expect(out.default).toBe(3);
60
- });
61
-
62
- it('honors a schema-authored stepSize verbatim for integers (compute ≥3.1.0-beta.5)', () => {
63
- // The package used to force integer stepSize to 1; since beta.5 a
64
- // server-authored stepSize wins and the 1-heuristic is only the fallback.
65
- const out = transformInputParameter(
66
- input({ paramType: 'integer', stepSize: 0.5, default: 2 }),
67
- 4
68
- );
69
- expect(out).toMatchObject({ paramType: 'Integer', stepSize: 0.5, default: 4 });
70
- });
71
-
72
- it('falls back to stepSize 1 for integers without an authored stepSize', () => {
73
- const out = transformInputParameter(input({ paramType: 'integer', default: 2 }), 4);
74
- expect(out).toMatchObject({ paramType: 'Integer', stepSize: 1, default: 4 });
75
- });
76
- });
77
-
78
- describe('transformInputParameter — text', () => {
79
- it('maps text and keeps the user value', () => {
80
- const out = transformInputParameter(input({ paramType: 'text', default: 'd' }), 'hello');
81
- expect(out).toMatchObject({ paramType: 'Text', default: 'hello' });
82
- });
83
-
84
- it('falls back to the schema default when no value is supplied', () => {
85
- expect(
86
- transformInputParameter(input({ paramType: 'text', default: 'd' }), undefined).default
87
- ).toBe('d');
88
- });
89
-
90
- it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
91
- // processInput does not force ''. fromInputParams filters undefined defaults, so the
92
- // param is simply not sent and Grasshopper uses its own internal default.
93
- expect(
94
- transformInputParameter(input({ paramType: 'text', default: undefined }), undefined).default
95
- ).toBeUndefined();
96
- });
97
- });
98
-
99
- describe('transformInputParameter — boolean', () => {
100
- it('maps boolean and preserves an explicit false (the bug-prone case)', () => {
101
- const out = transformInputParameter(input({ paramType: 'boolean', default: true }), false);
102
- expect(out).toMatchObject({ paramType: 'Boolean', default: false });
103
- });
104
-
105
- it('falls back to the schema default when no value is supplied', () => {
106
- expect(
107
- transformInputParameter(input({ paramType: 'boolean', default: true }), undefined).default
108
- ).toBe(true);
109
- });
110
-
111
- it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
112
- expect(
113
- transformInputParameter(input({ paramType: 'boolean', default: undefined }), undefined)
114
- .default
115
- ).toBeUndefined();
116
- });
117
- });
118
-
119
- describe('transformInputParameter — value-list / file / color / generic', () => {
120
- // The regression that motivated the swap: these used to be silently coerced to Text.
121
- // processInput now types them correctly. (The options map for ValueList isn't carried
122
- // on SchemaInput, so `values` is empty and the selected value rides on `default` —
123
- // which is exactly the string a contextual ValueList param consumes.)
124
- it('maps valueList to ValueList, carrying the selected value', () => {
125
- const out = transformInputParameter(input({ paramType: 'valueList', default: '0' }), '1');
126
- expect(out).toMatchObject({ paramType: 'ValueList', default: '1' });
127
- });
128
-
129
- // Regression: processInput has no dynamicValueList handler, so without the explicit
130
- // mapping it fell to the Geometry fallback with a null default — and fromInputParams
131
- // then DROPPED the input, so the user's selection never reached the definition.
132
- // The plugin's GetDynamicValueListParameter reuses the ValueList wire contract
133
- // (TypeName "ValueList"), so ValueList is the correct target type.
134
- it('maps dynamicValueList to ValueList, carrying the selected value', () => {
135
- const out = transformInputParameter(
136
- input({ paramType: 'dynamicValueList', default: '0' }),
137
- '3.5'
138
- );
139
- expect(out).toMatchObject({ paramType: 'ValueList', default: '3.5' });
140
- });
141
-
142
- it('maps dynamicValueList checklist (array value) to ValueList, carrying all selections', () => {
143
- const out = transformInputParameter(input({ paramType: 'dynamicValueList' }), ['a', 'b']);
144
- expect(out.paramType).toBe('ValueList');
145
- expect(out.default).toEqual(['a', 'b']);
146
- });
147
-
148
- it('maps file to File', () => {
149
- const out = transformInputParameter(input({ paramType: 'file' }), { fileName: 'a.3dm' });
150
- expect(out.paramType).toBe('File');
151
- });
152
-
153
- it('maps color to Color, carrying the value', () => {
154
- const out = transformInputParameter(input({ paramType: 'color' }), '255,0,0');
155
- expect(out).toMatchObject({ paramType: 'Color', default: '255,0,0' });
156
- });
157
-
158
- it('maps generic to the package fallback type (Geometry), not Text', () => {
159
- const out = transformInputParameter(input({ paramType: 'generic', default: 'd' }), undefined);
160
- expect(out.paramType).toBe('Geometry');
161
- });
162
- });
@@ -1,321 +0,0 @@
1
- import {
2
- GrasshopperClient,
3
- enableDebugLogging,
4
- type GrasshopperComputeConfig,
5
- type SolveScheduler
6
- } from '@selvajs/compute';
7
-
8
- // ============================================================================
9
- // Shared compute client cache
10
- // ============================================================================
11
- //
12
- // One warm `GrasshopperClient` (+ its `SolveScheduler`) per distinct compute
13
- // server, keyed by the server's **`id`** (ADR 0004 — a server's identity is its
14
- // `id`, never its URL). Definitions can pin different servers (the app's
15
- // `resolveServerForOrg` honors a per-definition pin → org default → global
16
- // default), so this is deliberately a per-server LRU, NOT a single shared
17
- // client — two definitions on two servers keep two warm clients; a churn of
18
- // one-off servers is bounded by `maxCachedClients`.
19
- //
20
- // Both hot paths share this cache:
21
- // - the solve endpoint uses the entry's `scheduler`;
22
- // - the definition-viewer render path uses the entry's `client` for `getIO`.
23
- // Sharing means a definition that was just solved renders from the same warm
24
- // client (and vice-versa) instead of re-handshaking Rhino.Compute per page load.
25
- //
26
- // Staleness: keyed on `id`, a rotated URL/apiKey keeps the SAME key with stale
27
- // connection details — so the config-write path MUST call `evict(id)` when a
28
- // server's config changes (ADR 0004 Consequences). That replaces the old
29
- // implicit "new key → LRU age-out" staleness the URL-keyed cache relied on.
30
-
31
- /**
32
- * Opaque identity of a compute server. Callers never construct the branded
33
- * string directly — they pass a resolved server config to `serverIdentity()`.
34
- * Keeping this opaque from day one (ADR 0004 D1) means "a server is now a pool
35
- * of URLs behind one id" stays an additive, non-breaking change.
36
- */
37
- export type ServerIdentity = string & { readonly __brand: 'ServerIdentity' };
38
-
39
- /** Minimal resolved-server shape the cache needs (a subset of the app's `ComputeServerConfig`). */
40
- export interface ResolvedServer {
41
- /** Stable identity — the cache key. */
42
- id: string;
43
- /** Base URL of the Rhino.Compute instance (a resolution detail of `id`). */
44
- serverUrl: string;
45
- /** Sent as `RhinoComputeKey`. */
46
- apiKey?: string;
47
- }
48
-
49
- /** Derive the opaque cache identity from a resolved server. Identity is the `id`. */
50
- export function serverIdentity(server: Pick<ResolvedServer, 'id'>): ServerIdentity {
51
- return server.id as ServerIdentity;
52
- }
53
-
54
- /**
55
- * Per-request telemetry holders read by the caller right after a solve resolves
56
- * to build a Server-Timing header. Populated by the client/scheduler callbacks.
57
- */
58
- export interface CachedClient {
59
- client: GrasshopperClient;
60
- scheduler: SolveScheduler;
61
- /**
62
- * Last decode/solve/encode reported by the compute server (Server-Timing),
63
- * written by onServerTiming. `seq` increments on every write. The scheduler
64
- * runs up to `maxConcurrentSolves` solves at once, so `last` alone can't be
65
- * trusted per-request: callers snapshot `seq` before their solve and
66
- * attribute `last` only when exactly one write happened since (necessarily
67
- * theirs — see the guard in `runSolvePipeline`), dropping the segment
68
- * otherwise instead of misattributing another request's timing.
69
- */
70
- rhinoTiming: { last: { decode: number; solve: number; encode: number } | null; seq: number };
71
- /**
72
- * Last solve's cache verdicts from the scheduler's onSettle: whether the
73
- * in-process response cache served it (no compute call) and whether the
74
- * definition had to be re-uploaded. `seq` increments on EVERY settle
75
- * (success or error); `last` is only written on success. Same
76
- * snapshot-and-attribute pattern as rhinoTiming — onSettle always fires
77
- * before the corresponding `scheduler.solve()` promise resolves, so at the
78
- * caller's read point its own settle is included in `seq`.
79
- */
80
- solveMeta: {
81
- last: { fromCache: boolean; definitionReuploaded?: boolean } | null;
82
- seq: number;
83
- };
84
- }
85
-
86
- /**
87
- * Config injected by the consuming app. Everything that used to be read from
88
- * `$env` / `computeLimits` in the app module is passed in, so this module stays
89
- * env-agnostic and testable.
90
- */
91
- export interface ClientCacheConfig {
92
- /** Per-solve timeout forwarded to the scheduler (`ComputeLimits.maxSolveDurationMs`). */
93
- maxSolveDurationMs: number;
94
- /** Ask Rhino.Compute to cache solve results and return them on identical repeats. */
95
- cachesolve: boolean;
96
- /** Also cache solves that reported GH errors (only meaningful with `cachesolve`). */
97
- cacheerroredsolves: boolean;
98
- /** Reference large definitions by server cache key (pointer) instead of re-uploading. */
99
- reuseServerDefinitionCache: boolean;
100
- /**
101
- * Max in-flight solves per compute server (scheduler `maxConcurrent`; excess
102
- * FIFO-queues). Size to the server's `compute.geometry` child count
103
- * (`ComputeLimits.computeMaxConcurrentSolves`).
104
- */
105
- maxConcurrentSolves: number;
106
- /**
107
- * Backpressure — max solves that may WAIT in the FIFO queue (excludes the
108
- * in-flight `maxConcurrentSolves`). `0` = unbounded (`ComputeLimits.
109
- * computeMaxQueueDepth`); a full queue sheds new solves with `QUEUE_FULL`.
110
- */
111
- maxQueueDepth: number;
112
- /**
113
- * Backpressure — max ms a solve may sit queued before executing; `0` = no
114
- * deadline (`ComputeLimits.computeQueueWaitMs`). A too-long wait sheds with
115
- * `QUEUE_TIMEOUT`.
116
- */
117
- queueWaitMs: number;
118
- /** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
119
- debug: boolean;
120
- /** VERBOSE lib-level logging (full solve request/response incl. geometry). */
121
- debugVerbose: boolean;
122
- /** Max distinct warm clients before the LRU evicts the oldest. Default 16. */
123
- maxCachedClients?: number;
124
- /** Sink for the concise debug lines (the app wires `console.log`). */
125
- onDebugLog?: (message: string) => void;
126
- }
127
-
128
- export interface ClientCache {
129
- /**
130
- * Get (or create) the warm client + scheduler for a resolved compute server,
131
- * keyed by its `id`. `definitionGuid`, when present, is stamped as the
132
- * `X-Selva-Definition` header on this client's outbound solve/IO requests —
133
- * inert routing/telemetry metadata until a pool router exists (ADR 0004 D2).
134
- */
135
- getClient(server: ResolvedServer, opts?: { definitionGuid?: string }): Promise<CachedClient>;
136
- /**
137
- * Dispose and drop the warm client for `id` (or a `ServerIdentity`). Called by
138
- * the config-write path when a server's URL/key rotates so the next request
139
- * rebuilds against fresh connection details (ADR 0004 Consequences).
140
- */
141
- evict(id: string | ServerIdentity): void;
142
- /** Dispose every warm client. Test seam / shutdown hook. */
143
- disposeAll(): void;
144
- }
145
-
146
- const DEFAULT_MAX_CACHED_CLIENTS = 16;
147
-
148
- /**
149
- * Build a per-server warm-client cache. Each cache owns its own LRU `Map` keyed
150
- * on server `id`.
151
- */
152
- export function createClientCache(config: ClientCacheConfig): ClientCache {
153
- const maxCachedClients = config.maxCachedClients ?? DEFAULT_MAX_CACHED_CLIENTS;
154
- const cache = new Map<string, CachedClient>();
155
-
156
- if (config.debugVerbose) enableDebugLogging();
157
-
158
- const debugLog = (message: string) => {
159
- if (config.debug) config.onDebugLog?.(message);
160
- };
161
-
162
- async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
163
- // Holders for per-request results from the client/scheduler callbacks; the
164
- // caller reads them right after each solve resolves to build Server-Timing,
165
- // using the seq counters to detect (and drop) ambiguous concurrent writes.
166
- const rhinoTiming: CachedClient['rhinoTiming'] = { last: null, seq: 0 };
167
- const solveMeta: CachedClient['solveMeta'] = { last: null, seq: 0 };
168
-
169
- const clientConfig: GrasshopperComputeConfig = {
170
- serverUrl: server.serverUrl,
171
- apiKey: server.apiKey,
172
- // Lib-level debug is the verbose one (full payload logs); keep it on the
173
- // verbose flag so the concise cache logs can run without the noise.
174
- debug: config.debugVerbose,
175
- // Ask Rhino.Compute to cache solve results and return them on identical
176
- // repeats (same definition + inputs), skipping the solve.
177
- cachesolve: config.cachesolve,
178
- // Opt-in: also cache solves that reported GH errors.
179
- cacheerroredsolves: config.cacheerroredsolves,
180
- // DEBUG: the server's per-request timing breakdown, which exposes the two
181
- // SERVER-side caches at once — definition cache (pointer reuse) via
182
- // `decode`, solve cache (cachesolve) via `solve`. Flagged below so the
183
- // contrast is obvious in the logs.
184
- onServerTiming: (t) => {
185
- const decode = t.decode ?? 0;
186
- const solve = t.solve ?? 0;
187
- const encode = t.encode ?? 0;
188
- // Always recorded (cheap): the caller reads this after scheduler.solve.
189
- rhinoTiming.seq += 1;
190
- rhinoTiming.last = { decode, solve, encode };
191
- if (!config.debug) return;
192
- // Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
193
- // is sub-millisecond. Only meaningful relative to the first (cold) solve.
194
- const defCache =
195
- decode <= 1 ? 'HIT (definition reused, no re-upload/parse)' : 'miss (full parse)';
196
- const solveCache =
197
- config.cachesolve && solve <= 1
198
- ? 'HIT (cached result, solve skipped)'
199
- : config.cachesolve
200
- ? 'miss (solved fresh)'
201
- : 'off';
202
- debugLog(
203
- `[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms ` +
204
- `| definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}`
205
- );
206
- }
207
- };
208
-
209
- // `X-Selva-Definition` on the wire (ADR 0004 D2). Baked at client-create
210
- // time because the scheduler has no per-request header hook — inert for a
211
- // single-member "pool", useful in compute-side access logs immediately.
212
- if (definitionGuid) {
213
- clientConfig.headers = { 'X-Selva-Definition': definitionGuid };
214
- }
215
-
216
- const client = await GrasshopperClient.create(clientConfig);
217
- const scheduler = client.createScheduler({
218
- mode: 'queue',
219
- // Queue mode defaults maxConcurrent to 1, which would serialize every
220
- // solve on this server through a single slot while the compute VM's
221
- // other compute.geometry children idle (audit B6) — always pass it.
222
- maxConcurrent: config.maxConcurrentSolves,
223
- // Backpressure (audit B7). The scheduler treats undefined as
224
- // unbounded/no-deadline, so map our `0`-means-off convention to undefined
225
- // rather than passing 0 (which would shed EVERY queued solve).
226
- maxQueueDepth: config.maxQueueDepth || undefined,
227
- queueWaitMs: config.queueWaitMs || undefined,
228
- timeoutMs: config.maxSolveDurationMs,
229
- cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
230
- // Solve large definitions by server cache-key (pointer) instead of
231
- // re-uploading the full binary every solve. On a stale-pointer miss the
232
- // client transparently re-uploads.
233
- reuseServerDefinitionCache: config.reuseServerDefinitionCache,
234
- // DEBUG: observe the in-process response cache. `fromCache === true` means
235
- // this solve was served from the app's own cache — Rhino.Compute was never
236
- // called. `false` means it went to the compute server.
237
- onSettle: (_ctx, result) => {
238
- // Count EVERY settle (success or error) so the caller's attribution
239
- // guard sees all concurrent activity, not just successful writes.
240
- solveMeta.seq += 1;
241
- if (result.status !== 'success') return;
242
- // Always recorded (cheap): the caller surfaces these on Server-Timing.
243
- solveMeta.last = {
244
- fromCache: result.fromCache === true,
245
- definitionReuploaded: result.definitionReuploaded
246
- };
247
- if (!config.debug) return;
248
- debugLog(
249
- `[Compute/selva-cache] ${result.fromCache ? 'HIT — served from cache (no compute call)' : 'miss — went to Rhino.Compute'} (${Math.round(result.durationMs)}ms)`
250
- );
251
- // Definition-cache verdict for a real compute call (not a cache HIT).
252
- if (!result.fromCache) {
253
- debugLog(
254
- result.definitionReuploaded === true
255
- ? '[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)'
256
- : result.definitionReuploaded === false
257
- ? '[Compute/def-cache] HIT — definition reused from server cache (no upload)'
258
- : '[Compute/def-cache] n/a — reuse disabled or non-reusable definition'
259
- );
260
- }
261
- // Surface real GH errors when present. Whether an errored solve is cached is
262
- // config-gated by `cacheerroredsolves` (default false → errored solves are
263
- // re-run each time; true → the error result is cached like any other, per R2).
264
- if (!result.fromCache) {
265
- const errs = (result.response as { errors?: unknown[] })?.errors;
266
- const errCount = Array.isArray(errs) ? errs.length : 0;
267
- if (errCount > 0) {
268
- const cacheNote = config.cacheerroredsolves
269
- ? 'cached (cacheerroredsolves on)'
270
- : 'not cached (cacheerroredsolves off) — re-run until fixed';
271
- debugLog(
272
- `[Compute/rhino-cache] ${errCount} GH error(s); ${cacheNote}. First: ${JSON.stringify((errs as unknown[])[0])}`
273
- );
274
- }
275
- }
276
- }
277
- });
278
- return { client, scheduler, rhinoTiming, solveMeta };
279
- }
280
-
281
- return {
282
- async getClient(server, opts): Promise<CachedClient> {
283
- const key = server.id;
284
- const existing = cache.get(key);
285
- if (existing) {
286
- // Refresh LRU position.
287
- cache.delete(key);
288
- cache.set(key, existing);
289
- return existing;
290
- }
291
-
292
- const entry = await build(server, opts?.definitionGuid);
293
-
294
- // Evict the least-recently-used entry before inserting when at capacity.
295
- if (cache.size >= maxCachedClients) {
296
- const oldestKey = cache.keys().next().value;
297
- if (oldestKey !== undefined) {
298
- cache.get(oldestKey)?.scheduler.dispose();
299
- cache.delete(oldestKey);
300
- }
301
- }
302
-
303
- cache.set(key, entry);
304
- return entry;
305
- },
306
-
307
- evict(id): void {
308
- const key = id as string;
309
- const entry = cache.get(key);
310
- if (entry) {
311
- entry.scheduler.dispose();
312
- cache.delete(key);
313
- }
314
- },
315
-
316
- disposeAll(): void {
317
- for (const entry of cache.values()) entry.scheduler.dispose();
318
- cache.clear();
319
- }
320
- };
321
- }
@@ -1,168 +0,0 @@
1
- /**
2
- * In-process cache of `.gh` definition bytes, keyed by immutable **version id**.
3
- *
4
- * The solve path used to eagerly `storage.get(fileKey)` on every request — moving
5
- * multi-MB definition bytes off disk/S3 into memory before the scheduler could
6
- * decide it didn't need them (the compute server already holds the definition via
7
- * the pointer/`reuseServerDefinitionCache` fast path). This cache turns that eager
8
- * read into a lazy `DefinitionRef.load()`: the scheduler calls `load()` ONLY when
9
- * an upload is unavoidable, and even then a warm entry serves the bytes without
10
- * touching storage. A pointer-known re-solve therefore moves ZERO definition bytes.
11
- *
12
- * Keying discipline (the whole reason this module exists):
13
- * - The key is the **version id**, an immutable UUID — never a `fileKey`, which
14
- * a delete-latest-then-reupload can REUSE for different content (see the
15
- * monotonic-version-number fix). A reused key would serve one version's bytes
16
- * for another: silent cache poisoning with no diagnostic. Callers pass
17
- * `version.id`, and the returned `DefinitionRef.key` is that same id, so the
18
- * scheduler's own result/pointer cache keys on the same immutable identity.
19
- *
20
- * Eviction:
21
- * - LRU by **total byte budget**, not entry count — definitions range from tens
22
- * of KB to hundreds of MB, so an entry-count cap would either pin gigabytes or
23
- * evict uselessly. `COMPUTE_DEFINITION_BYTE_CACHE_MB` sets the budget; `0`
24
- * disables the cache (every `getOrLoad` calls `load` and caches nothing).
25
- * - No TTL: version ids are immutable, so a cached entry can never go stale.
26
- * - An entry larger than the whole budget is served but never retained (caching
27
- * it would immediately evict everything including itself).
28
- */
29
-
30
- /**
31
- * Per-`getOrLoad` outcome, observable AFTER the scheduler has (or hasn't) called
32
- * `load()`. Lets the solve pipeline emit a `def_bytes` Server-Timing verdict:
33
- * - `load` never invoked → the scheduler served a pointer-known solve without
34
- * bytes (`skipped`);
35
- * - `load` invoked, `fromCache` true → warm byte-cache entry (`hit`);
36
- * - `load` invoked, `fromCache` false → fell through to the loader (`miss`).
37
- */
38
- export interface ByteRefOutcome {
39
- /** True once `load()` has been called at least once on this ref. */
40
- loaded: boolean;
41
- /** When `loaded`, whether the bytes came from a warm cache entry. */
42
- fromCache: boolean;
43
- }
44
-
45
- /** A definition reference shaped for `@selvajs/compute`'s `DefinitionRef`. */
46
- export interface ByteCacheRef {
47
- /** Immutable identity (a version UUID). Keys the scheduler's caches too. */
48
- key: string;
49
- /** Materialize the bytes — served from cache when warm, else via the loader. */
50
- load: () => Promise<Uint8Array>;
51
- /** Mutable outcome of this ref's `load()` (see {@link ByteRefOutcome}). */
52
- outcome: ByteRefOutcome;
53
- }
54
-
55
- /** Hit/miss/eviction counters for observability (Server-Timing, admin debug). */
56
- export interface ByteCacheStats {
57
- /** `load()` served from a warm entry (no loader call). */
58
- hits: number;
59
- /** `load()` fell through to the loader (cold key, or caching disabled). */
60
- misses: number;
61
- /** Entries dropped by the byte-budget LRU. */
62
- evictions: number;
63
- /** Current retained entry count. */
64
- entries: number;
65
- /** Current retained bytes. */
66
- bytes: number;
67
- }
68
-
69
- export interface DefinitionByteCache {
70
- /**
71
- * Return a `DefinitionRef` whose `load()` serves `versionId`'s bytes from the
72
- * cache when warm, otherwise calls `load` (and caches the result). The
73
- * `DefinitionRef` is cheap to build and moves no bytes until `load()` runs —
74
- * so handing it to the scheduler for a pointer-known solve costs nothing.
75
- */
76
- getOrLoad(versionId: string, load: () => Promise<Uint8Array>): ByteCacheRef;
77
- /** Snapshot of the counters (see {@link ByteCacheStats}). */
78
- stats(): ByteCacheStats;
79
- /** Drop everything (test seam / eviction on definition delete if ever needed). */
80
- clear(): void;
81
- }
82
-
83
- interface Entry {
84
- bytes: Uint8Array;
85
- }
86
-
87
- /**
88
- * Build a definition-byte cache with a total-byte LRU budget.
89
- *
90
- * @param maxBytes total retained-byte budget; `0` (or negative) disables caching
91
- * entirely — every `load()` calls the loader and nothing is retained.
92
- */
93
- export function createDefinitionByteCache(maxBytes: number): DefinitionByteCache {
94
- const budget = Math.max(0, Math.floor(maxBytes));
95
- // Map preserves insertion order → iteration order IS LRU order; re-inserting
96
- // on access moves an entry to the most-recently-used (last) position.
97
- const cache = new Map<string, Entry>();
98
- let retainedBytes = 0;
99
- const stats = { hits: 0, misses: 0, evictions: 0 };
100
-
101
- function touch(key: string, entry: Entry): void {
102
- cache.delete(key);
103
- cache.set(key, entry);
104
- }
105
-
106
- function evictToFit(): void {
107
- // Evict least-recently-used (first in insertion order) until within budget.
108
- while (retainedBytes > budget && cache.size > 0) {
109
- const oldestKey = cache.keys().next().value as string;
110
- const oldest = cache.get(oldestKey)!;
111
- cache.delete(oldestKey);
112
- retainedBytes -= oldest.bytes.byteLength;
113
- stats.evictions += 1;
114
- }
115
- }
116
-
117
- function store(key: string, bytes: Uint8Array): void {
118
- if (budget === 0) return;
119
- // An entry bigger than the whole budget is never retained — caching it would
120
- // evict everything (including itself) on the next `evictToFit`. Serve it
121
- // through, cache nothing.
122
- if (bytes.byteLength > budget) return;
123
- const existing = cache.get(key);
124
- if (existing) retainedBytes -= existing.bytes.byteLength;
125
- cache.set(key, { bytes });
126
- retainedBytes += bytes.byteLength;
127
- evictToFit();
128
- }
129
-
130
- return {
131
- getOrLoad(versionId, load) {
132
- const outcome: ByteRefOutcome = { loaded: false, fromCache: false };
133
- return {
134
- key: versionId,
135
- outcome,
136
- load: async () => {
137
- const hit = cache.get(versionId);
138
- if (hit) {
139
- touch(versionId, hit);
140
- stats.hits += 1;
141
- outcome.loaded = true;
142
- outcome.fromCache = true;
143
- return hit.bytes;
144
- }
145
- stats.misses += 1;
146
- outcome.loaded = true;
147
- outcome.fromCache = false;
148
- const bytes = await load();
149
- store(versionId, bytes);
150
- return bytes;
151
- }
152
- };
153
- },
154
- stats() {
155
- return {
156
- hits: stats.hits,
157
- misses: stats.misses,
158
- evictions: stats.evictions,
159
- entries: cache.size,
160
- bytes: retainedBytes
161
- };
162
- },
163
- clear() {
164
- cache.clear();
165
- retainedBytes = 0;
166
- }
167
- };
168
- }