@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
@@ -20,11 +20,29 @@
20
20
  * see {@link COMPUTE_CONTRACT_VERSION} and {@link COMPUTE_VERSION_HEADER}.
21
21
  */
22
22
 
23
- import { TreeBuilder, type GrasshopperComputeResponse, type InputParam } from '@selvajs/compute';
23
+ import {
24
+ ErrorCodes,
25
+ RhinoComputeError,
26
+ TreeBuilder,
27
+ type GrasshopperComputeResponse,
28
+ type InputParam,
29
+ type SolveDefinition
30
+ } from '@selvajs/compute';
24
31
  import type { SchemaInput } from '@selvajs/schemas';
25
- import { gzipSync } from 'node:zlib';
32
+ import { gzip } from 'node:zlib';
33
+ import { promisify } from 'node:util';
26
34
  import { transformInputParameter } from './transform-input.js';
27
35
  import type { CachedClient } from './client-cache.js';
36
+ import type { ByteRefOutcome } from './definition-byte-cache.js';
37
+ import { deriveSolveCacheInputKey, type SolveCacheConfigSubset } from './solve-cache-key.js';
38
+ import {
39
+ decodeSolveCacheEntry,
40
+ encodeSolveCacheEntry,
41
+ gunzipEntryBody
42
+ } from './solve-cache-envelope.js';
43
+
44
+ /** Async gzip (audit B8) — off the event loop, unlike the old `gzipSync`. */
45
+ const gzipAsync = promisify(gzip);
28
46
 
29
47
  // ============================================================================
30
48
  // Wire contract version (audit D5)
@@ -53,8 +71,20 @@ export type PipelineInput = SchemaInput & {
53
71
  };
54
72
 
