diff --git a/packages/typescript-resolver-files/package.json b/packages/typescript-resolver-files/package.json index 87cd817f..942dd0ba 100644 --- a/packages/typescript-resolver-files/package.json +++ b/packages/typescript-resolver-files/package.json @@ -52,6 +52,9 @@ "ts-morph": "^22.0.0", "tslib": "^2.8.0" }, + "devDependencies": { + "@graphql-codegen/cli": "^7.3.1" + }, "files": [ "dist", "!**/*.tsbuildinfo" diff --git a/packages/typescript-resolver-files/src/defineConfig.staleFilesCliBug.spec.ts b/packages/typescript-resolver-files/src/defineConfig.staleFilesCliBug.spec.ts new file mode 100644 index 00000000..b1f00815 --- /dev/null +++ b/packages/typescript-resolver-files/src/defineConfig.staleFilesCliBug.spec.ts @@ -0,0 +1,143 @@ +import { createRequire } from 'module'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { executeCodegen } from '@graphql-codegen/cli'; + +/** + * `@graphql-codegen/cli` used to resolve a generated file's `overwrite` option + * (`normalizeOverwriteConfig` in `generate-and-save.js`) by looking the file's + * own path up in `config.generates`, then requiring that entry to have a + * `plugins` key. + * + * Neither holds for a preset-based output such as the server preset: its + * `generates` entry is keyed by `baseOutputDir` (a directory it writes many + * files into, and some generated paths are not even under it), and it has no + * `plugins` key because the preset supplies plugins itself. So the lookup + * always missed, and `overwrite.removeStaleFiles: false` from `defineConfig()` + * was silently dropped in favour of Codegen's global default of + * `removeStaleFiles: true` - deleting resolver files in watch mode. + * + * The fix carries the `generates` entry's `overwrite` on each generated file + * (the same way `hooks` already was), so no path matching is needed at all. + * This repo carries it as `patches/@graphql-codegen__cli@7.3.1.patch` until it + * lands upstream. + * + * These tests exercise the CLI contract with a stub preset rather than the + * server preset itself, so they stay independent of what the server preset + * happens to emit. `defineConfig.spec.ts` covers the `overwrite` value the + * server preset declares. + */ + +const serverPresetOverwrite = { + removeStaleFiles: false, + updateExistingFiles: true, +}; + +const baseOutputDir = 'src/schema'; + +// Mirrors how the server preset emits files: many outputs from one `generates` +// entry keyed by a directory, including a path that is not under that directory. +const generatedFilenames = [ + `${baseOutputDir}/types.generated.ts`, + `${baseOutputDir}/resolvers/Query/user.ts`, + 'resolvers/User.ts', +]; + +const stubPreset = { + buildGeneratesSection: (options: Record) => + generatedFilenames.map((filename) => ({ + ...options, + filename, + plugins: [], + pluginMap: {}, + })), +}; + +describe('overwrite propagation through @graphql-codegen/cli', () => { + it('tags every file a preset generates with the generates entry’s overwrite', async () => { + const { result, error } = await executeCodegen({ + schema: 'type Query { user: User } type User { id: ID! name: String }', + generates: { + // No `plugins` key, keyed by a directory - exactly the server preset's shape. + [baseOutputDir]: { + preset: stubPreset, + overwrite: serverPresetOverwrite, + }, + }, + } as never); + + expect(error).toBeNull(); + expect(result.map((file) => file.filename)).toEqual(generatedFilenames); + + for (const file of result) { + expect({ + filename: file.filename, + overwrite: (file as { overwrite?: unknown }).overwrite, + }).toEqual({ + filename: file.filename, + overwrite: serverPresetOverwrite, + }); + } + }); +}); + +/** + * `normalizeOverwriteConfig` is internal to `generate-and-save.js`; the patch + * adds a named export so the resolution rules can be asserted directly. + */ +async function loadNormalizeOverwriteConfig() { + const require = createRequire(import.meta.url); + const cliPackageJsonPath = require.resolve( + '@graphql-codegen/cli/package.json' + ); + const generateAndSavePath = path.join( + path.dirname(cliPackageJsonPath), + 'esm', + 'generate-and-save.js' + ); + const mod = await import(pathToFileURL(generateAndSavePath).href); + return mod.normalizeOverwriteConfig as ( + config: { overwrite?: unknown }, + fileOutput: { filename: string; overwrite?: unknown } + ) => { removeStaleFiles: boolean; updateExistingFiles: boolean }; +} + +describe('normalizeOverwriteConfig()', () => { + it('uses the overwrite carried on the generated file', async () => { + const normalizeOverwriteConfig = await loadNormalizeOverwriteConfig(); + + expect( + normalizeOverwriteConfig( + { overwrite: true }, + { + filename: `${baseOutputDir}/resolvers/Query/user.ts`, + overwrite: serverPresetOverwrite, + } + ) + ).toEqual(serverPresetOverwrite); + }); + + it('falls back to the global overwrite when the file carries none', async () => { + const normalizeOverwriteConfig = await loadNormalizeOverwriteConfig(); + + expect( + normalizeOverwriteConfig( + { overwrite: { removeStaleFiles: false } }, + { filename: 'src/generated.ts' } + ) + ).toEqual({ removeStaleFiles: false, updateExistingFiles: true }); + }); + + it('expands the boolean shorthand', async () => { + const normalizeOverwriteConfig = await loadNormalizeOverwriteConfig(); + + expect( + normalizeOverwriteConfig({}, { filename: 'a.ts', overwrite: false }) + ).toEqual({ removeStaleFiles: false, updateExistingFiles: false }); + + expect(normalizeOverwriteConfig({}, { filename: 'a.ts' })).toEqual({ + removeStaleFiles: true, + updateExistingFiles: true, + }); + }); +}); diff --git a/patches/@graphql-codegen__cli@7.3.1.patch b/patches/@graphql-codegen__cli@7.3.1.patch new file mode 100644 index 00000000..8666ac47 --- /dev/null +++ b/patches/@graphql-codegen__cli@7.3.1.patch @@ -0,0 +1,204 @@ +diff --git a/cjs/codegen.js b/cjs/codegen.js +index 27e313bd7b700a4827567fd72b6328d0ca9a87c8..1d735b17f596cf5c2716cde521123ab08173567c 100644 +--- a/cjs/codegen.js ++++ b/cjs/codegen.js +@@ -378,6 +378,11 @@ async function executeCodegen(input) { + filename: outputArgs.filename, + content: output, + hooks: outputConfig.hooks || {}, ++ // Carry the `generates` entry's `overwrite` on the file ++ // itself. A preset writes many files under one entry keyed ++ // by its base output dir, so the entry cannot be recovered ++ // from the generated file's path later on. ++ overwrite: outputConfig.overwrite, + }); + }; + await context.profiler.run(() => Promise.all(outputs.map(process)), `Codegen: ${filename}`); +diff --git a/cjs/generate-and-save.js b/cjs/generate-and-save.js +index 0fe6eb8d696e70248b7e4bf14d70c180a128affc..3ec481027a1ba66d0dc3054498ee09d833b6eea3 100644 +--- a/cjs/generate-and-save.js ++++ b/cjs/generate-and-save.js +@@ -17,13 +17,14 @@ async function generate(input, saveToFile = true) { + const context = (0, config_js_1.ensureContext)(input); + const config = context.getConfig(); + await context.profiler.run(() => (0, hooks_js_1.lifecycleHooks)(config.hooks).afterStart(), 'Lifecycle: afterStart'); +- let previouslyGeneratedFilenames = []; ++ let previouslyGeneratedFiles = []; + function removeStaleFiles(config, generationResult) { + const filenames = generationResult.map(o => o.filename); + // find stale files from previous build which are not present in current build +- const staleFilenames = previouslyGeneratedFilenames.filter(f => !filenames.includes(f)); +- for (const filename of staleFilenames) { +- if (normalizeOverwriteConfig(config, filename).removeStaleFiles) { ++ const staleFiles = previouslyGeneratedFiles.filter(f => !filenames.includes(f.filename)); ++ for (const staleFile of staleFiles) { ++ const { filename } = staleFile; ++ if (normalizeOverwriteConfig(config, staleFile).removeStaleFiles) { + (0, file_system_js_1.unlinkFile)(filename, err => { + const prettyFilename = filename.replace(`${input.cwd || process.cwd()}/`, ''); + if (err) { +@@ -35,7 +36,12 @@ async function generate(input, saveToFile = true) { + }); + } + } +- previouslyGeneratedFilenames = filenames; ++ // Keep each file's own `overwrite` around so a file that disappears in a ++ // later run is still judged by the `generates` entry that produced it. ++ previouslyGeneratedFiles = generationResult.map(({ filename, overwrite }) => ({ ++ filename, ++ overwrite, ++ })); + } + const recentOutputHash = new Map(); + async function writeOutput(generationResult) { +@@ -55,7 +61,7 @@ async function generate(input, saveToFile = true) { + if (previousHash) { + recentOutputHash.set(result.filename, previousHash); + } +- if (!normalizeOverwriteConfig(config, result.filename).updateExistingFiles && exists) { ++ if (!normalizeOverwriteConfig(config, result).updateExistingFiles && exists) { + return; + } + let content = result.content || ''; +@@ -132,19 +138,13 @@ async function generate(input, saveToFile = true) { + await writeProfilerOutput(); + return outputFiles; + } +-function normalizeOverwriteConfig(config, outputPath) { +- const overwrite = (function getOverwriteOption() { +- const { overwrite: result = true } = config; +- const outputConfig = config.generates[outputPath]; +- if (!outputConfig) { +- (0, debugging_js_1.debugLog)(`Couldn't find a config of ${outputPath}`); +- return result; +- } +- if (isConfiguredOutput(outputConfig) && outputConfig.overwrite !== undefined) { +- return outputConfig.overwrite; +- } +- return result; +- })(); ++function normalizeOverwriteConfig(config, fileOutput) { ++ // `fileOutput.overwrite` is carried over from the `generates` entry that ++ // produced this file (see `codegen.js`). Looking it up by output path here ++ // does not work: a preset writes many files underneath a single `generates` ++ // entry keyed by its base output dir, so no `generates` key ever equals the ++ // path of a file the preset generated. ++ const overwrite = fileOutput.overwrite ?? config.overwrite ?? true; + if (overwrite === true) { + return { + removeStaleFiles: true, +@@ -160,9 +160,7 @@ function normalizeOverwriteConfig(config, outputPath) { + const { removeStaleFiles = true, updateExistingFiles = true } = overwrite; + return { removeStaleFiles, updateExistingFiles }; + } +-function isConfiguredOutput(output) { +- return typeof output.plugins !== 'undefined'; +-} ++exports.normalizeOverwriteConfig = normalizeOverwriteConfig; + async function hashFile(filePath) { + try { + return hash(await (0, file_system_js_1.readFile)(filePath)); +diff --git a/esm/codegen.js b/esm/codegen.js +index 4b6a854da1fdf7303e2a82908ea6db375ca13e17..125f30b63655cb4a19d943e27047aca6422ad559 100644 +--- a/esm/codegen.js ++++ b/esm/codegen.js +@@ -374,6 +374,11 @@ export async function executeCodegen(input) { + filename: outputArgs.filename, + content: output, + hooks: outputConfig.hooks || {}, ++ // Carry the `generates` entry's `overwrite` on the file ++ // itself. A preset writes many files under one entry keyed ++ // by its base output dir, so the entry cannot be recovered ++ // from the generated file's path later on. ++ overwrite: outputConfig.overwrite, + }); + }; + await context.profiler.run(() => Promise.all(outputs.map(process)), `Codegen: ${filename}`); +diff --git a/esm/generate-and-save.js b/esm/generate-and-save.js +index be32dbb79ebe8adaae8f98454d63c70eef4b3ca6..648d7056a05cbcf4b4681df59b555d53457c10b8 100644 +--- a/esm/generate-and-save.js ++++ b/esm/generate-and-save.js +@@ -13,13 +13,14 @@ export async function generate(input, saveToFile = true) { + const context = ensureContext(input); + const config = context.getConfig(); + await context.profiler.run(() => lifecycleHooks(config.hooks).afterStart(), 'Lifecycle: afterStart'); +- let previouslyGeneratedFilenames = []; ++ let previouslyGeneratedFiles = []; + function removeStaleFiles(config, generationResult) { + const filenames = generationResult.map(o => o.filename); + // find stale files from previous build which are not present in current build +- const staleFilenames = previouslyGeneratedFilenames.filter(f => !filenames.includes(f)); +- for (const filename of staleFilenames) { +- if (normalizeOverwriteConfig(config, filename).removeStaleFiles) { ++ const staleFiles = previouslyGeneratedFiles.filter(f => !filenames.includes(f.filename)); ++ for (const staleFile of staleFiles) { ++ const { filename } = staleFile; ++ if (normalizeOverwriteConfig(config, staleFile).removeStaleFiles) { + unlinkFile(filename, err => { + const prettyFilename = filename.replace(`${input.cwd || process.cwd()}/`, ''); + if (err) { +@@ -31,7 +32,12 @@ export async function generate(input, saveToFile = true) { + }); + } + } +- previouslyGeneratedFilenames = filenames; ++ // Keep each file's own `overwrite` around so a file that disappears in a ++ // later run is still judged by the `generates` entry that produced it. ++ previouslyGeneratedFiles = generationResult.map(({ filename, overwrite }) => ({ ++ filename, ++ overwrite, ++ })); + } + const recentOutputHash = new Map(); + async function writeOutput(generationResult) { +@@ -51,7 +57,7 @@ export async function generate(input, saveToFile = true) { + if (previousHash) { + recentOutputHash.set(result.filename, previousHash); + } +- if (!normalizeOverwriteConfig(config, result.filename).updateExistingFiles && exists) { ++ if (!normalizeOverwriteConfig(config, result).updateExistingFiles && exists) { + return; + } + let content = result.content || ''; +@@ -128,19 +134,13 @@ export async function generate(input, saveToFile = true) { + await writeProfilerOutput(); + return outputFiles; + } +-function normalizeOverwriteConfig(config, outputPath) { +- const overwrite = (function getOverwriteOption() { +- const { overwrite: result = true } = config; +- const outputConfig = config.generates[outputPath]; +- if (!outputConfig) { +- debugLog(`Couldn't find a config of ${outputPath}`); +- return result; +- } +- if (isConfiguredOutput(outputConfig) && outputConfig.overwrite !== undefined) { +- return outputConfig.overwrite; +- } +- return result; +- })(); ++function normalizeOverwriteConfig(config, fileOutput) { ++ // `fileOutput.overwrite` is carried over from the `generates` entry that ++ // produced this file (see `codegen.js`). Looking it up by output path here ++ // does not work: a preset writes many files underneath a single `generates` ++ // entry keyed by its base output dir, so no `generates` key ever equals the ++ // path of a file the preset generated. ++ const overwrite = fileOutput.overwrite ?? config.overwrite ?? true; + if (overwrite === true) { + return { + removeStaleFiles: true, +@@ -156,9 +156,6 @@ function normalizeOverwriteConfig(config, outputPath) { + const { removeStaleFiles = true, updateExistingFiles = true } = overwrite; + return { removeStaleFiles, updateExistingFiles }; + } +-function isConfiguredOutput(output) { +- return typeof output.plugins !== 'undefined'; +-} + async function hashFile(filePath) { + try { + return hash(await readFile(filePath)); +@@ -172,3 +169,4 @@ async function hashFile(filePath) { + throw err; + } + } ++export { normalizeOverwriteConfig }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a16972e8..8b5cf3f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@graphql-codegen/cli@7.3.1': 83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b + importers: .: @@ -219,7 +222,7 @@ importers: version: 1.0.0(jiti@2.7.0)(typescript@6.0.3) '@eddeee888/nx-graphql-code-generator': specifier: 2.1.0 - version: 2.1.0(@graphql-codegen/cli@7.3.1(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3))(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.8(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.15.8(@swc/helpers@0.5.23))) + version: 2.1.0(@graphql-codegen/cli@7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3))(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.8(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.15.8(@swc/helpers@0.5.23))) '@eslint/eslintrc': specifier: 3.3.1 version: 3.3.1 @@ -228,7 +231,7 @@ importers: version: 7.1.0(graphql@17.0.2) '@graphql-codegen/cli': specifier: 7.3.1 - version: 7.3.1(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3) + version: 7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3) '@graphql-codegen/near-operation-file-preset': specifier: 5.2.1 version: 5.2.1(graphql@17.0.2) @@ -436,6 +439,10 @@ importers: tslib: specifier: ^2.8.0 version: 2.8.1 + devDependencies: + '@graphql-codegen/cli': + specifier: ^7.3.1 + version: 7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3) packages/typescript-resolver-files-e2e: devDependencies: @@ -7364,9 +7371,9 @@ snapshots: - supports-color - typescript - '@eddeee888/nx-graphql-code-generator@2.1.0(@graphql-codegen/cli@7.3.1(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3))(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.8(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.15.8(@swc/helpers@0.5.23)))': + '@eddeee888/nx-graphql-code-generator@2.1.0(@graphql-codegen/cli@7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3))(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.8(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.15.8(@swc/helpers@0.5.23)))': dependencies: - '@graphql-codegen/cli': 7.3.1(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3) + '@graphql-codegen/cli': 7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3) '@nx/devkit': 20.8.4(nx@23.1.1(@swc-node/register@1.12.1(@swc/core@1.15.8(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.15.8(@swc/helpers@0.5.23))) semver: 7.8.5 tslib: 2.8.1 @@ -7583,7 +7590,7 @@ snapshots: graphql: 17.0.2 tslib: 2.8.1 - '@graphql-codegen/cli@7.3.1(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3)': + '@graphql-codegen/cli@7.3.1(patch_hash=83da0d9cc4f83ed581cc57f776ad2884d843d1e515bfddc34715b3e4fa18c91b)(@types/node@22.20.1)(graphql@17.0.2)(typescript@6.0.3)': dependencies: '@babel/generator': 7.29.8 '@babel/template': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e0d1aa41..47f3cab6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,5 @@ allowBuilds: minimumReleaseAgeExclude: - '@graphql-codegen/typescript-react-apollo@4.4.3-alpha-20260808101442-e439530add2acc6e119f54b148ccb537e4d0a7ec' - '@graphql-codegen/plugin-helpers@7.2.0' +patchedDependencies: + '@graphql-codegen/cli@7.3.1': patches/@graphql-codegen__cli@7.3.1.patch