@distilled.cloud/core 1.0.0-rc.1 → 1.0.0-rc.10

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 (148) hide show
  1. package/LICENSE +201 -0
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +16 -4
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.d.ts +4 -4
  6. package/lib/category.js +4 -4
  7. package/lib/codegen/boolean-string-enums.d.ts +36 -0
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -0
  9. package/lib/codegen/boolean-string-enums.js +94 -0
  10. package/lib/codegen/boolean-string-enums.js.map +1 -0
  11. package/lib/codegen/boolean-string-enums.test.d.ts +2 -0
  12. package/lib/codegen/boolean-string-enums.test.d.ts.map +1 -0
  13. package/lib/codegen/boolean-string-enums.test.js +147 -0
  14. package/lib/codegen/boolean-string-enums.test.js.map +1 -0
  15. package/lib/codegen/cli.d.ts +56 -6
  16. package/lib/codegen/cli.d.ts.map +1 -1
  17. package/lib/codegen/cli.js +59 -75
  18. package/lib/codegen/cli.js.map +1 -1
  19. package/lib/codegen/emit.d.ts +3 -7
  20. package/lib/codegen/emit.d.ts.map +1 -1
  21. package/lib/codegen/emit.js +6 -10
  22. package/lib/codegen/emit.js.map +1 -1
  23. package/lib/codegen/generator.d.ts.map +1 -1
  24. package/lib/codegen/generator.js +142 -20
  25. package/lib/codegen/generator.js.map +1 -1
  26. package/lib/codegen/graphql-client.d.ts +62 -0
  27. package/lib/codegen/graphql-client.d.ts.map +1 -0
  28. package/lib/codegen/graphql-client.js +294 -0
  29. package/lib/codegen/graphql-client.js.map +1 -0
  30. package/lib/codegen/graphql-client.test.d.ts +2 -0
  31. package/lib/codegen/graphql-client.test.d.ts.map +1 -0
  32. package/lib/codegen/graphql-client.test.js +311 -0
  33. package/lib/codegen/graphql-client.test.js.map +1 -0
  34. package/lib/codegen/graphql.d.ts +207 -0
  35. package/lib/codegen/graphql.d.ts.map +1 -0
  36. package/lib/codegen/graphql.js +799 -0
  37. package/lib/codegen/graphql.js.map +1 -0
  38. package/lib/codegen/openapi-cli.d.ts +17 -3
  39. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  40. package/lib/codegen/openapi-cli.js +50 -48
  41. package/lib/codegen/openapi-cli.js.map +1 -1
  42. package/lib/codegen/openapi.d.ts +69 -6
  43. package/lib/codegen/openapi.d.ts.map +1 -1
  44. package/lib/codegen/openapi.js +173 -16
  45. package/lib/codegen/openapi.js.map +1 -1
  46. package/lib/codegen/patches.d.ts +65 -0
  47. package/lib/codegen/patches.d.ts.map +1 -0
  48. package/lib/codegen/patches.js +236 -0
  49. package/lib/codegen/patches.js.map +1 -0
  50. package/lib/codegen/patches.test.d.ts +2 -0
  51. package/lib/codegen/patches.test.d.ts.map +1 -0
  52. package/lib/codegen/patches.test.js +105 -0
  53. package/lib/codegen/patches.test.js.map +1 -0
  54. package/lib/codegen/proto.d.ts +121 -0
  55. package/lib/codegen/proto.d.ts.map +1 -0
  56. package/lib/codegen/proto.js +962 -0
  57. package/lib/codegen/proto.js.map +1 -0
  58. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  59. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  60. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  61. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  62. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  63. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  64. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  65. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  66. package/lib/codegen/spec-path.d.ts +16 -0
  67. package/lib/codegen/spec-path.d.ts.map +1 -0
  68. package/lib/codegen/spec-path.js +101 -0
  69. package/lib/codegen/spec-path.js.map +1 -0
  70. package/lib/errors.d.ts.map +1 -1
  71. package/lib/errors.js +17 -13
  72. package/lib/errors.js.map +1 -1
  73. package/lib/graphql.d.ts +284 -0
  74. package/lib/graphql.d.ts.map +1 -0
  75. package/lib/graphql.fixture.d.ts +249 -0
  76. package/lib/graphql.fixture.d.ts.map +1 -0
  77. package/lib/graphql.fixture.js +240 -0
  78. package/lib/graphql.fixture.js.map +1 -0
  79. package/lib/graphql.js +718 -0
  80. package/lib/graphql.js.map +1 -0
  81. package/lib/graphql.test.d.ts +2 -0
  82. package/lib/graphql.test.d.ts.map +1 -0
  83. package/lib/graphql.test.js +780 -0
  84. package/lib/graphql.test.js.map +1 -0
  85. package/lib/graphql.types.d.ts +2 -0
  86. package/lib/graphql.types.d.ts.map +1 -0
  87. package/lib/graphql.types.js +45 -0
  88. package/lib/graphql.types.js.map +1 -0
  89. package/lib/json-patch.d.ts +18 -11
  90. package/lib/json-patch.d.ts.map +1 -1
  91. package/lib/json-patch.js +63 -25
  92. package/lib/json-patch.js.map +1 -1
  93. package/lib/pagination.d.ts +43 -3
  94. package/lib/pagination.d.ts.map +1 -1
  95. package/lib/pagination.js +91 -5
  96. package/lib/pagination.js.map +1 -1
  97. package/lib/protocol-http.d.ts.map +1 -1
  98. package/lib/protocol-http.js +62 -26
  99. package/lib/protocol-http.js.map +1 -1
  100. package/lib/protocol-http.test.d.ts +2 -0
  101. package/lib/protocol-http.test.d.ts.map +1 -0
  102. package/lib/protocol-http.test.js +88 -0
  103. package/lib/protocol-http.test.js.map +1 -0
  104. package/lib/protocol-rest.d.ts +12 -2
  105. package/lib/protocol-rest.d.ts.map +1 -1
  106. package/lib/protocol-rest.js +17 -3
  107. package/lib/protocol-rest.js.map +1 -1
  108. package/lib/retry.js +1 -1
  109. package/lib/schema.d.ts +1 -1
  110. package/lib/schema.d.ts.map +1 -1
  111. package/lib/schema.js +1 -1
  112. package/lib/schema.js.map +1 -1
  113. package/lib/trait.d.ts +27 -3
  114. package/lib/trait.d.ts.map +1 -1
  115. package/lib/trait.js +17 -1
  116. package/lib/trait.js.map +1 -1
  117. package/package.json +13 -10
  118. package/src/api.ts +18 -4
  119. package/src/category.ts +4 -4
  120. package/src/codegen/boolean-string-enums.test.ts +168 -0
  121. package/src/codegen/boolean-string-enums.ts +106 -0
  122. package/src/codegen/cli.ts +127 -110
  123. package/src/codegen/emit.ts +6 -10
  124. package/src/codegen/generator.ts +151 -21
  125. package/src/codegen/graphql-client.test.ts +386 -0
  126. package/src/codegen/graphql-client.ts +419 -0
  127. package/src/codegen/graphql.ts +1217 -0
  128. package/src/codegen/openapi-cli.ts +75 -59
  129. package/src/codegen/openapi.ts +255 -16
  130. package/src/codegen/patches.test.ts +130 -0
  131. package/src/codegen/patches.ts +291 -0
  132. package/src/codegen/proto.ts +1128 -0
  133. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  134. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  135. package/src/codegen/spec-path.ts +115 -0
  136. package/src/errors.ts +20 -25
  137. package/src/graphql.fixture.ts +371 -0
  138. package/src/graphql.test.ts +974 -0
  139. package/src/graphql.ts +1321 -0
  140. package/src/graphql.types.ts +185 -0
  141. package/src/json-patch.ts +82 -25
  142. package/src/pagination.ts +134 -7
  143. package/src/protocol-http.test.ts +107 -0
  144. package/src/protocol-http.ts +67 -31
  145. package/src/protocol-rest.ts +28 -4
  146. package/src/retry.ts +1 -1
  147. package/src/schema.ts +2 -2
  148. package/src/trait.ts +39 -3
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * The generic generator CLI harness (dev-time only).
3
3
  *