55
73
  export interface SolvePipelineArgs {
56
- /** The `.gh` bytes to solve (stored blob or remote fetch — resolved by the caller). */
57
- definitionSource: Uint8Array;
74
+ /**
75
+ * The definition to solve. Either raw `.gh` bytes (remote fetch, or a caller
76
+ * that resolved them eagerly) or a `DefinitionRef` — an identity-keyed lazy
77
+ * loader (from the definition-byte cache) whose bytes the scheduler
78
+ * materializes ONLY when an upload is unavoidable. A pointer-known solve of a
79
+ * `DefinitionRef` moves zero bytes.
80
+ */
81
+ definitionSource: SolveDefinition;
82
+ /**
83
+ * When `definitionSource` is a byte-cache `DefinitionRef`, its mutable outcome
84
+ * so the pipeline can emit the `def_bytes` Server-Timing verdict (skipped =
85
+ * `load` never ran, hit = warm cache, miss = loader). Omit for raw-bytes solves.
86
+ */
87
+ byteRefOutcome?: ByteRefOutcome;
58
88
  /** Persisted input params; only those with a `paramType` are sent to the solve. */
59
89
  inputs: PipelineInput[];
60
90
  /** User-chosen values keyed by input id; missing keys fall back to the schema default. */
@@ -86,6 +116,31 @@ export interface SolvePipelineArgs {
86
116
  * Server-Timing entries. The caller assembles these; the pipeline only echoes.
87
117
  */
88
118
  prepMarks?: [string, number][];
119
+ /**
120
+ * Optional durable L2 solve cache (H1). Present ONLY for live-channel solves
121
+ * whose definition has a caching quota > 0 — its presence IS the channel gate
122
+ * (zero policy conditionals in the pipeline). When set, the pipeline derives
123
+ * the wide input key from the transformed tree + `configSubset`, checks
124
+ * `lookup` before solving (a hit skips compute entirely, returning the stored
125
+ * gzipped envelope), and write-throughs `store` on a successful solve.
126
+ */
127
+ solveCache?: SolvePipelineCacheHook;
128
+ }
129
+
130
+ /**
131
+ * The app-supplied L2 hook. The app binds `lookup`/`store` to the org/definition/
132
+ * version scope and the definition's quota; the pipeline supplies the SHA-256
133
+ * `inputKey` (derived from the transformed tree + `configSubset`) and the opaque
134
+ * encoded entry bytes. Both are best-effort — `lookup` may miss/throw (treated as
135
+ * a miss), `store` is fire-and-forget.
136
+ */
137
+ export interface SolvePipelineCacheHook {
138
+ /** Solve-affecting config folded into the key (R8) — model units, tolerances, server id. */
139
+ configSubset: SolveCacheConfigSubset;
140
+ /** Fetch a stored entry for `inputKey`, or `null` on miss. Must not reject fatally. */
141
+ lookup(inputKey: string): Promise<Uint8Array | null>;
142
+ /** Store the encoded entry for `inputKey`. Fire-and-forget; best-effort. */
143
+ store(inputKey: string, entry: Uint8Array): void;
89
144
  }
90
145
 
91
146
  /** Phase timings the pipeline measured; the caller uses them for debug logging. */
@@ -99,6 +154,12 @@ export interface SolvePhaseMetrics {
99
154
  serializedBytes: number;
100
155
  /** Gzipped length in bytes, or null when compression was skipped. */
101
156
  compressedBytes: number | null;
157
+ /**
158
+ * L2 durable-cache verdict: `true` = served from L2 (no solve ran), `false` =
159
+ * L2 consulted and missed (solved), `null` = L2 not consulted (no hook — draft
160
+ * or caching off). Drives the `l2_cache` Server-Timing entry.
161
+ */
162
+ l2CacheHit: boolean | null;
102
163
  }
103
164
 
104
165
  /** A ready-to-send response: body + headers + the solve result + phase metrics. */
@@ -109,8 +170,13 @@ export interface SolveEnvelope {
109
170
  encoding?: 'gzip';
110
171
  /** Fully-assembled response headers (Content-Type/-Length/-Encoding, Vary, Server-Timing, version). */
111
172
  headers: Record<string, string>;
112
- /** The solved definition — the caller reads `errors`/`warnings` for its metric record. */
113
- result: GrasshopperComputeResponse;
173
+ /**
174
+ * The solved definition object, present only on a fresh (non-L2) solve. Absent
175
+ * on an L2 hit — the pipeline serves the stored gzipped body without ever
176
+ * materializing the object. The caller reads `errorCount`/`warningCount` off
177
+ * the `ok` outcome for its metric record, not this field.
178
+ */
179
+ result?: GrasshopperComputeResponse;
114
180
  /** Phase timings, for the caller's optional debug breakdown. */
115
181
  metrics: SolvePhaseMetrics;
116
182
  }
@@ -118,9 +184,10 @@ export interface SolveEnvelope {
118
184
  /**
119
185
  * Discriminated result. `ok` carries the envelope; every other variant names an
120
186
  * expected failure the transport maps to a status code (the app route maps
121
- * timeout→504, client_abort→499, too_large→413; `compute_error` re-surfaces the
122
- * original error for the generic 500/503 path). `durationMs` on the error
123
- * variants is the solve wall time up to the failure, for the metric record.
187
+ * timeout→504, client_abort→499, too_large→413, shed→503+Retry-After;
188
+ * `compute_error` re-surfaces the original error for the generic 500/503 path).
189
+ * `durationMs` on the error variants is the solve wall time up to the failure,
190
+ * for the metric record.
124
191
  */
125
192
  export type SolveOutcome =
126
193
  | {
@@ -133,6 +200,19 @@ export type SolveOutcome =
133
200
  | { kind: 'timeout'; durationMs: number; message: string }
134
201
  | { kind: 'client_abort'; durationMs: number }
135
202
  | { kind: 'too_large' }
203
+ /**
204
+ * Scheduler backpressure shed the solve before it executed — the per-server
205
+ * queue was full (`QUEUE_FULL`) or it sat queued past the wait deadline
206
+ * (`QUEUE_TIMEOUT`). Retryable: the route maps this to 503 + `Retry-After`.
207
+ * `retryAfterSeconds` is a suggested backoff hint for the client.
208
+ */
209
+ | {
210
+ kind: 'shed';
211
+ durationMs: number;
212
+ reason: 'queue_full' | 'queue_timeout';
213
+ retryAfterSeconds: number;
214
+ message: string;
215
+ }
136
216
  | { kind: 'compute_error'; durationMs: number; error: unknown };
137
217
 
138
218
  // ============================================================================
@@ -155,12 +235,38 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
155
235
  );
156
236
  const treeBuildMs = performance.now() - treeBuildStart;
157
237
 
238
+ // --- L2 durable cache lookup (H1) ---------------------------------------
239
+ // Derive the wide key from the TRANSFORMED tree (R13) + config subset (R8),
240
+ // then check the injected hook. Present only for cacheable live solves — its
241
+ // presence is the channel gate. A hit skips the solve entirely and serves the
242
+ // stored gzipped envelope; the derived key is reused for the write-through on
243
+ // a miss. Lookup is best-effort: any rejection is swallowed to a miss.
244
+ let inputKey: string | null = null;
245
+ if (args.solveCache) {
246
+ inputKey = deriveSolveCacheInputKey(inputTree, args.solveCache.configSubset).hash;
247
+ let stored: Uint8Array | null = null;
248
+ try {
249
+ stored = await args.solveCache.lookup(inputKey);
250
+ } catch {
251
+ stored = null;
252
+ }
253
+ if (stored) {
254
+ const hit = buildEnvelopeFromCacheEntry(stored, inputKey, args);
255
+ if (hit) return hit;
256
+ // Corrupt/tampered entry → fall through and solve as a miss.
257
+ }
258
+ }
259
+
158
260
  // --- solve --------------------------------------------------------------
159
- // Reset the per-request telemetry holders so a Selva-cache hit (no compute
160
- // call) isn't attributed a stale timing from an earlier request on this warm
161
- // client. See the rhinoTiming/solveMeta concurrency caveat in client-cache.
162
- client.rhinoTiming.last = null;
163
- client.solveMeta.last = null;
261
+ // Snapshot the shared telemetry sequence counters. The holders live on the
262
+ // warm client and are written by every request on this server; with
263
+ // maxConcurrentSolves > 1 another request can write them mid-flight. After
264
+ // the solve, a slot is attributed to THIS request only when exactly one
265
+ // write happened since this snapshot — for solveMeta that single settle is
266
+ // necessarily ours (onSettle fires before scheduler.solve() resolves).
267
+ // Anything ambiguous is dropped from Server-Timing, never misattributed.
268
+ const settleSeqBefore = client.solveMeta.seq;
269
+ const rhinoSeqBefore = client.rhinoTiming.seq;
164
270
 
165
271
  let result: GrasshopperComputeResponse;
166
272
  const solveStart = performance.now();
@@ -180,6 +286,30 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
180
286
  message: `Solve exceeded the ${Math.round(args.maxSolveDurationMs / 1000)}s deadline.`
181
287
  };
182
288
  }
