@distilled.cloud/core 1.0.0-rc.2 → 1.0.0-rc.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@distilled.cloud/core",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/alchemy-run/distilled",
@@ -31,12 +31,12 @@
31
31
  "build": "tsc -b"
32
32
  },
33
33
  "devDependencies": {
34
- "@effect/platform-bun": ">=4.0.0-beta.102 || >=4.0.0",
34
+ "@effect/platform-bun": ">=4.0.0-beta.104 || >=4.0.0",
35
35
  "@types/bun": "latest",
36
36
  "@types/node": "latest",
37
- "effect": ">=4.0.0-beta.102 || >=4.0.0"
37
+ "effect": ">=4.0.0-beta.104 || >=4.0.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "effect": ">=4.0.0-beta.102 || >=4.0.0"
40
+ "effect": ">=4.0.0-beta.104 || >=4.0.0"
41
41
  }
42
42
  }
package/src/category.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * ```ts
10
10
  * import * as Category from "@distilled.cloud/core/category";
11
11
  *
12
- * export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
12
+ * export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
13
13
  * "Unauthorized",
14
14
  * { message: Schema.String },
15
15
  * ).pipe(Category.withAuthError) {}
@@ -92,7 +92,7 @@ export interface RetryableInfo {
92
92
  *
93
93
  * @example
94
94
  * ```ts
95
- * export class MyError extends Schema.TaggedErrorClass<MyError>()(
95
+ * export class MyError extends Schema.TaggedError<MyError>()(
96
96
  * "MyError",
97
97
  * { message: Schema.String },
98
98
  * ).pipe(Category.withCategory(Category.AuthError)) {}
@@ -123,13 +123,13 @@ export const withCategory =
123
123
  * @example
124
124
  * ```ts
125
125
  * // Standard retryable error
126
- * export class TransientError extends Schema.TaggedErrorClass<TransientError>()(
126
+ * export class TransientError extends Schema.TaggedError<TransientError>()(
127
127
  * "TransientError",
128
128
  * { message: Schema.String },
129
129
  * ).pipe(Category.withRetryable()) {}
130
130
  *
131
131
  * // Throttling error (uses longer backoff)
132
- * export class RateLimitError extends Schema.TaggedErrorClass<RateLimitError>()(
132
+ * export class RateLimitError extends Schema.TaggedError<RateLimitError>()(
133
133
  * "RateLimitError",
134
134
  * { message: Schema.String },
135
135
  * ).pipe(Category.withRetryable({ throttling: true })) {}
@@ -37,6 +37,52 @@ export interface GeneratorCliOptions {
37
37
  readonly outDir?: string;
38
38
  /** Model files to skip (e.g. a shared protocols model). */
39
39
  readonly excludeModel?: (file: string) => boolean;
40
+ /**
41
+ * Where the models are, when they aren't a flat directory of `<resource>.json`.
42
+ * AWS vendors Amazon's own repo, whose models sit at
43
+ * `models/<service>/service/<version>/<service>-<version>.json`.
44
+ *
45
+ * Returns the model files to compile; manual specs are merged in after,
46
+ * as usual.
47
+ */
48
+ readonly discoverModels?: (ctx: {
49
+ readonly smithyDir: string;
50
+ }) => Effect.Effect<
51
+ ReadonlyArray<{ readonly file: string; readonly dir: string }>,
52
+ never,
53
+ FileSystem.FileSystem | Path.Path
54
+ >;
55
+ /**
56
+ * Side effects to run before generating — e.g. AWS copies `partitions.json`
57
+ * out of the smithy submodule, runtime data its endpoint resolver reads.
58
+ */
59
+ readonly prepare?: (ctx: {
60
+ readonly root: string;
61
+ readonly outDir: string;
62
+ }) => Effect.Effect<void, never, FileSystem.FileSystem | Path.Path>;
63
+ /**
64
+ * The output module's name, when it isn't the model's filename. AWS names
65
+ * modules after the service's `aws.api#service` sdkId (`amazon-s3.json` →
66
+ * `s3.ts`), so the public surface reads `AWS.S3.getObject`.
67
+ */
68
+ readonly resourceName?: (ctx: {
69
+ readonly model: any;
70
+ readonly file: string;
71
+ }) => string;
72
+ /** Barrel export name for a resource. Default: the resource name itself. */
73
+ readonly barrelExportName?: (resource: string) => string;
74
+ /**
75
+ * The closing pass over the output directory. Defaults to formatting;
76
+ * AWS lint-fixes first (see `codegen/format.ts`).
77
+ */
78
+ readonly finalize?: (dir: string) => Effect.Effect<void>;
79
+ /**
80
+ * Keep generating after a model fails, instead of stopping at the first.
81
+ * AWS compiles 430 services in one run, and one broken model shouldn't
82
+ * hide the state of the other 429. The run still FAILS at the end — the
83
+ * failures are reported together rather than swallowed.
84
+ */
85
+ readonly continueOnModelError?: boolean;
40
86
  /** Directory of hand-authored models merged after the generated ones. */
