Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funky-numbers-create.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 81 additions & 0 deletions packages/typescript-resolver-files/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -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<ResolversTypes['X']>` 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.
25 changes: 25 additions & 0 deletions packages/typescript-resolver-files/benchmark/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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,
resolverGeneration: 'minimal',
}),
},
};

export default config;
216 changes: 216 additions & 0 deletions packages/typescript-resolver-files/benchmark/generateSchema.ts
Original file line number Diff line number Diff line change
@@ -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<string, WorkloadPreset> = {
// ~ 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 `<sourceName>.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');
Loading