289
+ // Scheduler backpressure (audit B7): a full queue or an over-deadline queue
290
+ // wait sheds the solve BEFORE compute runs. Classify these as `shed` (503 +
291
+ // Retry-After) rather than the generic 500 `compute_error` path — they're
292
+ // load signals, not failures, and the client should back off and retry.
293
+ if (err instanceof RhinoComputeError) {
294
+ if (err.code === ErrorCodes.QUEUE_FULL) {
295
+ return {
296
+ kind: 'shed',
297
+ durationMs,
298
+ reason: 'queue_full',
299
+ retryAfterSeconds: 1,
300
+ message: 'Compute server is at capacity. Retry shortly.'
301
+ };
302
+ }
303
+ if (err.code === ErrorCodes.QUEUE_TIMEOUT) {
304
+ return {
305
+ kind: 'shed',
306
+ durationMs,
307
+ reason: 'queue_timeout',
308
+ retryAfterSeconds: 1,
309
+ message: 'Compute request waited too long in the queue. Retry shortly.'
310
+ };
311
+ }
312
+ }
183
313
  return { kind: 'compute_error', durationMs, error: err };
184
314
  }
185
315
  const solveMs = performance.now() - solveStart;
@@ -203,16 +333,59 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
203
333
  // in Server-Timing, included in `total`). Buffered (not streamed) so
