@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.
@@ -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
+ }