Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/social-worms-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@graphql-codegen/gql-tag-operations': minor
'@graphql-codegen/visitor-plugin-common': minor
'@graphql-codegen/typescript-operations': minor
'@graphql-codegen/plugin-helpers': minor
'@graphql-codegen/cli': minor
'@graphql-codegen/client-preset': minor
---

Add support for `documentsReadOnly`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the last chance to consider other names, such as: externalDocuments, referenceDocuments,
documentsReadOnly has its clear benefits as well, so I don't mind keeping it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pro of documentsReadOnly is that it looks related to documents
The con of documentsReadOnly is that it doesn't sound like English.

externalDocuments sounds better. I'm happy to go with that if you are 🙂

Copy link
Copy Markdown
Contributor Author

@ikusakov2 ikusakov2 Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for bringing this one up!
It's really hard to decide!

  • documentsReadOnly has clear benefits. It is self-descriptive and it appears near documents (for code completion) - which is quite valuable as well. But it is not a proper English.

  • externalDocuments: The feature is about documents that are loaded for reference but don't produce output. ReadOnly describes how the current output treats them, not a property of the documents themselves - which makes documentsReadOnly name slightly confusing. externalDocuments is a proper name - in terms of English and semantics, but it is not self-explanatory.

I guess a user of this new feature would have to go to the documentation in any case (either slightly confusing name or not informative enough name), so a better sounding name has a slight benefit, imho.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at all! Naming is hard! Glad I can bounce ideas with you 🤝
Let's lock externalDocuments in. I like the reasoning here.

Code completion show externalDocuments as well if users start typing documents , so it's not bad in that regard.


`documentsReadOnly` declares GraphQL documents that will be read but will not have type files generated for them. These documents are available to plugins for type resolution (e.g. fragment types), but no output files will be generated based on them. Accepts the same formats as `documents`.

This config option is useful for monorepos where each project may want to generate types for its own documents, but some may need to read shared fragments from across projects.
13 changes: 13 additions & 0 deletions dev-test/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,19 @@ const config: CodegenConfig = {
},
},
},
// #region documentsReadOnly
'./dev-test/documents-readonly/app/types.generated.ts': {
schema: './dev-test/documents-readonly/schema.graphqls',
documents: ['./dev-test/documents-readonly/app/*.graphql.ts'],
documentsReadOnly: ['./dev-test/documents-readonly/lib/*.graphql.ts'],
plugins: ['typescript-operations'],
},
'./dev-test/documents-readonly/lib/types.generated.ts': {
schema: './dev-test/documents-readonly/schema.graphqls',
documents: ['./dev-test/documents-readonly/lib/*.graphql.ts'],
plugins: ['typescript-operations'],
},
// #endregion
},
};

Expand Down
8 changes: 8 additions & 0 deletions dev-test/documents-readonly/app/User.graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* GraphQL */ `
query User($id: ID!) {
user(id: $id) {
id
...UserFragment
}
}
`;
15 changes: 15 additions & 0 deletions dev-test/documents-readonly/app/types.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type UserQueryVariables = Exact<{
id: Scalars['ID']['input'];
}>;

export type UserQuery = {
__typename?: 'Query';
user?: { __typename?: 'User'; id: string; name: string; role: UserRole } | null;
};

export type UserFragmentFragment = {
__typename?: 'User';
id: string;
name: string;
role: UserRole;
};
7 changes: 7 additions & 0 deletions dev-test/documents-readonly/lib/UserFragment.graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* GraphQL */ `
fragment UserFragment on User {
id
name
role
}
`;
6 changes: 6 additions & 0 deletions dev-test/documents-readonly/lib/types.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type UserFragmentFragment = {
__typename?: 'User';
id: string;
name: string;
role: UserRole;
};
14 changes: 14 additions & 0 deletions dev-test/documents-readonly/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type Query {
user(id: ID!): User
}

type User {
id: ID!
name: String!
role: UserRole!
}