204
334
  // Content-Length is known and a connection cut mid-transfer fails hard
205
335
  // instead of truncating the JSON.
336
+ // gzip is async (audit B8) — a multi-MB `gzipSync` blocked the event loop for
337
+ // every other request on this instance. Two independent reasons to compress:
338
+ // - the client advertised gzip AND the body clears the 1 KB break-even (tiny
339
+ // bodies aren't worth compressing for the wire);
340
+ // - an L2 write-through is pending — the entry stores compressed bytes so
341
+ // every future hit is near-CPU-free, so we compress even a small body (it
342
+ // will be cached), just not for the wire below the threshold.
343
+ const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
344
+ const worthSendingGzip = clientWantsGzip && serialized.length > 1024;
345
+ const willCache = args.solveCache != null;
206
346
  let compressed: Buffer | null = null;
207
347
  let gzipMs = 0;
208
- if (/\bgzip\b/i.test(args.acceptEncoding) && serialized.length > 1024) {
348
+ if (worthSendingGzip || willCache) {
209
349
  const gzipStart = performance.now();
210
- compressed = gzipSync(Buffer.from(serialized));
350
+ compressed = await gzipAsync(Buffer.from(serialized));
211
351
  gzipMs = performance.now() - gzipStart;
212
352
  }
213
353
 
354
+ // --- L2 write-through (H1) ----------------------------------------------
355
+ // On a cacheable solve with a compressed body, store the gzipped envelope so
356
+ // future hits are near-CPU-free. Fire-and-forget through the hook; the store
357
+ // itself is best-effort (may silently drop under quota/byte pressure).
358
+ if (args.solveCache && inputKey && compressed) {
359
+ const entry = encodeSolveCacheEntry(
360
+ {
361
+ errorCount: result.errors?.length ?? 0,
362
+ warningCount: result.warnings?.length ?? 0,
363
+ serializedBytes: serialized.length,
364
+ inputHash: inputKey
365
+ },
366
+ new Uint8Array(compressed)
367
+ );
368
+ args.solveCache.store(inputKey, entry);
369
+ }
370
+
214
371
  const serverTotalMs = performance.now() - args.loadStartMs;
215
372
 
373
+ // --- per-request telemetry attribution (see snapshot above) -------------
374
+ // settle: ours iff exactly one settle happened since the snapshot. rhino:
375
+ // only meaningful when the settle is ours AND it was a real compute call
376
+ // (a Selva-cache hit never reaches the server), and only unambiguous when
377
+ // exactly one Server-Timing callback fired in the window.
378
+ const settle = client.solveMeta.seq === settleSeqBefore + 1 ? client.solveMeta.last : null;
379
+ const rhino =
380
+ settle && !settle.fromCache && client.rhinoTiming.seq === rhinoSeqBefore + 1
381
+ ? client.rhinoTiming.last
382
+ : null;
383
+
384
+ // Send gzip to the client only when it's worth it for the wire — a body
385
+ // compressed solely to feed the L2 write-through (or one below the 1 KB
386
+ // break-even) goes out uncompressed.
387
+ const sendCompressed = compressed && worthSendingGzip ? compressed : null;
388
+
216
389
  // --- Server-Timing envelope (the versioned wire contract) ---------------
217
390
  const serverTiming = buildServerTiming({
218
391
  defLoadMs: args.defLoadMs,
@@ -221,8 +394,11 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
221
394
  serializeMs,
222
395
  gzipMs,
223
396
  serverTotalMs,
224
- rhino: client.rhinoTiming.last,
225
- settle: client.solveMeta.last,
397
+ rhino,
398
+ settle,
399
+ byteRefOutcome: args.byteRefOutcome,
400
+ // L2 was consulted (hook present) and missed — otherwise not consulted.
401
+ l2CacheHit: args.solveCache ? false : null,
226
402
  prepMarks: args.prepMarks ?? []
227
403
  });
228
404
 
@@ -240,16 +416,23 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
240
416
  gzipMs,
241
417
  serverTotalMs,
242
418
  serializedBytes: serialized.length,
243
- compressedBytes: compressed?.byteLength ?? null
419
+ compressedBytes: sendCompressed?.byteLength ?? null,
420
+ l2CacheHit: args.solveCache ? false : null
244
421
  };
