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

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 (132) hide show
  1. package/lib/api.d.ts +165 -0
  2. package/lib/api.d.ts.map +1 -0
  3. package/lib/api.js +178 -0
  4. package/lib/api.js.map +1 -0
  5. package/lib/codegen/cli.d.ts +29 -0
  6. package/lib/codegen/cli.d.ts.map +1 -0
  7. package/lib/codegen/cli.js +165 -0
  8. package/lib/codegen/cli.js.map +1 -0
  9. package/lib/codegen/emit.d.ts +129 -0
  10. package/lib/codegen/emit.d.ts.map +1 -0
  11. package/lib/codegen/emit.js +105 -0
  12. package/lib/codegen/emit.js.map +1 -0
  13. package/lib/codegen/format.d.ts +23 -0
  14. package/lib/codegen/format.d.ts.map +1 -0
  15. package/lib/codegen/format.js +28 -0
  16. package/lib/codegen/format.js.map +1 -0
  17. package/lib/codegen/generator.d.ts +334 -0
  18. package/lib/codegen/generator.d.ts.map +1 -0
  19. package/lib/codegen/generator.js +691 -0
  20. package/lib/codegen/generator.js.map +1 -0
  21. package/lib/codegen/graph.d.ts +36 -0
  22. package/lib/codegen/graph.d.ts.map +1 -0
  23. package/lib/codegen/graph.js +136 -0
  24. package/lib/codegen/graph.js.map +1 -0
  25. package/lib/codegen/members.d.ts +25 -0
  26. package/lib/codegen/members.d.ts.map +1 -0
  27. package/lib/codegen/members.js +55 -0
  28. package/lib/codegen/members.js.map +1 -0
  29. package/lib/codegen/naming.d.ts +29 -0
  30. package/lib/codegen/naming.d.ts.map +1 -0
  31. package/lib/codegen/naming.js +74 -0
  32. package/lib/codegen/naming.js.map +1 -0
  33. package/lib/codegen/openapi-cli.d.ts +38 -0
  34. package/lib/codegen/openapi-cli.d.ts.map +1 -0
  35. package/lib/codegen/openapi-cli.js +107 -0
  36. package/lib/codegen/openapi-cli.js.map +1 -0
  37. package/lib/codegen/openapi.d.ts +115 -0
  38. package/lib/codegen/openapi.d.ts.map +1 -0
  39. package/lib/codegen/openapi.js +1220 -0
  40. package/lib/codegen/openapi.js.map +1 -0
  41. package/lib/codegen/operations.d.ts +24 -0
  42. package/lib/codegen/operations.d.ts.map +1 -0
  43. package/lib/codegen/operations.js +56 -0
  44. package/lib/codegen/operations.js.map +1 -0
  45. package/lib/codegen/pagination.d.ts +39 -0
  46. package/lib/codegen/pagination.d.ts.map +1 -0
  47. package/lib/codegen/pagination.js +33 -0
  48. package/lib/codegen/pagination.js.map +1 -0
  49. package/lib/codegen/prelude.d.ts +15 -0
  50. package/lib/codegen/prelude.d.ts.map +1 -0
  51. package/lib/codegen/prelude.js +60 -0
  52. package/lib/codegen/prelude.js.map +1 -0
  53. package/lib/error-category.d.ts +28 -0
  54. package/lib/error-category.d.ts.map +1 -0
  55. package/lib/error-category.js +46 -0
  56. package/lib/error-category.js.map +1 -0
  57. package/lib/errors.d.ts +1 -0
  58. package/lib/errors.d.ts.map +1 -1
  59. package/lib/errors.js +1 -0
  60. package/lib/errors.js.map +1 -1
  61. package/lib/json-patch.d.ts +25 -32
  62. package/lib/json-patch.d.ts.map +1 -1
  63. package/lib/json-patch.js +23 -95
  64. package/lib/json-patch.js.map +1 -1
  65. package/lib/pagination.d.ts +37 -51
  66. package/lib/pagination.d.ts.map +1 -1
  67. package/lib/pagination.js +72 -90
  68. package/lib/pagination.js.map +1 -1
  69. package/lib/protocol-http.d.ts +74 -0
  70. package/lib/protocol-http.d.ts.map +1 -0
  71. package/lib/protocol-http.js +554 -0
  72. package/lib/protocol-http.js.map +1 -0
  73. package/lib/protocol-rest.d.ts +124 -0
  74. package/lib/protocol-rest.d.ts.map +1 -0
  75. package/lib/protocol-rest.js +242 -0
  76. package/lib/protocol-rest.js.map +1 -0
  77. package/lib/retry.d.ts +8 -2
  78. package/lib/retry.d.ts.map +1 -1
  79. package/lib/retry.js +21 -15
  80. package/lib/retry.js.map +1 -1
  81. package/lib/schema.d.ts +7 -8
  82. package/lib/schema.d.ts.map +1 -1
  83. package/lib/schema.js +7 -8
  84. package/lib/schema.js.map +1 -1
  85. package/lib/trait.d.ts +150 -0
  86. package/lib/trait.d.ts.map +1 -0
  87. package/lib/trait.js +107 -0
  88. package/lib/trait.js.map +1 -0
  89. package/package.json +18 -75
  90. package/src/api.ts +446 -0
  91. package/src/codegen/cli.ts +268 -0
  92. package/src/codegen/emit.ts +207 -0
  93. package/src/codegen/format.ts +47 -0
  94. package/src/codegen/generator.ts +1153 -0
  95. package/src/codegen/graph.ts +151 -0
  96. package/src/codegen/members.ts +71 -0
  97. package/src/codegen/naming.ts +86 -0
  98. package/src/codegen/openapi-cli.ts +166 -0
  99. package/src/codegen/openapi.ts +1450 -0
  100. package/src/codegen/operations.ts +76 -0
  101. package/src/codegen/pagination.ts +71 -0
  102. package/src/codegen/prelude.ts +70 -0
  103. package/src/error-category.ts +84 -0
  104. package/src/errors.ts +2 -0
  105. package/src/json-patch.ts +26 -110
  106. package/src/pagination.ts +86 -142
  107. package/src/protocol-http.ts +699 -0
  108. package/src/protocol-rest.ts +367 -0
  109. package/src/retry.ts +20 -21
  110. package/src/schema.ts +7 -8
  111. package/src/trait.ts +238 -0
  112. package/README.md +0 -30
  113. package/lib/client.d.ts +0 -167
  114. package/lib/client.d.ts.map +0 -1
  115. package/lib/client.js +0 -659
  116. package/lib/client.js.map +0 -1
  117. package/lib/schemas.d.ts +0 -60
  118. package/lib/schemas.d.ts.map +0 -1
  119. package/lib/schemas.js +0 -79
  120. package/lib/schemas.js.map +0 -1
  121. package/lib/sensitive.d.ts +0 -71
  122. package/lib/sensitive.d.ts.map +0 -1
  123. package/lib/sensitive.js +0 -96
  124. package/lib/sensitive.js.map +0 -1
  125. package/lib/traits.d.ts +0 -421
  126. package/lib/traits.d.ts.map +0 -1
  127. package/lib/traits.js +0 -737
  128. package/lib/traits.js.map +0 -1
  129. package/src/client.ts +0 -1177
  130. package/src/schemas.ts +0 -128
  131. package/src/sensitive.ts +0 -119
  132. package/src/traits.ts +0 -996
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The generic generator CLI harness (dev-time only).
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.
10
+ *
11
+ * A provider's `scripts/generate.ts` is: trait consts + an SdkSpec + a
12
+ * `runGeneratorCli` call.
13
+ */
14
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
15
+ import { Console, Effect } from "effect";
16
+ import * as FileSystem from "effect/FileSystem";
17
+ import * as Path from "effect/Path";
18
+ import { Flag } from "effect/unstable/cli";
19
+ import { Command } from "effect/unstable/cli";
20
+ import {
21
+ applyOperation,
22
+ isStaleTargetError,
23
+ type PatchFile,
24
+ } from "../json-patch.ts";
25
+ import { barrel } from "./emit.ts";
26
+ import { formatGenerated } from "./format.ts";
27
+ import { generateService, type SdkSpec } from "./generator.ts";
28
+
29
+ export interface GeneratorCliOptions {
30
+ /** Command description shown in --help. */
31
+ readonly description: string;
32
+ /** Absolute package root (usually `path.resolve(import.meta.dir, "..")`). */
33
+ readonly root: string;
34
+ /** Model directory default (relative to root). Default `.generated-specs`. */
35
+ readonly smithyDir?: string;
36
+ /** Output directory default (relative to root). Default `src/services`. */
37
+ readonly outDir?: string;
38
+ /** Model files to skip (e.g. a shared protocols model). */
39
+ readonly excludeModel?: (file: string) => boolean;
40
+ /** Directory of hand-authored models merged after the generated ones. */
41
+ readonly manualSpecsDir?: string;
42
+ /** RFC-6902 patch chain root. Default `patches`; `false` disables. */
43
+ readonly patchesDir?: string | false;
44
+ /**
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.
49
+ */
50
+ readonly transformModel?: (model: any, resource: string) => string | void;
51
+ /** The provider spec — built per model (metadata may vary per model). */
52
+ readonly spec: (model: any) => SdkSpec;
53
+ }
54
+
55
+ /** Run the generator CLI (BunRuntime main — call at module top level). */
56
+ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
57
+ const command = Command.make(
58
+ "generate",
59
+ {
60
+ smithy: Flag.string("smithy").pipe(
61
+ Flag.withDefault(options.smithyDir ?? ".generated-specs"),
62
+ Flag.withDescription("Directory of Smithy JSON models"),
63
+ ),
64
+ out: Flag.string("out").pipe(
65
+ Flag.withDefault(options.outDir ?? "src/services"),
66
+ Flag.withDescription("Output directory for generated service modules"),
67
+ ),
68
+ resource: Flag.string("resource").pipe(
69
+ Flag.withDefault(""),
70
+ Flag.withDescription("Only generate this resource (e.g. ai)"),
71
+ ),
72
+ },
73
+ (config) =>
74
+ Effect.gen(function* () {
75
+ const fs = yield* FileSystem.FileSystem;
76
+ const path = yield* Path.Path;
77
+ const root = options.root;
78
+ const smithyDir = path.resolve(root, config.smithy);
79
+ const outDir = path.resolve(root, config.out);
80
+
81
+ yield* Console.log("⚙️ generate");
82
+ yield* Console.log(` Smithy: ${smithyDir}`);
83
+ yield* Console.log(` Output: ${outDir}`);
84
+
85
+ // Generated models plus optional manual-specs (hand-authored models
86
+ // for APIs the provider's spec source doesn't cover). A manual model
87
+ // 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 }));
93
+ const manualDir = options.manualSpecsDir
94
+ ? path.resolve(root, options.manualSpecsDir)
95
+ : undefined;
96
+ const manual =
97
+ manualDir && (yield* fs.exists(manualDir))
98
+ ? (yield* fs.readDirectory(manualDir))
99
+ .filter((f) => f.endsWith(".json"))
100
+ .map((f) => ({ file: f, dir: manualDir }))
101
+ : [];
102
+ for (const m of manual) {
103
+ if (generated.some((g) => g.file === m.file)) {
104
+ return yield* Effect.die(
105
+ new Error(
106
+ `${options.manualSpecsDir}/${m.file} shadows a generated model — rename or delete it`,
107
+ ),
108
+ );
109
+ }
110
+ }
111
+ const entries = [...generated, ...manual].sort((a, b) =>
112
+ a.file.localeCompare(b.file),
113
+ );
114
+
115
+ yield* fs.makeDirectory(outDir, { recursive: true });
116
+
117
+ const written: string[] = [];
118
+ 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
+
142
+ for (const { file, dir } of entries) {
143
+ const resource = file.replace(/\.json$/, "");
144
+ if (config.resource && resource !== config.resource) continue;
145
+
146
+ const model = JSON.parse(
147
+ yield* fs.readFileString(path.join(dir, file)),
148
+ );
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
+ }
186
+
187
+ if (options.transformModel) {
188
+ const note = options.transformModel(model, resource);
189
+ if (note) yield* Console.log(` ${note}`);
190
+ }
191
+
192
+ const { code, operations } = generateService(
193
+ model,
194
+ options.spec(model),
195
+ );
196
+ if (operations === 0) continue;
197
+
198
+ yield* fs.writeFileString(path.join(outDir, `${resource}.ts`), code);
199
+ written.push(resource);
200
+ totalOps += operations;
201
+ }
202
+
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
+ // Barrel — namespace per resource to avoid op-name collisions.
215
+ //
216
+ // A FULL run is authoritative: the barrel is exactly what was
217
+ // generated, so a resource whose model went away drops out of it.
218
+ //
219
+ // A `--resource` run only knows about the resource it generated, so
220
+ // rewriting the barrel from that would delete every other export —
221
+ // which is how #397 shipped a cloudflare barrel exporting `workers`
222
+ // and nothing else, silently dropping 119 services. Merge into the
223
+ // existing barrel instead, so `--resource` can still ADD a brand-new
224
+ // resource without removing anything.
225
+ const barrelPath = path.join(outDir, "index.ts");
226
+ const filtered = config.resource !== "";
227
+ let resources = written;
228
+ if (filtered && (yield* fs.exists(barrelPath))) {
229
+ const existing = (yield* fs.readFileString(barrelPath))
230
+ .split("\n")
231
+ .map((line) => /^export \* as (\S+) from/.exec(line)?.[1])
232
+ .filter((name): name is string => name !== undefined);
233
+ // Ordered exactly as a full run orders it: by MODEL FILENAME, so
234
+ // the `.json` takes part in the collation (`ai_gateway.json` sorts
235
+ // before `ai.json`, where the bare names sort the other way).
236
+ // Otherwise a filtered run reshuffles the barrel into diff noise.
237
+ resources = [...new Set([...existing, ...written])].sort((a, b) =>
238
+ `${a}.json`.localeCompare(`${b}.json`),
239
+ );
240
+ }
241
+
242
+ yield* fs.writeFileString(
243
+ barrelPath,
244
+ barrel(
245
+ `// AUTO-GENERATED by scripts/generate.ts. Do not edit.\n`,
246
+ resources.map((r) => ({ name: r, path: `./${r}.ts` })),
247
+ ),
248
+ );
249
+
250
+ yield* formatGenerated(outDir);
251
+
252
+ 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
+ : "."),
257
+ );
258
+ yield* Console.log(` ${path.join(outDir, "index.ts")}`);
259
+ }),
260
+ ).pipe(Command.withDescription(options.description));
261
+
262
+ BunRuntime.runMain(
263
+ Effect.provide(
264
+ Command.run(command, { version: "1.0.0" }),
265
+ BunServices.layer,
266
+ ),
267
+ );
268
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shared emission idioms for the SDK generators (dev-time only).
3
+ *
4
+ * Both SDK generators emit the same compile-time-performance pattern
5
+ * (ported from distilled PR #360):
6
+ *
7
+ * export interface X { … } // hand-emitted type
8
+ * export const X = S.suspend(() => S.Struct({…}))
9
+ * .annotate({ identifier: "X" })
10
+ * as any as S.Schema<X>; // no inference needed
11
+ *
12
+ * plus closed-alias string-union enums, `S.TaggedErrorClass` error classes, and
13
+ * `API.make(() => ({ … }))` operation consts. The helpers here own those
14
+ * shared skeletons; providers own the content strings (member pipes, trait
15
+ * calls, config fields). Emitted output is normalized by oxfmt afterwards,
16
+ * so helpers emit canonical token streams rather than matching historical
17
+ * whitespace.
18
+ */
19
+ import { q } from "./naming.ts";
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
+ */
26
+ export const PURE = "/*@__PURE__*/ ";
27
+
28
+ /** `export interface X { … }` (or the empty-body form). */
29
+ export const interfaceDecl = (name: string, fields: string[]): string =>
30
+ fields.length
31
+ ? `export interface ${name} {\n${fields.join("\n")}\n}`
32
+ : `export interface ${name} {}`;
33
+
34
+ export interface SuspendConstOptions {
35
+ readonly name: string;
36
+ /** The schema expression inside the suspend thunk. */
37
+ readonly expr: string;
38
+ /** PURE marker(s), e.g. `"/*@__PURE__*​/ "`. Emitted verbatim before the expression. */
39
+ readonly pure?: string;
40
+ /** When set, `.annotate({ identifier: <name> })` is appended after the suspend. */
41
+ readonly annotateIdentifier?: boolean;
42
+ /** Extra annotation object source to use instead of the identifier default. */
43
+ readonly annotation?: string;
44
+ /**
45
+ * Explicit thunk return type (`(): S.Schema<X> =>`) — used for shapes in
46
+ * dependency cycles to stop circular type inference.
47
+ */
48
+ readonly thunkType?: string;
49
+ /** CF-style multiline body (`S.suspend(() =>\n<expr>,\n)`). */
50
+ readonly multiline?: boolean;
51
+ /** The cast target; defaults to `S.Schema<name>`. */
52
+ readonly castTo?: string;
53
+ }
54
+
55
+ /**
56
+ * The `export const X = S.suspend(…) … as any as S.Schema<X>;` skeleton
57
+ * shared by both generators.
58
+ */
59
+ export const suspendConst = (o: SuspendConstOptions): string => {
60
+ const cast = o.castTo ?? `S.Schema<${o.name}>`;
61
+ const thunk = o.thunkType ? `(): ${o.thunkType} =>` : `() =>`;
62
+ const suspend = o.multiline
63
+ ? `S.suspend(${thunk}\n${o.expr},\n)`
64
+ : `S.suspend(${thunk} ${o.expr})`;
65
+ const annotate = o.annotation
66
+ ? `.annotate(${o.annotation})`
67
+ : o.annotateIdentifier
68
+ ? `.annotate({ identifier: ${q(o.name)} })`
69
+ : "";
70
+ return `export const ${o.name} = ${o.pure ?? ""}${suspend}${annotate} as any as ${cast};\n`;
71
+ };
72
+
73
+ /**
74
+ * Member-level lazy reference: `S.suspend(() => X).annotate({ identifier })`.
75
+ * The `typed` form adds an explicit `S.Schema<X>` thunk return type — used
76
+ * for references into dependency cycles to stop circular type inference.
77
+ */
78
+ export const suspendRef = (name: string, typed = false): string =>
79
+ typed
80
+ ? `S.suspend((): S.Schema<${name}> => ${name}).annotate({ identifier: ${q(name)} })`
81
+ : `S.suspend(() => ${name}).annotate({ identifier: ${q(name)} })`;
82
+
83
+ export interface EnumDeclOptions {
84
+ readonly name: string;
85
+ readonly values: readonly string[];
86
+ readonly pure?: string;
87
+ /** The schema const expression; both SDKs use `S.String` (open enums). */
88
+ readonly schemaExpr?: string;
89
+ }
90
+
91
+ /**
92
+ * String-union enum ALIAS: the spec's documented values as a CLOSED literal
93
+ * union (`type X = "a" | "b"`) — response readers match documented values
94
+ * exhaustively. INPUT references re-open the alias inline
95
+ * (`X | (string & {})`) so consumers can send tomorrow's values without an
96
+ * SDK update. The schema stays `S.String` in both directions (the
97
+ * protocols never validate enum membership, so undocumented wire values
98
+ * always pass through at runtime).
99
+ */
100
+ export const enumDecl = (o: EnumDeclOptions): string[] => {
101
+ const union = o.values.length ? o.values.map(q).join(" | ") : "string";
102
+ return [
103
+ `export type ${o.name} = ${union};`,
104
+ `export const ${o.name} = ${o.pure ?? ""}${o.schemaExpr ?? "S.String"};\n`,
105
+ ];
106
+ };
107
+
108
+ export interface ErrorClassOptions {
109
+ readonly name: string;
110
+ /** The error tag; defaults to `name`. */
111
+ readonly tag?: string;
112
+ /** Field lines (` key: S.String,`). */
113
+ readonly fields: readonly string[];
114
+ /** Optional extra argument(s) after the fields object (annotations). */
115
+ readonly annotations?: string;
116
+ /** `.pipe(…)` suffix (e.g. category decorators). */
117
+ readonly pipes?: string;
118
+ /** Wrap the class expression (e.g. `T.applyErrorMatchers(<cls>, …)`). */
119
+ readonly wrap?: (cls: string) => string;
120
+ }
121
+
122
+ /**
123
+ * `export class X extends /*@__PURE__*​/ S.TaggedErrorClass<X>()("X", { … }) {}`
124
+ *
125
+ * The PURE markers are what make an unused error class droppable. A class
126
+ * whose heritage clause is an unannotated call can never be tree-shaken —
127
+ * the bundler has to assume the call has side effects — so a consumer
128
+ * importing one operation would retain every error class in the module
129
+ * (distilled #191).
130
+ *
131
+ * `wrap` needs its own marker as well as the inner one: a pure call's
132
+ * ARGUMENTS are still evaluated, so annotating only
133
+ * `T.applyErrorMatchers(S.TaggedErrorClass…(…), […])` leaves the inner call
134
+ * holding the class alive. Verified against esbuild in both directions.
135
+ */
136
+ export const errorClass = (o: ErrorClassOptions): string => {
137
+ 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 ?? ""}`;
139
+ const body = o.wrap ? `${PURE}${o.wrap(cls)}` : cls;
140
+ return `export class ${o.name} extends ${body} {}\n`;
141
+ };
142
+
143
+ export interface OperationConstOptions {
144
+ /** The exported (usually lowerFirst) operation name. */
145
+ readonly exportName: string;
146
+ /** Full type annotation (e.g. `API.OperationMethod<A, B, E, R>`). */
147
+ readonly typeAnnotation: string;
148
+ /** `API.make` or `API.makePaginated` (with namespace prefix). */
149
+ readonly factory: string;
150
+ /** The config object source (including braces). */
151
+ readonly config: string;
152
+ /** Optional extra factory argument (e.g. a pagination strategy). */
153
+ readonly extraArg?: string;
154
+ readonly pure?: string;
155
+ /**
156
+ * Widen the factory result to `any`, for annotations the factory's
157
+ * generic signature can't prove. The one case today: a paginated
158
+ * operation's `items` element type comes from the pagination trait's
159
+ * `items` PATH — a runtime string — so the factory can only infer the
160
+ * structural fallback while the annotation names the real element type.
161
+ *
162
+ * Unlike the schema consts' `as any as S.Schema<X>`, the target doesn't
163
+ * need restating here: the const carries its own annotation, which IS
164
+ * the assignment target, so a bare `as any` lands in the same place.
165
+ * Restating it would double every paginated operation's declaration —
166
+ * ~40k lines across the SDKs — for no added checking.
167
+ */
168
+ readonly castToAnnotation?: boolean;
169
+ }
170
+
171
+ /** `export const op: T = API.make(() => ({ … }));` */
172
+ export const operationConst = (o: OperationConstOptions): string =>
173
+ `export const ${o.exportName}: ${o.typeAnnotation} = ${o.pure ?? ""}${o.factory}(() => (${o.config})${
174
+ o.extraArg ? `, ${o.extraArg}` : ""
175
+ })${o.castToAnnotation ? ` as any` : ""};\n`;
176
+
177
+ import { tsKey } from "./naming.ts";
178
+
179
+ export interface InterfaceFieldOptions {
180
+ readonly name: string;
181
+ readonly type: string;
182
+ readonly optional: boolean;
183
+ readonly doc?: string;
184
+ }
185
+
186
+ /** Interface field line(s): optional doc comment + ` name?: Type;`. */
187
+ export const interfaceField = (o: InterfaceFieldOptions): string[] => [
188
+ ...(o.doc ? [` /** ${o.doc} */`] : []),
189
+ ` ${tsKey(o.name)}${o.optional ? "?" : ""}: ${o.type};`,
190
+ ];
191
+
192
+ /** `export type <Op>Error = A | B | <CommonErrors>;` */
193
+ export const errorUnionAlias = (
194
+ opName: string,
195
+ errorNames: readonly string[],
196
+ commonRef: string,
197
+ ): string =>
198
+ `export type ${opName}Error = ${[...errorNames, commonRef].join(" | ")};`;
199
+
200
+ /** Namespaced barrel: `export * as name from "./file.ts";` per entry. */
201
+ export const barrel = (
202
+ header: string,
203
+ entries: ReadonlyArray<{ name: string; path: string }>,
204
+ ): string =>
205
+ header +
206
+ entries.map((e) => `export * as ${e.name} from ${q(e.path)};`).join("\n") +
207
+ "\n";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Post-generation formatting (dev-time only).
3
+ *
4
+ * The emitters produce a canonical token stream, not formatted source, so
5
+ * every generator formats what it wrote before finishing. Without it a
6
+ * generate run leaves the whole output directory dirty against the committed
7
+ * (formatted) files, and a real regression is indistinguishable from
8
+ * whitespace in the diff.
9
+ *
10
+ * Lives here so the shared {@link runGeneratorCli} and the providers with
11
+ * their own pipelines run the identical step.
12
+ */
13
+ import { Console, Effect } from "effect";
14
+
15
+ /** Run a dev-time tool, failing the generate run if it does. */
16
+ export const runTool = (
17
+ argv: readonly string[],
18
+ ): Effect.Effect<void, never, never> =>
19
+ Effect.tryPromise({
20
+ try: () =>
21
+ Bun.spawn([...argv], { stdout: "inherit", stderr: "inherit" }).exited,
22
+ catch: (cause) => new Error(`${argv[0]} failed to start: ${cause}`),
23
+ }).pipe(
24
+ Effect.flatMap((code) =>
25
+ code === 0
26
+ ? Effect.void
27
+ : Effect.die(new Error(`${argv.join(" ")} exited with ${code}`)),
28
+ ),
29
+ Effect.catchCause((cause) => Effect.die(cause)),
30
+ );
31
+
32
+ /** Format a generated directory in place. */
33
+ export const formatGenerated = (dir: string) =>
34
+ Effect.flatMap(Console.log(`\n🧹 Formatting ${dir}`), () =>
35
+ runTool(["bunx", "oxfmt", dir]),
36
+ );
37
+
38
+ /**
39
+ * Lint-fix then format. `oxlint --fix` can leave its rewrites unformatted,
40
+ * so the formatter has to run after it, not before.
41
+ */
42
+ export const lintAndFormatGenerated = (dir: string) =>
43
+ Effect.flatMap(Console.log(`\n🧹 Linting and formatting ${dir}`), () =>
44
+ Effect.flatMap(runTool(["bunx", "oxlint", "--fix", dir]), () =>
45
+ runTool(["bunx", "oxfmt", dir]),
46
+ ),
47
+ );