41
87
  readonly manualSpecsDir?: string;
42
88
  /** RFC-6902 patch chain root. Default `patches`; `false` disables. */
@@ -82,14 +128,19 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
82
128
  yield* Console.log(` Smithy: ${smithyDir}`);
83
129
  yield* Console.log(` Output: ${outDir}`);
84
130
 
131
+ yield* options.prepare?.({ root, outDir }) ?? Effect.void;
132
+
85
133
  // Generated models plus optional manual-specs (hand-authored models
86
134
  // for APIs the provider's spec source doesn't cover). A manual model
87
135
  // 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 }));
136
+ const generated = options.discoverModels
137
+ ? yield* options.discoverModels({ smithyDir })
138
+ : (yield* fs.readDirectory(smithyDir))
139
+ .filter(
140
+ (f) =>
141
+ f.endsWith(".json") && !(options.excludeModel?.(f) ?? false),
142
+ )
143
+ .map((f) => ({ file: f, dir: smithyDir }));
93
144
  const manualDir = options.manualSpecsDir
94
145
  ? path.resolve(root, options.manualSpecsDir)
95
146
  : undefined;
@@ -115,6 +166,7 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
115
166
  yield* fs.makeDirectory(outDir, { recursive: true });
116
167
 
117
168
  const written: string[] = [];
169
+ const failedModels: string[] = [];
118
170
  let totalOps = 0;
119
171
  let totalPatches = 0;
120
172
  let staleOps = 0;
@@ -140,12 +192,15 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
140
192
  }
141
193
 
142
194
  for (const { file, dir } of entries) {
143
- const resource = file.replace(/\.json$/, "");
144
- if (config.resource && resource !== config.resource) continue;
145
-
146
195
  const model = JSON.parse(
147
196
  yield* fs.readFileString(path.join(dir, file)),
148
197
  );
198
+ // The module's name — the model's filename unless the provider
199
+ // derives it from the model itself (AWS: the service's sdkId).
200
+ const resource =
201
+ options.resourceName?.({ model, file }) ??
202
+ file.replace(/\.json$/, "");
203
+ if (config.resource && resource !== config.resource) continue;
149
204
 
150
205
  // Apply the RFC-6902 patch chain before generating. Hand-written
151
206
  // *.manual.json patches apply after the generated ones — they
@@ -189,10 +244,22 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
189
244
  if (note) yield* Console.log(` ${note}`);
190
245
  }
191
246
 
192
- const { code, operations } = generateService(
193
- model,
194
- options.spec(model),
195
- );
247
+ // `generateService` and the provider spec are synchronous and
248
+ // throw; with continueOnModelError the throw is recorded and the
249
+ // run moves on, so one broken model doesn't hide the state of
250
+ // every model after it. The run still fails at the end.
251
+ let generated;
252
+ try {
253
+ generated = generateService(model, options.spec(model));
254
+ } catch (e) {
255
+ if (!options.continueOnModelError) throw e;
256
+ failedModels.push(
257
+ `${resource}: ${e instanceof Error ? e.message : String(e)}`,
258
+ );
259
+ yield* Console.error(`❌ ${resource}`);
260
+ continue;
261
+ }
262
+ const { code, operations } = generated;
196
263
  if (operations === 0) continue;