245
422
 
246
423
  let envelope: SolveEnvelope;
247
- if (compressed) {
424
+ if (sendCompressed) {
248
425
  // A downstream proxy's `encode gzip` skips already-encoded responses, so
249
426
  // this never double-compresses. Vary is set on both branches.
250
427
  headers['Content-Encoding'] = 'gzip';
251
- headers['Content-Length'] = String(compressed.byteLength);
252
- envelope = { body: new Uint8Array(compressed), encoding: 'gzip', headers, result, metrics };
428
+ headers['Content-Length'] = String(sendCompressed.byteLength);
429
+ envelope = {
430
+ body: new Uint8Array(sendCompressed),
431
+ encoding: 'gzip',
432
+ headers,
433
+ result,
434
+ metrics
435
+ };
253
436
  } else {
254
437
  headers['Content-Length'] = String(Buffer.byteLength(serialized));
255
438
  envelope = { body: serialized, headers, result, metrics };
@@ -264,6 +447,84 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
264
447
  };
265
448
  }
266
449
 
450
+ // ============================================================================
451
+ // L2 cache-hit envelope
452
+ // ============================================================================
453
+
454
+ /**
455
+ * Build the `ok` outcome from a stored L2 entry — the near-CPU-free hit path (no
456
+ * solve, no `JSON.stringify`, no gzip). Returns `null` when the entry is corrupt
457
+ * or its stored `inputHash` doesn't match the requesting key (defense-in-depth
458
+ * against a wide-hash collision), so the caller falls through and solves.
459
+ */
460
+ function buildEnvelopeFromCacheEntry(
461
+ entryBytes: Uint8Array,
462
+ inputKey: string,
463
+ args: SolvePipelineArgs
464
+ ): (SolveOutcome & { kind: 'ok' }) | null {
465
+ const decoded = decodeSolveCacheEntry(entryBytes);
466
+ // A collision that produced the same storage key still can't serve the wrong
467
+ // geometry: the stored hash must match the requesting key's hash.
468
+ if (!decoded || decoded.header.inputHash !== inputKey) return null;
469
+
470
+ const { header, gzippedBody } = decoded;
471
+ const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
472
+ const serverTotalMs = performance.now() - args.loadStartMs;
473
+
474
+ const serverTiming = buildServerTiming({
475
+ defLoadMs: args.defLoadMs,
476
+ treeBuildMs: 0,
477
+ solveMs: 0,
478
+ serializeMs: 0,
479
+ gzipMs: 0,
480
+ serverTotalMs,
481
+ rhino: null,
482
+ settle: null,
483
+ byteRefOutcome: args.byteRefOutcome,
484
+ l2CacheHit: true,
485
+ prepMarks: args.prepMarks ?? []
486
+ });
487
+
488
+ const headers: Record<string, string> = {
489
+ 'Content-Type': 'application/json',
490
+ 'Server-Timing': serverTiming,
491
+ [COMPUTE_VERSION_HEADER]: String(COMPUTE_CONTRACT_VERSION),
492
+ Vary: 'Accept-Encoding'
493
+ };
494
+
495
+ const metrics: SolvePhaseMetrics = {
496
+ treeBuildMs: 0,
497
+ solveMs: 0,
498
+ serializeMs: 0,
499
+ gzipMs: 0,
500
+ serverTotalMs,
501
+ serializedBytes: header.serializedBytes,
502
+ compressedBytes: clientWantsGzip ? gzippedBody.byteLength : null,
503
+ l2CacheHit: true
504
+ };
505
+
506
+ let envelope: SolveEnvelope;
507
+ if (clientWantsGzip) {
508
+ // Serve the stored compressed bytes verbatim — the whole point of R6.
509
+ headers['Content-Encoding'] = 'gzip';
510
+ headers['Content-Length'] = String(gzippedBody.byteLength);
511
+ envelope = { body: new Uint8Array(gzippedBody), encoding: 'gzip', headers, metrics };
512
+ } else {
513
+ // Rare non-gzip client: gunzip back to the JSON string for the response.
514
+ const json = gunzipEntryBody(gzippedBody);
515
+ headers['Content-Length'] = String(Buffer.byteLength(json));
516
+ envelope = { body: json, headers, metrics };
517
+ }
518
+
519
+ return {
520
+ kind: 'ok',
521
+ envelope,
522
+ solveMs: 0,
523
+ errorCount: header.errorCount,
524
+ warningCount: header.warningCount
525
+ };
526
+ }
527
+
267
528
  // ============================================================================
