From 620b122a8b4de5edf10abf0cba741f6e631decb4 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Sat, 29 Aug 2026 17:46:41 +1000 Subject: [PATCH 1/5] Create cache functionalities --- .../cache.ts | 93 +++++++++++++++++++ ...getGraphQLObjectTypeResolversToGenerate.ts | 24 ++++- 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts new file mode 100644 index 00000000..aed3c4f4 --- /dev/null +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts @@ -0,0 +1,93 @@ +import { createHash } from 'crypto'; +import type { Project, SourceFile } from 'ts-morph'; +import type { TypeMappersMap } from '../parseTypeMappers/index.js'; +import type { GraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTypeResolversToGenerate.js'; + +type Cache = { + get(key: string): GraphQLObjectTypeResolversToGenerate | undefined; + updateCache( + key: string, + value: GraphQLObjectTypeResolversToGenerate + ): GraphQLObjectTypeResolversToGenerate; + createCacheKey({ + mode, + typesSourceFile, + typeMappersMap, + tsMorphProject, + }: { + mode: string; + typesSourceFile: SourceFile; + typeMappersMap: TypeMappersMap; + tsMorphProject: Project; + }): string; +}; + +export const createCache = (): Cache => { + /** + * Cache of the previous run's result, so the ts-morph type-checker work is + * skipped when nothing it depends on has changed (e.g. a watch re-run where only + * a resolver implementation file was edited). This function is a pure function of + * - `mode` + * - the generated types file + * - the mapper file contents + * + * so the key is a hash of exactly those. Persisted at module scope; + * reused only on an exact key match, so it can never go stale. + */ + const resultCache: Record = {}; + + return { + get(key) { + const result = resultCache[key]; + if (!result) { + return undefined; + } + + return structuredClone(result); + }, + /** + * updateCache + * Create a structured clone in the cache + * because downstream can update the value object + */ + updateCache(key, value) { + resultCache[key] = structuredClone(value); + return value; + }, + + /** + * Hash of everything the result depends on: the mode, the generated types file + * text (captures all schema-derived inputs), and each mapper file's contents. + * Same key => same result. + */ + createCacheKey({ mode, typesSourceFile, typeMappersMap, tsMorphProject }) { + const hash = createHash('sha1'); + hash.update(mode); + hash.update(' '); + hash.update(typesSourceFile.getFullText()); + + // The parsed mapper map (which schema type -> which mapper declaration) depends + // on config such as mappersSuffix, so hash it too: mapper file text alone would + // not distinguish two configs that read the same files differently. + hash.update(' '); + hash.update( + JSON.stringify( + Object.entries(typeMappersMap).sort(([a], [b]) => (a < b ? -1 : 1)) + ) + ); + + const mapperFilenames = [ + ...new Set(Object.values(typeMappersMap).map((m) => m.mapper.filename)), + ].sort(); + for (const filename of mapperFilenames) { + const sourceFile = tsMorphProject.getSourceFile(filename); + hash.update(' '); + hash.update(filename); + hash.update(' '); + hash.update(sourceFile ? sourceFile.getFullText() : ''); + } + + return hash.digest('hex'); + }, + } satisfies Cache; +}; diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts index 61632a79..9ea9f24a 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts @@ -19,12 +19,15 @@ import { } from './getNodePropertyMap.js'; import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema/index.js'; import type { GeneratedTypesFileMeta } from '../generateResolverFiles/index.js'; +import { createCache } from './cache.js'; export type GraphQLObjectTypeResolversToGenerate = Record< string, Record >; +const cache = createCache(); + export const getGraphQLObjectTypeResolversToGenerate = ({ mode, tsMorphProject, @@ -45,6 +48,18 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ return {}; } + const cacheKey = cache.createCacheKey({ + mode, + typesSourceFile, + typeMappersMap, + tsMorphProject, + }); + + const cachedResult = cache.get(cacheKey); + if (cachedResult) { + return cachedResult; + } + /** * `generatedTypesFileMeta.generatedResolverTypes.userDefined` is `schemaType` -> `generatedResolverTypes` * We will be parsing `types.generated.ts` file for the `generatedResolverTypes` @@ -73,6 +88,7 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ {} ); + // "Fast" mode if (mode === 'fast') { // 1. Get property map of all schema types const resolverTypesMap: Record< @@ -172,9 +188,11 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ }); }); - return result; + const newResult = cache.updateCache(cacheKey, result); + return newResult; } + // "Smart" mode // 1. Get property map of all schema types const schemaResolversTypePropertyMap: Record = {}; @@ -269,8 +287,8 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ } ); }); - - return result; + const newResult = cache.updateCache(cacheKey, result); + return newResult; }; const mustGetMapperOriginalDeclarationNode = ({ From 889939ef2aa270b7c61a8f222ee7560cdb0603c5 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Sat, 29 Aug 2026 18:21:06 +1000 Subject: [PATCH 2/5] Rename method --- .../src/getGraphQLObjectTypeResolversToGenerate/cache.ts | 4 ++-- .../getGraphQLObjectTypeResolversToGenerate.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts index aed3c4f4..86a39147 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts @@ -5,7 +5,7 @@ import type { GraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTyp type Cache = { get(key: string): GraphQLObjectTypeResolversToGenerate | undefined; - updateCache( + set( key: string, value: GraphQLObjectTypeResolversToGenerate ): GraphQLObjectTypeResolversToGenerate; @@ -50,7 +50,7 @@ export const createCache = (): Cache => { * Create a structured clone in the cache * because downstream can update the value object */ - updateCache(key, value) { + set(key, value) { resultCache[key] = structuredClone(value); return value; }, diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts index 9ea9f24a..c6dc6ce4 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts @@ -188,7 +188,7 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ }); }); - const newResult = cache.updateCache(cacheKey, result); + const newResult = cache.set(cacheKey, result); return newResult; } @@ -287,7 +287,7 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ } ); }); - const newResult = cache.updateCache(cacheKey, result); + const newResult = cache.set(cacheKey, result); return newResult; }; From 08dcf0d6e83ea114c2e427af22aa2439812588f9 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Sat, 29 Aug 2026 18:44:49 +1000 Subject: [PATCH 3/5] Implement lru cache --- .../cache.ts | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts index 86a39147..67986d9c 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/cache.ts @@ -22,36 +22,63 @@ type Cache = { }): string; }; +/** + * Maximum number of entries kept in the cache. Each entry corresponds to one + * distinct `(mode + types file + mappers)` state. A long watch session can visit + * many such states, and separate `generates` targets each contribute their own + * key, so we keep the N most-recently-used entries and evict the rest to bound + * memory. N is comfortably larger than the number of concurrent targets a single + * codegen run has. + */ +const MAX_CACHE_ENTRIES = 50; + export const createCache = (): Cache => { /** - * Cache of the previous run's result, so the ts-morph type-checker work is - * skipped when nothing it depends on has changed (e.g. a watch re-run where only - * a resolver implementation file was edited). This function is a pure function of + * Cache of previous runs' results, so the ts-morph type-checker work is skipped + * when nothing it depends on has changed (e.g. a watch re-run where only a + * resolver implementation file was edited). The result is a pure function of * - `mode` * - the generated types file * - the mapper file contents * - * so the key is a hash of exactly those. Persisted at module scope; - * reused only on an exact key match, so it can never go stale. + * so the key is a hash of exactly those. Reused only on an exact key match, so + * it can never go stale. A `Map` is used because its insertion order gives a + * cheap LRU: the first key is the least-recently-used. */ - const resultCache: Record = {}; + const resultCache = new Map(); return { get(key) { - const result = resultCache[key]; - if (!result) { + const result = resultCache.get(key); + if (result === undefined) { return undefined; } + // Mark as most-recently-used by re-inserting at the end. + resultCache.delete(key); + resultCache.set(key, result); + return structuredClone(result); }, /** - * updateCache + * set * Create a structured clone in the cache * because downstream can update the value object */ set(key, value) { - resultCache[key] = structuredClone(value); + // Re-insert so this key becomes the most-recently-used entry. + resultCache.delete(key); + resultCache.set(key, structuredClone(value)); + + // Evict least-recently-used entries beyond the cap. + while (resultCache.size > MAX_CACHE_ENTRIES) { + const lruKey = resultCache.keys().next().value; + if (lruKey === undefined) { + break; + } + resultCache.delete(lruKey); + } + return value; }, From ded351386bd82da2e3a23ddba61852e21cdf10f1 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Sat, 29 Aug 2026 19:03:51 +1000 Subject: [PATCH 4/5] Add changeset --- .changeset/chubby-sites-know.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/chubby-sites-know.md diff --git a/.changeset/chubby-sites-know.md b/.changeset/chubby-sites-know.md new file mode 100644 index 00000000..6cd7d8e2 --- /dev/null +++ b/.changeset/chubby-sites-know.md @@ -0,0 +1,15 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': minor +--- + +Add LRU cache for resolver types vs mappers type check + +Resolver types vs mappers check is the most expensive check in this plugin. We need this to determine which resolvers need to be added to avoid runtime errors. + +Previously in watch mode, we run this expensive check every run, even if the schema or mappers don't change. Adding a LRU cache helps said scenario by re-using a previously parsed data. + +This ensures the codemod scenarios, where schema or mappers don't change, run as efficiently as possible: + +- ensuring resolver exports exist +- ensuring resolvers are injected correctly to avoid runtime issues +- etc. From dd267abb49320a77d0994235d274131f10814123 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Sat, 29 Aug 2026 19:05:32 +1000 Subject: [PATCH 5/5] Update benchmark --- .../benchmark/README.md | 15 ++++-- .../benchmark/run.ts | 54 ++++++++++++++++--- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/typescript-resolver-files/benchmark/README.md b/packages/typescript-resolver-files/benchmark/README.md index 197a7bc2..04cfb93a 100644 --- a/packages/typescript-resolver-files/benchmark/README.md +++ b/packages/typescript-resolver-files/benchmark/README.md @@ -29,13 +29,18 @@ Presets (in `generateSchema.ts`): `small` (~20 types), `medium` (~100), ### What it measures -Each iteration runs in a fresh child process and does codegen **twice**: +Each iteration runs in a fresh child process and does codegen **three times**: - **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: +- **WARM, cache hit** — output on disk + the preset's module-level ts-morph + `Project` reused, and nothing changed, so the phase-7 result cache hits (a + `--watch` re-run after editing a resolver implementation). +- **WARM, cache miss** — a mapper file is edited between runs, so the phase-7 + cache key changes and `getGraphQLObjectTypeResolversToGenerate` recomputes (a + `--watch` re-run after editing a mapper). Cheaper than COLD because ts-morph is + already warm, but not free like the cache hit. + +The runner prints a per-phase median/min/max table for each, 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. diff --git a/packages/typescript-resolver-files/benchmark/run.ts b/packages/typescript-resolver-files/benchmark/run.ts index 078e7ca7..c5d3883e 100644 --- a/packages/typescript-resolver-files/benchmark/run.ts +++ b/packages/typescript-resolver-files/benchmark/run.ts @@ -21,6 +21,8 @@ */ import { spawnSync } from 'child_process'; import { performance } from 'perf_hooks'; +import * as fs from 'fs'; +import * as path from 'path'; import { CodegenContext, generate } from '@graphql-codegen/cli'; import config from './codegen.js'; import { @@ -67,8 +69,34 @@ interface SingleResult { stats: { modules: number; types: number; mappers: number }; cold: RunTimings; warm: RunTimings; + warmMapperChanged: RunTimings; } +/** + * Edit one mapper file on disk so its contents (and therefore the phase-7 cache + * key) change. Used to measure the cache-miss path: the ts-morph project is still + * warm, but `getGraphQLObjectTypeResolversToGenerate` must recompute. + */ +const changeOneMapperFile = (modulesDir: string): void => { + for (const entry of fs.readdirSync(modulesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const dir = path.join(modulesDir, entry.name); + const mapperFile = fs + .readdirSync(dir) + .find((f) => f.endsWith('.mappers.ts')); + if (mapperFile) { + fs.appendFileSync( + path.join(dir, mapperFile), + `\n// benchmark: mapper edited at ${Date.now()}\n` + ); + return; + } + } + throw new Error('No mapper file found to edit'); +}; + /** * 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 @@ -96,10 +124,13 @@ const runProfiledGenerate = async (): Promise => { }; /** - * 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. + * Produce one data point for a preset. In its own process (so COLD is genuinely + * cold), measure three runs: + * - COLD: nothing generated yet. + * - WARM: output on disk + the ts-morph Project singleton reused, and nothing + * changed -> the phase-7 cache HITS (watch re-run after editing a resolver). + * - WARM + mapper changed: a mapper file is edited between runs, so the phase-7 + * cache MISSES and it recomputes (watch re-run after editing a mapper). */ const run = async (presetName: string): Promise => { const preset = workloadPresets[presetName]; @@ -110,15 +141,18 @@ const run = async (presetName: string): Promise => { ).join(', ')}` ); } - const { stats } = generateWorkload({ + const { modulesDir, stats } = generateWorkload({ preset, workloadDir: defaultWorkloadDir(), }); const cold = await runProfiledGenerate(); // nothing on disk -> cold - const warm = await runProfiledGenerate(); // output exists + singleton reused -> warm + const warm = await runProfiledGenerate(); // unchanged -> phase-7 cache hit + + changeOneMapperFile(modulesDir); // invalidate the phase-7 cache key + const warmMapperChanged = await runProfiledGenerate(); // cache miss -> recompute - return { preset: presetName, stats, cold, warm }; + return { preset: presetName, stats, cold, warm, warmMapperChanged }; }; // ---------- orchestrator ---------- @@ -235,9 +269,13 @@ const orchestrate = async ( results.map((r) => r.cold) ); printTable( - 'WARM (watch re-run)', + 'WARM (watch re-run, nothing changed — cache hit)', results.map((r) => r.warm) ); + printTable( + 'WARM (watch re-run, mapper changed — cache miss)', + results.map((r) => r.warmMapperChanged) + ); }; // ---------- entry ----------