From ee943886649165a953a07fd5cab38c9e336b95bc Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Thu, 27 Aug 2026 01:15:31 +1000 Subject: [PATCH 1/5] Add benchmark --- .gitignore | 4 + .../benchmark/README.md | 81 ++++++ .../benchmark/codegen.ts | 24 ++ .../benchmark/generateSchema.ts | 216 +++++++++++++++ .../benchmark/run.ts | 261 ++++++++++++++++++ .../benchmark/tsconfig.json | 12 + .../typescript-resolver-files/project.json | 10 +- 7 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 packages/typescript-resolver-files/benchmark/README.md create mode 100644 packages/typescript-resolver-files/benchmark/codegen.ts create mode 100644 packages/typescript-resolver-files/benchmark/generateSchema.ts create mode 100644 packages/typescript-resolver-files/benchmark/run.ts create mode 100644 packages/typescript-resolver-files/benchmark/tsconfig.json diff --git a/.gitignore b/.gitignore index 4881ca0c..1d4df268 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ dist tmp out-tsc +# benchmark harness (generated workload + codegen profiler traces) +packages/typescript-resolver-files/benchmark/.workload +codegen-*.json + # dependencies node_modules diff --git a/packages/typescript-resolver-files/benchmark/README.md b/packages/typescript-resolver-files/benchmark/README.md new file mode 100644 index 00000000..197a7bc2 --- /dev/null +++ b/packages/typescript-resolver-files/benchmark/README.md @@ -0,0 +1,81 @@ +# Benchmark harness — `typescript-resolver-files` preset + +Repeatable performance harness for measuring the preset's run time on a +realistically large schema. Dev-only; nothing here ships in the package. + +## Why + +The largest e2e fixture is ~155 lines, too small to measure anything. This +harness generates a synthetic `mode: 'modules'` schema (with diverging mappers, +so the type-checker path actually does work) and runs codegen against it, +capturing per-phase timings from graphql-codegen's own profiler — the preset +already wraps every phase in `profiler.run(fn, name)`. + +## Usage + +From the repo root: + +```bash +pnpm nx benchmark typescript-resolver-files --preset=large --iterations=5 +``` + +Both flags are optional (default `--preset=large --iterations=5`). The runner +generates the workload itself — `generateSchema.ts` is a utility module, not a +runnable script. + +Presets (in `generateSchema.ts`): `small` (~20 types), `medium` (~100), +`large` (~200), `xlarge` (~400). **`xlarge` can exhaust the Node heap** — see +"Known limits" below. + +### What it measures + +Each iteration runs in a fresh child process and does codegen **twice**: + +- **COLD** — nothing generated yet (true first run / CI). +- **WARM** — output already on disk and the preset's module-level ts-morph + `Project` singleton reused (i.e. a `--watch` re-run). + +The runner prints a per-phase median/min/max table for both, plus two totals: +`preset phases subtotal` (work inside the preset) and `total generate() wall` +(the whole pipeline, including downstream plugin rendering + file writes). The +gap between them is downstream codegen cost. + +## Baseline findings (200-type `large`, this machine — indicative, not absolute) + +The headline is that **cold-start is dominated by downstream codegen, not the +preset itself**: + +| span | cold ms | notes | +| ------------------------------------------------------ | ------- | -------------------------------------------- | +| total `generate()` wall | ~4400 | | +| └ downstream `Codegen:` render | ~3900 | **~89% of cold time** | +|   • resolver-file `add` outputs (~200) | ~2600 | per-output codegen-core overhead × N outputs | +|   • types-file render (typescript-resolvers) | ~1300 | same content phase 6 builds in ~55ms raw | +| └ preset `Build Generates Section` | ~420 | phase 7 ~190, generateResolverFiles ~130 | + +The downstream cost is **superlinear (~O(types²))**: the `Codegen:` span is +53ms / 956ms / 3739ms at 20 / 100 / 200 types (20→200 types = 10×, time = 70×). +The number of returned outputs grows ~linearly with types, and each output pays +a codegen-core cost that itself scales with schema size. + +WARM re-runs are ~10× faster than cold (~390ms), thanks to the ts-morph +singleton and the "skip unchanged files" filter; there the preset's own phases +(`generateResolverFiles`/`postProcessFiles` ~110ms, phase 7 ~100ms) dominate. + +## Known limits + +- **`xlarge` (~400 types) can OOM** the default V8 heap during the phase-7 + type-checker path (`getGraphQLObjectTypeResolversToGenerate`, whose + assignability checks against `Maybe` force the checker to + instantiate the whole `ResolversTypes` map — ~O(types²) in memory). The runner + gives child processes `--max-old-space-size=4096`; larger schemas need more, + and it does not always help. This memory behaviour is itself a finding. +- Numbers are machine-relative — use them for **before/after deltas**, not + absolute claims. + +## Files + +- `generateSchema.ts` — synthetic workload generator (+ presets). +- `codegen.ts` — codegen config pointing at the generated `.workload/`. +- `run.ts` — orchestrator + single-run measurement. +- `.workload/` — generated, gitignored. diff --git a/packages/typescript-resolver-files/benchmark/codegen.ts b/packages/typescript-resolver-files/benchmark/codegen.ts new file mode 100644 index 00000000..4458da52 --- /dev/null +++ b/packages/typescript-resolver-files/benchmark/codegen.ts @@ -0,0 +1,24 @@ +import * as path from 'path'; +import type { CodegenConfig } from '@graphql-codegen/cli'; +import { defineConfig } from '../src/index.js'; + +const workloadDir = path.join(import.meta.dirname, '.workload'); +const modulesDir = path.join(workloadDir, 'modules'); + +// The preset resolves `tsConfigFilePath` as `path.join(process.cwd(), value)`, +// so it must be given relative to the cwd (repo root, when run via the runner). +const tsConfigFilePath = path.relative( + process.cwd(), + path.join(workloadDir, 'tsconfig.json') +); + +const config: CodegenConfig = { + schema: [path.join(modulesDir, '**/*.graphqls')], + generates: { + [modulesDir]: defineConfig({ + tsConfigFilePath, + }), + }, +}; + +export default config; diff --git a/packages/typescript-resolver-files/benchmark/generateSchema.ts b/packages/typescript-resolver-files/benchmark/generateSchema.ts new file mode 100644 index 00000000..110b6d7e --- /dev/null +++ b/packages/typescript-resolver-files/benchmark/generateSchema.ts @@ -0,0 +1,216 @@ +/** + * Synthetic workload generator for the benchmark harness. + * + * Produces a realistically large `mode: 'modules'` schema plus diverging mapper + * files, modelled on `packages/typescript-resolver-files-e2e/src/test-modules`. + * The mappers deliberately diverge from the schema so that the type-checker path + * in `getGraphQLObjectTypeResolversToGenerate` (phase 7) and `postProcessFiles` + * actually does work — that is where the run-time cost lives. + * + * Output goes to a gitignored `.workload/` dir. Nothing here is committed. + * + * Utility module only — imported by `run.ts`; not runnable on its own. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +interface WorkloadPreset { + modules: number; + typesPerModule: number; + fieldsPerType: number; + /** Fraction (0..1) of object types that get a diverging mapper file. */ + mapperRatio: number; +} + +export const workloadPresets: Record = { + // ~ existing e2e fixtures (~20 types) + small: { modules: 4, typesPerModule: 5, fieldsPerType: 6, mapperRatio: 0.5 }, + // ~100 types + medium: { + modules: 10, + typesPerModule: 10, + fieldsPerType: 8, + mapperRatio: 0.5, + }, + // ~200 types — a realistically large service schema + large: { + modules: 20, + typesPerModule: 10, + fieldsPerType: 8, + mapperRatio: 0.5, + }, + // ~400 types — stress; may exhaust the default heap (see README) + xlarge: { + modules: 40, + typesPerModule: 10, + fieldsPerType: 10, + mapperRatio: 0.5, + }, +}; + +const scalarFieldTypes = ['String', 'Int', 'Boolean', 'Float', 'DateTime']; + +const typeName = (mod: number, t: number): string => `M${mod}T${t}`; + +/** Generate the `.graphqls` body for a single module. */ +const moduleSchema = (mod: number, preset: WorkloadPreset): string => { + const { typesPerModule, fieldsPerType } = preset; + const lines: string[] = []; + + // Root query fields contributed by this module. + lines.push('extend type Query {'); + for (let t = 0; t < typesPerModule; t++) { + lines.push(` m${mod}t${t}(id: ID!): ${typeName(mod, t)}`); + } + lines.push('}'); + lines.push(''); + + for (let t = 0; t < typesPerModule; t++) { + lines.push(`type ${typeName(mod, t)} {`); + lines.push(' id: ID!'); + // scalar fields + for (let f = 0; f < fieldsPerType; f++) { + const scalar = scalarFieldTypes[f % scalarFieldTypes.length]; + lines.push(` field${f}: ${scalar}!`); + } + // relation field to another type in the same module (drives resolver gen + // when the mapper omits/diverges it) + const relTarget = typeName(mod, (t + 1) % typesPerModule); + lines.push(` relation: ${relTarget}!`); + lines.push(` relationList: [${relTarget}!]!`); + lines.push('}'); + lines.push(''); + } + + return lines.join('\n'); +}; + +/** + * Generate a diverging mapper for a type: keeps `id` + scalar fields (compatible), + * replaces the relation with a plain id string (missing -> resolver required), + * and makes one scalar field the wrong type (incompatible -> resolver required). + */ +const typeMapper = (mod: number, t: number, preset: WorkloadPreset): string => { + const name = typeName(mod, t); + const lines: string[] = []; + lines.push(`export interface ${name}Mapper {`); + lines.push(' id: string;'); + for (let f = 0; f < preset.fieldsPerType; f++) { + const scalar = scalarFieldTypes[f % scalarFieldTypes.length]; + // Make field0 deliberately the wrong type to exercise the "incompatible" + // assignability branch; keep the rest compatible. + if (f === 0) { + lines.push(` field${f}: { nested: string };`); + continue; + } + const tsType = + scalar === 'Int' || scalar === 'Float' + ? 'number' + : scalar === 'Boolean' + ? 'boolean' + : 'string'; + lines.push(` field${f}: ${tsType};`); + } + // relation replaced by an id string; the object-typed `relation`/`relationList` + // fields are absent -> resolvers must be generated. + lines.push(' relationId: string;'); + lines.push('}'); + return lines.join('\n'); +}; + +const baseSchema = (): string => + [ + 'type Query', + 'type Mutation', + 'type Subscription', + '', + 'scalar DateTime', + '', + ].join('\n'); + +const workloadTsConfig = (): string => + JSON.stringify( + { + compilerOptions: { + target: 'es2022', + lib: ['es2022'], + module: 'nodenext', + moduleResolution: 'nodenext', + strict: true, + skipLibCheck: true, + noEmit: true, + types: [], + }, + include: ['modules/**/*.ts'], + }, + null, + 2 + ); + +interface GeneratedWorkload { + workloadDir: string; + modulesDir: string; + tsConfigPath: string; + stats: { modules: number; types: number; mappers: number }; +} + +export const generateWorkload = ({ + preset, + workloadDir, +}: { + preset: WorkloadPreset; + workloadDir: string; +}): GeneratedWorkload => { + const modulesDir = path.join(workloadDir, 'modules'); + // Fresh output every time so cold-start numbers are honest. + fs.rmSync(workloadDir, { recursive: true, force: true }); + fs.mkdirSync(modulesDir, { recursive: true }); + + // base module + const baseDir = path.join(modulesDir, 'base'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'base.graphqls'), baseSchema()); + + let typeCount = 0; + let mapperCount = 0; + const mapperEvery = + preset.mapperRatio > 0 ? Math.round(1 / preset.mapperRatio) : 0; + + for (let m = 0; m < preset.modules; m++) { + const modDir = path.join(modulesDir, `mod${m}`); + fs.mkdirSync(modDir, { recursive: true }); + fs.writeFileSync( + path.join(modDir, `mod${m}.graphqls`), + moduleSchema(m, preset) + ); + + const mapperBodies: string[] = []; + for (let t = 0; t < preset.typesPerModule; t++) { + typeCount++; + if (mapperEvery > 0 && typeCount % mapperEvery === 0) { + mapperBodies.push(typeMapper(m, t, preset)); + mapperCount++; + } + } + if (mapperBodies.length > 0) { + // parseTypeMappers expects `.mappers.ts` next to the schema. + fs.writeFileSync( + path.join(modDir, `mod${m}.mappers.ts`), + mapperBodies.join('\n\n') + '\n' + ); + } + } + + const tsConfigPath = path.join(workloadDir, 'tsconfig.json'); + fs.writeFileSync(tsConfigPath, workloadTsConfig()); + + return { + workloadDir, + modulesDir, + tsConfigPath, + stats: { modules: preset.modules, types: typeCount, mappers: mapperCount }, + }; +}; + +export const defaultWorkloadDir = (): string => + path.join(import.meta.dirname, '.workload'); diff --git a/packages/typescript-resolver-files/benchmark/run.ts b/packages/typescript-resolver-files/benchmark/run.ts new file mode 100644 index 00000000..078e7ca7 --- /dev/null +++ b/packages/typescript-resolver-files/benchmark/run.ts @@ -0,0 +1,261 @@ +/** + * Benchmark runner for the `typescript-resolver-files` preset. + * + * Two layers: + * - `__single__`: in ONE fresh process, generate a workload, then run codegen + * twice — COLD (nothing generated yet) and WARM (output on disk + the preset's + * module-level ts-morph Project singleton reused, i.e. a watch re-run). Emits + * one JSON line of per-phase timings for both. + * - orchestrator (default): spawns `__single__` N times (each a genuine cold + * process), aggregates median/min/max per phase and total, prints a table. + * + * Per-phase timings come from graphql-codegen's own profiler: the preset already + * wraps every phase in `profiler.run(fn, name)`. We inject our OWN recording + * profiler onto the context and read its events back. + * + * We deliberately do not use `context.useProfiler()` / the built-in + * `createProfiler()`: in codegen CLI >= 7.3.x `generate()` flushes a profiler + * that has an `outputName` to a trace file and then calls `profiler.clear()`, + * which would both litter a `codegen-*.json` and wipe events before we read + * them. Our profiler reports `outputName: null`, so that flush/clear no-ops. + */ +import { spawnSync } from 'child_process'; +import { performance } from 'perf_hooks'; +import { CodegenContext, generate } from '@graphql-codegen/cli'; +import config from './codegen.js'; +import { + workloadPresets, + generateWorkload, + defaultWorkloadDir, +} from './generateSchema.js'; + +/** Minimal profiler matching the `context.profiler` shape used across CLI versions. */ +interface RecordingProfiler { + outputName: null; + run(fn: () => T | Promise, name: string): Promise; + collect(): { name: string; dur: number }[]; + clear(): void; +} + +const createRecordingProfiler = (): RecordingProfiler => { + const events: { name: string; dur: number }[] = []; + return { + outputName: null, + async run(fn, name) { + const start = performance.now(); + const value = await fn(); + // dur in microseconds, matching graphql-codegen's ProfilerEvent. + events.push({ name, dur: (performance.now() - start) * 1000 }); + return value; + }, + collect: () => events, + clear: () => { + events.length = 0; + }, + }; +}; + +const PRESET_PREFIX = '[@eddeee888/gcg-typescript-resolver-files]:'; +const RESULT_SENTINEL = '__BENCH_RESULT__'; + +interface RunTimings { + wallMs: number; + phases: Record; // phase name -> ms +} +interface SingleResult { + preset: string; + stats: { modules: number; types: number; mappers: number }; + cold: RunTimings; + warm: RunTimings; +} + +/** + * Run codegen once against the current on-disk state, returning the wall time + * and per-phase timings. Cold vs warm is decided by the caller (i.e. by whether + * output already exists), not here. + */ +const runProfiledGenerate = async (): Promise => { + const context = new CodegenContext({ config: { ...config, silent: true } }); + // Inject our recording profiler (see file header for why not `useProfiler()`). + context.profiler = + createRecordingProfiler() as unknown as typeof context.profiler; + + const start = performance.now(); + await generate(context, true); + const wallMs = performance.now() - start; + + const phases: Record = {}; + for (const event of context.profiler.collect()) { + if (event.name?.startsWith(PRESET_PREFIX)) { + const phase = event.name.slice(PRESET_PREFIX.length).trim(); + // dur is microseconds; sum in case a phase is entered more than once. + phases[phase] = (phases[phase] ?? 0) + event.dur / 1000; + } + } + return { wallMs, phases }; +}; + +/** + * Produce one data point for a preset: (re)generate the workload fresh, then + * measure a COLD run (nothing generated yet) immediately followed by a WARM run + * (output on disk + the preset's ts-morph Project singleton reused, i.e. a watch + * re-run). Runs in its own process so COLD is genuinely cold. + */ +const run = async (presetName: string): Promise => { + const preset = workloadPresets[presetName]; + if (!preset) { + throw new Error( + `Unknown preset "${presetName}". Available: ${Object.keys( + workloadPresets + ).join(', ')}` + ); + } + const { stats } = generateWorkload({ + preset, + workloadDir: defaultWorkloadDir(), + }); + + const cold = await runProfiledGenerate(); // nothing on disk -> cold + const warm = await runProfiledGenerate(); // output exists + singleton reused -> warm + + return { preset: presetName, stats, cold, warm }; +}; + +// ---------- orchestrator ---------- + +const median = (xs: number[]): number => { + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +}; +const fmt = (n: number): string => n.toFixed(1).padStart(8); + +/** + * Ordered union of phase names seen across `runs`. Derived from the data (not a + * hardcoded list) so it always matches whatever phases the preset currently + * emits. Each run's `phases` keys are already in execution order — the profiler + * records the preset's sequential phases in completion order — so preserving + * first-seen order across runs keeps the table in preset order. + */ +const collectPhaseOrder = (runs: RunTimings[]): string[] => { + const order: string[] = []; + const seen = new Set(); + for (const r of runs) { + for (const phase of Object.keys(r.phases)) { + if (!seen.has(phase)) { + seen.add(phase); + order.push(phase); + } + } + } + return order; +}; + +const printTable = (label: string, runs: RunTimings[]): void => { + const phaseOrder = collectPhaseOrder(runs); + console.log(`\n=== ${label} (n=${runs.length}) ===`); + console.log( + `${'phase'.padEnd(42)}${'median'.padStart(8)}${'min'.padStart( + 8 + )}${'max'.padStart(8)} (ms)` + ); + for (const phase of phaseOrder) { + const vals = runs.map((r) => r.phases[phase] ?? 0); + console.log( + `${phase.padEnd(42)}${fmt(median(vals))}${fmt(Math.min(...vals))}${fmt( + Math.max(...vals) + )}` + ); + } + const phaseSum = runs.map((r) => + phaseOrder.reduce((acc, p) => acc + (r.phases[p] ?? 0), 0) + ); + const wall = runs.map((r) => r.wallMs); + console.log( + `${'— preset phases subtotal'.padEnd(42)}${fmt(median(phaseSum))}${fmt( + Math.min(...phaseSum) + )}${fmt(Math.max(...phaseSum))}` + ); + console.log( + `${'— total generate() wall'.padEnd(42)}${fmt(median(wall))}${fmt( + Math.min(...wall) + )}${fmt(Math.max(...wall))}` + ); +}; + +const orchestrate = async ( + presetName: string, + iterations: number +): Promise => { + const results: SingleResult[] = []; + console.log( + `Benchmarking preset "${presetName}" x${iterations} cold processes...` + ); + for (let i = 0; i < iterations; i++) { + const child = spawnSync( + process.execPath, + ['--import', 'tsx', import.meta.filename, '__single__', presetName], + { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: { + ...process.env, + // The type-checker path is memory-hungry on large schemas; give each + // cold process headroom. `xlarge` may still exhaust this (see README). + NODE_OPTIONS: + '--conditions=@workspace/source --max-old-space-size=4096', + }, + } + ); + if (child.status !== 0) { + console.error(child.stdout); + console.error(child.stderr); + throw new Error(`Iteration ${i + 1} failed`); + } + const line = child.stdout + .split('\n') + .find((l) => l.startsWith(RESULT_SENTINEL)); + if (!line) { + console.error(child.stdout); + throw new Error(`Iteration ${i + 1}: no result line`); + } + results.push(JSON.parse(line.slice(RESULT_SENTINEL.length))); + process.stdout.write('.'); + } + console.log(''); + + const stats = results[0].stats; + console.log( + `\nWorkload: ${stats.modules} modules, ${stats.types} object types, ${stats.mappers} mappers` + ); + + printTable( + 'COLD (first run)', + results.map((r) => r.cold) + ); + printTable( + 'WARM (watch re-run)', + results.map((r) => r.warm) + ); +}; + +// ---------- entry ---------- + +const main = async (): Promise => { + const [arg0, arg1] = process.argv.slice(2); + if (arg0 === '__single__') { + // `||` (not `??`): Nx interpolates absent `{args.*}` to an empty string. + const result = await run(arg1 || 'large'); + console.log(RESULT_SENTINEL + JSON.stringify(result)); + return; + } + const presetName = arg0 || 'large'; + const iterations = Number(arg1 || '5'); + await orchestrate(presetName, iterations); +}; + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/typescript-resolver-files/benchmark/tsconfig.json b/packages/typescript-resolver-files/benchmark/tsconfig.json new file mode 100644 index 00000000..153ae0e9 --- /dev/null +++ b/packages/typescript-resolver-files/benchmark/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["*.ts"], + "exclude": [".workload"] +} diff --git a/packages/typescript-resolver-files/project.json b/packages/typescript-resolver-files/project.json index bb25666b..038e38e6 100644 --- a/packages/typescript-resolver-files/project.json +++ b/packages/typescript-resolver-files/project.json @@ -4,5 +4,13 @@ "sourceRoot": "packages/typescript-resolver-files/src", "projectType": "library", "tags": [], - "targets": {} + "targets": { + "benchmark": { + "executor": "nx:run-commands", + "options": { + "cwd": "{workspaceRoot}", + "command": "tsx --conditions=@workspace/source packages/typescript-resolver-files/benchmark/run.ts {args.preset} {args.iterations}" + } + } + } } From 95b24988eae178685e9135ef06e8d37478e5bc08 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Thu, 27 Aug 2026 07:58:54 +1000 Subject: [PATCH 2/5] Use minimal in benchmarking --- packages/typescript-resolver-files/benchmark/codegen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/typescript-resolver-files/benchmark/codegen.ts b/packages/typescript-resolver-files/benchmark/codegen.ts index 4458da52..e0c01f96 100644 --- a/packages/typescript-resolver-files/benchmark/codegen.ts +++ b/packages/typescript-resolver-files/benchmark/codegen.ts @@ -17,6 +17,7 @@ const config: CodegenConfig = { generates: { [modulesDir]: defineConfig({ tsConfigFilePath, + resolverGeneration: 'minimal', }), }, }; From 768ac066d2378cd1d61b5d4aa054860fefb130af Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Thu, 27 Aug 2026 08:00:06 +1000 Subject: [PATCH 3/5] Improvement 1: do not parse schema in content runs --- packages/typescript-resolver-files/src/preset.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/typescript-resolver-files/src/preset.ts b/packages/typescript-resolver-files/src/preset.ts index 197d0266..7ea88832 100644 --- a/packages/typescript-resolver-files/src/preset.ts +++ b/packages/typescript-resolver-files/src/preset.ts @@ -235,6 +235,10 @@ export const preset: Types.OutputPreset = { plugins: [{ add: { content: meta.content } }], config: {}, schema, + // Pass schemaAst so codegen-core reuses the prebuilt schema instead of + // rebuilding one per output (O(types) each -> O(types^2) overall). This + // is an `add`-only output, so the schema doesn't affect its content. + schemaAst, documents: [], }; generatesSection.push(typeDefsFile); @@ -308,6 +312,10 @@ export const preset: Types.OutputPreset = { plugins: [{ add: { content } }], config: {}, schema, + // Pass schemaAst so codegen-core reuses the prebuilt schema instead of + // rebuilding one per output (O(types) each -> O(types^2) overall). This + // is an `add`-only output, so the schema doesn't affect its content. + schemaAst, documents: [], }; }); From d7bab8af921b91123b89c584e9d1b5cffcba01b2 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Thu, 27 Aug 2026 20:54:37 +1000 Subject: [PATCH 4/5] Add changeset --- .changeset/funky-numbers-create.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/funky-numbers-create.md diff --git a/.changeset/funky-numbers-create.md b/.changeset/funky-numbers-create.md new file mode 100644 index 00000000..c12bb5a1 --- /dev/null +++ b/.changeset/funky-numbers-create.md @@ -0,0 +1,5 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': patch +--- + +Pass `schemaAst` from parent to every built generates block to avoid re-parsing schema -> schema AST From 9991444680dccf6c82c882dc22e791e6d9e32f5f Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Thu, 27 Aug 2026 21:35:09 +1000 Subject: [PATCH 5/5] Fix lint issue --- packages/typescript-resolver-files/eslint.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/typescript-resolver-files/eslint.config.mjs b/packages/typescript-resolver-files/eslint.config.mjs index 80c059fe..27b985dc 100644 --- a/packages/typescript-resolver-files/eslint.config.mjs +++ b/packages/typescript-resolver-files/eslint.config.mjs @@ -1,6 +1,8 @@ import baseConfig from '../../eslint.config.mjs'; export default [ + // Generated benchmark workload output is not source; never lint it. + { ignores: ['**/.workload/**'] }, ...baseConfig, { files: ['**/*.json'], @@ -9,6 +11,7 @@ export default [ 'error', { ignoredFiles: [ + '{projectRoot}/benchmark/**/*.ts', '{projectRoot}/eslint.config.mjs', '{projectRoot}/vite.config.ts', ],