@highstate/backend-api 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/highstate.manifest.json +1 -1
- package/dist/index.js +1204 -93
- package/package.json +12 -10
- package/src/handlers/instance-state.ts +96 -0
- package/src/handlers/library.ts +63 -0
- package/src/handlers/operation.ts +108 -0
- package/src/handlers/panel.ts +7 -6
- package/src/handlers/project-model.ts +131 -0
- package/src/handlers/project.ts +46 -0
- package/src/handlers/secret.ts +6 -5
- package/src/handlers/worker.ts +29 -32
- package/src/index.ts +125 -19
- package/src/shared/api-error.ts +42 -0
- package/src/shared/authentication.ts +172 -12
- package/src/shared/authorization-header.test.ts +36 -0
- package/src/shared/authorization-header.ts +24 -0
- package/src/shared/conversion.test.ts +122 -0
- package/src/shared/conversion.ts +600 -0
- package/src/shared/error-handling.test.ts +115 -0
- package/src/shared/error-handling.ts +127 -14
- package/src/shared/field-mask.test.ts +37 -0
- package/src/shared/field-mask.ts +81 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/serialization.test.ts +17 -0
- package/src/shared/serialization.ts +35 -0
- package/src/shared/validation.ts +45 -5
- package/src/handlers/instance.ts +0 -44
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InstanceState as BackendInstanceState,
|
|
3
|
+
Operation as BackendOperation,
|
|
4
|
+
OperationPhase as BackendOperationPhase,
|
|
5
|
+
FullProjectModel as BackendProjectModel,
|
|
6
|
+
ProjectOutput,
|
|
7
|
+
} from "@highstate/backend/shared"
|
|
8
|
+
import { create, fromJson, type JsonValue, toJson } from "@bufbuild/protobuf"
|
|
9
|
+
import { type Timestamp, timestampFromDate, ValueSchema } from "@bufbuild/protobuf/wkt"
|
|
10
|
+
import {
|
|
11
|
+
type Component,
|
|
12
|
+
ComponentKind,
|
|
13
|
+
ComponentSchema,
|
|
14
|
+
type Entity,
|
|
15
|
+
EntitySchema,
|
|
16
|
+
EvaluationStatus,
|
|
17
|
+
type Hub,
|
|
18
|
+
HubSchema,
|
|
19
|
+
type Instance,
|
|
20
|
+
type InstanceCustomStatus,
|
|
21
|
+
InstanceCustomStatusSchema,
|
|
22
|
+
InstanceOperationStatus,
|
|
23
|
+
InstanceSchema,
|
|
24
|
+
InstanceSource,
|
|
25
|
+
type InstanceState,
|
|
26
|
+
InstanceStateSchema,
|
|
27
|
+
InstanceStatus,
|
|
28
|
+
type Library,
|
|
29
|
+
LibrarySchema,
|
|
30
|
+
type Operation,
|
|
31
|
+
type OperationLog,
|
|
32
|
+
OperationLogSchema,
|
|
33
|
+
type OperationPhase,
|
|
34
|
+
OperationPhaseSchema,
|
|
35
|
+
OperationPhaseType,
|
|
36
|
+
OperationSchema,
|
|
37
|
+
OperationStatus,
|
|
38
|
+
OperationType,
|
|
39
|
+
type Project,
|
|
40
|
+
type ProjectModel,
|
|
41
|
+
ProjectModelSchema,
|
|
42
|
+
ProjectSchema,
|
|
43
|
+
} from "@highstate/api/v1"
|
|
44
|
+
import {
|
|
45
|
+
instanceCustomStatusInputSchema,
|
|
46
|
+
operationMetaSchema,
|
|
47
|
+
operationOptionsSchema,
|
|
48
|
+
operationPhaseSchema,
|
|
49
|
+
} from "@highstate/backend/shared"
|
|
50
|
+
import {
|
|
51
|
+
type ComponentModel,
|
|
52
|
+
type EntityModel,
|
|
53
|
+
type HubModel,
|
|
54
|
+
type HubModelPatch,
|
|
55
|
+
type InstanceModel,
|
|
56
|
+
type InstanceModelPatch,
|
|
57
|
+
instanceInputSchema,
|
|
58
|
+
instanceModelSchema,
|
|
59
|
+
z,
|
|
60
|
+
} from "@highstate/contract"
|
|
61
|
+
|
|
62
|
+
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
|
63
|
+
z.union([
|
|
64
|
+
z.string(),
|
|
65
|
+
z.number(),
|
|
66
|
+
z.boolean(),
|
|
67
|
+
z.null(),
|
|
68
|
+
z.array(jsonValueSchema),
|
|
69
|
+
z.record(z.string(), jsonValueSchema),
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
const jsonObjectSchema = z.record(z.string(), jsonValueSchema)
|
|
73
|
+
|
|
74
|
+
export function toProject(project: ProjectOutput): Project {
|
|
75
|
+
return create(ProjectSchema, {
|
|
76
|
+
id: project.id,
|
|
77
|
+
name: project.name,
|
|
78
|
+
meta: project.meta,
|
|
79
|
+
spaceId: project.spaceId,
|
|
80
|
+
modelStorageId: project.modelStorageId,
|
|
81
|
+
libraryId: project.libraryId,
|
|
82
|
+
pulumiBackendId: project.pulumiBackendId,
|
|
83
|
+
createdAt: toTimestamp(project.createdAt),
|
|
84
|
+
updatedAt: toTimestamp(project.updatedAt),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function toProjectModel(model: BackendProjectModel): ProjectModel {
|
|
89
|
+
return create(ProjectModelSchema, {
|
|
90
|
+
instances: [...model.instances, ...model.virtualInstances, ...model.ghostInstances].map(
|
|
91
|
+
toInstance,
|
|
92
|
+
),
|
|
93
|
+
hubs: model.hubs.map(toHub),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toInstance(instance: InstanceModel): Instance {
|
|
98
|
+
return create(InstanceSchema, {
|
|
99
|
+
id: instance.id,
|
|
100
|
+
kind: toComponentKind(instance.kind),
|
|
101
|
+
type: instance.type,
|
|
102
|
+
name: instance.name,
|
|
103
|
+
arguments: Object.entries(instance.args ?? {}).map(([key, value]) => ({
|
|
104
|
+
key,
|
|
105
|
+
value: fromJson(ValueSchema, value as JsonValue),
|
|
106
|
+
})),
|
|
107
|
+
inputs: toInstanceReferenceMap(instance.inputs),
|
|
108
|
+
hubInputs: Object.fromEntries(
|
|
109
|
+
Object.entries(instance.hubInputs ?? {}).map(([key, values]) => [
|
|
110
|
+
key,
|
|
111
|
+
{ values: values.map(value => ({ hubId: value.hubId })) },
|
|
112
|
+
]),
|
|
113
|
+
),
|
|
114
|
+
injectionInputs: (instance.injectionInputs ?? []).map(value => ({ hubId: value.hubId })),
|
|
115
|
+
position: instance.position ?? undefined,
|
|
116
|
+
resolvedInputs: toInstanceReferenceMap(instance.resolvedInputs),
|
|
117
|
+
parentId: instance.parentId,
|
|
118
|
+
outputs: toInstanceReferenceMap(instance.outputs),
|
|
119
|
+
resolvedOutputs: toInstanceReferenceMap(instance.resolvedOutputs),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function fromInstance(instance: Instance): InstanceModel {
|
|
124
|
+
return instanceModelSchema.parse({
|
|
125
|
+
id: instance.id,
|
|
126
|
+
kind: fromComponentKind(instance.kind),
|
|
127
|
+
type: instance.type,
|
|
128
|
+
name: instance.name,
|
|
129
|
+
args: Object.fromEntries(
|
|
130
|
+
instance.arguments.map(argument => [
|
|
131
|
+
argument.key,
|
|
132
|
+
argument.value ? toJson(ValueSchema, argument.value) : null,
|
|
133
|
+
]),
|
|
134
|
+
),
|
|
135
|
+
inputs: fromInstanceReferenceMap(instance.inputs),
|
|
136
|
+
hubInputs: Object.fromEntries(
|
|
137
|
+
Object.entries(instance.hubInputs).map(([key, list]) => [
|
|
138
|
+
key,
|
|
139
|
+
list.values.map(value => ({ hubId: value.hubId })),
|
|
140
|
+
]),
|
|
141
|
+
),
|
|
142
|
+
injectionInputs: instance.injectionInputs.map(value => ({ hubId: value.hubId })),
|
|
143
|
+
position: instance.position,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function toHub(hub: HubModel): Hub {
|
|
148
|
+
return create(HubSchema, {
|
|
149
|
+
id: hub.id,
|
|
150
|
+
position: hub.position ?? undefined,
|
|
151
|
+
inputs: hub.inputs ?? [],
|
|
152
|
+
injectionInputs: (hub.injectionInputs ?? []).map(value => ({ hubId: value.hubId })),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function fromHub(hub: Hub): HubModel {
|
|
157
|
+
return {
|
|
158
|
+
id: hub.id,
|
|
159
|
+
position: hub.position,
|
|
160
|
+
inputs: hub.inputs.map(fromInstanceReference),
|
|
161
|
+
injectionInputs: hub.injectionInputs.map(value => ({ hubId: value.hubId })),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function toInstancePatch(instance: Instance, paths: readonly string[]): InstanceModelPatch {
|
|
166
|
+
const patch: InstanceModelPatch = {}
|
|
167
|
+
|
|
168
|
+
for (const path of paths) {
|
|
169
|
+
switch (path) {
|
|
170
|
+
case "arguments":
|
|
171
|
+
patch.args = Object.fromEntries(
|
|
172
|
+
instance.arguments.map(argument => [
|
|
173
|
+
argument.key,
|
|
174
|
+
argument.value ? toJson(ValueSchema, argument.value) : null,
|
|
175
|
+
]),
|
|
176
|
+
)
|
|
177
|
+
break
|
|
178
|
+
case "inputs":
|
|
179
|
+
patch.inputs = fromInstanceReferenceMap(instance.inputs)
|
|
180
|
+
break
|
|
181
|
+
case "hub_inputs":
|
|
182
|
+
patch.hubInputs = Object.fromEntries(
|
|
183
|
+
Object.entries(instance.hubInputs).map(([key, list]) => [
|
|
184
|
+
key,
|
|
185
|
+
list.values.map(value => ({ hubId: value.hubId })),
|
|
186
|
+
]),
|
|
187
|
+
)
|
|
188
|
+
break
|
|
189
|
+
case "injection_inputs":
|
|
190
|
+
patch.injectionInputs = instance.injectionInputs.map(value => ({ hubId: value.hubId }))
|
|
191
|
+
break
|
|
192
|
+
case "position":
|
|
193
|
+
patch.position = instance.position
|
|
194
|
+
? { x: instance.position.x, y: instance.position.y }
|
|
195
|
+
: null
|
|
196
|
+
break
|
|
197
|
+
case "position.x":
|
|
198
|
+
patch.position = { x: instance.position?.x ?? 0 }
|
|
199
|
+
break
|
|
200
|
+
case "position.y":
|
|
201
|
+
patch.position = { y: instance.position?.y ?? 0 }
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return patch
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function toHubPatch(hub: Hub, paths: readonly string[]): HubModelPatch {
|
|
210
|
+
const patch: HubModelPatch = {}
|
|
211
|
+
|
|
212
|
+
for (const path of paths) {
|
|
213
|
+
switch (path) {
|
|
214
|
+
case "position":
|
|
215
|
+
patch.position = hub.position ? { x: hub.position.x, y: hub.position.y } : null
|
|
216
|
+
break
|
|
217
|
+
case "position.x":
|
|
218
|
+
patch.position = { x: hub.position?.x ?? 0 }
|
|
219
|
+
break
|
|
220
|
+
case "position.y":
|
|
221
|
+
patch.position = { y: hub.position?.y ?? 0 }
|
|
222
|
+
break
|
|
223
|
+
case "inputs":
|
|
224
|
+
patch.inputs = hub.inputs.map(fromInstanceReference)
|
|
225
|
+
break
|
|
226
|
+
case "injection_inputs":
|
|
227
|
+
patch.injectionInputs = hub.injectionInputs.map(value => ({ hubId: value.hubId }))
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return patch
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function toInstanceState(state: BackendInstanceState): InstanceState {
|
|
236
|
+
const model = state.model ? instanceModelSchema.parse(state.model) : undefined
|
|
237
|
+
const resolvedInputs = state.resolvedInputs
|
|
238
|
+
? fromUnknownInstanceReferenceMap(state.resolvedInputs)
|
|
239
|
+
: undefined
|
|
240
|
+
|
|
241
|
+
return create(InstanceStateSchema, {
|
|
242
|
+
id: state.id,
|
|
243
|
+
instanceId: state.instanceId,
|
|
244
|
+
status: toInstanceStatus(state.status),
|
|
245
|
+
source: toInstanceSource(state.source),
|
|
246
|
+
kind: toComponentKind(state.kind),
|
|
247
|
+
parentInstanceId: state.parentInstanceId ?? undefined,
|
|
248
|
+
evaluationState: state.evaluationState
|
|
249
|
+
? {
|
|
250
|
+
status: toEvaluationStatus(state.evaluationState.status),
|
|
251
|
+
message: state.evaluationState.message ?? undefined,
|
|
252
|
+
model: state.evaluationState.model
|
|
253
|
+
? toInstance(instanceModelSchema.parse(state.evaluationState.model))
|
|
254
|
+
: undefined,
|
|
255
|
+
evaluatedAt: toTimestamp(state.evaluationState.evaluatedAt),
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
258
|
+
lastOperationState: state.lastOperationState
|
|
259
|
+
? {
|
|
260
|
+
operationId: state.lastOperationState.operationId,
|
|
261
|
+
stateId: state.lastOperationState.stateId,
|
|
262
|
+
status: toInstanceOperationStatus(state.lastOperationState.status),
|
|
263
|
+
currentResourceCount: state.lastOperationState.currentResourceCount ?? undefined,
|
|
264
|
+
totalResourceCount: state.lastOperationState.totalResourceCount ?? undefined,
|
|
265
|
+
model: toInstance(instanceModelSchema.parse(state.lastOperationState.model)),
|
|
266
|
+
resolvedInputs: toInstanceReferenceMap(
|
|
267
|
+
fromUnknownInstanceReferenceMap(state.lastOperationState.resolvedInputs),
|
|
268
|
+
),
|
|
269
|
+
startedAt: toNullableTimestamp(state.lastOperationState.startedAt),
|
|
270
|
+
finishedAt: toNullableTimestamp(state.lastOperationState.finishedAt),
|
|
271
|
+
}
|
|
272
|
+
: undefined,
|
|
273
|
+
terminalIds: state.terminalIds ?? [],
|
|
274
|
+
pageIds: state.pageIds ?? [],
|
|
275
|
+
panelIds: state.panelIds ?? [],
|
|
276
|
+
secretNames: state.secretNames ?? [],
|
|
277
|
+
customStatuses: (state.customStatuses ?? []).map(toInstanceCustomStatus),
|
|
278
|
+
currentResourceCount: state.currentResourceCount ?? undefined,
|
|
279
|
+
hasResourceHooks: state.hasResourceHooks,
|
|
280
|
+
statusFields: state.statusFields
|
|
281
|
+
? fromJson(ValueSchema, jsonValueSchema.parse(state.statusFields))
|
|
282
|
+
: undefined,
|
|
283
|
+
model: model ? toInstance(model) : undefined,
|
|
284
|
+
resolvedInputs: toInstanceReferenceMap(resolvedInputs),
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function toInstanceCustomStatus(
|
|
289
|
+
status: NonNullable<BackendInstanceState["customStatuses"]>[number],
|
|
290
|
+
): InstanceCustomStatus {
|
|
291
|
+
const meta = instanceCustomStatusInputSchema.shape.meta.parse(status.meta)
|
|
292
|
+
|
|
293
|
+
return create(InstanceCustomStatusSchema, {
|
|
294
|
+
name: status.name,
|
|
295
|
+
meta: {
|
|
296
|
+
title: meta.title ?? status.name,
|
|
297
|
+
description: meta.description,
|
|
298
|
+
icon: meta.icon,
|
|
299
|
+
iconColor: meta.iconColor,
|
|
300
|
+
},
|
|
301
|
+
value: status.value,
|
|
302
|
+
message: status.message ?? undefined,
|
|
303
|
+
order: status.order,
|
|
304
|
+
serviceAccountId: status.serviceAccountId,
|
|
305
|
+
createdAt: toTimestamp(status.createdAt),
|
|
306
|
+
updatedAt: toTimestamp(status.updatedAt),
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function toOperation(operation: BackendOperation): Operation {
|
|
311
|
+
const meta = operationMetaSchema.parse(operation.meta)
|
|
312
|
+
const options = operationOptionsSchema.partial().parse(operation.options)
|
|
313
|
+
const phases = operation.phases ? operationPhaseSchema.array().parse(operation.phases) : []
|
|
314
|
+
|
|
315
|
+
return create(OperationSchema, {
|
|
316
|
+
id: operation.id,
|
|
317
|
+
meta,
|
|
318
|
+
type: toOperationType(operation.type),
|
|
319
|
+
status: toOperationStatus(operation.status),
|
|
320
|
+
options: {
|
|
321
|
+
forceUpdateDependencies: options.forceUpdateDependencies ?? false,
|
|
322
|
+
ignoreChangedDependencies: options.ignoreChangedDependencies ?? false,
|
|
323
|
+
ignoreDependencies: options.ignoreDependencies ?? false,
|
|
324
|
+
forceUpdateChildren: options.forceUpdateChildren ?? false,
|
|
325
|
+
onlyDestroyGhosts: options.onlyDestroyGhosts ?? false,
|
|
326
|
+
firstDestroyGhosts: options.firstDestroyGhosts ?? false,
|
|
327
|
+
ignoreGhosts: options.ignoreGhosts ?? false,
|
|
328
|
+
destroyDependentInstances: options.destroyDependentInstances ?? false,
|
|
329
|
+
invokeDestroyTriggers: options.invokeDestroyTriggers ?? false,
|
|
330
|
+
deleteUnreachableResources: options.deleteUnreachableResources ?? false,
|
|
331
|
+
forceDeleteState: options.forceDeleteState ?? false,
|
|
332
|
+
allowPartialCompositeInstanceUpdate: options.allowPartialCompositeInstanceUpdate ?? false,
|
|
333
|
+
allowPartialCompositeInstanceDestruction:
|
|
334
|
+
options.allowPartialCompositeInstanceDestruction ?? false,
|
|
335
|
+
refresh: options.refresh ?? false,
|
|
336
|
+
debug: options.debug ?? false,
|
|
337
|
+
},
|
|
338
|
+
requestedInstanceIds: z.string().array().parse(operation.requestedInstanceIds),
|
|
339
|
+
phases: phases.map(toOperationPhase),
|
|
340
|
+
startedAt: toTimestamp(operation.startedAt),
|
|
341
|
+
updatedAt: toTimestamp(operation.updatedAt),
|
|
342
|
+
finishedAt: toNullableTimestamp(operation.finishedAt),
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function toOperationPhase(phase: BackendOperationPhase): OperationPhase {
|
|
347
|
+
return create(OperationPhaseSchema, {
|
|
348
|
+
type: toOperationPhaseType(phase.type),
|
|
349
|
+
instances: phase.instances,
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function toOperationLog(
|
|
354
|
+
operationId: string,
|
|
355
|
+
log: { id: string; stateId: string | null; content: string; isSystem?: boolean },
|
|
356
|
+
): OperationLog {
|
|
357
|
+
return create(OperationLogSchema, {
|
|
358
|
+
id: log.id,
|
|
359
|
+
operationId,
|
|
360
|
+
stateId: log.stateId ?? undefined,
|
|
361
|
+
isSystem: log.isSystem ?? false,
|
|
362
|
+
content: log.content,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function toLibrary(library: {
|
|
367
|
+
components: Record<string, ComponentModel>
|
|
368
|
+
entities: Record<string, EntityModel>
|
|
369
|
+
}): Library {
|
|
370
|
+
return create(LibrarySchema, {
|
|
371
|
+
components: Object.fromEntries(
|
|
372
|
+
Object.entries(library.components).map(([type, component]) => [type, toComponent(component)]),
|
|
373
|
+
),
|
|
374
|
+
entities: Object.fromEntries(
|
|
375
|
+
Object.entries(library.entities).map(([type, entity]) => [type, toEntity(entity)]),
|
|
376
|
+
),
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function toComponent(component: ComponentModel): Component {
|
|
381
|
+
return create(ComponentSchema, {
|
|
382
|
+
type: component.type,
|
|
383
|
+
kind: toComponentKind(component.kind),
|
|
384
|
+
arguments: Object.fromEntries(
|
|
385
|
+
Object.entries(component.args).map(([name, argument]) => [
|
|
386
|
+
name,
|
|
387
|
+
{
|
|
388
|
+
schema: jsonObjectSchema.parse(argument.schema),
|
|
389
|
+
required: argument.required,
|
|
390
|
+
meta: argument.meta,
|
|
391
|
+
},
|
|
392
|
+
]),
|
|
393
|
+
),
|
|
394
|
+
inputs: Object.fromEntries(
|
|
395
|
+
Object.entries(component.inputs).map(([name, port]) => [name, toComponentPort(port)]),
|
|
396
|
+
),
|
|
397
|
+
outputs: Object.fromEntries(
|
|
398
|
+
Object.entries(component.outputs).map(([name, port]) => [name, toComponentPort(port)]),
|
|
399
|
+
),
|
|
400
|
+
meta: component.meta,
|
|
401
|
+
definitionHash: component.definitionHash,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function toEntity(entity: EntityModel): Entity {
|
|
406
|
+
return create(EntitySchema, {
|
|
407
|
+
type: entity.type,
|
|
408
|
+
extensions: entity.extensions ?? [],
|
|
409
|
+
directExtensions: entity.directExtensions ?? [],
|
|
410
|
+
inclusions: entity.inclusions ?? [],
|
|
411
|
+
directInclusions: entity.directInclusions ?? [],
|
|
412
|
+
schema: jsonObjectSchema.parse(entity.schema),
|
|
413
|
+
meta: entity.meta,
|
|
414
|
+
definitionHash: entity.definitionHash,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function toTimestamp(value: Date): Timestamp {
|
|
419
|
+
if (!Number.isFinite(value.getTime())) {
|
|
420
|
+
throw new Error("Cannot convert invalid date to timestamp")
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return timestampFromDate(value)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function toNullableTimestamp(value: Date | null | undefined): Timestamp | undefined {
|
|
427
|
+
return value ? toTimestamp(value) : undefined
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function toComponentPort(port: ComponentModel["inputs"][string]) {
|
|
431
|
+
return {
|
|
432
|
+
entityType: port.type,
|
|
433
|
+
fromInput: port.fromInput,
|
|
434
|
+
required: port.required,
|
|
435
|
+
multiple: port.multiple,
|
|
436
|
+
meta: port.meta,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function toInstanceReferenceMap(values?: InstanceModel["inputs"]) {
|
|
441
|
+
return Object.fromEntries(
|
|
442
|
+
Object.entries(values ?? {}).map(([key, references]) => [
|
|
443
|
+
key,
|
|
444
|
+
{
|
|
445
|
+
values: references,
|
|
446
|
+
},
|
|
447
|
+
]),
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function fromInstanceReferenceMap(
|
|
452
|
+
values: Instance["inputs"],
|
|
453
|
+
): NonNullable<InstanceModel["inputs"]> {
|
|
454
|
+
return Object.fromEntries(
|
|
455
|
+
Object.entries(values).map(([key, list]) => [key, list.values.map(fromInstanceReference)]),
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function fromUnknownInstanceReferenceMap(value: unknown): NonNullable<InstanceModel["inputs"]> {
|
|
460
|
+
return instanceModelSchema.shape.inputs.unwrap().parse(value)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function fromInstanceReference(value: Instance["inputs"][string]["values"][number]) {
|
|
464
|
+
return instanceInputSchema.parse({
|
|
465
|
+
instanceId: value.instanceId,
|
|
466
|
+
output: value.output,
|
|
467
|
+
path: value.path,
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function toComponentKind(value: string): ComponentKind {
|
|
472
|
+
if (value === "unit") return ComponentKind.UNIT
|
|
473
|
+
if (value === "composite") return ComponentKind.COMPOSITE
|
|
474
|
+
throw new Error(`Unknown component kind "${value}"`)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function fromComponentKind(value: ComponentKind): "unit" | "composite" {
|
|
478
|
+
if (value === ComponentKind.UNIT) return "unit"
|
|
479
|
+
if (value === ComponentKind.COMPOSITE) return "composite"
|
|
480
|
+
throw new Error("Component kind must be specified")
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function toInstanceStatus(value: string): InstanceStatus {
|
|
484
|
+
const statuses = {
|
|
485
|
+
undeployed: InstanceStatus.UNDEPLOYED,
|
|
486
|
+
attempted: InstanceStatus.ATTEMPTED,
|
|
487
|
+
deployed: InstanceStatus.DEPLOYED,
|
|
488
|
+
failed: InstanceStatus.FAILED,
|
|
489
|
+
} as const
|
|
490
|
+
const status = statuses[value as keyof typeof statuses]
|
|
491
|
+
if (status === undefined) throw new Error(`Unknown instance status "${value}"`)
|
|
492
|
+
return status
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function toInstanceSource(value: string): InstanceSource {
|
|
496
|
+
if (value === "resident") return InstanceSource.RESIDENT
|
|
497
|
+
if (value === "virtual") return InstanceSource.VIRTUAL
|
|
498
|
+
throw new Error(`Unknown instance source "${value}"`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function toEvaluationStatus(value: string): EvaluationStatus {
|
|
502
|
+
if (value === "evaluating") return EvaluationStatus.EVALUATING
|
|
503
|
+
if (value === "evaluated") return EvaluationStatus.EVALUATED
|
|
504
|
+
if (value === "error") return EvaluationStatus.ERROR
|
|
505
|
+
throw new Error(`Unknown evaluation status "${value}"`)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function toInstanceOperationStatus(value: string): InstanceOperationStatus {
|
|
509
|
+
const statuses = {
|
|
510
|
+
updating: InstanceOperationStatus.UPDATING,
|
|
511
|
+
processing_triggers: InstanceOperationStatus.PROCESSING_TRIGGERS,
|
|
512
|
+
previewing: InstanceOperationStatus.PREVIEWING,
|
|
513
|
+
destroying: InstanceOperationStatus.DESTROYING,
|
|
514
|
+
refreshing: InstanceOperationStatus.REFRESHING,
|
|
515
|
+
pending: InstanceOperationStatus.PENDING,
|
|
516
|
+
cancelling: InstanceOperationStatus.CANCELLING,
|
|
517
|
+
updated: InstanceOperationStatus.UPDATED,
|
|
518
|
+
previewed: InstanceOperationStatus.PREVIEWED,
|
|
519
|
+
skipped: InstanceOperationStatus.SKIPPED,
|
|
520
|
+
destroyed: InstanceOperationStatus.DESTROYED,
|
|
521
|
+
refreshed: InstanceOperationStatus.REFRESHED,
|
|
522
|
+
cancelled: InstanceOperationStatus.CANCELLED,
|
|
523
|
+
failed: InstanceOperationStatus.FAILED,
|
|
524
|
+
} as const
|
|
525
|
+
const status = statuses[value as keyof typeof statuses]
|
|
526
|
+
if (status === undefined) throw new Error(`Unknown instance operation status "${value}"`)
|
|
527
|
+
return status
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function fromOperationType(value: OperationType) {
|
|
531
|
+
if (value === OperationType.UPDATE) return "update"
|
|
532
|
+
if (value === OperationType.PREVIEW) return "preview"
|
|
533
|
+
if (value === OperationType.DESTROY) return "destroy"
|
|
534
|
+
if (value === OperationType.RECREATE) return "recreate"
|
|
535
|
+
if (value === OperationType.REFRESH) return "refresh"
|
|
536
|
+
throw new Error("Operation type must be specified")
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function fromOperationPhase(phase: OperationPhase): BackendOperationPhase {
|
|
540
|
+
return operationPhaseSchema.parse({
|
|
541
|
+
type: fromOperationPhaseType(phase.type),
|
|
542
|
+
instances: phase.instances.map(instance => ({
|
|
543
|
+
id: instance.id,
|
|
544
|
+
parentId: instance.parentId,
|
|
545
|
+
message: instance.message,
|
|
546
|
+
})),
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function toOperationType(value: string): OperationType {
|
|
551
|
+
const types = {
|
|
552
|
+
update: OperationType.UPDATE,
|
|
553
|
+
preview: OperationType.PREVIEW,
|
|
554
|
+
destroy: OperationType.DESTROY,
|
|
555
|
+
recreate: OperationType.RECREATE,
|
|
556
|
+
refresh: OperationType.REFRESH,
|
|
557
|
+
} as const
|
|
558
|
+
const type = types[value as keyof typeof types]
|
|
559
|
+
if (type === undefined) throw new Error(`Unknown operation type "${value}"`)
|
|
560
|
+
return type
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function toOperationStatus(value: string): OperationStatus {
|
|
564
|
+
const statuses = {
|
|
565
|
+
pending: OperationStatus.PENDING,
|
|
566
|
+
running: OperationStatus.RUNNING,
|
|
567
|
+
failing: OperationStatus.FAILING,
|
|
568
|
+
cancelling: OperationStatus.CANCELLING,
|
|
569
|
+
completed: OperationStatus.COMPLETED,
|
|
570
|
+
failed: OperationStatus.FAILED,
|
|
571
|
+
cancelled: OperationStatus.CANCELLED,
|
|
572
|
+
} as const
|
|
573
|
+
const status = statuses[value as keyof typeof statuses]
|
|
574
|
+
if (status === undefined) throw new Error(`Unknown operation status "${value}"`)
|
|
575
|
+
return status
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function toOperationPhaseType(value: string): OperationPhaseType {
|
|
579
|
+
const types = {
|
|
580
|
+
destroy: OperationPhaseType.DESTROY,
|
|
581
|
+
preview: OperationPhaseType.PREVIEW,
|
|
582
|
+
update: OperationPhaseType.UPDATE,
|
|
583
|
+
refresh: OperationPhaseType.REFRESH,
|
|
584
|
+
} as const
|
|
585
|
+
const type = types[value as keyof typeof types]
|
|
586
|
+
if (type === undefined) throw new Error(`Unknown operation phase type "${value}"`)
|
|
587
|
+
return type
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function fromOperationPhaseType(value: OperationPhaseType): BackendOperationPhase["type"] {
|
|
591
|
+
const types = {
|
|
592
|
+
[OperationPhaseType.DESTROY]: "destroy",
|
|
593
|
+
[OperationPhaseType.PREVIEW]: "preview",
|
|
594
|
+
[OperationPhaseType.UPDATE]: "update",
|
|
595
|
+
[OperationPhaseType.REFRESH]: "refresh",
|
|
596
|
+
} as const
|
|
597
|
+
const type = types[value as keyof typeof types]
|
|
598
|
+
if (!type) throw new Error("Operation phase type must be specified")
|
|
599
|
+
return type
|
|
600
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Code, ConnectError } from "@connectrpc/connect"
|
|
2
|
+
import { BadRequestSchema, ErrorInfoSchema } from "@highstate/api/v1"
|
|
3
|
+
import {
|
|
4
|
+
BackendError,
|
|
5
|
+
BackendErrorCategory,
|
|
6
|
+
InvalidPageSizeError,
|
|
7
|
+
PermissionDeniedError,
|
|
8
|
+
} from "@highstate/backend/shared"
|
|
9
|
+
import { describe, expect, it, vi } from "vitest"
|
|
10
|
+
import { createErrorHandlingInterceptor } from "./error-handling"
|
|
11
|
+
|
|
12
|
+
describe("createErrorHandlingInterceptor", () => {
|
|
13
|
+
it("maps backend errors to structured details", async () => {
|
|
14
|
+
const logger = { error: vi.fn() }
|
|
15
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
16
|
+
const handler = interceptor(async () => {
|
|
17
|
+
throw new InvalidPageSizeError(-1)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await handler({
|
|
22
|
+
method: { name: "ListOperations" },
|
|
23
|
+
header: new Headers({ "x-request-id": "request-1" }),
|
|
24
|
+
} as never)
|
|
25
|
+
expect.fail("Expected handler to throw")
|
|
26
|
+
} catch (error) {
|
|
27
|
+
expect(error).toBeInstanceOf(ConnectError)
|
|
28
|
+
const connectError = error as ConnectError
|
|
29
|
+
expect(connectError.code).toBe(Code.InvalidArgument)
|
|
30
|
+
expect(connectError.findDetails(ErrorInfoSchema)[0]?.reason).toBe("PAGE_SIZE_INVALID")
|
|
31
|
+
expect(connectError.findDetails(BadRequestSchema)[0]?.fieldViolations[0]?.field).toBe(
|
|
32
|
+
"page_size",
|
|
33
|
+
)
|
|
34
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("sanitizes and logs unexpected errors once", async () => {
|
|
39
|
+
const logger = { error: vi.fn() }
|
|
40
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
41
|
+
const handler = interceptor(async () => {
|
|
42
|
+
throw new Error("sensitive internal path")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
handler({ method: { name: "GetProject" }, header: new Headers() } as never),
|
|
47
|
+
).rejects.toMatchObject({ code: Code.Internal, rawMessage: "An unexpected error occurred" })
|
|
48
|
+
expect(logger.error).toHaveBeenCalledOnce()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("preserves cancellation without domain details", async () => {
|
|
52
|
+
const logger = { error: vi.fn() }
|
|
53
|
+
const cancellation = new ConnectError("Canceled", Code.Canceled)
|
|
54
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
55
|
+
const handler = interceptor(async () => {
|
|
56
|
+
throw cancellation
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await expect(
|
|
60
|
+
handler({ method: { name: "Connect" }, header: new Headers() } as never),
|
|
61
|
+
).rejects.toBe(cancellation)
|
|
62
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("preserves unauthenticated and permission-denied classifications without secret metadata", async () => {
|
|
66
|
+
for (const error of [
|
|
67
|
+
new BackendErrorForTest("Authentication required", BackendErrorCategory.Unauthenticated),
|
|
68
|
+
new PermissionDeniedError("project.get"),
|
|
69
|
+
]) {
|
|
70
|
+
const interceptor = createErrorHandlingInterceptor({ logger: { error: vi.fn() } } as never)
|
|
71
|
+
const handler = interceptor(async () => {
|
|
72
|
+
throw error
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
handler({ method: { name: "GetProject" }, header: new Headers() } as never),
|
|
77
|
+
).rejects.toMatchObject({
|
|
78
|
+
code:
|
|
79
|
+
error.category === BackendErrorCategory.Unauthenticated
|
|
80
|
+
? Code.Unauthenticated
|
|
81
|
+
: Code.PermissionDenied,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const secret = new BackendErrorForTest(
|
|
86
|
+
"Authentication failed",
|
|
87
|
+
BackendErrorCategory.Unauthenticated,
|
|
88
|
+
{
|
|
89
|
+
token: "plaintext-token",
|
|
90
|
+
safe: "value",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
const interceptor = createErrorHandlingInterceptor({ logger: { error: vi.fn() } } as never)
|
|
94
|
+
const handler = interceptor(async () => {
|
|
95
|
+
throw secret
|
|
96
|
+
})
|
|
97
|
+
const result = (await handler({
|
|
98
|
+
method: { name: "GetProject" },
|
|
99
|
+
header: new Headers(),
|
|
100
|
+
} as never).catch(error => error as ConnectError)) as ConnectError
|
|
101
|
+
|
|
102
|
+
expect(result.findDetails(ErrorInfoSchema)[0]?.metadata).toEqual({ safe: "value" })
|
|
103
|
+
expect(result.rawMessage).not.toContain("plaintext-token")
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
class BackendErrorForTest extends BackendError {
|
|
108
|
+
constructor(
|
|
109
|
+
message: string,
|
|
110
|
+
category: BackendErrorCategory,
|
|
111
|
+
metadata: Record<string, string> = {},
|
|
112
|
+
) {
|
|
113
|
+
super(message, { category, reason: "AUTHENTICATION_FAILED", metadata })
|
|
114
|
+
}
|
|
115
|
+
}
|