@beignet/core 0.0.47 → 0.0.49

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 (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +81 -15
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/openapi/index.d.ts +8 -0
  34. package/dist/openapi/index.d.ts.map +1 -1
  35. package/dist/openapi/index.js +79 -5
  36. package/dist/openapi/index.js.map +1 -1
  37. package/dist/outbox/index.d.ts +8 -5
  38. package/dist/outbox/index.d.ts.map +1 -1
  39. package/dist/outbox/index.js +17 -3
  40. package/dist/outbox/index.js.map +1 -1
  41. package/dist/ports/cache.d.ts +9 -1
  42. package/dist/ports/cache.d.ts.map +1 -1
  43. package/dist/ports/cache.js +20 -5
  44. package/dist/ports/cache.js.map +1 -1
  45. package/dist/ports/index.d.ts +1 -1
  46. package/dist/ports/index.d.ts.map +1 -1
  47. package/dist/ports/index.js +1 -1
  48. package/dist/ports/index.js.map +1 -1
  49. package/dist/providers/instrumentation.d.ts.map +1 -1
  50. package/dist/providers/instrumentation.js +14 -2
  51. package/dist/providers/instrumentation.js.map +1 -1
  52. package/dist/providers/metadata.d.ts +2 -0
  53. package/dist/providers/metadata.d.ts.map +1 -1
  54. package/dist/providers/metadata.js +78 -16
  55. package/dist/providers/metadata.js.map +1 -1
  56. package/dist/query-codec.d.ts +24 -0
  57. package/dist/query-codec.d.ts.map +1 -1
  58. package/dist/query-codec.js +207 -72
  59. package/dist/query-codec.js.map +1 -1
  60. package/dist/server/hooks/rate-limit.d.ts +16 -8
  61. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  62. package/dist/server/hooks/rate-limit.js +31 -17
  63. package/dist/server/hooks/rate-limit.js.map +1 -1
  64. package/dist/server/request-executor.d.ts.map +1 -1
  65. package/dist/server/request-executor.js +9 -9
  66. package/dist/server/request-executor.js.map +1 -1
  67. package/dist/server/request-preparation.d.ts.map +1 -1
  68. package/dist/server/request-preparation.js +11 -9
  69. package/dist/server/request-preparation.js.map +1 -1
  70. package/dist/server/server.d.ts.map +1 -1
  71. package/dist/server/server.js +2 -0
  72. package/dist/server/server.js.map +1 -1
  73. package/dist/server/use-case-route.d.ts +8 -5
  74. package/dist/server/use-case-route.d.ts.map +1 -1
  75. package/dist/server/use-case-route.js +44 -17
  76. package/dist/server/use-case-route.js.map +1 -1
  77. package/package.json +3 -3
  78. package/skills/app-architecture/SKILL.md +27 -1
  79. package/src/client/client.ts +29 -28
  80. package/src/contracts/contract-builder.ts +32 -2
  81. package/src/contracts/contract-group.ts +1 -0
  82. package/src/contracts/contract-like.ts +40 -1
  83. package/src/contracts/index.ts +23 -0
  84. package/src/contracts/query-transport.ts +697 -0
  85. package/src/contracts/schema-shape.ts +24 -0
  86. package/src/contracts/types.ts +5 -0
  87. package/src/openapi/index.ts +126 -2
  88. package/src/outbox/index.ts +26 -5
  89. package/src/ports/cache.ts +29 -7
  90. package/src/ports/index.ts +1 -1
  91. package/src/providers/instrumentation.ts +15 -2
  92. package/src/providers/metadata.ts +132 -16
  93. package/src/server/hooks/rate-limit.ts +50 -24
  94. package/src/server/request-executor.ts +17 -9
  95. package/src/server/request-preparation.ts +22 -9
  96. package/src/server/server.ts +2 -0
  97. package/src/server/use-case-route.ts +62 -23
  98. package/src/query-codec.ts +0 -130
@@ -73,3 +73,27 @@ export function formatPathParamsMismatch(args: {
73
73
  .filter(Boolean)
74
74
  .join("; ");
75
75
  }
76
+
77
+ /** Compare an introspected query schema's fields with its transport fields. */
78
+ export function compareQueryTransportFields(args: {
79
+ schemaKeys: readonly string[];
80
+ transportKeys: readonly string[];
81
+ }): boolean {
82
+ const schemaKeys = [...args.schemaKeys].sort();
83
+ const transportKeys = [...args.transportKeys].sort();
84
+ return (
85
+ schemaKeys.length === transportKeys.length &&
86
+ schemaKeys.every((key, index) => key === transportKeys[index])
87
+ );
88
+ }
89
+
90
+ /** Format a query schema/transport mismatch consistently across consumers. */
91
+ export function formatQueryTransportMismatch(args: {
92
+ contractName: string;
93
+ schemaKeys: readonly string[];
94
+ transportKeys: readonly string[];
95
+ }): string {
96
+ const schemaKeys = [...args.schemaKeys].sort();
97
+ const transportKeys = [...args.transportKeys].sort();
98
+ return `Query transport fields for contract "${args.contractName}" must match its query schema fields (schema: ${schemaKeys.join(", ") || "none"}; transport: ${transportKeys.join(", ") || "none"}).`;
99
+ }
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { IdempotencyMeta } from "../idempotency/index.js";
3
3
  import type { ContractDeprecationMeta } from "./lifecycle.js";
4
4
  import type { OpenAPIOperationMeta } from "./openapi-meta.js";
5
+ import type { QueryTransport } from "./query-transport.js";
5
6
  import type { RateLimitMeta } from "./rate-limit.js";
6
7
 
7
8
  /**
@@ -343,6 +344,10 @@ export type HttpContractConfig<
343
344
  * Query parameter schema.
344
345
  */
345
346
  query: TQuery;
347
+ /**
348
+ * Deterministic HTTP encoding for the query schema.
349
+ */
350
+ queryTransport: QueryTransport | null;
346
351
  /**
347
352
  * Request header schema or schemas.
348
353
  */
@@ -11,6 +11,8 @@ import {
11
11
  getContractHeaderSchemas,
12
12
  methodSupportsRequestBody,
13
13
  parsePathTemplate,
14
+ type QueryFieldTransport,
15
+ queryTransportSchema,
14
16
  resolveContract,
15
17
  STANDARD_ERROR_RESPONSE_SCHEMA,
16
18
  } from "../contracts/index.js";
@@ -20,7 +22,9 @@ import {
20
22
  } from "../contracts/lifecycle.js";
21
23
  import {
22
24
  comparePathParamsToTemplate,
25
+ compareQueryTransportFields,
23
26
  formatPathParamsMismatch,
27
+ formatQueryTransportMismatch,
24
28
  } from "../contracts/schema-shape.js";
25
29
  import {
26
30
  createZodIntrospector,
@@ -157,6 +161,21 @@ export interface ParameterObject {
157
161
  * Parameter schema.
158
162
  */
159
163
  schema?: SchemaObject | ReferenceObject;
164
+ /**
165
+ * OpenAPI parameter serialization style.
166
+ */
167
+ style?:
168
+ | "form"
169
+ | "simple"
170
+ | "matrix"
171
+ | "label"
172
+ | "spaceDelimited"
173
+ | "pipeDelimited"
174
+ | "deepObject";
175
+ /**
176
+ * Whether arrays and objects are expanded into separate parameter values.
177
+ */
178
+ explode?: boolean;
160
179
  /**
161
180
  * Parameter description.
162
181
  */
@@ -627,12 +646,36 @@ function addQueryParams(
627
646
  state: GeneratorState,
628
647
  ): void {
629
648
  if (!contract.query) return;
649
+ const transport = contract.queryTransport;
650
+ if (!transport) {
651
+ throw new Error(
652
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
653
+ );
654
+ }
630
655
 
631
656
  const shape = state.introspector.getShape(contract.query);
632
- if (!shape) return;
657
+ if (!shape) {
658
+ throw new Error(
659
+ `Unable to inspect query schema for contract "${contract.name}". Pass a schemaIntrospector that can read its fields so OpenAPI requiredness and constraints remain aligned with the declared query transport.`,
660
+ );
661
+ }
633
662
 
634
- for (const key of Object.keys(shape)) {
663
+ const schemaKeys = Object.keys(shape);
664
+ const transportKeys = Object.keys(transport.fields);
665
+ if (!compareQueryTransportFields({ schemaKeys, transportKeys })) {
666
+ throw new Error(
667
+ formatQueryTransportMismatch({
668
+ contractName: contract.name,
669
+ schemaKeys,
670
+ transportKeys,
671
+ }),
672
+ );
673
+ }
674
+
675
+ for (const key of Object.keys(transport.fields)) {
635
676
  const originalField = shape[key];
677
+ const fieldTransport = transport.fields[key];
678
+ if (!originalField || !fieldTransport) continue;
636
679
  const optional = state.introspector.isOptional(originalField);
637
680
 
638
681
  const field = originalField;
@@ -644,6 +687,7 @@ function addQueryParams(
644
687
  state,
645
688
  "input",
646
689
  );
690
+ applyQueryTransportSchema(paramSchemaRef, fieldTransport, state);
647
691
 
648
692
  const param: ParameterObject = {
649
693
  name: key,
@@ -651,12 +695,92 @@ function addQueryParams(
651
695
  required: !optional,
652
696
  schema: paramSchemaRef,
653
697
  description,
698
+ style: fieldTransport.kind === "deep-object" ? "deepObject" : "form",
699
+ explode: true,
654
700
  };
655
701
 
656
702
  addParameter(operation, param);
657
703
  }
658
704
  }
659
705
 
706
+ function applyQueryTransportSchema(
707
+ schema: SchemaObject | ReferenceObject,
708
+ transport: QueryFieldTransport,
709
+ state: GeneratorState,
710
+ ): void {
711
+ if (!("$ref" in schema) || typeof schema.$ref !== "string") return;
712
+ const prefix = "#/components/schemas/";
713
+ if (!schema.$ref.startsWith(prefix)) return;
714
+ const name = schema.$ref.slice(prefix.length);
715
+ const converted = state.components.schemas?.[name];
716
+ if (!converted || !state.components.schemas) return;
717
+ state.components.schemas[name] = mergeQueryTransportSchema(
718
+ converted,
719
+ transport,
720
+ );
721
+ }
722
+
723
+ function mergeQueryTransportSchema(
724
+ converted: SchemaObject,
725
+ transport: QueryFieldTransport,
726
+ ): SchemaObject {
727
+ const declared = queryTransportSchema(transport);
728
+ const merged: SchemaObject = {
729
+ ...converted,
730
+ type: declared.type,
731
+ };
732
+ if (declared.format) merged.format = declared.format;
733
+ if (transport.kind === "integer") {
734
+ merged.minimum = Math.max(
735
+ typeof converted.minimum === "number"
736
+ ? converted.minimum
737
+ : Number.MIN_SAFE_INTEGER,
738
+ Number.MIN_SAFE_INTEGER,
739
+ );
740
+ merged.maximum = Math.min(
741
+ typeof converted.maximum === "number"
742
+ ? converted.maximum
743
+ : Number.MAX_SAFE_INTEGER,
744
+ Number.MAX_SAFE_INTEGER,
745
+ );
746
+ }
747
+ if (declared["x-beignet-empty-query"]) {
748
+ merged["x-beignet-empty-query"] = declared["x-beignet-empty-query"];
749
+ }
750
+
751
+ if (transport.kind === "array") {
752
+ const convertedItems =
753
+ typeof converted.items === "object" && converted.items !== null
754
+ ? (converted.items as SchemaObject)
755
+ : {};
756
+ merged.items = mergeQueryTransportSchema(convertedItems, transport.item);
757
+ }
758
+
759
+ if (transport.kind === "deep-object") {
760
+ const convertedProperties =
761
+ typeof converted.properties === "object" && converted.properties !== null
762
+ ? (converted.properties as Record<string, unknown>)
763
+ : {};
764
+ merged.properties = Object.fromEntries(
765
+ Object.entries(transport.fields).map(([key, field]) => {
766
+ const convertedProperty = convertedProperties[key];
767
+ return [
768
+ key,
769
+ mergeQueryTransportSchema(
770
+ typeof convertedProperty === "object" && convertedProperty !== null
771
+ ? (convertedProperty as SchemaObject)
772
+ : {},
773
+ field,
774
+ ),
775
+ ];
776
+ }),
777
+ );
778
+ merged.additionalProperties = false;
779
+ }
780
+
781
+ return merged;
782
+ }
783
+
660
784
  /**
661
785
  * Add header parameters from contract to operation.
662
786
  */
@@ -555,7 +555,8 @@ export interface DrainOutboxOptions {
555
555
  */
556
556
  registry: OutboxRegistry;
557
557
  /**
558
- * Event bus used for event messages.
558
+ * Event bus used for event messages. Required when the registry contains
559
+ * events.
559
560
  */
560
561
  eventBus?: {
561
562
  publish<E extends EventPayloadDef>(
@@ -565,7 +566,8 @@ export interface DrainOutboxOptions {
565
566
  ): MaybePromise<void>;
566
567
  };
567
568
  /**
568
- * Job dispatcher used for job messages.
569
+ * Job dispatcher used for job messages. Required when the registry contains
570
+ * jobs.
569
571
  */
570
572
  jobs?: JobDispatcherPort;
571
573
  /**
@@ -1476,15 +1478,17 @@ async function deliverOutboxMessage(
1476
1478
  * Claim and deliver one batch of outbox messages.
1477
1479
  *
1478
1480
  * This does not loop forever; production workers should call it on their own
1479
- * polling cadence. Event and job messages require matching registry entries.
1480
- * Failed messages are retried with backoff until `maxAttempts`, then
1481
- * dead-lettered.
1481
+ * polling cadence. Event and job messages require matching registry entries
1482
+ * and delivery transports. Required transports are validated before a batch
1483
+ * is claimed. Failed messages are retried with backoff until `maxAttempts`,
1484
+ * then dead-lettered.
1482
1485
  */
1483
1486
  export async function drainOutbox(
1484
1487
  options: DrainOutboxOptions,
1485
1488
  ): Promise<DrainOutboxResult> {
1486
1489
  const batchSize = options.batchSize ?? 100;
1487
1490
  assertPositiveInteger("batchSize", batchSize);
1491
+ assertOutboxDeliveryCapabilities(options);
1488
1492
  const instrumentation = createProviderInstrumentation(
1489
1493
  options.instrumentation,
1490
1494
  {
@@ -1659,6 +1663,23 @@ export async function drainOutbox(
1659
1663
  return result;
1660
1664
  }
1661
1665
 
1666
+ function assertOutboxDeliveryCapabilities(options: DrainOutboxOptions): void {
1667
+ const missing: string[] = [];
1668
+
1669
+ if (options.registry.events.size > 0 && !options.eventBus) {
1670
+ missing.push("events require an event bus");
1671
+ }
1672
+ if (options.registry.jobs.size > 0 && !options.jobs) {
1673
+ missing.push("jobs require a job dispatcher");
1674
+ }
1675
+
1676
+ if (missing.length > 0) {
1677
+ throw new OutboxRegistryError(
1678
+ `Cannot drain this outbox registry: ${missing.join("; ")}.`,
1679
+ );
1680
+ }
1681
+ }
1682
+
1662
1683
  /**
1663
1684
  * Domain event recorder port re-exported for outbox integrations.
1664
1685
  */
@@ -3,11 +3,35 @@
3
3
  */
4
4
  export interface CacheSetOptions {
5
5
  /**
6
- * Time-to-live in seconds. Omit this for a value that does not expire.
6
+ * Time-to-live in seconds. Values must be positive safe integers. Omit this
7
+ * for a value that does not expire.
7
8
  */
8
9
  ttlSeconds?: number;
9
10
  }
10
11
 
12
+ /**
13
+ * Resolve and validate the optional TTL shared by cache adapters.
14
+ *
15
+ * @param options - Cache write options.
16
+ * @returns The positive TTL, or `undefined` for a persistent value.
17
+ */
18
+ export function resolveCacheTtlSeconds(
19
+ options?: CacheSetOptions,
20
+ ): number | undefined {
21
+ const ttlSeconds = options?.ttlSeconds;
22
+ if (ttlSeconds === undefined) {
23
+ return undefined;
24
+ }
25
+
26
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) {
27
+ throw new RangeError(
28
+ "Cache ttlSeconds must be a positive safe integer when provided.",
29
+ );
30
+ }
31
+
32
+ return ttlSeconds;
33
+ }
34
+
11
35
  /**
12
36
  * App-facing string cache port.
13
37
  *
@@ -54,15 +78,12 @@ type MemoryCacheEntry = {
54
78
  };
55
79
 
56
80
  function resolveExpiresAt(options: CacheSetOptions | undefined): number | null {
57
- if (options?.ttlSeconds == null) {
81
+ const ttlSeconds = resolveCacheTtlSeconds(options);
82
+ if (ttlSeconds === undefined) {
58
83
  return null;
59
84
  }
60
85
 
61
- if (options.ttlSeconds <= 0) {
62
- return Date.now();
63
- }
64
-
65
- return Date.now() + options.ttlSeconds * 1000;
86
+ return Math.min(Number.MAX_SAFE_INTEGER, Date.now() + ttlSeconds * 1000);
66
87
  }
67
88
 
68
89
  function isExpired(entry: MemoryCacheEntry): boolean {
@@ -120,6 +141,7 @@ export function createMemoryCache(
120
141
  return (await getFreshEntry(key)) != null;
121
142
  },
122
143
  async remember(key, factory, options) {
144
+ resolveCacheTtlSeconds(options);
123
145
  const cached = await cache.get(key);
124
146
  if (cached != null) {
125
147
  return cached;
@@ -483,7 +483,7 @@ export type { CachePort, CacheSetOptions } from "./cache.js";
483
483
  /**
484
484
  * Cache helper exports.
485
485
  */
486
- export { createMemoryCache } from "./cache.js";
486
+ export { createMemoryCache, resolveCacheTtlSeconds } from "./cache.js";
487
487
  /**
488
488
  * Clock port exports.
489
489
  */
@@ -502,7 +502,11 @@ export function createProviderInstrumentation(
502
502
  function isEnabled(watcher = options.watcher): boolean {
503
503
  if (!port) return false;
504
504
  if (!watcher) return true;
505
- return port.isWatcherEnabled?.(watcher) ?? true;
505
+ try {
506
+ return port.isWatcherEnabled?.(watcher) ?? true;
507
+ } catch {
508
+ return false;
509
+ }
506
510
  }
507
511
 
508
512
  function record(event: ProviderInstrumentationEventInput): unknown {
@@ -530,7 +534,16 @@ export function createProviderInstrumentation(
530
534
  }
531
535
 
532
536
  try {
533
- return port.record(redacted);
537
+ const result = port.record(redacted);
538
+ if (
539
+ result !== null &&
540
+ (typeof result === "object" || typeof result === "function") &&
541
+ "then" in result &&
542
+ typeof result.then === "function"
543
+ ) {
544
+ return Promise.resolve(result).catch(() => undefined);
545
+ }
546
+ return result;
534
547
  } catch {
535
548
  return undefined;
536
549
  }
@@ -24,6 +24,7 @@ export type ProviderPackageVariantMetadata = {
24
24
  displayName?: string;
25
25
  env?: readonly string[];
26
26
  requiredEnv?: readonly string[];
27
+ requiredEnvAlternatives?: readonly (readonly string[])[];
27
28
  requiredTables?: readonly string[];
28
29
  registration?: ProviderPackageRegistrationMetadata;
29
30
  };
@@ -34,6 +35,7 @@ export type ProviderPackageMetadata = {
34
35
  appPorts?: readonly ProviderPackagePortMetadata[];
35
36
  env?: readonly string[];
36
37
  requiredEnv?: readonly string[];
38
+ requiredEnvAlternatives?: readonly (readonly string[])[];
37
39
  requiredTables?: readonly string[];
38
40
  registration?: ProviderPackageRegistrationMetadata;
39
41
  variants?: readonly ProviderPackageVariantMetadata[];
@@ -91,17 +93,43 @@ export function parseProviderPackageMetadata(
91
93
  );
92
94
  if (requiredEnv !== undefined) {
93
95
  metadata.requiredEnv = requiredEnv;
94
- const envSet = new Set(env ?? []);
95
- for (const envVar of requiredEnv) {
96
- if (!envSet.has(envVar)) {
97
- issues.push({
98
- path: "beignet.provider.requiredEnv",
99
- message: `${envVar} must also be listed in beignet.provider.env`,
100
- });
101
- }
96
+ validateRequiredEnvMembers(
97
+ requiredEnv,
98
+ env,
99
+ "beignet.provider.requiredEnv",
100
+ "beignet.provider.env",
101
+ issues,
102
+ );
103
+ }
104
+
105
+ const requiredEnvAlternatives = parseStringArrayArray(
106
+ input.requiredEnvAlternatives,
107
+ "beignet.provider.requiredEnvAlternatives",
108
+ issues,
109
+ );
110
+ if (requiredEnvAlternatives !== undefined) {
111
+ metadata.requiredEnvAlternatives = requiredEnvAlternatives;
112
+ for (const alternative of requiredEnvAlternatives) {
113
+ validateRequiredEnvMembers(
114
+ alternative,
115
+ env,
116
+ "beignet.provider.requiredEnvAlternatives",
117
+ "beignet.provider.env",
118
+ issues,
119
+ );
102
120
  }
103
121
  }
104
122
 
123
+ if (
124
+ input.requiredEnv !== undefined &&
125
+ input.requiredEnvAlternatives !== undefined
126
+ ) {
127
+ issues.push({
128
+ path: "beignet.provider.requiredEnvAlternatives",
129
+ message: "must not be set alongside beignet.provider.requiredEnv",
130
+ });
131
+ }
132
+
105
133
  const requiredTables = parseStringArray(
106
134
  input.requiredTables,
107
135
  "beignet.provider.requiredTables",
@@ -137,6 +165,13 @@ export function parseProviderPackageMetadata(
137
165
  "must not be set when beignet.provider.variants is present; declare requiredEnv on each variant instead",
138
166
  });
139
167
  }
168
+ if (input.requiredEnvAlternatives !== undefined) {
169
+ issues.push({
170
+ path: "beignet.provider.requiredEnvAlternatives",
171
+ message:
172
+ "must not be set when beignet.provider.variants is present; declare requiredEnvAlternatives on each variant instead",
173
+ });
174
+ }
140
175
  if (input.registration !== undefined) {
141
176
  issues.push({
142
177
  path: "beignet.provider.registration",
@@ -186,6 +221,60 @@ function parseStringArray(
186
221
  return parsed;
187
222
  }
188
223
 
224
+ function parseStringArrayArray(
225
+ value: unknown,
226
+ path: string,
227
+ issues: ProviderPackageMetadataIssue[],
228
+ ): string[][] | undefined {
229
+ if (value === undefined) return undefined;
230
+ if (!Array.isArray(value)) {
231
+ issues.push({ path, message: "must be an array of string arrays" });
232
+ return undefined;
233
+ }
234
+ if (value.length === 0) {
235
+ issues.push({
236
+ path,
237
+ message: "must include at least one alternative when present",
238
+ });
239
+ return undefined;
240
+ }
241
+
242
+ const parsed: string[][] = [];
243
+ for (const [index, entry] of value.entries()) {
244
+ const entryPath = `${path}[${index}]`;
245
+ const alternative = parseStringArray(entry, entryPath, issues);
246
+ if (alternative === undefined) continue;
247
+ if (alternative.length === 0) {
248
+ issues.push({
249
+ path: entryPath,
250
+ message: "must include at least one environment variable",
251
+ });
252
+ continue;
253
+ }
254
+ parsed.push(alternative);
255
+ }
256
+
257
+ return parsed;
258
+ }
259
+
260
+ function validateRequiredEnvMembers(
261
+ requiredEnv: readonly string[],
262
+ env: readonly string[] | undefined,
263
+ path: string,
264
+ envPath: string,
265
+ issues: ProviderPackageMetadataIssue[],
266
+ ): void {
267
+ const envSet = new Set(env ?? []);
268
+ for (const envVar of requiredEnv) {
269
+ if (!envSet.has(envVar)) {
270
+ issues.push({
271
+ path,
272
+ message: `${envVar} must also be listed in ${envPath}`,
273
+ });
274
+ }
275
+ }
276
+ }
277
+
189
278
  function parseAppPorts(
190
279
  value: unknown,
191
280
  issues: ProviderPackageMetadataIssue[],
@@ -280,16 +369,40 @@ function parseVariants(
280
369
  issues,
281
370
  );
282
371
  if (requiredEnv !== undefined) {
283
- const envSet = new Set(env ?? []);
284
- for (const envVar of requiredEnv) {
285
- if (!envSet.has(envVar)) {
286
- issues.push({
287
- path: `${entryPath}.requiredEnv`,
288
- message: `${envVar} must also be listed in ${entryPath}.env`,
289
- });
290
- }
372
+ validateRequiredEnvMembers(
373
+ requiredEnv,
374
+ env,
375
+ `${entryPath}.requiredEnv`,
376
+ `${entryPath}.env`,
377
+ issues,
378
+ );
379
+ }
380
+
381
+ const requiredEnvAlternatives = parseStringArrayArray(
382
+ entry.requiredEnvAlternatives,
383
+ `${entryPath}.requiredEnvAlternatives`,
384
+ issues,
385
+ );
386
+ if (requiredEnvAlternatives !== undefined) {
387
+ for (const alternative of requiredEnvAlternatives) {
388
+ validateRequiredEnvMembers(
389
+ alternative,
390
+ env,
391
+ `${entryPath}.requiredEnvAlternatives`,
392
+ `${entryPath}.env`,
393
+ issues,
394
+ );
291
395
  }
292
396
  }
397
+ if (
398
+ entry.requiredEnv !== undefined &&
399
+ entry.requiredEnvAlternatives !== undefined
400
+ ) {
401
+ issues.push({
402
+ path: `${entryPath}.requiredEnvAlternatives`,
403
+ message: `must not be set alongside ${entryPath}.requiredEnv`,
404
+ });
405
+ }
293
406
 
294
407
  const requiredTables = parseStringArray(
295
408
  entry.requiredTables,
@@ -308,6 +421,9 @@ function parseVariants(
308
421
  if (displayName !== undefined) variant.displayName = displayName;
309
422
  if (env !== undefined) variant.env = env;
310
423
  if (requiredEnv !== undefined) variant.requiredEnv = requiredEnv;
424
+ if (requiredEnvAlternatives !== undefined) {
425
+ variant.requiredEnvAlternatives = requiredEnvAlternatives;
426
+ }
311
427
  if (requiredTables !== undefined) variant.requiredTables = requiredTables;
312
428
  if (registration !== undefined) variant.registration = registration;
313
429
  parsed.push(variant);