enum UserRole {
ADMIN
CUSTOMER
}
128 changes: 97 additions & 31 deletions packages/graphql-codegen-cli/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
normalizeOutputParam,
Types,
} from '@graphql-codegen/plugin-helpers';
import { NoTypeDefinitionsFound } from '@graphql-tools/load';
import { NoTypeDefinitionsFound, type UnnormalizedTypeDefPointer } from '@graphql-tools/load';
import { mergeTypeDefs } from '@graphql-tools/merge';
import { CodegenContext, ensureContext } from './config.js';
import { getDocumentTransform } from './documentTransforms.js';
Expand Down Expand Up @@ -86,6 +86,7 @@ export async function executeCodegen(
let rootConfig: { [key: string]: any } = {};
let rootSchemas: Types.Schema[];
let rootDocuments: Types.OperationDocument[];
let rootDocumentsReadOnly: Types.OperationDocument[];
const generates: { [filename: string]: Types.ConfiguredOutput } = {};

const cache = createCache();
Expand Down Expand Up @@ -136,6 +137,11 @@ export async function executeCodegen(
/* Normalize root "documents" field */
rootDocuments = normalizeInstanceOrArray<Types.OperationDocument>(config.documents);

/* Normalize root "documentsReadOnly" field */
rootDocumentsReadOnly = normalizeInstanceOrArray<Types.OperationDocument>(
config.documentsReadOnly,
);

/* Normalize "generators" field */
const generateKeys = Object.keys(config.generates || {});

Expand Down Expand Up @@ -228,13 +234,15 @@ export async function executeCodegen(
let outputSchemaAst: GraphQLSchema;
let outputSchema: DocumentNode;
const outputFileTemplateConfig = outputConfig.config || {};
let outputDocuments: Types.DocumentFile[] = [];
const outputDocuments: Types.DocumentFile[] = [];
const outputSpecificSchemas = normalizeInstanceOrArray<Types.Schema>(
outputConfig.schema,
);
let outputSpecificDocuments = normalizeInstanceOrArray<Types.OperationDocument>(
outputConfig.documents,
);
let outputSpecificDocumentsReadOnly =
normalizeInstanceOrArray<Types.OperationDocument>(outputConfig.documentsReadOnly);

const preset: Types.OutputPreset | null = hasPreset
? typeof outputConfig.preset === 'string'
Expand All @@ -247,6 +255,10 @@ export async function executeCodegen(
filename,
outputSpecificDocuments,
);
outputSpecificDocumentsReadOnly = await preset.prepareDocuments(
filename,
outputSpecificDocumentsReadOnly,
);
}

return subTask.newListr(
Expand Down Expand Up @@ -308,41 +320,97 @@ export async function executeCodegen(
task: wrapTask(
async () => {
debugLog(`[CLI] Loading Documents`);
const documentPointerMap: any = {};

const populateDocumentPointerMap = (
allDocumentsDenormalizedPointers: Types.OperationDocument[],
): UnnormalizedTypeDefPointer => {
const pointer: UnnormalizedTypeDefPointer = {};
for (const denormalizedPtr of allDocumentsDenormalizedPointers) {
if (typeof denormalizedPtr === 'string') {
pointer[denormalizedPtr] = {};
} else if (typeof denormalizedPtr === 'object') {
Object.assign(pointer, denormalizedPtr);
}
}
return pointer;
};

const allDocumentsDenormalizedPointers = [
...rootDocuments,
...outputSpecificDocuments,
];
for (const denormalizedPtr of allDocumentsDenormalizedPointers) {
if (typeof denormalizedPtr === 'string') {
documentPointerMap[denormalizedPtr] = {};
} else if (typeof denormalizedPtr === 'object') {
Object.assign(documentPointerMap, denormalizedPtr);
}
}
const documentPointerMap = populateDocumentPointerMap(
allDocumentsDenormalizedPointers,
);

const hash = JSON.stringify(documentPointerMap);
const result = await cache('documents', hash, async () => {
try {
const documents = await context.loadDocuments(documentPointerMap);
return {
documents,
};
} catch (error) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return {
documents: [],
};
const outputDocumentsStandard = await cache(
'documents',
hash,
async (): Promise<Types.DocumentFile[]> => {
try {
const documents = await context.loadDocuments(
documentPointerMap,
'standard',
);
return documents;
} catch (error) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return [];
}
throw error;
}
},
);

const allReadOnlyDenormalizedPointers = [
...rootDocumentsReadOnly,
...outputSpecificDocumentsReadOnly,
];

const readOnlyPointerMap = populateDocumentPointerMap(
allReadOnlyDenormalizedPointers,
);

const readOnlyHash = JSON.stringify(readOnlyPointerMap);
const outputDocumentsReadOnly = await cache(
'documents',
readOnlyHash,
async (): Promise<Types.DocumentFile[]> => {
try {
const documents = await context.loadDocuments(
readOnlyPointerMap,
'read-only',
);
return documents;
} catch (error) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return [];
}
throw error;
}
},
);

throw error;
const processedFile: Record<string, true> = {};
const mergedDocuments = [
...outputDocumentsStandard,
...outputDocumentsReadOnly,
];
for (const file of mergedDocuments) {
if (processedFile[file.hash]) {
continue;
}
});

outputDocuments = result.documents;
outputDocuments.push(file);
processedFile[file.hash] = true;
}
Comment on lines +401 to +413
Copy link
Copy Markdown
Collaborator

@eddeee888 eddeee888 Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've considered forwarding documentsReadOnly as-is but hit a few annoying cases:

  • documents are processed a lot after the initial loading stage here. If we kept documentsReadOnly separate, we'd need to run the same functions over them
  • Plugins use positional param, which means documentsReadOnly need to be at the last position to avoid a breaking change. This works, but feels disconnected

For this reason, I feel it's easier if we merge them as early as possible, and run the same processing over both types, and plugins like typescript-operations can handle the filtering without having to access another param

},
filename,
`Load GraphQL documents: ${filename}`,
Expand Down Expand Up @@ -437,7 +505,7 @@ export async function executeCodegen(
pluginContext,
profiler: context.profiler,
documentTransforms,
},
} satisfies Types.GenerateOptions,
];

