@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,9 @@
1
+ // Access-rule input assembly — marshals providers + flags into what
2
+ // platform's pure canView/canEdit/… rules consume.
3
+
4
+ export {
5
+ createProjectAccessInputBuilder,
6
+ type ProjectAccessFlags,
7
+ type ProjectAccessInputBuilder,
8
+ type ProjectAccessInputDeps
9
+ } from './project-access-input.js';
@@ -0,0 +1,145 @@
1
+ import type { Project, ProjectAccessInput, RequestContext } from '@selvajs/platform';
2
+
3
+ /**
4
+ * Assembly of `ProjectAccessInput` — the marshalling layer between an app's
5
+ * providers and platform's pure access rules (`canView`/`canEdit`/`canSolve`/
6
+ * `canManage`/…). The rules are pure functions over one input shape; the
7
+ * knowledge captured here is **which rows each project visibility needs**, so
8
+ * a consuming app doesn't reimplement (and mis-fetch) it:
9
+ *
10
+ * - `platform` → grants (skipped entirely while the feature flag is off)
11
+ * - `private` → caller's project member row
12
+ * - `org` / `public` → caller's project member row (for canEdit/canManage) and
13
+ * org member row (for canView). Cross-org public skips the org row.
14
+ *
15
+ * Wiring is injected: row lookups as functions (so lazily-initialized
16
+ * providers work unchanged) and flags read per call (so runtime flag flips
17
+ * behave like the app's own `flag()` reads).
18
+ */
19
+
20
+ export interface ProjectAccessFlags {
21
+ /** `ALLOW_CROSS_ORG_PUBLIC` — public visibility spans orgs when true. */
22
+ allowCrossOrgPublic: boolean;
23
+ /** `ENABLE_PLATFORM_PROJECTS` — platform visibility is live when true. */
24
+ enablePlatformProjects: boolean;
25
+ }
26
+
27
+ export interface ProjectAccessInputDeps {
28
+ getProjectMember(
29
+ ctx: RequestContext,
30
+ projectId: string,
31
+ userId: string
32
+ ): Promise<ProjectAccessInput['member']>;
33
+ getOrgMember(
34
+ ctx: RequestContext,
35
+ orgId: string,
36
+ userId: string
37
+ ): Promise<ProjectAccessInput['orgMember']>;
38
+ listPlatformGrants(
39
+ ctx: RequestContext,
40
+ projectId: string
41
+ ): Promise<ProjectAccessInput['platformGrants']>;
42
+ /** Read the two access-relevant feature flags. Called once per assembly. */
43
+ flags(): ProjectAccessFlags;
44
+ }
45
+
46
+ export interface ProjectAccessInputBuilder {
47
+ /**
48
+ * Build the rule input for any project-scope rule, fetching exactly the
49
+ * rows the rule will consult based on `project.visibility`. Other fields
50
+ * default to safe values; pass `overrides` for the rare callers that
51
+ * already loaded a row (e.g. tests, batched listing pages).
52
+ */
53
+ buildProjectAccessInput(
54
+ ctx: RequestContext,
55
+ project: Project,
56
+ overrides?: Partial<ProjectAccessInput>
57
+ ): Promise<ProjectAccessInput>;
58
+ /**
59
+ * Build a `ProjectAccessInput` from caller-provided rows without any I/O.
60
+ * Used by listing pages that have already batch-loaded membership for many
61
+ * projects; the per-row predicate calls this instead of
62
+ * `buildProjectAccessInput` to avoid an N+1 fetch.
63
+ */
64
+ projectAccessInputFromRows(
65
+ ctx: RequestContext,
66
+ project: Project,
67
+ rows: {
68
+ member?: ProjectAccessInput['member'];
69
+ orgMember?: ProjectAccessInput['orgMember'];
70
+ platformGrants?: ProjectAccessInput['platformGrants'];
71
+ }
72
+ ): ProjectAccessInput;
73
+ }
74
+
75
+ export function createProjectAccessInputBuilder(
76
+ deps: ProjectAccessInputDeps
77
+ ): ProjectAccessInputBuilder {
78
+ return {
79
+ async buildProjectAccessInput(
80
+ ctx: RequestContext,
81
+ project: Project,
82
+ overrides: Partial<ProjectAccessInput> = {}
83
+ ): Promise<ProjectAccessInput> {
84
+ const { allowCrossOrgPublic, enablePlatformProjects } = deps.flags();
85
+
86
+ let member: ProjectAccessInput['member'] = null;
87
+ let orgMember: ProjectAccessInput['orgMember'] = null;
88
+ let platformGrants: ProjectAccessInput['platformGrants'] = [];
89
+
90
+ if (project.visibility === 'platform') {
91
+ // When the flag is off the rule short-circuits before reading grants —
92
+ // skip the lookup to keep "feature disabled" cheap.
93
+ if (enablePlatformProjects) {
94
+ platformGrants = await deps.listPlatformGrants(ctx, project.id);
95
+ }
96
+ } else if (project.visibility === 'private') {
97
+ member = await deps.getProjectMember(ctx, project.id, ctx.userId);
98
+ } else {
99
+ const skipOrgMember = project.visibility === 'public' && allowCrossOrgPublic;
100
+ [member, orgMember] = await Promise.all([
101
+ deps.getProjectMember(ctx, project.id, ctx.userId),
102
+ skipOrgMember ? Promise.resolve(null) : deps.getOrgMember(ctx, project.orgId, ctx.userId)
103
+ ]);
104
+ }
105
+
106
+ return {
107
+ orgPermissions: ctx.orgPermissions,
108
+ platformPermissions: ctx.platformPermissions,
109
+ project,
110
+ member,
111
+ orgMember,
112
+ allowCrossOrgPublic,
113
+ enablePlatformProjects,
114
+ platformGrants,
115
+ actingOrgId: ctx.actingOrgId ?? null,
116
+ userId: ctx.userId,
117
+ ...overrides
118
+ };
119
+ },
120
+
121
+ projectAccessInputFromRows(
122
+ ctx: RequestContext,
123
+ project: Project,
124
+ rows: {
125
+ member?: ProjectAccessInput['member'];
126
+ orgMember?: ProjectAccessInput['orgMember'];
127
+ platformGrants?: ProjectAccessInput['platformGrants'];
128
+ }
129
+ ): ProjectAccessInput {
130
+ const { allowCrossOrgPublic, enablePlatformProjects } = deps.flags();
131
+ return {
132
+ orgPermissions: ctx.orgPermissions,
133
+ platformPermissions: ctx.platformPermissions,
134
+ project,
135
+ member: rows.member ?? null,
136
+ orgMember: rows.orgMember ?? null,
137
+ allowCrossOrgPublic,
138
+ enablePlatformProjects,
139
+ platformGrants: rows.platformGrants ?? [],
140
+ actingOrgId: ctx.actingOrgId ?? null,
141
+ userId: ctx.userId
142
+ };
143
+ }
144
+ };
145
+ }
@@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
12
 
