diff --git a/graphql/codegen/package.json b/graphql/codegen/package.json index 13b00be85..035d2610c 100644 --- a/graphql/codegen/package.json +++ b/graphql/codegen/package.json @@ -35,9 +35,9 @@ "scripts": { "clean": "makage clean", "prepack": "npm run build", - "copy:ts": "makage copy src/core/codegen/orm/query-builder.ts dist/core/codegen/orm --flat", - "build": "makage build && npm run copy:ts", - "build:dev": "makage build --dev && npm run copy:ts", + "copy:templates": "mkdir -p dist/core/codegen/templates && cp src/core/codegen/templates/*.ts dist/core/codegen/templates/", + "build": "makage build && npm run copy:templates", + "build:dev": "makage build --dev && npm run copy:templates", "dev": "ts-node ./src/index.ts", "lint": "eslint . --fix", "fmt": "oxfmt --write .", diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap index 938aeda85..11e9fad8f 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap @@ -104,10 +104,17 @@ exports[`client-generator generateOrmClientFile generates OrmClient class with e * @generated by @constructive-io/graphql-codegen * DO NOT EDIT - changes will be overwritten */ +import type { + GraphQLAdapter, + GraphQLError, + QueryResult, +} from '@constructive-io/graphql-types'; -import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types'; - -export type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types'; +export type { + GraphQLAdapter, + GraphQLError, + QueryResult, +} from '@constructive-io/graphql-types'; /** * Default adapter that uses fetch for HTTP requests. @@ -199,7 +206,7 @@ export class GraphQLRequestError extends Error { public readonly errors: GraphQLError[], public readonly data: unknown = null ) { - const messages = errors.map(e => e.message).join('; '); + const messages = errors.map((e) => e.message).join('; '); super(\`GraphQL Error: \${messages}\`); this.name = 'GraphQLRequestError'; } @@ -214,7 +221,9 @@ export class OrmClient { } else if (config.endpoint) { this.adapter = new FetchAdapter(config.endpoint, config.headers); } else { - throw new Error('OrmClientConfig requires either an endpoint or a custom adapter'); + throw new Error( + 'OrmClientConfig requires either an endpoint or a custom adapter' + ); } } @@ -252,7 +261,6 @@ exports[`client-generator generateSelectTypesFile generates select type utilitie * @generated by @constructive-io/graphql-codegen * DO NOT EDIT - changes will be overwritten */ - export interface ConnectionResult { nodes: T[]; totalCount: number; @@ -341,7 +349,9 @@ export type DeepExact = T extends Shape export type InferSelectResult = TSelect extends undefined ? TEntity : { - [K in keyof TSelect as TSelect[K] extends false | undefined ? never : K]: TSelect[K] extends true + [K in keyof TSelect as TSelect[K] extends false | undefined + ? never + : K]: TSelect[K] extends true ? K extends keyof TEntity ? TEntity[K] : never @@ -349,7 +359,9 @@ export type InferSelectResult = TSelect extends undefined ? K extends keyof TEntity ? NonNullable extends ConnectionResult ? ConnectionResult> - : InferSelectResult, NestedSelect> | (null extends TEntity[K] ? null : never) + : + | InferSelectResult, NestedSelect> + | (null extends TEntity[K] ? null : never) : never : K extends keyof TEntity ? TEntity[K] diff --git a/graphql/codegen/src/core/codegen/client.ts b/graphql/codegen/src/core/codegen/client.ts index 5db32717d..809c6f513 100644 --- a/graphql/codegen/src/core/codegen/client.ts +++ b/graphql/codegen/src/core/codegen/client.ts @@ -1,262 +1,74 @@ /** * Client generator - generates client.ts with configure() and execute() - */ -import { getGeneratedFileHeader } from './utils'; - -/** - * Generate client.ts content - */ -export function generateClientFile(): string { - return `${getGeneratedFileHeader('GraphQL client configuration and execution')} - -// ============================================================================ -// Configuration -// ============================================================================ - -export interface GraphQLClientConfig { - /** GraphQL endpoint URL */ - endpoint: string; - /** Default headers to include in all requests */ - headers?: Record; -} - -let globalConfig: GraphQLClientConfig | null = null; - -/** - * Configure the GraphQL client - * - * @example - * \`\`\`ts - * import { configure } from './generated'; - * - * configure({ - * endpoint: 'https://api.example.com/graphql', - * headers: { - * Authorization: 'Bearer ', - * }, - * }); - * \`\`\` - */ -export function configure(config: GraphQLClientConfig): void { - globalConfig = config; -} - -/** - * Get the current configuration - * @throws Error if not configured - */ -export function getConfig(): GraphQLClientConfig { - if (!globalConfig) { - throw new Error( - 'GraphQL client not configured. Call configure() before making requests.' - ); - } - return globalConfig; -} - -/** - * Set a single header value - * Useful for updating Authorization after login - * - * @example - * \`\`\`ts - * setHeader('Authorization', 'Bearer '); - * \`\`\` - */ -export function setHeader(key: string, value: string): void { - const config = getConfig(); - globalConfig = { - ...config, - headers: { ...config.headers, [key]: value }, - }; -} - -/** - * Merge multiple headers into the current configuration * - * @example - * \`\`\`ts - * setHeaders({ Authorization: 'Bearer ', 'X-Custom': 'value' }); - * \`\`\` + * Reads from template files in the templates/ directory for proper type checking. */ -export function setHeaders(headers: Record): void { - const config = getConfig(); - globalConfig = { - ...config, - headers: { ...config.headers, ...headers }, - }; -} - -// ============================================================================ -// Error handling -// ============================================================================ - -export interface GraphQLError { - message: string; - locations?: Array<{ line: number; column: number }>; - path?: Array; - extensions?: Record; -} - -export class GraphQLClientError extends Error { - constructor( - message: string, - public errors: GraphQLError[], - public response?: Response - ) { - super(message); - this.name = 'GraphQLClientError'; - } -} - -// ============================================================================ -// Execution -// ============================================================================ +import * as fs from 'fs'; +import * as path from 'path'; +import { getGeneratedFileHeader } from './utils'; -export interface ExecuteOptions { - /** Override headers for this request */ - headers?: Record; - /** AbortSignal for request cancellation */ - signal?: AbortSignal; +export interface GenerateClientFileOptions { + /** + * Generate browser-compatible code using native fetch + * When true (default), uses native W3C fetch API + * When false, uses undici fetch with dispatcher support for localhost DNS resolution + * @default true + */ + browserCompatible?: boolean; } /** - * Execute a GraphQL operation - * - * @example - * \`\`\`ts - * const result = await execute( - * carsQueryDocument, - * { first: 10 } - * ); - * \`\`\` + * Find a template file path. + * Templates are at ./templates/ relative to this file in both src/ and dist/. */ -export async function execute>( - document: string, - variables?: TVariables, - options?: ExecuteOptions -): Promise { - const config = getConfig(); - - const response = await fetch(config.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...config.headers, - ...options?.headers, - }, - body: JSON.stringify({ - query: document, - variables, - }), - signal: options?.signal, - }); +function findTemplateFile(templateName: string): string { + const templatePath = path.join(__dirname, 'templates', templateName); - const json = await response.json(); - - if (json.errors && json.errors.length > 0) { - throw new GraphQLClientError( - json.errors[0].message || 'GraphQL request failed', - json.errors, - response - ); + if (fs.existsSync(templatePath)) { + return templatePath; } - return json.data as TData; + throw new Error( + `Could not find template file: ${templateName}. ` + + `Searched in: ${templatePath}` + ); } /** - * Execute a GraphQL operation with full response (data + errors) - * Useful when you want to handle partial data with errors + * Read a template file and replace the header with generated file header */ -export async function executeWithErrors>( - document: string, - variables?: TVariables, - options?: ExecuteOptions -): Promise<{ data: TData | null; errors: GraphQLError[] | null }> { - const config = getConfig(); +function readTemplateFile(templateName: string, description: string): string { + const templatePath = findTemplateFile(templateName); + let content = fs.readFileSync(templatePath, 'utf-8'); - const response = await fetch(config.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...config.headers, - ...options?.headers, - }, - body: JSON.stringify({ - query: document, - variables, - }), - signal: options?.signal, - }); + // Replace the source file header comment with the generated file header + // Match the header pattern used in template files + const headerPattern = + /\/\*\*[\s\S]*?\* NOTE: This file is read at codegen time and written to output\.[\s\S]*?\*\/\n*/; - const json = await response.json(); + content = content.replace( + headerPattern, + getGeneratedFileHeader(description) + '\n' + ); - return { - data: json.data ?? null, - errors: json.errors ?? null, - }; + return content; } -// ============================================================================ -// QueryClient Factory -// ============================================================================ - -/** - * Default QueryClient configuration optimized for GraphQL - * - * These defaults provide a good balance between freshness and performance: - * - staleTime: 1 minute - data considered fresh, won't refetch - * - gcTime: 5 minutes - unused data kept in cache - * - refetchOnWindowFocus: false - don't refetch when tab becomes active - * - retry: 1 - retry failed requests once - */ -export const defaultQueryClientOptions = { - defaultOptions: { - queries: { - staleTime: 1000 * 60, // 1 minute - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnWindowFocus: false, - retry: 1, - }, - }, -}; - /** - * QueryClient options type for createQueryClient + * Generate client.ts content + * @param options - Generation options */ -export interface CreateQueryClientOptions { - defaultOptions?: { - queries?: { - staleTime?: number; - gcTime?: number; - refetchOnWindowFocus?: boolean; - retry?: number | boolean; - retryDelay?: number | ((attemptIndex: number) => number); - }; - mutations?: { - retry?: number | boolean; - retryDelay?: number | ((attemptIndex: number) => number); - }; - }; -} - -// Note: createQueryClient is available when using with @tanstack/react-query -// Import QueryClient from '@tanstack/react-query' and use these options: -// -// import { QueryClient } from '@tanstack/react-query'; -// const queryClient = new QueryClient(defaultQueryClientOptions); -// -// Or merge with your own options: -// const queryClient = new QueryClient({ -// ...defaultQueryClientOptions, -// defaultOptions: { -// ...defaultQueryClientOptions.defaultOptions, -// queries: { -// ...defaultQueryClientOptions.defaultOptions.queries, -// staleTime: 30000, // Override specific options -// }, -// }, -// }); -`; +export function generateClientFile( + options: GenerateClientFileOptions = {} +): string { + const { browserCompatible = true } = options; + + const templateName = browserCompatible + ? 'client.browser.ts' + : 'client.node.ts'; + + return readTemplateFile( + templateName, + 'GraphQL client configuration and execution' + ); } diff --git a/graphql/codegen/src/core/codegen/index.ts b/graphql/codegen/src/core/codegen/index.ts index d300cd7ae..407e10527 100644 --- a/graphql/codegen/src/core/codegen/index.ts +++ b/graphql/codegen/src/core/codegen/index.ts @@ -130,7 +130,9 @@ export function generate(options: GenerateOptions): GenerateResult { // 1. Generate client.ts files.push({ path: 'client.ts', - content: generateClientFile(), + content: generateClientFile({ + browserCompatible: config.browserCompatible ?? true, + }), }); // Collect table type names for import path resolution diff --git a/graphql/codegen/src/core/codegen/orm/client-generator.ts b/graphql/codegen/src/core/codegen/orm/client-generator.ts index 7b658cb4a..765d95807 100644 --- a/graphql/codegen/src/core/codegen/orm/client-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/client-generator.ts @@ -9,341 +9,86 @@ import { generateCode, commentBlock } from '../babel-ast'; import { getTableNames, lcFirst, getGeneratedFileHeader } from '../utils'; import * as fs from 'fs'; import * as path from 'path'; + export interface GeneratedClientFile { fileName: string; content: string; } /** - * Generate the main client.ts file (OrmClient class) - * This is the runtime client that handles GraphQL execution - */ -export function generateOrmClientFile(): GeneratedClientFile { - // This is runtime code that doesn't change based on schema - // We generate it as a static file - const content = `/** - * ORM Client - Runtime GraphQL executor - * @generated by @constructive-io/graphql-codegen - * DO NOT EDIT - changes will be overwritten + * Find a template file path. + * Templates are at ../templates/ relative to this file in both src/ and dist/. */ +function findTemplateFile(templateName: string): string { + const templatePath = path.join(__dirname, '../templates', templateName); -import type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types'; - -export type { GraphQLAdapter, GraphQLError, QueryResult } from '@constructive-io/graphql-types'; - -/** - * Default adapter that uses fetch for HTTP requests. - * This is used when no custom adapter is provided. - */ -export class FetchAdapter implements GraphQLAdapter { - private headers: Record; - - constructor( - private endpoint: string, - headers?: Record - ) { - this.headers = headers ?? {}; + if (fs.existsSync(templatePath)) { + return templatePath; } - async execute( - document: string, - variables?: Record - ): Promise> { - const response = await fetch(this.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...this.headers, - }, - body: JSON.stringify({ - query: document, - variables: variables ?? {}, - }), - }); - - if (!response.ok) { - return { - ok: false, - data: null, - errors: [{ message: \`HTTP \${response.status}: \${response.statusText}\` }], - }; - } - - const json = (await response.json()) as { - data?: T; - errors?: GraphQLError[]; - }; - - if (json.errors && json.errors.length > 0) { - return { - ok: false, - data: null, - errors: json.errors, - }; - } - - return { - ok: true, - data: json.data as T, - errors: undefined, - }; - } - - setHeaders(headers: Record): void { - this.headers = { ...this.headers, ...headers }; - } - - getEndpoint(): string { - return this.endpoint; - } -} - -/** - * Configuration for creating an ORM client. - * Either provide endpoint (and optional headers) for HTTP requests, - * or provide a custom adapter for alternative execution strategies. - */ -export interface OrmClientConfig { - /** GraphQL endpoint URL (required if adapter not provided) */ - endpoint?: string; - /** Default headers for HTTP requests (only used with endpoint) */ - headers?: Record; - /** Custom adapter for GraphQL execution (overrides endpoint/headers) */ - adapter?: GraphQLAdapter; + throw new Error( + `Could not find template file: ${templateName}. ` + + `Searched in: ${templatePath}` + ); } /** - * Error thrown when GraphQL request fails + * Read a template file and replace the header with generated file header */ -export class GraphQLRequestError extends Error { - constructor( - public readonly errors: GraphQLError[], - public readonly data: unknown = null - ) { - const messages = errors.map(e => e.message).join('; '); - super(\`GraphQL Error: \${messages}\`); - this.name = 'GraphQLRequestError'; - } -} +function readTemplateFile(templateName: string, description: string): string { + const templatePath = findTemplateFile(templateName); + let content = fs.readFileSync(templatePath, 'utf-8'); -export class OrmClient { - private adapter: GraphQLAdapter; - - constructor(config: OrmClientConfig) { - if (config.adapter) { - this.adapter = config.adapter; - } else if (config.endpoint) { - this.adapter = new FetchAdapter(config.endpoint, config.headers); - } else { - throw new Error('OrmClientConfig requires either an endpoint or a custom adapter'); - } - } - - async execute( - document: string, - variables?: Record - ): Promise> { - return this.adapter.execute(document, variables); - } + // Replace the source file header comment with the generated file header + // Match the header pattern used in template files + const headerPattern = + /\/\*\*[\s\S]*?\* NOTE: This file is read at codegen time and written to output\.[\s\S]*?\*\/\n*/; - /** - * Set headers for requests. - * Only works if the adapter supports headers. - */ - setHeaders(headers: Record): void { - if (this.adapter.setHeaders) { - this.adapter.setHeaders(headers); - } - } + content = content.replace( + headerPattern, + getGeneratedFileHeader(description) + '\n' + ); - /** - * Get the endpoint URL. - * Returns empty string if the adapter doesn't have an endpoint. - */ - getEndpoint(): string { - return this.adapter.getEndpoint?.() ?? ''; - } + return content; } -`; +/** + * Generate the main client.ts file (OrmClient class) + * This is the runtime client that handles GraphQL execution + * + * Reads from the templates directory for proper type checking. + */ +export function generateOrmClientFile(): GeneratedClientFile { return { fileName: 'client.ts', - content, + content: readTemplateFile('orm-client.ts', 'ORM Client - Runtime GraphQL executor'), }; } /** * Generate the query-builder.ts file (runtime query builder) * - * Reads from the actual TypeScript file in the source directory, - * which enables proper type checking and testability. + * Reads from the templates directory for proper type checking and testability. */ export function generateQueryBuilderFile(): GeneratedClientFile { - // Read the query-builder.ts source file - // Handle both development (src/) and production (dist/) scenarios - let sourceFilePath = path.join(__dirname, 'query-builder.ts'); - - // If running from dist/, look for the source in src/ instead - if (!fs.existsSync(sourceFilePath)) { - // Navigate from dist/cli/codegen/orm/ to src/cli/codegen/orm/ - sourceFilePath = path.resolve(__dirname, '../../../../src/cli/codegen/orm/query-builder.ts'); - } - - // If still not found, try relative to package root - if (!fs.existsSync(sourceFilePath)) { - // For installed packages, the file should be adjacent in the same dir - throw new Error( - `Could not find query-builder.ts source file. ` + - `Searched in: ${path.join(__dirname, 'query-builder.ts')} and ` + - `${path.resolve(__dirname, '../../../../src/cli/codegen/orm/query-builder.ts')}` - ); - } - - let sourceContent = fs.readFileSync(sourceFilePath, 'utf-8'); - - // Replace the source file header comment with the generated file header - const headerComment = `/** - * Query Builder - Builds and executes GraphQL operations - * - * This is the RUNTIME code that gets copied to generated output. - * It uses gql-ast to build GraphQL documents programmatically. - * - * NOTE: This file is read at codegen time and written to output. - * Any changes here will affect all generated ORM clients. - */`; - - const generatedHeader = `/** - * Query Builder - Builds and executes GraphQL operations - * @generated by @constructive-io/graphql-codegen - * DO NOT EDIT - changes will be overwritten - */`; - - sourceContent = sourceContent.replace(headerComment, generatedHeader); - return { fileName: 'query-builder.ts', - content: sourceContent, + content: readTemplateFile( + 'query-builder.ts', + 'Query Builder - Builds and executes GraphQL operations' + ), }; } /** * Generate the select-types.ts file - */ -export function generateSelectTypesFile(): GeneratedClientFile { - const content = `/** - * Type utilities for select inference - * @generated by @constructive-io/graphql-codegen - * DO NOT EDIT - changes will be overwritten - */ - -export interface ConnectionResult { - nodes: T[]; - totalCount: number; - pageInfo: PageInfo; -} - -export interface PageInfo { - hasNextPage: boolean; - hasPreviousPage: boolean; - startCursor?: string | null; - endCursor?: string | null; -} - -export interface FindManyArgs { - select?: TSelect; - where?: TWhere; - orderBy?: TOrderBy[]; - first?: number; - last?: number; - after?: string; - before?: string; - offset?: number; -} - -export interface FindFirstArgs { - select?: TSelect; - where?: TWhere; -} - -export interface CreateArgs { - data: TData; - select?: TSelect; -} - -export interface UpdateArgs { - where: TWhere; - data: TData; - select?: TSelect; -} - -export interface DeleteArgs { - where: TWhere; -} - -/** - * Recursively validates select objects, rejecting unknown keys. - * - * This type ensures that users can only select fields that actually exist - * in the GraphQL schema. It returns \`never\` if any excess keys are found - * at any nesting level, causing a TypeScript compile error. * - * Why this is needed: - * TypeScript's excess property checking has a quirk where it only catches - * invalid fields when they are the ONLY fields. When mixed with valid fields - * (e.g., \`{ id: true, invalidField: true }\`), the structural typing allows - * the excess property through. This type explicitly checks for and rejects - * such cases. - * - * @example - * // This will cause a type error because 'invalid' doesn't exist: - * type Result = DeepExact<{ id: true, invalid: true }, { id?: boolean }>; - * // Result = never (causes assignment error) - * - * @example - * // This works because all fields are valid: - * type Result = DeepExact<{ id: true }, { id?: boolean; name?: boolean }>; - * // Result = { id: true } + * Reads from the templates directory for proper type checking. */ -export type DeepExact = T extends Shape - ? Exclude extends never - ? { - [K in keyof T]: K extends keyof Shape - ? T[K] extends { select: infer NS } - ? Shape[K] extends { select?: infer ShapeNS } - ? { select: DeepExact> } - : T[K] - : T[K] - : never; - } - : never - : never; - -/** - * Infer result type from select configuration - */ -export type InferSelectResult = TSelect extends undefined - ? TEntity - : { - [K in keyof TSelect as TSelect[K] extends false | undefined ? never : K]: TSelect[K] extends true - ? K extends keyof TEntity - ? TEntity[K] - : never - : TSelect[K] extends { select: infer NestedSelect } - ? K extends keyof TEntity - ? NonNullable extends ConnectionResult - ? ConnectionResult> - : InferSelectResult, NestedSelect> | (null extends TEntity[K] ? null : never) - : never - : K extends keyof TEntity - ? TEntity[K] - : never; - }; -`; - +export function generateSelectTypesFile(): GeneratedClientFile { return { fileName: 'select-types.ts', - content, + content: readTemplateFile('select-types.ts', 'Type utilities for select inference'), }; } diff --git a/graphql/codegen/src/core/codegen/templates/client.browser.ts b/graphql/codegen/src/core/codegen/templates/client.browser.ts new file mode 100644 index 000000000..06344f1ec --- /dev/null +++ b/graphql/codegen/src/core/codegen/templates/client.browser.ts @@ -0,0 +1,265 @@ +/** + * GraphQL client configuration and execution (Browser-compatible) + * + * This is the RUNTIME code that gets copied to generated output. + * Uses native W3C fetch API for browser compatibility. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated clients. + */ + +// ============================================================================ +// Configuration +// ============================================================================ + +export interface GraphQLClientConfig { + /** GraphQL endpoint URL */ + endpoint: string; + /** Default headers to include in all requests */ + headers?: Record; +} + +let globalConfig: GraphQLClientConfig | null = null; + +/** + * Configure the GraphQL client + * + * @example + * ```ts + * import { configure } from './generated'; + * + * configure({ + * endpoint: 'https://api.example.com/graphql', + * headers: { + * Authorization: 'Bearer ', + * }, + * }); + * ``` + */ +export function configure(config: GraphQLClientConfig): void { + globalConfig = config; +} + +/** + * Get the current configuration + * @throws Error if not configured + */ +export function getConfig(): GraphQLClientConfig { + if (!globalConfig) { + throw new Error( + 'GraphQL client not configured. Call configure() before making requests.' + ); + } + return globalConfig; +} + +/** + * Set a single header value + * Useful for updating Authorization after login + * + * @example + * ```ts + * setHeader('Authorization', 'Bearer '); + * ``` + */ +export function setHeader(key: string, value: string): void { + const config = getConfig(); + globalConfig = { + ...config, + headers: { ...config.headers, [key]: value }, + }; +} + +/** + * Merge multiple headers into the current configuration + * + * @example + * ```ts + * setHeaders({ Authorization: 'Bearer ', 'X-Custom': 'value' }); + * ``` + */ +export function setHeaders(headers: Record): void { + const config = getConfig(); + globalConfig = { + ...config, + headers: { ...config.headers, ...headers }, + }; +} + +// ============================================================================ +// Error handling +// ============================================================================ + +export interface GraphQLError { + message: string; + locations?: Array<{ line: number; column: number }>; + path?: Array; + extensions?: Record; +} + +export class GraphQLClientError extends Error { + constructor( + message: string, + public errors: GraphQLError[], + public response?: Response + ) { + super(message); + this.name = 'GraphQLClientError'; + } +} + +// ============================================================================ +// Execution +// ============================================================================ + +export interface ExecuteOptions { + /** Override headers for this request */ + headers?: Record; + /** AbortSignal for request cancellation */ + signal?: AbortSignal; +} + +/** + * Execute a GraphQL operation + * + * @example + * ```ts + * const result = await execute( + * carsQueryDocument, + * { first: 10 } + * ); + * ``` + */ +export async function execute< + TData = unknown, + TVariables = Record, +>( + document: string, + variables?: TVariables, + options?: ExecuteOptions +): Promise { + const config = getConfig(); + + const response = await fetch(config.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...config.headers, + ...options?.headers, + }, + body: JSON.stringify({ + query: document, + variables, + }), + signal: options?.signal, + }); + + const json = await response.json(); + + if (json.errors && json.errors.length > 0) { + throw new GraphQLClientError( + json.errors[0].message || 'GraphQL request failed', + json.errors, + response + ); + } + + return json.data as TData; +} + +/** + * Execute a GraphQL operation with full response (data + errors) + * Useful when you want to handle partial data with errors + */ +export async function executeWithErrors< + TData = unknown, + TVariables = Record, +>( + document: string, + variables?: TVariables, + options?: ExecuteOptions +): Promise<{ data: TData | null; errors: GraphQLError[] | null }> { + const config = getConfig(); + + const response = await fetch(config.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...config.headers, + ...options?.headers, + }, + body: JSON.stringify({ + query: document, + variables, + }), + signal: options?.signal, + }); + + const json = await response.json(); + + return { + data: json.data ?? null, + errors: json.errors ?? null, + }; +} + +// ============================================================================ +// QueryClient Factory +// ============================================================================ + +/** + * Default QueryClient configuration optimized for GraphQL + * + * These defaults provide a good balance between freshness and performance: + * - staleTime: 1 minute - data considered fresh, won't refetch + * - gcTime: 5 minutes - unused data kept in cache + * - refetchOnWindowFocus: false - don't refetch when tab becomes active + * - retry: 1 - retry failed requests once + */ +export const defaultQueryClientOptions = { + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}; + +/** + * QueryClient options type for createQueryClient + */ +export interface CreateQueryClientOptions { + defaultOptions?: { + queries?: { + staleTime?: number; + gcTime?: number; + refetchOnWindowFocus?: boolean; + retry?: number | boolean; + retryDelay?: number | ((attemptIndex: number) => number); + }; + mutations?: { + retry?: number | boolean; + retryDelay?: number | ((attemptIndex: number) => number); + }; + }; +} + +// Note: createQueryClient is available when using with @tanstack/react-query +// Import QueryClient from '@tanstack/react-query' and use these options: +// +// import { QueryClient } from '@tanstack/react-query'; +// const queryClient = new QueryClient(defaultQueryClientOptions); +// +// Or merge with your own options: +// const queryClient = new QueryClient({ +// ...defaultQueryClientOptions, +// defaultOptions: { +// ...defaultQueryClientOptions.defaultOptions, +// queries: { +// ...defaultQueryClientOptions.defaultOptions.queries, +// staleTime: 30000, // Override specific options +// }, +// }, +// }); diff --git a/graphql/codegen/src/core/codegen/templates/client.node.ts b/graphql/codegen/src/core/codegen/templates/client.node.ts new file mode 100644 index 000000000..e38725782 --- /dev/null +++ b/graphql/codegen/src/core/codegen/templates/client.node.ts @@ -0,0 +1,350 @@ +/** + * GraphQL client configuration and execution (Node.js with undici) + * + * This is the RUNTIME code that gets copied to generated output. + * Uses undici fetch with dispatcher support for localhost DNS resolution. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated clients. + */ + +import dns from 'node:dns'; +import { Agent, fetch, type RequestInit } from 'undici'; + +// ============================================================================ +// Localhost DNS Resolution +// ============================================================================ + +/** + * Check if a hostname is localhost or a localhost subdomain + */ +function isLocalhostHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost'); +} + +/** + * Create an undici Agent that resolves *.localhost to 127.0.0.1 + * This fixes DNS resolution issues on macOS where subdomains like api.localhost + * don't resolve automatically (unlike browsers which handle *.localhost). + */ +function createLocalhostAgent(): Agent { + return new Agent({ + connect: { + lookup(hostname, opts, cb) { + if (isLocalhostHostname(hostname)) { + // When opts.all is true, callback expects an array of {address, family} objects + // When opts.all is false/undefined, callback expects (err, address, family) + if (opts.all) { + cb(null, [{ address: '127.0.0.1', family: 4 }]); + } else { + cb(null, '127.0.0.1', 4); + } + return; + } + dns.lookup(hostname, opts, cb); + }, + }, + }); +} + +let localhostAgent: Agent | null = null; + +function getLocalhostAgent(): Agent { + if (!localhostAgent) { + localhostAgent = createLocalhostAgent(); + } + return localhostAgent; +} + +/** + * Get fetch options with localhost agent if needed + */ +function getFetchOptions( + endpoint: string, + baseOptions: RequestInit +): RequestInit { + const url = new URL(endpoint); + if (isLocalhostHostname(url.hostname)) { + const options: RequestInit = { + ...baseOptions, + dispatcher: getLocalhostAgent(), + }; + // Set Host header for localhost subdomains to preserve routing + if (url.hostname !== 'localhost') { + options.headers = { + ...(baseOptions.headers as Record), + Host: url.hostname, + }; + } + return options; + } + return baseOptions; +} + +// ============================================================================ +// Configuration +// ============================================================================ + +export interface GraphQLClientConfig { + /** GraphQL endpoint URL */ + endpoint: string; + /** Default headers to include in all requests */ + headers?: Record; +} + +let globalConfig: GraphQLClientConfig | null = null; + +/** + * Configure the GraphQL client + * + * @example + * ```ts + * import { configure } from './generated'; + * + * configure({ + * endpoint: 'https://api.example.com/graphql', + * headers: { + * Authorization: 'Bearer ', + * }, + * }); + * ``` + */ +export function configure(config: GraphQLClientConfig): void { + globalConfig = config; +} + +/** + * Get the current configuration + * @throws Error if not configured + */ +export function getConfig(): GraphQLClientConfig { + if (!globalConfig) { + throw new Error( + 'GraphQL client not configured. Call configure() before making requests.' + ); + } + return globalConfig; +} + +/** + * Set a single header value + * Useful for updating Authorization after login + * + * @example + * ```ts + * setHeader('Authorization', 'Bearer '); + * ``` + */ +export function setHeader(key: string, value: string): void { + const config = getConfig(); + globalConfig = { + ...config, + headers: { ...config.headers, [key]: value }, + }; +} + +/** + * Merge multiple headers into the current configuration + * + * @example + * ```ts + * setHeaders({ Authorization: 'Bearer ', 'X-Custom': 'value' }); + * ``` + */ +export function setHeaders(headers: Record): void { + const config = getConfig(); + globalConfig = { + ...config, + headers: { ...config.headers, ...headers }, + }; +} + +// ============================================================================ +// Error handling +// ============================================================================ + +export interface GraphQLError { + message: string; + locations?: Array<{ line: number; column: number }>; + path?: Array; + extensions?: Record; +} + +export class GraphQLClientError extends Error { + constructor( + message: string, + public errors: GraphQLError[], + public response?: Response + ) { + super(message); + this.name = 'GraphQLClientError'; + } +} + +// ============================================================================ +// Execution +// ============================================================================ + +export interface ExecuteOptions { + /** Override headers for this request */ + headers?: Record; + /** AbortSignal for request cancellation */ + signal?: AbortSignal; +} + +/** + * Execute a GraphQL operation + * + * @example + * ```ts + * const result = await execute( + * carsQueryDocument, + * { first: 10 } + * ); + * ``` + */ +export async function execute< + TData = unknown, + TVariables = Record, +>( + document: string, + variables?: TVariables, + options?: ExecuteOptions +): Promise { + const config = getConfig(); + + const baseOptions: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...config.headers, + ...options?.headers, + }, + body: JSON.stringify({ + query: document, + variables, + }), + signal: options?.signal, + }; + + const fetchOptions = getFetchOptions(config.endpoint, baseOptions); + const response = await fetch(config.endpoint, fetchOptions); + + const json = (await response.json()) as { + data?: TData; + errors?: GraphQLError[]; + }; + + if (json.errors && json.errors.length > 0) { + throw new GraphQLClientError( + json.errors[0].message || 'GraphQL request failed', + json.errors, + response as unknown as Response + ); + } + + return json.data as TData; +} + +/** + * Execute a GraphQL operation with full response (data + errors) + * Useful when you want to handle partial data with errors + */ +export async function executeWithErrors< + TData = unknown, + TVariables = Record, +>( + document: string, + variables?: TVariables, + options?: ExecuteOptions +): Promise<{ data: TData | null; errors: GraphQLError[] | null }> { + const config = getConfig(); + + const baseOptions: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...config.headers, + ...options?.headers, + }, + body: JSON.stringify({ + query: document, + variables, + }), + signal: options?.signal, + }; + + const fetchOptions = getFetchOptions(config.endpoint, baseOptions); + const response = await fetch(config.endpoint, fetchOptions); + + const json = (await response.json()) as { + data?: TData; + errors?: GraphQLError[]; + }; + + return { + data: json.data ?? null, + errors: json.errors ?? null, + }; +} + +// ============================================================================ +// QueryClient Factory +// ============================================================================ + +/** + * Default QueryClient configuration optimized for GraphQL + * + * These defaults provide a good balance between freshness and performance: + * - staleTime: 1 minute - data considered fresh, won't refetch + * - gcTime: 5 minutes - unused data kept in cache + * - refetchOnWindowFocus: false - don't refetch when tab becomes active + * - retry: 1 - retry failed requests once + */ +export const defaultQueryClientOptions = { + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}; + +/** + * QueryClient options type for createQueryClient + */ +export interface CreateQueryClientOptions { + defaultOptions?: { + queries?: { + staleTime?: number; + gcTime?: number; + refetchOnWindowFocus?: boolean; + retry?: number | boolean; + retryDelay?: number | ((attemptIndex: number) => number); + }; + mutations?: { + retry?: number | boolean; + retryDelay?: number | ((attemptIndex: number) => number); + }; + }; +} + +// Note: createQueryClient is available when using with @tanstack/react-query +// Import QueryClient from '@tanstack/react-query' and use these options: +// +// import { QueryClient } from '@tanstack/react-query'; +// const queryClient = new QueryClient(defaultQueryClientOptions); +// +// Or merge with your own options: +// const queryClient = new QueryClient({ +// ...defaultQueryClientOptions, +// defaultOptions: { +// ...defaultQueryClientOptions.defaultOptions, +// queries: { +// ...defaultQueryClientOptions.defaultOptions.queries, +// staleTime: 30000, // Override specific options +// }, +// }, +// }); diff --git a/graphql/codegen/src/core/codegen/templates/orm-client.ts b/graphql/codegen/src/core/codegen/templates/orm-client.ts new file mode 100644 index 000000000..a71633d67 --- /dev/null +++ b/graphql/codegen/src/core/codegen/templates/orm-client.ts @@ -0,0 +1,158 @@ +/** + * ORM Client - Runtime GraphQL executor + * + * This is the RUNTIME code that gets copied to generated output. + * Provides the core ORM client functionality for executing GraphQL operations. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated ORM clients. + */ + +import type { + GraphQLAdapter, + GraphQLError, + QueryResult, +} from '@constructive-io/graphql-types'; + +export type { + GraphQLAdapter, + GraphQLError, + QueryResult, +} from '@constructive-io/graphql-types'; + +/** + * Default adapter that uses fetch for HTTP requests. + * This is used when no custom adapter is provided. + */ +export class FetchAdapter implements GraphQLAdapter { + private headers: Record; + + constructor( + private endpoint: string, + headers?: Record + ) { + this.headers = headers ?? {}; + } + + async execute( + document: string, + variables?: Record + ): Promise> { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...this.headers, + }, + body: JSON.stringify({ + query: document, + variables: variables ?? {}, + }), + }); + + if (!response.ok) { + return { + ok: false, + data: null, + errors: [{ message: `HTTP ${response.status}: ${response.statusText}` }], + }; + } + + const json = (await response.json()) as { + data?: T; + errors?: GraphQLError[]; + }; + + if (json.errors && json.errors.length > 0) { + return { + ok: false, + data: null, + errors: json.errors, + }; + } + + return { + ok: true, + data: json.data as T, + errors: undefined, + }; + } + + setHeaders(headers: Record): void { + this.headers = { ...this.headers, ...headers }; + } + + getEndpoint(): string { + return this.endpoint; + } +} + +/** + * Configuration for creating an ORM client. + * Either provide endpoint (and optional headers) for HTTP requests, + * or provide a custom adapter for alternative execution strategies. + */ +export interface OrmClientConfig { + /** GraphQL endpoint URL (required if adapter not provided) */ + endpoint?: string; + /** Default headers for HTTP requests (only used with endpoint) */ + headers?: Record; + /** Custom adapter for GraphQL execution (overrides endpoint/headers) */ + adapter?: GraphQLAdapter; +} + +/** + * Error thrown when GraphQL request fails + */ +export class GraphQLRequestError extends Error { + constructor( + public readonly errors: GraphQLError[], + public readonly data: unknown = null + ) { + const messages = errors.map((e) => e.message).join('; '); + super(`GraphQL Error: ${messages}`); + this.name = 'GraphQLRequestError'; + } +} + +export class OrmClient { + private adapter: GraphQLAdapter; + + constructor(config: OrmClientConfig) { + if (config.adapter) { + this.adapter = config.adapter; + } else if (config.endpoint) { + this.adapter = new FetchAdapter(config.endpoint, config.headers); + } else { + throw new Error( + 'OrmClientConfig requires either an endpoint or a custom adapter' + ); + } + } + + async execute( + document: string, + variables?: Record + ): Promise> { + return this.adapter.execute(document, variables); + } + + /** + * Set headers for requests. + * Only works if the adapter supports headers. + */ + setHeaders(headers: Record): void { + if (this.adapter.setHeaders) { + this.adapter.setHeaders(headers); + } + } + + /** + * Get the endpoint URL. + * Returns empty string if the adapter doesn't have an endpoint. + */ + getEndpoint(): string { + return this.adapter.getEndpoint?.() ?? ''; + } +} diff --git a/graphql/codegen/src/core/codegen/orm/query-builder.ts b/graphql/codegen/src/core/codegen/templates/query-builder.ts similarity index 100% rename from graphql/codegen/src/core/codegen/orm/query-builder.ts rename to graphql/codegen/src/core/codegen/templates/query-builder.ts diff --git a/graphql/codegen/src/core/codegen/templates/select-types.ts b/graphql/codegen/src/core/codegen/templates/select-types.ts new file mode 100644 index 000000000..4f0360a77 --- /dev/null +++ b/graphql/codegen/src/core/codegen/templates/select-types.ts @@ -0,0 +1,116 @@ +/** + * Type utilities for select inference + * + * This is the RUNTIME code that gets copied to generated output. + * Provides type utilities for ORM select operations. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated ORM clients. + */ + +export interface ConnectionResult { + nodes: T[]; + totalCount: number; + pageInfo: PageInfo; +} + +export interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; +} + +export interface FindManyArgs { + select?: TSelect; + where?: TWhere; + orderBy?: TOrderBy[]; + first?: number; + last?: number; + after?: string; + before?: string; + offset?: number; +} + +export interface FindFirstArgs { + select?: TSelect; + where?: TWhere; +} + +export interface CreateArgs { + data: TData; + select?: TSelect; +} + +export interface UpdateArgs { + where: TWhere; + data: TData; + select?: TSelect; +} + +export interface DeleteArgs { + where: TWhere; +} + +/** + * Recursively validates select objects, rejecting unknown keys. + * + * This type ensures that users can only select fields that actually exist + * in the GraphQL schema. It returns `never` if any excess keys are found + * at any nesting level, causing a TypeScript compile error. + * + * Why this is needed: + * TypeScript's excess property checking has a quirk where it only catches + * invalid fields when they are the ONLY fields. When mixed with valid fields + * (e.g., `{ id: true, invalidField: true }`), the structural typing allows + * the excess property through. This type explicitly checks for and rejects + * such cases. + * + * @example + * // This will cause a type error because 'invalid' doesn't exist: + * type Result = DeepExact<{ id: true, invalid: true }, { id?: boolean }>; + * // Result = never (causes assignment error) + * + * @example + * // This works because all fields are valid: + * type Result = DeepExact<{ id: true }, { id?: boolean; name?: boolean }>; + * // Result = { id: true } + */ +export type DeepExact = T extends Shape + ? Exclude extends never + ? { + [K in keyof T]: K extends keyof Shape + ? T[K] extends { select: infer NS } + ? Shape[K] extends { select?: infer ShapeNS } + ? { select: DeepExact> } + : T[K] + : T[K] + : never; + } + : never + : never; + +/** + * Infer result type from select configuration + */ +export type InferSelectResult = TSelect extends undefined + ? TEntity + : { + [K in keyof TSelect as TSelect[K] extends false | undefined + ? never + : K]: TSelect[K] extends true + ? K extends keyof TEntity + ? TEntity[K] + : never + : TSelect[K] extends { select: infer NestedSelect } + ? K extends keyof TEntity + ? NonNullable extends ConnectionResult + ? ConnectionResult> + : + | InferSelectResult, NestedSelect> + | (null extends TEntity[K] ? null : never) + : never + : K extends keyof TEntity + ? TEntity[K] + : never; + }; diff --git a/graphql/codegen/src/core/introspect/fetch-schema.ts b/graphql/codegen/src/core/introspect/fetch-schema.ts index 383552f78..49829bf9f 100644 --- a/graphql/codegen/src/core/introspect/fetch-schema.ts +++ b/graphql/codegen/src/core/introspect/fetch-schema.ts @@ -2,7 +2,7 @@ * Fetch GraphQL schema introspection from an endpoint */ import dns from 'node:dns'; -import { Agent } from 'undici'; +import { Agent, fetch, type RequestInit } from 'undici'; import { SCHEMA_INTROSPECTION_QUERY } from './schema-query'; import type { IntrospectionQueryResponse } from '../../types/introspection'; @@ -23,7 +23,13 @@ function createLocalhostAgent(): Agent { connect: { lookup(hostname, opts, cb) { if (isLocalhostHostname(hostname)) { - cb(null, '127.0.0.1', 4); + // When opts.all is true, callback expects an array of {address, family} objects + // When opts.all is false/undefined, callback expects (err, address, family) + if (opts.all) { + cb(null, [{ address: '127.0.0.1', family: 4 }]); + } else { + cb(null, '127.0.0.1', 4); + } return; } dns.lookup(hostname, opts, cb); @@ -91,8 +97,8 @@ export async function fetchSchema( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - // Build fetch options - const fetchOptions: RequestInit & { dispatcher?: Agent } = { + // Build fetch options using undici's RequestInit type + const fetchOptions: RequestInit = { method: 'POST', headers: requestHeaders, body: JSON.stringify({ diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index f73ca4f81..bae0a864f 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -261,6 +261,15 @@ export interface GraphQLSDKConfigTarget { */ reactQuery?: boolean; + /** + * Generate browser-compatible code using native fetch + * When true (default), uses native W3C fetch API (works in browsers and Node.js) + * When false, uses undici fetch with dispatcher support for localhost DNS resolution + * (Node.js only - enables proper *.localhost subdomain resolution on macOS) + * @default true + */ + browserCompatible?: boolean; + /** * Query key generation configuration * Controls how query keys are structured for cache management @@ -398,6 +407,7 @@ export const DEFAULT_CONFIG: GraphQLSDKConfigTarget = { }, orm: false, reactQuery: false, + browserCompatible: true, queryKeys: DEFAULT_QUERY_KEY_CONFIG, watch: DEFAULT_WATCH_CONFIG, }; diff --git a/graphql/codegen/tsconfig.json b/graphql/codegen/tsconfig.json index efe6742a0..a820886fd 100644 --- a/graphql/codegen/tsconfig.json +++ b/graphql/codegen/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src/**/*"], - "exclude": ["src/__tests__/**/*"] + "exclude": ["src/__tests__/**/*", "src/core/codegen/templates/**/*"] }