const process = async (outputArgs: Types.GenerateOptions) => {
Expand Down Expand Up @@ -519,9 +587,7 @@ export async function executeCodegen(
const errors = executedContext.errors.map(subErr => subErr.message || subErr.toString());
error = new AggregateError(executedContext.errors, String(errors.join('\n\n')));
// Best-effort to all stack traces for debugging
error.stack = `${error.stack}\n\n${executedContext.errors
.map(subErr => subErr.stack)
.join('\n\n')}`;
error.stack = `${error.stack}\n\n${executedContext.errors.map(subErr => subErr.stack).join('\n\n')}`;
}

return { result, error };
Expand Down
42 changes: 25 additions & 17 deletions packages/graphql-codegen-cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRequire } from 'module';
import { resolve } from 'path';
import { cosmiconfig, defaultLoaders } from 'cosmiconfig';
import { GraphQLSchema, GraphQLSchemaExtensions, print } from 'graphql';
import { GraphQLConfig } from 'graphql-config';
import { GraphQLConfig, type Source } from 'graphql-config';
import { createJiti } from 'jiti';
import { env } from 'string-env-interpolation';
import yaml from 'yaml';
Expand All @@ -16,6 +16,7 @@ import {
Profiler,
Types,
} from '@graphql-codegen/plugin-helpers';
import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load';
import { findAndLoadGraphQLConfig } from './graphql-config.js';
import {
defaultDocumentsLoadOptions,
Expand Down Expand Up @@ -464,18 +465,22 @@ export class CodegenContext {
return addHashToSchema(loadSchema(pointer, config));
}

async loadDocuments(pointer: Types.OperationDocument[]): Promise<Types.DocumentFile[]> {
async loadDocuments(
pointer: UnnormalizedTypeDefPointer,
type: 'standard' | 'read-only',
): Promise<Types.DocumentFile[]> {
const config = this.getConfig(defaultDocumentsLoadOptions);
if (this._graphqlConfig) {
// TODO: pointer won't work here
return addHashToDocumentFiles(
return addMetadataToSources(
this._graphqlConfig
.getProject(this._project)
.loadDocuments(pointer, { ...config, ...config.config }),
type,
);
}

return addHashToDocumentFiles(loadDocuments(pointer, config));
return addMetadataToSources(loadDocuments(pointer, config), type);
}
}

Expand All @@ -502,24 +507,27 @@ function addHashToSchema(schemaPromise: Promise<GraphQLSchema>): Promise<GraphQL
});
}

function hashDocument(doc: Types.DocumentFile) {
if (doc.rawSDL) {
return hashContent(doc.rawSDL);
}
async function addMetadataToSources(
documentFilesPromise: Promise<Source[]>,
type: 'standard' | 'read-only',
): Promise<Types.DocumentFile[]> {
function hashDocument(doc: Source): string | null {
if (doc.rawSDL) {
return hashContent(doc.rawSDL);
}

if (doc.document) {
return hashContent(print(doc.document));
}
if (doc.document) {
return hashContent(print(doc.document));
}

return null;
}
return null;
}

function addHashToDocumentFiles(
documentFilesPromise: Promise<Types.DocumentFile[]>,
): Promise<Types.DocumentFile[]> {
return documentFilesPromise.then(documentFiles =>
documentFiles.map(doc => {
// Note: `doc` here is technically `Source`, but by the end of the funciton it's `Types.DocumentFile`. This re-declaration makes TypeScript happy.
documentFiles.map((doc: Types.DocumentFile): Types.DocumentFile => {
doc.hash = hashDocument(doc);
doc.type = type;

return doc;
}),
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql-codegen-cli/src/load.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extname, join } from 'path';
import { GraphQLError, GraphQLSchema } from 'graphql';
import type { Source } from 'graphql-config';
import { Types } from '@graphql-codegen/plugin-helpers';
import { ApolloEngineLoader } from '@graphql-tools/apollo-engine-loader';
import { CodeFileLoader } from '@graphql-tools/code-file-loader';
Expand Down Expand Up @@ -69,7 +70,7 @@ export async function loadSchema(
export async function loadDocuments(
documentPointers: UnnormalizedTypeDefPointer | UnnormalizedTypeDefPointer[],
config: Types.Config,
): Promise<Types.DocumentFile[]> {
): Promise<Source[]> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 2 new fields were added to Types.DocumentFile, not to Source, which makes is harder to see where the contract is established.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I don't fully understand this statement. However I've traced the CLI code to use the right type:

  • before adding metadata (hash, type), the documents are Source
  • after adding metadata the documents are DocumentFile

I feel this may not answer your concern. Could you please help me understand this a bit more?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit worried that the Types.DocumentFile[] has 2 new properties (hash and type) and Source doesn't have them - which might mislead a user.
But it is correct from the polymorphic world perspective. So - we can leave it as is.

const loaders = [
new CodeFileLoader({
pluckConfig: {
Expand Down
Loading
Loading