268
529
  // Server-Timing assembly
269
530
  // ============================================================================
@@ -277,6 +538,9 @@ interface ServerTimingParts {
277
538
  serverTotalMs: number;
278
539
  rhino: CachedClient['rhinoTiming']['last'];
279
540
  settle: CachedClient['solveMeta']['last'];
541
+ byteRefOutcome?: ByteRefOutcome;
542
+ /** L2 verdict: true=hit, false=miss (consulted), null=not consulted. */
543
+ l2CacheHit: boolean | null;
280
544
  prepMarks: [string, number][];
281
545
  }
282
546
 
@@ -320,5 +584,20 @@ function buildServerTiming(parts: ServerTimingParts): string {
320
584
  }
321
585
  }
322
586
 
587
+ // Byte-cache verdict for `DefinitionRef` solves: skipped = the scheduler never
588
+ // materialized bytes (pointer-known solve — the whole point of this path), hit
589
+ // = warm byte cache, miss = fell through to storage. Absent for raw-bytes solves.
590
+ const byteRef = parts.byteRefOutcome;
591
+ if (byteRef) {
592
+ const verdict = !byteRef.loaded ? 'skipped' : byteRef.fromCache ? 'hit' : 'miss';
593
+ header += `, def_bytes;desc=${verdict}`;
594
+ }
595
+
596
+ // Durable L2 verdict (H1): 1 = hit (no solve ran), 0 = miss (consulted then
597
+ // solved). Absent when the L2 wasn't consulted (draft channel / caching off).
598
+ if (parts.l2CacheHit !== null) {
599
+ header += `, l2_cache;dur=${parts.l2CacheHit ? 1 : 0}`;
600
+ }
601
+
323
602
  return header;
324
603
  }
@@ -40,13 +40,27 @@ export function transformInputParameter(
40
40
  };
41
41
 
42
42
  const processed = processInput(raw);
43
- // processInput's default-normalization only understands scalars and innerTree
44
- // objects — a plain array default (multi-select widgets, e.g. a dynamic value
45
- // list checklist) is dropped as "malformed", which would omit the input from
46
- // the solve entirely. Restore it; TreeBuilder.appendFlat handles arrays natively.
43
+ // Two cases where processInput drops a default we must carry anyway (the
44
+ // default IS what TreeBuilder puts on the wire; a dropped default means the
45
+ // input is omitted from the solve and the user's selection never reaches
46
+ // Grasshopper):
47
+ // - a plain array default (multi-select widgets, e.g. a dynamic value list
48
+ // checklist) is dropped as "malformed"; TreeBuilder.appendFlat handles
49
+ // arrays natively.
50
+ // - since @selvajs/compute 3.1.0-beta.5, the ValueList fallback drops a
51
+ // scalar default it can't validate against a `values` map — and
52
+ // `SchemaInput` never carries the option map (it lives on the layout
53
+ // item's config), so every app value-list selection lands here.
47
54
  const effective = value ?? input.default;