197
264
 
198
265
  yield* fs.writeFileString(path.join(outDir, `${resource}.ts`), code);
@@ -224,11 +291,22 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
224
291
  // resource without removing anything.
225
292
  const barrelPath = path.join(outDir, "index.ts");
226
293
  const filtered = config.resource !== "";
227
- let resources = written;
294
+ // Sorted, not in generation order: a provider that discovers models
295
+ // in some other order (AWS walks Amazon's directory tree) would
296
+ // otherwise reshuffle the barrel on every run.
297
+ let resources = [...written].sort((a, b) =>
298
+ `${a}.json`.localeCompare(`${b}.json`),
299
+ );
228
300
  if (filtered && (yield* fs.exists(barrelPath))) {
301
+ // Recover the RESOURCE from each export line's path, not its name:
302
+ // the two differ when barrelExportName renames (AWS exports
303
+ // `S3` from `./s3.ts`).
229
304
  const existing = (yield* fs.readFileString(barrelPath))
230
305
  .split("\n")
231
- .map((line) => /^export \* as (\S+) from/.exec(line)?.[1])
306
+ .map(
307
+ (line) =>
308
+ /^export \* as \S+ from "\.\/(.+)\.ts";/.exec(line)?.[1],
309
+ )
232
310
  .filter((name): name is string => name !== undefined);
233
311
  // Ordered exactly as a full run orders it: by MODEL FILENAME, so
234
312
  // the `.json` takes part in the collation (`ai_gateway.json` sorts
@@ -243,11 +321,14 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
243
321
  barrelPath,
244
322
  barrel(
245
323
  `// AUTO-GENERATED by scripts/generate.ts. Do not edit.\n`,
246
- resources.map((r) => ({ name: r, path: `./${r}.ts` })),
324
+ resources.map((r) => ({
325
+ name: options.barrelExportName?.(r) ?? r,
326
+ path: `./${r}.ts`,
327
+ })),
247
328
  ),
248
329
  );
249
330
 
250
- yield* formatGenerated(outDir);
331
+ yield* (options.finalize ?? formatGenerated)(outDir);
251
332
 
252
333
  yield* Console.log(
253
334
  `\n✅ Generated ${totalOps} operations across ${written.length} resource modules` +
@@ -256,6 +337,19 @@ export const runGeneratorCli = (options: GeneratorCliOptions): void => {
256
337
  : "."),
257
338
  );
258
339
  yield* Console.log(` ${path.join(outDir, "index.ts")}`);
340
+
341
+ // Models that failed under continueOnModelError. Reported together,
342
+ // at the end, and the run FAILS — a generate that couldn't produce
343
+ // a module must not exit 0 with the failure buried in the log.
344
+ if (failedModels.length) {
345
+ yield* Console.error(
346
+ `\n❌ ${failedModels.length} model(s) failed to generate:`,
347
+ );
348
+ for (const f of failedModels) yield* Console.error(` ${f}`);
349
+ return yield* Effect.die(
350
+ new Error(`${failedModels.length} model(s) failed to generate`),
351
+ );
352
+ }
259
353
  }),
260
354
  ).pipe(Command.withDescription(options.description));
261
355
 
@@ -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,
@@ -120,7 +120,7 @@ export interface ErrorClassOptions {
120
120
  }
121
121
 
