@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.
- package/dist/access/__tests__/project-access-input.test.d.ts +2 -0
- package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
- package/dist/access/__tests__/project-access-input.test.js +146 -0
- package/dist/access/__tests__/project-access-input.test.js.map +1 -0
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +4 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/project-access-input.d.ts +52 -0
- package/dist/access/project-access-input.d.ts.map +1 -0
- package/dist/access/project-access-input.js +56 -0
- package/dist/access/project-access-input.js.map +1 -0
- package/dist/compute/__tests__/client-cache.test.js +70 -1
- package/dist/compute/__tests__/client-cache.test.js.map +1 -1
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts +2 -0
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/definition-byte-cache.test.js +107 -0
- package/dist/compute/__tests__/definition-byte-cache.test.js.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.js +36 -0
- package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
- package/dist/compute/__tests__/solve-cache.test.d.ts +13 -0
- package/dist/compute/__tests__/solve-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/solve-cache.test.js +234 -0
- package/dist/compute/__tests__/solve-cache.test.js.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.js +327 -15
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
- package/dist/compute/__tests__/transform-input.test.js +7 -1
- package/dist/compute/__tests__/transform-input.test.js.map +1 -1
- package/dist/compute/client-cache.d.ts +32 -8
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +24 -6
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/definition-byte-cache.d.ts +85 -0
- package/dist/compute/definition-byte-cache.d.ts.map +1 -0
- package/dist/compute/definition-byte-cache.js +110 -0
- package/dist/compute/definition-byte-cache.js.map +1 -0
- package/dist/compute/index.d.ts +7 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +6 -1
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +62 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +35 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/memory-solve-cache.d.ts +58 -0
- package/dist/compute/memory-solve-cache.d.ts.map +1 -0
- package/dist/compute/memory-solve-cache.js +0 -0
- package/dist/compute/memory-solve-cache.js.map +1 -0
- package/dist/compute/rate-limit.d.ts +12 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +18 -0
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/solve-cache-envelope.d.ts +53 -0
- package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
- package/dist/compute/solve-cache-envelope.js +66 -0
- package/dist/compute/solve-cache-envelope.js.map +1 -0
- package/dist/compute/solve-cache-key.d.ts +65 -0
- package/dist/compute/solve-cache-key.d.ts.map +1 -0
- package/dist/compute/solve-cache-key.js +50 -0
- package/dist/compute/solve-cache-key.js.map +1 -0
- package/dist/compute/solve-cache-single-flight.d.ts +35 -0
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
- package/dist/compute/solve-cache-single-flight.js +48 -0
- package/dist/compute/solve-cache-single-flight.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +71 -8
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +198 -15
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/compute/transform-input.d.ts.map +1 -1
- package/dist/compute/transform-input.js +21 -6
- package/dist/compute/transform-input.js.map +1 -1
- package/dist/definitions/__tests__/definition-service.test.d.ts +14 -0
- package/dist/definitions/__tests__/definition-service.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/definition-service.test.js +95 -0
- package/dist/definitions/__tests__/definition-service.test.js.map +1 -0
- package/dist/definitions/definition-service.d.ts +1 -1
- package/dist/definitions/definition-service.d.ts.map +1 -1
- package/dist/definitions/definition-service.js +8 -4
- package/dist/definitions/definition-service.js.map +1 -1
- package/dist/errors/SentryErrorReporter.d.ts +17 -0
- package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
- package/dist/errors/SentryErrorReporter.js +60 -0
- package/dist/errors/SentryErrorReporter.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/http/__tests__/body-size.test.d.ts +2 -0
- package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
- package/dist/http/__tests__/body-size.test.js +24 -0
- package/dist/http/__tests__/body-size.test.js.map +1 -0
- package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
- package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
- package/dist/http/__tests__/route-classifier.test.js +54 -0
- package/dist/http/__tests__/route-classifier.test.js.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.js +44 -0
- package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
- package/dist/http/__tests__/security-headers.test.d.ts +2 -0
- package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
- package/dist/http/__tests__/security-headers.test.js +24 -0
- package/dist/http/__tests__/security-headers.test.js.map +1 -0
- package/dist/http/body-size.d.ts +21 -0
- package/dist/http/body-size.d.ts.map +1 -0
- package/dist/http/body-size.js +20 -0
- package/dist/http/body-size.js.map +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +6 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/redirect.d.ts +11 -0
- package/dist/http/redirect.d.ts.map +1 -0
- package/dist/http/redirect.js +20 -0
- package/dist/http/redirect.js.map +1 -0
- package/dist/http/route-classifier.d.ts +47 -0
- package/dist/http/route-classifier.d.ts.map +1 -0
- package/dist/http/route-classifier.js +37 -0
- package/dist/http/route-classifier.js.map +1 -0
- package/dist/http/security-headers.d.ts +31 -0
- package/dist/http/security-headers.d.ts.map +1 -0
- package/dist/http/security-headers.js +31 -0
- package/dist/http/security-headers.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/ops/__tests__/semver.test.d.ts +2 -0
- package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
- package/dist/ops/__tests__/semver.test.js +38 -0
- package/dist/ops/__tests__/semver.test.js.map +1 -0
- package/dist/ops/index.d.ts +2 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +3 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/semver.d.ts +35 -0
- package/dist/ops/semver.d.ts.map +1 -0
- package/dist/ops/semver.js +55 -0
- package/dist/ops/semver.js.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.js +50 -0
- package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +3 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/tokens/token-codec.d.ts +54 -0
- package/dist/tokens/token-codec.d.ts.map +1 -0
- package/dist/tokens/token-codec.js +62 -0
- package/dist/tokens/token-codec.js.map +1 -0
- package/package.json +35 -2
- package/src/access/__tests__/project-access-input.test.ts +175 -0
- package/src/access/index.ts +9 -0
- package/src/access/project-access-input.ts +145 -0
- package/src/compute/__tests__/client-cache.test.ts +81 -1
- package/src/compute/__tests__/definition-byte-cache.test.ts +129 -0
- package/src/compute/__tests__/rate-limit.test.ts +37 -0
- package/src/compute/__tests__/solve-cache.test.ts +268 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +380 -14
- package/src/compute/__tests__/transform-input.test.ts +8 -1
- package/src/compute/client-cache.ts +59 -16
- package/src/compute/definition-byte-cache.ts +168 -0
- package/src/compute/index.ts +34 -0
- package/src/compute/limits.ts +91 -1
- package/src/compute/memory-solve-cache.ts +0 -0
- package/src/compute/rate-limit.ts +27 -0
- package/src/compute/solve-cache-envelope.ts +98 -0
- package/src/compute/solve-cache-key.ts +85 -0
- package/src/compute/solve-cache-single-flight.ts +59 -0
- package/src/compute/solve-pipeline.ts +301 -22
- package/src/compute/transform-input.ts +20 -6
- package/src/definitions/__tests__/definition-service.test.ts +131 -0
- package/src/definitions/definition-service.ts +8 -4
- package/src/errors/SentryErrorReporter.ts +88 -0
- package/src/errors/index.ts +3 -0
- package/src/http/__tests__/body-size.test.ts +27 -0
- package/src/http/__tests__/route-classifier.test.ts +60 -0
- package/src/http/__tests__/safe-redirect.test.ts +47 -0
- package/src/http/__tests__/security-headers.test.ts +29 -0
- package/src/http/body-size.ts +24 -0
- package/src/http/index.ts +10 -0
- package/src/http/redirect.ts +16 -0
- package/src/http/route-classifier.ts +74 -0
- package/src/http/security-headers.ts +42 -0
- package/src/index.ts +11 -2
- package/src/ops/__tests__/semver.test.ts +42 -0
- package/src/ops/index.ts +3 -0
- package/src/ops/semver.ts +60 -0
- package/src/tokens/__tests__/token-codec.test.ts +63 -0
- package/src/tokens/index.ts +8 -0
- 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
|
});
|