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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/compute/client-cache.d.ts.map +1 -1
  4. package/dist/compute/client-cache.js +6 -0
  5. package/dist/compute/client-cache.js.map +1 -1
  6. package/dist/compute/safe-url.d.ts.map +1 -1
  7. package/dist/compute/safe-url.js +16 -0
  8. package/dist/compute/safe-url.js.map +1 -1
  9. package/dist/compute/solve-cache-single-flight.d.ts +8 -1
  10. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
  11. package/dist/compute/solve-cache-single-flight.js +4 -2
  12. package/dist/compute/solve-cache-single-flight.js.map +1 -1
  13. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  14. package/dist/compute/solve-pipeline.js +3 -1
  15. package/dist/compute/solve-pipeline.js.map +1 -1
  16. package/package.json +14 -18
  17. package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
  18. package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
  19. package/dist/access/__tests__/project-access-input.test.js +0 -146
  20. package/dist/access/__tests__/project-access-input.test.js.map +0 -1
  21. package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
  22. package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
  23. package/dist/compute/__tests__/client-cache.test.js +0 -218
  24. package/dist/compute/__tests__/client-cache.test.js.map +0 -1
  25. package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
  26. package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
  27. package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
  28. package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
  29. package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
  30. package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
  31. package/dist/compute/__tests__/rate-limit.test.js +0 -98
  32. package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
  33. package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
  34. package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
  35. package/dist/compute/__tests__/remote-definition.test.js +0 -114
  36. package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
  37. package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
  38. package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
  39. package/dist/compute/__tests__/safe-url.test.js +0 -71
  40. package/dist/compute/__tests__/safe-url.test.js.map +0 -1
  41. package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
  42. package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
  43. package/dist/compute/__tests__/solve-cache.test.js +0 -234
  44. package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
  45. package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
  46. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
  47. package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
  48. package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
  49. package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
  50. package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
  51. package/dist/compute/__tests__/transform-input.test.js +0 -120
  52. package/dist/compute/__tests__/transform-input.test.js.map +0 -1
  53. package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
  54. package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
  55. package/dist/definitions/__tests__/definition-service.test.js +0 -95
  56. package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
  57. package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
  58. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
  59. package/dist/definitions/__tests__/load-for-render.test.js +0 -196
  60. package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
  61. package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
  62. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
  63. package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
  64. package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
  65. package/dist/http/__tests__/body-size.test.d.ts +0 -2
  66. package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
  67. package/dist/http/__tests__/body-size.test.js +0 -24
  68. package/dist/http/__tests__/body-size.test.js.map +0 -1
  69. package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
  70. package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
  71. package/dist/http/__tests__/route-classifier.test.js +0 -54
  72. package/dist/http/__tests__/route-classifier.test.js.map +0 -1
  73. package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
  74. package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
  75. package/dist/http/__tests__/safe-redirect.test.js +0 -44
  76. package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
  77. package/dist/http/__tests__/security-headers.test.d.ts +0 -2
  78. package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
  79. package/dist/http/__tests__/security-headers.test.js +0 -24
  80. package/dist/http/__tests__/security-headers.test.js.map +0 -1
  81. package/dist/ops/__tests__/semver.test.d.ts +0 -2
  82. package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
  83. package/dist/ops/__tests__/semver.test.js +0 -38
  84. package/dist/ops/__tests__/semver.test.js.map +0 -1
  85. package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
  86. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
  87. package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
  88. package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
  89. package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
  90. package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
  91. package/dist/tokens/__tests__/token-codec.test.js +0 -50
  92. package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
  93. package/src/access/__tests__/project-access-input.test.ts +0 -175
  94. package/src/access/index.ts +0 -9
  95. package/src/access/project-access-input.ts +0 -145
  96. package/src/compute/__tests__/client-cache.test.ts +0 -244
  97. package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
  98. package/src/compute/__tests__/rate-limit.test.ts +0 -106
  99. package/src/compute/__tests__/remote-definition.test.ts +0 -140
  100. package/src/compute/__tests__/safe-url.test.ts +0 -72
  101. package/src/compute/__tests__/solve-cache.test.ts +0 -268
  102. package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
  103. package/src/compute/__tests__/transform-input.test.ts +0 -162
  104. package/src/compute/client-cache.ts +0 -321
  105. package/src/compute/definition-byte-cache.ts +0 -168
  106. package/src/compute/index.ts +0 -82
  107. package/src/compute/limits.ts +0 -237
  108. package/src/compute/memory-solve-cache.ts +0 -0
  109. package/src/compute/rate-limit.ts +0 -110
  110. package/src/compute/remote-definition.ts +0 -149
  111. package/src/compute/safe-url.ts +0 -186
  112. package/src/compute/solve-cache-envelope.ts +0 -98
  113. package/src/compute/solve-cache-key.ts +0 -85
  114. package/src/compute/solve-cache-single-flight.ts +0 -59
  115. package/src/compute/solve-pipeline.ts +0 -603
  116. package/src/compute/transform-input.ts +0 -66
  117. package/src/definitions/__tests__/definition-service.test.ts +0 -131
  118. package/src/definitions/__tests__/load-for-render.test.ts +0 -244
  119. package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
  120. package/src/definitions/definition-service.ts +0 -247
  121. package/src/definitions/index.ts +0 -22
  122. package/src/definitions/load-for-render.ts +0 -272
  123. package/src/definitions/schema-extraction.ts +0 -117
  124. package/src/errors/SentryErrorReporter.ts +0 -88
  125. package/src/errors/index.ts +0 -3
  126. package/src/http/__tests__/body-size.test.ts +0 -27
  127. package/src/http/__tests__/route-classifier.test.ts +0 -60
  128. package/src/http/__tests__/safe-redirect.test.ts +0 -47
  129. package/src/http/__tests__/security-headers.test.ts +0 -29
  130. package/src/http/body-size.ts +0 -24
  131. package/src/http/index.ts +0 -10
  132. package/src/http/redirect.ts +0 -16
  133. package/src/http/route-classifier.ts +0 -74
  134. package/src/http/security-headers.ts +0 -42
  135. package/src/index.ts +0 -27
  136. package/src/ops/__tests__/semver.test.ts +0 -42
  137. package/src/ops/index.ts +0 -3
  138. package/src/ops/semver.ts +0 -60
  139. package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
  140. package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
  141. package/src/providers/create-selva-providers.ts +0 -241
  142. package/src/providers/index.ts +0 -12
  143. package/src/tokens/__tests__/token-codec.test.ts +0 -63
  144. package/src/tokens/index.ts +0 -8
  145. package/src/tokens/token-codec.ts +0 -86
