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

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 (192) hide show
  1. package/dist/access/__tests__/project-access-input.test.d.ts +2 -0
  2. package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
  3. package/dist/access/__tests__/project-access-input.test.js +146 -0
  4. package/dist/access/__tests__/project-access-input.test.js.map +1 -0
  5. package/dist/access/index.d.ts +2 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/index.js +4 -0
  8. package/dist/access/index.js.map +1 -0
  9. package/dist/access/project-access-input.d.ts +52 -0
  10. package/dist/access/project-access-input.d.ts.map +1 -0
  11. package/dist/access/project-access-input.js +56 -0
  12. package/dist/access/project-access-input.js.map +1 -0
  13. package/dist/compute/__tests__/client-cache.test.js +70 -1
  14. package/dist/compute/__tests__/client-cache.test.js.map +1 -1
  15. package/dist/compute/__tests__/definition-byte-cache.test.d.ts +2 -0
  16. package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +1 -0
  17. package/dist/compute/__tests__/definition-byte-cache.test.js +107 -0
  18. package/dist/compute/__tests__/definition-byte-cache.test.js.map +1 -0
  19. package/dist/compute/__tests__/rate-limit.test.js +36 -0
  20. package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
  21. package/dist/compute/__tests__/solve-cache.test.d.ts +13 -0
  22. package/dist/compute/__tests__/solve-cache.test.d.ts.map +1 -0
  23. package/dist/compute/__tests__/solve-cache.test.js +234 -0
  24. package/dist/compute/__tests__/solve-cache.test.js.map +1 -0
  25. package/dist/compute/__tests__/solve-pipeline.test.js +327 -15
  26. package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
  27. package/dist/compute/__tests__/transform-input.test.js +7 -1
  28. package/dist/compute/__tests__/transform-input.test.js.map +1 -1
  29. package/dist/compute/client-cache.d.ts +32 -8
  30. package/dist/compute/client-cache.d.ts.map +1 -1
  31. package/dist/compute/client-cache.js +24 -6
  32. package/dist/compute/client-cache.js.map +1 -1
  33. package/dist/compute/definition-byte-cache.d.ts +85 -0
  34. package/dist/compute/definition-byte-cache.d.ts.map +1 -0
  35. package/dist/compute/definition-byte-cache.js +110 -0
  36. package/dist/compute/definition-byte-cache.js.map +1 -0
  37. package/dist/compute/index.d.ts +7 -2
  38. package/dist/compute/index.d.ts.map +1 -1
  39. package/dist/compute/index.js +6 -1
  40. package/dist/compute/index.js.map +1 -1
  41. package/dist/compute/limits.d.ts +62 -0
  42. package/dist/compute/limits.d.ts.map +1 -1
  43. package/dist/compute/limits.js +35 -1
  44. package/dist/compute/limits.js.map +1 -1
  45. package/dist/compute/memory-solve-cache.d.ts +58 -0
  46. package/dist/compute/memory-solve-cache.d.ts.map +1 -0
  47. package/dist/compute/memory-solve-cache.js +0 -0
  48. package/dist/compute/memory-solve-cache.js.map +1 -0
  49. package/dist/compute/rate-limit.d.ts +12 -0
  50. package/dist/compute/rate-limit.d.ts.map +1 -1
  51. package/dist/compute/rate-limit.js +18 -0
  52. package/dist/compute/rate-limit.js.map +1 -1
  53. package/dist/compute/solve-cache-envelope.d.ts +53 -0
  54. package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
  55. package/dist/compute/solve-cache-envelope.js +66 -0
  56. package/dist/compute/solve-cache-envelope.js.map +1 -0
  57. package/dist/compute/solve-cache-key.d.ts +65 -0
  58. package/dist/compute/solve-cache-key.d.ts.map +1 -0
  59. package/dist/compute/solve-cache-key.js +50 -0
  60. package/dist/compute/solve-cache-key.js.map +1 -0
  61. package/dist/compute/solve-cache-single-flight.d.ts +35 -0
  62. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
  63. package/dist/compute/solve-cache-single-flight.js +48 -0
  64. package/dist/compute/solve-cache-single-flight.js.map +1 -0
  65. package/dist/compute/solve-pipeline.d.ts +71 -8
  66. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  67. package/dist/compute/solve-pipeline.js +198 -15
  68. package/dist/compute/solve-pipeline.js.map +1 -1
  69. package/dist/compute/transform-input.d.ts.map +1 -1
  70. package/dist/compute/transform-input.js +21 -6
  71. package/dist/compute/transform-input.js.map +1 -1
  72. package/dist/definitions/__tests__/definition-service.test.d.ts +14 -0
  73. package/dist/definitions/__tests__/definition-service.test.d.ts.map +1 -0
  74. package/dist/definitions/__tests__/definition-service.test.js +95 -0
  75. package/dist/definitions/__tests__/definition-service.test.js.map +1 -0
  76. package/dist/definitions/definition-service.d.ts +1 -1
  77. package/dist/definitions/definition-service.d.ts.map +1 -1
  78. package/dist/definitions/definition-service.js +8 -4
  79. package/dist/definitions/definition-service.js.map +1 -1
  80. package/dist/errors/SentryErrorReporter.d.ts +17 -0
  81. package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
  82. package/dist/errors/SentryErrorReporter.js +60 -0
  83. package/dist/errors/SentryErrorReporter.js.map +1 -0
  84. package/dist/errors/index.d.ts +2 -0
  85. package/dist/errors/index.d.ts.map +1 -0
  86. package/dist/errors/index.js +3 -0
  87. package/dist/errors/index.js.map +1 -0
  88. package/dist/http/__tests__/body-size.test.d.ts +2 -0
  89. package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
  90. package/dist/http/__tests__/body-size.test.js +24 -0
  91. package/dist/http/__tests__/body-size.test.js.map +1 -0
  92. package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
  93. package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
  94. package/dist/http/__tests__/route-classifier.test.js +54 -0
  95. package/dist/http/__tests__/route-classifier.test.js.map +1 -0
  96. package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
  97. package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
  98. package/dist/http/__tests__/safe-redirect.test.js +44 -0
  99. package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
  100. package/dist/http/__tests__/security-headers.test.d.ts +2 -0
  101. package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
  102. package/dist/http/__tests__/security-headers.test.js +24 -0
  103. package/dist/http/__tests__/security-headers.test.js.map +1 -0
  104. package/dist/http/body-size.d.ts +21 -0
  105. package/dist/http/body-size.d.ts.map +1 -0
  106. package/dist/http/body-size.js +20 -0
  107. package/dist/http/body-size.js.map +1 -0
  108. package/dist/http/index.d.ts +5 -0
  109. package/dist/http/index.d.ts.map +1 -0
  110. package/dist/http/index.js +6 -0
  111. package/dist/http/index.js.map +1 -0
  112. package/dist/http/redirect.d.ts +11 -0
  113. package/dist/http/redirect.d.ts.map +1 -0
  114. package/dist/http/redirect.js +20 -0
  115. package/dist/http/redirect.js.map +1 -0
  116. package/dist/http/route-classifier.d.ts +47 -0
  117. package/dist/http/route-classifier.d.ts.map +1 -0
  118. package/dist/http/route-classifier.js +37 -0
  119. package/dist/http/route-classifier.js.map +1 -0
  120. package/dist/http/security-headers.d.ts +31 -0
  121. package/dist/http/security-headers.d.ts.map +1 -0
  122. package/dist/http/security-headers.js +31 -0
  123. package/dist/http/security-headers.js.map +1 -0
  124. package/dist/index.d.ts +11 -2
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +11 -2
  127. package/dist/index.js.map +1 -1
  128. package/dist/ops/__tests__/semver.test.d.ts +2 -0
  129. package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
  130. package/dist/ops/__tests__/semver.test.js +38 -0
  131. package/dist/ops/__tests__/semver.test.js.map +1 -0
  132. package/dist/ops/index.d.ts +2 -0
  133. package/dist/ops/index.d.ts.map +1 -0
  134. package/dist/ops/index.js +3 -0
  135. package/dist/ops/index.js.map +1 -0
  136. package/dist/ops/semver.d.ts +35 -0
  137. package/dist/ops/semver.d.ts.map +1 -0
  138. package/dist/ops/semver.js +55 -0
  139. package/dist/ops/semver.js.map +1 -0
  140. package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
  141. package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
  142. package/dist/tokens/__tests__/token-codec.test.js +50 -0
  143. package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
  144. package/dist/tokens/index.d.ts +2 -0
  145. package/dist/tokens/index.d.ts.map +1 -0
  146. package/dist/tokens/index.js +3 -0
  147. package/dist/tokens/index.js.map +1 -0
  148. package/dist/tokens/token-codec.d.ts +54 -0
  149. package/dist/tokens/token-codec.d.ts.map +1 -0
  150. package/dist/tokens/token-codec.js +62 -0
  151. package/dist/tokens/token-codec.js.map +1 -0
  152. package/package.json +35 -2
  153. package/src/access/__tests__/project-access-input.test.ts +175 -0
  154. package/src/access/index.ts +9 -0
  155. package/src/access/project-access-input.ts +145 -0
  156. package/src/compute/__tests__/client-cache.test.ts +81 -1
  157. package/src/compute/__tests__/definition-byte-cache.test.ts +129 -0
  158. package/src/compute/__tests__/rate-limit.test.ts +37 -0
  159. package/src/compute/__tests__/solve-cache.test.ts +268 -0
  160. package/src/compute/__tests__/solve-pipeline.test.ts +380 -14
  161. package/src/compute/__tests__/transform-input.test.ts +8 -1
  162. package/src/compute/client-cache.ts +59 -16
  163. package/src/compute/definition-byte-cache.ts +168 -0
  164. package/src/compute/index.ts +34 -0
  165. package/src/compute/limits.ts +91 -1
  166. package/src/compute/memory-solve-cache.ts +0 -0
  167. package/src/compute/rate-limit.ts +27 -0
  168. package/src/compute/solve-cache-envelope.ts +98 -0
  169. package/src/compute/solve-cache-key.ts +85 -0
  170. package/src/compute/solve-cache-single-flight.ts +59 -0
  171. package/src/compute/solve-pipeline.ts +301 -22
  172. package/src/compute/transform-input.ts +20 -6
  173. package/src/definitions/__tests__/definition-service.test.ts +131 -0
  174. package/src/definitions/definition-service.ts +8 -4
  175. package/src/errors/SentryErrorReporter.ts +88 -0
  176. package/src/errors/index.ts +3 -0
  177. package/src/http/__tests__/body-size.test.ts +27 -0
  178. package/src/http/__tests__/route-classifier.test.ts +60 -0
  179. package/src/http/__tests__/safe-redirect.test.ts +47 -0
  180. package/src/http/__tests__/security-headers.test.ts +29 -0
  181. package/src/http/body-size.ts +24 -0
  182. package/src/http/index.ts +10 -0
  183. package/src/http/redirect.ts +16 -0
  184. package/src/http/route-classifier.ts +74 -0
  185. package/src/http/security-headers.ts +42 -0
  186. package/src/index.ts +11 -2
  187. package/src/ops/__tests__/semver.test.ts +42 -0
  188. package/src/ops/index.ts +3 -0
  189. package/src/ops/semver.ts +60 -0
  190. package/src/tokens/__tests__/token-codec.test.ts +63 -0
  191. package/src/tokens/index.ts +8 -0
  192. package/src/tokens/token-codec.ts +86 -0