4
- * Owns the smithy-model pipeline every SDK package shares: scan the model
5
- * directory (plus optional hand-authored manual specs), apply the RFC-6902
6
- * patch chain (`patches/<resource>/*.json`, `*.manual.json` last; stale
7
- * targets warn, malformed patches fail the run), compile each model through
8
- * {@link generateService} with the provider's {@link SdkSpec}, write the
9
- * service modules and the namespaced barrel.
4
+ * Owns the smithy→SDK pipeline every SDK package shares: scan the model
5
+ * directory (plus optional hand-authored manual specs), compile each model
6
+ * through {@link generateService} with the provider's {@link SdkSpec}, write
7
+ * the service modules and the namespaced barrel. RFC-6902 patches apply in
8
+ * convert so `.generated-specs` is already the patched model.
10
9
  *
11
10
  * A provider's `scripts/generate.ts` is: trait consts + an SdkSpec + a
12
11
  * `runGeneratorCli` call.
@@ -17,11 +16,6 @@ import * as FileSystem from "effect/FileSystem";
17
16
  import * as Path from "effect/Path";
18
17
  import { Flag } from "effect/unstable/cli";
19
18
  import { Command } from "effect/unstable/cli";
20
- import {
21
- applyOperation,
22
- isStaleTargetError,
23
- type PatchFile,
24
- } from "../json-patch.ts";
25
19
  import { barrel } from "./emit.ts";
26
20
  import { formatGenerated } from "./format.ts";
27
21
  import { generateService, type SdkSpec } from "./generator.ts";
