@highstate/pulumi 0.9.15 → 0.9.18

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/src/unit.ts CHANGED
@@ -5,20 +5,24 @@
5
5
  /* eslint-disable @typescript-eslint/no-explicit-any */
6
6
 
7
7
  import type { DeepInput, InputArray, InputMap } from "./utils"
8
+ import type { ComponentSecret } from "../../contract/src/unit"
8
9
  import {
9
- type ArgumentValue,
10
10
  type ComponentInputSpec,
11
- type Entity,
11
+ type EntityModel,
12
12
  type Unit,
13
13
  type ComponentInput,
14
14
  type InstanceInput,
15
15
  parseInstanceId,
16
- type ArgumentValueSchema,
17
16
  getInstanceId,
18
17
  type ComponentArgumentSpec,
19
18
  type ComponentArgumentSpecToStatic,
19
+ HighstateSignature,
20
+ camelCaseToHumanReadable,
21
+ z,
22
+ type UnitSecretModel,
23
+ unitSecretSchema,
24
+ unitArtifactSchema,
20
25
  } from "@highstate/contract"
21
- import { Type, type Static } from "@sinclair/typebox"
22
26
  import { mapValues, pickBy, pipe } from "remeda"
23
27
  import {
24
28
  Config,
@@ -31,59 +35,71 @@ import {
31
35
  type Unwrap,
32
36
  } from "@pulumi/pulumi"
33
37
  import { Ajv } from "ajv"
34
- import { createdSecrets } from "./secret"
38
+ import { updatedSecretValues, type UnitSecret } from "./secret"
35
39
 
36
40
  const ajv = new Ajv({ strict: false })
37
41
 
38
- export type InstanceTerminalFile = {
39
- content: Input<string | undefined>
40
- mode?: Input<number | undefined>
41
- isBinary?: Input<boolean>
42
+ export type ObjectMeta = {
43
+ title?: Input<string | undefined>
44
+ description?: Input<string | undefined>
45
+ icon?: Input<string | undefined>
46
+ iconColor?: Input<string | undefined>
42
47
  }
43
48
 
44
- export type InstanceTerminal = {
49
+ export type InstanceFileMeta = {
45
50
  name: Input<string>
46
- title: Input<string>
47
- description?: Input<string>
48
- icon?: Input<string>
51
+ contentType?: Input<string>
52
+ size?: Input<number>
53
+ isBinary?: Input<boolean>
54
+ mode?: Input<number>
55
+ }
56
+
57
+ export type UnitArtifact = {
58
+ hash: Input<string>
59
+ meta?: Input<ObjectMeta>
60
+ }
61
+
62
+ export type InstanceFile = {
63
+ meta: Input<InstanceFileMeta>
64
+ content:
65
+ | { type: "embedded"; value: Input<string> }
66
+ | {
67
+ type: "artifact"
68
+ [HighstateSignature.Artifact]: UnitArtifact
69
+ }
70
+ }
71
+
72
+ export type InstanceTerminalSpec = {
49
73
  image: Input<string>
50
74
  command: InputArray<string>
51
75
  cwd?: Input<string | undefined>
52
76
  env?: InputMap<string | undefined>
53
- files?: InputMap<InstanceTerminalFile | string | undefined>
77
+ files?: InputMap<InstanceFile | string | undefined>
78
+ }
79
+
80
+ export type InstanceTerminal = {
81
+ name: Input<string>
82
+ meta: Input<ObjectMeta>
83
+ spec: Input<InstanceTerminalSpec>
54
84
  }
55
85
 
56
86
  export type StatusFieldValue = string | number | boolean | string[]
57
87
 
58
88
  export type StatusField<TArgName extends string = string> = {
59
89
  name: Input<string>
60
- value?: Input<StatusFieldValue | undefined>
61
- displayName?: Input<string | undefined>
62
- sensitive?: Input<boolean | undefined>
63
- url?: Input<string | undefined>
90
+ meta?: Input<ObjectMeta>
64
91
  complementaryTo?: Input<TArgName | undefined>
65
- }
66
-
67
- export type InstanceFileMeta = {
68
- name: Input<string>
69
- contentType: Input<string>
70
- isBinary?: Input<boolean>
71
- size: Input<number>
72
- }
73
-
74
- export type InstanceFile = {
75
- meta: Input<InstanceFileMeta>
76
- content: Input<string>
92
+ value?: Input<StatusFieldValue | undefined>
77
93
  }
78
94
 
79
95
  export type InstancePageBlock =
80
96
  | { type: "markdown"; content: Input<string> }
81
97
  | { type: "qr"; content: Input<string>; showContent?: boolean; language?: string }
82
- | { type: "file"; fileMeta: Input<InstanceFileMeta> }
98
+ | ({ type: "file" } & InstanceFile)
83
99
 
84
100
  export type InstancePage = {
85
101
  name: Input<string>
86
- title: Input<string>
102
+ meta: Input<ObjectMeta>
87
103
  content: InputArray<InstancePageBlock>
88
104
  }
89
105
 
@@ -103,8 +119,14 @@ export type InstanceTrigger = {
103
119
  spec: Input<InstanceTriggerSpec>
104
120
  }
105
121
 
122
+ export type InstanceWorker = {
123
+ name: Input<string>
124
+ image: Input<string>
125
+ params?: InputMap<unknown>
126
+ }
127
+
106
128
  export type ExtraOutputs<TArgName extends string = string> = {
107
- $status?:
129
+ $statusFields?:
108
130
  | InputMap<Omit<StatusField<TArgName>, "name"> | StatusFieldValue | undefined>
109
131
  | InputArray<StatusField<TArgName> | undefined>
110
132
 
@@ -113,11 +135,14 @@ export type ExtraOutputs<TArgName extends string = string> = {
113
135
  | InputArray<InstanceTerminal | undefined>
114
136
 
115
137
  $pages?: InputMap<Omit<InstancePage, "name"> | undefined> | InputArray<InstancePage | undefined>
116
- $files?: InputArray<InstanceFile | undefined>
117
138
 
118
139
  $triggers?:
119
140
  | InputMap<Omit<InstanceTrigger, "name"> | undefined>
120
141
  | InputArray<InstanceTrigger | undefined>
142
+
143
+ $workers?:
144
+ | InputMap<Omit<InstanceWorker, "name"> | undefined>
145
+ | InputArray<InstanceWorker | undefined>
121
146
  }
122
147
 
123
148
  export type InstanceTriggerInvocation = {
@@ -129,17 +154,21 @@ type OutputMapToDeepInputMap<T extends Record<string, unknown>, TArgName extends
129
154
  ? ExtraOutputs
130
155
  : { [K in keyof T]: DeepInput<T[K]> } & ExtraOutputs<TArgName>
131
156
 
157
+ export type SecretValueMapToSecretMap<T extends Record<string, unknown>> = {
158
+ [K in keyof T]-?: UnitSecret<T[K]>
159
+ }
160
+
132
161
  export interface UnitContext<
133
- TArgs extends Record<string, ArgumentValue>,
162
+ TArgs extends Record<string, unknown>,
134
163
  TInputs extends Record<string, unknown>,
135
164
  TOutputs extends Record<string, unknown>,
136
- TSecrets extends Record<string, ArgumentValue>,
165
+ TSecrets extends Record<string, unknown>,
137
166
  > {
138
167
  args: TArgs
139
168
  instanceId: string
140
169
  type: string
141
170
  name: string
142
- secrets: Output<TSecrets>
171
+ secrets: SecretValueMapToSecretMap<TSecrets>
143
172
 
144
173
  inputs: TInputs extends Record<string, never>
145
174
  ? never
@@ -158,10 +187,10 @@ export interface UnitContext<
158
187
  }
159
188
 
160
189
  type InputSpecToValue<T extends ComponentInputSpec> = T[2] extends true
161
- ? Static<T[0]["schema"]>[]
190
+ ? z.infer<T[0]["schema"]>[]
162
191
  : T[1] extends true
163
- ? Static<T[0]["schema"]>
164
- : Static<T[0]["schema"]> | undefined
192
+ ? z.infer<T[0]["schema"]>
193
+ : z.infer<T[0]["schema"]> | undefined
165
194
 
166
195
  type InputSpecMapToValueMap<T extends Record<string, ComponentInputSpec>> =
167
196
  T extends Record<string, never>
@@ -210,18 +239,20 @@ function getOutput(unit: Unit, input: ComponentInput, refs: InstanceInput[]) {
210
239
  const value = getStackRef(ref).requireOutput(ref.output)
211
240
 
212
241
  return value.apply(value => {
213
- let schema = entity.schema
214
-
215
- if (input.multiple) {
216
- schema = Type.Union([schema, Type.Array(schema)])
217
- }
218
-
219
- if (!ajv.validate(schema, value)) {
220
- throw new Error(`Invalid output for '${input.type}': ${ajv.errorsText()}`)
242
+ if (Array.isArray(value)) {
243
+ for (const [index, item] of value.entries()) {
244
+ if (!ajv.validate(entity.schema, item)) {
245
+ throw new Error(`Invalid output for '${input.type}[${index}]': ${ajv.errorsText()}`)
246
+ }
247
+ }
248
+ } else {
249
+ if (!ajv.validate(entity.schema, value)) {
250
+ throw new Error(`Invalid output for '${input.type}': ${ajv.errorsText()}`)
251
+ }
221
252
  }
222
253
 
223
254
  if (Array.isArray(value)) {
224
- return value as unknown
255
+ return value
225
256
  }
226
257
 
227
258
  return input.multiple ? [value] : value
@@ -237,17 +268,15 @@ function getOutput(unit: Unit, input: ComponentInput, refs: InstanceInput[]) {
237
268
  return values
238
269
  }
239
270
 
240
- function isAnyOfSchema(schema: ArgumentValueSchema, itemType: string): boolean {
271
+ function isAnyOfSchema(schema: z.core.JSONSchema.BaseSchema, itemType: string): boolean {
241
272
  if (schema.anyOf) {
242
- return Object.values(schema.anyOf).every(schema =>
243
- isAnyOfSchema(schema as ArgumentValueSchema, itemType),
244
- )
273
+ return Object.values(schema.anyOf).every(schema => isAnyOfSchema(schema, itemType))
245
274
  }
246
275
 
247
276
  return schema.type === itemType
248
277
  }
249
278
 
250
- function isStringSchema(schema: ArgumentValueSchema): boolean {
279
+ function isStringSchema(schema: z.core.JSONSchema.BaseSchema): boolean {
251
280
  if (schema.type === "string") {
252
281
  return true
253
282
  }
@@ -259,7 +288,7 @@ function isStringSchema(schema: ArgumentValueSchema): boolean {
259
288
  return false
260
289
  }
261
290
 
262
- function isNumberSchema(schema: ArgumentValueSchema): boolean {
291
+ function isNumberSchema(schema: z.core.JSONSchema.BaseSchema): boolean {
263
292
  if (schema.type === "number") {
264
293
  return true
265
294
  }
@@ -271,7 +300,7 @@ function isNumberSchema(schema: ArgumentValueSchema): boolean {
271
300
  return false
272
301
  }
273
302
 
274
- function isBooleanSchema(schema: ArgumentValueSchema): boolean {
303
+ function isBooleanSchema(schema: z.core.JSONSchema.BaseSchema): boolean {
275
304
  if (schema.type === "boolean") {
276
305
  return true
277
306
  }
@@ -302,19 +331,48 @@ export function forUnit<
302
331
  const args = mapValues(unit.model.args, (arg, argName) => {
303
332
  switch (true) {
304
333
  case isStringSchema(arg.schema): {
305
- return arg.required ? config.require(argName) : (config.get(argName) ?? arg.schema.default)
334
+ if (arg.required) {
335
+ return config.require(argName)
336
+ }
337
+
338
+ // handle empty strings as undefined
339
+ return config.get(argName) || arg.schema.default
306
340
  }
307
341
  case isNumberSchema(arg.schema): {
308
- return arg.required
309
- ? config.requireNumber(argName)
310
- : (config.getNumber(argName) ?? arg.schema.default)
342
+ if (arg.required) {
343
+ return config.requireNumber(argName)
344
+ }
345
+
346
+ // handle empty strings as undefined
347
+ const value = config.get(argName)
348
+ if (!value) {
349
+ return arg.schema.default
350
+ }
351
+
352
+ return config.getNumber(argName) ?? arg.schema.default
311
353
  }
312
354
  case isBooleanSchema(arg.schema): {
313
- return arg.required
314
- ? config.requireBoolean(argName)
315
- : (config.getBoolean(argName) ?? arg.schema.default)
355
+ if (arg.required) {
356
+ return config.requireBoolean(argName)
357
+ }
358
+
359
+ // handle empty strings as undefined
360
+ const value = config.get(argName)
361
+ if (!value) {
362
+ return arg.schema.default
363
+ }
364
+
365
+ return config.getBoolean(argName) ?? arg.schema.default
316
366
  }
317
367
  default: {
368
+ if (!arg.required) {
369
+ const value = config.get(argName)
370
+ // handle empty strings as undefined
371
+ if (!value) {
372
+ return arg.schema.default
373
+ }
374
+ }
375
+
318
376
  const value = arg.required ? config.requireObject(argName) : config.getObject(argName)
319
377
  if (value === undefined) return arg.schema.default
320
378
 
@@ -327,36 +385,54 @@ export function forUnit<
327
385
  }
328
386
  }) as ComponentArgumentSpecToStatic<TArgs>
329
387
 
330
- const secrets = output(
331
- mapValues(unit.model.secrets, (secret, secretName) => {
332
- switch (true) {
333
- case isStringSchema(secret.schema): {
334
- return secret.required ? config.requireSecret(secretName) : config.getSecret(secretName)
335
- }
336
- case isNumberSchema(secret.schema): {
337
- return secret.required
338
- ? config.requireSecretNumber(secretName)
339
- : config.getSecretNumber(secretName)
340
- }
341
- case isBooleanSchema(secret.schema): {
342
- return secret.required
343
- ? config.requireSecretBoolean(secretName)
344
- : config.getSecretBoolean(secretName)
345
- }
346
- default: {
347
- const value = secret.required
348
- ? config.requireSecretObject(secretName)
349
- : config.getSecretObject(secretName)
388
+ const secretIds = config.requireObject<Record<string, string>>("$secretIds")
350
389
 
351
- if (!ajv.validate(secret.schema, value)) {
352
- throw new Error(`Invalid secret for '${secretName}': ${ajv.errorsText()}`)
353
- }
390
+ const getSecretValue = (
391
+ secretName: string,
392
+ secret: ComponentSecret,
393
+ ): Output<unknown> | undefined => {
394
+ switch (true) {
395
+ case isStringSchema(secret.schema): {
396
+ return secret.required ? config.requireSecret(secretName) : config.getSecret(secretName)
397
+ }
398
+ case isNumberSchema(secret.schema): {
399
+ return secret.required
400
+ ? config.requireSecretNumber(secretName)
401
+ : config.getSecretNumber(secretName)
402
+ }
403
+ case isBooleanSchema(secret.schema): {
404
+ return secret.required
405
+ ? config.requireSecretBoolean(secretName)
406
+ : config.getSecretBoolean(secretName)
407
+ }
408
+ default: {
409
+ const value = secret.required
410
+ ? config.requireSecretObject(secretName)
411
+ : config.getSecretObject(secretName)
354
412
 
355
- return value
413
+ if (!ajv.validate(secret.schema, value)) {
414
+ throw new Error(`Invalid secret for '${secretName}': ${ajv.errorsText()}`)
356
415
  }
416
+
417
+ return value
357
418
  }
358
- }),
359
- ) as unknown as Output<ComponentArgumentSpecToStatic<TSecrets>>
419
+ }
420
+ }
421
+
422
+ const secrets = mapValues(unit.model.secrets, (secret, secretName): UnitSecret<unknown> => {
423
+ const secretId = secretIds[secretName]
424
+ if (!secretId) {
425
+ throw new Error(`Secret '${secretName}' not found in the config.`)
426
+ }
427
+
428
+ return {
429
+ [HighstateSignature.Secret]: true,
430
+ id: secretId,
431
+ value: secret.required
432
+ ? config.requireSecret(secretName)
433
+ : output(getSecretValue(secretName, secret)),
434
+ }
435
+ })
360
436
 
361
437
  const inputs = mapValues(unit.model.inputs, (input, inputName) => {
362
438
  const value = input.required
@@ -382,24 +458,20 @@ export function forUnit<
382
458
  instanceId,
383
459
  type,
384
460
  name: instanceName,
385
- secrets,
461
+ secrets: secrets as SecretValueMapToSecretMap<ComponentArgumentSpecToStatic<TSecrets>>,
386
462
  inputs: inputs as any,
387
463
  invokedTriggers: config.getObject<InstanceTriggerInvocation[]>("$invokedTriggers") ?? [],
388
464
 
389
465
  outputs: async (outputs: any = {}) => {
390
466
  const result: any = mapValues(outputs, (outputValue, outputName) => {
391
- if (outputName === "$status") {
392
- return output(outputValue).apply(mapStatus)
467
+ if (outputName === "$statusFields") {
468
+ return output(outputValue).apply(mapStatusFields)
393
469
  }
394
470
 
395
471
  if (outputName === "$pages") {
396
472
  return output(outputValue).apply(mapPages)
397
473
  }
398
474
 
399
- if (outputName === "$files") {
400
- return output(outputValue).apply(mapFiles)
401
- }
402
-
403
475
  if (outputName === "$terminals") {
404
476
  return output(outputValue).apply(mapTerminals)
405
477
  }
@@ -433,7 +505,7 @@ export function forUnit<
433
505
  return undefined
434
506
  }
435
507
 
436
- const schema = outputModel.multiple ? Type.Array(entity.schema) : entity.schema
508
+ const schema = outputModel.multiple ? entity.schema.array() : entity.schema
437
509
 
438
510
  if (!ajv.validate(schema, value)) {
439
511
  throw new Error(`Invalid output for '${outputName}': ${ajv.errorsText()}`)
@@ -445,8 +517,38 @@ export function forUnit<
445
517
 
446
518
  await Promise.all(Object.values(result).map(o => outputToPromise(o)))
447
519
 
448
- if (Object.keys(createdSecrets).length > 0) {
449
- result.$secrets = createdSecrets
520
+ result.$secrets = updatedSecretValues
521
+
522
+ // collect secrets from all outputs
523
+ const secretsMap: Record<string, UnitSecretModel[]> = {}
524
+ for (const [outputName, outputValue] of Object.entries(outputs)) {
525
+ if (!outputName.startsWith("$")) {
526
+ const resolvedValue = await outputToPromise(outputValue)
527
+ const secrets = extractObjectsFromValue(unitSecretSchema, resolvedValue)
528
+ if (secrets.length > 0) {
529
+ secretsMap[outputName] = secrets
530
+ }
531
+ }
532
+ }
533
+
534
+ // collect artifacts from all outputs
535
+ const artifactsMap: Record<string, UnitArtifact[]> = {}
536
+ for (const [outputName, outputValue] of Object.entries(outputs)) {
537
+ if (!outputName.startsWith("$")) {
538
+ const resolvedValue = await outputToPromise(outputValue)
539
+ const artifacts = extractObjectsFromValue(unitArtifactSchema, resolvedValue)
540
+ if (artifacts.length > 0) {
541
+ artifactsMap[outputName] = artifacts
542
+ }
543
+ }
544
+ }
545
+
546
+ if (Object.keys(artifactsMap).length > 0) {
547
+ result.$exportedArtifacts = artifactsMap
548
+ }
549
+
550
+ if (Object.keys(secretsMap).length > 0) {
551
+ result.$exportedSecretIds = mapValues(secretsMap, v => v.map(secret => secret.id))
450
552
  }
451
553
 
452
554
  return result
@@ -454,20 +556,30 @@ export function forUnit<
454
556
  }
455
557
  }
456
558
 
457
- export type EntityValue<T extends Entity> = Static<T["schema"]>
458
- export type EntityInput<T extends Entity> = Output<EntityValue<T>>
559
+ export type EntityValue<T extends EntityModel> = z.infer<T["schema"]>
560
+ export type EntityInput<T extends EntityModel> = Output<EntityValue<T>>
459
561
 
460
562
  function outputToPromise(o: unknown): Promise<unknown> {
461
563
  return new Promise(resolve => (output(o) as Output<unknown>).apply(resolve))
462
564
  }
463
565
 
464
- function mapStatus(status: Unwrap<ExtraOutputs["$status"]>): StatusField[] {
566
+ function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): StatusField[] {
465
567
  if (!status) {
466
568
  return []
467
569
  }
468
570
 
469
571
  if (Array.isArray(status)) {
470
- return status.filter(field => !!field?.value) as StatusField[]
572
+ return status
573
+ .filter(field => !!field?.value)
574
+ .map(field => {
575
+ return {
576
+ name: field!.name,
577
+ meta: {
578
+ title: field!.meta?.title ?? camelCaseToHumanReadable(field!.name),
579
+ },
580
+ value: field!.value,
581
+ } as StatusField
582
+ })
471
583
  }
472
584
 
473
585
  return Object.entries(status)
@@ -482,10 +594,23 @@ function mapStatus(status: Unwrap<ExtraOutputs["$status"]>): StatusField[] {
482
594
  typeof field === "boolean" ||
483
595
  Array.isArray(field)
484
596
  ) {
485
- return { name, value: field }
597
+ return {
598
+ name,
599
+ meta: {
600
+ title: camelCaseToHumanReadable(name),
601
+ },
602
+ value: field,
603
+ }
486
604
  }
487
605
 
488
- return { ...(field as StatusField), name }
606
+ return {
607
+ ...(field as StatusField),
608
+ meta: {
609
+ ...field.meta,
610
+ title: field.meta?.title ?? camelCaseToHumanReadable(name),
611
+ },
612
+ name,
613
+ }
489
614
  })
490
615
  .filter(field => !!field?.value) as StatusField[]
491
616
  }
@@ -516,7 +641,10 @@ export function fileFromString(
516
641
  contentType,
517
642
  size: Buffer.byteLength(content, "utf8"),
518
643
  },
519
- content: isSecret ? secret(content) : content,
644
+ content: {
645
+ type: "embedded",
646
+ value: isSecret ? secret(content) : content,
647
+ },
520
648
  }
521
649
  }
522
650
 
@@ -533,14 +661,13 @@ export function fileFromBuffer(
533
661
  size: content.byteLength,
534
662
  isBinary: true,
535
663
  },
536
- content: isSecret ? secret(content.toString("base64")) : content.toString("base64"),
664
+ content: {
665
+ type: "embedded",
666
+ value: isSecret ? secret(content.toString("base64")) : content.toString("base64"),
667
+ },
537
668
  }
538
669
  }
539
670
 
540
- function mapFiles(files: Unwrap<ExtraOutputs["$files"]>): InstanceFile[] {
541
- return files?.filter(file => !!file) ?? []
542
- }
543
-
544
671
  function mapTerminals(terminals: Unwrap<ExtraOutputs["$terminals"]>): InstanceTerminal[] {
545
672
  if (!terminals) {
546
673
  return []
@@ -559,24 +686,38 @@ function mapTerminals(terminals: Unwrap<ExtraOutputs["$terminals"]>): InstanceTe
559
686
  return terminals
560
687
  .filter(terminal => !!terminal)
561
688
  .map(terminal => {
562
- if (!terminal.files) {
689
+ if (!terminal.spec.files) {
563
690
  return terminal
564
691
  }
565
692
 
566
693
  return {
567
694
  ...terminal,
568
695
 
569
- files: pipe(
570
- terminal.files,
571
- mapValues(file => {
572
- if (typeof file === "string") {
573
- return { content: file }
574
- }
575
-
576
- return file
577
- }),
578
- pickBy(value => !!value?.content),
579
- ),
696
+ spec: {
697
+ ...terminal.spec,
698
+
699
+ files: pipe(
700
+ terminal.spec.files,
701
+ mapValues(file => {
702
+ if (typeof file === "string") {
703
+ return {
704
+ meta: {
705
+ name: "content",
706
+ contentType: "text/plain",
707
+ size: Buffer.byteLength(file, "utf8"),
708
+ },
709
+ content: {
710
+ type: "embedded" as const,
711
+ value: file,
712
+ },
713
+ }
714
+ }
715
+
716
+ return file
717
+ }),
718
+ pickBy(value => !!value),
719
+ ),
720
+ },
580
721
  }
581
722
  })
582
723
  }
@@ -594,3 +735,40 @@ function mapTriggers(triggers: Unwrap<ExtraOutputs["$triggers"]>): InstanceTrigg
594
735
  .filter(([, trigger]) => !!trigger)
595
736
  .map(([name, trigger]) => ({ ...(trigger as InstanceTrigger), name }))
596
737
  }
738
+
739
+ /**
740
+ * Extracts all objects with the specified schema from a value.
741
+ */
742
+ function extractObjectsFromValue<TSchema extends z.ZodType>(
743
+ schema: TSchema,
744
+ data: unknown,
745
+ ): z.infer<TSchema>[] {
746
+ const result: z.infer<TSchema>[] = []
747
+
748
+ function traverse(obj: unknown): void {
749
+ if (obj === null || obj === undefined || typeof obj !== "object") {
750
+ return
751
+ }
752
+
753
+ if (Array.isArray(obj)) {
754
+ for (const item of obj) {
755
+ traverse(item)
756
+ }
757
+ return
758
+ }
759
+
760
+ const parseResult = schema.safeParse(obj)
761
+ if (parseResult.success) {
762
+ result.push(parseResult.data)
763
+ return
764
+ }
765
+
766
+ // recursively traverse all properties
767
+ for (const value of Object.values(obj)) {
768
+ traverse(value)
769
+ }
770
+ }
771
+
772
+ traverse(data)
773
+ return result
774
+ }