typespec-http-zod 0.18.0 → 0.19.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/src/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
1
+ import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
@@ -1260,8 +1260,20 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1260
1260
  *
1261
1261
  * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1262
1262
  * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1263
- * same thing by leaving the property out of `required`, so the inferred type was wider than both
1264
- * the contract and the wire and a consumer had to strip it at the boundary.
1263
+ * same thing by leaving the property out of `required`, so the inferred type is wider than the
1264
+ * wire and a consumer reading a payload had to strip it at the boundary.
1265
+ *
1266
+ * **It does NOT match the contract type, and this docblock used to claim it did.** The
1267
+ * contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
1268
+ * PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
1269
+ * it would cost every producer a conditional spread and buy the wire nothing. Measured when this
1270
+ * was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
1271
+ * compiling.
1272
+ *
1273
+ * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1274
+ * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1275
+ * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
1276
+ * pairs the contract against raw `z.infer`, not against this.
1265
1277
  *
1266
1278
  * Derived from the same schema rather than hand-written beside it, so there is still one source of
1267
1279
  * truth: a mapped type cannot drift from the thing it maps.
@@ -1663,8 +1675,14 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1663
1675
  * No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
1664
1676
  * a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
1665
1677
  * carry a method - a narrowed multipart file part is the near miss - this needs one.
1678
+ *
1679
+ * **Exported, because a consumer needs it for a NAMED type and could not reach it.** It was applied
1680
+ * to \`WireOutputs\` and declared locally, so a codebase whose layers hand back \`readonly T[]\` had
1681
+ * the producer view sitting in the file it was already importing and no way to name it. Measured on
1682
+ * one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
1683
+ * of them expressible against the published shape.
1666
1684
  */
1667
- type Produced<T> = T extends readonly (infer Element)[]
1685
+ export type Produced<T> = T extends readonly (infer Element)[]
1668
1686
  ? readonly Produced<Element>[]
1669
1687
  : T extends object
1670
1688
  ? { readonly [K in keyof T]: Produced<T[K]> }
@@ -1872,6 +1890,36 @@ export async function emitHttpZod(context, wrapper = {}) {
1872
1890
  * there means a server emitter follows without knowing this rule exists.
1873
1891
  */
1874
1892
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
1893
+ /**
1894
+ * **Every service must seal the same way, because `@typespec/openapi3` has no per-service
1895
+ * options.**
1896
+ *
1897
+ * This option exists only so that two emitters answering the same question about the same models
1898
+ * give the same answer - it is stated twice on purpose, and its own docblock says to set it to
1899
+ * whatever openapi3 is set to. openapi3 applies ONE value to the whole program. So services that
1900
+ * seal differently cannot both be mirrored: whichever way openapi3 is configured, at least one
1901
+ * service publishes a document that disagrees with the validator generated beside it. Sealed here
1902
+ * and silent there refuses a payload the document permits; the reverse publishes a strictness the
1903
+ * runtime does not enforce.
1904
+ *
1905
+ * Reported once for the program rather than once per service, because the fault is the
1906
+ * DISAGREEMENT and no single service is the wrong one.
1907
+ */
1908
+ const sealings = new Map();
1909
+ for (const snapshot of snapshots) {
1910
+ const name = snapshot.service.namespace.name;
1911
+ const perService = options.services?.[name];
1912
+ sealings.set(name, perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false);
1913
+ }
1914
+ if (new Set(sealings.values()).size > 1) {
1915
+ const sealed = [...sealings.entries()].filter(([, seals]) => seals).map(([name]) => name);
1916
+ const open = [...sealings.entries()].filter(([, seals]) => !seals).map(([name]) => name);
1917
+ reportDiagnostic(context.program, {
1918
+ code: "unmirrorable-seal",
1919
+ target: NoTarget,
1920
+ format: { sealed: sealed.join(", "), open: open.join(", ") },
1921
+ });
1922
+ }
1875
1923
  for (const snapshot of snapshots) {
1876
1924
  beginOperationIds();
1877
1925
  const service = snapshot.service;
package/dist/src/lib.d.ts CHANGED
@@ -125,6 +125,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
125
125
  "empty-union": {
126
126
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
127
127
  };
128
+ "unmirrorable-seal": {
129
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
130
+ };
128
131
  "unknown-key-vocabulary": {
129
132
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
130
133
  };
@@ -138,7 +141,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
138
141
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
139
142
  };
140
143
  }, EmitterOptions, never>;
141
- export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
144
+ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
142
145
  "unsupported-type": {
143
146
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
144
147
  };
@@ -151,6 +154,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
151
154
  "empty-union": {
152
155
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
153
156
  };
157
+ "unmirrorable-seal": {
158
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
159
+ };
154
160
  "unknown-key-vocabulary": {
155
161
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
156
162
  };
@@ -176,6 +182,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
176
182
  "empty-union": {
177
183
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
178
184
  };
185
+ "unmirrorable-seal": {
186
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
187
+ };
179
188
  "unknown-key-vocabulary": {
180
189
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
181
190
  };
package/dist/src/lib.js CHANGED
@@ -127,6 +127,19 @@ const diagnostics = {
127
127
  * consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
128
128
  * vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
129
129
  */
130
+ /**
131
+ * Two services sealing differently, which `@typespec/openapi3` cannot mirror.
132
+ *
133
+ * An error rather than a warning, on the same footing as every other divergence here: the output
134
+ * is a document and a validator that contradict each other about whether a property may exist,
135
+ * and shipping that is worse than failing the build.
136
+ */
137
+ "unmirrorable-seal": {
138
+ severity: "error",
139
+ messages: {
140
+ default: paramMessage `'seal-object-schemas' resolves to true for ${"sealed"} and false for ${"open"}, and '@typespec/openapi3' has no per-service options - it applies one value to the whole program. So one of these services would publish a document that disagrees with the validator emitted beside it. Give every service the same value, or split the surfaces into separate compiles.`,
141
+ },
142
+ },
130
143
  "unknown-key-vocabulary": {
131
144
  severity: "error",
132
145
  messages: {
package/dist/src/types.js CHANGED
@@ -256,7 +256,23 @@ export function propertyToTs(program, property, wireName) {
256
256
  */
257
257
  if (property.defaultValue !== undefined)
258
258
  return `${key}: ${value};`;
259
- // `exactOptionalPropertyTypes` is on, and Zod's `.optional()` infers `T | undefined`.
259
+ /**
260
+ * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
261
+ *
262
+ * It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
263
+ * it would make this walk agree with `Exact<z.infer<...>>` on the schemas side. **Measured, it
264
+ * breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
265
+ * what you were given - stopped compiling, because what a handler RECEIVES carries
266
+ * `?: T | undefined` and what it would then have to SUPPLY did not.
267
+ *
268
+ * That is the difference from the index signature and from `readonly`, which look like the same
269
+ * class and are not: removing those two makes MORE values assignable, and removing this one makes
270
+ * fewer. `{ retiredOn: undefined }` serialises to `{}`, identical to omitting the key, so
271
+ * permitting it costs the wire nothing and refusing it costs every producer a conditional spread.
272
+ *
273
+ * The two emitted surfaces differ here on purpose, exactly as they do on openness:
274
+ * `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
275
+ */
260
276
  return property.optional ? `${key}?: ${value} | undefined;` : `${key}: ${value};`;
261
277
  }
262
278
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",