@@ -37,15 +31,63 @@ export interface GeneratorCliOptions {
37
31
  readonly outDir?: string;
38
32
  /** Model files to skip (e.g. a shared protocols model). */
39
33
  readonly excludeModel?: (file: string) => boolean;
34
+ /**
35
+ * Where the models are, when they aren't a flat directory of `<resource>.json`.
36
+ * AWS vendors Amazon's own repo, whose models sit at
37
+ * `models/<service>/service/<version>/<service>-<version>.json`.
38
+ *
39
+ * Returns the model files to compile; manual specs are merged in after,
40
+ * as usual.
41
+ */
42
+ readonly discoverModels?: (ctx: {
43
+ readonly smithyDir: string;
44
+ }) => Effect.Effect<
45
+ ReadonlyArray<{ readonly file: string; readonly dir: string }>,
46
+ never,
47
+ FileSystem.FileSystem | Path.Path
48
+ >;
49
+ /**
50
+ * Side effects to run before generating — e.g. AWS copies `partitions.json`
51
+ * out of the smithy submodule, runtime data its endpoint resolver reads.
52
+ */
53
+ readonly prepare?: (ctx: {
54
+ readonly root: string;
55
+ readonly outDir: string;
56
+ }) => Effect.Effect<void, never, FileSystem.FileSystem | Path.Path>;
57
+ /**
58
+ * The output module's name, when it isn't the model's filename. AWS names
59
+ * modules after the service's `aws.api#service` sdkId (`amazon-s3.json` →
60
+ * `s3.ts`), so the public surface reads `AWS.S3.getObject`.
61
+ */
62
+ readonly resourceName?: (ctx: {
63
+ readonly model: any;
64
+ readonly file: string;
65
+ }) => string;
66
+ /** Barrel export name for a resource. Default: the resource name itself. */
67
+ readonly barrelExportName?: (resource: string) => string;
68
+ /**
69
+ * The closing pass over the output directory. Defaults to formatting;
70
+ * AWS lint-fixes first (see `codegen/format.ts`).
71
+ */
72
+ readonly finalize?: (dir: string) => Effect.Effect<void>;
73
+ /**
74
+ * Keep generating after a model fails, instead of stopping at the first.
75
+ * AWS compiles 430 services in one run, and one broken model shouldn't
76
+ * hide the state of the other 429. The run still FAILS at the end — the
77
+ * failures are reported together rather than swallowed.
78
+ */
79
+ readonly continueOnModelError?: boolean;
40
80
  /** Directory of hand-authored models merged after the generated ones. */
41
81
  readonly manualSpecsDir?: string;
42
- /** RFC-6902 patch chain root. Default `patches`; `false` disables. */
43
- readonly patchesDir?: string | false;
44
82
  /**
45
- * Model transform applied AFTER the patch chain, before generation —
46
- * for whole-model rewrites that must see post-patch shape names (e.g.
47
- * cloudflare's scope-twin structural dedup). May mutate the model;
48
- * a returned log line is printed.
83
+ * RFC-6902 patches apply in convert, never here. Only `false` is accepted
84
+ * so a string is a type error rather than a silently ignored setting.
85
+ */
86
+ readonly patchesDir?: false;
87
+ /**
88
+ * Model transform applied before generation (e.g. AWS dropping
89
+ * unreachable foreign-namespace shapes from a vendored model). Does not
90
+ * write back to `.generated-specs` — convert owns the committed model.
49
91
  */
50
92
  readonly transformModel?: (model: any, resource: string) => string | void;
51
93
  /** The provider spec — built per model (metadata may vary per model). */
@@ -57,15 +99,15 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
57
99
  const command = Command.make(
58
100
  "generate",
59
101
  {
60
- smithy: Flag.string("smithy").pipe(
102
+ smithy: Flag.String("smithy").pipe(
61
103
  Flag.withDefault(options.smithyDir ?? ".generated-specs"),
62
104
  Flag.withDescription("Directory of Smithy JSON models"),
63
105
  ),
64
- out: Flag.string("out").pipe(
106
+ out: Flag.String("out").pipe(
65
107
  Flag.withDefault(options.outDir ?? "src/services"),
66
108
  Flag.withDescription("Output directory for generated service modules"),
67
109
  ),
68
- resource: Flag.string("resource").pipe(
110
+ resource: Flag.String("resource").pipe(
69
111
  Flag.withDefault(""),
70
112
  Flag.withDescription("Only generate this resource (e.g. ai)"),
71
113
  ),
@@ -82,14 +124,19 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
82
124
  yield* Console.log(` Smithy: ${smithyDir}`);
83
125
  yield* Console.log(` Output: ${outDir}`);
84
126
 
127
+ yield* options.prepare?.({ root, outDir }) ?? Effect.void;
128
+
85
129
  // Generated models plus optional manual-specs (hand-authored models
86
130
  // for APIs the provider's spec source doesn't cover). A manual model
87
131
  // must not shadow a generated one.
88
- const generated = (yield* fs.readDirectory(smithyDir))
89
- .filter(
90
- (f) => f.endsWith(".json") && !(options.excludeModel?.(f) ?? false),
91
- )
92
- .map((f) => ({ file: f, dir: smithyDir }));
132
+ const generated = options.discoverModels
133
+ ? yield* options.discoverModels({ smithyDir })
134
+ : (yield* fs.readDirectory(smithyDir))
135
+ .filter(
136
+ (f) =>
137
+ f.endsWith(".json") && !(options.excludeModel?.(f) ?? false),
138
+ )
139
+ .map((f) => ({ file: f, dir: smithyDir }));
93
140
  const manualDir = options.manualSpecsDir
94
141
  ? path.resolve(root, options.manualSpecsDir)
95
142
  : undefined;
@@ -115,84 +162,41 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
115
162
  yield* fs.makeDirectory(outDir, { recursive: true });
116
163
 
117
164
  const written: string[] = [];
165
+ const failedModels: string[] = [];
118
166
  let totalOps = 0;
119
- let totalPatches = 0;
120
- let staleOps = 0;
121
- const badPatches: string[] = [];
122
-
123
- // Orphan check: a patch directory matching no smithy model would be
124
- // silently dropped — flag it instead.
125
- const patchRoot =
126
- options.patchesDir === false
127
- ? undefined
128
- : path.join(root, options.patchesDir ?? "patches");
129
- if (patchRoot && (yield* fs.exists(patchRoot))) {
130
- const resources = new Set(
131
- entries.map((e) => e.file.replace(/\.json$/, "")),
132
- );
133
- for (const dir of yield* fs.readDirectory(patchRoot)) {
134
- if (!resources.has(dir)) {
135
- yield* Console.warn(
136
- `⚠️ patches/${dir}/ matches no smithy model — orphaned?`,
137
- );
138
- }
139
- }
140
- }
141
167
 
142
168
  for (const { file, dir } of entries) {
143
- const resource = file.replace(/\.json$/, "");
144
- if (config.resource && resource !== config.resource) continue;
145
-
146
169
  const model = JSON.parse(
147
170
  yield* fs.readFileString(path.join(dir, file)),
148
171
  );
149
-
150
- // Apply the RFC-6902 patch chain before generating. Hand-written
151
- // *.manual.json patches apply after the generated ones — they
152
- // usually target post-rename shape names.
153
- const patchDir = patchRoot && path.join(patchRoot, resource);
154
- if (patchDir && (yield* fs.exists(patchDir))) {
155
- const patchFiles = (yield* fs.readDirectory(patchDir))
156
- .filter((f) => f.endsWith(".json"))
157
- .sort(
158
- (a, b) =>
159
- Number(a.endsWith(".manual.json")) -
160
- Number(b.endsWith(".manual.json")) || a.localeCompare(b),
161
- );
162
- for (const pf of patchFiles) {
163
- const parsed = JSON.parse(
164
- yield* fs.readFileString(path.join(patchDir, pf)),
165
- ) as PatchFile;
166
- for (const patchOp of parsed.patches ?? []) {
167
- try {
168
- applyOperation(model, patchOp);
169
- } catch (e) {
170
- const msg = e instanceof Error ? e.message : String(e);
171
- if (isStaleTargetError(msg)) {
172
- staleOps++;
173
- yield* Console.warn(
174
- ` ⚠️ stale: ${resource}/${pf} [${patchOp.op} ${patchOp.path}]`,
175
- );
176
- } else {
177
- badPatches.push(
178
- `${resource}/${pf} [${patchOp.op} ${patchOp.path}]: ${msg}`,
179
- );
180
- }
181
- }
182
- }
183
- totalPatches++;
184
- }
185
- }
172
+ // The module's name — the model's filename unless the provider
173
+ // derives it from the model itself (AWS: the service's sdkId).
174
+ const resource =
175
+ options.resourceName?.({ model, file }) ??
176
+ file.replace(/\.json$/, "");
177
+ if (config.resource && resource !== config.resource) continue;
186
178
 
187
179
  if (options.transformModel) {
188
180
  const note = options.transformModel(model, resource);
189
181
  if (note) yield* Console.log(` ${note}`);
190
182
  }
191
183
 
192
- const { code, operations } = generateService(
193
- model,
194
- options.spec(model),
195
- );
184
+ // `generateService` and the provider spec are synchronous and
185
+ // throw; with continueOnModelError the throw is recorded and the
186
+ // run moves on, so one broken model doesn't hide the state of
187
+ // every model after it. The run still fails at the end.
188
+ let generated;
189
+ try {
190
+ generated = generateService(model, options.spec(model));
191
+ } catch (e) {
192
+ if (!options.continueOnModelError) throw e;
193
+ failedModels.push(
194
+ `${resource}: ${e instanceof Error ? e.message : String(e)}`,
195
+ );
196
+ yield* Console.error(`❌ ${resource}`);
197
+ continue;
198
+ }
199
+ const { code, operations } = generated;
196
200
  if (operations === 0) continue;
197
201
 
198
202
  yield* fs.writeFileString(path.join(outDir, `${resource}.ts`), code);
@@ -200,17 +204,6 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
200
204
  totalOps += operations;
201
205
  }
202
206
 
203
- if (badPatches.length) {
204
- for (const b of badPatches) {
205
- yield* Console.error(`❌ bad patch: ${b}`);
206
- }
207
- return yield* Effect.die(
208
- new Error(
209
- `${badPatches.length} malformed patch operation(s) — fix or remove them`,
210
- ),
211
- );
212
- }
213
-
214
207
  // Barrel — namespace per resource to avoid op-name collisions.
215
208
  //
216
209
  // A FULL run is authoritative: the barrel is exactly what was
@@ -224,11 +217,22 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
224
217
  // resource without removing anything.
225
218
  const barrelPath = path.join(outDir, "index.ts");
226
219
  const filtered = config.resource !== "";
227
- let resources = written;
220
+ // Sorted, not in generation order: a provider that discovers models
221
+ // in some other order (AWS walks Amazon's directory tree) would
222
+ // otherwise reshuffle the barrel on every run.
223
+ let resources = [...written].sort((a, b) =>
224
+ `${a}.json`.localeCompare(`${b}.json`),
225
+ );
228
226
  if (filtered && (yield* fs.exists(barrelPath))) {
227
+ // Recover the RESOURCE from each export line's path, not its name:
228
+ // the two differ when barrelExportName renames (AWS exports
229
+ // `S3` from `./s3.ts`).
229
230
  const existing = (yield* fs.readFileString(barrelPath))
230
231
  .split("\n")
231
- .map((line) => /^export \* as (\S+) from/.exec(line)?.[1])
232
+ .map(
233
+ (line) =>
234
+ /^export \* as \S+ from "\.\/(.+)\.ts";/.exec(line)?.[1],
235
+ )
232
236
  .filter((name): name is string => name !== undefined);
233
237
  // Ordered exactly as a full run orders it: by MODEL FILENAME, so
234
238
  // the `.json` takes part in the collation (`ai_gateway.json` sorts
@@ -243,19 +247,32 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
243
247
  barrelPath,
244
248
  barrel(
245
249
  `// AUTO-GENERATED by scripts/generate.ts. Do not edit.\n`,
246
- resources.map((r) => ({ name: r, path: `./${r}.ts` })),
250
+ resources.map((r) => ({
251
+ name: options.barrelExportName?.(r) ?? r,
252
+ path: `./${r}.ts`,
253
+ })),
247
254
  ),
248
255
  );
249
256
 
250
- yield* formatGenerated(outDir);
257
+ yield* (options.finalize ?? formatGenerated)(outDir);
251
258
 
252
259
  yield* Console.log(
253
- `\n✅ Generated ${totalOps} operations across ${written.length} resource modules` +
254
- (totalPatches
255
- ? ` (${totalPatches} patch files applied${staleOps ? `, ${staleOps} stale op(s) skipped` : ""}).`
256
- : "."),
260
+ `\n✅ Generated ${totalOps} operations across ${written.length} resource modules.`,
257
261
  );
258
262
  yield* Console.log(` ${path.join(outDir, "index.ts")}`);
263
+
264
+ // Models that failed under continueOnModelError. Reported together,
265
+ // at the end, and the run FAILS — a generate that couldn't produce
266
+ // a module must not exit 0 with the failure buried in the log.
267
+ if (failedModels.length) {
268
+ yield* Console.error(
269
+ `\n❌ ${failedModels.length} model(s) failed to generate:`,
270
+ );
271
+ for (const f of failedModels) yield* Console.error(` ${f}`);
272
+ return yield* Effect.die(
273
+ new Error(`${failedModels.length} model(s) failed to generate`),
274
+ );
275
+ }
259
276
  }),
260
277
  ).pipe(Command.withDescription(options.description));
261
278
 
@@ -9,7 +9,7 @@
9
9
  * .annotate({ identifier: "X" })
10
10
  * as any as S.Schema<X>; // no inference needed
11
11
  *
12
- * plus closed-alias string-union enums, `S.TaggedErrorClass` error classes, and
12
+ * plus closed-alias string-union enums, `S.TaggedError` error classes, and
13
13
  * `API.make(() => ({ … }))` operation consts. The helpers here own those
14
14
  * shared skeletons; providers own the content strings (member pipes, trait
15
15
  * calls, config fields). Emitted output is normalized by oxfmt afterwards,
@@ -18,11 +18,7 @@
18
18
  */
19
19
  import { q } from "./naming.ts";
20
20
 
21
- /**
22
- * The PURE annotation emitted before generated schema consts. A single
23
- * `/*@__PURE__*​/` — Rolldown 1.1+ warns on the `/*#__PURE__*​/` form
24
- * (distilled #374).
25
- */
21
+ /** PURE annotates calls, never property reads. Both @ and # forms are supported. */
26
22
  export const PURE = "/*@__PURE__*/ ";
27
23
 
28
24
  /** `export interface X { … }` (or the empty-body form). */
@@ -101,7 +97,7 @@ export const enumDecl = (o: EnumDeclOptions): string[] => {
101
97
  const union = o.values.length ? o.values.map(q).join(" | ") : "string";
102
98
  return [
103
99
  `export type ${o.name} = ${union};`,
104
- `export const ${o.name} = ${o.pure ?? ""}${o.schemaExpr ?? "S.String"};\n`,
100
+ `export const ${o.name} = ${o.schemaExpr ? `${o.pure ?? ""}${o.schemaExpr}` : "S.String"};\n`,
105
101
  ];
106
102
  };
107
103
 
@@ -120,7 +116,7 @@ export interface ErrorClassOptions {
120
116
  }
121
117
 
122
118
  /**
123
- * `export class X extends /*@__PURE__*​/ S.TaggedErrorClass<X>()("X", { … }) {}`
119
+ * `export class X extends /*@__PURE__*​/ S.TaggedError<X>()("X", { … }) {}`
124
120
  *
125
121
  * The PURE markers are what make an unused error class droppable. A class
126
122
  * whose heritage clause is an unannotated call can never be tree-shaken —
@@ -130,12 +126,12 @@ export interface ErrorClassOptions {
130
126
  *
131
127
  * `wrap` needs its own marker as well as the inner one: a pure call's
132
128
  * ARGUMENTS are still evaluated, so annotating only
133
- * `T.applyErrorMatchers(S.TaggedErrorClass…(…), […])` leaves the inner call
129
+ * `T.applyErrorMatchers(S.TaggedError…(…), […])` leaves the inner call
134
130
  * holding the class alive. Verified against esbuild in both directions.
135
131
  */
136
132
  export const errorClass = (o: ErrorClassOptions): string => {
137
133
  const annotations = o.annotations ? `,\n${o.annotations}` : "";
138
- const cls = `${PURE}S.TaggedErrorClass<${o.name}>()(${q(o.tag ?? o.name)}, {\n${o.fields.join("\n")}\n}${annotations})${o.pipes ?? ""}`;
134
+ const cls = `${PURE}S.TaggedError<${o.name}>()(${q(o.tag ?? o.name)}, {\n${o.fields.join("\n")}\n}${annotations})${o.pipes ?? ""}`;
139
135
  const body = o.wrap ? `${PURE}${o.wrap(cls)}` : cls;
140
136
  return `export class ${o.name} extends ${body} {}\n`;
141
137
  };
@@ -55,6 +55,10 @@ import {
55
55
  } from "./operations.ts";
56
56
  import { memberBases, smithyWireName } from "./members.ts";
57
57
  import { validatePaginated } from "./pagination.ts";
58
+ import {
59
+ booleanStringEnums,
60
+ STRING_ENCODED_TRAIT,
61
+ } from "./boolean-string-enums.ts";
58
62
 
59
63
  const PAGINATED_TRAIT = "smithy.api#paginated";
60
64
 
@@ -447,6 +451,9 @@ export const generateService = (
447
451
  model: any,
448
452
  spec: SdkSpec,
449
453
  ): GeneratedService => {
454
+ // `"true" | "false"` request members become real booleans that travel as
455
+ // their string spelling (see boolean-string-enums.ts).
456
+ booleanStringEnums(model);
450
457
  const shapes: ShapeMap = model.shapes;
451
458
  const pure = spec.pure ?? PURE;
452
459
  const prelude = spec.prelude ?? JSON_PRELUDE;
@@ -487,8 +494,27 @@ export const generateService = (
487
494
  const order = topoOrder(shapes, reachable, shapeDeps);
488
495
  const indexOf = orderIndex(order);
489
496
 
490
- const ref = makeSchemaRef(prelude, indexOf);
491
- const tsRef = makeTsRef(tsPrelude);
497
+ /**
498
+ * Shape id → the id whose emitted body it shares.
499
+ *
500
+ * Structural aliasing (see the struct emission below) has to cascade: a
501
+ * parent only matches another parent if their MEMBERS render identically,
502
+ * and a member renders as its target's name. So every reference resolves
503
+ * through this map first — once `…ItemGroupRuleGroup` is aliased onto a
504
+ * canonical shape, the parents referencing it start rendering the same
505
+ * text and collapse in turn.
506
+ *
507
+ * Filled in topological order, so a canonical target is always a shape
508
+ * that was already emitted — a back-reference, never a forward one.
509
+ */
510
+ const canonicalId = new Map<string, string>();
511
+ const canon = (target: string): string => canonicalId.get(target) ?? target;
512
+
513
+ const rawRef = makeSchemaRef(prelude, indexOf);
514
+ const rawTsRef = makeTsRef(tsPrelude);
515
+ const ref = (target: string, selfIdx: number) =>
516
+ rawRef(canon(target), selfIdx);
517
+ const tsRef = (target: string) => rawTsRef(canon(target));
492
518
 
493
519
  // Direction classification for enum openness. Enum ALIASES are emitted
494
520
  // CLOSED (exhaustively matchable on reads); request-reachable shapes
@@ -529,7 +555,8 @@ export const generateService = (
529
555
  * pure response/error shapes stay the plain closed alias, and so do
530
556
  * union-arm discriminant literals.
531
557
  */
532
- const tsRefAt = (target: string, ownerId: string): string => {
558
+ const tsRefAt = (rawTarget: string, ownerId: string): string => {
559
+ const target = canon(rawTarget);
533
560
  const base = tsRef(target);
534
561
  if (!requestReachable.has(ownerId)) return base;
535
562
  if (discriminantEnums.has(target)) return base;
@@ -605,6 +632,50 @@ export const generateService = (
605
632
  };
606
633
  });
607
634
 
635
+ /** The single value a one-value enum shape fixes, if `target` is one. */
636
+ const soleEnumValue = (target: string): string | undefined => {
637
+ const d = shapes[target];
638
+ if (d?.type !== "enum") return undefined;
639
+ const values = Object.values(d.members ?? {}).map(
640
+ (m: any) => m.traits?.["smithy.api#enumValue"],
641
+ );
642
+ return values.length === 1 && typeof values[0] === "string"
643
+ ? values[0]
644
+ : undefined;
645
+ };
646
+
647
+ /**
648
+ * The member every case of a union pins to a different literal — the tag
649
+ * the API itself discriminates by (`type: "zone"`, `id: "browser_check"`).
650
+ * Undefined unless every case is a structure carrying that member and no
651
+ * two cases claim the same value.
652
+ */
653
+ const unionDiscriminator = (
654
+ caseTargets: readonly string[],
655
+ ): { key: string; values: string[] } | undefined => {
656
+ if (caseTargets.length < 2) return undefined;
657
+ const perCase = caseTargets.map((t) => {
658
+ const cd = shapes[t];
659
+ if (cd?.type !== "structure") return undefined;
660
+ const tags = new Map<string, string>();
661
+ for (const mi of memberInfos(cd)) {
662
+ const value = soleEnumValue(mi.target);
663
+ if (value !== undefined) tags.set(mi.tsName, value);
664
+ }
665
+ return tags;
666
+ });
667
+ if (perCase.some((tags) => tags === undefined || tags.size === 0)) {
668
+ return undefined;
669
+ }
670
+ for (const key of perCase[0]!.keys()) {
671
+ const values = perCase.map((tags) => tags!.get(key));
672
+ if (values.some((v) => v === undefined)) continue;
673
+ if (new Set(values).size !== values.length) continue;
674
+ return { key, values: values as string[] };
675
+ }
676
+ return undefined;
677
+ };
678
+
608
679
  // Generic pipes for the smithy bindings (the SDK's traits module exports
609
680
  // core's Label/Query/Header/HttpBody/Body builders under these names);
610
681
  // provider bindings and member traits append theirs via memberExtraPipes.
@@ -648,6 +719,7 @@ export const generateService = (
648
719
  ([trait, builder]) =>
649
720
  `${builder}(${JSON.stringify(info.traits[trait])})`,
650
721
  ),
722
+ ...(STRING_ENCODED_TRAIT in info.traits ? ["T.StringEncoded()"] : []),
651
723
  ...(spec.memberExtraPipes?.(info) ?? []),
652
724
  ]);
653
725
 
@@ -731,6 +803,13 @@ export const generateService = (
731
803
 
732
804
  // 4. Error classes from the operations' errors lists.
733
805
  const out: string[] = [];
806
+ // Emitted struct body → the name that owns it, for structural aliasing.
807
+ // Keyed on the emitted TEXT, so two shapes collapse only when what they
808
+ // would emit is byte-identical — including member names, targets, docs and
809
+ // pipes. References are already resolved to names at this point, so a
810
+ // difference anywhere in the tree shows up as different text.
811
+ const structBodies = new Map<string, { id: string; name: string }>();
812
+ let aliased = 0;
734
813
  // Set when any error carries CATEGORY_TRAIT, so the header only imports
735
814
  // the category module when something actually uses it.
736
815
  let usesCategories = false;
@@ -866,7 +945,6 @@ export const generateService = (
866
945
  fields.push(...inject.interfaceLines);
867
946
  members.push(inject.structLine);
868
947
  }
869
- out.push(interfaceDecl(name, fields));
870
948
  const struct = members.length
871
949
  ? `S.Struct({\n${members.join("\n")}\n})`
872
950
  : `S.Struct({})`;
@@ -884,26 +962,65 @@ export const generateService = (
884
962
  : []),
885
963
  ];
886
964
  const tail = pipes.map((p) => `.pipe(${p})`).join("");
965
+
966
+ // Shapes whose emitted body is byte-identical are one shape wearing
967
+ // many names. The docs pipelines generate a fresh copy of every nested
968
+ // shape per operation, so cloudflare's zero_trust carries 374 copies of
969
+ // `{ group?: unknown }` — one per operation x application type x
970
+ // include/exclude/require — and 72% of its 16,800 structures are
971
+ // redundant that way.
972
+ //
973
+ // Emit the body once and alias the rest to it. The public name is kept,
974
+ // so nothing about the surface changes; only the duplicate bodies go.
975
+ // Operation I/O is excluded: those carry the operation's Http trait and
976
+ // must never be merged onto one another.
977
+ const bodyKey = `${JSON.stringify(fields)}|${struct}${tail}`;
978
+ const canonical = structCtx.isOpIo
979
+ ? undefined
980
+ : structBodies.get(bodyKey);
981
+ if (canonical !== undefined) {
982
+ out.push(`export type ${name} = ${canonical.name};`);
983
+ out.push(`export const ${name} = ${canonical.name};\n`);
984
+ // Later shapes referencing this one now render the canonical name,
985
+ // which is what lets their bodies collapse too.
986
+ canonicalId.set(id, canonical.id);
987
+ aliased++;
988
+ } else {
989
+ if (!structCtx.isOpIo) {
990
+ structBodies.set(bodyKey, { id, name });
991
+ }
992
+ out.push(interfaceDecl(name, fields));
993
+ out.push(
994
+ suspendConst({
995
+ name,
996
+ pure,
997
+ multiline: true,
998
+ annotateIdentifier: true,
999
+ expr: `${struct}${tail}`,
1000
+ }),
1001
+ );
1002
+ }
1003
+ } else if (d.type === "list") {
1004
+ const nullable =
1005
+ spec.nullableTrait !== undefined &&
1006
+ spec.nullableTrait in (d.member.traits ?? {});
1007
+ const item = ref(d.member.target, i);
887
1008
  out.push(
888
- suspendConst({
889
- name,
890
- pure,
891
- multiline: true,
892
- annotateIdentifier: true,
893
- expr: `${struct}${tail}`,
894
- }),
1009
+ `export type ${name} = Array<${tsRefAt(d.member.target, id)}${nullable ? " | null" : ""}>;`,
895
1010
  );
896
- } else if (d.type === "list") {
897
- out.push(`export type ${name} = Array<${tsRefAt(d.member.target, id)}>;`);
898
1011
  out.push(
899
- `export const ${name} = ${pure}S.Array(${ref(d.member.target, i)}) as any as S.Schema<${name}>;\n`,
1012
+ `export const ${name} = ${pure}S.Array(${nullable ? `S.NullOr(${item})` : item}) as any as S.Schema<${name}>;\n`,
900
1013
  );
901
1014
  } else if (d.type === "map") {
1015
+ const nullable =
1016
+ spec.nullableTrait !== undefined &&
1017
+ spec.nullableTrait in (d.value.traits ?? {});
1018
+ const value = ref(d.value.target, i);
902
1019
  out.push(
903
- `export type ${name} = { [key: string]: ${tsRefAt(d.value.target, id)} | undefined };`,
1020
+ `export type ${name} = { [key: string]: ${tsRefAt(d.value.target, id)}${nullable ? " | null" : ""} | undefined };`,
904
1021
  );
905
1022
  out.push(
906
- `export const ${name} = ${pure}S.Record(S.String, ${ref(d.value.target, i)}) as any as S.Schema<${name}>;\n`,
1023
+ `export const ${name} = ${pure}S.Record(S.String, ${nullable ? `S.NullOr(${value})` : value}) as any as S.Schema<${name}>;\n`,
907
1024
  );
908
1025
  } else if (d.type === "union") {
909
1026
  // A union arm targeting the union itself carries no information
@@ -923,9 +1040,10 @@ export const generateService = (
923
1040
  if (spec.union) {
924
1041
  out.push(...spec.union({ name, caseTargets, caseKeys, tsRef }));
925
1042
  } else if (spec.unionStyle === "opaque-cases") {
1043
+ const disc = unionDiscriminator(caseTargets);
926
1044
  out.push(
927
1045
  `export type ${name} = ${caseTargets.map((t) => tsRefAt(t, id)).join(" | ") || "unknown"};`,
928
- `export const ${name} = ${pure}S.Unknown.pipe(T.UnionCases(${JSON.stringify(caseKeys)}));\n`,
1046
+ `export const ${name} = ${pure}S.Unknown.pipe(T.UnionCases(${JSON.stringify(caseKeys)}${disc ? `, ${JSON.stringify(disc)}` : ""}));\n`,
929
1047
  );
930
1048
  } else {
931
1049
  throw new Error(
@@ -947,7 +1065,7 @@ export const generateService = (
947
1065
  const union = values.length ? values.join(" | ") : "number";
948
1066
  out.push(
949
1067
  `export type ${name} = ${union};`,
950
- `export const ${name} = ${pure}S.Number;\n`,
1068
+ `export const ${name} = S.Number;\n`,
951
1069
  );
952
1070
  }
953
1071
  });
@@ -975,14 +1093,26 @@ export const generateService = (
975
1093
  // No items path: `.items()` is a page passthrough at runtime, so an
976
1094
  // item IS a whole response.
977
1095
  if (!itemsPath) return tsRef(outputId);
978
- let def = shapes[outputId];
1096
+ let id: string = outputId;
1097
+ // Whether the path crossed a list on the way down (`"edges.node"` on a
1098
+ // Relay connection). When it did, the path itself is what fans out, so
1099
+ // the value it lands on IS one item — it needn't be a list again.
1100
+ let fannedOut = false;
979
1101
  for (const segment of itemsPath.split(".")) {
1102
+ let def: any = shapes[id];
1103
+ if (def?.type === "list") {
1104
+ id = def.member.target as string;
1105
+ def = shapes[id];
1106
+ fannedOut = true;
1107
+ }
980
1108
  if (def?.type !== "structure") return undefined;
981
1109
  const info = memberInfos(def).find((m) => m.tsName === segment);
982
1110
  if (!info) return undefined;
983
- def = shapes[info.target];
1111
+ id = info.target;
984
1112
  }
985
- return def?.type === "list" ? tsRef(def.member.target) : undefined;
1113
+ const last: any = shapes[id];
1114
+ if (last?.type === "list") return tsRef(last.member.target);
1115
+ return fannedOut ? tsRef(id) : undefined;
986
1116
  };
987
1117
 
988
1118
  const emitOperation =