@@ -1,175 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import type {
3
- Project,
4
- ProjectAccessInput,
5
- ProjectVisibility,
6
- RequestContext
7
- } from '@selvajs/platform';
8
- import {
9
- createProjectAccessInputBuilder,
10
- type ProjectAccessFlags
11
- } from '../project-access-input.js';
12
-
13
- const CTX: RequestContext = {
14
- userId: 'user-1',
15
- actingOrgId: 'org-1',
16
- platformPermissions: ['manage_compute'],
17
- orgPermissions: ['manage_org_members']
18
- } as RequestContext;
19
-
20
- function project(visibility: ProjectVisibility): Project {
21
- return {
22
- id: 'proj-1',
23
- orgId: 'org-1',
24
- name: 'P',
25
- slug: 'p',
26
- visibility,
27
- ownerId: 'user-9',
28
- createdBy: 'user-9',
29
- updatedBy: 'user-9',
30
- autoJoinOnUpload: false,
31
- createdAt: '2026-01-01T00:00:00Z',
32
- updatedAt: '2026-01-01T00:00:00Z'
33
- };
34
- }
35
-
36
- // Row identity is all the builder consumes — it passes rows through opaquely.
37
- const MEMBER = {
38
- projectId: 'proj-1',
39
- userId: 'user-1',
40
- role: 'editor'
41
- } as unknown as NonNullable<ProjectAccessInput['member']>;
42
- const ORG_MEMBER = {
43
- orgId: 'org-1',
44
- userId: 'user-1',
45
- role: 'member'
46
- } as unknown as NonNullable<ProjectAccessInput['orgMember']>;
47
- const GRANT = {
48
- projectId: 'proj-1',
49
- orgId: 'org-2',
50
- canSolve: true
51
- } as unknown as ProjectAccessInput['platformGrants'][number];
52
-
53
- function makeDeps(flags: Partial<ProjectAccessFlags> = {}) {
54
- return {
55
- getProjectMember: vi.fn().mockResolvedValue(MEMBER),
56
- getOrgMember: vi.fn().mockResolvedValue(ORG_MEMBER),
57
- listPlatformGrants: vi.fn().mockResolvedValue([GRANT]),
58
- flags: vi.fn().mockReturnValue({
59
- allowCrossOrgPublic: false,
60
- enablePlatformProjects: false,
61
- ...flags
62
- })
63
- };
64
- }
65
-
66
- describe('buildProjectAccessInput — per-visibility row fetching', () => {
67
- it('private: fetches only the project member row', async () => {
68
- const deps = makeDeps();
69
- const builder = createProjectAccessInputBuilder(deps);
70
- const input = await builder.buildProjectAccessInput(CTX, project('private'));
71
-
72
- expect(input.member).toEqual(MEMBER);
73
- expect(input.orgMember).toBeNull();
74
- expect(input.platformGrants).toEqual([]);
75
- expect(deps.getOrgMember).not.toHaveBeenCalled();
76
- expect(deps.listPlatformGrants).not.toHaveBeenCalled();
77
- });
78
-
79
- it.each(['org', 'public'] as const)('%s: fetches project member + org member', async (vis) => {
80
- const deps = makeDeps();
81
- const builder = createProjectAccessInputBuilder(deps);
82
- const input = await builder.buildProjectAccessInput(CTX, project(vis));
83
-
84
- expect(input.member).toEqual(MEMBER);
85
- expect(input.orgMember).toEqual(ORG_MEMBER);
86
- expect(deps.getOrgMember).toHaveBeenCalledWith(CTX, 'org-1', 'user-1');
87
- });
88
-
89
- it('public + ALLOW_CROSS_ORG_PUBLIC: skips the org member row', async () => {
90
- const deps = makeDeps({ allowCrossOrgPublic: true });
91
- const builder = createProjectAccessInputBuilder(deps);
92
- const input = await builder.buildProjectAccessInput(CTX, project('public'));
93
-
94
- expect(input.member).toEqual(MEMBER);
95
- expect(input.orgMember).toBeNull();
96
- expect(deps.getOrgMember).not.toHaveBeenCalled();
97
- });
98
-
99
- it('org visibility still fetches the org row when cross-org public is on', async () => {
100
- const deps = makeDeps({ allowCrossOrgPublic: true });
101
- const builder = createProjectAccessInputBuilder(deps);
102
- const input = await builder.buildProjectAccessInput(CTX, project('org'));
103
- expect(input.orgMember).toEqual(ORG_MEMBER);
104
- });
105
-
106
- it('platform + flag on: fetches grants only', async () => {
107
- const deps = makeDeps({ enablePlatformProjects: true });
108
- const builder = createProjectAccessInputBuilder(deps);
109
- const input = await builder.buildProjectAccessInput(CTX, project('platform'));
110
-
111
- expect(input.platformGrants).toEqual([GRANT]);
112
- expect(input.member).toBeNull();
113
- expect(deps.getProjectMember).not.toHaveBeenCalled();
114
- expect(deps.getOrgMember).not.toHaveBeenCalled();
115
- });
116
-
117
- it('platform + flag off: skips the grant lookup entirely (feature disabled stays cheap)', async () => {
118
- const deps = makeDeps({ enablePlatformProjects: false });
119
- const builder = createProjectAccessInputBuilder(deps);
120
- const input = await builder.buildProjectAccessInput(CTX, project('platform'));
121
-
122
- expect(input.platformGrants).toEqual([]);
123
- expect(deps.listPlatformGrants).not.toHaveBeenCalled();
124
- });
125
-
126
- it('threads ctx fields and flags into the input, with overrides winning', async () => {
127
- const deps = makeDeps({ allowCrossOrgPublic: true });
128
- const builder = createProjectAccessInputBuilder(deps);
129
- const input = await builder.buildProjectAccessInput(CTX, project('private'), {
130
- member: null
131
- });
132
-
133
- expect(input.userId).toBe('user-1');
134
- expect(input.actingOrgId).toBe('org-1');
135
- expect(input.orgPermissions).toEqual(CTX.orgPermissions);
136
- expect(input.platformPermissions).toEqual(CTX.platformPermissions);
137
- expect(input.allowCrossOrgPublic).toBe(true);
138
- expect(input.member).toBeNull(); // override beat the fetched row
139
- });
140
-
141
- it('reads flags per call, not at builder creation', async () => {
142
- const deps = makeDeps();
143
- const builder = createProjectAccessInputBuilder(deps);
144
- await builder.buildProjectAccessInput(CTX, project('private'));
145
- deps.flags.mockReturnValue({ allowCrossOrgPublic: true, enablePlatformProjects: true });
146
- const input = await builder.buildProjectAccessInput(CTX, project('private'));
147
- expect(input.enablePlatformProjects).toBe(true);
148
- });
149
- });
150
-
151
- describe('projectAccessInputFromRows', () => {
152
- it('assembles from caller rows with zero I/O', () => {
153
- const deps = makeDeps();
154
- const builder = createProjectAccessInputBuilder(deps);
155
- const input = builder.projectAccessInputFromRows(CTX, project('org'), {
156
- member: MEMBER,
157
- orgMember: ORG_MEMBER
158
- });
159
-
160
- expect(input.member).toEqual(MEMBER);
161
- expect(input.orgMember).toEqual(ORG_MEMBER);
162
- expect(input.platformGrants).toEqual([]);
163
- expect(deps.getProjectMember).not.toHaveBeenCalled();
164
- expect(deps.getOrgMember).not.toHaveBeenCalled();
165
- expect(deps.listPlatformGrants).not.toHaveBeenCalled();
166
- });
167
-
168
- it('defaults omitted rows to null/empty', () => {
169
- const builder = createProjectAccessInputBuilder(makeDeps());
170
- const input = builder.projectAccessInputFromRows(CTX, project('private'), {});
171
- expect(input.member).toBeNull();
172
- expect(input.orgMember).toBeNull();
173
- expect(input.platformGrants).toEqual([]);
174
- });
175
- });
@@ -1,9 +0,0 @@
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';
@@ -1,145 +0,0 @@
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
- }
@@ -1,244 +0,0 @@
1
- /**
2
- * Tests for `createClientCache` — the per-server warm-client LRU.
3
- *
4
- * `@selvajs/compute`'s `GrasshopperClient.create` does a real network preflight,
5
- * so we mock the module: `create` records the config it was called with and
6
- * returns a fake client whose `createScheduler` returns a disposable stub. That
7
- * lets us assert the cache's own behavior (id-keying, LRU eviction, evict,
8
- * disposeAll, the `X-Selva-Definition` header) without a live compute server.
9
- */
10
-
11
- import { describe, it, expect, beforeEach, vi } from 'vitest';
12
-
13
- // --- Mock @selvajs/compute -------------------------------------------------
14
- const createdConfigs: any[] = [];
15
- const createdSchedulerOptions: any[] = [];
16
- const disposedSchedulers: any[] = [];
17
-
18
- vi.mock('@selvajs/compute', () => {
19
- class GrasshopperClient {
20
- static async create(config: any) {
21
- createdConfigs.push(config);
22
- return new GrasshopperClient(config);
23
- }
24
- constructor(public config: any) {}
25
- createScheduler(options: any) {
26
- createdSchedulerOptions.push(options);
27
- const scheduler = {
28
- disposed: false,
29
- dispose() {
30
- this.disposed = true;
31
- disposedSchedulers.push(this);
32
- }
33
- };
34
- return scheduler;
35
- }
36
- }
37
- return { GrasshopperClient, enableDebugLogging: vi.fn() };
38
- });
39
-
40
- import { createClientCache, serverIdentity } from '../client-cache.js';
41
-
42
- function baseConfig() {
43
- return {
44
- maxSolveDurationMs: 30_000,
45
- maxConcurrentSolves: 4,
46
- maxQueueDepth: 0,
47
- queueWaitMs: 0,
48
- cachesolve: true,
49
- cacheerroredsolves: false,
50
- reuseServerDefinitionCache: true,
51
- debug: false,
52
- debugVerbose: false
53
- };
54
- }
55
-
56
- const server = (id: string, over: Partial<{ serverUrl: string; apiKey: string }> = {}) => ({
57
- id,
58
- serverUrl: over.serverUrl ?? `http://compute-${id}:6500`,
59
- apiKey: over.apiKey ?? `key-${id}`
60
- });
61
-
62
- beforeEach(() => {
63
- createdConfigs.length = 0;
64
- createdSchedulerOptions.length = 0;
65
- disposedSchedulers.length = 0;
66
- });
67
-
68
- describe('createClientCache — keying', () => {
69
- it('keys on id: two calls for the same id build one client', async () => {
70
- const cache = createClientCache(baseConfig());
71
- const a = await cache.getClient(server('s1'));
72
- const b = await cache.getClient(server('s1'));
73
- expect(a).toBe(b);
74
- expect(createdConfigs).toHaveLength(1);
75
- });
76
-
77
- it('reuses the entry even when the URL/apiKey rotate under the same id', async () => {
78
- // The whole point of ADR 0004 D1: identity is the id, so a rotated URL is
79
- // the SAME entry (stale until evicted) — not a new client.
80
- const cache = createClientCache(baseConfig());
81
- const a = await cache.getClient(server('s1', { serverUrl: 'http://old:6500' }));
82
- const b = await cache.getClient(server('s1', { serverUrl: 'http://new:6500' }));
83
- expect(a).toBe(b);
84
- expect(createdConfigs).toHaveLength(1);
85
- // It kept the ORIGINAL connection details — proving eviction is required.
86
- expect(createdConfigs[0].serverUrl).toBe('http://old:6500');
87
- });
88
-
89
- it('builds distinct clients for distinct ids', async () => {
90
- const cache = createClientCache(baseConfig());
91
- const a = await cache.getClient(server('s1'));
92
- const b = await cache.getClient(server('s2'));
93
- expect(a).not.toBe(b);
94
- expect(createdConfigs).toHaveLength(2);
95
- });
96
- });
97
-
98
- describe('createClientCache — LRU eviction', () => {
99
- it('evicts the least-recently-used entry past capacity and disposes its scheduler', async () => {
100
- const cache = createClientCache({ ...baseConfig(), maxCachedClients: 2 });
101
- const s1 = await cache.getClient(server('s1'));
102
- await cache.getClient(server('s2'));
103
- // Touch s1 so s2 becomes the LRU.
104
- await cache.getClient(server('s1'));
105
- await cache.getClient(server('s3')); // over capacity → evict s2
106
-
107
- expect(disposedSchedulers).toHaveLength(1);
108
- // s1 still warm (same entry, no rebuild); s2 rebuilt on next access.
109
- expect(await cache.getClient(server('s1'))).toBe(s1);
110
- expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(1);
111
- await cache.getClient(server('s2'));
112
- expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(2);
113
- });
114
- });
115
-
116
- describe('createClientCache — explicit invalidation', () => {
117
- it('evict(id) disposes the scheduler and forces a rebuild (config-write hook)', async () => {
118
- const cache = createClientCache(baseConfig());
119
- await cache.getClient(server('s1'));
120
- cache.evict('s1');
121
- expect(disposedSchedulers).toHaveLength(1);
122
- await cache.getClient(server('s1'));
123
- expect(createdConfigs).toHaveLength(2);
124
- });
125
-
126
- it('evict accepts a ServerIdentity', async () => {
127
- const cache = createClientCache(baseConfig());
128
- await cache.getClient(server('s1'));
129
- cache.evict(serverIdentity({ id: 's1' }));
130
- expect(disposedSchedulers).toHaveLength(1);
131
- });
132
-
133
- it('evict on an unknown id is a no-op', async () => {
134
- const cache = createClientCache(baseConfig());
135
- await cache.getClient(server('s1'));
136
- cache.evict('nope');
137
- expect(disposedSchedulers).toHaveLength(0);
138
- });
139
-
140
- it('disposeAll disposes every warm scheduler and clears the cache', async () => {
141
- const cache = createClientCache(baseConfig());
142
- await cache.getClient(server('s1'));
143
- await cache.getClient(server('s2'));
144
- cache.disposeAll();
145
- expect(disposedSchedulers).toHaveLength(2);
146
- // Next access rebuilds.
147
- await cache.getClient(server('s1'));
148
- expect(createdConfigs).toHaveLength(3);
149
- });
150
- });
151
-
152
- describe('createClientCache — X-Selva-Definition (ADR 0004 D2)', () => {
153
- it('stamps the definition guid on the client headers when provided', async () => {
154
- const cache = createClientCache(baseConfig());
155
- await cache.getClient(server('s1'), { definitionGuid: 'guid-123' });
156
- expect(createdConfigs[0].headers).toEqual({ 'X-Selva-Definition': 'guid-123' });
157
- });
158
-
159
- it('omits the header entirely when no guid is given', async () => {
160
- const cache = createClientCache(baseConfig());
161
- await cache.getClient(server('s1'));
162
- expect(createdConfigs[0].headers).toBeUndefined();
163
- });
164
- });
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
-
240
- describe('serverIdentity', () => {
241
- it('derives identity from the id', () => {
242
- expect(serverIdentity({ id: 'abc' })).toBe('abc');
243
- });
244
- });