@distilled.cloud/core 1.0.0-rc.8 → 1.0.0-rc.9

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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/lib/codegen/cli.d.ts +8 -6
  3. package/lib/codegen/cli.d.ts.map +1 -1
  4. package/lib/codegen/cli.js +6 -62
  5. package/lib/codegen/cli.js.map +1 -1
  6. package/lib/codegen/graphql.d.ts +6 -0
  7. package/lib/codegen/graphql.d.ts.map +1 -1
  8. package/lib/codegen/graphql.js +14 -4
  9. package/lib/codegen/graphql.js.map +1 -1
  10. package/lib/codegen/openapi-cli.d.ts +17 -3
  11. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  12. package/lib/codegen/openapi-cli.js +50 -48
  13. package/lib/codegen/openapi-cli.js.map +1 -1
  14. package/lib/codegen/openapi.d.ts +27 -1
  15. package/lib/codegen/openapi.d.ts.map +1 -1
  16. package/lib/codegen/openapi.js +81 -3
  17. package/lib/codegen/openapi.js.map +1 -1
  18. package/lib/codegen/patches.d.ts +65 -0
  19. package/lib/codegen/patches.d.ts.map +1 -0
  20. package/lib/codegen/patches.js +236 -0
  21. package/lib/codegen/patches.js.map +1 -0
  22. package/lib/codegen/patches.test.d.ts +2 -0
  23. package/lib/codegen/patches.test.d.ts.map +1 -0
  24. package/lib/codegen/patches.test.js +105 -0
  25. package/lib/codegen/patches.test.js.map +1 -0
  26. package/lib/codegen/proto.d.ts +121 -0
  27. package/lib/codegen/proto.d.ts.map +1 -0
  28. package/lib/codegen/proto.js +962 -0
  29. package/lib/codegen/proto.js.map +1 -0
  30. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  31. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  32. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  33. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  34. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  35. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  36. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  37. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  38. package/lib/codegen/spec-path.d.ts +16 -0
  39. package/lib/codegen/spec-path.d.ts.map +1 -0
  40. package/lib/codegen/spec-path.js +101 -0
  41. package/lib/codegen/spec-path.js.map +1 -0
  42. package/lib/json-patch.d.ts +18 -11
  43. package/lib/json-patch.d.ts.map +1 -1
  44. package/lib/json-patch.js +63 -25
  45. package/lib/json-patch.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/codegen/cli.ts +14 -91
  48. package/src/codegen/graphql.ts +25 -3
  49. package/src/codegen/openapi-cli.ts +75 -59
  50. package/src/codegen/openapi.ts +118 -4
  51. package/src/codegen/patches.test.ts +130 -0
  52. package/src/codegen/patches.ts +291 -0
  53. package/src/codegen/proto.ts +1128 -0
  54. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  55. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  56. package/src/codegen/spec-path.ts +115 -0
  57. package/src/json-patch.ts +82 -25