@@ -0,0 +1,168 @@
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
+ }
@@ -3,11 +3,44 @@
3
3
  export {
4
4
  resolveComputeLimits,
5
5
  readPositiveInt,
6
+ readNonNegativeInt,
6
7
  readBool,
7
8
  type ComputeLimits,
8
9
  type EnvRecord
9
10
  } from './limits.js';
10
11
 
12
+ export {
13
+ createDefinitionByteCache,
14
+ type DefinitionByteCache,
15
+ type ByteCacheRef,
16
+ type ByteCacheStats
17
+ } from './definition-byte-cache.js';
18
+
19
+ export {
20
+ createMemorySolveResultCache,
21
+ type MemorySolveResultCache,
22
+ type SolveCacheStats
23
+ } from './memory-solve-cache.js';
24
+
25
+ export {
26
+ deriveSolveCacheInputKey,
27
+ type SolveCacheConfigSubset,
28
+ type SolveCacheInputKey
29
+ } from './solve-cache-key.js';
30
+
31
+ export {
32
+ encodeSolveCacheEntry,
33
+ decodeSolveCacheEntry,
34
+ gunzipEntryBody,
35
+ type EnvelopeHeader,
36
+ type DecodedSolveCacheEntry
37
+ } from './solve-cache-envelope.js';
38
+
39
+ export {
40
+ createSolveCacheSingleFlight,
41
+ type SolveCacheSingleFlight
42
+ } from './solve-cache-single-flight.js';
43
+
11
44
  export {
12
45
  createComputeRateLimiter,
13
46
  type ComputeRateLimiter,
@@ -41,6 +74,7 @@ export {
41
74
  COMPUTE_CONTRACT_VERSION,
42
75
  COMPUTE_VERSION_HEADER,
43
76
  type SolvePipelineArgs,
77
+ type SolvePipelineCacheHook,
44
78
  type SolveOutcome,
45
79
  type SolveEnvelope,
46
80
  type SolvePhaseMetrics,
@@ -38,6 +38,23 @@ export function readPositiveInt(env: EnvRecord, name: string, fallback: number):
38
38
  return Math.floor(parsed);
39
39
  }
40
40
 
41
+ /**
42
+ * Parse a non-negative integer env value — like {@link readPositiveInt} but `0`
43
+ * is a valid value (used by knobs where `0` means "disable", e.g. a cache
44
+ * budget). Returns `fallback` when absent, and warns + falls back when present
45
+ * but not a finite non-negative number.
46
+ */
47
+ export function readNonNegativeInt(env: EnvRecord, name: string, fallback: number): number {
48
+ const raw = env[name];
49
+ if (!raw) return fallback;
50
+ const parsed = Number(raw);
51
+ if (!Number.isFinite(parsed) || parsed < 0) {
52
+ console.warn(`[selva] Invalid ${name}=${raw}, falling back to ${fallback}`);
53
+ return fallback;
54
+ }
55
+ return Math.floor(parsed);
56
+ }
57
+
41
58
  /**
42
59
  * Parse a boolean env flag. Accepts `true/1/yes/on` (case-insensitive) as true
43
60
  * and `false/0/no/off` as false; any other / absent value falls back.
@@ -115,6 +132,61 @@ export interface ComputeLimits {
115
132
  * honoring the flag. Conservative default off.
116
133
  */
117
134
  computeCacheErroredSolves: boolean;
135
+ /**
136
+ * Max in-flight solves this app instance sends to ONE compute server
137
+ * (forwarded as the SolveScheduler's `maxConcurrent`; excess solves FIFO-queue).
138
+ * Size it to the server's `compute.geometry` child count — Rhino.Compute
139
+ * spawns N children per VM precisely to run N solves concurrently, and a
140
+ * lower value idles that capacity while users queue (audit B6: the scheduler's
141
+ * queue-mode default is 1, which serialized ALL solves per server). Multiple
142
+ * app instances each apply their own cap.
143
+ */
144
+ computeMaxConcurrentSolves: number;
145
+ /**
146
+ * Backpressure — max solves allowed to WAIT in the per-server FIFO queue (i.e.
147
+ * excluding the `computeMaxConcurrentSolves` already in flight). A solve that
148
+ * arrives to a full queue is shed immediately (scheduler `QUEUE_FULL`, mapped
149
+ * to HTTP 503 + `Retry-After`) instead of piling up unbounded. `0` = unbounded
150
+ * (the pre-shedding default): the queue grows without limit, matching prior
151
+ * behavior. Size it to roughly 2–3× `computeMaxConcurrentSolves` once the
152
+ * Rhino pool's real capacity is known — shedding fast beats a hung request.
153
+ */
154
+ computeMaxQueueDepth: number;
155
+ /**
156
+ * Backpressure — longest (ms) a solve may sit QUEUED before it starts
157
+ * executing; still-waiting past this it's shed (scheduler `QUEUE_TIMEOUT` →
158
+ * HTTP 503 + `Retry-After`) rather than burning compute on a stale request.
159
+ * Bounds tail latency: the scheduler's `timeoutMs` clock starts at execution,
160
+ * so without this a solve's total wait is unbounded and invisible. `0` = no
161
+ * queue deadline (the default). A sensible tuned value is ≈ `maxSolveDurationMs`.
162
+ */
163
+ computeQueueWaitMs: number;
164
+ /**
165
+ * Total-byte budget (bytes) for the in-process definition-byte cache keyed on
166
+ * immutable version id. A warm entry lets a solve skip `storage.get` entirely,
167
+ * and a pointer-known solve never loads bytes at all. `0` disables the cache
168
+ * (every solve re-reads storage). Sized as a total-byte LRU because definitions
169
+ * span KB→hundreds of MB; env value is MB, converted here.
170
+ */
171
+ computeDefinitionByteCacheBytes: number;
172
+ /**
173
+ * Default per-definition entry quota for the durable L2 solve cache (H1), used
174
+ * when a definition's `solveCacheLimit` is absent (inherit). A definition may
175
+ * override this; `0` on the definition turns caching off for it. This global
176
+ * default `0` means the L2 is effectively off until an operator sets a quota —
177
+ * safe because the memory backend also ships only under
178
+ * `SOLVE_CACHE_PROVIDER=memory`. Counts, not bytes: authors think in "how many
179
+ * solves to remember"; the byte backstop below is the operator's memory guard.
180
+ */
181
+ solveCacheDefaultMaxEntries: number;
182
+ /**
183
+ * Global byte backstop (bytes) across ALL definitions' L2 entries, evicted
184
+ * global-LRU regardless of per-definition counts. Entries range KB→100s of MB,
185
+ * so a count quota alone can't bound memory. `0` disables the backstop (rely on
186
+ * per-definition counts only — not recommended in the memory backend). Env value
187
+ * is MB, converted here. On Redis this moves to `maxmemory allkeys-lru`.
188
+ */
189
+ solveCacheMaxTotalBytes: number;
118
190
  }
