@selvajs/server 0.2.0-beta.1
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/LICENSE +21 -0
- package/dist/compute/__tests__/client-cache.test.d.ts +11 -0
- package/dist/compute/__tests__/client-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/client-cache.test.js +149 -0
- package/dist/compute/__tests__/client-cache.test.js.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.d.ts +2 -0
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.js +62 -0
- package/dist/compute/__tests__/rate-limit.test.js.map +1 -0
- package/dist/compute/__tests__/remote-definition.test.d.ts +11 -0
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +1 -0
- package/dist/compute/__tests__/remote-definition.test.js +114 -0
- package/dist/compute/__tests__/remote-definition.test.js.map +1 -0
- package/dist/compute/__tests__/safe-url.test.d.ts +2 -0
- package/dist/compute/__tests__/safe-url.test.d.ts.map +1 -0
- package/dist/compute/__tests__/safe-url.test.js +71 -0
- package/dist/compute/__tests__/safe-url.test.js.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +11 -0
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.js +245 -0
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -0
- package/dist/compute/__tests__/transform-input.test.d.ts +14 -0
- package/dist/compute/__tests__/transform-input.test.d.ts.map +1 -0
- package/dist/compute/__tests__/transform-input.test.js +114 -0
- package/dist/compute/__tests__/transform-input.test.js.map +1 -0
- package/dist/compute/client-cache.d.ts +104 -0
- package/dist/compute/client-cache.d.ts.map +1 -0
- package/dist/compute/client-cache.js +147 -0
- package/dist/compute/client-cache.js.map +1 -0
- package/dist/compute/index.d.ts +8 -0
- package/dist/compute/index.d.ts.map +1 -0
- package/dist/compute/index.js +9 -0
- package/dist/compute/index.js.map +1 -0
- package/dist/compute/limits.d.ts +102 -0
- package/dist/compute/limits.d.ts.map +1 -0
- package/dist/compute/limits.js +76 -0
- package/dist/compute/limits.js.map +1 -0
- package/dist/compute/rate-limit.d.ts +49 -0
- package/dist/compute/rate-limit.d.ts.map +1 -0
- package/dist/compute/rate-limit.js +49 -0
- package/dist/compute/rate-limit.js.map +1 -0
- package/dist/compute/remote-definition.d.ts +53 -0
- package/dist/compute/remote-definition.d.ts.map +1 -0
- package/dist/compute/remote-definition.js +110 -0
- package/dist/compute/remote-definition.js.map +1 -0
- package/dist/compute/safe-url.d.ts +15 -0
- package/dist/compute/safe-url.d.ts.map +1 -0
- package/dist/compute/safe-url.js +195 -0
- package/dist/compute/safe-url.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +127 -0
- package/dist/compute/solve-pipeline.d.ts.map +1 -0
- package/dist/compute/solve-pipeline.js +195 -0
- package/dist/compute/solve-pipeline.js.map +1 -0
- package/dist/compute/transform-input.d.ts +20 -0
- package/dist/compute/transform-input.d.ts.map +1 -0
- package/dist/compute/transform-input.js +47 -0
- package/dist/compute/transform-input.js.map +1 -0
- package/dist/definitions/__tests__/load-for-render.test.d.ts +8 -0
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/load-for-render.test.js +196 -0
- package/dist/definitions/__tests__/load-for-render.test.js.map +1 -0
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +11 -0
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/schema-extraction.test.js +115 -0
- package/dist/definitions/__tests__/schema-extraction.test.js.map +1 -0
- package/dist/definitions/definition-service.d.ts +77 -0
- package/dist/definitions/definition-service.d.ts.map +1 -0
- package/dist/definitions/definition-service.js +184 -0
- package/dist/definitions/definition-service.js.map +1 -0
- package/dist/definitions/index.d.ts +4 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/index.js +8 -0
- package/dist/definitions/index.js.map +1 -0
- package/dist/definitions/load-for-render.d.ts +61 -0
- package/dist/definitions/load-for-render.d.ts.map +1 -0
- package/dist/definitions/load-for-render.js +165 -0
- package/dist/definitions/load-for-render.js.map +1 -0
- package/dist/definitions/schema-extraction.d.ts +22 -0
- package/dist/definitions/schema-extraction.d.ts.map +1 -0
- package/dist/definitions/schema-extraction.js +89 -0
- package/dist/definitions/schema-extraction.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +7 -0
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +1 -0
- package/dist/providers/__tests__/create-selva-providers.test.js +135 -0
- package/dist/providers/__tests__/create-selva-providers.test.js.map +1 -0
- package/dist/providers/create-selva-providers.d.ts +53 -0
- package/dist/providers/create-selva-providers.d.ts.map +1 -0
- package/dist/providers/create-selva-providers.js +152 -0
- package/dist/providers/create-selva-providers.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +6 -0
- package/dist/providers/index.js.map +1 -0
- package/package.json +60 -0
- package/src/compute/__tests__/client-cache.test.ts +164 -0
- package/src/compute/__tests__/rate-limit.test.ts +69 -0
- package/src/compute/__tests__/remote-definition.test.ts +140 -0
- package/src/compute/__tests__/safe-url.test.ts +72 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +294 -0
- package/src/compute/__tests__/transform-input.test.ts +155 -0
- package/src/compute/client-cache.ts +278 -0
- package/src/compute/index.ts +48 -0
- package/src/compute/limits.ts +147 -0
- package/src/compute/rate-limit.ts +83 -0
- package/src/compute/remote-definition.ts +149 -0
- package/src/compute/safe-url.ts +186 -0
- package/src/compute/solve-pipeline.ts +324 -0
- package/src/compute/transform-input.ts +52 -0
- package/src/definitions/__tests__/load-for-render.test.ts +244 -0
- package/src/definitions/__tests__/schema-extraction.test.ts +137 -0
- package/src/definitions/definition-service.ts +243 -0
- package/src/definitions/index.ts +22 -0
- package/src/definitions/load-for-render.ts +272 -0
- package/src/definitions/schema-extraction.ts +117 -0
- package/src/index.ts +18 -0
- package/src/providers/__tests__/create-selva-providers.test.ts +172 -0
- package/src/providers/__tests__/fixtures/override-config.mjs +8 -0
- package/src/providers/create-selva-providers.ts +241 -0
- package/src/providers/index.ts +12 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type InputParam, type InputParamSchema, processInput } from '@selvajs/compute';
|
|
2
|
+
import type { SchemaInput } from '@selvajs/schemas';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adapt a persisted `SchemaInput` (plus the user's chosen value) into the package's
|
|
6
|
+
* raw `InputParamSchema`, then let `@selvajs/compute`'s own `processInput` produce the
|
|
7
|
+
* typed `InputParam`. The package handles every paramType (Number/Integer/Text/Boolean/
|
|
8
|
+
* ValueList/Geometry/File/Color), normalizes the lowercase UI paramType to its
|
|
9
|
+
* PascalCase form, and falls back gracefully on underspecified inputs — so we no longer
|
|
10
|
+
* maintain a parallel hand-rolled transform that silently coerced valueList/file/color
|
|
11
|
+
* to Text and drifted from the package (and from the parapet app, which already delegates
|
|
12
|
+
* here). `values`/`acceptedFormats` are absent on `SchemaInput` (they live on the layout
|
|
13
|
+
* item's config), so valueList/file inputs fall back to carrying the selected value with
|
|
14
|
+
* no option list — the same value that reaches Grasshopper.
|
|
15
|
+
*/
|
|
16
|
+
export function transformInputParameter(
|
|
17
|
+
input: SchemaInput & { minimum?: number; maximum?: number; stepSize?: number },
|
|
18
|
+
value: unknown
|
|
19
|
+
): InputParam {
|
|
20
|
+
const raw: InputParamSchema = {
|
|
21
|
+
id: input.id,
|
|
22
|
+
name: input.nickname,
|
|
23
|
+
nickname: input.nickname || null,
|
|
24
|
+
description: input.description || '',
|
|
25
|
+
// Dynamic value lists ride the ValueList contract on the wire — the plugin's
|
|
26
|
+
// GetDynamicValueListParameter deliberately reuses TypeName "ValueList" so the
|
|
27
|
+
// compute fork's existing ValueList input case assigns it. processInput has no
|
|
28
|
+
// dynamicValueList handler; without this mapping it falls back to Geometry with
|
|
29
|
+
// a null default and TreeBuilder DROPS the input, so the user's selection never
|
|
30
|
+
// reaches the definition (the param then solves on its own stale fallback).
|
|
31
|
+
paramType: input.paramType === 'dynamicValueList' ? 'valueList' : input.paramType,
|
|
32
|
+
treeAccess: input.inputStructure === 'tree',
|
|
33
|
+
minimum: input.minimum ?? null,
|
|
34
|
+
maximum: input.maximum ?? null,
|
|
35
|
+
atLeast: 1,
|
|
36
|
+
atMost: 1,
|
|
37
|
+
stepSize: input.stepSize,
|
|
38
|
+
// The user's selection wins; otherwise the schema default.
|
|
39
|
+
default: value ?? input.default
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const processed = processInput(raw);
|
|
43
|
+
// processInput's default-normalization only understands scalars and innerTree
|
|
44
|
+
// objects — a plain array default (multi-select widgets, e.g. a dynamic value
|
|
45
|
+
// list checklist) is dropped as "malformed", which would omit the input from
|
|
46
|
+
// the solve entirely. Restore it; TreeBuilder.appendFlat handles arrays natively.
|
|
47
|
+
const effective = value ?? input.default;
|
|
48
|
+
if (Array.isArray(effective) && processed.default == null) {
|
|
49
|
+
(processed as { default: unknown }).default = effective;
|
|
50
|
+
}
|
|
51
|
+
return processed;
|
|
52
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `createDefinitionLoader` (K4) — version resolution, error
|
|
3
|
+
* classification, and the ADR 0005 schema-staleness behavior: a cached schema
|
|
4
|
+
* is used only at the app's current format version; anything else re-extracts
|
|
5
|
+
* from compute and persists back (best-effort, current-version only).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
+
import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
|
|
10
|
+
import type {
|
|
11
|
+
ComputeServerConfig,
|
|
12
|
+
DefinitionRecord,
|
|
13
|
+
DefinitionVersion,
|
|
14
|
+
RequestContext
|
|
15
|
+
} from '@selvajs/platform';
|
|
16
|
+
import { createDefinitionLoader, DefinitionLoadError } from '../load-for-render.js';
|
|
17
|
+
|
|
18
|
+
const ctx = { userId: 'user-1' } as RequestContext;
|
|
19
|
+
|
|
20
|
+
function uiSchema(version: string, inputs: unknown[] = []): UISchema {
|
|
21
|
+
return {
|
|
22
|
+
id: 'schema-1',
|
|
23
|
+
name: 'Test',
|
|
24
|
+
schemaVersion: version,
|
|
25
|
+
inputs,
|
|
26
|
+
outputs: [],
|
|
27
|
+
layout: { type: 'tabbed', tabs: [] }
|
|
28
|
+
} as unknown as UISchema;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const record = {
|
|
32
|
+
guid: 'def-1',
|
|
33
|
+
projectId: 'proj-1',
|
|
34
|
+
computeServerId: null,
|
|
35
|
+
liveVersionId: 'v-live',
|
|
36
|
+
draftVersionId: 'v-draft'
|
|
37
|
+
} as unknown as DefinitionRecord;
|
|
38
|
+
|
|
39
|
+
function version(overrides: Partial<DefinitionVersion> = {}): DefinitionVersion {
|
|
40
|
+
return {
|
|
41
|
+
id: 'v-live',
|
|
42
|
+
definitionId: 'def-1',
|
|
43
|
+
versionNumber: 1,
|
|
44
|
+
fileExt: 'gh',
|
|
45
|
+
fileKey: 'definitions/def-1/versions/v1.gh',
|
|
46
|
+
uploadedBy: 'user-1',
|
|
47
|
+
uploadedAt: '2026-07-01T00:00:00Z',
|
|
48
|
+
...overrides
|
|
49
|
+
} as DefinitionVersion;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const computeServer = { id: 'srv-1', serverUrl: 'http://c.example' } as ComputeServerConfig;
|
|
53
|
+
|
|
54
|
+
function makeDeps(overrides: Record<string, unknown> = {}) {
|
|
55
|
+
const getIO = vi.fn(async () => ({ inputs: [{ id: 'a', default: 42 }], outputs: [] }));
|
|
56
|
+
const deps = {
|
|
57
|
+
storage: { get: vi.fn(async () => new Uint8Array([1, 2, 3])) },
|
|
58
|
+
definitions: {
|
|
59
|
+
getVersion: vi.fn(async () => version()),
|
|
60
|
+
setVersionSchema: vi.fn(async () => {})
|
|
61
|
+
},
|
|
62
|
+
projects: { getProject: vi.fn(async () => ({ id: 'proj-1', orgId: 'org-1' })) },
|
|
63
|
+
resolveServer: vi.fn(async () => computeServer),
|
|
64
|
+
getClient: vi.fn(async () => ({ client: { getIO, getRawIO: vi.fn() } })),
|
|
65
|
+
fetchSchema: vi.fn(async () => uiSchema(UI_SCHEMA_VERSION, [{ id: 'a' }])),
|
|
66
|
+
onWarn: vi.fn(),
|
|
67
|
+
...overrides
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
deps: deps as never as Parameters<typeof createDefinitionLoader>[0],
|
|
71
|
+
mocks: deps,
|
|
72
|
+
getIO
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function flushMicrotasks() {
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('schema cache staleness (ADR 0005)', () => {
|
|
81
|
+
it('uses the cached schema when its version matches, without re-extracting or persisting', async () => {
|
|
82
|
+
const cached = uiSchema(UI_SCHEMA_VERSION, [{ id: 'a' }]);
|
|
83
|
+
const { deps, mocks } = makeDeps();
|
|
84
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: cached }));
|
|
85
|
+
|
|
86
|
+
const result = await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
87
|
+
|
|
88
|
+
expect(mocks.fetchSchema).not.toHaveBeenCalled();
|
|
89
|
+
expect(mocks.definitions.setVersionSchema).not.toHaveBeenCalled();
|
|
90
|
+
// Compute defaults are merged into the cached schema's inputs.
|
|
91
|
+
expect((result.schema.inputs[0] as { default?: unknown }).default).toBe(42);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('re-extracts and persists when the cached schema is at an older format version', async () => {
|
|
95
|
+
const { deps, mocks } = makeDeps();
|
|
96
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: uiSchema('2.10.0') }));
|
|
97
|
+
|
|
98
|
+
await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
99
|
+
await flushMicrotasks();
|
|
100
|
+
|
|
101
|
+
expect(mocks.fetchSchema).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(mocks.definitions.setVersionSchema).toHaveBeenCalledWith(
|
|
103
|
+
ctx,
|
|
104
|
+
'v-live',
|
|
105
|
+
expect.objectContaining({ schemaVersion: UI_SCHEMA_VERSION })
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('re-extracts and persists when no schema is cached (pre-caching row)', async () => {
|
|
110
|
+
const { deps, mocks } = makeDeps();
|
|
111
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: undefined }));
|
|
112
|
+
|
|
113
|
+
await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
114
|
+
await flushMicrotasks();
|
|
115
|
+
|
|
116
|
+
expect(mocks.fetchSchema).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(mocks.definitions.setVersionSchema).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does NOT persist a re-extracted schema that is still not at the current version', async () => {
|
|
121
|
+
// Compute plugin behind the app: re-extraction yields an older format.
|
|
122
|
+
const { deps, mocks } = makeDeps({
|
|
123
|
+
fetchSchema: vi.fn(async () => uiSchema('2.10.0'))
|
|
124
|
+
});
|
|
125
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: undefined }));
|
|
126
|
+
|
|
127
|
+
const result = await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
128
|
+
await flushMicrotasks();
|
|
129
|
+
|
|
130
|
+
expect(mocks.definitions.setVersionSchema).not.toHaveBeenCalled();
|
|
131
|
+
// The older schema still renders (older shapes only lack optional additions).
|
|
132
|
+
expect(result.schema.schemaVersion).toBe('2.10.0');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('a failed persist warns but does not fail the render', async () => {
|
|
136
|
+
const { deps, mocks } = makeDeps();
|
|
137
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: undefined }));
|
|
138
|
+
mocks.definitions.setVersionSchema.mockRejectedValue(new Error('RLS: not allowed'));
|
|
139
|
+
|
|
140
|
+
const result = await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
141
|
+
await flushMicrotasks();
|
|
142
|
+
|
|
143
|
+
expect(result.schema.schemaVersion).toBe(UI_SCHEMA_VERSION);
|
|
144
|
+
expect(mocks.onWarn).toHaveBeenCalledWith(expect.stringContaining('cache refresh failed'));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('version resolution', () => {
|
|
149
|
+
it('resolves the draft pointer for the draft channel', async () => {
|
|
150
|
+
const { deps, mocks } = makeDeps();
|
|
151
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ id: 'v-draft' }));
|
|
152
|
+
|
|
153
|
+
await createDefinitionLoader(deps)(ctx, record, 'draft');
|
|
154
|
+
expect(mocks.definitions.getVersion).toHaveBeenCalledWith(ctx, 'v-draft');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('an explicit versionId wins over the channel pointer', async () => {
|
|
158
|
+
const { deps, mocks } = makeDeps();
|
|
159
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ id: 'v-7' }));
|
|
160
|
+
|
|
161
|
+
await createDefinitionLoader(deps)(ctx, record, 'live', 'v-7');
|
|
162
|
+
expect(mocks.definitions.getVersion).toHaveBeenCalledWith(ctx, 'v-7');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects a version that belongs to another definition ('data')", async () => {
|
|
166
|
+
const { deps, mocks } = makeDeps();
|
|
167
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ definitionId: 'other-def' }));
|
|
168
|
+
|
|
169
|
+
await expect(createDefinitionLoader(deps)(ctx, record, 'live')).rejects.toMatchObject({
|
|
170
|
+
name: 'DefinitionLoadError',
|
|
171
|
+
kind: 'data'
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('error classification', () => {
|
|
177
|
+
it("no pointer for the channel → 'data'", async () => {
|
|
178
|
+
const { deps } = makeDeps();
|
|
179
|
+
const bare = { ...record, liveVersionId: null } as unknown as DefinitionRecord;
|
|
180
|
+
await expect(createDefinitionLoader(deps)(ctx, bare, 'live')).rejects.toMatchObject({
|
|
181
|
+
kind: 'data'
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("missing blob → 'data'", async () => {
|
|
186
|
+
const { deps, mocks } = makeDeps();
|
|
187
|
+
mocks.storage.get.mockResolvedValue(null as never);
|
|
188
|
+
await expect(createDefinitionLoader(deps)(ctx, record, 'live')).rejects.toMatchObject({
|
|
189
|
+
kind: 'data'
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("unresolvable compute server → 'missing-config'", async () => {
|
|
194
|
+
const { deps, mocks } = makeDeps();
|
|
195
|
+
mocks.resolveServer.mockRejectedValue(new Error('none visible'));
|
|
196
|
+
await expect(createDefinitionLoader(deps)(ctx, record, 'live')).rejects.toMatchObject({
|
|
197
|
+
kind: 'missing-config'
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("client connect failure → 'connect'", async () => {
|
|
202
|
+
const { deps, mocks } = makeDeps();
|
|
203
|
+
mocks.getClient.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
204
|
+
await expect(createDefinitionLoader(deps)(ctx, record, 'live')).rejects.toMatchObject({
|
|
205
|
+
kind: 'connect'
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("IO/schema phase failure → 'schema'", async () => {
|
|
210
|
+
const { deps, mocks } = makeDeps({
|
|
211
|
+
fetchSchema: vi.fn(async () => {
|
|
212
|
+
throw new Error('no Schema output');
|
|
213
|
+
})
|
|
214
|
+
});
|
|
215
|
+
mocks.definitions.getVersion.mockResolvedValue(version({ schema: undefined }));
|
|
216
|
+
const err = await createDefinitionLoader(deps)(ctx, record, 'live').catch((e) => e);
|
|
217
|
+
expect(err).toBeInstanceOf(DefinitionLoadError);
|
|
218
|
+
expect(err.kind).toBe('schema');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('compute default merging', () => {
|
|
223
|
+
beforeEach(() => vi.clearAllMocks());
|
|
224
|
+
|
|
225
|
+
it('converts Color defaults to hex', async () => {
|
|
226
|
+
const { deps, mocks } = makeDeps({
|
|
227
|
+
getClient: vi.fn(async () => ({
|
|
228
|
+
client: {
|
|
229
|
+
getIO: vi.fn(async () => ({
|
|
230
|
+
inputs: [{ id: 'col', paramType: 'Color', default: '255,128,0' }],
|
|
231
|
+
outputs: []
|
|
232
|
+
})),
|
|
233
|
+
getRawIO: vi.fn()
|
|
234
|
+
}
|
|
235
|
+
}))
|
|
236
|
+
});
|
|
237
|
+
mocks.definitions.getVersion.mockResolvedValue(
|
|
238
|
+
version({ schema: uiSchema(UI_SCHEMA_VERSION, [{ id: 'col' }]) })
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const result = await createDefinitionLoader(deps)(ctx, record, 'live');
|
|
242
|
+
expect((result.schema.inputs[0] as { default?: unknown }).default).toBe('#ff8000');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the compute-schema extraction gate (K4 / ADR 0005).
|
|
3
|
+
*
|
|
4
|
+
* `fetch` is stubbed per test to script the compute `/grasshopper/schema`
|
|
5
|
+
* response. The version-compat gate (`assertSupportedSchemaVersion`) is also
|
|
6
|
+
* exercised directly: newer-than-supported must throw 'unsupported', while
|
|
7
|
+
* older / missing versions pass (compute's C# migrator emits its own current
|
|
8
|
+
* version; older shapes only lack optional additions).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
|
|
13
|
+
import type { ComputeServerConfig } from '@selvajs/platform';
|
|
14
|
+
import {
|
|
15
|
+
fetchSchemaFromCompute,
|
|
16
|
+
assertSupportedSchemaVersion,
|
|
17
|
+
SchemaExtractionError
|
|
18
|
+
} from '../schema-extraction.js';
|
|
19
|
+
|
|
20
|
+
function schema(version?: string): UISchema {
|
|
21
|
+
return {
|
|
22
|
+
id: 'schema-1',
|
|
23
|
+
name: 'Test',
|
|
24
|
+
...(version !== undefined && { schemaVersion: version }),
|
|
25
|
+
inputs: [],
|
|
26
|
+
outputs: [],
|
|
27
|
+
layout: { type: 'tabbed', tabs: [] }
|
|
28
|
+
} as unknown as UISchema;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bump(version: string, part: 0 | 1 | 2): string {
|
|
32
|
+
const nums = version.split('.').map(Number);
|
|
33
|
+
nums[part] += 1;
|
|
34
|
+
return nums.join('.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const server: ComputeServerConfig = {
|
|
38
|
+
id: 'srv-1',
|
|
39
|
+
serverUrl: 'http://compute.example.com',
|
|
40
|
+
apiKey: 'secret'
|
|
41
|
+
} as ComputeServerConfig;
|
|
42
|
+
|
|
43
|
+
describe('assertSupportedSchemaVersion', () => {
|
|
44
|
+
it('passes the current version', () => {
|
|
45
|
+
expect(() => assertSupportedSchemaVersion(schema(UI_SCHEMA_VERSION))).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('passes older versions (older shapes only lack optional additions)', () => {
|
|
49
|
+
expect(() => assertSupportedSchemaVersion(schema('1.0.0'))).not.toThrow();
|
|
50
|
+
expect(() => assertSupportedSchemaVersion(schema('2.0.0'))).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('passes a schema without a version (pre-2.12.0 plugin)', () => {
|
|
54
|
+
expect(() => assertSupportedSchemaVersion(schema())).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each([
|
|
58
|
+
['major', 0],
|
|
59
|
+
['minor', 1],
|
|
60
|
+
['patch', 2]
|
|
61
|
+
] as const)('throws unsupported for a newer %s version', (_label, part) => {
|
|
62
|
+
const newer = bump(UI_SCHEMA_VERSION, part);
|
|
63
|
+
try {
|
|
64
|
+
assertSupportedSchemaVersion(schema(newer));
|
|
65
|
+
expect.unreachable('should have thrown');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
expect(err).toBeInstanceOf(SchemaExtractionError);
|
|
68
|
+
expect((err as SchemaExtractionError).kind).toBe('unsupported');
|
|
69
|
+
expect((err as SchemaExtractionError).message).toContain(newer);
|
|
70
|
+
expect((err as SchemaExtractionError).message).toContain(UI_SCHEMA_VERSION);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('passes an unparseable version string (fail open — invalid is not "newer")', () => {
|
|
75
|
+
expect(() => assertSupportedSchemaVersion(schema('abc'))).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('fetchSchemaFromCompute', () => {
|
|
80
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
fetchMock = vi.fn();
|
|
84
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.unstubAllGlobals();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function computeResponse(schemas: unknown[]): Response {
|
|
92
|
+
// Compute wraps results with PascalCase keys.
|
|
93
|
+
return new Response(JSON.stringify([{ FileName: 'definition.gh', Schemas: schemas }]), {
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: { 'content-type': 'application/json' }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
it('extracts the first schema and sends the API key header', async () => {
|
|
100
|
+
fetchMock.mockResolvedValue(computeResponse([schema(UI_SCHEMA_VERSION)]));
|
|
101
|
+
const result = await fetchSchemaFromCompute(new Uint8Array([1]), server);
|
|
102
|
+
expect(result.id).toBe('schema-1');
|
|
103
|
+
|
|
104
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
105
|
+
expect(url).toBe('http://compute.example.com/grasshopper/schema');
|
|
106
|
+
expect((init.headers as Record<string, string>)['RhinoComputeKey']).toBe('secret');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws unreachable when the network request fails', async () => {
|
|
110
|
+
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
111
|
+
await expect(fetchSchemaFromCompute(new Uint8Array([1]), server)).rejects.toMatchObject({
|
|
112
|
+
name: 'SchemaExtractionError',
|
|
113
|
+
kind: 'unreachable'
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('throws unreachable on a non-OK status', async () => {
|
|
118
|
+
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }));
|
|
119
|
+
await expect(fetchSchemaFromCompute(new Uint8Array([1]), server)).rejects.toMatchObject({
|
|
120
|
+
kind: 'unreachable'
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('throws invalid when the definition yields no schemas', async () => {
|
|
125
|
+
fetchMock.mockResolvedValue(computeResponse([]));
|
|
126
|
+
await expect(fetchSchemaFromCompute(new Uint8Array([1]), server)).rejects.toMatchObject({
|
|
127
|
+
kind: 'invalid'
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('applies the version gate to the extracted schema', async () => {
|
|
132
|
+
fetchMock.mockResolvedValue(computeResponse([schema(bump(UI_SCHEMA_VERSION, 1))]));
|
|
133
|
+
await expect(fetchSchemaFromCompute(new Uint8Array([1]), server)).rejects.toMatchObject({
|
|
134
|
+
kind: 'unsupported'
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
IDataProvider,
|
|
4
|
+
IStorageProvider,
|
|
5
|
+
RequestContext,
|
|
6
|
+
DefinitionRecord,
|
|
7
|
+
DefinitionFileExt,
|
|
8
|
+
DefinitionVersion,
|
|
9
|
+
UpdateMetadataInput,
|
|
10
|
+
UISchema
|
|
11
|
+
} from '@selvajs/platform';
|
|
12
|
+
import { ProviderError, definitionPaths } from '@selvajs/platform';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Input passed to `DefinitionService.create`. Carries everything the service
|
|
16
|
+
* needs to assemble a `DefinitionRecord` plus orchestrate the v1 upload.
|
|
17
|
+
*
|
|
18
|
+
* Distinct from `CreateDefinitionInputSchema` exported by
|
|
19
|
+
* `@selvajs/platform/definitions`, which validates the user-facing
|
|
20
|
+
* HTTP body (no guid/ownerId — those are derived server-side).
|
|
21
|
+
*/
|
|
22
|
+
export interface CreateDefinitionRecord {
|
|
23
|
+
guid: string;
|
|
24
|
+
projectId: string;
|
|
25
|
+
ownerId: string;
|
|
26
|
+
fileExt: DefinitionFileExt;
|
|
27
|
+
originalFilename?: string;
|
|
28
|
+
displayName: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
category?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
coverImage?: string;
|
|
33
|
+
computeServerId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Orchestrates writes that span IDataProvider + IStorageProvider for the
|
|
38
|
+
* spec §6 versioning model. Lives in @selvajs/selva (not @selvajs/platform)
|
|
39
|
+
* because it is application orchestration, not platform contract.
|
|
40
|
+
*
|
|
41
|
+
* Ordering rules:
|
|
42
|
+
*
|
|
43
|
+
* create — metadata-first, then v1:
|
|
44
|
+
* 1. Write record with status='pending', both pointers null
|
|
45
|
+
* 2. Upload v1 blob to versions/v1.{ext}
|
|
46
|
+
* 3. Insert DefinitionVersion row v1
|
|
47
|
+
* 4. `attachInitialVersion` — atomically set live + draft pointers and
|
|
48
|
+
* flip status to 'draft' in a single store operation.
|
|
49
|
+
* Step 4 is one round-trip so a mid-flight failure can't leave a partial
|
|
50
|
+
* state (status='draft' with null pointers, or status='pending' with
|
|
51
|
+
* pointers set). If step 2 fails the record stays 'pending' with no
|
|
52
|
+
* blob and is filtered out of list endpoints by default.
|
|
53
|
+
*
|
|
54
|
+
* uploadVersion — append-only:
|
|
55
|
+
* 1. Resolve next versionNumber from the version list
|
|
56
|
+
* 2. Upload blob to versions/v{N}.{ext}
|
|
57
|
+
* 3. Insert DefinitionVersion row
|
|
58
|
+
* 4. Advance draft pointer (live unchanged)
|
|
59
|
+
*
|
|
60
|
+
* publish — pointer flip only, no blob writes.
|
|
61
|
+
*
|
|
62
|
+
* deleteVersion — store enforces "not referenced by live/draft"; on
|
|
63
|
+
* success, delete the underlying blob.
|
|
64
|
+
*/
|
|
65
|
+
export class DefinitionService {
|
|
66
|
+
constructor(
|
|
67
|
+
private data: IDataProvider,
|
|
68
|
+
private storage: IStorageProvider
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
async create(
|
|
72
|
+
ctx: RequestContext,
|
|
73
|
+
input: CreateDefinitionRecord,
|
|
74
|
+
file: Uint8Array,
|
|
75
|
+
schema: UISchema
|
|
76
|
+
): Promise<{ record: DefinitionRecord; version: DefinitionVersion }> {
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
const actor = ctx.userId || input.ownerId;
|
|
79
|
+
const record: DefinitionRecord = {
|
|
80
|
+
guid: input.guid,
|
|
81
|
+
projectId: input.projectId,
|
|
82
|
+
ownerId: input.ownerId,
|
|
83
|
+
createdBy: actor,
|
|
84
|
+
updatedBy: actor,
|
|
85
|
+
displayName: input.displayName,
|
|
86
|
+
description: input.description,
|
|
87
|
+
category: input.category,
|
|
88
|
+
tags: input.tags,
|
|
89
|
+
coverImage: input.coverImage,
|
|
90
|
+
computeServerId: input.computeServerId,
|
|
91
|
+
status: 'pending',
|
|
92
|
+
solveCount: 0,
|
|
93
|
+
liveVersionId: null,
|
|
94
|
+
draftVersionId: null,
|
|
95
|
+
createdAt: now,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
deletedAt: null
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// 1. Metadata first — if this fails there's nothing to clean up.
|
|
101
|
+
await this.data.definitions.create(ctx, record);
|
|
102
|
+
|
|
103
|
+
// 2. v1 blob — if this fails the record stays 'pending' and is hidden
|
|
104
|
+
// from default list queries.
|
|
105
|
+
const versionId = randomUUID();
|
|
106
|
+
const fileKey = definitionPaths.version(input.guid, 1, input.fileExt);
|
|
107
|
+
await this.storage.put(fileKey, file, 'application/octet-stream');
|
|
108
|
+
|
|
109
|
+
// 3. Version row.
|
|
110
|
+
const version: DefinitionVersion = {
|
|
111
|
+
id: versionId,
|
|
112
|
+
definitionId: input.guid,
|
|
113
|
+
versionNumber: 1,
|
|
114
|
+
fileExt: input.fileExt,
|
|
115
|
+
fileKey,
|
|
116
|
+
originalFilename: input.originalFilename,
|
|
117
|
+
uploadedBy: actor,
|
|
118
|
+
uploadedAt: now,
|
|
119
|
+
schema,
|
|
120
|
+
schemaExtractedAt: now
|
|
121
|
+
};
|
|
122
|
+
await this.data.definitions.createVersion(ctx, version);
|
|
123
|
+
|
|
124
|
+
// 4. Atomically point both channels at v1 and flip status='draft' in a
|
|
125
|
+
// single store operation. Replaces three separate writes
|
|
126
|
+
// (setLiveVersion + setDraftVersion + update) — see the class
|
|
127
|
+
// header for why ordering matters here.
|
|
128
|
+
await this.data.definitions.attachInitialVersion(ctx, input.guid, versionId);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
record: { ...record, status: 'draft', liveVersionId: versionId, draftVersionId: versionId },
|
|
132
|
+
version
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Upload a new version of an existing definition. Writes the blob, inserts
|
|
138
|
+
* the version row, and advances the draft pointer. `live` is unchanged —
|
|
139
|
+
* use `publish` to promote.
|
|
140
|
+
*/
|
|
141
|
+
async uploadVersion(
|
|
142
|
+
ctx: RequestContext,
|
|
143
|
+
guid: string,
|
|
144
|
+
file: Uint8Array,
|
|
145
|
+
ext: DefinitionFileExt,
|
|
146
|
+
originalName: string,
|
|
147
|
+
schema: UISchema,
|
|
148
|
+
changeNote?: string
|
|
149
|
+
): Promise<DefinitionVersion> {
|
|
150
|
+
const existing = await this.data.definitions.get(ctx, guid);
|
|
151
|
+
if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
|
|
152
|
+
|
|
153
|
+
// Highest existing versionNumber + 1; covers gaps from deletions.
|
|
154
|
+
const versions = await this.data.definitions.listVersions(ctx, guid, { limit: 1 });
|
|
155
|
+
const next = (versions.items[0]?.versionNumber ?? 0) + 1;
|
|
156
|
+
|
|
157
|
+
const versionId = randomUUID();
|
|
158
|
+
const fileKey = definitionPaths.version(guid, next, ext);
|
|
159
|
+
await this.storage.put(fileKey, file, 'application/octet-stream');
|
|
160
|
+
|
|
161
|
+
const now = new Date().toISOString();
|
|
162
|
+
const actor = ctx.userId || existing.ownerId;
|
|
163
|
+
const version: DefinitionVersion = {
|
|
164
|
+
id: versionId,
|
|
165
|
+
definitionId: guid,
|
|
166
|
+
versionNumber: next,
|
|
167
|
+
fileExt: ext,
|
|
168
|
+
fileKey,
|
|
169
|
+
originalFilename: originalName,
|
|
170
|
+
uploadedBy: actor,
|
|
171
|
+
uploadedAt: now,
|
|
172
|
+
changeNote: changeNote?.trim() || undefined,
|
|
173
|
+
schema,
|
|
174
|
+
schemaExtractedAt: now
|
|
175
|
+
};
|
|
176
|
+
await this.data.definitions.createVersion(ctx, version);
|
|
177
|
+
await this.data.definitions.setDraftVersion(ctx, guid, versionId);
|
|
178
|
+
|
|
179
|
+
return version;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Advance the live channel. If `versionId` is omitted, promotes the
|
|
184
|
+
* current draft. Pass an arbitrary version id to roll forward/back —
|
|
185
|
+
* spec §6 makes rollback a first-class operation.
|
|
186
|
+
*/
|
|
187
|
+
async publish(ctx: RequestContext, guid: string, versionId?: string): Promise<DefinitionVersion> {
|
|
188
|
+
const existing = await this.data.definitions.get(ctx, guid);
|
|
189
|
+
if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
|
|
190
|
+
|
|
191
|
+
const target = versionId ?? existing.draftVersionId;
|
|
192
|
+
if (!target) throw new ProviderError('No version to publish', 400);
|
|
193
|
+
|
|
194
|
+
const version = await this.data.definitions.getVersion(ctx, target);
|
|
195
|
+
if (!version || version.definitionId !== guid) {
|
|
196
|
+
throw new ProviderError(`Version '${target}' not found for this definition`, 404);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await this.data.definitions.setLiveVersion(ctx, guid, target);
|
|
200
|
+
// Flip status to 'published' the first time live moves off pending/draft.
|
|
201
|
+
if (existing.status === 'draft' || existing.status === 'pending') {
|
|
202
|
+
await this.data.definitions.update(ctx, guid, { status: 'published' });
|
|
203
|
+
}
|
|
204
|
+
return version;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async deleteVersion(ctx: RequestContext, guid: string, versionId: string): Promise<void> {
|
|
208
|
+
const version = await this.data.definitions.getVersion(ctx, versionId);
|
|
209
|
+
if (!version || version.definitionId !== guid) {
|
|
210
|
+
throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
|
|
211
|
+
}
|
|
212
|
+
// Store enforces the live/draft reference check; if it throws, blob stays.
|
|
213
|
+
await this.data.definitions.deleteVersion(ctx, versionId);
|
|
214
|
+
await this.storage.delete(version.fileKey);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async updateMeta(ctx: RequestContext, guid: string, patch: UpdateMetadataInput): Promise<void> {
|
|
218
|
+
await this.data.definitions.update(ctx, guid, patch);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async saveCoverImage(ctx: RequestContext, guid: string, imageData: Uint8Array): Promise<string> {
|
|
222
|
+
const path = definitionPaths.image(guid);
|
|
223
|
+
await this.storage.put(path, imageData, 'image/webp');
|
|
224
|
+
const url = this.storage.getPublicUrl(path);
|
|
225
|
+
await this.data.definitions.update(ctx, guid, { coverImage: url });
|
|
226
|
+
return url;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async delete(ctx: RequestContext, guid: string): Promise<void> {
|
|
230
|
+
// CONTRACT (spec §9): metadata is soft-deleted (preserved for audit,
|
|
231
|
+
// restorable by clearing `deleted_at`); blobs are HARD-deleted. The
|
|
232
|
+
// record is the audit trail; the blobs are storage cost.
|
|
233
|
+
//
|
|
234
|
+
// Order matters. Metadata first so the API immediately hides the
|
|
235
|
+
// record (RLS / `deleted_at IS NULL` filter). If the blob wipe then
|
|
236
|
+
// fails, the orphan storage is unreachable through the API — a
|
|
237
|
+
// retention sweep can clean it up later. The reverse order leaves a
|
|
238
|
+
// window where the record is still alive but its blobs are gone — a
|
|
239
|
+
// 404 source for any in-flight reader.
|
|
240
|
+
await this.data.definitions.delete(ctx, guid);
|
|
241
|
+
await this.storage.deletePrefix(definitionPaths.prefix(guid));
|
|
242
|
+
}
|
|
243
|
+
}
|