122
122
  /**
123
- * `export class X extends /*@__PURE__*​/ S.TaggedErrorClass<X>()("X", { … }) {}`
123
+ * `export class X extends /*@__PURE__*​/ S.TaggedError<X>()("X", { … }) {}`
124
124
  *
125
125
  * The PURE markers are what make an unused error class droppable. A class
126
126
  * whose heritage clause is an unannotated call can never be tree-shaken —
@@ -130,12 +130,12 @@ export interface ErrorClassOptions {
130
130
  *
131
131
  * `wrap` needs its own marker as well as the inner one: a pure call's
132
132
  * ARGUMENTS are still evaluated, so annotating only
133
- * `T.applyErrorMatchers(S.TaggedErrorClass…(…), […])` leaves the inner call
133
+ * `T.applyErrorMatchers(S.TaggedError…(…), […])` leaves the inner call
134
134
  * holding the class alive. Verified against esbuild in both directions.
135
135
  */
136
136
  export const errorClass = (o: ErrorClassOptions): string => {
137
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 ?? ""}`;
138
+ const cls = `${PURE}S.TaggedError<${o.name}>()(${q(o.tag ?? o.name)}, {\n${o.fields.join("\n")}\n}${annotations})${o.pipes ?? ""}`;
139
139
  const body = o.wrap ? `${PURE}${o.wrap(cls)}` : cls;
140
140
  return `export class ${o.name} extends ${body} {}\n`;
141
141
  };
@@ -487,8 +487,27 @@ export const generateService = (
487
487
  const order = topoOrder(shapes, reachable, shapeDeps);
488
488
  const indexOf = orderIndex(order);
489
489
 
490
- const ref = makeSchemaRef(prelude, indexOf);
491
- const tsRef = makeTsRef(tsPrelude);
490
+ /**
491
+ * Shape id → the id whose emitted body it shares.
492
+ *
493
+ * Structural aliasing (see the struct emission below) has to cascade: a
494
+ * parent only matches another parent if their MEMBERS render identically,
495
+ * and a member renders as its target's name. So every reference resolves
496
+ * through this map first — once `…ItemGroupRuleGroup` is aliased onto a
497
+ * canonical shape, the parents referencing it start rendering the same
498
+ * text and collapse in turn.
499
+ *
500
+ * Filled in topological order, so a canonical target is always a shape
501
+ * that was already emitted — a back-reference, never a forward one.
502
+ */
503
+ const canonicalId = new Map<string, string>();
504
+ const canon = (target: string): string => canonicalId.get(target) ?? target;
505
+
506
+ const rawRef = makeSchemaRef(prelude, indexOf);
507
+ const rawTsRef = makeTsRef(tsPrelude);
508
+ const ref = (target: string, selfIdx: number) =>
509
+ rawRef(canon(target), selfIdx);
510
+ const tsRef = (target: string) => rawTsRef(canon(target));
492
511
 
493
512
  // Direction classification for enum openness. Enum ALIASES are emitted
494
513
  // CLOSED (exhaustively matchable on reads); request-reachable shapes
@@ -529,7 +548,8 @@ export const generateService = (
529
548
  * pure response/error shapes stay the plain closed alias, and so do
530
549
  * union-arm discriminant literals.
531
550
  */
532
- const tsRefAt = (target: string, ownerId: string): string => {
551
+ const tsRefAt = (rawTarget: string, ownerId: string): string => {
552
+ const target = canon(rawTarget);
533
553
  const base = tsRef(target);
534
554
  if (!requestReachable.has(ownerId)) return base;
535
555
  if (discriminantEnums.has(target)) return base;
@@ -731,6 +751,13 @@ export const generateService = (
731
751
 
732
752
  // 4. Error classes from the operations' errors lists.
733
753
  const out: string[] = [];
754
+ // Emitted struct body → the name that owns it, for structural aliasing.
755
+ // Keyed on the emitted TEXT, so two shapes collapse only when what they
756
+ // would emit is byte-identical — including member names, targets, docs and
757
+ // pipes. References are already resolved to names at this point, so a
758
+ // difference anywhere in the tree shows up as different text.
759
+ const structBodies = new Map<string, { id: string; name: string }>();
760
+ let aliased = 0;
734
761
  // Set when any error carries CATEGORY_TRAIT, so the header only imports
735
762
  // the category module when something actually uses it.
736
763
  let usesCategories = false;
@@ -866,7 +893,6 @@ export const generateService = (
866
893
  fields.push(...inject.interfaceLines);
867
894
  members.push(inject.structLine);
868
895
  }
869
- out.push(interfaceDecl(name, fields));
870
896
  const struct = members.length
871
897
  ? `S.Struct({\n${members.join("\n")}\n})`
872
898
  : `S.Struct({})`;
@@ -884,15 +910,44 @@ export const generateService = (
884
910
  : []),
885
911
  ];
886
912
  const tail = pipes.map((p) => `.pipe(${p})`).join("");
887
- out.push(
888
- suspendConst({
889
- name,
890
- pure,
891
- multiline: true,
892
- annotateIdentifier: true,
893
- expr: `${struct}${tail}`,
894
- }),
895
- );
913
+
914
+ // Shapes whose emitted body is byte-identical are one shape wearing
915
+ // many names. The docs pipelines generate a fresh copy of every nested
916
+ // shape per operation, so cloudflare's zero_trust carries 374 copies of
917
+ // `{ group?: unknown }` — one per operation x application type x
918
+ // include/exclude/require — and 72% of its 16,800 structures are
919
+ // redundant that way.
920
+ //
921
+ // Emit the body once and alias the rest to it. The public name is kept,
922
+ // so nothing about the surface changes; only the duplicate bodies go.
923
+ // Operation I/O is excluded: those carry the operation's Http trait and
924
+ // must never be merged onto one another.
925
+ const bodyKey = `${JSON.stringify(fields)}|${struct}${tail}`;
926
+ const canonical = structCtx.isOpIo
927
+ ? undefined
928
+ : structBodies.get(bodyKey);
929
+ if (canonical !== undefined) {
930
+ out.push(`export type ${name} = ${canonical.name};`);
931
+ out.push(`export const ${name} = ${canonical.name};\n`);
932
+ // Later shapes referencing this one now render the canonical name,
933
+ // which is what lets their bodies collapse too.
934
+ canonicalId.set(id, canonical.id);
935
+ aliased++;
936
+ } else {
937
+ if (!structCtx.isOpIo) {
938
+ structBodies.set(bodyKey, { id, name });
939
+ }
940
+ out.push(interfaceDecl(name, fields));
941
+ out.push(
942
+ suspendConst({
943
+ name,
944
+ pure,
945
+ multiline: true,
946
+ annotateIdentifier: true,
947
+ expr: `${struct}${tail}`,
948
+ }),
949
+ );
950
+ }
896
951
  } else if (d.type === "list") {
897
952
  out.push(`export type ${name} = Array<${tsRefAt(d.member.target, id)}>;`);
898
953
  out.push(
@@ -975,14 +1030,26 @@ export const generateService = (
975
1030
  // No items path: `.items()` is a page passthrough at runtime, so an
976
1031
  // item IS a whole response.
977
1032
  if (!itemsPath) return tsRef(outputId);
978
- let def = shapes[outputId];
1033
+ let id: string = outputId;
1034
+ // Whether the path crossed a list on the way down (`"edges.node"` on a
1035
+ // Relay connection). When it did, the path itself is what fans out, so
1036
+ // the value it lands on IS one item — it needn't be a list again.
1037
+ let fannedOut = false;
979
1038
  for (const segment of itemsPath.split(".")) {
1039
+ let def: any = shapes[id];
1040
+ if (def?.type === "list") {
1041
+ id = def.member.target as string;
1042
+ def = shapes[id];
1043
+ fannedOut = true;
1044
+ }
980
1045
  if (def?.type !== "structure") return undefined;
981
1046
  const info = memberInfos(def).find((m) => m.tsName === segment);
982
1047
  if (!info) return undefined;
983
- def = shapes[info.target];
1048
+ id = info.target;
984
1049
  }
985
- return def?.type === "list" ? tsRef(def.member.target) : undefined;
1050
+ const last: any = shapes[id];
1051
+ if (last?.type === "list") return tsRef(last.member.target);
1052
+ return fannedOut ? tsRef(id) : undefined;
986
1053
  };
987
1054
 
988
1055
  const emitOperation =