@@ -0,0 +1,291 @@
1
+ /**
2
+ * RFC-6902 patch application for convert (dev-time only).
3
+ *
4
+ * OpenAPI ops (`/paths`, `/components`, …) apply to the spec before
5
+ * conversion; Smithy ops (`/shapes`, `/metadata`, `/smithy`) apply to the
6
+ * model after conversion. `.generated-specs` is the patched model.
7
+ * `scripts/generate.ts` does not apply patches.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import {
12
+ applyOperation,
13
+ isStaleTargetError,
14
+ type JsonPatchOperation,
15
+ type PatchFile,
16
+ } from "../json-patch.ts";
17
+ import { verbNounSmithyModel } from "./rewrite-operation-ids.ts";
18
+
19
+ export type OnStalePatch = "fail" | "warn";
20
+
21
+ export interface ApplyPatchesResult {
22
+ files: number;
23
+ applied: number;
24
+ stale: number;
25
+ errors: string[];
26
+ }
27
+
28
+ /** Smithy-model JSON pointers — OpenAPI has no `/shapes` tree. */
29
+ export const isSmithyPatchPath = (pointer: string): boolean =>
30
+ pointer.startsWith("/shapes") ||
31
+ pointer.startsWith("/metadata") ||
32
+ pointer.startsWith("/smithy");
33
+
34
+ const exists = async (p: string): Promise<boolean> => {
35
+ try {
36
+ await fs.access(p);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+
43
+ /**
44
+ * RFC-6902 files in `dir`: every `*.json`, `*.manual.json` last (those
45
+ * usually target post-rename shape names). Missing dir → `[]`.
46
+ */
47
+ export const listRfc6902PatchFiles = async (dir: string): Promise<string[]> => {
48
+ if (!(await exists(dir))) return [];
49
+ return (await fs.readdir(dir))
50
+ .filter((f) => f.endsWith(".json"))
51
+ .sort(
52
+ (a, b) =>
53
+ Number(a.endsWith(".manual.json")) -
54
+ Number(b.endsWith(".manual.json")) || a.localeCompare(b),
55
+ )
56
+ .map((f) => path.join(dir, f));
57
+ };
58
+
59
+ export const applyRfc6902Files = async (
60
+ target: unknown,
61
+ files: readonly string[],
62
+ opts: {
63
+ readonly onStalePatch?: OnStalePatch;
64
+ readonly include?: (op: JsonPatchOperation) => boolean;
65
+ readonly label?: (file: string) => string;
66
+ } = {},
67
+ ): Promise<ApplyPatchesResult> => {
68
+ const onStalePatch = opts.onStalePatch ?? "fail";
69
+ const include = opts.include ?? (() => true);
70
+ const result: ApplyPatchesResult = {
71
+ files: 0,
72
+ applied: 0,
73
+ stale: 0,
74
+ errors: [],
75
+ };
76
+ for (const file of files) {
77
+ const parsed = JSON.parse(await fs.readFile(file, "utf8")) as PatchFile;
78
+ const label = opts.label?.(file) ?? path.basename(file);
79
+ result.files++;
80
+ for (const patchOp of parsed.patches ?? []) {
81
+ if (!include(patchOp)) continue;
82
+ try {
83
+ applyOperation(target, patchOp);
84
+ result.applied++;
85
+ } catch (e) {
86
+ const msg = e instanceof Error ? e.message : String(e);
87
+ const line = `${label} [${patchOp.op} ${patchOp.path}]`;
88
+ if (isStaleTargetError(e)) {
89
+ result.stale++;
90
+ if (onStalePatch === "fail") {
91
+ result.errors.push(`${line}: stale target (${msg})`);
92
+ } else {
93
+ console.warn(` ⚠️ stale: ${line}`);
94
+ }
95
+ } else {
96
+ result.errors.push(`${line}: ${msg}`);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return result;
102
+ };
103
+
104
+ /**
105
+ * An RFC-6902 `move` that renames an operation shape leaves the service's
106
+ * `operations` list pointing at the old id. Drop entries whose target no
107
+ * longer exists and append operation shapes the list is missing, keeping
108
+ * the existing order. Returns the number of services whose list changed.
109
+ */
110
+ export const syncServiceOperations = (model: {
111
+ shapes?: Record<string, any>;
112
+ }): number => {
113
+ const shapes = model.shapes ?? {};
114
+ const opIds = Object.keys(shapes).filter(
115
+ (id) => shapes[id]?.type === "operation",
116
+ );
117
+ let changed = 0;
118
+ for (const def of Object.values(shapes)) {
119
+ if (def?.type !== "service") continue;
120
+ const current: Array<{ target: string }> = def.operations ?? [];
121
+ const kept = current.filter((o) => shapes[o.target]?.type === "operation");
122
+ const listed = new Set(kept.map((o) => o.target));
123
+ const after = [
124
+ ...kept,
125
+ ...opIds.filter((id) => !listed.has(id)).map((target) => ({ target })),
126
+ ];
127
+ if (JSON.stringify(current) !== JSON.stringify(after)) {
128
+ def.operations = after;
129
+ changed++;
130
+ }
131
+ }
132
+ return changed;
133
+ };
134
+
135
+ /**
136
+ * Every `target` in a Smithy model must resolve to a shape or a prelude
137
+ * (`smithy.api#…`) id. Returns the dangling ones as `"<owner> → <target>"`.
138
+ * A convert that produces any is broken — generate would emit references
139
+ * to types that do not exist.
140
+ */
141
+ export const danglingTargets = (model: {
142
+ shapes?: Record<string, any>;
143
+ }): string[] => {
144
+ const shapes = model.shapes ?? {};
145
+ const ids = new Set(Object.keys(shapes));
146
+ const out: string[] = [];
147
+ const walk = (owner: string, node: unknown): void => {
148
+ if (Array.isArray(node)) {
149
+ for (const item of node) walk(owner, item);
150
+ return;
151
+ }
152
+ if (node === null || typeof node !== "object") return;
153
+ for (const [key, value] of Object.entries(
154
+ node as Record<string, unknown>,
155
+ )) {
156
+ if (key === "target" && typeof value === "string") {
157
+ if (!value.startsWith("smithy.") && !ids.has(value)) {
158
+ out.push(`${owner} → ${value}`);
159
+ }
160
+ } else {
161
+ walk(owner, value);
162
+ }
163
+ }
164
+ };
165
+ for (const [id, def] of Object.entries(shapes)) walk(id, def);
166
+ return out;
167
+ };
168
+
169
+ /**
170
+ * Last step of every convert: Smithy RFC-6902 patches, then verbNoun
171
+ * operation names, then an optional model transform, then a reference
172
+ * check. Writes models back so `.generated-specs` is what generate
173
+ * compiles. `outDir` may be nested (GCP `stable/` / `unstable/`).
174
+ *
175
+ * Input must be FRESHLY converted models. Smithy patches are `move`/`add`
176
+ * ops that are not idempotent, and a `transform` may not be either, so
177
+ * running this over a model that already went through it is an error —
178
+ * re-run the package's `convert` instead.
179
+ */
180
+ export const finalizeConvert = async (o: {
181
+ readonly root: string;
182
+ readonly outDir?: string;
183
+ readonly patchesDir?: string | false;
184
+ readonly exclude?: (file: string) => boolean;
185
+ readonly include?: (resource: string) => boolean;
186
+ readonly transform?: (model: any, resource: string) => string | void;
187
+ readonly operationNaming?: "as-is" | "verbNoun";
188
+ readonly onStalePatch?: OnStalePatch;
189
+ }): Promise<void> => {
190
+ const specsDir = path.resolve(o.root, o.outDir ?? ".generated-specs");
191
+ if (!(await exists(specsDir))) return;
192
+ const patchesDir =
193
+ o.patchesDir === false
194
+ ? undefined
195
+ : path.resolve(o.root, o.patchesDir ?? "patches");
196
+ const naming = o.operationNaming ?? "verbNoun";
197
+
198
+ const walk = async (dir: string): Promise<string[]> => {
199
+ const out: string[] = [];
200
+ for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
201
+ const p = path.join(dir, ent.name);
202
+ if (ent.isDirectory()) {
203
+ out.push(...(await walk(p)));
204
+ } else if (
205
+ ent.name.endsWith(".json") &&
206
+ !(o.exclude?.(ent.name) ?? false)
207
+ ) {
208
+ out.push(p);
209
+ }
210
+ }
211
+ return out;
212
+ };
213
+
214
+ const files = (await walk(specsDir)).sort((a, b) => a.localeCompare(b));
215
+ const broken: string[] = [];
216
+ for (const modelPath of files) {
217
+ const resource = path.basename(modelPath, ".json");
218
+ if (o.include && !o.include(resource)) continue;
219
+ const model = JSON.parse(await fs.readFile(modelPath, "utf8"));
220
+ if (model.metadata?.[FINALIZED_KEY]) {
221
+ throw new Error(
222
+ `${path.relative(o.root, modelPath)} was already finalized — finalizeConvert is not idempotent; re-run this package's convert from the spec instead`,
223
+ );
224
+ }
225
+ if (patchesDir) {
226
+ const patchFiles = await listRfc6902PatchFiles(
227
+ path.join(patchesDir, resource),
228
+ );
229
+ const applied = await applyRfc6902Files(model, patchFiles, {
230
+ onStalePatch: o.onStalePatch,
231
+ include: (op) => isSmithyPatchPath(op.path),
232
+ label: (f) => `${resource}/${path.basename(f)}`,
233
+ });
234
+ if (applied.errors.length) {
235
+ for (const err of applied.errors) console.error(`❌ bad patch: ${err}`);
236
+ throw new Error(
237
+ `${applied.errors.length} patch operation(s) failed for ${resource} — fix the pointers or delete the patch`,
238
+ );
239
+ }
240
+ if (applied.applied > 0) {
241
+ console.log(
242
+ ` patched ${resource}: ${applied.files} file(s), ${applied.applied} op(s)` +
243
+ (applied.stale ? `, ${applied.stale} stale` : ""),
244
+ );
245
+ }
246
+ }
247
+
248
+ if (naming === "verbNoun") {
249
+ const { renamed, collisions } = verbNounSmithyModel(model);
250
+ if (renamed > 0) {
251
+ console.log(` verbNoun ${resource}: renamed ${renamed} operation(s)`);
252
+ }
253
+ for (const c of collisions) {
254
+ console.warn(
255
+ ` ⚠️ verbNoun collision ${resource}: ${c} (kept original)`,
256
+ );
257
+ }
258
+ }
259
+
260
+ const note = o.transform?.(model, resource);
261
+ if (note) console.log(` ${note}`);
262
+
263
+ syncServiceOperations(model);
264
+
265
+ const dangling = danglingTargets(model);
266
+ if (dangling.length) {
267
+ broken.push(`${resource}: ${dangling.length} dangling target(s)`);
268
+ for (const d of dangling.slice(0, 5)) console.error(` ❌ ${d}`);
269
+ if (dangling.length > 5) {
270
+ console.error(` … ${dangling.length - 5} more`);
271
+ }
272
+ // Leave the unfinalized model on disk for inspection; never stamp
273
+ // a model that generate cannot compile.
274
+ continue;
275
+ }
276
+
277
+ model.metadata = { ...model.metadata, [FINALIZED_KEY]: true };
278
+ await fs.writeFile(modelPath, `${JSON.stringify(model, null, 2)}\n`);
279
+ }
280
+ if (broken.length) {
281
+ throw new Error(
282
+ `finalizeConvert: ${broken.length} model(s) reference shapes that do not exist:\n ${broken.join("\n ")}`,
283
+ );
284
+ }
285
+ };
286
+
287
+ /**
288
+ * Metadata marker stamped by {@link finalizeConvert}. Guards against a
289
+ * second pass over the same file (see the note on finalizeConvert).
290
+ */
291
+ export const FINALIZED_KEY = "distilled.finalized";