@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,247 +0,0 @@
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. Reserve next versionNumber from the record's monotonic counter
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
- // v1 is created below with versionNumber 1, so the next upload reserves 2.
94
- nextVersionNumber: 2,
95
- liveVersionId: null,
96
- draftVersionId: null,
97
- createdAt: now,
98
- updatedAt: now,
99
- deletedAt: null
100
- };
101
-
102
- // 1. Metadata first — if this fails there's nothing to clean up.
103
- await this.data.definitions.create(ctx, record);
104
-
105
- // 2. v1 blob — if this fails the record stays 'pending' and is hidden
106
- // from default list queries.
107
- const versionId = randomUUID();
108
- const fileKey = definitionPaths.version(input.guid, 1, input.fileExt);
109
- await this.storage.put(fileKey, file, 'application/octet-stream');
110
-
111
- // 3. Version row.
112
- const version: DefinitionVersion = {
113
- id: versionId,
114
- definitionId: input.guid,
115
- versionNumber: 1,
116
- fileExt: input.fileExt,
117
- fileKey,
118
- originalFilename: input.originalFilename,
119
- uploadedBy: actor,
120
- uploadedAt: now,
121
- schema,
122
- schemaExtractedAt: now
123
- };
124
- await this.data.definitions.createVersion(ctx, version);
125
-
126
- // 4. Atomically point both channels at v1 and flip status='draft' in a
127
- // single store operation. Replaces three separate writes
128
- // (setLiveVersion + setDraftVersion + update) — see the class
129
- // header for why ordering matters here.
130
- await this.data.definitions.attachInitialVersion(ctx, input.guid, versionId);
131
-
132
- return {
133
- record: { ...record, status: 'draft', liveVersionId: versionId, draftVersionId: versionId },
134
- version
135
- };
136
- }
137
-
138
- /**
139
- * Upload a new version of an existing definition. Writes the blob, inserts
140
- * the version row, and advances the draft pointer. `live` is unchanged —
141
- * use `publish` to promote.
142
- */
143
- async uploadVersion(
144
- ctx: RequestContext,
145
- guid: string,
146
- file: Uint8Array,
147
- ext: DefinitionFileExt,
148
- originalName: string,
149
- schema: UISchema,
150
- changeNote?: string
151
- ): Promise<DefinitionVersion> {
152
- const existing = await this.data.definitions.get(ctx, guid);
153
- if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
154
-
155
- // Reserve the next version number from the record's monotonic counter — NOT
156
- // max(existing)+1, which reused a number after delete-latest and collided
157
- // the `fileKey` (stale blob served for new content). The counter only ever
158
- // advances, so a delete-then-reupload gets a fresh number and fresh key.
159
- const next = await this.data.definitions.reserveNextVersionNumber(ctx, guid);
160
-
161
- const versionId = randomUUID();
162
- const fileKey = definitionPaths.version(guid, next, ext);
163
- await this.storage.put(fileKey, file, 'application/octet-stream');
164
-
165
- const now = new Date().toISOString();
166
- const actor = ctx.userId || existing.ownerId;
167
- const version: DefinitionVersion = {
168
- id: versionId,
169
- definitionId: guid,
170
- versionNumber: next,
171
- fileExt: ext,
172
- fileKey,
173
- originalFilename: originalName,
174
- uploadedBy: actor,
175
- uploadedAt: now,
176
- changeNote: changeNote?.trim() || undefined,
177
- schema,
178
- schemaExtractedAt: now
179
- };
180
- await this.data.definitions.createVersion(ctx, version);
181
- await this.data.definitions.setDraftVersion(ctx, guid, versionId);
182
-
183
- return version;
184
- }
185
-
186
- /**
187
- * Advance the live channel. If `versionId` is omitted, promotes the
188
- * current draft. Pass an arbitrary version id to roll forward/back —
189
- * spec §6 makes rollback a first-class operation.
190
- */
191
- async publish(ctx: RequestContext, guid: string, versionId?: string): Promise<DefinitionVersion> {
192
- const existing = await this.data.definitions.get(ctx, guid);
193
- if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
194
-
195
- const target = versionId ?? existing.draftVersionId;
196
- if (!target) throw new ProviderError('No version to publish', 400);
197
-
198
- const version = await this.data.definitions.getVersion(ctx, target);
199
- if (!version || version.definitionId !== guid) {
200
- throw new ProviderError(`Version '${target}' not found for this definition`, 404);
201
- }
202
-
203
- await this.data.definitions.setLiveVersion(ctx, guid, target);
204
- // Flip status to 'published' the first time live moves off pending/draft.
205
- if (existing.status === 'draft' || existing.status === 'pending') {
206
- await this.data.definitions.update(ctx, guid, { status: 'published' });
207
- }
208
- return version;
209
- }
210
-
211
- async deleteVersion(ctx: RequestContext, guid: string, versionId: string): Promise<void> {
212
- const version = await this.data.definitions.getVersion(ctx, versionId);
213
- if (!version || version.definitionId !== guid) {
214
- throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
215
- }
216
- // Store enforces the live/draft reference check; if it throws, blob stays.
217
- await this.data.definitions.deleteVersion(ctx, versionId);
218
- await this.storage.delete(version.fileKey);
219
- }
220
-
221
- async updateMeta(ctx: RequestContext, guid: string, patch: UpdateMetadataInput): Promise<void> {
222
- await this.data.definitions.update(ctx, guid, patch);
223
- }
224
-
225
- async saveCoverImage(ctx: RequestContext, guid: string, imageData: Uint8Array): Promise<string> {
226
- const path = definitionPaths.image(guid);
227
- await this.storage.put(path, imageData, 'image/webp');
228
- const url = this.storage.getPublicUrl(path);
229
- await this.data.definitions.update(ctx, guid, { coverImage: url });
230
- return url;
231
- }
232
-
233
- async delete(ctx: RequestContext, guid: string): Promise<void> {
234
- // CONTRACT (spec §9): metadata is soft-deleted (preserved for audit,
235
- // restorable by clearing `deleted_at`); blobs are HARD-deleted. The
236
- // record is the audit trail; the blobs are storage cost.
237
- //
238
- // Order matters. Metadata first so the API immediately hides the
239
- // record (RLS / `deleted_at IS NULL` filter). If the blob wipe then
240
- // fails, the orphan storage is unreachable through the API — a
241
- // retention sweep can clean it up later. The reverse order leaves a
242
- // window where the record is still alive but its blobs are gone — a
243
- // 404 source for any in-flight reader.
244
- await this.data.definitions.delete(ctx, guid);
245
- await this.storage.deletePrefix(definitionPaths.prefix(guid));
246
- }
247
- }
@@ -1,22 +0,0 @@
1
- // Definitions server slice — the write orchestration (DefinitionService), the
2
- // compute-schema extraction/validation gate, and the render loader. All
3
- // composition-root wiring (stores, warm-client cache, server resolution) is
4
- // injected; access gating stays with the calling app.
5
-
6
- export { DefinitionService, type CreateDefinitionRecord } from './definition-service.js';
7
-
8
- export {
9
- fetchSchemaFromCompute,
10
- assertSupportedSchemaVersion,
11
- SchemaExtractionError
12
- } from './schema-extraction.js';
13
-
14
- export {
15
- createDefinitionLoader,
16
- DefinitionLoadError,
17
- type DefinitionChannel,
18
- type DefinitionLoader,
19
- type DefinitionLoaderDeps,
20
- type DefinitionLoadErrorKind,
21
- type LoadedDefinition
22
- } from './load-for-render.js';
@@ -1,272 +0,0 @@
1
- // Loader for "given a definition record, produce everything the client needs to
2
- // render its form and solve it": version resolution, blob fetch, compute-server
3
- // resolution, schema fetch (with the ADR 0005 staleness check), default merging.
4
- //
5
- // Access gating is intentionally NOT done here — the calling route decides
6
- // whether `ctx` is allowed to load this definition, since gates differ across
7
- // routes (project membership, segment ownership, share-token, admin override).
8
- //
9
- // Everything stateful is injected (`DefinitionLoaderDeps`): stores, server
10
- // resolution, the warm-client cache, and the schema fetcher — so any app built
11
- // on the engine can wire its own composition root.
12
-
13
- import type { GrasshopperClient } from '@selvajs/compute/grasshopper';
14
- import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
15
- import type {
16
- ComputeServerConfig,
17
- DefinitionRecord,
18
- DefinitionVersion,
19
- IDefinitionStore,
20
- IProjectStore,
21
- IStorageProvider,
22
- RequestContext
23
- } from '@selvajs/platform';
24
- import { fetchSchemaFromCompute } from './schema-extraction.js';
25
-
26
- export type DefinitionChannel = 'live' | 'draft';
27
-
28
- export interface LoadedDefinition {
29
- version: DefinitionVersion;
30
- definitionSource: Uint8Array;
31
- computeServer: ComputeServerConfig;
32
- schema: UISchema;
33
- }
34
-
35
- /**
36
- * Classified failure modes so callers can map to appropriate HTTP status:
37
- * - 'data': record references a version/blob that isn't there. (400)
38
- * - 'missing-config': no compute server resolvable for this org/definition. (503)
39
- * - 'connect': compute server is configured but unreachable. (503)
40
- * - 'schema': compute responded but IO/schema phase failed. (500)
41
- */
42
- export type DefinitionLoadErrorKind = 'data' | 'missing-config' | 'connect' | 'schema';
43
-
44
- export class DefinitionLoadError extends Error {
45
- constructor(
46
- public readonly kind: DefinitionLoadErrorKind,
47
- message: string,
48
- public readonly cause?: unknown
49
- ) {
50
- super(message);
51
- this.name = 'DefinitionLoadError';
52
- }
53
- }
54
-
55
- export interface DefinitionLoaderDeps {
56
- storage: Pick<IStorageProvider, 'get'>;
57
- definitions: Pick<IDefinitionStore, 'getVersion' | 'setVersionSchema'>;
58
- projects: Pick<IProjectStore, 'getProject'>;
59
- /** §3 resolution order: definition pin → org default override → global default. */
60
- resolveServer: (
61
- ctx: RequestContext,
62
- orgId: string | null,
63
- opts: { definitionPin: string | null }
64
- ) => Promise<ComputeServerConfig>;
65
- /** Shared warm-client cache (ADR 0004: keyed by server id, definition-guid affinity header). */
66
- getClient: (
67
- server: ComputeServerConfig,
68
- opts: { definitionGuid: string }
69
- ) => Promise<{ client: GrasshopperClient }>;
70
- /** Schema extraction; defaults to `fetchSchemaFromCompute`. Injectable for tests. */
71
- fetchSchema?: (bytes: Uint8Array, server: ComputeServerConfig) => Promise<UISchema>;
72
- /**
73
- * Verbose IO diagnostics — when set, an unhealthy IO result (empty inputs /
74
- * parse / load errors) also dumps the raw /io wire shape so
75
- * casing/serialization mismatches are visible.
76
- */
77
- computeDebug?: boolean;
78
- /** Diagnostic sink; defaults to `console.warn`. */
79
- onWarn?: (message: string, detail?: string) => void;
80
- }
81
-
82
- export type DefinitionLoader = (
83
- ctx: RequestContext,
84
- record: DefinitionRecord,
85
- channel: DefinitionChannel,
86
- explicitVersionId?: string | null
87
- ) => Promise<LoadedDefinition>;
88
-
89
- /**
90
- * Build a definition loader over the injected stores/compute wiring.
91
- *
92
- * The returned function loads a definition's version blob + schema (with
93
- * merged compute defaults). By default it resolves the `channel` pointer
94
- * (live/draft); pass `explicitVersionId` to render an arbitrary historical
95
- * version instead — the caller must gate that behind edit permission
96
- * (editor-only, like the draft channel). Throws `DefinitionLoadError` for
97
- * classified failures so the caller can translate to HTTP status codes.
98
- */
99
- export function createDefinitionLoader(deps: DefinitionLoaderDeps): DefinitionLoader {
100
- const {
101
- storage,
102
- definitions,
103
- projects,
104
- resolveServer,
105
- getClient,
106
- fetchSchema = fetchSchemaFromCompute,
107
- computeDebug = false,
108
- onWarn = (message, detail) => (detail ? console.warn(message, detail) : console.warn(message))
109
- } = deps;
110
-
111
- return async function loadDefinitionForRender(ctx, record, channel, explicitVersionId) {
112
- const versionId =
113
- explicitVersionId ?? (channel === 'draft' ? record.draftVersionId : record.liveVersionId);
114
- if (!versionId)
115
- throw new DefinitionLoadError(
116
- 'data',
117
- `Definition '${record.guid}' has no ${channel} version`
118
- );
119
-
120
- const version = await definitions.getVersion(ctx, versionId);
121
- if (!version) throw new DefinitionLoadError('data', `version missing for '${record.guid}'`);
122
- // An explicitly requested version must belong to this definition.
123
- if (version.definitionId !== record.guid)
124
- throw new DefinitionLoadError('data', `version does not belong to '${record.guid}'`);
125
-
126
- const definitionSource = await storage.get(version.fileKey);
127
- if (!definitionSource)
128
- throw new DefinitionLoadError(
129
- 'data',
130
- `Definition file for '${record.guid}' not found on disk`
131
- );
132
-
133
- const project = await projects.getProject(ctx, record.projectId);
134
- let computeServer: ComputeServerConfig;
135
- try {
136
- computeServer = await resolveServer(ctx, project?.orgId ?? null, {
137
- definitionPin: record.computeServerId ?? null
138
- });
139
- } catch (err) {
140
- throw new DefinitionLoadError(
141
- 'missing-config',
142
- 'No compute server configured for this definition',
143
- err
144
- );
145
- }
146
-
147
- // Only the client is used here (getIO); the entry's solve scheduler rides
148
- // along unused.
149
- let client: GrasshopperClient;
150
- try {
151
- client = (await getClient(computeServer, { definitionGuid: record.guid })).client;
152
- } catch (err) {
153
- const message = err instanceof Error ? err.message : String(err);
154
- throw new DefinitionLoadError(
155
- 'connect',
156
- `Failed to connect to Rhino Compute server: ${message}`,
157
- err
158
- );
159
- }
160
-
161
- try {
162
- // Prefer the schema cached on the version row (extracted + validated at
163
- // upload) — but ONLY when its format version matches the app's. The
164
- // stored schema is a disposable cache (ADR 0005): on a version mismatch
165
- // (or a pre-caching row with no schema at all) re-extract from compute,
166
- // whose C# SchemaMigrator emits the current format — the web side never
167
- // migrates schemas itself. `getIO` is always needed to merge compute
168
- // default values into the schema, cached or not.
169
- const cachedSchema = version.schema as UISchema | undefined;
170
- const freshCache =
171
- cachedSchema && cachedSchema.schemaVersion === UI_SCHEMA_VERSION ? cachedSchema : null;
172
- const [definition, fetchedSchema] = await Promise.all([
173
- client.getIO(definitionSource),
174
- freshCache ? Promise.resolve(freshCache) : fetchSchema(definitionSource, computeServer)
175
- ]);
176
-
177
- // Persist the refreshed schema back onto the version row (best-effort —
178
- // a viewer context may lack write access; the next editor render or the
179
- // solve-time backfill will land it). Only when the re-extracted schema
180
- // is at the app's current version: persisting an older format (compute
181
- // plugin behind the app) would just go stale again next render.
182
- if (!freshCache && fetchedSchema.schemaVersion === UI_SCHEMA_VERSION) {
183
- definitions.setVersionSchema(ctx, version.id, fetchedSchema).catch((err) => {
184
- onWarn(
185
- `[loadForRender] schema cache refresh failed for version ${version.id}: ` +
186
- `${err instanceof Error ? err.message : String(err)}`
187
- );
188
- });
189
- }
190
-
191
- if (!definition) {
192
- throw new Error(`Failed to get definition IO — server returned undefined`);
193
- }
194
-
195
- // Surface IO health when something looks off: zero inputs, or the compute
196
- // layer reported parse/load problems (e.g. an empty ValueList because its
197
- // backing GH component didn't resolve → "Missing Definition Objects", or a
198
- // PascalCase /io response the parser silently dropped to []). On a problem
199
- // we also fetch the raw IO so the un-parsed wire shape is in the logs.
200
- // Always-on warn (cheap, only fires on the unhappy path); the verbose raw
201
- // dump is gated on `computeDebug`.
202
- const ioParseErrors = (definition as { parseErrors?: unknown[] }).parseErrors ?? [];
203
- const ioLoadErrors = (definition as { loadErrors?: unknown[] }).loadErrors ?? [];
204
- const ioLooksBroken =
205
- definition.inputs.length === 0 || ioParseErrors.length > 0 || ioLoadErrors.length > 0;
206
- if (ioLooksBroken) {
207
- onWarn(
208
- `[loadForRender] IO health for '${record.guid}':`,
209
- JSON.stringify({
210
- inputCount: definition.inputs.length,
211
- outputCount: definition.outputs?.length,
212
- parseErrors: ioParseErrors,
213
- loadErrors: ioLoadErrors,
214
- loadWarnings: (definition as { loadWarnings?: unknown }).loadWarnings
215
- })
216
- );
217
- if (computeDebug) {
218
- const rawIO = await client
219
- .getRawIO(definitionSource)
220
- .catch((e) => ({ error: String(e) }));
221
- onWarn('[loadForRender] raw /io:', JSON.stringify(rawIO).slice(0, 3000));
222
- }
223
- }
224
-
225
- return {
226
- version,
227
- definitionSource,
228
- computeServer,
229
- schema: mergeComputeDefaults(fetchedSchema, definition.inputs)
230
- };
231
- } catch (err) {
232
- if (err instanceof DefinitionLoadError) throw err;
233
- const message = err instanceof Error ? err.message : String(err);
234
- throw new DefinitionLoadError('schema', message, err);
235
- }
236
- };
237
- }
238
-
239
- // Merge default values from Compute definition into schema inputs.
240
- // Colors come back as "r,g,b" or "a,r,g,b" strings — convert to hex so the
241
- // color picker can consume them directly.
242
- function mergeComputeDefaults(
243
- schema: UISchema,
244
- computeInputs: Array<{ id?: string; paramType?: string; default?: unknown }>
245
- ): UISchema {
246
- const byParamId = new Map(computeInputs.map((input) => [input.id, input]));
247
-
248
- return {
249
- ...schema,
250
- inputs: schema.inputs.map((schemaInput) => {
251
- const computeInput = byParamId.get(schemaInput.id);
252
-
253
- if (computeInput?.paramType === 'Color' && computeInput.default !== undefined) {
254
- const toHex = (value: number) => value.toString(16).padStart(2, '0');
255
- const parts = String(computeInput.default)
256
- .split(',')
257
- .map((s) => parseInt(s.trim(), 10));
258
-
259
- if (parts.length === 3) {
260
- computeInput.default = `#${toHex(parts[0])}${toHex(parts[1])}${toHex(parts[2])}`;
261
- } else if (parts.length === 4) {
262
- computeInput.default = `#${toHex(parts[1])}${toHex(parts[2])}${toHex(parts[3])}`;
263
- }
264
- }
265
-
266
- if (computeInput && computeInput.default !== undefined) {
267
- return { ...schemaInput, default: computeInput.default };
268
- }
269
- return schemaInput;
270
- })
271
- };
272
- }
@@ -1,117 +0,0 @@
1
- // Shared compute-schema extraction. One implementation used by:
2
- // - upload routes (validate-and-cache gate — no upload without a valid schema)
3
- // - the render path (falls back to live extraction for un-cached or stale
4
- // versions; see load-for-render.ts and ADR 0005)
5
- // - the temporary solve-time backfill bridge (see the app's specs/SchemaCaching.md)
6
- //
7
- // Extraction IS validation: a `.gh` with no valid "Schema" output, or an
8
- // unreachable compute server, throws here and the caller rejects the upload.
9
-
10
- import { camelcaseKeys } from '@selvajs/compute/core';
11
- import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
12
- import type { ComputeServerConfig } from '@selvajs/platform';
13
-
14
- /** Thrown when compute is reachable but the definition yields no usable schema. */
15
- export class SchemaExtractionError extends Error {
16
- constructor(
17
- public readonly kind: 'unreachable' | 'invalid' | 'unsupported',
18
- message: string
19
- ) {
20
- super(message);
21
- this.name = 'SchemaExtractionError';
22
- }
23
- }
24
-
25
- function parseSemver(version: string): [number, number, number] | null {
26
- const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
27
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
28
- }
29
-
30
- /**
31
- * Plugin↔app compat gate: reject a schema emitted by a NEWER plugin than this
32
- * app was built against — its shape may contain constructs the renderer does
33
- * not know. Older (or missing) versions pass: the compute-side C# migrator
34
- * always emits its own current version, and an older shape only lacks
35
- * optional additions.
36
- */
37
- export function assertSupportedSchemaVersion(schema: UISchema): void {
38
- const version = schema.schemaVersion;
39
- if (!version) return; // pre-2.12.0 plugin — older than the app, fine
40
- const extracted = parseSemver(version);
41
- const supported = parseSemver(UI_SCHEMA_VERSION);
42
- if (!extracted || !supported) return;
43
- const newer =
44
- extracted[0] > supported[0] ||
45
- (extracted[0] === supported[0] &&
46
- (extracted[1] > supported[1] ||
47
- (extracted[1] === supported[1] && extracted[2] > supported[2])));
48
- if (newer) {
49
- throw new SchemaExtractionError(
50
- 'unsupported',
51
- `This definition was authored with a newer Selva plugin (schema format ${version}); ` +
52
- `this server supports schema format ≤ ${UI_SCHEMA_VERSION}. Update the server, or ` +
53
- `re-save the definition with a matching plugin version.`
54
- );
55
- }
56
- }
57
-
58
- /**
59
- * Fetch the UI schema from Rhino Compute's `/grasshopper/schema` endpoint (no
60
- * solve required). Throws `SchemaExtractionError` so callers can map to the
61
- * right HTTP status (503 unreachable / 422 invalid or unsupported).
62
- */
63
- export async function fetchSchemaFromCompute(
64
- definitionBytes: Uint8Array,
65
- server: ComputeServerConfig
66
- ): Promise<UISchema> {
67
- const schemaUrl = new URL('/grasshopper/schema', server.serverUrl).toString();
68
-
69
- const formData = new FormData();
70
- const blob = new Blob([new Uint8Array(definitionBytes)], { type: 'application/octet-stream' });
71
- formData.append('file', blob, 'definition.gh');
72
-
73
- const headers: Record<string, string> = {};
74
- if (server.apiKey) {
75
- headers['RhinoComputeKey'] = server.apiKey;
76
- }
77
-
78
- let response: Response;
79
- try {
80
- response = await fetch(schemaUrl, { method: 'POST', headers, body: formData });
81
- } catch (err) {
82
- throw new SchemaExtractionError(
83
- 'unreachable',
84
- `Compute server is unreachable: ${err instanceof Error ? err.message : String(err)}`
85
- );
86
- }
87
-
88
- if (!response.ok) {
89
- throw new SchemaExtractionError(
90
- 'unreachable',
91
- `Schema endpoint returned ${response.status}: ${response.statusText}`
92
- );
93
- }
94
-
95
- // Compute returns [{ FileName, Schemas }] with PascalCase wrapper keys only.
96
- // The schema contents are already camelCase from our C# serializer, so we only
97
- // need a shallow camelcase per wrapper to normalize FileName→fileName,
98
- // Schemas→schemas (shallow camelcaseKeys is a no-op on arrays, so each
99
- // element is normalized individually). deep:true would mangle user-defined
100
- // option names (e.g. "Display3d" → "display3d").
101
- const raw = await response.json();
102
- const results = (Array.isArray(raw) ? raw : [raw]).map((r) => camelcaseKeys(r)) as {
103
- schemas: UISchema[];
104
- }[];
105
- const schemas = results.flatMap((r) => r.schemas ?? []);
106
-
107
- if (schemas.length === 0) {
108
- throw new SchemaExtractionError(
109
- 'invalid',
110
- 'No schemas found in definition.\n\n' +
111
- 'In Grasshopper, verify a Context Bake component with the output name "Schema" is present and wired to the solver.'
112
- );
113
- }
114
-
115
- assertSupportedSchemaVersion(schemas[0]);
116
- return schemas[0];
117
- }