119
191
 
120
192
  /**
@@ -142,6 +214,24 @@ export function resolveComputeLimits(env: EnvRecord): ComputeLimits {
142
214
  definitionCacheTtlMs: readPositiveInt(env, 'DEFINITION_CACHE_TTL_MS', 5 * 60 * 1000),
143
215
  computeReuseDefinitionCache: readBool(env, 'COMPUTE_REUSE_DEFINITION_CACHE', true),
144
216
  computeServerCachesolve: readBool(env, 'COMPUTE_SERVER_CACHESOLVE', true),
145
- computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false)
217
+ computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false),
218
+ // 4 matches rhino.compute's default --childcount; tune to the actual VM.
219
+ computeMaxConcurrentSolves: readPositiveInt(env, 'COMPUTE_MAX_CONCURRENT', 4),
220
+ // Both 0 (unbounded / no deadline) by default — nothing sheds until an
221
+ // operator who's measured their pool opts in. readNonNegativeInt so `0` is a
222
+ // valid "disabled" value, not treated as invalid.
223
+ computeMaxQueueDepth: readNonNegativeInt(env, 'COMPUTE_MAX_QUEUE_DEPTH', 0),
224
+ computeQueueWaitMs: readNonNegativeInt(env, 'COMPUTE_QUEUE_WAIT_MS', 0),
225
+ // Env is MB; 0 disables. Default 256 MB holds a handful of typical
226
+ // definitions warm without pinning gigabytes. readNonNegativeInt so `0`
227
+ // (disable) is honored rather than treated as invalid.
228
+ computeDefinitionByteCacheBytes:
229
+ readNonNegativeInt(env, 'COMPUTE_DEFINITION_BYTE_CACHE_MB', 256) * MB,
230
+ // L2 durable solve cache (H1). Default per-definition quota 0 = inherit-to-off
231
+ // until an operator opts in; the memory backend also only mounts under
232
+ // SOLVE_CACHE_PROVIDER=memory, so nothing caches by accident. Byte backstop
233
+ // 512 MB caps total heap use across all definitions (env is MB).
234
+ solveCacheDefaultMaxEntries: readNonNegativeInt(env, 'SOLVE_CACHE_DEFAULT_MAX_ENTRIES', 0),
235
+ solveCacheMaxTotalBytes: readNonNegativeInt(env, 'SOLVE_CACHE_MAX_TOTAL_MB', 512) * MB
146
236
  };
147
237
  }
@@ -18,6 +18,10 @@
18
18
  *
19
19
  * The window + cap are injected (they live in `ComputeLimits`), so this module
20
20
  * reads no env of its own.
21
+ *
22
+ * Not compute-only despite the name: `peek` + `clear` make it usable for
23
+ * failure-counting flows too (login limiting keys by IP, records only failed
24
+ * attempts via `check`, and forgives on success via `clear`).
21
25
  */