13
13
  // --- Mock @selvajs/compute -------------------------------------------------
14
14
  const createdConfigs: any[] = [];
15
+ const createdSchedulerOptions: any[] = [];
15
16
  const disposedSchedulers: any[] = [];
16
17
 
17
18
  vi.mock('@selvajs/compute', () => {
@@ -21,7 +22,8 @@ vi.mock('@selvajs/compute', () => {
21
22
  return new GrasshopperClient(config);
22
23
  }
23
24
  constructor(public config: any) {}
24
- createScheduler() {
25
+ createScheduler(options: any) {
26
+ createdSchedulerOptions.push(options);
25
27
  const scheduler = {
26
28
  disposed: false,
27
29
  dispose() {
@@ -40,6 +42,9 @@ import { createClientCache, serverIdentity } from '../client-cache.js';
40
42
  function baseConfig() {
41
43
  return {
42
44
  maxSolveDurationMs: 30_000,
45
+ maxConcurrentSolves: 4,
46
+ maxQueueDepth: 0,
47
+ queueWaitMs: 0,
43
48
  cachesolve: true,
44
49
  cacheerroredsolves: false,
45
50
  reuseServerDefinitionCache: true,
@@ -56,6 +61,7 @@ const server = (id: string, over: Partial<{ serverUrl: string; apiKey: string }>
56
61
 
57
62
  beforeEach(() => {
58
63
  createdConfigs.length = 0;
64
+ createdSchedulerOptions.length = 0;
59
65
  disposedSchedulers.length = 0;
60
66
  });
61
67
 
@@ -157,6 +163,80 @@ describe('createClientCache — X-Selva-Definition (ADR 0004 D2)', () => {
157
163
  });
158
164
  });
159
165
 
166
+ describe('createClientCache — scheduler concurrency (audit B6)', () => {
167
+ it('forwards maxConcurrentSolves as the scheduler maxConcurrent in queue mode', async () => {
168
+ const cache = createClientCache({ ...baseConfig(), maxConcurrentSolves: 7 });
169
+ await cache.getClient(server('s1'));
170
+ expect(createdSchedulerOptions).toHaveLength(1);
171
+ // Queue mode with NO maxConcurrent defaults to 1 in the scheduler,
172
+ // serializing every solve on the server — the option must always be set.
173
+ expect(createdSchedulerOptions[0].mode).toBe('queue');
174
+ expect(createdSchedulerOptions[0].maxConcurrent).toBe(7);
175
+ });
176
+ });
177
+
178
+ describe('createClientCache — scheduler backpressure (audit B7)', () => {
179
+ it('forwards non-zero queue bounds to the scheduler', async () => {
180
+ const cache = createClientCache({ ...baseConfig(), maxQueueDepth: 12, queueWaitMs: 90_000 });
181
+ await cache.getClient(server('s1'));
182
+ expect(createdSchedulerOptions[0].maxQueueDepth).toBe(12);
183
+ expect(createdSchedulerOptions[0].queueWaitMs).toBe(90_000);
184
+ });
185
+
186
+ it('maps a 0 (disabled) queue bound to undefined so the scheduler stays unbounded', async () => {
187
+ // The scheduler treats undefined as unbounded/no-deadline; passing 0 would
188
+ // shed EVERY queued solve. Our `0 = off` convention must become undefined.
189
+ const cache = createClientCache({ ...baseConfig(), maxQueueDepth: 0, queueWaitMs: 0 });
190
+ await cache.getClient(server('s1'));
191
+ expect(createdSchedulerOptions[0].maxQueueDepth).toBeUndefined();
192
+ expect(createdSchedulerOptions[0].queueWaitMs).toBeUndefined();
193
+ });
194
+ });
195
+
196
+ describe('createClientCache — per-request telemetry sequence counters', () => {
197
+ it('onServerTiming bumps rhinoTiming.seq on every write', async () => {
198
+ const cache = createClientCache(baseConfig());
199
+ const entry = await cache.getClient(server('s1'));
200
+ expect(entry.rhinoTiming).toEqual({ last: null, seq: 0 });
201
+ createdConfigs[0].onServerTiming({ decode: 5, solve: 100, encode: 3 });
202
+ expect(entry.rhinoTiming.seq).toBe(1);
203
+ expect(entry.rhinoTiming.last).toEqual({ decode: 5, solve: 100, encode: 3 });
204
+ createdConfigs[0].onServerTiming({ decode: 1, solve: 2, encode: 3 });
205
+ expect(entry.rhinoTiming.seq).toBe(2);
206
+ expect(entry.rhinoTiming.last).toEqual({ decode: 1, solve: 2, encode: 3 });
207
+ });
208
+
209
+ it('onSettle bumps solveMeta.seq on every settle but writes last only on success', async () => {
210
+ const cache = createClientCache(baseConfig());
211
+ const entry = await cache.getClient(server('s1'));
212
+ const onSettle = createdSchedulerOptions[0].onSettle;
213
+ expect(entry.solveMeta).toEqual({ last: null, seq: 0 });
214
+
215
+ onSettle(
216
+ { key: 'k' },
217
+ {
218
+ status: 'success',
219
+ fromCache: false,
220
+ definitionReuploaded: false,
221
+ durationMs: 10,
222
+ response: {}
223
+ }
224
+ );
225
+ expect(entry.solveMeta.seq).toBe(1);
226
+ expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
227
+
228
+ // An error settle must still count toward seq (the attribution guard in
229
+ // the pipeline needs to see ALL concurrent activity) without clobbering last.
230
+ onSettle({ key: 'k' }, { status: 'error', error: new Error('x'), durationMs: 5 });
231
+ expect(entry.solveMeta.seq).toBe(2);
232
+ expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
233
+
234
+ onSettle({ key: 'k' }, { status: 'success', fromCache: true, durationMs: 0, response: {} });
235
+ expect(entry.solveMeta.seq).toBe(3);
236
+ expect(entry.solveMeta.last).toEqual({ fromCache: true, definitionReuploaded: undefined });
237
+ });
238
+ });
239
+
160
240
  describe('serverIdentity', () => {
161
241
  it('derives identity from the id', () => {
162
242
  expect(serverIdentity({ id: 'abc' })).toBe('abc');
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createDefinitionByteCache } from '../definition-byte-cache.js';
3
+
4
+ /** A `Uint8Array` of `n` bytes filled with a marker so equality is checkable. */
5
+ function bytes(n: number, marker = 0): Uint8Array {
6
+ return new Uint8Array(n).fill(marker);
7
+ }
8
+
9
+ describe('createDefinitionByteCache', () => {
10
+ it('serves a warm entry without re-invoking the loader (hit) and reports it', async () => {
11
+ const cache = createDefinitionByteCache(10 * 1024 * 1024);
12
+ const load = vi.fn(async () => bytes(1024, 7));
13
+
14
+ const first = await cache.getOrLoad('v-1', load).load();
15
+ const second = await cache.getOrLoad('v-1', load).load();
16
+
17
+ expect(first).toEqual(second);
18
+ expect(load).toHaveBeenCalledTimes(1); // second call served from cache
19
+ const s = cache.stats();
20
+ expect(s.hits).toBe(1);
21
+ expect(s.misses).toBe(1);
22
+ expect(s.entries).toBe(1);
23
+ expect(s.bytes).toBe(1024);
24
+ });
25
+
26
+ it('per-ref outcome distinguishes miss (loader) from hit (warm)', async () => {
27
+ const cache = createDefinitionByteCache(10 * 1024 * 1024);
28
+ const load = async () => bytes(512);
29
+
30
+ const refMiss = cache.getOrLoad('v-1', load);
31
+ await refMiss.load();
32
+ expect(refMiss.outcome).toEqual({ loaded: true, fromCache: false });
33
+
34
+ const refHit = cache.getOrLoad('v-1', load);
35
+ await refHit.load();
36
+ expect(refHit.outcome).toEqual({ loaded: true, fromCache: true });
37
+ });
38
+
39
+ it("a ref whose load() is never called leaves outcome.loaded false (the 'skipped' verdict)", () => {
40
+ const cache = createDefinitionByteCache(10 * 1024 * 1024);
41
+ const ref = cache.getOrLoad('v-1', async () => bytes(1));
42
+ // The scheduler served a pointer-known solve — load() never ran.
43
+ expect(ref.outcome).toEqual({ loaded: false, fromCache: false });
44
+ });
45
+
46
+ it('evicts least-recently-used entries when the byte budget is exceeded', async () => {
47
+ // Budget holds ~2 of these 1 KB entries.
48
+ const cache = createDefinitionByteCache(2 * 1024);
49
+ const load = (marker: number) => async () => bytes(1024, marker);
50
+
51
+ await cache.getOrLoad('a', load(1)).load(); // [a]
52
+ await cache.getOrLoad('b', load(2)).load(); // [a,b]
53
+ // Touch 'a' so 'b' becomes the LRU victim.
54
+ await cache.getOrLoad('a', load(1)).load(); // hit → [b,a]
55
+ await cache.getOrLoad('c', load(3)).load(); // insert c → evict b → [a,c]
56
+
57
+ expect(cache.stats().entries).toBe(2);
58
+ expect(cache.stats().evictions).toBe(1);
59
+ expect(cache.stats().bytes).toBe(2 * 1024);
60
+
61
+ // 'a' survived (it was touched, so 'b' was the LRU victim) → served from
62
+ // cache without the loader. Checked BEFORE re-loading 'b', since bringing 'b'
63
+ // back would itself evict 'a' under the 2-entry budget.
64
+ const aLoad = vi.fn(load(1));
65
+ await cache.getOrLoad('a', aLoad).load();
66
+ expect(aLoad).toHaveBeenCalledTimes(0);
67
+
68
+ // 'b' was evicted → its next load calls the loader again (miss).
69
+ const bLoad = vi.fn(load(2));
70
+ await cache.getOrLoad('b', bLoad).load();
71
+ expect(bLoad).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('never retains an entry larger than the whole budget (serves through, caches nothing)', async () => {
75
+ const cache = createDefinitionByteCache(1024);
76
+ const load = vi.fn(async () => bytes(4096));
77
+
78
+ const out = await cache.getOrLoad('big', load).load();
79
+ expect(out.byteLength).toBe(4096); // still served
80
+ expect(cache.stats().entries).toBe(0); // but not retained
81
+ expect(cache.stats().bytes).toBe(0);
82
+
83
+ // Every load re-fetches — nothing was cached.
84
+ await cache.getOrLoad('big', load).load();
85
+ expect(load).toHaveBeenCalledTimes(2);
86
+ });
87
+
88
+ it('keys on the passed version id — two versions that reused a fileKey never collide', async () => {
89
+ // The trap this cache exists to avoid: delete-latest-then-reupload reused a
90
+ // fileKey, but the version id (the KEY) is always fresh. Distinct ids →
91
+ // distinct entries, so the new version never serves the old bytes.
92
+ const cache = createDefinitionByteCache(10 * 1024 * 1024);
93
+ const oldBytes = bytes(1024, 1);
94
+ const newBytes = bytes(1024, 2);
95
+
96
+ const a = await cache.getOrLoad('version-old', async () => oldBytes).load();
97
+ const b = await cache.getOrLoad('version-new', async () => newBytes).load();
98
+
99
+ expect(a).toEqual(oldBytes);
100
+ expect(b).toEqual(newBytes);
101
+ expect(a).not.toEqual(b); // no cross-contamination despite a shared fileKey
102
+ expect(cache.stats().entries).toBe(2);
103
+ });
104
+
105
+ it('budget 0 disables caching entirely (every load hits the loader, nothing retained)', async () => {
106
+ const cache = createDefinitionByteCache(0);
107
+ const load = vi.fn(async () => bytes(16));
108
+
109
+ const ref1 = cache.getOrLoad('v-1', load);
110
+ await ref1.load();
111
+ await cache.getOrLoad('v-1', load).load();
112
+
113
+ expect(load).toHaveBeenCalledTimes(2);
114
+ expect(cache.stats().entries).toBe(0);
115
+ expect(cache.stats().bytes).toBe(0);
116
+ // Outcome still reports loaded, but never fromCache.
117
+ expect(ref1.outcome).toEqual({ loaded: true, fromCache: false });
118
+ });
119
+
120
+ it('re-storing the same key updates bytes without double-counting the budget', async () => {
121
+ const cache = createDefinitionByteCache(10 * 1024 * 1024);
122
+ // clear() to force a re-store path: first load caches, clear drops it, second caches again.
123
+ await cache.getOrLoad('v-1', async () => bytes(1024)).load();
124
+ expect(cache.stats().bytes).toBe(1024);
125
+ cache.clear();
126
+ expect(cache.stats().bytes).toBe(0);
127
+ expect(cache.stats().entries).toBe(0);
128
+ });
129
+ });
@@ -66,4 +66,41 @@ describe('createComputeRateLimiter', () => {
66
66
  // A different limiter instance is unaffected.
67
67
  expect(other.check('user:alice').allowed).toBe(true);
68
68
  });
69
+
70
+ // peek + clear support failure-counting flows (login limiting): peek gates
71
+ // the attempt without spending budget, check records a failure, clear
72
+ // forgives on success.
73
+ describe('peek / clear', () => {
74
+ it('peek never increments the counter', () => {
75
+ for (let i = 0; i < 1000; i++) expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
76
+ // Full budget still available after all those peeks.
77
+ for (let i = 0; i < MAX_PER_WINDOW; i++) {
78
+ expect(limiter.check('ip:1.2.3.4').allowed).toBe(true);
79
+ }
80
+ });
81
+
82
+ it('peek reports a full bucket with retryAfter, still without mutating it', () => {
83
+ for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
84
+ const result = limiter.peek('ip:1.2.3.4');
85
+ expect(result.allowed).toBe(false);
86
+ expect(result.retryAfter).toBeGreaterThan(0);
87
+ });
88
+
89
+ it('peek allows again once the window expires', () => {
90
+ for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
91
+ expect(limiter.peek('ip:1.2.3.4').allowed).toBe(false);
92
+ vi.advanceTimersByTime(WINDOW_MS + 1);
93
+ expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
94
+ });
95
+
96
+ it('clear drops only the given key', () => {
97
+ for (let i = 0; i < MAX_PER_WINDOW; i++) {
98
+ limiter.check('ip:1.2.3.4');
99
+ limiter.check('ip:5.6.7.8');
100
+ }
101
+ limiter.clear('ip:1.2.3.4');
102
+ expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
103
+ expect(limiter.peek('ip:5.6.7.8').allowed).toBe(false);
104
+ });
105
+ });
69
106
  });