48
- if (Array.isArray(effective) && processed.default == null) {
49
- (processed as { default: unknown }).default = effective;
55
+ if (processed.default == null && effective != null) {
56
+ if (Array.isArray(effective)) {
57
+ (processed as { default: unknown }).default = effective;
58
+ } else if (processed.paramType === 'ValueList' && typeof effective !== 'object') {
59
+ // Scoped to ValueList: for other types a dropped scalar default means
60
+ // the package rejected it as unparseable — restoring it would ship
61
+ // garbage to Grasshopper.
62
+ (processed as { default: unknown }).default = effective;
63
+ }
50
64
  }
51
65
  return processed;
52
66
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for `DefinitionService.uploadVersion` version-number allocation (caching
3
+ * Phase 1.5). The root-cause fix: the next version number comes from the store's
4
+ * monotonic `reserveNextVersionNumber` counter — NOT max(existing)+1, which
5
+ * reused a number (and its `fileKey`) after delete-latest and let a stale storage
6
+ * blob serve the old version's bytes for new content.
7
+ *
8
+ * These run against fakes that record the store/storage calls, so they pin the
9
+ * SERVICE's contract (reserve → derive fileKey from the reserved number). That a
10
+ * real provider's counter is truly monotonic across delete-then-reupload is
11
+ * covered by the definition-store conformance suite (both providers).
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import type {
16
+ IDataProvider,
17
+ IStorageProvider,
18
+ RequestContext,
19
+ DefinitionRecord,
20
+ DefinitionVersion,
21
+ UISchema
22
+ } from '@selvajs/platform';
23
+ import { DefinitionService } from '../definition-service.js';
24
+
25
+ const CTX = { userId: 'u-1' } as RequestContext;
26
+ const SCHEMA = {} as UISchema;
27
+
28
+ function fakeRecord(guid: string, nextVersionNumber: number): DefinitionRecord {
29
+ const now = new Date().toISOString();
30
+ return {
31
+ guid,
32
+ projectId: 'p-1',
33
+ ownerId: 'u-1',
34
+ createdBy: 'u-1',
35
+ updatedBy: 'u-1',
36
+ displayName: 'Def',
37
+ status: 'draft',
38
+ solveCount: 0,
39
+ nextVersionNumber,
40
+ liveVersionId: 'v-existing',
41
+ draftVersionId: 'v-existing',
42
+ createdAt: now,
43
+ updatedAt: now,
44
+ deletedAt: null
45
+ };
46
+ }
47
+
48
+ /**
49
+ * A minimal data provider whose `definitions` store hands out the reserved
50
+ * number and records the version row it was told to create. Only the methods
51
+ * `uploadVersion` touches are implemented.
52
+ */
53
+ function fakeDeps(record: DefinitionRecord) {
54
+ let counter = record.nextVersionNumber;
55
+ const created: DefinitionVersion[] = [];
56
+ const puts: string[] = [];
57
+
58
+ const data = {
59
+ definitions: {
60
+ get: async () => record,
61
+ reserveNextVersionNumber: async () => counter++, // monotonic, never reused
62
+ createVersion: async (_ctx: RequestContext, v: DefinitionVersion) => {
63
+ created.push(v);
64
+ },
65
+ setDraftVersion: async () => {}
66
+ }
67
+ } as unknown as IDataProvider;
68
+
69
+ const storage = {
70
+ put: async (key: string) => {
71
+ puts.push(key);
72
+ }
73
+ } as unknown as IStorageProvider;
74
+
75
+ return { data, storage, created, puts };
76
+ }
77
+
78
+ describe('DefinitionService.uploadVersion — version-number allocation', () => {
79
+ it('reserves the next number from the record counter and derives the fileKey from it', async () => {
80
+ const record = fakeRecord('11111111-1111-1111-1111-111111111111', 3);
81
+ const { data, storage, created, puts } = fakeDeps(record);
82
+ const svc = new DefinitionService(data, storage);
83
+
84
+ const version = await svc.uploadVersion(
85
+ CTX,
86
+ record.guid,
87
+ new Uint8Array([1, 2, 3]),
88
+ 'gh',
89
+ 'model.gh',
90
+ SCHEMA
91
+ );
92
+
93
+ expect(version.versionNumber).toBe(3);
94
+ // fileKey encodes the reserved number — so a fresh number ⇒ fresh key.
95
+ expect(version.fileKey).toContain('v3');
96
+ expect(puts).toEqual([version.fileKey]);
97
+ expect(created).toHaveLength(1);
98
+ expect(created[0].versionNumber).toBe(3);
99
+ });
100
+
101
+ it('two consecutive uploads mint distinct numbers and distinct fileKeys', async () => {
102
+ const record = fakeRecord('22222222-2222-2222-2222-222222222222', 5);
103
+ const { data, storage } = fakeDeps(record);
104
+ const svc = new DefinitionService(data, storage);
105
+
106
+ const a = await svc.uploadVersion(CTX, record.guid, new Uint8Array([1]), 'gh', 'a.gh', SCHEMA);
107
+ const b = await svc.uploadVersion(CTX, record.guid, new Uint8Array([2]), 'gh', 'b.gh', SCHEMA);
108
+
109
+ expect(a.versionNumber).toBe(5);
110
+ expect(b.versionNumber).toBe(6);
111
+ expect(a.fileKey).not.toBe(b.fileKey);
112
+ });
113
+
114
+ it('does NOT derive the number from the version list (no listVersions call)', async () => {
115
+ // The old bug read the version list; the fix must not. A deps object with no
116
+ // listVersions still works — reserveNextVersionNumber is the only source.
117
+ const record = fakeRecord('33333333-3333-3333-3333-333333333333', 9);
118
+ const { data, storage } = fakeDeps(record);
119
+ const svc = new DefinitionService(data, storage);
120
+
121
+ const version = await svc.uploadVersion(
122
+ CTX,
123
+ record.guid,
124
+ new Uint8Array([1]),
125
+ 'gh',
126
+ 'a.gh',
127
+ SCHEMA
128
+ );
129
+ expect(version.versionNumber).toBe(9);
130
+ });
131
+ });
@@ -52,7 +52,7 @@ export interface CreateDefinitionRecord {
52
52
  * blob and is filtered out of list endpoints by default.
53
53
  *
54
54
  * uploadVersion — append-only:
55
- * 1. Resolve next versionNumber from the version list
55
+ * 1. Reserve next versionNumber from the record's monotonic counter
56
56
  * 2. Upload blob to versions/v{N}.{ext}
57
57
  * 3. Insert DefinitionVersion row
58
58
  * 4. Advance draft pointer (live unchanged)
@@ -90,6 +90,8 @@ export class DefinitionService {
90
90
  computeServerId: input.computeServerId,
91
91
  status: 'pending',
92
92
  solveCount: 0,
93
+ // v1 is created below with versionNumber 1, so the next upload reserves 2.
94
+ nextVersionNumber: 2,
93
95
  liveVersionId: null,
94
96
  draftVersionId: null,
95
97
  createdAt: now,
@@ -150,9 +152,11 @@ export class DefinitionService {
150
152
  const existing = await this.data.definitions.get(ctx, guid);
151
153
  if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
152
154
 
153
- // Highest existing versionNumber + 1; covers gaps from deletions.
154
- const versions = await this.data.definitions.listVersions(ctx, guid, { limit: 1 });
155
- const next = (versions.items[0]?.versionNumber ?? 0) + 1;
155
+ // Reserve the next version number from the record's monotonic counter — NOT
156
+ // max(existing)+1, which reused a number after delete-latest and collided
157
+ // the `fileKey` (stale blob served for new content). The counter only ever
158
+ // advances, so a delete-then-reupload gets a fresh number and fresh key.
159
+ const next = await this.data.definitions.reserveNextVersionNumber(ctx, guid);
156
160
 
157
161
  const versionId = randomUUID();
158
162
  const fileKey = definitionPaths.version(guid, next, ext);