Skip to content
Closed
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
58 changes: 47 additions & 11 deletions graphql/codegen/src/__tests__/codegen/expand-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, GENERATED_SENTINEL } from '../../core/generate';

describe('expandApiNamesToMultiTarget', () => {
it('returns null for no apiNames', () => {
Expand Down Expand Up @@ -151,11 +151,25 @@ describe('removeStaleTargetDirs', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('removes directories not in current target list', () => {
fs.mkdirSync(path.join(tempDir, 'admin'));
fs.mkdirSync(path.join(tempDir, 'auth'));
fs.mkdirSync(path.join(tempDir, 'public'));
fs.mkdirSync(path.join(tempDir, 'objects'));
/** Create a directory with the .generated sentinel (codegen output). */
function mkGeneratedDir(name: string) {
const dir = path.join(tempDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, GENERATED_SENTINEL), 'generated by codegen\n');
}

/** Create a plain directory without the sentinel (hand-written code). */
function mkHandwrittenDir(name: string) {
const dir = path.join(tempDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.ts'), 'export function hello() {}\n');
}

it('removes generated directories not in current target list', () => {
mkGeneratedDir('admin');
mkGeneratedDir('auth');
mkGeneratedDir('public');
mkGeneratedDir('objects');

const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']);

Expand All @@ -166,8 +180,21 @@ describe('removeStaleTargetDirs', () => {
expect(fs.existsSync(path.join(tempDir, 'objects'))).toBe(false);
});

it('preserves hand-written directories even when not in target list', () => {
mkGeneratedDir('admin');
mkHandwrittenDir('config');
mkHandwrittenDir('utils');

const removed = removeStaleTargetDirs(tempDir, ['admin']);

expect(removed).toEqual([]);
expect(fs.existsSync(path.join(tempDir, 'admin'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, 'config'))).toBe(true);
expect(fs.existsSync(path.join(tempDir, 'utils'))).toBe(true);
});

it('preserves files (only removes directories)', () => {
fs.mkdirSync(path.join(tempDir, 'admin'));
mkGeneratedDir('admin');
fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export {}');

const removed = removeStaleTargetDirs(tempDir, ['admin']);
Expand All @@ -182,19 +209,28 @@ describe('removeStaleTargetDirs', () => {
});

it('returns empty array when no stale directories exist', () => {
fs.mkdirSync(path.join(tempDir, 'admin'));
fs.mkdirSync(path.join(tempDir, 'auth'));
mkGeneratedDir('admin');
mkGeneratedDir('auth');

const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']);
expect(removed).toEqual([]);
});

it('removes all directories when target list is empty', () => {
fs.mkdirSync(path.join(tempDir, 'old-target'));
it('removes all generated directories when target list is empty', () => {
mkGeneratedDir('old-target');

const removed = removeStaleTargetDirs(tempDir, []);

expect(removed).toEqual(['old-target']);
expect(fs.existsSync(path.join(tempDir, 'old-target'))).toBe(false);
});

it('skips directories without sentinel', () => {
fs.mkdirSync(path.join(tempDir, 'empty-dir'));

const removed = removeStaleTargetDirs(tempDir, []);

expect(removed).toEqual([]);
expect(fs.existsSync(path.join(tempDir, 'empty-dir'))).toBe(true);
});
});
21 changes: 19 additions & 2 deletions graphql/codegen/src/core/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,12 @@ export async function generate(
}
allFilesWritten.push(...(writeResult.filesWritten ?? []));

// Drop sentinel so removeStaleTargetDirs knows this is a generated dir
fs.writeFileSync(
path.join(outputRoot, GENERATED_SENTINEL),
`generated by @constructive-io/graphql-codegen\n`,
);

if (skillsToWrite.length > 0) {
const skillsOutputDir = resolveSkillsOutputDir(config, outputRoot);
const skillsWriteResult = await writeGeneratedFiles(skillsToWrite, skillsOutputDir, [], {
Expand Down Expand Up @@ -626,9 +632,13 @@ function applySharedPgpmDb(
};
}

/** Sentinel file written into every generated target directory. */
export const GENERATED_SENTINEL = '.generated';

/**
* Remove subdirectories in `outputRoot` that are not in `currentTargetNames`.
* Useful for cleaning up stale target output before a fresh multi-target generate.
* Only removes directories that contain a `.generated` sentinel file — hand-written
* directories (e.g. `config/`, `utils/`) are preserved automatically.
* Returns the list of directory names that were removed.
*/
export function removeStaleTargetDirs(
Expand All @@ -643,7 +653,14 @@ export function removeStaleTargetDirs(
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 });
const dirPath = path.join(outputRoot, entry.name);
if (!fs.existsSync(path.join(dirPath, GENERATED_SENTINEL))) {
if (verbose) {
console.log(`Preserved non-generated directory: ${entry.name}`);
}
continue;
}
fs.rmSync(dirPath, { recursive: true, force: true });
removed.push(entry.name);
if (verbose) {
console.log(`Removed stale target directory: ${entry.name}`);
Expand Down
2 changes: 1 addition & 1 deletion graphql/codegen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, GENERATED_SENTINEL } from './core/generate';

// Config utilities
export { findConfigFile, loadConfigFile } from './core/config';
Expand Down
Loading