22
26
 
23
27
  export interface RateLimitResult {
@@ -39,6 +43,14 @@ export interface ComputeRateLimiter {
39
43
  * Returns `{ allowed: false, retryAfter }` once the bucket is full.
40
44
  */
41
45
  check(key: string): RateLimitResult;
46
+ /**
47
+ * Check WITHOUT recording. For flows that only count selected outcomes —
48
+ * e.g. login limiting, where `peek` gates the attempt and `check` records
49
+ * only failures (a success calls `clear` instead).
50
+ */
51
+ peek(key: string): RateLimitResult;
52
+ /** Drop `key`'s bucket — e.g. a successful login forgives prior failures. */
53
+ clear(key: string): void;
42
54
  /** Test seam — wipes in-memory state. Production code never calls this. */
43
55
  reset(): void;
44
56
  /** The resolved config, exposed for time-based test assertions. */
@@ -76,6 +88,21 @@ export function createComputeRateLimiter(config: ComputeRateLimiterConfig): Comp
76
88
  entry.count += 1;
77
89
  return { allowed: true };
78
90
  },
91
+ peek(key: string): RateLimitResult {
92
+ const now = Date.now();
93
+ const entry = buckets.get(key);
94
+
95
+ if (!entry || now > entry.resetAt) {
96
+ return { allowed: true };
97
+ }
98
+ if (entry.count >= maxPerWindow) {
99
+ return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
100
+ }
101
+ return { allowed: true };
102
+ },
103
+ clear(key: string): void {
104
+ buckets.delete(key);
105
+ },
79
106
  reset(): void {
80
107
  buckets.clear();
81
108
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * L2 cache entry format (R6). An entry is the gzipped response body plus a tiny
3
+ * metadata header, so a hit is near-CPU-free: no `JSON.stringify`, no re-solve —
4
+ * gzip clients get the stored bytes verbatim, and the header carries just enough
5
+ * to rebuild the `ok` outcome (error/warning counts for the metric, uncompressed
6
+ * length for the `Content-Length` on a gunzip fallback).
7
+ *
8
+ * Wire layout (opaque to the `ISolveResultCache` backend, which sees only bytes):
9
+ *
10
+ * [ 4 bytes: header length N, big-endian uint32 ]
11
+ * [ N bytes: UTF-8 JSON EnvelopeHeader ]
12
+ * [ rest: gzipped JSON response body ]
13
+ *
14
+ * The `algo` echo (M3) is already stripped at the source in the package's
15
+ * `runSolve`, so the body never carries the base64 definition — entries stay
16
+ * small without a strip step here.
17
+ */
18
+
19
+ import { gunzipSync } from 'node:zlib';
20
+
21
+ /** Metadata stored beside the gzipped body. Small; parsed on every hit. */
22
+ export interface EnvelopeHeader {
23
+ /** Grasshopper runtime error count, for the solve metric. */
24
+ errorCount: number;
25
+ /** Grasshopper runtime warning count, for the solve metric. */
26
+ warningCount: number;
27
+ /** Uncompressed JSON body length in bytes (Content-Length for a gunzip fallback). */
28
+ serializedBytes: number;
29
+ /**
30
+ * SHA-256 hex of the canonical input preimage (H2 defense-in-depth). A hit
31
+ * verifies this equals the requesting key's hash before serving — a wide-hash
32
+ * collision that produced the same storage key still can't serve the wrong
33
+ * geometry.
34
+ */
35
+ inputHash: string;
36
+ }
37
+
38
+ /**
39
+ * Encode a cache entry from the gzipped body + header. The body MUST already be
40
+ * gzipped (the pipeline gzips before storing so every hit serves compressed
41
+ * bytes; a rare non-gzip client gunzips on read).
42
+ */
43
+ export function encodeSolveCacheEntry(header: EnvelopeHeader, gzippedBody: Uint8Array): Uint8Array {
44
+ const headerJson = Buffer.from(JSON.stringify(header), 'utf8');
45
+ const out = Buffer.allocUnsafe(4 + headerJson.byteLength + gzippedBody.byteLength);
46
+ out.writeUInt32BE(headerJson.byteLength, 0);
47
+ headerJson.copy(out, 4);
48
+ Buffer.from(gzippedBody.buffer, gzippedBody.byteOffset, gzippedBody.byteLength).copy(
49
+ out,
50
+ 4 + headerJson.byteLength
51
+ );
52
+ return out;
53
+ }
54
+
55
+ /** A decoded entry: the parsed header + the still-gzipped body slice. */
56
+ export interface DecodedSolveCacheEntry {
57
+ header: EnvelopeHeader;
58
+ gzippedBody: Uint8Array;
59
+ }
60
+
61
+ /**
62
+ * Decode a cache entry. Returns `null` on any malformed input (truncated, bad
63
+ * length prefix, non-JSON header) — a corrupt entry is treated as a miss, never
64
+ * an error, matching the best-effort cache contract.
65
+ */
66
+ export function decodeSolveCacheEntry(bytes: Uint8Array): DecodedSolveCacheEntry | null {
67
+ if (bytes.byteLength < 4) return null;
68
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
69
+ const headerLen = buf.readUInt32BE(0);
70
+ const headerEnd = 4 + headerLen;
71
+ if (headerEnd > buf.byteLength) return null;
72
+ let header: EnvelopeHeader;
73
+ try {
74
+ header = JSON.parse(buf.toString('utf8', 4, headerEnd)) as EnvelopeHeader;
75
+ } catch {
76
+ return null;
77
+ }
78
+ if (
79
+ typeof header?.errorCount !== 'number' ||
80
+ typeof header?.warningCount !== 'number' ||
81
+ typeof header?.serializedBytes !== 'number' ||
82
+ typeof header?.inputHash !== 'string'
83
+ ) {
84
+ return null;
85
+ }
86
+ // Slice (view, no copy) of the gzipped remainder.
87
+ const gzippedBody = new Uint8Array(
88
+ bytes.buffer,
89
+ bytes.byteOffset + headerEnd,
90
+ bytes.byteLength - headerEnd
91
+ );
92
+ return { header, gzippedBody };
93
+ }
94
+
95
+ /** Gunzip a stored body back to the JSON string, for a non-gzip client (rare). */
96
+ export function gunzipEntryBody(gzippedBody: Uint8Array): string {
97
+ return gunzipSync(gzippedBody).toString('utf8');
98
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Durable L2 cache-key derivation (H2 / R8 / R13).
3
+ *
4
+ * The in-process L1 cache keys on a 32-bit FNV hash — fine for a 20-entry Map,
5
+ * unsafe for a durable, cross-user cache where a collision serves user A's
6
+ * geometry to user B (CONTEXT.md L92–97 records exactly this bug shipping once).
7
+ * So the L2 key is a wide SHA-256 hash, and the entry stores the canonical
8
+ * preimage so a hit can compare it byte-for-byte (defense-in-depth).
9
+ *
10
+ * What goes into the key:
11
+ * - The **transformed** input tree (R13) — the exact tree handed to the
12
+ * scheduler, canonicalized with the package's exported `stableStringify` so
13
+ * the L2 key parity-matches the L1 canonicalization path.
14
+ * - A **config subset** (R8): everything OTHER than definition+inputs that
15
+ * changes a solve's result — model units, tolerances, `dataversion`, the
16
+ * compute contract version, and (when known) the Rhino server identity. A
17
+ * durable key of `(versionId, inputHash)` alone would collide across
18
+ * differing server configs or Rhino versions.
19
+ *
20
+ * `versionId`, `definitionId`, and `orgId` are NOT hashed in here — they're
21
+ * separate parts of the {@link SolveCacheKey} tuple so the backend can scope its
22
+ * keyspace (per-definition quota, per-org isolation) without unpacking the hash.
23
+ */
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import { stableStringify } from '@selvajs/compute';
27
+ import { COMPUTE_CONTRACT_VERSION } from './solve-pipeline.js';
28
+
29
+ /**
30
+ * Solve-affecting configuration folded into the key alongside the input tree.
31
+ * Every field here is something that, if it changed, would legitimately change
32
+ * the solve output for the SAME definition + inputs. Assemble it from the app's
33
+ * resolved solve config; unset fields are simply absent from the canonical form.
34
+ */
35
+ export interface SolveCacheConfigSubset {
36
+ /** Rhino model unit system, if the app pins one (else the definition/server default). */
37
+ modelUnits?: string;
38
+ /** Absolute tolerance, if pinned. */
39
+ absoluteTolerance?: number;
40
+ /** Angle tolerance, if pinned. */
41
+ angleTolerance?: number;
42
+ /** Grasshopper `dataversion` / data-tree schema version, if pinned. */
43
+ dataVersion?: number | string;
44
+ /**
45
+ * Compute-server identity (id or URL). Folds in because two servers can run
46
+ * different Rhino/plugin versions that yield different geometry for the same
47
+ * inputs. Optional — omit for a single-pool deployment where it's constant.
48
+ */
49
+ computeServerId?: string;
50
+ }
51
+
52
+ /** The canonical preimage + its SHA-256 digest. Store both in the L2 entry header. */
53
+ export interface SolveCacheInputKey {
54
+ /** The canonical string that was hashed (for hit-time byte comparison). */
55
+ canonical: string;
56
+ /** SHA-256 hex of {@link canonical} — the `inputKey` part of the storage key. */
57
+ hash: string;
58
+ }
59
+
60
+ /**
61
+ * Derive the durable `inputKey` from the transformed input tree and the solve
62
+ * config subset. Deterministic and stable across processes/instances: the same
63
+ * tree + config always produce the same hash, which is what lets a shared cache
64
+ * hit across app instances.
65
+ *
66
+ * @param transformedTree the tree AFTER `transformInputParameter`/`TreeBuilder`
67
+ * (R13) — the exact object handed to `scheduler.solve`.
68
+ * @param config solve-affecting config (R8); `COMPUTE_CONTRACT_VERSION` is folded
69
+ * in automatically so a wire-contract bump invalidates old entries for free.
70
+ */
71
+ export function deriveSolveCacheInputKey(
72
+ transformedTree: unknown,
73
+ config: SolveCacheConfigSubset
74
+ ): SolveCacheInputKey {
75
+ // stableStringify sorts keys deterministically — same content, same string,
76
+ // regardless of property insertion order. Tag each part so a value can't shift
77
+ // across the tree/config boundary and collide (e.g. tree "]" meeting config "[").
78
+ const canonical = stableStringify({
79
+ v: COMPUTE_CONTRACT_VERSION,
80
+ tree: transformedTree,
81
+ config
82
+ });
83
+ const hash = createHash('sha256').update(canonical).digest('hex');
84
+ return { canonical, hash };
85
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * In-process single-flight (R4) — dogpile protection above the L2 cache.
3
+ *
4
+ * N identical live solves arriving while the first is still running would each
5
+ * enqueue and each hit Rhino (the scheduler has no in-flight coalescing, and
6
+ * Rhino's `cachesolve` still costs a round trip per repeat). The classic cold-key
7
+ * stampede: a hot public definition + a deploy = a thundering herd onto compute.
8
+ *
9
+ * This coalesces them: the first caller for a key runs the work; concurrent
10
+ * callers for the SAME key await the same promise and share its result. Once the
11
+ * work settles (resolve OR reject), the key is released so the next request runs
12
+ * fresh — the map never retains a settled entry, so it can't serve a stale
13
+ * result or leak memory.
14
+ *
15
+ * It sits ABOVE the `ISolveResultCache` interface (in-process, per app instance).
16
+ * A cross-instance lease (Redis `SET NX`) is a later backend capability; this is
17
+ * the free first slice that covers the common same-instance burst.
18
+ *
19
+ * Correctness note: the shared promise resolves to ONE value for all waiters, so
20
+ * the wrapped work must return an immutable / independently-serializable result
21
+ * (the solve pipeline returns a fresh envelope object — safe to share by
22
+ * reference here because the app serializes it per response).
23
+ */
24
+
25
+ export interface SolveCacheSingleFlight {
26
+ /**
27
+ * Run `work` under `key`, coalescing concurrent identical calls. The first
28
+ * caller executes; overlapping callers for the same key await the same promise.
29
+ * The key is freed as soon as the promise settles (success or failure).
30
+ */
31
+ run<T>(key: string, work: () => Promise<T>): Promise<T>;
32
+ /** Number of keys currently in flight (observability / tests). */
33
+ inFlight(): number;
34
+ }
35
+
36
+ export function createSolveCacheSingleFlight(): SolveCacheSingleFlight {
37
+ const inflight = new Map<string, Promise<unknown>>();
38
+
39
+ return {
40
+ run<T>(key: string, work: () => Promise<T>): Promise<T> {
41
+ const existing = inflight.get(key);
42
+ if (existing) return existing as Promise<T>;
43
+
44
+ // `.finally` releases the key on both settle paths. Start the work inside
45
+ // the promise chain so a synchronous throw in `work` still rejects the
46
+ // shared promise (and still frees the key) rather than escaping.
47
+ const p = (async () => work())().finally(() => {
48
+ // Only delete if it's still OUR promise — a key freed and re-taken by a
49
+ // later caller must not be clobbered.
50
+ if (inflight.get(key) === p) inflight.delete(key);
51
+ });
52
+ inflight.set(key, p);
53
+ return p;
54
+ },
55
+ inFlight(): number {
56
+ return inflight.size;
57
+ }
58
+ };
59
+ }