diff --git a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts index 6d405cf4b..60a552631 100644 --- a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts +++ b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from '../../core/generate'; +import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, TARGETS_MANIFEST } from '../../core/generate'; describe('expandApiNamesToMultiTarget', () => { it('returns null for no apiNames', () => { @@ -151,7 +151,13 @@ describe('removeStaleTargetDirs', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('removes directories not in current target list', () => { + /** Write a .targets manifest listing the given names. */ + function writeManifest(names: string[]) { + fs.writeFileSync(path.join(tempDir, TARGETS_MANIFEST), JSON.stringify(names)); + } + + it('removes previous targets that are no longer current', () => { + writeManifest(['admin', 'auth', 'public', 'objects']); fs.mkdirSync(path.join(tempDir, 'admin')); fs.mkdirSync(path.join(tempDir, 'auth')); fs.mkdirSync(path.join(tempDir, 'public')); @@ -166,14 +172,30 @@ describe('removeStaleTargetDirs', () => { expect(fs.existsSync(path.join(tempDir, 'objects'))).toBe(false); }); - it('preserves files (only removes directories)', () => { + it('preserves directories not listed in manifest', () => { + writeManifest(['admin', 'auth']); fs.mkdirSync(path.join(tempDir, 'admin')); - fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export {}'); + fs.mkdirSync(path.join(tempDir, 'auth')); + fs.mkdirSync(path.join(tempDir, 'config')); + fs.mkdirSync(path.join(tempDir, 'utils')); + + const removed = removeStaleTargetDirs(tempDir, ['admin']); + + expect(removed).toEqual(['auth']); + expect(fs.existsSync(path.join(tempDir, 'admin'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'auth'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, 'config'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'utils'))).toBe(true); + }); + + it('returns empty array when no manifest exists', () => { + fs.mkdirSync(path.join(tempDir, 'admin')); + fs.mkdirSync(path.join(tempDir, 'config')); const removed = removeStaleTargetDirs(tempDir, ['admin']); expect(removed).toEqual([]); - expect(fs.existsSync(path.join(tempDir, 'index.ts'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'config'))).toBe(true); }); it('returns empty array when output root does not exist', () => { @@ -181,7 +203,8 @@ describe('removeStaleTargetDirs', () => { expect(removed).toEqual([]); }); - it('returns empty array when no stale directories exist', () => { + it('returns empty array when no stale targets exist', () => { + writeManifest(['admin', 'auth']); fs.mkdirSync(path.join(tempDir, 'admin')); fs.mkdirSync(path.join(tempDir, 'auth')); @@ -189,7 +212,8 @@ describe('removeStaleTargetDirs', () => { expect(removed).toEqual([]); }); - it('removes all directories when target list is empty', () => { + it('removes all previous targets when current list is empty', () => { + writeManifest(['old-target']); fs.mkdirSync(path.join(tempDir, 'old-target')); const removed = removeStaleTargetDirs(tempDir, []); @@ -197,4 +221,12 @@ describe('removeStaleTargetDirs', () => { expect(removed).toEqual(['old-target']); expect(fs.existsSync(path.join(tempDir, 'old-target'))).toBe(false); }); + + it('handles corrupt manifest gracefully', () => { + fs.writeFileSync(path.join(tempDir, TARGETS_MANIFEST), 'not json'); + fs.mkdirSync(path.join(tempDir, 'admin')); + + const removed = removeStaleTargetDirs(tempDir, []); + expect(removed).toEqual([]); + }); }); diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 026d117f6..7e2ace908 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -626,9 +626,14 @@ function applySharedPgpmDb( }; } +/** Manifest file listing generated target names, written to the output root. */ +export const TARGETS_MANIFEST = '.targets'; + /** - * Remove subdirectories in `outputRoot` that are not in `currentTargetNames`. - * Useful for cleaning up stale target output before a fresh multi-target generate. + * Remove stale generated target directories from `outputRoot`. + * Reads the `.targets` manifest (written by `generateMulti`) to know which + * directories were previously generated. Only those are eligible for removal; + * hand-written directories (e.g. `config/`, `utils/`) are never touched. * Returns the list of directory names that were removed. */ export function removeStaleTargetDirs( @@ -639,14 +644,26 @@ export function removeStaleTargetDirs( const removed: string[] = []; if (!fs.existsSync(outputRoot)) return removed; + const manifestPath = path.join(outputRoot, TARGETS_MANIFEST); + if (!fs.existsSync(manifestPath)) return removed; + + let previousTargets: string[]; + try { + previousTargets = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + } catch { + return removed; + } + const currentTargets = new Set(currentTargetNames); - const entries = fs.readdirSync(outputRoot, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && !currentTargets.has(entry.name)) { - fs.rmSync(path.join(outputRoot, entry.name), { recursive: true, force: true }); - removed.push(entry.name); + const staleTargets = previousTargets.filter((t) => !currentTargets.has(t)); + + for (const target of staleTargets) { + const dirPath = path.join(outputRoot, target); + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + removed.push(target); if (verbose) { - console.log(`Removed stale target directory: ${entry.name}`); + console.log(`Removed stale target directory: ${target}`); } } } @@ -833,6 +850,12 @@ export async function generateMulti( [], { pruneStaleFiles: false }, ); + + // Write manifest so removeStaleTargetDirs knows which dirs are generated + fs.writeFileSync( + path.join(outputRoot, TARGETS_MANIFEST), + JSON.stringify(successfulNames.sort()) + '\n', + ); } } diff --git a/graphql/codegen/src/index.ts b/graphql/codegen/src/index.ts index a8a417da4..4d54cb5be 100644 --- a/graphql/codegen/src/index.ts +++ b/graphql/codegen/src/index.ts @@ -23,7 +23,7 @@ export { defineConfig } from './types/config'; // Main generate function (orchestrates the entire pipeline) export type { GenerateOptions, GenerateResult, GenerateMultiOptions, GenerateMultiResult } from './core/generate'; -export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from './core/generate'; +export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, TARGETS_MANIFEST } from './core/generate'; // Config utilities export { findConfigFile, loadConfigFile } from './core/config';