diff --git a/graphql/codegen/README.md b/graphql/codegen/README.md index 100495d74..349f36c41 100644 --- a/graphql/codegen/README.md +++ b/graphql/codegen/README.md @@ -12,7 +12,7 @@

-CLI-based GraphQL SDK generator for PostGraphile endpoints. Generate type-safe React Query hooks or a Prisma-like ORM client from your GraphQL schema. +GraphQL SDK generator for Constructive databases. Generate type-safe React Query hooks or a Prisma-like ORM client from your GraphQL schema. ## Features @@ -24,29 +24,21 @@ CLI-based GraphQL SDK generator for PostGraphile endpoints. Generate type-safe R - **Advanced Type Inference**: Const generics for narrowed return types based on select clauses - **Relation Support**: Typed nested selects for belongsTo, hasMany, and manyToMany relations - **Error Handling**: Discriminated unions with `.unwrap()`, `.unwrapOr()`, `.unwrapOrElse()` methods -- **AST-Based Generation**: Uses `ts-morph` for reliable code generation +- **AST-Based Generation**: Uses Babel for reliable code generation - **Configurable**: Filter tables, queries, and mutations with glob patterns - **Type-Safe**: Full TypeScript support with generated interfaces ## Table of Contents - [Installation](#installation) -- [Quick Start](#quick-start) -- [CLI Commands](#cli-commands) -- [Configuration](#configuration) +- [Programmatic API](#programmatic-api) - [React Query Hooks](#react-query-hooks) - [ORM Client](#orm-client) - - [Basic Usage](#basic-usage) - - [Select & Type Inference](#select--type-inference) - - [Relations](#relations) - - [Filtering & Ordering](#filtering--ordering) - - [Pagination](#pagination) - - [Error Handling](#error-handling) - - [Custom Operations](#custom-operations) +- [Configuration](#configuration) +- [CLI Commands](#cli-commands) - [Architecture](#architecture) - [Generated Types](#generated-types) - [Development](#development) -- [Roadmap](#roadmap) ## Installation @@ -54,217 +46,195 @@ CLI-based GraphQL SDK generator for PostGraphile endpoints. Generate type-safe R pnpm add @constructive-io/graphql-codegen ``` -## Quick Start +## Programmatic API -### 1. Initialize Config (Optional) +The primary way to use this package is through the programmatic API. Import the `generate` function and call it with your configuration. -```bash -npx graphql-sdk init -``` - -Creates a `graphql-sdk.config.ts` file: +### Generate from Endpoint ```typescript -import { defineConfig } from '@constructive-io/graphql-codegen'; +import { generate } from '@constructive-io/graphql-codegen'; -export default defineConfig({ +// Generate React Query hooks from a GraphQL endpoint +await generate({ endpoint: 'https://api.example.com/graphql', - output: './generated/graphql', - headers: { - Authorization: 'Bearer ', - }, + output: './generated', + headers: { Authorization: 'Bearer ' }, + reactQuery: true, }); -``` - -### 2. Generate SDK -```bash -# Generate React Query hooks -npx graphql-sdk generate -e https://api.example.com/graphql -o ./generated/hooks +// Generate ORM client from a GraphQL endpoint +await generate({ + endpoint: 'https://api.example.com/graphql', + output: './generated', + headers: { Authorization: 'Bearer ' }, + orm: true, +}); -# Generate ORM client -npx graphql-sdk generate-orm -e https://api.example.com/graphql -o ./generated/orm +// Generate both React Query hooks and ORM client +await generate({ + endpoint: 'https://api.example.com/graphql', + output: './generated', + reactQuery: true, + orm: true, +}); ``` -### 3. Use the Generated Code - -```typescript -// ORM Client -import { createClient } from './generated/orm'; - -const db = createClient({ endpoint: 'https://api.example.com/graphql' }); - -const users = await db.user.findMany({ - select: { id: true, username: true }, - first: 10, -}).execute(); - -// React Query Hooks -import { useCarsQuery } from './generated/hooks'; +### Generate from Database -function CarList() { - const { data } = useCarsQuery({ first: 10 }); - return
    {data?.cars.nodes.map(car =>
  • {car.name}
  • )}
; -} -``` +Connect directly to a PostgreSQL database to generate code: -## CLI Commands +```typescript +import { generate } from '@constructive-io/graphql-codegen'; -### `graphql-sdk generate` +// Generate from database with explicit schemas +await generate({ + db: { + schemas: ['public', 'app_public'], + }, + output: './generated', + reactQuery: true, +}); -Generate React Query hooks from a PostGraphile endpoint. +// Generate from database using API names for automatic schema discovery +await generate({ + db: { + apiNames: ['my_api'], + }, + output: './generated', + orm: true, +}); -```bash -Options: - -e, --endpoint GraphQL endpoint URL (overrides config) - -t, --target Target name in config file - -o, --output Output directory (default: ./generated/graphql) - -c, --config Path to config file - -a, --authorization Authorization header value - --dry-run Preview without writing files - --skip-custom-operations Only generate table CRUD hooks - -v, --verbose Show detailed output +// Generate with explicit database config (overrides environment variables) +await generate({ + db: { + config: { + host: 'localhost', + port: 5432, + database: 'mydb', + user: 'postgres', + }, + schemas: ['public'], + }, + output: './generated', + reactQuery: true, +}); ``` -### `graphql-sdk generate-orm` +### Generate from PGPM Module -Generate Prisma-like ORM client from a PostGraphile endpoint. +Generate code from a PGPM module path: -```bash -Options: - -e, --endpoint GraphQL endpoint URL - -t, --target Target name in config file - -o, --output Output directory (default: ./generated/orm) - -c, --config Path to config file - -a, --authorization Authorization header value - --skip-custom-operations Only generate table models - --dry-run Preview without writing files - -v, --verbose Show detailed output -``` +```typescript +import { generate } from '@constructive-io/graphql-codegen'; -### `graphql-sdk init` +// Generate from a PGPM module directory +await generate({ + db: { + pgpm: { modulePath: './packages/my-module' }, + schemas: ['public'], + }, + output: './generated', + reactQuery: true, +}); -Create a configuration file. +// Generate from a PGPM workspace with module name +await generate({ + db: { + pgpm: { + workspacePath: '/path/to/workspace', + moduleName: 'my-module', + }, + schemas: ['public'], + }, + output: './generated', + orm: true, +}); -```bash -Options: - -f, --format Config format: ts, js, json (default: ts) - -o, --output Output path for config file +// Keep the ephemeral database after generation (for debugging) +await generate({ + db: { + pgpm: { modulePath: './packages/my-module' }, + schemas: ['public'], + keepDb: true, + }, + output: './generated', + reactQuery: true, +}); ``` -### `graphql-sdk introspect` - -Inspect schema without generating code. - -```bash -Options: - -e, --endpoint GraphQL endpoint URL - --json Output as JSON - -v, --verbose Show detailed output -``` +### Configuration Options -## Configuration +The `generate` function accepts a configuration object with the following options: ```typescript interface GraphQLSDKConfigTarget { - // Required (choose one) - endpoint?: string; - schema?: string; + // Source (choose one) + endpoint?: string; // GraphQL endpoint URL + schemaFile?: string; // Path to GraphQL schema file (.graphql) + db?: DbConfig; // Database configuration (see below) // Output - output?: string; // default: './generated/graphql' + output?: string; // Output directory (default: './generated/graphql') // Authentication - headers?: Record; + headers?: Record; // HTTP headers for endpoint requests + + // Generator flags + reactQuery?: boolean; // Generate React Query hooks (output: {output}/hooks) + orm?: boolean; // Generate ORM client (output: {output}/orm) // Table filtering (for CRUD operations from _meta) tables?: { - include?: string[]; // default: ['*'] - exclude?: string[]; // default: [] + include?: string[]; // Glob patterns (default: ['*']) + exclude?: string[]; // Glob patterns (default: []) }; // Query filtering (for ALL queries from __schema) queries?: { - include?: string[]; // default: ['*'] - exclude?: string[]; // default: ['_meta', 'query'] + include?: string[]; // Glob patterns (default: ['*']) + exclude?: string[]; // Glob patterns (default: ['_meta', 'query']) }; // Mutation filtering (for ALL mutations from __schema) mutations?: { - include?: string[]; // default: ['*'] - exclude?: string[]; // default: [] + include?: string[]; // Glob patterns (default: ['*']) + exclude?: string[]; // Glob patterns (default: []) }; // Code generation options codegen?: { - maxFieldDepth?: number; // default: 2 - skipQueryField?: boolean; // default: true + maxFieldDepth?: number; // Max depth for nested fields (default: 2) + skipQueryField?: boolean; // Skip 'query' field (default: true) }; - // ORM-specific config - orm?: { - output?: string; // default: './generated/orm' - useSharedTypes?: boolean; // default: true + // Query key generation + queryKeys?: { + generateScopedKeys?: boolean; // Generate scope-aware keys (default: true) + generateMutationKeys?: boolean; // Generate mutation keys (default: true) + generateCascadeHelpers?: boolean; // Generate invalidation helpers (default: true) + relationships?: Record; }; } -interface GraphQLSDKMultiConfig { - defaults?: GraphQLSDKConfigTarget; - targets: Record; -} - -type GraphQLSDKConfig = GraphQLSDKConfigTarget | GraphQLSDKMultiConfig; -``` - -### Multi-target Configuration - -Configure multiple schema sources and outputs in one file: - -```typescript -export default defineConfig({ - defaults: { - headers: { Authorization: 'Bearer ' }, - }, - targets: { - public: { - endpoint: 'https://api.example.com/graphql', - output: './generated/public', - }, - admin: { - schema: './admin.schema.graphql', - output: './generated/admin', - }, - }, -}); -``` - -CLI behavior: - -- `graphql-codegen generate` runs all targets -- `graphql-codegen generate --target admin` runs a single target -- `--output` requires `--target` when multiple targets exist - -### Glob Patterns - -Filter patterns support wildcards: +// Database configuration for direct database introspection or PGPM module +interface DbConfig { + // PostgreSQL connection config (falls back to PGHOST, PGPORT, etc. env vars) + config?: Partial; -- `*` - matches any string -- `?` - matches single character + // PGPM module configuration for ephemeral database creation + pgpm?: { + modulePath?: string; // Path to PGPM module directory + workspacePath?: string; // Path to PGPM workspace (with moduleName) + moduleName?: string; // Module name within workspace + }; -Examples: + // Schema selection (choose one) + schemas?: string[]; // Explicit PostgreSQL schema names + apiNames?: string[]; // API names for automatic schema discovery -```typescript -{ - tables: { - include: ['User', 'Product', 'Order*'], - exclude: ['*_archive', 'temp_*'], - }, - queries: { - exclude: ['_meta', 'query', '*Debug*'], - }, - mutations: { - include: ['create*', 'update*', 'delete*', 'login', 'register', 'logout'], - }, + // Debugging + keepDb?: boolean; // Keep ephemeral database after generation } ``` @@ -279,138 +249,74 @@ generated/hooks/ ├── index.ts # Main barrel export (configure, hooks, types) ├── client.ts # configure() and execute() functions ├── types.ts # Entity interfaces, filter types, enums -├── hooks.ts # All hooks re-exported ├── queries/ │ ├── index.ts # Query hooks barrel │ ├── useCarsQuery.ts # Table list query (findMany) │ ├── useCarQuery.ts # Table single item query (findOne) -│ ├── useCurrentUserQuery.ts # Custom query │ └── ... └── mutations/ ├── index.ts # Mutation hooks barrel ├── useCreateCarMutation.ts ├── useUpdateCarMutation.ts ├── useDeleteCarMutation.ts - ├── useLoginMutation.ts # Custom mutation └── ... ``` ### Setup & Configuration -#### 1. Configure the Client - Configure the GraphQL client once at your app's entry point: ```tsx -// App.tsx or main.tsx import { configure } from './generated/hooks'; -// Basic configuration -configure({ - endpoint: 'https://api.example.com/graphql', -}); - -// With authentication configure({ endpoint: 'https://api.example.com/graphql', headers: { Authorization: 'Bearer ', - 'X-Custom-Header': 'value', }, }); ``` -#### 2. Update Headers at Runtime - -```tsx -import { configure } from './generated/hooks'; - -// After login, update the authorization header -function handleLoginSuccess(token: string) { - configure({ - endpoint: 'https://api.example.com/graphql', - headers: { - Authorization: `Bearer ${token}`, - }, - }); -} -``` - ### Table Query Hooks For each table, two query hooks are generated: -#### List Query (`use{Table}sQuery`) - -Fetches multiple records with pagination, filtering, and ordering: - ```tsx -import { useCarsQuery } from './generated/hooks'; +import { useCarsQuery, useCarQuery } from './generated/hooks'; +// List query with filtering, pagination, and ordering function CarList() { - const { data, isLoading, isError, error, refetch, isFetching } = useCarsQuery( - { - // Pagination - first: 10, // First N records - // last: 10, // Last N records - // after: 'cursor', // Cursor-based pagination - // before: 'cursor', - // offset: 20, // Offset pagination - - // Filtering - filter: { - brand: { equalTo: 'Tesla' }, - price: { greaterThan: 50000 }, - }, - - // Ordering - orderBy: ['CREATED_AT_DESC', 'NAME_ASC'], - } - ); + const { data, isLoading, isError, error } = useCarsQuery({ + first: 10, + filter: { + brand: { equalTo: 'Tesla' }, + price: { greaterThan: 50000 }, + }, + orderBy: ['CREATED_AT_DESC'], + }); if (isLoading) return
Loading...
; if (isError) return
Error: {error.message}
; return ( -
-

Total: {data?.cars.totalCount}

-
    - {data?.cars.nodes.map((car) => ( -
  • - {car.brand} - ${car.price} -
  • - ))} -
- - {/* Pagination info */} - {data?.cars.pageInfo.hasNextPage && ( - - )} -
+
    + {data?.cars.nodes.map((car) => ( +
  • {car.brand} - ${car.price}
  • + ))} +
); } -``` - -#### Single Item Query (`use{Table}Query`) - -Fetches a single record by ID: - -```tsx -import { useCarQuery } from './generated/hooks'; +// Single item query by ID function CarDetails({ carId }: { carId: string }) { - const { data, isLoading, isError } = useCarQuery({ - id: carId, - }); + const { data, isLoading } = useCarQuery({ id: carId }); if (isLoading) return
Loading...
; - if (isError) return
Car not found
; return (

{data?.car?.brand}

Price: ${data?.car?.price}

-

Created: {data?.car?.createdAt}

); } @@ -420,1472 +326,421 @@ function CarDetails({ carId }: { carId: string }) { For each table, three mutation hooks are generated: -#### Create Mutation (`useCreate{Table}Mutation`) - ```tsx -import { useCreateCarMutation } from './generated/hooks'; +import { + useCreateCarMutation, + useUpdateCarMutation, + useDeleteCarMutation, +} from './generated/hooks'; -function CreateCarForm() { +function CarForm() { const createCar = useCreateCarMutation({ onSuccess: (data) => { console.log('Created car:', data.createCar.car.id); - // Invalidate queries, redirect, show toast, etc. - }, - onError: (error) => { - console.error('Failed to create car:', error); - }, - }); - - const handleSubmit = (formData: { brand: string; price: number }) => { - createCar.mutate({ - input: { - car: { - brand: formData.brand, - price: formData.price, - }, - }, - }); - }; - - return ( -
{ - e.preventDefault(); - handleSubmit({ brand: 'Tesla', price: 80000 }); - }} - > - {/* form fields */} - - {createCar.isError &&

Error: {createCar.error.message}

} -
- ); -} -``` - -#### Update Mutation (`useUpdate{Table}Mutation`) - -```tsx -import { useUpdateCarMutation } from './generated/hooks'; - -function EditCarForm({ - carId, - currentBrand, -}: { - carId: string; - currentBrand: string; -}) { - const updateCar = useUpdateCarMutation({ - onSuccess: (data) => { - console.log('Updated car:', data.updateCar.car.brand); - }, - }); - - const handleUpdate = (newBrand: string) => { - updateCar.mutate({ - input: { - id: carId, - patch: { - brand: newBrand, - }, - }, - }); - }; - - return ( - - ); -} -``` - -#### Delete Mutation (`useDelete{Table}Mutation`) - -```tsx -import { useDeleteCarMutation } from './generated/hooks'; - -function DeleteCarButton({ carId }: { carId: string }) { - const deleteCar = useDeleteCarMutation({ - onSuccess: () => { - console.log('Car deleted'); - // Navigate away, refetch list, etc. }, }); return ( ); } ``` -### Custom Query Hooks +### Custom Query and Mutation Hooks -Custom queries from your schema (like `currentUser`, `nodeById`, etc.) get their own hooks: +Custom queries and mutations from your schema get their own hooks: ```tsx -import { useCurrentUserQuery, useNodeByIdQuery } from './generated/hooks'; +import { useCurrentUserQuery, useLoginMutation } from './generated/hooks'; -// Simple custom query function UserProfile() { - const { data, isLoading } = useCurrentUserQuery(); - - if (isLoading) return
Loading...
; - if (!data?.currentUser) return
Not logged in
; - - return ( -
-

Welcome, {data.currentUser.username}

-

Email: {data.currentUser.email}

-
- ); -} - -// Custom query with arguments -function NodeViewer({ nodeId }: { nodeId: string }) { - const { data } = useNodeByIdQuery({ - id: nodeId, - }); - - return
{JSON.stringify(data?.node, null, 2)}
; + const { data } = useCurrentUserQuery(); + return

Welcome, {data?.currentUser?.username}

; } -``` - -### Custom Mutation Hooks -Custom mutations (like `login`, `register`, `logout`) get dedicated hooks: - -```tsx -import { - useLoginMutation, - useRegisterMutation, - useLogoutMutation, - useForgotPasswordMutation, -} from './generated/hooks'; - -// Login function LoginForm() { const login = useLoginMutation({ onSuccess: (data) => { const token = data.login.apiToken?.accessToken; - if (token) { - localStorage.setItem('token', token); - // Reconfigure client with new token - configure({ - endpoint: 'https://api.example.com/graphql', - headers: { Authorization: `Bearer ${token}` }, - }); - } - }, - onError: (error) => { - alert('Login failed: ' + error.message); - }, - }); - - const handleLogin = (email: string, password: string) => { - login.mutate({ - input: { email, password }, - }); - }; - - return ( -
{ - e.preventDefault(); - handleLogin('user@example.com', 'password'); - }} - > - {/* email and password inputs */} - -
- ); -} - -// Register -function RegisterForm() { - const register = useRegisterMutation({ - onSuccess: () => { - alert('Registration successful! Please check your email.'); + if (token) localStorage.setItem('token', token); }, }); - const handleRegister = (data: { - email: string; - password: string; - username: string; - }) => { - register.mutate({ - input: { - email: data.email, - password: data.password, - username: data.username, - }, - }); - }; - return ( - ); } +``` -// Logout -function LogoutButton() { - const logout = useLogoutMutation({ - onSuccess: () => { - localStorage.removeItem('token'); - window.location.href = '/login'; - }, - }); +### Centralized Query Keys - return ; -} +The codegen generates a centralized query key factory for type-safe cache management: -// Forgot Password -function ForgotPasswordForm() { - const forgotPassword = useForgotPasswordMutation({ - onSuccess: () => { - alert('Password reset email sent!'); - }, - }); +```tsx +import { userKeys, invalidate } from './generated/hooks'; +import { useQueryClient } from '@tanstack/react-query'; - return ( - - ); -} -``` +// Query key structure +userKeys.all; // ['user'] +userKeys.lists(); // ['user', 'list'] +userKeys.list({ first: 10 }); // ['user', 'list', { first: 10 }] +userKeys.details(); // ['user', 'detail'] +userKeys.detail('user-123'); // ['user', 'detail', 'user-123'] -### Filtering +// Invalidation helpers +const queryClient = useQueryClient(); +invalidate.user.all(queryClient); +invalidate.user.lists(queryClient); +invalidate.user.detail(queryClient, userId); +``` -All filter types from your PostGraphile schema are available: +## ORM Client -```tsx -// String filters -useCarsQuery({ - filter: { - brand: { - equalTo: 'Tesla', - notEqualTo: 'Ford', - in: ['Tesla', 'BMW', 'Mercedes'], - notIn: ['Unknown'], - contains: 'es', // LIKE '%es%' - startsWith: 'Tes', // LIKE 'Tes%' - endsWith: 'la', // LIKE '%la' - includesInsensitive: 'TESLA', // Case-insensitive - }, - }, -}); +The ORM client provides a Prisma-like fluent API for GraphQL operations without React dependencies. -// Number filters -useProductsQuery({ - filter: { - price: { - equalTo: 100, - greaterThan: 50, - greaterThanOrEqualTo: 50, - lessThan: 200, - lessThanOrEqualTo: 200, - }, - }, -}); - -// Boolean filters -useUsersQuery({ - filter: { - isActive: { equalTo: true }, - isAdmin: { equalTo: false }, - }, -}); - -// Date/DateTime filters -useOrdersQuery({ - filter: { - createdAt: { - greaterThan: '2024-01-01T00:00:00Z', - lessThan: '2024-12-31T23:59:59Z', - }, - }, -}); - -// Null checks -useUsersQuery({ - filter: { - deletedAt: { isNull: true }, // Only non-deleted - }, -}); - -// Logical operators -useUsersQuery({ - filter: { - // AND (implicit) - isActive: { equalTo: true }, - role: { equalTo: 'ADMIN' }, - }, -}); - -useUsersQuery({ - filter: { - // OR - or: [{ role: { equalTo: 'ADMIN' } }, { role: { equalTo: 'MODERATOR' } }], - }, -}); - -useUsersQuery({ - filter: { - // Complex: active AND (admin OR moderator) - and: [ - { isActive: { equalTo: true } }, - { - or: [ - { role: { equalTo: 'ADMIN' } }, - { role: { equalTo: 'MODERATOR' } }, - ], - }, - ], - }, -}); - -useUsersQuery({ - filter: { - // NOT - not: { status: { equalTo: 'DELETED' } }, - }, -}); -``` - -### Ordering - -```tsx -// Single order -useCarsQuery({ - orderBy: ['CREATED_AT_DESC'], -}); - -// Multiple orders (fallback) -useCarsQuery({ - orderBy: ['BRAND_ASC', 'CREATED_AT_DESC'], -}); - -// Available OrderBy values per table: -// - PRIMARY_KEY_ASC / PRIMARY_KEY_DESC -// - NATURAL -// - {FIELD_NAME}_ASC / {FIELD_NAME}_DESC -``` - -### Pagination - -```tsx -// First N records -useCarsQuery({ first: 10 }); - -// Last N records -useCarsQuery({ last: 10 }); - -// Offset pagination -useCarsQuery({ first: 10, offset: 20 }); // Skip 20, take 10 - -// Cursor-based pagination -function PaginatedList() { - const [cursor, setCursor] = useState(null); - - const { data } = useCarsQuery({ - first: 10, - after: cursor, - }); - - return ( -
- {data?.cars.nodes.map((car) => ( -
{car.brand}
- ))} - - {data?.cars.pageInfo.hasNextPage && ( - - )} -
- ); -} - -// PageInfo structure -// { -// hasNextPage: boolean; -// hasPreviousPage: boolean; -// startCursor: string | null; -// endCursor: string | null; -// } -``` - -### React Query Options - -All hooks accept standard React Query options: - -```tsx -// Query hooks -useCarsQuery( - { first: 10 }, // Variables - { - // React Query options - enabled: isAuthenticated, // Conditional fetching - refetchInterval: 30000, // Poll every 30s - refetchOnWindowFocus: true, // Refetch on tab focus - staleTime: 5 * 60 * 1000, // Consider fresh for 5 min - gcTime: 30 * 60 * 1000, // Keep in cache for 30 min - retry: 3, // Retry failed requests - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), - placeholderData: previousData, // Show previous data while loading - select: (data) => data.cars.nodes, // Transform data - } -); - -// Mutation hooks -useCreateCarMutation({ - onSuccess: (data, variables, context) => { - console.log('Created:', data); - queryClient.invalidateQueries({ queryKey: ['cars'] }); - }, - onError: (error, variables, context) => { - console.error('Error:', error); - }, - onSettled: (data, error, variables, context) => { - console.log('Mutation completed'); - }, - onMutate: async (variables) => { - // Optimistic update - await queryClient.cancelQueries({ queryKey: ['cars'] }); - const previousCars = queryClient.getQueryData(['cars']); - queryClient.setQueryData(['cars'], (old) => ({ - ...old, - cars: { - ...old.cars, - nodes: [...old.cars.nodes, { id: 'temp', ...variables.input.car }], - }, - })); - return { previousCars }; - }, -}); -``` - -### Cache Invalidation - -```tsx -import { useQueryClient } from '@tanstack/react-query'; -import { useCreateCarMutation, useCarsQuery } from './generated/hooks'; - -function CreateCarWithInvalidation() { - const queryClient = useQueryClient(); - - const createCar = useCreateCarMutation({ - onSuccess: () => { - // Invalidate all car queries to refetch - queryClient.invalidateQueries({ queryKey: ['cars'] }); - - // Or invalidate specific queries - queryClient.invalidateQueries({ queryKey: ['cars', { first: 10 }] }); - }, - }); - - // ... -} -``` - -### Centralized Query Keys - -The codegen generates a centralized query key factory following the [lukemorales query-key-factory](https://tanstack.com/query/docs/framework/react/community/lukemorales-query-key-factory) pattern. This provides type-safe cache management with autocomplete support. - -#### Generated Files - -| File | Purpose | -| ------------------ | ------------------------------------------------------- | -| `query-keys.ts` | Query key factories for all entities | -| `mutation-keys.ts` | Mutation key factories for tracking in-flight mutations | -| `invalidation.ts` | Type-safe cache invalidation helpers | - -#### Using Query Keys - -```tsx -import { userKeys, invalidate } from './generated/hooks'; -import { useQueryClient } from '@tanstack/react-query'; - -// Query key structure -userKeys.all; // ['user'] -userKeys.lists(); // ['user', 'list'] -userKeys.list({ first: 10 }); // ['user', 'list', { first: 10 }] -userKeys.details(); // ['user', 'detail'] -userKeys.detail('user-123'); // ['user', 'detail', 'user-123'] - -// Granular cache invalidation -const queryClient = useQueryClient(); - -// Invalidate ALL user queries -queryClient.invalidateQueries({ queryKey: userKeys.all }); - -// Invalidate only list queries -queryClient.invalidateQueries({ queryKey: userKeys.lists() }); - -// Invalidate a specific user -queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) }); -``` - -#### Invalidation Helpers - -Type-safe invalidation utilities: - -```tsx -import { invalidate, remove } from './generated/hooks'; - -// Invalidate queries (triggers refetch) -invalidate.user.all(queryClient); -invalidate.user.lists(queryClient); -invalidate.user.detail(queryClient, userId); - -// Remove from cache (for delete operations) -remove.user(queryClient, userId); -``` - -#### Mutation Key Tracking - -Track in-flight mutations with `useIsMutating`: - -```tsx -import { useIsMutating } from '@tanstack/react-query'; -import { userMutationKeys } from './generated/hooks'; - -function UserList() { - // Check if any user mutations are in progress - const isMutating = useIsMutating({ mutationKey: userMutationKeys.all }); - - // Check if a specific user is being deleted - const isDeleting = useIsMutating({ - mutationKey: userMutationKeys.delete(userId), - }); - - return ( -
- {isMutating > 0 && } - -
- ); -} -``` - -#### Optimistic Updates with Query Keys - -```tsx -import { useCreateUserMutation, userKeys } from './generated/hooks'; - -const createUser = useCreateUserMutation({ - onMutate: async (newUser) => { - // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: userKeys.lists() }); - - // Snapshot previous value - const previous = queryClient.getQueryData(userKeys.list()); - - // Optimistically update cache - queryClient.setQueryData(userKeys.list(), (old) => ({ - ...old, - users: { - ...old.users, - nodes: [...old.users.nodes, { id: 'temp', ...newUser.input.user }], - }, - })); - - return { previous }; - }, - onError: (err, variables, context) => { - // Rollback on error - queryClient.setQueryData(userKeys.list(), context.previous); - }, - onSettled: () => { - // Refetch after mutation - queryClient.invalidateQueries({ queryKey: userKeys.lists() }); - }, -}); -``` - -#### Configuration - -Query key generation is enabled by default. Configure in your config file: - -```typescript -// graphql-sdk.config.ts -export default defineConfig({ - endpoint: 'https://api.example.com/graphql', - - queryKeys: { - // Generate scope-aware keys (default: true) - generateScopedKeys: true, - - // Generate mutation keys (default: true) - generateMutationKeys: true, - - // Generate invalidation helpers (default: true) - generateCascadeHelpers: true, - - // Define entity relationships for cascade invalidation - relationships: { - table: { parent: 'database', foreignKey: 'databaseId' }, - field: { parent: 'table', foreignKey: 'tableId' }, - }, - }, -}); -``` - -For detailed documentation on query key factory design and implementation, see [docs/QUERY-KEY-FACTORY.md](./docs/QUERY-KEY-FACTORY.md). - -### Prefetching - -```tsx -import { useQueryClient } from '@tanstack/react-query'; - -function CarListItem({ car }: { car: Car }) { - const queryClient = useQueryClient(); - - // Prefetch details on hover - const handleHover = () => { - queryClient.prefetchQuery({ - queryKey: ['car', { id: car.id }], - queryFn: () => execute(carQuery, { id: car.id }), - }); - }; - - return ( - - {car.brand} - - ); -} -``` - -### Type Exports - -All generated types are exported for use in your application: - -```tsx -import type { - // Entity types - Car, - User, - Product, - Order, - - // Filter types - CarFilter, - UserFilter, - StringFilter, - IntFilter, - UUIDFilter, - DatetimeFilter, - - // OrderBy types - CarsOrderBy, - UsersOrderBy, - - // Input types - CreateCarInput, - UpdateCarInput, - CarPatch, - LoginInput, - - // Payload types - LoginPayload, - CreateCarPayload, -} from './generated/hooks'; - -// Use in your components -interface CarListProps { - filter?: CarFilter; - orderBy?: CarsOrderBy[]; -} - -function CarList({ filter, orderBy }: CarListProps) { - const { data } = useCarsQuery({ filter, orderBy, first: 10 }); - // ... -} -``` - -### Error Handling - -```tsx -function CarList() { - const { data, isLoading, isError, error, failureCount } = useCarsQuery({ - first: 10, - }); - - if (isLoading) { - return
Loading...
; - } - - if (isError) { - // error is typed as Error - return ( -
-

Error: {error.message}

-

Failed {failureCount} times

- -
- ); - } - - return
{/* render data */}
; -} - -// Global error handling -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - onError: (error) => { - console.error('Query error:', error); - // Show toast, log to monitoring, etc. - }, - }, - mutations: { - onError: (error) => { - console.error('Mutation error:', error); - }, - }, - }, -}); -``` - -### Generated Types Reference - -```typescript -// Query hook return type -type UseQueryResult = { - data: TData | undefined; - error: Error | null; - isLoading: boolean; - isFetching: boolean; - isError: boolean; - isSuccess: boolean; - refetch: () => Promise>; - // ... more React Query properties -}; - -// Mutation hook return type -type UseMutationResult = { - data: TData | undefined; - error: Error | null; - isLoading: boolean; // deprecated, use isPending - isPending: boolean; - isError: boolean; - isSuccess: boolean; - mutate: (variables: TVariables) => void; - mutateAsync: (variables: TVariables) => Promise; - reset: () => void; - // ... more React Query properties -}; - -// Connection result (for list queries) -interface CarsConnection { - nodes: Car[]; - totalCount: number; - pageInfo: PageInfo; -} - -interface PageInfo { - hasNextPage: boolean; - hasPreviousPage: boolean; - startCursor: string | null; - endCursor: string | null; -} -``` - ---- - -## ORM Client - -The ORM client provides a Prisma-like fluent API for GraphQL operations without React dependencies. - -### Generated Output Structure - -``` -generated/orm/ -├── index.ts # createClient() factory + re-exports -├── client.ts # OrmClient class (GraphQL executor) -├── query-builder.ts # QueryBuilder with execute(), unwrap(), etc. -├── select-types.ts # Type utilities for select inference -├── input-types.ts # All generated types (entities, filters, inputs, etc.) -├── types.ts # Re-exports from input-types -├── models/ -│ ├── index.ts # Barrel export for all models -│ ├── user.ts # UserModel class -│ ├── product.ts # ProductModel class -│ ├── order.ts # OrderModel class -│ └── ... -├── query/ -│ └── index.ts # Custom query operations (currentUser, etc.) -└── mutation/ - └── index.ts # Custom mutation operations (login, register, etc.) -``` - -### Basic Usage - -```typescript -import { createClient } from './generated/orm'; - -// Create client instance -const db = createClient({ - endpoint: 'https://api.example.com/graphql', - headers: { Authorization: 'Bearer ' }, -}); - -// Query users -const result = await db.user - .findMany({ - select: { id: true, username: true, email: true }, - first: 20, - }) - .execute(); - -if (result.ok) { - console.log(result.data.users.nodes); -} else { - console.error(result.errors); -} - -// Find first matching user -const user = await db.user - .findFirst({ - select: { id: true, username: true }, - where: { username: { equalTo: 'john' } }, - }) - .execute(); - -// Create a user -const newUser = await db.user - .create({ - data: { username: 'john', email: 'john@example.com' }, - select: { id: true, username: true }, - }) - .execute(); - -// Update a user -const updated = await db.user - .update({ - where: { id: 'user-id' }, - data: { displayName: 'John Doe' }, - select: { id: true, displayName: true }, - }) - .execute(); - -// Delete a user -const deleted = await db.user - .delete({ - where: { id: 'user-id' }, - }) - .execute(); -``` - -### Select & Type Inference - -The ORM uses **const generics** to infer return types based on your select clause. Only the fields you select will be in the return type. - -```typescript -// Select specific fields - return type is narrowed -const users = await db.user - .findMany({ - select: { id: true, username: true }, // Only id and username - }) - .unwrap(); - -// TypeScript knows the exact shape: -// users.users.nodes[0] is { id: string; username: string | null } - -// If you try to access a field you didn't select, TypeScript will error: -// users.users.nodes[0].email // Error: Property 'email' does not exist - -// Without select, you get the full entity type -const allFields = await db.user.findMany({}).unwrap(); -// allFields.users.nodes[0] has all User fields -``` - -### Relations - -Relations are fully typed in Select types. The ORM supports all PostGraphile relation types: - -#### BelongsTo Relations (Single Entity) - -```typescript -// Order.customer is a belongsTo relation to User -const orders = await db.order - .findMany({ - select: { - id: true, - orderNumber: true, - // Nested select for belongsTo relation - customer: { - select: { id: true, username: true, displayName: true }, - }, - }, - }) - .unwrap(); - -// TypeScript knows: -// orders.orders.nodes[0].customer is { id: string; username: string | null; displayName: string | null } -``` - -#### HasMany Relations (Connection/Collection) - -```typescript -// Order.orderItems is a hasMany relation to OrderItem -const orders = await db.order - .findMany({ - select: { - id: true, - // HasMany with pagination and filtering - orderItems: { - select: { id: true, quantity: true, price: true }, - first: 10, // Pagination - filter: { quantity: { greaterThan: 0 } }, // Filtering - orderBy: ['QUANTITY_DESC'], // Ordering - }, - }, - }) - .unwrap(); - -// orders.orders.nodes[0].orderItems is a connection: -// { nodes: Array<{ id: string; quantity: number | null; price: number | null }>, totalCount: number, pageInfo: PageInfo } -``` - -#### ManyToMany Relations - -```typescript -// Order.productsByOrderItemOrderIdAndProductId is a manyToMany through OrderItem -const orders = await db.order - .findMany({ - select: { - id: true, - productsByOrderItemOrderIdAndProductId: { - select: { id: true, name: true, price: true }, - first: 5, - }, - }, - }) - .unwrap(); -``` - -#### Deeply Nested Relations - -```typescript -// Multiple levels of nesting -const products = await db.product - .findMany({ - select: { - id: true, - name: true, - // BelongsTo: Product -> User (seller) - seller: { - select: { - id: true, - username: true, - // Even deeper nesting if needed - }, - }, - // BelongsTo: Product -> Category - category: { - select: { id: true, name: true }, - }, - // HasMany: Product -> Review - reviews: { - select: { - id: true, - rating: true, - comment: true, - }, - first: 5, - orderBy: ['CREATED_AT_DESC'], - }, - }, - }) - .unwrap(); -``` - -### Filtering & Ordering - -#### Filter Types - -Each entity has a generated Filter type with field-specific operators: - -```typescript -// String filters -where: { - username: { - equalTo: 'john', - notEqualTo: 'jane', - in: ['john', 'jane', 'bob'], - notIn: ['admin'], - contains: 'oh', // LIKE '%oh%' - startsWith: 'j', // LIKE 'j%' - endsWith: 'n', // LIKE '%n' - includesInsensitive: 'OH', // Case-insensitive - } -} - -// Number filters (Int, Float, BigInt, BigFloat) -where: { - price: { - equalTo: 100, - greaterThan: 50, - greaterThanOrEqualTo: 50, - lessThan: 200, - lessThanOrEqualTo: 200, - in: [100, 200, 300], - } -} - -// Boolean filters -where: { - isActive: { equalTo: true } -} - -// UUID filters -where: { - id: { - equalTo: 'uuid-string', - in: ['uuid-1', 'uuid-2'], - } -} - -// DateTime filters -where: { - createdAt: { - greaterThan: '2024-01-01T00:00:00Z', - lessThan: '2024-12-31T23:59:59Z', - } -} - -// JSON filters -where: { - metadata: { - contains: { key: 'value' }, - containsKey: 'key', - containsAllKeys: ['key1', 'key2'], - } -} +### Generated Output Structure -// Null checks (all filters) -where: { - deletedAt: { isNull: true } -} +``` +generated/orm/ +├── index.ts # createClient() factory + re-exports +├── client.ts # OrmClient class (GraphQL executor) +├── query-builder.ts # QueryBuilder with execute(), unwrap(), etc. +├── select-types.ts # Type utilities for select inference +├── input-types.ts # All generated types +├── models/ +│ ├── index.ts # Barrel export for all models +│ ├── user.ts # UserModel class +│ └── ... +├── query/ +│ └── index.ts # Custom query operations +└── mutation/ + └── index.ts # Custom mutation operations ``` -#### Logical Operators +### Basic Usage ```typescript -// AND (implicit - all conditions must match) -where: { - isActive: { equalTo: true }, - username: { startsWith: 'j' } -} - -// AND (explicit) -where: { - and: [ - { isActive: { equalTo: true } }, - { username: { startsWith: 'j' } } - ] -} +import { createClient } from './generated/orm'; -// OR -where: { - or: [ - { status: { equalTo: 'ACTIVE' } }, - { status: { equalTo: 'PENDING' } } - ] -} +const db = createClient({ + endpoint: 'https://api.example.com/graphql', + headers: { Authorization: 'Bearer ' }, +}); -// NOT -where: { - not: { status: { equalTo: 'DELETED' } } -} +// Query users +const result = await db.user + .findMany({ + select: { id: true, username: true, email: true }, + first: 20, + }) + .execute(); -// Complex combinations -where: { - and: [ - { isActive: { equalTo: true } }, - { - or: [ - { role: { equalTo: 'ADMIN' } }, - { role: { equalTo: 'MODERATOR' } } - ] - } - ] +if (result.ok) { + console.log(result.data.users.nodes); +} else { + console.error(result.errors); } -``` -#### Ordering +// Create a user +const newUser = await db.user + .create({ + data: { username: 'john', email: 'john@example.com' }, + select: { id: true, username: true }, + }) + .execute(); -```typescript -const users = await db.user - .findMany({ - select: { id: true, username: true, createdAt: true }, - orderBy: [ - 'CREATED_AT_DESC', // Newest first - 'USERNAME_ASC', // Then alphabetical - ], +// Update a user +const updated = await db.user + .update({ + where: { id: 'user-id' }, + data: { displayName: 'John Doe' }, + select: { id: true, displayName: true }, }) - .unwrap(); + .execute(); -// Available OrderBy values (generated per entity): -// - PRIMARY_KEY_ASC / PRIMARY_KEY_DESC -// - NATURAL -// - {FIELD_NAME}_ASC / {FIELD_NAME}_DESC +// Delete a user +const deleted = await db.user + .delete({ where: { id: 'user-id' } }) + .execute(); ``` -### Pagination +### Select & Type Inference -The ORM supports cursor-based and offset pagination: +The ORM uses const generics to infer return types based on your select clause: ```typescript -// First N records -const first10 = await db.user +const users = await db.user .findMany({ - select: { id: true }, - first: 10, + select: { id: true, username: true }, }) .unwrap(); -// Last N records -const last10 = await db.user - .findMany({ - select: { id: true }, - last: 10, - }) - .unwrap(); +// TypeScript knows the exact shape: +// users.users.nodes[0] is { id: string; username: string | null } -// Cursor-based pagination (after/before) -const page1 = await db.user - .findMany({ - select: { id: true }, - first: 10, - }) - .unwrap(); +// Accessing unselected fields is a compile error: +// users.users.nodes[0].email // Error: Property 'email' does not exist +``` + +### Relations -const endCursor = page1.users.pageInfo.endCursor; +Relations are fully typed in Select types: -const page2 = await db.user +```typescript +// BelongsTo relation +const orders = await db.order .findMany({ - select: { id: true }, - first: 10, - after: endCursor, // Get records after this cursor + select: { + id: true, + customer: { + select: { id: true, username: true }, + }, + }, }) .unwrap(); -// Offset pagination -const page3 = await db.user +// HasMany relation with pagination +const users = await db.user .findMany({ - select: { id: true }, - first: 10, - offset: 20, // Skip first 20 records + select: { + id: true, + orders: { + select: { id: true, total: true }, + first: 10, + orderBy: ['CREATED_AT_DESC'], + }, + }, }) .unwrap(); - -// PageInfo structure -// { -// hasNextPage: boolean; -// hasPreviousPage: boolean; -// startCursor: string | null; -// endCursor: string | null; -// } - -// Total count is always included -console.log(page1.users.totalCount); // Total matching records ``` ### Error Handling The ORM provides multiple ways to handle errors: -#### Discriminated Union (Recommended) - ```typescript -const result = await db.user - .findMany({ - select: { id: true }, - }) - .execute(); +// Discriminated union (recommended) +const result = await db.user.findMany({ select: { id: true } }).execute(); if (result.ok) { - // TypeScript knows result.data is non-null console.log(result.data.users.nodes); - // result.errors is undefined in this branch } else { - // TypeScript knows result.errors is non-null - console.error(result.errors[0].message); - // result.data is null in this branch + console.error(result.errors); } -``` - -#### `.unwrap()` - Throw on Error - -```typescript -import { GraphQLRequestError } from './generated/orm'; +// .unwrap() - throws on error try { - // Throws GraphQLRequestError if query fails - const data = await db.user - .findMany({ - select: { id: true }, - }) - .unwrap(); - - console.log(data.users.nodes); + const data = await db.user.findMany({ select: { id: true } }).unwrap(); } catch (error) { if (error instanceof GraphQLRequestError) { console.error('GraphQL errors:', error.errors); - console.error('Message:', error.message); } } -``` - -#### `.unwrapOr()` - Default Value on Error -```typescript -// Returns default value if query fails (no throwing) +// .unwrapOr() - returns default on error const data = await db.user - .findMany({ - select: { id: true }, - }) - .unwrapOr({ - users: { - nodes: [], - totalCount: 0, - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }, - }); - -// Always returns data (either real or default) -console.log(data.users.nodes); -``` - -#### `.unwrapOrElse()` - Callback on Error + .findMany({ select: { id: true } }) + .unwrapOr({ users: { nodes: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } } }); -```typescript -// Call a function to handle errors and return fallback +// .unwrapOrElse() - callback on error const data = await db.user - .findMany({ - select: { id: true }, - }) + .findMany({ select: { id: true } }) .unwrapOrElse((errors) => { - // Log errors, send to monitoring, etc. - console.error('Query failed:', errors.map((e) => e.message).join(', ')); - - // Return fallback data - return { - users: { - nodes: [], - totalCount: 0, - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }, - }; + console.error('Query failed:', errors); + return { users: { nodes: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } } }; }); ``` -#### Error Types +### Custom Operations + +Custom queries and mutations are available on `db.query` and `db.mutation`: ```typescript -interface GraphQLError { - message: string; - locations?: { line: number; column: number }[]; - path?: (string | number)[]; - extensions?: Record; -} +// Custom query +const currentUser = await db.query + .currentUser({ select: { id: true, username: true } }) + .unwrap(); -class GraphQLRequestError extends Error { - readonly errors: GraphQLError[]; - readonly data: unknown; // Partial data if available -} +// Custom mutation +const login = await db.mutation + .login( + { input: { email: 'user@example.com', password: 'secret' } }, + { select: { apiToken: { select: { accessToken: true } } } } + ) + .unwrap(); -type QueryResult = - | { ok: true; data: T; errors: undefined } - | { ok: false; data: null; errors: GraphQLError[] }; +console.log(login.login.apiToken?.accessToken); ``` -### Custom Operations +## Configuration -Custom queries and mutations (like `login`, `currentUser`, etc.) are available on `db.query` and `db.mutation`: +### Config File -#### Custom Queries +Create a `graphql-sdk.config.ts` file: ```typescript -// Query with select -const currentUser = await db.query - .currentUser({ - select: { id: true, username: true, email: true }, - }) - .unwrap(); - -// Query without select (returns full type) -const me = await db.query.currentUser({}).unwrap(); +import { defineConfig } from '@constructive-io/graphql-codegen'; -// Query with arguments -const node = await db.query - .nodeById( - { - id: 'some-node-id', - }, - { - select: { id: true }, - } - ) - .unwrap(); +export default defineConfig({ + endpoint: 'https://api.example.com/graphql', + output: './generated/graphql', + headers: { + Authorization: 'Bearer ', + }, + reactQuery: true, + orm: true, +}); ``` -#### Custom Mutations +### Multi-target Configuration + +Configure multiple schema sources and outputs: ```typescript -// Login mutation with typed select -const login = await db.mutation - .login( - { - input: { - email: 'user@example.com', - password: 'secret123', - }, +export default defineConfig({ + defaults: { + headers: { Authorization: 'Bearer ' }, + }, + targets: { + public: { + endpoint: 'https://api.example.com/graphql', + output: './generated/public', + reactQuery: true, + }, + admin: { + schemaFile: './admin.schema.graphql', + output: './generated/admin', + orm: true, }, - { - select: { - clientMutationId: true, - apiToken: { - select: { - accessToken: true, - accessTokenExpiresAt: true, - }, - }, + database: { + db: { + pgpm: { modulePath: './packages/my-module' }, + schemas: ['public'], }, - } - ) - .unwrap(); + output: './generated/db', + reactQuery: true, + orm: true, + }, + }, +}); +``` -console.log(login.login.apiToken?.accessToken); +### Glob Patterns -// Register mutation -const register = await db.mutation - .register({ - input: { - email: 'new@example.com', - password: 'secret123', - username: 'newuser', - }, - }) - .unwrap(); +Filter patterns support wildcards: -// Logout mutation -await db.mutation - .logout({ - input: { clientMutationId: 'optional-id' }, - }) - .execute(); +```typescript +{ + tables: { + include: ['User', 'Product', 'Order*'], + exclude: ['*_archive', 'temp_*'], + }, + queries: { + exclude: ['_meta', 'query', '*Debug*'], + }, + mutations: { + include: ['create*', 'update*', 'delete*', 'login', 'register', 'logout'], + }, +} ``` -### Query Builder API +## CLI Commands -Every operation returns a `QueryBuilder` that can be inspected before execution: +The CLI provides a convenient way to run code generation from the command line. -```typescript -const query = db.user.findMany({ - select: { id: true, username: true }, - where: { isActive: { equalTo: true } }, - first: 10, -}); +### `graphql-sdk generate` -// Inspect the generated GraphQL -console.log(query.toGraphQL()); -// query UserQuery($where: UserFilter, $first: Int) { -// users(filter: $where, first: $first) { -// nodes { id username } -// totalCount -// pageInfo { hasNextPage hasPreviousPage startCursor endCursor } -// } -// } - -// Get variables -console.log(query.getVariables()); -// { where: { isActive: { equalTo: true } }, first: 10 } - -// Execute when ready -const result = await query.execute(); -// Or: const data = await query.unwrap(); +Generate React Query hooks and/or ORM client from various sources. + +```bash +Source Options (choose one): + -c, --config Path to config file (graphql-sdk.config.ts) + -e, --endpoint GraphQL endpoint URL + -s, --schema-file Path to GraphQL schema file (.graphql) + --pgpm-module-path Path to PGPM module directory + --pgpm-workspace-path Path to PGPM workspace (requires --pgpm-module-name) + --pgpm-module-name PGPM module name in workspace + +Database Options (for pgpm modes): + --schemas Comma-separated list of PostgreSQL schemas to introspect + --api-names Comma-separated API names for automatic schema discovery + (mutually exclusive with --schemas) + +Generator Options: + --react-query Generate React Query hooks + --orm Generate ORM client + -t, --target Target name in config file + -o, --output Output directory + -a, --authorization Authorization header value + --skip-custom-operations Only generate table CRUD operations + --dry-run Preview without writing files + --keep-db Keep ephemeral database after generation (pgpm modes) + -v, --verbose Show detailed output + +Watch Mode Options: + -w, --watch Watch for schema changes and regenerate + --poll-interval Polling interval in milliseconds (default: 5000) + --debounce Debounce delay in milliseconds (default: 500) + --touch Touch file after regeneration + --no-clear Don't clear console on regeneration ``` -### Client Configuration +Examples: -```typescript -import { createClient } from './generated/orm'; +```bash +# Generate React Query hooks from an endpoint +npx graphql-sdk generate --endpoint https://api.example.com/graphql --output ./generated --react-query -// Basic configuration -const db = createClient({ - endpoint: 'https://api.example.com/graphql', -}); +# Generate ORM client from an endpoint +npx graphql-sdk generate --endpoint https://api.example.com/graphql --output ./generated --orm -// With authentication -const db = createClient({ - endpoint: 'https://api.example.com/graphql', - headers: { - Authorization: 'Bearer ', - 'X-Custom-Header': 'value', - }, -}); +# Generate both React Query hooks and ORM client +npx graphql-sdk generate --endpoint https://api.example.com/graphql --output ./generated --react-query --orm -// Update headers at runtime -db.setHeaders({ - Authorization: 'Bearer ', -}); +# Generate from a PGPM module +npx graphql-sdk generate --pgpm-module-path ./packages/my-module --schemas public --react-query -// Get current endpoint -console.log(db.getEndpoint()); +# Generate using apiNames for automatic schema discovery +npx graphql-sdk generate --pgpm-module-path ./packages/my-module --api-names my_api --react-query --orm ``` ---- +### `graphql-sdk init` -## Architecture +Create a configuration file. -### How It Works +```bash +Options: + -f, --format Config format: ts, js, json (default: ts) + -o, --output Output path for config file +``` + +### `graphql-sdk introspect` + +Inspect schema without generating code. -1. **Fetch `_meta`**: Gets table metadata from PostGraphile's `_meta` query including: - - Table names and fields - - Relations (belongsTo, hasMany, manyToMany) - - Constraints (primary key, foreign key, unique) - - Inflection rules (query names, type names) +```bash +Options: + -e, --endpoint GraphQL endpoint URL + --json Output as JSON + -v, --verbose Show detailed output +``` -2. **Fetch `__schema`**: Gets full schema introspection for ALL operations: - - All queries (including custom ones like `currentUser`) - - All mutations (including custom ones like `login`, `register`) - - All types (entities, inputs, enums, scalars) +## Architecture -3. **Filter Operations**: Removes table CRUD from custom operations to avoid duplicates +### How It Works -4. **Generate Code**: Creates type-safe code using AST-based generation (`ts-morph`) +The codegen fetches `_meta` for table metadata (names, fields, relations, constraints) and `__schema` for full schema introspection (all queries, mutations, types). It then filters operations to avoid duplicates and generates type-safe code using Babel AST. ### Code Generation Pipeline @@ -1924,68 +779,16 @@ PostGraphile Endpoint └───────────────────┘ ``` -### Key Concepts - -#### Type Inference with Const Generics +### Type Inference with Const Generics The ORM uses TypeScript const generics to infer return types: ```typescript -// Model method signature findMany( args?: FindManyArgs ): QueryBuilder<{ users: ConnectionResult> }> - -// InferSelectResult maps select object to result type -type InferSelectResult = { - [K in keyof TSelect & keyof TEntity as TSelect[K] extends false | undefined - ? never - : K]: TSelect[K] extends true - ? TEntity[K] - : TSelect[K] extends { select: infer NestedSelect } - ? /* handle nested select */ - : TEntity[K]; -}; -``` - -#### Select Types with Relations - -Select types include relation fields with proper typing: - -```typescript -export type OrderSelect = { - // Scalar fields - id?: boolean; - orderNumber?: boolean; - status?: boolean; - - // BelongsTo relation - customer?: boolean | { select?: UserSelect }; - - // HasMany relation - orderItems?: - | boolean - | { - select?: OrderItemSelect; - first?: number; - filter?: OrderItemFilter; - orderBy?: OrderItemsOrderBy[]; - }; - - // ManyToMany relation - productsByOrderItemOrderIdAndProductId?: - | boolean - | { - select?: ProductSelect; - first?: number; - filter?: ProductFilter; - orderBy?: ProductsOrderBy[]; - }; -}; ``` ---- - ## Generated Types ### Entity Types @@ -2024,7 +827,6 @@ export interface StringFilter { contains?: string; startsWith?: string; endsWith?: string; - // ... more operators } ``` @@ -2060,35 +862,8 @@ export interface UpdateUserInput { id: string; patch: UserPatch; } - -export interface UserPatch { - username?: string | null; - email?: string | null; - displayName?: string | null; -} -``` - -### Payload Types (Custom Operations) - -```typescript -export interface LoginPayload { - clientMutationId?: string | null; - apiToken?: ApiToken | null; -} - -export interface ApiToken { - accessToken: string; - accessTokenExpiresAt?: string | null; -} - -export type LoginPayloadSelect = { - clientMutationId?: boolean; - apiToken?: boolean | { select?: ApiTokenSelect }; -}; ``` ---- - ## Development ```bash @@ -2101,27 +876,6 @@ pnpm build # Run in watch mode pnpm dev -# Test React Query hooks generation -node bin/graphql-sdk.js generate \ - -e http://public-0e394519.localhost:3000/graphql \ - -o ./output-rq \ - --verbose - -# Test ORM client generation -node bin/graphql-sdk.js generate-orm \ - -e http://public-0e394519.localhost:3000/graphql \ - -o ./output-orm \ - --verbose - -# Type check generated output -npx tsc --noEmit output-orm/*.ts output-orm/**/*.ts \ - --skipLibCheck --target ES2022 --module ESNext \ - --moduleResolution bundler --strict - -# Run example tests -npx tsx examples/test-orm.ts -npx tsx examples/type-inference-test.ts - # Type check pnpm lint:types @@ -2129,22 +883,6 @@ pnpm lint:types pnpm test ``` ---- - -## Roadmap - -- [x] **Relations**: Typed nested select with relation loading -- [x] **Type Inference**: Const generics for narrowed return types -- [x] **Error Handling**: Discriminated unions with unwrap methods -- [ ] **Aggregations**: Count, sum, avg operations -- [ ] **Batch Operations**: Bulk create/update/delete -- [ ] **Transactions**: Transaction support where available -- [ ] **Subscriptions**: Real-time subscription support -- [ ] **Custom Scalars**: Better handling of PostGraphile custom types -- [ ] **Query Caching**: Optional caching layer for ORM client -- [ ] **Middleware**: Request/response interceptors -- [ ] **Connection Pooling**: For high-throughput scenarios - ## Requirements - Node.js >= 18 diff --git a/graphql/codegen/examples/single-target.config.ts b/graphql/codegen/examples/single-target.config.ts index 9e7b858b3..063acb6f2 100644 --- a/graphql/codegen/examples/single-target.config.ts +++ b/graphql/codegen/examples/single-target.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from '../src/types/config'; /** - * Single-target config (backward compatible format) - * Tests that the old config format without "targets" key still works. + * Single-target config */ export default defineConfig({ endpoint: 'http://api.localhost:3000/graphql', diff --git a/graphql/codegen/package.json b/graphql/codegen/package.json index 4aae42df7..2692b7fb2 100644 --- a/graphql/codegen/package.json +++ b/graphql/codegen/package.json @@ -1,7 +1,7 @@ { "name": "@constructive-io/graphql-codegen", "version": "2.32.0", - "description": "CLI-based GraphQL SDK generator for PostGraphile endpoints with React Query hooks", + "description": "GraphQL SDK generator for Constructive databases with React Query hooks", "keywords": [ "graphql", "postgraphile", @@ -35,7 +35,7 @@ "scripts": { "clean": "makage clean", "prepack": "npm run build", - "copy:ts": "makage copy src/cli/codegen/orm/query-builder.ts dist/cli/codegen/orm --flat", + "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", "dev": "ts-node ./src/index.ts", @@ -44,10 +44,10 @@ "fmt:check": "prettier --check .", "test": "jest --passWithNoTests", "test:watch": "jest --watch", - "example:codegen:sdk": "tsx src/cli/index.ts generate --config examples/multi-target.config.ts", - "example:codegen:orm": "tsx src/cli/index.ts generate-orm --config examples/multi-target.config.ts", - "example:codegen:sdk:schema": "node dist/cli/index.js generate --schema examples/example.schema.graphql --output examples/output/generated-sdk-schema", - "example:codegen:orm:schema": "node dist/cli/index.js generate-orm --schema examples/example.schema.graphql --output examples/output/generated-orm-schema", + "example:codegen:sdk": "tsx src/cli/index.ts --config examples/multi-target.config.ts --react-query", + "example:codegen:orm": "tsx src/cli/index.ts --config examples/multi-target.config.ts --orm", + "example:codegen:sdk:schema": "node dist/cli/index.js --schema-file examples/example.schema.graphql --output examples/output/generated-sdk-schema --react-query", + "example:codegen:orm:schema": "node dist/cli/index.js --schema-file examples/example.schema.graphql --output examples/output/generated-orm-schema --orm", "example:sdk": "tsx examples/react-hooks-sdk-test.tsx", "example:orm": "tsx examples/orm-sdk-test.ts", "example:sdk:typecheck": "tsc --noEmit --jsx react --esModuleInterop --skipLibCheck --moduleResolution node examples/react-hooks-sdk-test.tsx" @@ -56,8 +56,10 @@ "@0no-co/graphql.web": "^1.1.2", "@babel/generator": "^7.28.6", "@babel/types": "^7.28.6", + "@constructive-io/graphql-server": "workspace:^", "@constructive-io/graphql-types": "workspace:^", "@inquirerer/utils": "^3.2.0", + "@pgpmjs/core": "workspace:^", "ajv": "^8.17.1", "deepmerge": "^4.3.1", "find-and-require-package-json": "^0.9.0", @@ -66,7 +68,12 @@ "inflekt": "^0.3.0", "inquirerer": "^4.4.0", "jiti": "^2.6.1", - "prettier": "^3.7.4" + "pg-cache": "workspace:^", + "pg-env": "workspace:^", + "pgsql-client": "workspace:^", + "pgsql-seed": "workspace:^", + "prettier": "^3.7.4", + "undici": "^7.19.0" }, "peerDependencies": { "@tanstack/react-query": "^5.0.0", diff --git a/graphql/codegen/src/__tests__/codegen/client-generator.test.ts b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts index f5b03828e..afcf275e4 100644 --- a/graphql/codegen/src/__tests__/codegen/client-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts @@ -8,7 +8,7 @@ import { generateQueryBuilderFile, generateSelectTypesFile, generateCreateClientFile, -} from '../../cli/codegen/orm/client-generator'; +} from '../../core/codegen/orm/client-generator'; import type { CleanTable, CleanFieldType, CleanRelations } from '../../types/schema'; // ============================================================================ diff --git a/graphql/codegen/src/__tests__/codegen/format-output.test.ts b/graphql/codegen/src/__tests__/codegen/format-output.test.ts index 1db9f0f30..d05d52671 100644 --- a/graphql/codegen/src/__tests__/codegen/format-output.test.ts +++ b/graphql/codegen/src/__tests__/codegen/format-output.test.ts @@ -5,7 +5,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { formatOutput } from '../../cli/commands/generate'; +import { formatOutput } from '../../core/output'; describe('formatOutput', () => { let tempDir: string; diff --git a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts index 982ad96b4..d509456fa 100644 --- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts @@ -6,7 +6,7 @@ * used to validate the AST-based migration produces equivalent results. */ // Jest globals - no import needed -import { generateInputTypesFile, collectInputTypeNames, collectPayloadTypeNames } from '../../cli/codegen/orm/input-types-generator'; +import { generateInputTypesFile, collectInputTypeNames, collectPayloadTypeNames } from '../../core/codegen/orm/input-types-generator'; import type { CleanTable, CleanFieldType, diff --git a/graphql/codegen/src/__tests__/codegen/model-generator.test.ts b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts index 75d7e5991..1a27b9062 100644 --- a/graphql/codegen/src/__tests__/codegen/model-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts @@ -3,7 +3,7 @@ * * Tests the generated model classes with findMany, findFirst, create, update, delete methods. */ -import { generateModelFile } from '../../cli/codegen/orm/model-generator'; +import { generateModelFile } from '../../core/codegen/orm/model-generator'; import type { CleanTable, CleanFieldType, CleanRelations } from '../../types/schema'; // ============================================================================ diff --git a/graphql/codegen/src/__tests__/codegen/query-keys-factory.test.ts b/graphql/codegen/src/__tests__/codegen/query-keys-factory.test.ts index 177cc2dc1..abc8484b9 100644 --- a/graphql/codegen/src/__tests__/codegen/query-keys-factory.test.ts +++ b/graphql/codegen/src/__tests__/codegen/query-keys-factory.test.ts @@ -6,11 +6,11 @@ * - Mutation keys factory (mutation-keys.ts) * - Cache invalidation helpers (invalidation.ts) */ -import { generateQueryKeysFile } from '../../cli/codegen/query-keys'; -import { generateMutationKeysFile } from '../../cli/codegen/mutation-keys'; -import { generateInvalidationFile } from '../../cli/codegen/invalidation'; +import { generateQueryKeysFile } from '../../core/codegen/query-keys'; +import { generateMutationKeysFile } from '../../core/codegen/mutation-keys'; +import { generateInvalidationFile } from '../../core/codegen/invalidation'; import type { CleanTable, CleanFieldType, CleanRelations, CleanOperation, CleanTypeRef } from '../../types/schema'; -import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import type { QueryKeyConfig, EntityRelationship } from '../../types/config'; const fieldTypes = { uuid: { gqlType: 'UUID', isArray: false } as CleanFieldType, @@ -141,7 +141,7 @@ const fieldTable = createTable({ }, }); -const simpleConfig: ResolvedQueryKeyConfig = { +const simpleConfig: QueryKeyConfig = { style: 'hierarchical', relationships: {}, generateScopedKeys: true, diff --git a/graphql/codegen/src/__tests__/codegen/react-query-hooks.test.ts b/graphql/codegen/src/__tests__/codegen/react-query-hooks.test.ts index 507ab13f4..d5e273df6 100644 --- a/graphql/codegen/src/__tests__/codegen/react-query-hooks.test.ts +++ b/graphql/codegen/src/__tests__/codegen/react-query-hooks.test.ts @@ -9,18 +9,18 @@ * - Schema types * - Barrel files */ -import { generateListQueryHook, generateSingleQueryHook } from '../../cli/codegen/queries'; -import { generateCreateMutationHook, generateUpdateMutationHook, generateDeleteMutationHook } from '../../cli/codegen/mutations'; -import { generateCustomQueryHook } from '../../cli/codegen/custom-queries'; -import { generateCustomMutationHook } from '../../cli/codegen/custom-mutations'; -import { generateSchemaTypesFile } from '../../cli/codegen/schema-types-generator'; +import { generateListQueryHook, generateSingleQueryHook } from '../../core/codegen/queries'; +import { generateCreateMutationHook, generateUpdateMutationHook, generateDeleteMutationHook } from '../../core/codegen/mutations'; +import { generateCustomQueryHook } from '../../core/codegen/custom-queries'; +import { generateCustomMutationHook } from '../../core/codegen/custom-mutations'; +import { generateSchemaTypesFile } from '../../core/codegen/schema-types-generator'; import { generateQueriesBarrel, generateMutationsBarrel, generateMainBarrel, generateCustomQueriesBarrel, generateCustomMutationsBarrel, -} from '../../cli/codegen/barrel'; +} from '../../core/codegen/barrel'; import type { CleanTable, CleanFieldType, diff --git a/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts b/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts index b36741fb1..cf21047a6 100644 --- a/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts +++ b/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts @@ -6,10 +6,10 @@ * - Mutation generators return null (since they require React Query) * - Standalone fetch functions are still generated for queries */ -import { generateListQueryHook, generateSingleQueryHook, generateAllQueryHooks } from '../../cli/codegen/queries'; -import { generateCreateMutationHook, generateUpdateMutationHook, generateDeleteMutationHook, generateAllMutationHooks } from '../../cli/codegen/mutations'; -import { generateCustomQueryHook, generateAllCustomQueryHooks } from '../../cli/codegen/custom-queries'; -import { generateCustomMutationHook, generateAllCustomMutationHooks } from '../../cli/codegen/custom-mutations'; +import { generateListQueryHook, generateSingleQueryHook, generateAllQueryHooks } from '../../core/codegen/queries'; +import { generateCreateMutationHook, generateUpdateMutationHook, generateDeleteMutationHook, generateAllMutationHooks } from '../../core/codegen/mutations'; +import { generateCustomQueryHook, generateAllCustomQueryHooks } from '../../core/codegen/custom-queries'; +import { generateCustomMutationHook, generateAllCustomMutationHooks } from '../../core/codegen/custom-mutations'; import type { CleanTable, CleanFieldType, CleanRelations, CleanOperation, CleanTypeRef, TypeRegistry } from '../../types/schema'; // ============================================================================ diff --git a/graphql/codegen/src/__tests__/codegen/scalars.test.ts b/graphql/codegen/src/__tests__/codegen/scalars.test.ts index 65c61adf5..27539d3f4 100644 --- a/graphql/codegen/src/__tests__/codegen/scalars.test.ts +++ b/graphql/codegen/src/__tests__/codegen/scalars.test.ts @@ -8,7 +8,7 @@ import { BASE_FILTER_TYPE_NAMES, scalarToTsType, scalarToFilterType, -} from '../../cli/codegen/scalars'; +} from '../../core/codegen/scalars'; describe('scalars', () => { describe('SCALAR_TS_MAP', () => { diff --git a/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts b/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts index b4b041a33..70fc56de2 100644 --- a/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts @@ -1,7 +1,7 @@ /** * Snapshot tests for schema-types-generator */ -import { generateSchemaTypesFile } from '../../cli/codegen/schema-types-generator'; +import { generateSchemaTypesFile } from '../../core/codegen/schema-types-generator'; import type { TypeRegistry, ResolvedType } from '../../types/schema'; function createTypeRegistry(types: Array<[string, ResolvedType]>): TypeRegistry { diff --git a/graphql/codegen/src/__tests__/codegen/utils.test.ts b/graphql/codegen/src/__tests__/codegen/utils.test.ts index 5554bdbf6..62dbf33a0 100644 --- a/graphql/codegen/src/__tests__/codegen/utils.test.ts +++ b/graphql/codegen/src/__tests__/codegen/utils.test.ts @@ -13,7 +13,7 @@ import { gqlTypeToTs, getPrimaryKeyInfo, getGeneratedFileHeader, -} from '../../cli/codegen/utils'; +} from '../../core/codegen/utils'; import type { CleanTable, CleanRelations } from '../../types/schema'; const emptyRelations: CleanRelations = { diff --git a/graphql/codegen/src/__tests__/config/resolve-config.test.ts b/graphql/codegen/src/__tests__/config/resolve-config.test.ts index 984cc1a7e..1f81efffb 100644 --- a/graphql/codegen/src/__tests__/config/resolve-config.test.ts +++ b/graphql/codegen/src/__tests__/config/resolve-config.test.ts @@ -19,7 +19,7 @@ describe('config resolution', () => { const resolved = resolveConfig(config); expect(resolved.endpoint).toBe('https://api.example.com/graphql'); - expect(resolved.schema).toBeNull(); + expect(resolved.schemaFile).toBeUndefined(); expect(resolved.output).toBe(DEFAULT_CONFIG.output); expect(resolved.tables.include).toEqual(DEFAULT_CONFIG.tables.include); expect(resolved.queries.exclude).toEqual(DEFAULT_CONFIG.queries.exclude); @@ -77,7 +77,7 @@ describe('config resolution', () => { output: './generated/public', }, admin: { - schema: './admin.schema.graphql', + schemaFile: './admin.schema.graphql', output: './generated/admin', headers: { 'X-Admin': '1' }, }, @@ -103,7 +103,7 @@ describe('config resolution', () => { Authorization: 'Bearer token', 'X-Admin': '1', }); - expect(adminTarget?.config.schema).toBe('./admin.schema.graphql'); + expect(adminTarget?.config.schemaFile).toBe('./admin.schema.graphql'); }); it('detects multi-target configs', () => { diff --git a/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts b/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts index 375098263..af8828802 100644 --- a/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts +++ b/graphql/codegen/src/__tests__/introspect/infer-tables.test.ts @@ -4,7 +4,7 @@ * These tests verify that we can correctly infer CleanTable[] metadata * from standard GraphQL introspection (without _meta query). */ -import { inferTablesFromIntrospection } from '../../cli/introspect/infer-tables'; +import { inferTablesFromIntrospection } from '../../core/introspect/infer-tables'; import type { IntrospectionQueryResponse, IntrospectionType, diff --git a/graphql/codegen/src/cli/commands/generate-orm.ts b/graphql/codegen/src/cli/commands/generate-orm.ts deleted file mode 100644 index 8c5b03e90..000000000 --- a/graphql/codegen/src/cli/commands/generate-orm.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * Generate ORM command - generates Prisma-like ORM client from GraphQL schema - * - * This command: - * 1. Fetches schema from endpoint or loads from file - * 2. Infers table metadata from introspection (replaces _meta) - * 3. Generates a Prisma-like ORM client with fluent API - */ - -import type { - GraphQLSDKConfig, - GraphQLSDKConfigTarget, - ResolvedTargetConfig, -} from '../../types/config'; -import { isMultiConfig, mergeConfig, resolveConfig } from '../../types/config'; -import { - createSchemaSource, - validateSourceOptions, -} from '../introspect/source'; -import { runCodegenPipeline, validateTablesFound } from './shared'; -import { findConfigFile, loadConfigFile } from './init'; -import { writeGeneratedFiles } from './generate'; -import { generateOrm as generateOrmFiles } from '../codegen/orm'; - -export interface GenerateOrmOptions { - /** Path to config file */ - config?: string; - /** Named target in a multi-target config */ - target?: string; - /** GraphQL endpoint URL (overrides config) */ - endpoint?: string; - /** Path to GraphQL schema file (.graphql) */ - schema?: string; - /** Output directory (overrides config) */ - output?: string; - /** Authorization header */ - authorization?: string; - /** Verbose output */ - verbose?: boolean; - /** Dry run - don't write files */ - dryRun?: boolean; - /** Skip custom operations (only generate table CRUD) */ - skipCustomOperations?: boolean; -} - -export interface GenerateOrmTargetResult { - name: string; - output: string; - success: boolean; - message: string; - tables?: string[]; - customQueries?: string[]; - customMutations?: string[]; - filesWritten?: string[]; - errors?: string[]; -} - -export interface GenerateOrmResult { - success: boolean; - message: string; - targets?: GenerateOrmTargetResult[]; - tables?: string[]; - customQueries?: string[]; - customMutations?: string[]; - filesWritten?: string[]; - errors?: string[]; -} - -/** - * Execute the generate-orm command (generates ORM client) - */ -export async function generateOrm( - options: GenerateOrmOptions = {} -): Promise { - if (options.verbose) { - console.log('Loading configuration...'); - } - - const configResult = await loadConfig(options); - if (!configResult.success) { - return { - success: false, - message: configResult.error!, - }; - } - - const targets = configResult.targets ?? []; - if (targets.length === 0) { - return { - success: false, - message: 'No targets resolved from configuration.', - }; - } - - const isMultiTarget = configResult.isMulti ?? targets.length > 1; - const results: GenerateOrmTargetResult[] = []; - - for (const target of targets) { - const result = await generateOrmForTarget(target, options, isMultiTarget); - results.push(result); - } - - if (!isMultiTarget) { - const [result] = results; - return { - success: result.success, - message: result.message, - targets: results, - tables: result.tables, - customQueries: result.customQueries, - customMutations: result.customMutations, - filesWritten: result.filesWritten, - errors: result.errors, - }; - } - - const successCount = results.filter((result) => result.success).length; - const failedCount = results.length - successCount; - const summaryMessage = - failedCount === 0 - ? `Generated ORM clients for ${results.length} targets.` - : `Generated ORM clients for ${successCount} of ${results.length} targets.`; - - return { - success: failedCount === 0, - message: summaryMessage, - targets: results, - errors: - failedCount > 0 - ? results.flatMap((result) => result.errors ?? []) - : undefined, - }; -} - -async function generateOrmForTarget( - target: ResolvedTargetConfig, - options: GenerateOrmOptions, - isMultiTarget: boolean -): Promise { - const config = target.config; - const outputDir = options.output || config.orm.output; - const prefix = isMultiTarget ? `[${target.name}] ` : ''; - const log = options.verbose - ? (message: string) => console.log(`${prefix}${message}`) - : () => {}; - const formatMessage = (message: string) => - isMultiTarget ? `Target "${target.name}": ${message}` : message; - - if (isMultiTarget) { - console.log(`\nTarget "${target.name}"`); - const sourceLabel = config.schema - ? `schema: ${config.schema}` - : `endpoint: ${config.endpoint}`; - console.log(` Source: ${sourceLabel}`); - console.log(` Output: ${outputDir}`); - } - - // 1. Validate source - const sourceValidation = validateSourceOptions({ - endpoint: config.endpoint || undefined, - schema: config.schema || undefined, - }); - if (!sourceValidation.valid) { - return { - name: target.name, - output: outputDir, - success: false, - message: formatMessage(sourceValidation.error!), - }; - } - - const source = createSchemaSource({ - endpoint: config.endpoint || undefined, - schema: config.schema || undefined, - authorization: options.authorization || config.headers['Authorization'], - headers: config.headers, - }); - - // 2. Run the codegen pipeline - let pipelineResult; - try { - pipelineResult = await runCodegenPipeline({ - source, - config, - verbose: options.verbose, - skipCustomOperations: options.skipCustomOperations, - }); - } catch (err) { - return { - name: target.name, - output: outputDir, - success: false, - message: formatMessage( - `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}` - ), - }; - } - - const { tables, customOperations, stats } = pipelineResult; - - // 3. Validate tables found - const tablesValidation = validateTablesFound(tables); - if (!tablesValidation.valid) { - return { - name: target.name, - output: outputDir, - success: false, - message: formatMessage(tablesValidation.error!), - }; - } - - // 4. Generate ORM code - console.log(`${prefix}Generating code...`); - const { files: generatedFiles, stats: genStats } = generateOrmFiles({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - config, - }); - console.log(`${prefix}Generated ${genStats.totalFiles} files`); - - log(` ${genStats.tables} table models`); - log(` ${genStats.customQueries} custom query operations`); - log(` ${genStats.customMutations} custom mutation operations`); - - const customQueries = customOperations.queries.map((q) => q.name); - const customMutations = customOperations.mutations.map((m) => m.name); - - if (options.dryRun) { - return { - name: target.name, - output: outputDir, - success: true, - message: formatMessage( - `Dry run complete. Would generate ${generatedFiles.length} files for ${tables.length} tables and ${stats.customQueries + stats.customMutations} custom operations.` - ), - tables: tables.map((t) => t.name), - customQueries, - customMutations, - filesWritten: generatedFiles.map((f) => f.path), - }; - } - - // 5. Write files - log('Writing files...'); - const writeResult = await writeGeneratedFiles(generatedFiles, outputDir, [ - 'models', - 'query', - 'mutation', - ]); - - if (!writeResult.success) { - return { - name: target.name, - output: outputDir, - success: false, - message: formatMessage( - `Failed to write files: ${writeResult.errors?.join(', ')}` - ), - errors: writeResult.errors, - }; - } - - const totalOps = customQueries.length + customMutations.length; - const customOpsMsg = totalOps > 0 ? ` and ${totalOps} custom operations` : ''; - - return { - name: target.name, - output: outputDir, - success: true, - message: formatMessage( - `Generated ORM client for ${tables.length} tables${customOpsMsg}. Files written to ${outputDir}` - ), - tables: tables.map((t) => t.name), - customQueries, - customMutations, - filesWritten: writeResult.filesWritten, - }; -} - -interface LoadConfigResult { - success: boolean; - targets?: ResolvedTargetConfig[]; - isMulti?: boolean; - error?: string; -} - -function buildTargetOverrides( - options: GenerateOrmOptions -): GraphQLSDKConfigTarget { - const overrides: GraphQLSDKConfigTarget = {}; - - if (options.endpoint) { - overrides.endpoint = options.endpoint; - overrides.schema = undefined; - } - - if (options.schema) { - overrides.schema = options.schema; - overrides.endpoint = undefined; - } - - return overrides; -} - -async function loadConfig( - options: GenerateOrmOptions -): Promise { - if (options.endpoint && options.schema) { - return { - success: false, - error: 'Cannot use both --endpoint and --schema. Choose one source.', - }; - } - - // Find config file - let configPath = options.config; - if (!configPath) { - configPath = findConfigFile() ?? undefined; - } - - let baseConfig: GraphQLSDKConfig = {}; - - if (configPath) { - const loadResult = await loadConfigFile(configPath); - if (!loadResult.success) { - return { success: false, error: loadResult.error }; - } - baseConfig = loadResult.config; - } - - const overrides = buildTargetOverrides(options); - - if (isMultiConfig(baseConfig)) { - if (Object.keys(baseConfig.targets).length === 0) { - return { - success: false, - error: 'Config file defines no targets.', - }; - } - - if ( - !options.target && - (options.endpoint || options.schema || options.output) - ) { - return { - success: false, - error: - 'Multiple targets configured. Use --target with --endpoint, --schema, or --output.', - }; - } - - if (options.target && !baseConfig.targets[options.target]) { - return { - success: false, - error: `Target "${options.target}" not found in config file.`, - }; - } - - const selectedTargets = options.target - ? { [options.target]: baseConfig.targets[options.target] } - : baseConfig.targets; - const defaults = baseConfig.defaults ?? {}; - const resolvedTargets: ResolvedTargetConfig[] = []; - - for (const [name, target] of Object.entries(selectedTargets)) { - let mergedTarget = mergeConfig(defaults, target); - if (options.target && name === options.target) { - mergedTarget = mergeConfig(mergedTarget, overrides); - } - - if (!mergedTarget.endpoint && !mergedTarget.schema) { - return { - success: false, - error: `Target "${name}" is missing an endpoint or schema.`, - }; - } - - resolvedTargets.push({ - name, - config: resolveConfig(mergedTarget), - }); - } - - return { - success: true, - targets: resolvedTargets, - isMulti: true, - }; - } - - if (options.target) { - return { - success: false, - error: - 'Config file does not define targets. Remove --target to continue.', - }; - } - - const mergedConfig = mergeConfig(baseConfig, overrides); - - if (!mergedConfig.endpoint && !mergedConfig.schema) { - return { - success: false, - error: - 'No source specified. Use --endpoint or --schema, or create a config file with "graphql-codegen init".', - }; - } - - return { - success: true, - targets: [{ name: 'default', config: resolveConfig(mergedConfig) }], - isMulti: false, - }; -} diff --git a/graphql/codegen/src/cli/commands/generate.ts b/graphql/codegen/src/cli/commands/generate.ts deleted file mode 100644 index b6f035ca2..000000000 --- a/graphql/codegen/src/cli/commands/generate.ts +++ /dev/null @@ -1,553 +0,0 @@ -/** - * Generate command - generates SDK from GraphQL schema - * - * This command: - * 1. Fetches schema from endpoint or loads from file - * 2. Infers table metadata from introspection (replaces _meta) - * 3. Generates hooks for both table CRUD and custom operations - */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { execSync } from 'node:child_process'; - -import type { - GraphQLSDKConfig, - GraphQLSDKConfigTarget, - ResolvedTargetConfig, -} from '../../types/config'; -import { isMultiConfig, mergeConfig, resolveConfig } from '../../types/config'; -import { - createSchemaSource, - validateSourceOptions, -} from '../introspect/source'; -import { runCodegenPipeline, validateTablesFound } from './shared'; -import { findConfigFile, loadConfigFile } from './init'; -import { generate } from '../codegen'; - -export interface GenerateOptions { - /** Path to config file */ - config?: string; - /** Named target in a multi-target config */ - target?: string; - /** GraphQL endpoint URL (overrides config) */ - endpoint?: string; - /** Path to GraphQL schema file (.graphql) */ - schema?: string; - /** Output directory (overrides config) */ - output?: string; - /** Authorization header */ - authorization?: string; - /** Verbose output */ - verbose?: boolean; - /** Dry run - don't write files */ - dryRun?: boolean; - /** Skip custom operations (only generate table CRUD) */ - skipCustomOperations?: boolean; -} - -export interface GenerateTargetResult { - name: string; - output: string; - success: boolean; - message: string; - tables?: string[]; - customQueries?: string[]; - customMutations?: string[]; - filesWritten?: string[]; - errors?: string[]; -} - -export interface GenerateResult { - success: boolean; - message: string; - targets?: GenerateTargetResult[]; - tables?: string[]; - customQueries?: string[]; - customMutations?: string[]; - filesWritten?: string[]; - errors?: string[]; -} - -/** - * Execute the generate command (generates React Query SDK) - */ -export async function generateReactQuery( - options: GenerateOptions = {} -): Promise { - if (options.verbose) { - console.log('Loading configuration...'); - } - - const configResult = await loadConfig(options); - if (!configResult.success) { - return { - success: false, - message: configResult.error!, - }; - } - - const targets = configResult.targets ?? []; - if (targets.length === 0) { - return { - success: false, - message: 'No targets resolved from configuration.', - }; - } - - const isMultiTarget = configResult.isMulti ?? targets.length > 1; - const results: GenerateTargetResult[] = []; - - for (const target of targets) { - const result = await generateForTarget(target, options, isMultiTarget); - results.push(result); - } - - if (!isMultiTarget) { - const [result] = results; - return { - success: result.success, - message: result.message, - targets: results, - tables: result.tables, - customQueries: result.customQueries, - customMutations: result.customMutations, - filesWritten: result.filesWritten, - errors: result.errors, - }; - } - - const successCount = results.filter((result) => result.success).length; - const failedCount = results.length - successCount; - const summaryMessage = - failedCount === 0 - ? `Generated SDK for ${results.length} targets.` - : `Generated SDK for ${successCount} of ${results.length} targets.`; - - return { - success: failedCount === 0, - message: summaryMessage, - targets: results, - errors: - failedCount > 0 - ? results.flatMap((result) => result.errors ?? []) - : undefined, - }; -} - -async function generateForTarget( - target: ResolvedTargetConfig, - options: GenerateOptions, - isMultiTarget: boolean -): Promise { - const config = target.config; - const prefix = isMultiTarget ? `[${target.name}] ` : ''; - const log = options.verbose - ? (message: string) => console.log(`${prefix}${message}`) - : () => {}; - const formatMessage = (message: string) => - isMultiTarget ? `Target "${target.name}": ${message}` : message; - - if (isMultiTarget) { - console.log(`\nTarget "${target.name}"`); - const sourceLabel = config.schema - ? `schema: ${config.schema}` - : `endpoint: ${config.endpoint}`; - console.log(` Source: ${sourceLabel}`); - console.log(` Output: ${config.output}`); - } - - // 1. Validate source - const sourceValidation = validateSourceOptions({ - endpoint: config.endpoint || undefined, - schema: config.schema || undefined, - }); - if (!sourceValidation.valid) { - return { - name: target.name, - output: config.output, - success: false, - message: formatMessage(sourceValidation.error!), - }; - } - - const source = createSchemaSource({ - endpoint: config.endpoint || undefined, - schema: config.schema || undefined, - authorization: options.authorization || config.headers['Authorization'], - headers: config.headers, - }); - - // 2. Run the codegen pipeline - let pipelineResult; - try { - pipelineResult = await runCodegenPipeline({ - source, - config, - verbose: options.verbose, - skipCustomOperations: options.skipCustomOperations, - }); - } catch (err) { - return { - name: target.name, - output: config.output, - success: false, - message: formatMessage( - `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}` - ), - }; - } - - const { tables, customOperations, stats } = pipelineResult; - - // 3. Validate tables found - const tablesValidation = validateTablesFound(tables); - if (!tablesValidation.valid) { - return { - name: target.name, - output: config.output, - success: false, - message: formatMessage(tablesValidation.error!), - }; - } - - // 4. Generate code - console.log(`${prefix}Generating code...`); - const { files: generatedFiles, stats: genStats } = generate({ - tables, - customOperations: { - queries: customOperations.queries, - mutations: customOperations.mutations, - typeRegistry: customOperations.typeRegistry, - }, - config, - }); - console.log(`${prefix}Generated ${genStats.totalFiles} files`); - - log(` ${genStats.queryHooks} table query hooks`); - log(` ${genStats.mutationHooks} table mutation hooks`); - log(` ${genStats.customQueryHooks} custom query hooks`); - log(` ${genStats.customMutationHooks} custom mutation hooks`); - - const customQueries = customOperations.queries.map((q) => q.name); - const customMutations = customOperations.mutations.map((m) => m.name); - - if (options.dryRun) { - return { - name: target.name, - output: config.output, - success: true, - message: formatMessage( - `Dry run complete. Would generate ${generatedFiles.length} files for ${tables.length} tables and ${stats.customQueries + stats.customMutations} custom operations.` - ), - tables: tables.map((t) => t.name), - customQueries, - customMutations, - filesWritten: generatedFiles.map((f) => f.path), - }; - } - - // 5. Write files - log('Writing files...'); - const writeResult = await writeGeneratedFiles(generatedFiles, config.output, [ - 'queries', - 'mutations', - ]); - - if (!writeResult.success) { - return { - name: target.name, - output: config.output, - success: false, - message: formatMessage( - `Failed to write files: ${writeResult.errors?.join(', ')}` - ), - errors: writeResult.errors, - }; - } - - const totalOps = customQueries.length + customMutations.length; - const customOpsMsg = totalOps > 0 ? ` and ${totalOps} custom operations` : ''; - - return { - name: target.name, - output: config.output, - success: true, - message: formatMessage( - `Generated SDK for ${tables.length} tables${customOpsMsg}. Files written to ${config.output}` - ), - tables: tables.map((t) => t.name), - customQueries, - customMutations, - filesWritten: writeResult.filesWritten, - }; -} - -interface LoadConfigResult { - success: boolean; - targets?: ResolvedTargetConfig[]; - isMulti?: boolean; - error?: string; -} - -function buildTargetOverrides( - options: GenerateOptions -): GraphQLSDKConfigTarget { - const overrides: GraphQLSDKConfigTarget = {}; - - if (options.endpoint) { - overrides.endpoint = options.endpoint; - overrides.schema = undefined; - } - - if (options.schema) { - overrides.schema = options.schema; - overrides.endpoint = undefined; - } - - if (options.output) { - overrides.output = options.output; - } - - return overrides; -} - -async function loadConfig(options: GenerateOptions): Promise { - if (options.endpoint && options.schema) { - return { - success: false, - error: 'Cannot use both --endpoint and --schema. Choose one source.', - }; - } - - // Find config file - let configPath = options.config; - if (!configPath) { - configPath = findConfigFile() ?? undefined; - } - - let baseConfig: GraphQLSDKConfig = {}; - - if (configPath) { - const loadResult = await loadConfigFile(configPath); - if (!loadResult.success) { - return { success: false, error: loadResult.error }; - } - baseConfig = loadResult.config; - } - - const overrides = buildTargetOverrides(options); - - if (isMultiConfig(baseConfig)) { - if (Object.keys(baseConfig.targets).length === 0) { - return { - success: false, - error: 'Config file defines no targets.', - }; - } - - if ( - !options.target && - (options.endpoint || options.schema || options.output) - ) { - return { - success: false, - error: - 'Multiple targets configured. Use --target with --endpoint, --schema, or --output.', - }; - } - - if (options.target && !baseConfig.targets[options.target]) { - return { - success: false, - error: `Target "${options.target}" not found in config file.`, - }; - } - - const selectedTargets = options.target - ? { [options.target]: baseConfig.targets[options.target] } - : baseConfig.targets; - const defaults = baseConfig.defaults ?? {}; - const resolvedTargets: ResolvedTargetConfig[] = []; - - for (const [name, target] of Object.entries(selectedTargets)) { - let mergedTarget = mergeConfig(defaults, target); - if (options.target && name === options.target) { - mergedTarget = mergeConfig(mergedTarget, overrides); - } - - if (!mergedTarget.endpoint && !mergedTarget.schema) { - return { - success: false, - error: `Target "${name}" is missing an endpoint or schema.`, - }; - } - - resolvedTargets.push({ - name, - config: resolveConfig(mergedTarget), - }); - } - - return { - success: true, - targets: resolvedTargets, - isMulti: true, - }; - } - - if (options.target) { - return { - success: false, - error: - 'Config file does not define targets. Remove --target to continue.', - }; - } - - const mergedConfig = mergeConfig(baseConfig, overrides); - - if (!mergedConfig.endpoint && !mergedConfig.schema) { - return { - success: false, - error: - 'No source specified. Use --endpoint or --schema, or create a config file with "graphql-codegen init".', - }; - } - - return { - success: true, - targets: [{ name: 'default', config: resolveConfig(mergedConfig) }], - isMulti: false, - }; -} - -export interface GeneratedFile { - path: string; - content: string; -} - -export interface WriteResult { - success: boolean; - filesWritten?: string[]; - errors?: string[]; -} - -export interface WriteOptions { - showProgress?: boolean; -} - -export async function writeGeneratedFiles( - files: GeneratedFile[], - outputDir: string, - subdirs: string[], - options: WriteOptions = {} -): Promise { - const { showProgress = true } = options; - const errors: string[] = []; - const written: string[] = []; - const total = files.length; - const isTTY = process.stdout.isTTY; - - // Ensure output directory exists - try { - fs.mkdirSync(outputDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { - success: false, - errors: [`Failed to create output directory: ${message}`], - }; - } - - // Create subdirectories - for (const subdir of subdirs) { - const subdirPath = path.join(outputDir, subdir); - try { - fs.mkdirSync(subdirPath, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - errors.push(`Failed to create directory ${subdirPath}: ${message}`); - } - } - - if (errors.length > 0) { - return { success: false, errors }; - } - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const filePath = path.join(outputDir, file.path); - - // Show progress - if (showProgress) { - const progress = Math.round(((i + 1) / total) * 100); - if (isTTY) { - process.stdout.write( - `\rWriting files: ${i + 1}/${total} (${progress}%)` - ); - } else if (i % 100 === 0 || i === total - 1) { - // Non-TTY: periodic updates for CI/CD - console.log(`Writing files: ${i + 1}/${total}`); - } - } - - // Ensure parent directory exists - const parentDir = path.dirname(filePath); - try { - fs.mkdirSync(parentDir, { recursive: true }); - } catch { - // Ignore if already exists - } - - try { - fs.writeFileSync(filePath, file.content, 'utf-8'); - written.push(filePath); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - errors.push(`Failed to write ${filePath}: ${message}`); - } - } - - // Clear progress line - if (showProgress && isTTY) { - process.stdout.write('\r' + ' '.repeat(40) + '\r'); - } - - // Format all generated files with prettier - if (errors.length === 0) { - if (showProgress) { - console.log('Formatting generated files...'); - } - const formatResult = formatOutput(outputDir); - if (!formatResult.success && showProgress) { - console.warn('Warning: Failed to format generated files:', formatResult.error); - } - } - - return { - success: errors.length === 0, - filesWritten: written, - errors: errors.length > 0 ? errors : undefined, - }; -} - -/** - * Format generated files using prettier - * Runs prettier on the output directory after all files are written - */ -export function formatOutput(outputDir: string): { success: boolean; error?: string } { - const absoluteOutputDir = path.resolve(outputDir); - - try { - execSync( - `npx prettier --write --single-quote --trailing-comma all --tab-width 2 --semi "${absoluteOutputDir}"`, - { - stdio: 'pipe', - encoding: 'utf-8', - }, - ); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} diff --git a/graphql/codegen/src/cli/commands/index.ts b/graphql/codegen/src/cli/commands/index.ts deleted file mode 100644 index bb15eb3a7..000000000 --- a/graphql/codegen/src/cli/commands/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * CLI commands exports - */ - -export { initCommand, findConfigFile, loadConfigFile } from './init'; -export type { InitOptions, InitResult } from './init'; - -export { generateReactQuery } from './generate'; -export type { GenerateOptions, GenerateResult, GenerateTargetResult } from './generate'; - -export { generateOrm } from './generate-orm'; -export type { GenerateOrmOptions, GenerateOrmResult, GenerateOrmTargetResult } from './generate-orm'; diff --git a/graphql/codegen/src/cli/commands/init.ts b/graphql/codegen/src/cli/commands/init.ts deleted file mode 100644 index e3a6497aa..000000000 --- a/graphql/codegen/src/cli/commands/init.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Init command - creates a new graphql-codegen configuration file - */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { createJiti } from 'jiti'; - -export interface InitOptions { - /** Target directory for the config file */ - directory?: string; - /** Force overwrite existing config */ - force?: boolean; - /** GraphQL endpoint URL to pre-populate */ - endpoint?: string; - /** Output directory to pre-populate */ - output?: string; -} - -const CONFIG_FILENAME = 'graphql-codegen.config.ts'; - -const CONFIG_TEMPLATE = `import { defineConfig } from '@constructive-io/graphql-codegen'; - -export default defineConfig({ - // GraphQL endpoint URL (PostGraphile with _meta plugin) - endpoint: '{{ENDPOINT}}', - - // Output directory for generated files - output: '{{OUTPUT}}', - - // Optional: Multi-target config (use instead of endpoint/output) - // defaults: { - // headers: { Authorization: 'Bearer YOUR_TOKEN' }, - // }, - // targets: { - // public: { endpoint: 'https://api.example.com/graphql', output: './generated/public' }, - // admin: { schema: './admin.schema.graphql', output: './generated/admin' }, - // }, - - // Optional: Tables to include/exclude (supports glob patterns) - // tables: { - // include: ['*'], - // exclude: ['_*', 'pg_*'], - // }, - - // Optional: Authorization header for authenticated endpoints - // headers: { - // Authorization: 'Bearer YOUR_TOKEN', - // }, - - // Optional: Watch mode settings (in-memory caching, no file I/O) - // watch: { - // pollInterval: 3000, // ms - // debounce: 800, // ms - // clearScreen: true, - // touchFile: '.trigger', // Optional: file to touch on change - // }, -}); -`; - -export interface InitResult { - success: boolean; - message: string; - configPath?: string; -} - -/** - * Execute the init command - */ -export async function initCommand( - options: InitOptions = {} -): Promise { - const { - directory = process.cwd(), - force = false, - endpoint = '', - output = './generated', - } = options; - - const configPath = path.join(directory, CONFIG_FILENAME); - - // Check if config already exists - if (fs.existsSync(configPath) && !force) { - return { - success: false, - message: `Configuration file already exists: ${configPath}\nUse --force to overwrite.`, - }; - } - - // Generate config content - const content = CONFIG_TEMPLATE.replace( - '{{ENDPOINT}}', - endpoint || 'http://localhost:5000/graphql' - ).replace('{{OUTPUT}}', output); - - try { - // Ensure directory exists - fs.mkdirSync(directory, { recursive: true }); - - // Write config file - fs.writeFileSync(configPath, content, 'utf-8'); - - return { - success: true, - message: `Created configuration file: ${configPath}`, - configPath, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { - success: false, - message: `Failed to create configuration file: ${message}`, - }; - } -} - -/** - * Find the nearest config file by walking up directories - */ -export function findConfigFile( - startDir: string = process.cwd() -): string | null { - let currentDir = startDir; - - while (true) { - const configPath = path.join(currentDir, CONFIG_FILENAME); - if (fs.existsSync(configPath)) { - return configPath; - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - // Reached root - return null; - } - currentDir = parentDir; - } -} - -/** - * Load and validate a config file - * - * Uses jiti to support TypeScript config files (.ts) in addition to - * JavaScript (.js, .mjs, .cjs) without requiring the user to have - * tsx or ts-node installed. - */ -export async function loadConfigFile(configPath: string): Promise<{ - success: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config?: any; - error?: string; -}> { - const resolvedPath = path.resolve(configPath); - - if (!fs.existsSync(resolvedPath)) { - return { - success: false, - error: `Config file not found: ${resolvedPath}`, - }; - } - - try { - // Use jiti to load TypeScript/ESM config files seamlessly - // jiti handles .ts, .js, .mjs, .cjs and ESM/CJS interop - const jiti = createJiti(__filename, { - interopDefault: true, - debug: process.env.JITI_DEBUG === '1', - }); - - // jiti.import() with { default: true } returns mod?.default ?? mod - const config = await jiti.import(resolvedPath, { default: true }); - - if (!config || typeof config !== 'object') { - return { - success: false, - error: 'Config file must export a configuration object', - }; - } - - const hasEndpoint = 'endpoint' in config; - const hasSchema = 'schema' in config; - const hasTargets = 'targets' in config; - - if (!hasEndpoint && !hasSchema && !hasTargets) { - return { - success: false, - error: 'Config file must define "endpoint", "schema", or "targets".', - }; - } - - if (hasTargets) { - const targets = config.targets as unknown; - if (!targets || typeof targets !== 'object' || Array.isArray(targets)) { - return { - success: false, - error: 'Config file "targets" must be an object of named configs.', - }; - } - } - - return { - success: true, - config, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { - success: false, - error: `Failed to load config file: ${message}`, - }; - } -} diff --git a/graphql/codegen/src/cli/index.ts b/graphql/codegen/src/cli/index.ts index b765814a1..61a9360b8 100644 --- a/graphql/codegen/src/cli/index.ts +++ b/graphql/codegen/src/cli/index.ts @@ -1,697 +1,204 @@ #!/usr/bin/env node /** * CLI entry point for graphql-codegen + * + * This is a thin wrapper around the core generate() function. + * All business logic is in the core modules. */ +import { CLI, CLIOptions, Inquirerer, Question, getPackageJson } from 'inquirerer'; -import { CLI, CLIOptions, Inquirerer, ParsedArgs, cliExitWithError, extractFirst, getPackageJson } from 'inquirerer'; +import { generate } from '../core/generate'; +import { findConfigFile } from '../core/config'; +import type { GenerateResult } from '../core/generate'; -import { initCommand, findConfigFile, loadConfigFile } from './commands/init'; -import { generateReactQuery } from './commands/generate'; -import { generateOrm } from './commands/generate-orm'; -import { startWatch } from './watch'; -import { - isMultiConfig, - mergeConfig, - resolveConfig, - type GraphQLSDKConfig, - type GraphQLSDKConfigTarget, - type ResolvedConfig, -} from '../types/config'; - -const usageText = ` -graphql-codegen - CLI for generating GraphQL SDK from PostGraphile endpoints or schema files - -Usage: - graphql-codegen [options] - -Commands: - init Initialize a new graphql-codegen configuration file - generate Generate SDK from GraphQL endpoint or schema file - generate-orm Generate Prisma-like ORM client from GraphQL endpoint or schema file - introspect Introspect a GraphQL endpoint or schema file and print table info - -Options: - --help, -h Show this help message - --version, -v Show version number - -Run 'graphql-codegen --help' for more information on a command. -`; - -const initUsageText = ` -graphql-codegen init - Initialize a new graphql-codegen configuration file - -Usage: - graphql-codegen init [options] - -Options: - --directory, -d Target directory for the config file (default: .) - --force, -f Force overwrite existing config - --endpoint, -e GraphQL endpoint URL to pre-populate - --output, -o Output directory to pre-populate (default: ./generated) - --help, -h Show this help message -`; - -const generateUsageText = ` -graphql-codegen generate - Generate SDK from GraphQL endpoint or schema file - -Usage: - graphql-codegen generate [options] - -Options: - --config, -c Path to config file - --target, -t Target name in config file - --endpoint, -e GraphQL endpoint URL (overrides config) - --schema, -s Path to GraphQL schema file (.graphql) - --output, -o Output directory (overrides config) - --authorization, -a
Authorization header value - --verbose, -v Verbose output - --dry-run Dry run - show what would be generated without writing files - --watch, -w Watch mode - poll endpoint for schema changes (in-memory) - --poll-interval Polling interval in milliseconds (default: 3000) - --debounce Debounce delay before regenerating (default: 800) - --touch File to touch on schema change - --no-clear Do not clear terminal on regeneration - --help, -h Show this help message -`; - -const generateOrmUsageText = ` -graphql-codegen generate-orm - Generate Prisma-like ORM client from GraphQL endpoint or schema file +const usage = ` +graphql-codegen - GraphQL SDK generator for Constructive databases Usage: - graphql-codegen generate-orm [options] - -Options: - --config, -c Path to config file - --target, -t Target name in config file - --endpoint, -e GraphQL endpoint URL (overrides config) - --schema, -s Path to GraphQL schema file (.graphql) - --output, -o Output directory (overrides config) - --authorization, -a
Authorization header value - --verbose, -v Verbose output - --dry-run Dry run - show what would be generated without writing files - --skip-custom-operations Skip custom operations (only generate table CRUD) - --watch, -w Watch mode - poll endpoint for schema changes (in-memory) - --poll-interval Polling interval in milliseconds (default: 3000) - --debounce Debounce delay before regenerating (default: 800) - --touch File to touch on schema change - --no-clear Do not clear terminal on regeneration - --help, -h Show this help message -`; - -const introspectUsageText = ` -graphql-codegen introspect - Introspect a GraphQL endpoint or schema file and print table info - -Usage: - graphql-codegen introspect [options] - -Options: - --endpoint, -e GraphQL endpoint URL - --schema, -s Path to GraphQL schema file (.graphql) - --authorization, -a
Authorization header value - --json Output as JSON - --help, -h Show this help message + graphql-codegen [options] + +Source Options (choose one): + -c, --config Path to config file + -e, --endpoint GraphQL endpoint URL + -s, --schema-file Path to GraphQL schema file + --pgpm-module-path Path to PGPM module directory + --pgpm-workspace-path Path to PGPM workspace (requires --pgpm-module-name) + --pgpm-module-name PGPM module name in workspace + +Database Options: + --schemas Comma-separated PostgreSQL schemas + --api-names Comma-separated API names (mutually exclusive with --schemas) + +Generator Options: + --react-query Generate React Query hooks + --orm Generate ORM client + -o, --output Output directory + -t, --target Target name in config file + -a, --authorization Authorization header value + --keep-db Keep ephemeral database after generation + --dry-run Preview without writing files + -v, --verbose Show detailed output + + -h, --help Show this help message + --version Show version number `; -/** - * Format duration in a human-readable way - * - Under 1 second: show milliseconds (e.g., "123ms") - * - Over 1 second: show seconds with 2 decimal places (e.g., "1.23s") - */ -function formatDuration(ms: number): string { - if (ms < 1000) { - return `${Math.round(ms)}ms`; - } - return `${(ms / 1000).toFixed(2)}s`; -} - -/** - * Load configuration for watch mode, merging CLI options with config file - */ -async function loadWatchConfig(options: { - config?: string; - target?: string; - endpoint?: string; - output?: string; - pollInterval?: number; - debounce?: number; - touch?: string; - clear?: boolean; -}): Promise { - let configPath = options.config; - if (!configPath) { - configPath = findConfigFile() ?? undefined; - } - - let baseConfig: GraphQLSDKConfig = {}; - - if (configPath) { - const loadResult = await loadConfigFile(configPath); - if (!loadResult.success) { - console.error('x', loadResult.error); - return null; - } - baseConfig = loadResult.config; - } - - if (isMultiConfig(baseConfig)) { - if (!options.target) { - console.error( - 'x Watch mode requires --target when using multiple targets.' - ); - return null; - } - - if (!baseConfig.targets[options.target]) { - console.error(`x Target "${options.target}" not found in config file.`); - return null; - } - } else if (options.target) { - console.error('x Config file does not define targets. Remove --target.'); - return null; - } - - const sourceOverrides: GraphQLSDKConfigTarget = {}; - if (options.endpoint) { - sourceOverrides.endpoint = options.endpoint; - sourceOverrides.schema = undefined; - } - - const watchOverrides: GraphQLSDKConfigTarget = { - watch: { - ...(options.pollInterval !== undefined && { - pollInterval: options.pollInterval, - }), - ...(options.debounce !== undefined && { debounce: options.debounce }), - ...(options.touch !== undefined && { touchFile: options.touch }), - ...(options.clear !== undefined && { clearScreen: options.clear }), - }, - }; - - let mergedTarget: GraphQLSDKConfigTarget; - - if (isMultiConfig(baseConfig)) { - const defaults = baseConfig.defaults ?? {}; - const targetConfig = baseConfig.targets[options.target!]; - mergedTarget = mergeConfig(defaults, targetConfig); - } else { - mergedTarget = baseConfig; - } - - mergedTarget = mergeConfig(mergedTarget, sourceOverrides); - mergedTarget = mergeConfig(mergedTarget, watchOverrides); - - if (!mergedTarget.endpoint) { - console.error( - 'x No endpoint specified. Watch mode only supports live endpoints.' - ); - return null; - } - - if (mergedTarget.schema) { - console.error( - 'x Watch mode is only supported with an endpoint, not schema.' - ); - return null; - } - - return resolveConfig(mergedTarget); -} - -/** - * Init command handler - */ -async function handleInit(argv: Partial): Promise { - if (argv.help || argv.h) { - console.log(initUsageText); - process.exit(0); - } - - const startTime = performance.now(); - const result = await initCommand({ - directory: (argv.directory as string) || (argv.d as string) || '.', - force: !!(argv.force || argv.f), - endpoint: (argv.endpoint as string) || (argv.e as string), - output: (argv.output as string) || (argv.o as string) || './generated', - }); - const duration = formatDuration(performance.now() - startTime); - - if (result.success) { - console.log('[ok]', result.message, `(${duration})`); - } else { - console.error('x', result.message, `(${duration})`); - process.exit(1); - } -} - -/** - * Generate command handler - */ -async function handleGenerate(argv: Partial): Promise { - if (argv.help || argv.h) { - console.log(generateUsageText); - process.exit(0); - } - - const startTime = performance.now(); - - const config = (argv.config as string) || (argv.c as string); - const target = (argv.target as string) || (argv.t as string); - const endpoint = (argv.endpoint as string) || (argv.e as string); - const schema = (argv.schema as string) || (argv.s as string); - const output = (argv.output as string) || (argv.o as string); - const authorization = (argv.authorization as string) || (argv.a as string); - const verbose = !!(argv.verbose || argv.v); - const dryRun = !!(argv['dry-run'] || argv.dryRun); - const watch = !!(argv.watch || argv.w); - const pollInterval = argv['poll-interval'] !== undefined - ? parseInt(argv['poll-interval'] as string, 10) - : undefined; - const debounce = argv.debounce !== undefined - ? parseInt(argv.debounce as string, 10) - : undefined; - const touch = argv.touch as string | undefined; - const clear = argv.clear !== false; - - if (endpoint && schema) { - console.error( - 'x Cannot use both --endpoint and --schema. Choose one source.' - ); - process.exit(1); - } - - if (watch) { - if (schema) { - console.error( - 'x Watch mode is only supported with --endpoint, not --schema.' - ); - process.exit(1); - } - const watchConfig = await loadWatchConfig({ - config, - target, - endpoint, - output, - pollInterval, - debounce, - touch, - clear, - }); - if (!watchConfig) { - process.exit(1); - } - - await startWatch({ - config: watchConfig, - generatorType: 'generate', - verbose, - authorization, - configPath: config, - target, - outputDir: output, - }); - return; - } - - const result = await generateReactQuery({ - config, - target, - endpoint, - schema, - output, - authorization, - verbose, - dryRun, - }); - const duration = formatDuration(performance.now() - startTime); - - const targetResults = result.targets ?? []; - const hasNamedTargets = - targetResults.length > 0 && - (targetResults.length > 1 || targetResults[0]?.name !== 'default'); - - if (hasNamedTargets) { - console.log(result.success ? '[ok]' : 'x', result.message); - targetResults.forEach((t) => { - const status = t.success ? '[ok]' : 'x'; - console.log(`\n${status} ${t.message}`); - - if (t.tables && t.tables.length > 0) { - console.log(' Tables:'); - t.tables.forEach((table) => console.log(` - ${table}`)); - } - if (t.filesWritten && t.filesWritten.length > 0) { - console.log(' Files written:'); - t.filesWritten.forEach((file) => console.log(` - ${file}`)); - } - if (!t.success && t.errors) { - t.errors.forEach((error) => console.error(` - ${error}`)); - } - }); - - if (!result.success) { - process.exit(1); - } - return; - } - - if (result.success) { - console.log('[ok]', result.message, `(${duration})`); - if (result.tables && result.tables.length > 0) { - console.log('\nTables:'); - result.tables.forEach((t) => console.log(` - ${t}`)); - } - if (result.filesWritten && result.filesWritten.length > 0) { - console.log('\nFiles written:'); - result.filesWritten.forEach((f) => console.log(` - ${f}`)); - } - } else { - console.error('x', result.message, `(${duration})`); - if (result.errors) { - result.errors.forEach((e) => console.error(' -', e)); - } - process.exit(1); - } -} - -/** - * Generate ORM command handler - */ -async function handleGenerateOrm(argv: Partial): Promise { - if (argv.help || argv.h) { - console.log(generateOrmUsageText); - process.exit(0); - } - - const startTime = performance.now(); - - const config = (argv.config as string) || (argv.c as string); - const target = (argv.target as string) || (argv.t as string); - const endpoint = (argv.endpoint as string) || (argv.e as string); - const schema = (argv.schema as string) || (argv.s as string); - const output = (argv.output as string) || (argv.o as string); - const authorization = (argv.authorization as string) || (argv.a as string); - const verbose = !!(argv.verbose || argv.v); - const dryRun = !!(argv['dry-run'] || argv.dryRun); - const skipCustomOperations = !!(argv['skip-custom-operations'] || argv.skipCustomOperations); - const watch = !!(argv.watch || argv.w); - const pollInterval = argv['poll-interval'] !== undefined - ? parseInt(argv['poll-interval'] as string, 10) - : undefined; - const debounce = argv.debounce !== undefined - ? parseInt(argv.debounce as string, 10) - : undefined; - const touch = argv.touch as string | undefined; - const clear = argv.clear !== false; - - if (endpoint && schema) { - console.error( - 'x Cannot use both --endpoint and --schema. Choose one source.' - ); - process.exit(1); - } - - if (watch) { - if (schema) { - console.error( - 'x Watch mode is only supported with --endpoint, not --schema.' - ); - process.exit(1); - } - const watchConfig = await loadWatchConfig({ - config, - target, - endpoint, - output, - pollInterval, - debounce, - touch, - clear, - }); - if (!watchConfig) { - process.exit(1); - } - - await startWatch({ - config: watchConfig, - generatorType: 'generate-orm', - verbose, - authorization, - configPath: config, - target, - outputDir: output, - skipCustomOperations, - }); - return; - } - - const result = await generateOrm({ - config, - target, - endpoint, - schema, - output, - authorization, - verbose, - dryRun, - skipCustomOperations, - }); - const duration = formatDuration(performance.now() - startTime); +const questions: Question[] = [ + { + name: 'endpoint', + message: 'GraphQL endpoint URL', + type: 'text', + required: false, + }, + { + name: 'output', + message: 'Output directory', + type: 'text', + required: false, + default: './generated', + useDefault: true, + }, + { + name: 'reactQuery', + message: 'Generate React Query hooks?', + type: 'confirm', + required: false, + default: false, + useDefault: true, + }, + { + name: 'orm', + message: 'Generate ORM client?', + type: 'confirm', + required: false, + default: false, + useDefault: true, + }, +]; - const targetResults = result.targets ?? []; - const hasNamedTargets = - targetResults.length > 0 && - (targetResults.length > 1 || targetResults[0]?.name !== 'default'); +function printResult(result: GenerateResult): void { + const targets = result.targets ?? []; + const isMultiTarget = targets.length > 1 || (targets.length === 1 && targets[0]?.name !== 'default'); - if (hasNamedTargets) { + if (isMultiTarget) { console.log(result.success ? '[ok]' : 'x', result.message); - targetResults.forEach((t) => { - const status = t.success ? '[ok]' : 'x'; - console.log(`\n${status} ${t.message}`); - - if (t.tables && t.tables.length > 0) { - console.log(' Tables:'); - t.tables.forEach((table) => console.log(` - ${table}`)); - } - if (t.customQueries && t.customQueries.length > 0) { - console.log(' Custom Queries:'); - t.customQueries.forEach((query) => console.log(` - ${query}`)); - } - if (t.customMutations && t.customMutations.length > 0) { - console.log(' Custom Mutations:'); - t.customMutations.forEach((mutation) => console.log(` - ${mutation}`)); + for (const t of targets) { + console.log(`\n${t.success ? '[ok]' : 'x'} ${t.message}`); + if (t.tables?.length) { + console.log(' Tables:', t.tables.join(', ')); } - if (t.filesWritten && t.filesWritten.length > 0) { - console.log(' Files written:'); - t.filesWritten.forEach((file) => console.log(` - ${file}`)); - } - if (!t.success && t.errors) { - t.errors.forEach((error) => console.error(` - ${error}`)); - } - }); - - if (!result.success) { - process.exit(1); - } - return; - } - - if (result.success) { - console.log('[ok]', result.message, `(${duration})`); - if (result.tables && result.tables.length > 0) { - console.log('\nTables:'); - result.tables.forEach((t) => console.log(` - ${t}`)); } - if (result.customQueries && result.customQueries.length > 0) { - console.log('\nCustom Queries:'); - result.customQueries.forEach((q) => console.log(` - ${q}`)); - } - if (result.customMutations && result.customMutations.length > 0) { - console.log('\nCustom Mutations:'); - result.customMutations.forEach((m) => console.log(` - ${m}`)); - } - if (result.filesWritten && result.filesWritten.length > 0) { - console.log('\nFiles written:'); - result.filesWritten.forEach((f) => console.log(` - ${f}`)); + } else if (result.success) { + console.log('[ok]', result.message); + if (result.tables?.length) { + console.log('Tables:', result.tables.join(', ')); } } else { - console.error('x', result.message, `(${duration})`); - if (result.errors) { - result.errors.forEach((e) => console.error(' -', e)); - } - process.exit(1); - } -} - -/** - * Introspect command handler - */ -async function handleIntrospect(argv: Partial): Promise { - if (argv.help || argv.h) { - console.log(introspectUsageText); - process.exit(0); - } - - const startTime = performance.now(); - - const endpoint = (argv.endpoint as string) || (argv.e as string); - const schema = (argv.schema as string) || (argv.s as string); - const authorization = (argv.authorization as string) || (argv.a as string); - const json = !!argv.json; - - if (!endpoint && !schema) { - console.error('x Either --endpoint or --schema must be provided.'); - process.exit(1); - } - if (endpoint && schema) { - console.error( - 'x Cannot use both --endpoint and --schema. Choose one source.' - ); - process.exit(1); - } - - const { createSchemaSource } = await import('./introspect/source'); - const { inferTablesFromIntrospection } = await import('./introspect/infer-tables'); - - try { - const source = createSchemaSource({ - endpoint, - schema, - authorization, - }); - - console.log('Fetching schema from', source.describe(), '...'); - - const { introspection } = await source.fetch(); - const tables = inferTablesFromIntrospection(introspection); - const duration = formatDuration(performance.now() - startTime); - - if (json) { - console.log(JSON.stringify(tables, null, 2)); - } else { - console.log(`\n[ok] Found ${tables.length} tables (${duration}):\n`); - tables.forEach((table) => { - const fieldCount = table.fields.length; - const relationCount = - table.relations.belongsTo.length + - table.relations.hasOne.length + - table.relations.hasMany.length + - table.relations.manyToMany.length; - console.log( - ` ${table.name} (${fieldCount} fields, ${relationCount} relations)` - ); - }); - } - } catch (err) { - const duration = formatDuration(performance.now() - startTime); - console.error( - 'x Failed to introspect schema:', - err instanceof Error ? err.message : err, - `(${duration})` - ); - process.exit(1); + console.error('x', result.message); + result.errors?.forEach((e) => console.error(' -', e)); } } -const createCommandMap = (): Record) => Promise> => { - return { - init: handleInit, - generate: handleGenerate, - 'generate-orm': handleGenerateOrm, - introspect: handleIntrospect, - }; -}; - export const commands = async ( - argv: Partial, + argv: Record, prompter: Inquirerer, _options: CLIOptions -): Promise> => { - if (argv.version || argv.v) { +): Promise> => { + if (argv.version) { const pkg = getPackageJson(__dirname); console.log(pkg.version); process.exit(0); } - const { first: command, newArgv } = extractFirst(argv); - - if ((argv.help || argv.h) && !command) { - console.log(usageText); + if (argv.help || argv.h) { + console.log(usage); process.exit(0); } - if (command === 'help') { - console.log(usageText); - process.exit(0); - } + // Normalize CLI args + const normalizedArgv = { + ...argv, + config: argv.config || findConfigFile() || undefined, + output: argv.output || argv.o, + endpoint: argv.endpoint || argv.e, + schemaFile: argv['schema-file'] || argv.s, + authorization: argv.authorization || argv.a, + target: argv.target || argv.t, + reactQuery: argv['react-query'], + orm: argv.orm, + dryRun: argv['dry-run'], + verbose: argv.verbose || argv.v, + keepDb: argv['keep-db'], + }; - const commandMap = createCommandMap(); + const answers = await prompter.prompt(normalizedArgv, questions); - if (!command) { - const answer = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'command', - message: 'What do you want to do?', - options: Object.keys(commandMap), - }, - ]); - const selectedCommand = answer.command as string; - const commandFn = commandMap[selectedCommand]; - if (commandFn) { - await commandFn(newArgv); - } - prompter.close(); - return argv; - } + // Build db config if pgpm options provided + const pgpmModulePath = argv['pgpm-module-path'] as string | undefined; + const pgpmWorkspacePath = argv['pgpm-workspace-path'] as string | undefined; + const pgpmModuleName = argv['pgpm-module-name'] as string | undefined; + const schemasArg = argv.schemas as string | undefined; + const apiNamesArg = argv['api-names'] as string | undefined; + + const db = (pgpmModulePath || pgpmWorkspacePath) ? { + pgpm: { + modulePath: pgpmModulePath, + workspacePath: pgpmWorkspacePath, + moduleName: pgpmModuleName, + }, + schemas: schemasArg ? schemasArg.split(',').map((s) => s.trim()) : undefined, + apiNames: apiNamesArg ? apiNamesArg.split(',').map((s) => s.trim()) : undefined, + keepDb: !!normalizedArgv.keepDb, + } : undefined; + + const result = await generate({ + config: answers.config as string | undefined, + target: normalizedArgv.target as string | undefined, + endpoint: answers.endpoint as string | undefined, + schemaFile: normalizedArgv.schemaFile as string | undefined, + db, + output: answers.output as string | undefined, + authorization: normalizedArgv.authorization as string | undefined, + reactQuery: !!answers.reactQuery || !!normalizedArgv.reactQuery, + orm: !!answers.orm || !!normalizedArgv.orm, + dryRun: !!normalizedArgv.dryRun, + verbose: !!normalizedArgv.verbose, + }); - const commandFn = commandMap[command]; + printResult(result); - if (!commandFn) { - console.log(usageText); - await cliExitWithError(`Unknown command: ${command}`); + if (!result.success) { + process.exit(1); } - await commandFn(newArgv); prompter.close(); - return argv; }; export const options: Partial = { minimistOpts: { alias: { - v: 'version', h: 'help', c: 'config', - t: 'target', e: 'endpoint', - s: 'schema', + s: 'schema-file', o: 'output', a: 'authorization', - d: 'directory', - f: 'force', - w: 'watch', - }, - boolean: ['help', 'version', 'force', 'verbose', 'dry-run', 'watch', 'json', 'skip-custom-operations', 'clear'], - string: ['config', 'target', 'endpoint', 'schema', 'output', 'authorization', 'directory', 'touch', 'poll-interval', 'debounce'], - default: { - clear: true, + t: 'target', + v: 'verbose', }, + boolean: [ + 'help', 'version', 'verbose', 'dry-run', 'react-query', 'orm', 'keep-db', + ], + string: [ + 'config', 'endpoint', 'schema-file', 'output', 'authorization', 'target', + 'pgpm-module-path', 'pgpm-workspace-path', 'pgpm-module-name', + 'schemas', 'api-names', + ], }, }; if (require.main === module) { - if (process.argv.includes('--version') || process.argv.includes('-v')) { - const pkg = getPackageJson(__dirname); - console.log(pkg.version); - process.exit(0); - } - - const app = new CLI(commands, options); - - app.run().then(() => { - }).catch((error) => { - console.error('Unexpected error:', error); - process.exit(1); - }); + const cli = new CLI(commands, options); + cli.run(); } diff --git a/graphql/codegen/src/cli/introspect/source/index.ts b/graphql/codegen/src/cli/introspect/source/index.ts deleted file mode 100644 index fee3433e3..000000000 --- a/graphql/codegen/src/cli/introspect/source/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Schema Source Module - * - * Provides a unified interface for loading GraphQL schemas from different sources: - * - Live GraphQL endpoints (via introspection) - * - Static .graphql schema files - */ -export * from './types'; -export * from './endpoint'; -export * from './file'; - -import type { SchemaSource } from './types'; -import { EndpointSchemaSource } from './endpoint'; -import { FileSchemaSource } from './file'; - -export interface CreateSchemaSourceOptions { - /** - * GraphQL endpoint URL (for live introspection) - */ - endpoint?: string; - - /** - * Path to GraphQL schema file (.graphql) - */ - schema?: string; - - /** - * Optional authorization header for endpoint requests - */ - authorization?: string; - - /** - * Optional additional headers for endpoint requests - */ - headers?: Record; - - /** - * Request timeout in milliseconds (for endpoint requests) - */ - timeout?: number; -} - -/** - * Create a schema source based on configuration - * - * @param options - Source configuration - * @returns Appropriate SchemaSource implementation - * @throws Error if neither endpoint nor schema is provided - */ -export function createSchemaSource( - options: CreateSchemaSourceOptions -): SchemaSource { - if (options.schema) { - return new FileSchemaSource({ - schemaPath: options.schema, - }); - } - - if (options.endpoint) { - return new EndpointSchemaSource({ - endpoint: options.endpoint, - authorization: options.authorization, - headers: options.headers, - timeout: options.timeout, - }); - } - - throw new Error( - 'Either endpoint or schema must be provided. ' + - 'Use --endpoint for live introspection or --schema for a local file.' - ); -} - -/** - * Validate that source options are valid (at least one source specified) - */ -export function validateSourceOptions(options: CreateSchemaSourceOptions): { - valid: boolean; - error?: string; -} { - if (!options.endpoint && !options.schema) { - return { - valid: false, - error: 'Either endpoint or schema must be provided', - }; - } - - if (options.endpoint && options.schema) { - return { - valid: false, - error: 'Cannot use both endpoint and schema. Choose one source.', - }; - } - - return { valid: true }; -} diff --git a/graphql/codegen/src/cli/codegen/babel-ast.ts b/graphql/codegen/src/core/codegen/babel-ast.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/babel-ast.ts rename to graphql/codegen/src/core/codegen/babel-ast.ts diff --git a/graphql/codegen/src/cli/codegen/barrel.ts b/graphql/codegen/src/core/codegen/barrel.ts similarity index 97% rename from graphql/codegen/src/cli/codegen/barrel.ts rename to graphql/codegen/src/core/codegen/barrel.ts index 90a722b1a..fce2a9e3e 100644 --- a/graphql/codegen/src/cli/codegen/barrel.ts +++ b/graphql/codegen/src/core/codegen/barrel.ts @@ -107,13 +107,9 @@ export interface MainBarrelOptions { export function generateMainBarrel( tables: CleanTable[], - options: MainBarrelOptions | boolean = {} + options: MainBarrelOptions = {} ): string { - // Support legacy signature where second arg was just hasSchemaTypes boolean - const opts: MainBarrelOptions = - typeof options === 'boolean' - ? { hasSchemaTypes: options, hasMutations: true } - : options; + const opts: MainBarrelOptions = options; const { hasSchemaTypes = false, diff --git a/graphql/codegen/src/cli/codegen/client.ts b/graphql/codegen/src/core/codegen/client.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/client.ts rename to graphql/codegen/src/core/codegen/client.ts diff --git a/graphql/codegen/src/cli/codegen/custom-mutations.ts b/graphql/codegen/src/core/codegen/custom-mutations.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/custom-mutations.ts rename to graphql/codegen/src/core/codegen/custom-mutations.ts diff --git a/graphql/codegen/src/cli/codegen/custom-queries.ts b/graphql/codegen/src/core/codegen/custom-queries.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/custom-queries.ts rename to graphql/codegen/src/core/codegen/custom-queries.ts diff --git a/graphql/codegen/src/cli/codegen/gql-ast.ts b/graphql/codegen/src/core/codegen/gql-ast.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/gql-ast.ts rename to graphql/codegen/src/core/codegen/gql-ast.ts diff --git a/graphql/codegen/src/cli/codegen/index.ts b/graphql/codegen/src/core/codegen/index.ts similarity index 80% rename from graphql/codegen/src/cli/codegen/index.ts rename to graphql/codegen/src/core/codegen/index.ts index 8778b7406..d300cd7ae 100644 --- a/graphql/codegen/src/cli/codegen/index.ts +++ b/graphql/codegen/src/core/codegen/index.ts @@ -28,7 +28,7 @@ import type { CleanOperation, TypeRegistry, } from '../../types/schema'; -import type { ResolvedConfig, ResolvedQueryKeyConfig } from '../../types/config'; +import type { GraphQLSDKConfigTarget, QueryKeyConfig } from '../../types/config'; import { DEFAULT_QUERY_KEY_CONFIG } from '../../types/config'; import { generateClientFile } from './client'; @@ -82,8 +82,15 @@ export interface GenerateOptions { mutations: CleanOperation[]; typeRegistry: TypeRegistry; }; - /** Resolved configuration */ - config: ResolvedConfig; + /** Configuration */ + config: GraphQLSDKConfigTarget; + /** + * Path to shared types directory (relative import path). + * When provided, types.ts and schema-types.ts are NOT generated + * and imports reference the shared types location instead. + * Example: '..' means types are in parent directory + */ + sharedTypesPath?: string; } // ============================================================================ @@ -91,29 +98,32 @@ export interface GenerateOptions { // ============================================================================ /** - * Generate all SDK files for tables only (legacy function signature) + * Generate all SDK files for tables only */ export function generateAllFiles( tables: CleanTable[], - config: ResolvedConfig + config: GraphQLSDKConfigTarget ): GenerateResult { return generate({ tables, config }); } /** * Generate all SDK files with full support for custom operations + * + * When sharedTypesPath is provided, types.ts and schema-types.ts are NOT generated + * (they're expected to exist in the shared types directory). */ export function generate(options: GenerateOptions): GenerateResult { - const { tables, customOperations, config } = options; + const { tables, customOperations, config, sharedTypesPath } = options; const files: GeneratedFile[] = []; // Extract codegen options const maxDepth = config.codegen.maxFieldDepth; const skipQueryField = config.codegen.skipQueryField; - const reactQueryEnabled = config.reactQuery.enabled; + const reactQueryEnabled = config.reactQuery; // Query key configuration (use defaults if not provided) - const queryKeyConfig: ResolvedQueryKeyConfig = config.queryKeys ?? DEFAULT_QUERY_KEY_CONFIG; + const queryKeyConfig: QueryKeyConfig = config.queryKeys ?? DEFAULT_QUERY_KEY_CONFIG; const useCentralizedKeys = queryKeyConfig.generateScopedKeys; const hasRelationships = Object.keys(queryKeyConfig.relationships).length > 0; @@ -126,34 +136,51 @@ export function generate(options: GenerateOptions): GenerateResult { // Collect table type names for import path resolution const tableTypeNames = new Set(tables.map((t) => getTableNames(t).typeName)); - // 2. Generate schema-types.ts for custom operations (if any) - // NOTE: This must come BEFORE types.ts so that types.ts can import enum types + // When using shared types, skip generating types.ts and schema-types.ts + // They're already generated in the shared directory let hasSchemaTypes = false; let generatedEnumNames: string[] = []; - if (customOperations && customOperations.typeRegistry) { - const schemaTypesResult = generateSchemaTypesFile({ - typeRegistry: customOperations.typeRegistry, - tableTypeNames, - }); - // Only include if there's meaningful content - if (schemaTypesResult.content.split('\n').length > 10) { - files.push({ - path: 'schema-types.ts', - content: schemaTypesResult.content, + if (sharedTypesPath) { + // Using shared types - check if schema-types would be generated + if (customOperations && customOperations.typeRegistry) { + const schemaTypesResult = generateSchemaTypesFile({ + typeRegistry: customOperations.typeRegistry, + tableTypeNames, }); - hasSchemaTypes = true; - generatedEnumNames = schemaTypesResult.generatedEnums || []; + if (schemaTypesResult.content.split('\n').length > 10) { + hasSchemaTypes = true; + generatedEnumNames = schemaTypesResult.generatedEnums || []; + } } - } + } else { + // 2. Generate schema-types.ts for custom operations (if any) + // NOTE: This must come BEFORE types.ts so that types.ts can import enum types + if (customOperations && customOperations.typeRegistry) { + const schemaTypesResult = generateSchemaTypesFile({ + typeRegistry: customOperations.typeRegistry, + tableTypeNames, + }); - // 3. Generate types.ts (can now import enums from schema-types) - files.push({ - path: 'types.ts', - content: generateTypesFile(tables, { - enumsFromSchemaTypes: generatedEnumNames, - }), - }); + // Only include if there's meaningful content + if (schemaTypesResult.content.split('\n').length > 10) { + files.push({ + path: 'schema-types.ts', + content: schemaTypesResult.content, + }); + hasSchemaTypes = true; + generatedEnumNames = schemaTypesResult.generatedEnums || []; + } + } + + // 3. Generate types.ts (can now import enums from schema-types) + files.push({ + path: 'types.ts', + content: generateTypesFile(tables, { + enumsFromSchemaTypes: generatedEnumNames, + }), + }); + } // 3b. Generate centralized query keys (query-keys.ts) let hasQueryKeys = false; diff --git a/graphql/codegen/src/cli/codegen/invalidation.ts b/graphql/codegen/src/core/codegen/invalidation.ts similarity index 99% rename from graphql/codegen/src/cli/codegen/invalidation.ts rename to graphql/codegen/src/core/codegen/invalidation.ts index e7f07add6..96247303e 100644 --- a/graphql/codegen/src/cli/codegen/invalidation.ts +++ b/graphql/codegen/src/core/codegen/invalidation.ts @@ -5,7 +5,7 @@ * for parent-child entity relationships. */ import type { CleanTable } from '../../types/schema'; -import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import type { QueryKeyConfig, EntityRelationship } from '../../types/config'; import { getTableNames, getGeneratedFileHeader, ucFirst, lcFirst } from './utils'; import * as t from '@babel/types'; import { @@ -18,7 +18,7 @@ import { export interface InvalidationGeneratorOptions { tables: CleanTable[]; - config: ResolvedQueryKeyConfig; + config: QueryKeyConfig; } export interface GeneratedInvalidationFile { diff --git a/graphql/codegen/src/cli/codegen/mutation-keys.ts b/graphql/codegen/src/core/codegen/mutation-keys.ts similarity index 98% rename from graphql/codegen/src/cli/codegen/mutation-keys.ts rename to graphql/codegen/src/core/codegen/mutation-keys.ts index 657e1fb5a..431d84fe3 100644 --- a/graphql/codegen/src/cli/codegen/mutation-keys.ts +++ b/graphql/codegen/src/core/codegen/mutation-keys.ts @@ -8,7 +8,7 @@ * - Tracking mutation state with useIsMutating */ import type { CleanTable, CleanOperation } from '../../types/schema'; -import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import type { QueryKeyConfig, EntityRelationship } from '../../types/config'; import { getTableNames, getGeneratedFileHeader, lcFirst } from './utils'; import * as t from '@babel/types'; import { @@ -22,7 +22,7 @@ import { export interface MutationKeyGeneratorOptions { tables: CleanTable[]; customMutations: CleanOperation[]; - config: ResolvedQueryKeyConfig; + config: QueryKeyConfig; } export interface GeneratedMutationKeysFile { diff --git a/graphql/codegen/src/cli/codegen/mutations.ts b/graphql/codegen/src/core/codegen/mutations.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/mutations.ts rename to graphql/codegen/src/core/codegen/mutations.ts diff --git a/graphql/codegen/src/cli/codegen/orm/barrel.ts b/graphql/codegen/src/core/codegen/orm/barrel.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/barrel.ts rename to graphql/codegen/src/core/codegen/orm/barrel.ts diff --git a/graphql/codegen/src/cli/codegen/orm/client-generator.ts b/graphql/codegen/src/core/codegen/orm/client-generator.ts similarity index 99% rename from graphql/codegen/src/cli/codegen/orm/client-generator.ts rename to graphql/codegen/src/core/codegen/orm/client-generator.ts index ed50c6b72..7b658cb4a 100644 --- a/graphql/codegen/src/cli/codegen/orm/client-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/client-generator.ts @@ -460,11 +460,10 @@ export function generateCreateClientFile( // export * from './select-types'; statements.push(t.exportAllDeclaration(t.stringLiteral('./select-types'))); - // Re-export all models for backwards compatibility - // export * from './models'; + // Re-export all models statements.push(t.exportAllDeclaration(t.stringLiteral('./models'))); - // Re-export custom operations for backwards compatibility + // Re-export custom operations if (hasCustomQueries) { statements.push( t.exportNamedDeclaration( diff --git a/graphql/codegen/src/cli/codegen/orm/client.ts b/graphql/codegen/src/core/codegen/orm/client.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/client.ts rename to graphql/codegen/src/core/codegen/orm/client.ts diff --git a/graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts b/graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts rename to graphql/codegen/src/core/codegen/orm/custom-ops-generator.ts diff --git a/graphql/codegen/src/cli/codegen/orm/index.ts b/graphql/codegen/src/core/codegen/orm/index.ts similarity index 90% rename from graphql/codegen/src/cli/codegen/orm/index.ts rename to graphql/codegen/src/core/codegen/orm/index.ts index c0c3955cb..c3e817946 100644 --- a/graphql/codegen/src/cli/codegen/orm/index.ts +++ b/graphql/codegen/src/core/codegen/orm/index.ts @@ -5,7 +5,7 @@ * and produces the complete ORM client output. */ import type { CleanTable, CleanOperation, TypeRegistry } from '../../../types/schema'; -import type { ResolvedConfig } from '../../../types/config'; +import type { GraphQLSDKConfigTarget } from '../../../types/config'; import { generateOrmClientFile, generateQueryBuilderFile, @@ -32,7 +32,14 @@ export interface GenerateOrmOptions { mutations: CleanOperation[]; typeRegistry?: TypeRegistry; }; - config: ResolvedConfig; + config: GraphQLSDKConfigTarget; + /** + * Path to shared types directory (relative import path). + * When provided, entity types are imported from shared types + * instead of being generated in input-types.ts. + * Example: '..' means types are in parent directory + */ + sharedTypesPath?: string; } export interface GenerateOrmResult { @@ -49,10 +56,11 @@ export interface GenerateOrmResult { * Generate all ORM client files */ export function generateOrm(options: GenerateOrmOptions): GenerateOrmResult { - const { tables, customOperations, config } = options; + const { tables, customOperations, sharedTypesPath } = options; const files: GeneratedFile[] = []; - const useSharedTypes = config.orm.useSharedTypes; + // Use shared types when a sharedTypesPath is provided (unified output mode) + const useSharedTypes = !!sharedTypesPath; const hasCustomQueries = (customOperations?.queries.length ?? 0) > 0; const hasCustomMutations = (customOperations?.mutations.length ?? 0) > 0; const typeRegistry = customOperations?.typeRegistry; diff --git a/graphql/codegen/src/cli/codegen/orm/input-types-generator.ts b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/input-types-generator.ts rename to graphql/codegen/src/core/codegen/orm/input-types-generator.ts diff --git a/graphql/codegen/src/cli/codegen/orm/model-generator.ts b/graphql/codegen/src/core/codegen/orm/model-generator.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/model-generator.ts rename to graphql/codegen/src/core/codegen/orm/model-generator.ts diff --git a/graphql/codegen/src/cli/codegen/orm/query-builder.ts b/graphql/codegen/src/core/codegen/orm/query-builder.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/query-builder.ts rename to graphql/codegen/src/core/codegen/orm/query-builder.ts diff --git a/graphql/codegen/src/cli/codegen/orm/select-types.ts b/graphql/codegen/src/core/codegen/orm/select-types.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/orm/select-types.ts rename to graphql/codegen/src/core/codegen/orm/select-types.ts diff --git a/graphql/codegen/src/cli/codegen/queries.ts b/graphql/codegen/src/core/codegen/queries.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/queries.ts rename to graphql/codegen/src/core/codegen/queries.ts diff --git a/graphql/codegen/src/cli/codegen/query-keys.ts b/graphql/codegen/src/core/codegen/query-keys.ts similarity index 99% rename from graphql/codegen/src/cli/codegen/query-keys.ts rename to graphql/codegen/src/core/codegen/query-keys.ts index ba32cff8c..791d4eec6 100644 --- a/graphql/codegen/src/cli/codegen/query-keys.ts +++ b/graphql/codegen/src/core/codegen/query-keys.ts @@ -11,7 +11,7 @@ import * as t from '@babel/types'; import type { CleanTable, CleanOperation } from '../../types/schema'; -import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import type { QueryKeyConfig, EntityRelationship } from '../../types/config'; import { getTableNames, getGeneratedFileHeader, ucFirst, lcFirst } from './utils'; import { generateCode, @@ -25,7 +25,7 @@ import { export interface QueryKeyGeneratorOptions { tables: CleanTable[]; customQueries: CleanOperation[]; - config: ResolvedQueryKeyConfig; + config: QueryKeyConfig; } export interface GeneratedQueryKeysFile { diff --git a/graphql/codegen/src/cli/codegen/scalars.ts b/graphql/codegen/src/core/codegen/scalars.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/scalars.ts rename to graphql/codegen/src/core/codegen/scalars.ts diff --git a/graphql/codegen/src/cli/codegen/schema-gql-ast.ts b/graphql/codegen/src/core/codegen/schema-gql-ast.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/schema-gql-ast.ts rename to graphql/codegen/src/core/codegen/schema-gql-ast.ts diff --git a/graphql/codegen/src/cli/codegen/schema-types-generator.ts b/graphql/codegen/src/core/codegen/schema-types-generator.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/schema-types-generator.ts rename to graphql/codegen/src/core/codegen/schema-types-generator.ts diff --git a/graphql/codegen/src/core/codegen/shared/index.ts b/graphql/codegen/src/core/codegen/shared/index.ts new file mode 100644 index 000000000..ccf6819fe --- /dev/null +++ b/graphql/codegen/src/core/codegen/shared/index.ts @@ -0,0 +1,129 @@ +/** + * Shared types generator + * + * Generates shared TypeScript types that can be imported by both + * React Query SDK and ORM client outputs. + * + * Output structure: + * shared/ + * index.ts - Barrel export + * types.ts - Entity interfaces + * schema-types.ts - Enums, input types, payload types + * filters.ts - Filter types (StringFilter, IntFilter, etc.) + */ +import type { CleanTable, CleanOperation, TypeRegistry } from '../../../types/schema'; +import type { GraphQLSDKConfigTarget } from '../../../types/config'; +import * as t from '@babel/types'; +import { generateCode, addJSDocComment } from '../babel-ast'; +import { generateTypesFile } from '../types'; +import { generateSchemaTypesFile } from '../schema-types-generator'; +import { getTableNames } from '../utils'; + +/** + * Helper to create export * from './module' statement + */ +function exportAllFrom(modulePath: string): t.ExportAllDeclaration { + return t.exportAllDeclaration(t.stringLiteral(modulePath)); +} + +export interface GeneratedFile { + path: string; + content: string; +} + +export interface GenerateSharedOptions { + tables: CleanTable[]; + customOperations?: { + queries: CleanOperation[]; + mutations: CleanOperation[]; + typeRegistry: TypeRegistry; + }; + config: GraphQLSDKConfigTarget; +} + +export interface GenerateSharedResult { + files: GeneratedFile[]; + generatedEnumNames: string[]; + hasSchemaTypes: boolean; +} + +/** + * Generate shared types that can be imported by both React Query SDK and ORM client + */ +export function generateSharedTypes(options: GenerateSharedOptions): GenerateSharedResult { + const { tables, customOperations } = options; + const files: GeneratedFile[] = []; + + // Collect table type names for import path resolution + const tableTypeNames = new Set(tables.map((t) => getTableNames(t).typeName)); + + // 1. Generate schema-types.ts for custom operations (if any) + // NOTE: This must come BEFORE types.ts so that types.ts can import enum types + let hasSchemaTypes = false; + let generatedEnumNames: string[] = []; + if (customOperations && customOperations.typeRegistry) { + const schemaTypesResult = generateSchemaTypesFile({ + typeRegistry: customOperations.typeRegistry, + tableTypeNames, + }); + + // Only include if there's meaningful content + if (schemaTypesResult.content.split('\n').length > 10) { + files.push({ + path: 'schema-types.ts', + content: schemaTypesResult.content, + }); + hasSchemaTypes = true; + generatedEnumNames = schemaTypesResult.generatedEnums || []; + } + } + + // 2. Generate types.ts (entity interfaces and filter types) + files.push({ + path: 'types.ts', + content: generateTypesFile(tables, { + enumsFromSchemaTypes: generatedEnumNames, + }), + }); + + // 3. Generate barrel export (index.ts) + const barrelContent = generateSharedBarrel(hasSchemaTypes); + files.push({ + path: 'index.ts', + content: barrelContent, + }); + + return { + files, + generatedEnumNames, + hasSchemaTypes, + }; +} + +/** + * Generate the barrel export for shared types using Babel AST + */ +function generateSharedBarrel(hasSchemaTypes: boolean): string { + const statements: t.Statement[] = []; + + // Export types + statements.push(exportAllFrom('./types')); + + // Export schema types if present + if (hasSchemaTypes) { + statements.push(exportAllFrom('./schema-types')); + } + + // Add file header as leading comment on first statement + if (statements.length > 0) { + addJSDocComment(statements[0], [ + 'Shared types - auto-generated, do not edit', + '@generated by @constructive-io/graphql-codegen', + ]); + } + + return generateCode(statements); +} + +export { generateTypesFile } from '../types'; +export { generateSchemaTypesFile } from '../schema-types-generator'; diff --git a/graphql/codegen/src/cli/codegen/type-resolver.ts b/graphql/codegen/src/core/codegen/type-resolver.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/type-resolver.ts rename to graphql/codegen/src/core/codegen/type-resolver.ts diff --git a/graphql/codegen/src/cli/codegen/types.ts b/graphql/codegen/src/core/codegen/types.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/types.ts rename to graphql/codegen/src/core/codegen/types.ts diff --git a/graphql/codegen/src/cli/codegen/utils.ts b/graphql/codegen/src/core/codegen/utils.ts similarity index 100% rename from graphql/codegen/src/cli/codegen/utils.ts rename to graphql/codegen/src/core/codegen/utils.ts diff --git a/graphql/codegen/src/core/config/index.ts b/graphql/codegen/src/core/config/index.ts new file mode 100644 index 000000000..239e5581b --- /dev/null +++ b/graphql/codegen/src/core/config/index.ts @@ -0,0 +1,17 @@ +/** + * Configuration module exports + */ + +export { + CONFIG_FILENAME, + findConfigFile, + loadConfigFile, + type LoadConfigFileResult, +} from './loader'; + +export { + loadAndResolveConfig, + loadWatchConfig, + type ConfigOverrideOptions, + type LoadConfigResult, +} from './resolver'; diff --git a/graphql/codegen/src/core/config/loader.ts b/graphql/codegen/src/core/config/loader.ts new file mode 100644 index 000000000..3ec07672c --- /dev/null +++ b/graphql/codegen/src/core/config/loader.ts @@ -0,0 +1,112 @@ +/** + * Configuration file loading utilities + * + * Pure functions for finding and loading graphql-codegen configuration files. + * These are core utilities that can be used programmatically or by the CLI. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createJiti } from 'jiti'; + +export const CONFIG_FILENAME = 'graphql-codegen.config.ts'; + +/** + * Find the nearest config file by walking up directories + */ +export function findConfigFile( + startDir: string = process.cwd() +): string | null { + let currentDir = startDir; + + while (true) { + const configPath = path.join(currentDir, CONFIG_FILENAME); + if (fs.existsSync(configPath)) { + return configPath; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + // Reached root + return null; + } + currentDir = parentDir; + } +} + +export interface LoadConfigFileResult { + success: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: any; + error?: string; +} + +/** + * Load and validate a config file + * + * Uses jiti to support TypeScript config files (.ts) in addition to + * JavaScript (.js, .mjs, .cjs) without requiring the user to have + * tsx or ts-node installed. + */ +export async function loadConfigFile( + configPath: string +): Promise { + const resolvedPath = path.resolve(configPath); + + if (!fs.existsSync(resolvedPath)) { + return { + success: false, + error: `Config file not found: ${resolvedPath}`, + }; + } + + try { + // Use jiti to load TypeScript/ESM config files seamlessly + // jiti handles .ts, .js, .mjs, .cjs and ESM/CJS interop + const jiti = createJiti(__filename, { + interopDefault: true, + debug: process.env.JITI_DEBUG === '1', + }); + + // jiti.import() with { default: true } returns mod?.default ?? mod + const config = await jiti.import(resolvedPath, { default: true }); + + if (!config || typeof config !== 'object') { + return { + success: false, + error: 'Config file must export a configuration object', + }; + } + + const hasEndpoint = 'endpoint' in config; + const hasSchema = 'schema' in config; + const hasTargets = 'targets' in config; + + if (!hasEndpoint && !hasSchema && !hasTargets) { + return { + success: false, + error: 'Config file must define "endpoint", "schema", or "targets".', + }; + } + + if (hasTargets) { + const targets = config.targets as unknown; + if (!targets || typeof targets !== 'object' || Array.isArray(targets)) { + return { + success: false, + error: 'Config file "targets" must be an object of named configs.', + }; + } + } + + return { + success: true, + config, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + success: false, + error: `Failed to load config file: ${message}`, + }; + } +} diff --git a/graphql/codegen/src/core/config/resolver.ts b/graphql/codegen/src/core/config/resolver.ts new file mode 100644 index 000000000..cbf7aa59e --- /dev/null +++ b/graphql/codegen/src/core/config/resolver.ts @@ -0,0 +1,294 @@ +/** + * Configuration resolution utilities + * + * Functions for resolving and merging configuration from various sources + * (config file, CLI options, defaults) into a final resolved configuration. + */ +import type { + GraphQLSDKConfig, + GraphQLSDKConfigTarget, + GraphQLSDKMultiConfig, + TargetConfig, +} from '../../types/config'; +import { isMultiConfig, mergeConfig, getConfigOptions } from '../../types/config'; +import { findConfigFile, loadConfigFile } from './loader'; + +/** + * Options that can override config file settings. + * Extends GraphQLSDKConfigTarget with CLI-specific fields. + * + * This is the same as GenerateOptions - both extend GraphQLSDKConfigTarget + * with config and target fields for CLI usage. + */ +export interface ConfigOverrideOptions extends GraphQLSDKConfigTarget { + /** Path to config file (CLI-only) */ + config?: string; + /** Named target in a multi-target config (CLI-only) */ + target?: string; +} + +/** + * Result of loading and resolving configuration + */ +export interface LoadConfigResult { + success: boolean; + targets?: TargetConfig[]; + isMulti?: boolean; + error?: string; +} + +/** + * Load and resolve configuration from file and/or options + * + * This is the main entry point for configuration loading. It: + * 1. Finds and loads the config file (if any) + * 2. Applies CLI option overrides + * 3. Resolves multi-target or single-target configurations + * 4. Returns fully resolved configuration ready for use + */ +export async function loadAndResolveConfig( + options: ConfigOverrideOptions +): Promise { + // Destructure CLI-only fields, rest is config overrides + const { config: configPath, target: targetName, ...overrides } = options; + + // Validate that at most one source is specified + const sources = [ + overrides.endpoint, + overrides.schemaFile, + overrides.db, + ].filter(Boolean); + if (sources.length > 1) { + return { + success: false, + error: + 'Multiple sources specified. Use only one of: endpoint, schemaFile, or db.', + }; + } + + // Find config file + let resolvedConfigPath = configPath; + if (!resolvedConfigPath) { + resolvedConfigPath = findConfigFile() ?? undefined; + } + + let baseConfig: GraphQLSDKConfig = {}; + + if (resolvedConfigPath) { + const loadResult = await loadConfigFile(resolvedConfigPath); + if (!loadResult.success) { + return { success: false, error: loadResult.error }; + } + baseConfig = loadResult.config; + } + + if (isMultiConfig(baseConfig)) { + return resolveMultiTargetConfig(baseConfig, targetName, overrides); + } + + return resolveSingleTargetConfig(baseConfig as GraphQLSDKConfigTarget, targetName, overrides); +} + +/** + * Resolve a multi-target configuration + */ +function resolveMultiTargetConfig( + baseConfig: GraphQLSDKMultiConfig, + targetName: string | undefined, + overrides: GraphQLSDKConfigTarget +): LoadConfigResult { + if (Object.keys(baseConfig.targets).length === 0) { + return { + success: false, + error: 'Config file defines no targets.', + }; + } + + if ( + !targetName && + (overrides.endpoint || overrides.schemaFile || overrides.db || overrides.output) + ) { + return { + success: false, + error: + 'Multiple targets configured. Use --target with source or output overrides.', + }; + } + + if (targetName && !baseConfig.targets[targetName]) { + return { + success: false, + error: `Target "${targetName}" not found in config file.`, + }; + } + + const selectedTargets = targetName + ? { [targetName]: baseConfig.targets[targetName] } + : baseConfig.targets; + const defaults = baseConfig.defaults ?? {}; + const resolvedTargets: TargetConfig[] = []; + + for (const [name, target] of Object.entries(selectedTargets)) { + let mergedTarget = mergeConfig(defaults, target); + if (targetName && name === targetName) { + mergedTarget = mergeConfig(mergedTarget, overrides); + } + + const hasSource = + mergedTarget.endpoint || + mergedTarget.schemaFile || + mergedTarget.db; + + if (!hasSource) { + return { + success: false, + error: `Target "${name}" is missing a source (endpoint, schemaFile, or db).`, + }; + } + + resolvedTargets.push({ + name, + config: getConfigOptions(mergedTarget), + }); + } + + return { + success: true, + targets: resolvedTargets, + isMulti: true, + }; +} + +/** + * Resolve a single-target configuration + */ +function resolveSingleTargetConfig( + baseConfig: GraphQLSDKConfigTarget, + targetName: string | undefined, + overrides: GraphQLSDKConfigTarget +): LoadConfigResult { + if (targetName) { + return { + success: false, + error: + 'Config file does not define targets. Remove --target to continue.', + }; + } + + const mergedConfig = mergeConfig(baseConfig, overrides); + + // Check if we have a source (endpoint, schemaFile, or db) + const hasSource = + mergedConfig.endpoint || + mergedConfig.schemaFile || + mergedConfig.db; + + if (!hasSource) { + return { + success: false, + error: + 'No source specified. Use --endpoint, --schema-file, or --db, or create a config file with "graphql-codegen init".', + }; + } + + return { + success: true, + targets: [{ name: 'default', config: getConfigOptions(mergedConfig) }], + isMulti: false, + }; +} + +/** + * Build watch configuration from options + * + * Used by watch mode to resolve configuration with watch-specific overrides. + */ +export async function loadWatchConfig(options: { + config?: string; + target?: string; + endpoint?: string; + output?: string; + pollInterval?: number; + debounce?: number; + touch?: string; + clear?: boolean; +}): Promise { + let configPath = options.config; + if (!configPath) { + configPath = findConfigFile() ?? undefined; + } + + let baseConfig: GraphQLSDKConfig = {}; + + if (configPath) { + const loadResult = await loadConfigFile(configPath); + if (!loadResult.success) { + console.error('x', loadResult.error); + return null; + } + baseConfig = loadResult.config; + } + + if (isMultiConfig(baseConfig)) { + if (!options.target) { + console.error( + 'x Watch mode requires --target when using multiple targets.' + ); + return null; + } + + if (!baseConfig.targets[options.target]) { + console.error(`x Target "${options.target}" not found in config file.`); + return null; + } + } else if (options.target) { + console.error('x Config file does not define targets. Remove --target.'); + return null; + } + + const sourceOverrides: GraphQLSDKConfigTarget = {}; + if (options.endpoint) { + sourceOverrides.endpoint = options.endpoint; + sourceOverrides.schemaFile = undefined; + } + + const watchOverrides: GraphQLSDKConfigTarget = { + watch: { + ...(options.pollInterval !== undefined && { + pollInterval: options.pollInterval, + }), + ...(options.debounce !== undefined && { debounce: options.debounce }), + ...(options.touch !== undefined && { touchFile: options.touch }), + ...(options.clear !== undefined && { clearScreen: options.clear }), + }, + }; + + let mergedTarget: GraphQLSDKConfigTarget; + + if (isMultiConfig(baseConfig)) { + const defaults = baseConfig.defaults ?? {}; + const targetConfig = baseConfig.targets[options.target!]; + mergedTarget = mergeConfig(defaults, targetConfig); + } else { + mergedTarget = baseConfig; + } + + mergedTarget = mergeConfig(mergedTarget, sourceOverrides); + mergedTarget = mergeConfig(mergedTarget, watchOverrides); + + if (!mergedTarget.endpoint) { + console.error( + 'x No endpoint specified. Watch mode only supports live endpoints.' + ); + return null; + } + + if (mergedTarget.schemaFile) { + console.error( + 'x Watch mode is only supported with an endpoint, not schemaFile.' + ); + return null; + } + + return getConfigOptions(mergedTarget); +} diff --git a/graphql/codegen/src/core/database/index.ts b/graphql/codegen/src/core/database/index.ts new file mode 100644 index 000000000..74c41cb03 --- /dev/null +++ b/graphql/codegen/src/core/database/index.ts @@ -0,0 +1,79 @@ +/** + * Database schema utilities + * + * Provides functions for building GraphQL schemas directly from PostgreSQL databases. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { buildSchemaSDL } from '@constructive-io/graphql-server'; + +export interface BuildSchemaFromDatabaseOptions { + /** Database name */ + database: string; + /** PostgreSQL schemas to include */ + schemas: string[]; + /** Output directory for the schema file */ + outDir: string; + /** Optional filename (default: schema.graphql) */ + filename?: string; +} + +export interface BuildSchemaFromDatabaseResult { + /** Path to the generated schema file */ + schemaPath: string; + /** The SDL content */ + sdl: string; +} + +/** + * Build a GraphQL schema from a PostgreSQL database and write it to a file. + * + * This function introspects the database using PostGraphile and generates + * a GraphQL SDL file that can be used for code generation. + * + * @param options - Configuration options + * @returns The path to the generated schema file and the SDL content + */ +export async function buildSchemaFromDatabase( + options: BuildSchemaFromDatabaseOptions +): Promise { + const { database, schemas, outDir, filename = 'schema.graphql' } = options; + + // Ensure output directory exists + await fs.promises.mkdir(outDir, { recursive: true }); + + // Build schema SDL from database + const sdl = await buildSchemaSDL({ + database, + schemas, + graphile: { pgSettings: async () => ({ role: 'administrator' }) }, + }); + + // Write schema to file + const schemaPath = path.join(outDir, filename); + await fs.promises.writeFile(schemaPath, sdl, 'utf-8'); + + return { schemaPath, sdl }; +} + +/** + * Build a GraphQL schema SDL string from a PostgreSQL database without writing to file. + * + * This is a convenience wrapper around buildSchemaSDL from graphql-server. + * + * @param options - Configuration options + * @returns The SDL content as a string + */ +export async function buildSchemaSDLFromDatabase(options: { + database: string; + schemas: string[]; +}): Promise { + const { database, schemas } = options; + + return buildSchemaSDL({ + database, + schemas, + graphile: { pgSettings: async () => ({ role: 'administrator' }) }, + }); +} diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts new file mode 100644 index 000000000..408be6326 --- /dev/null +++ b/graphql/codegen/src/core/generate.ts @@ -0,0 +1,289 @@ +/** + * Main generate function - orchestrates the entire codegen pipeline + * + * This is the primary entry point for programmatic usage. + * The CLI is a thin wrapper around this function. + */ +import path from 'path'; + +import { loadAndResolveConfig } from './config'; +import { createSchemaSource, validateSourceOptions } from './introspect'; +import { runCodegenPipeline, validateTablesFound } from './pipeline'; +import { generate as generateReactQueryFiles } from './codegen'; +import { generateOrm as generateOrmFiles } from './codegen/orm'; +import { generateSharedTypes } from './codegen/shared'; +import { writeGeneratedFiles } from './output'; +import type { GraphQLSDKConfigTarget, TargetConfig } from '../types/config'; + +export interface GenerateOptions extends GraphQLSDKConfigTarget { + config?: string; + target?: string; + authorization?: string; + verbose?: boolean; + dryRun?: boolean; + skipCustomOperations?: boolean; +} + +export interface GenerateTargetResult { + name: string; + output: string; + success: boolean; + message: string; + tables?: string[]; + filesWritten?: string[]; + errors?: string[]; +} + +export interface GenerateResult { + success: boolean; + message: string; + targets?: GenerateTargetResult[]; + tables?: string[]; + filesWritten?: string[]; + errors?: string[]; +} + +/** + * Main generate function - orchestrates the entire codegen pipeline + */ +export async function generate(options: GenerateOptions = {}): Promise { + if (options.verbose) { + console.log('Loading configuration...'); + } + + const configResult = await loadAndResolveConfig(options); + if (!configResult.success) { + return { success: false, message: configResult.error! }; + } + + const targets = configResult.targets ?? []; + if (targets.length === 0) { + return { success: false, message: 'No targets resolved from configuration.' }; + } + + const results: GenerateTargetResult[] = []; + + for (const target of targets) { + const runReactQuery = options.reactQuery ?? target.config.reactQuery; + const runOrm = options.orm ?? target.config.orm; + + if (!runReactQuery && !runOrm) { + results.push({ + name: target.name, + output: target.config.output, + success: false, + message: `Target "${target.name}": No generators enabled. Use --react-query or --orm.`, + }); + continue; + } + + const result = await generateForTarget(target, options, runReactQuery, runOrm); + results.push(result); + } + + if (results.length === 1) { + const [result] = results; + return { + success: result.success, + message: result.message, + targets: results, + tables: result.tables, + filesWritten: result.filesWritten, + errors: result.errors, + }; + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.length - successCount; + + return { + success: failedCount === 0, + message: failedCount === 0 + ? `Generated ${results.length} outputs successfully.` + : `Generated ${successCount} of ${results.length} outputs.`, + targets: results, + errors: failedCount > 0 ? results.flatMap((r) => r.errors ?? []) : undefined, + }; +} + +async function generateForTarget( + target: TargetConfig, + options: GenerateOptions, + runReactQuery: boolean, + runOrm: boolean +): Promise { + const config = target.config; + const outputRoot = config.output; + + // Validate source + const sourceValidation = validateSourceOptions({ + endpoint: config.endpoint || undefined, + schemaFile: config.schemaFile || undefined, + db: config.db, + }); + if (!sourceValidation.valid) { + return { + name: target.name, + output: outputRoot, + success: false, + message: sourceValidation.error!, + }; + } + + const source = createSchemaSource({ + endpoint: config.endpoint || undefined, + schemaFile: config.schemaFile || undefined, + db: config.db, + authorization: options.authorization || config.headers?.['Authorization'], + headers: config.headers, + }); + + // Run pipeline + let pipelineResult; + try { + console.log(`Fetching schema from ${source.describe()}...`); + pipelineResult = await runCodegenPipeline({ + source, + config, + verbose: options.verbose, + skipCustomOperations: options.skipCustomOperations, + }); + } catch (err) { + return { + name: target.name, + output: outputRoot, + success: false, + message: `Failed to fetch schema: ${err instanceof Error ? err.message : 'Unknown error'}`, + }; + } + + const { tables, customOperations } = pipelineResult; + + // Validate tables + const tablesValidation = validateTablesFound(tables); + if (!tablesValidation.valid) { + return { + name: target.name, + output: outputRoot, + success: false, + message: tablesValidation.error!, + }; + } + + const allFilesWritten: string[] = []; + const bothEnabled = runReactQuery && runOrm; + + // Generate shared types when both are enabled + if (bothEnabled) { + console.log('Generating shared types...'); + const sharedResult = generateSharedTypes({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + }); + + if (!options.dryRun) { + const writeResult = await writeGeneratedFiles(sharedResult.files, outputRoot, []); + if (!writeResult.success) { + return { + name: target.name, + output: outputRoot, + success: false, + message: `Failed to write shared types: ${writeResult.errors?.join(', ')}`, + errors: writeResult.errors, + }; + } + allFilesWritten.push(...(writeResult.filesWritten ?? [])); + } + } + + // Generate React Query hooks + if (runReactQuery) { + const hooksDir = path.join(outputRoot, 'hooks'); + console.log('Generating React Query hooks...'); + const { files } = generateReactQueryFiles({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + sharedTypesPath: bothEnabled ? '..' : undefined, + }); + + if (!options.dryRun) { + const writeResult = await writeGeneratedFiles(files, hooksDir, ['queries', 'mutations']); + if (!writeResult.success) { + return { + name: target.name, + output: outputRoot, + success: false, + message: `Failed to write React Query hooks: ${writeResult.errors?.join(', ')}`, + errors: writeResult.errors, + }; + } + allFilesWritten.push(...(writeResult.filesWritten ?? [])); + } + } + + // Generate ORM client + if (runOrm) { + const ormDir = path.join(outputRoot, 'orm'); + console.log('Generating ORM client...'); + const { files } = generateOrmFiles({ + tables, + customOperations: { + queries: customOperations.queries, + mutations: customOperations.mutations, + typeRegistry: customOperations.typeRegistry, + }, + config, + sharedTypesPath: bothEnabled ? '..' : undefined, + }); + + if (!options.dryRun) { + const writeResult = await writeGeneratedFiles(files, ormDir, ['models', 'query', 'mutation']); + if (!writeResult.success) { + return { + name: target.name, + output: outputRoot, + success: false, + message: `Failed to write ORM client: ${writeResult.errors?.join(', ')}`, + errors: writeResult.errors, + }; + } + allFilesWritten.push(...(writeResult.filesWritten ?? [])); + } + } + + // Generate unified barrel when both are enabled + if (bothEnabled && !options.dryRun) { + const barrelContent = `/** + * Generated SDK - auto-generated, do not edit + * @generated by @constructive-io/graphql-codegen + */ +export * from './types'; +export * from './hooks'; +export * from './orm'; +`; + await writeGeneratedFiles([{ path: 'index.ts', content: barrelContent }], outputRoot, []); + } + + const generators = [runReactQuery && 'React Query', runOrm && 'ORM'].filter(Boolean).join(' and '); + + return { + name: target.name, + output: outputRoot, + success: true, + message: options.dryRun + ? `Dry run complete. Would generate ${generators} for ${tables.length} tables.` + : `Generated ${generators} for ${tables.length} tables. Files written to ${outputRoot}`, + tables: tables.map((t) => t.name), + filesWritten: allFilesWritten, + }; +} diff --git a/graphql/codegen/src/core/index.ts b/graphql/codegen/src/core/index.ts index 5e6e782c0..5149bd636 100644 --- a/graphql/codegen/src/core/index.ts +++ b/graphql/codegen/src/core/index.ts @@ -1,7 +1,14 @@ /** - * Core query building exports + * Core module exports + * + * This module contains all the core business logic for graphql-codegen. + * The CLI is a thin wrapper around these core functions. */ +// Main generate function (orchestrates the entire pipeline) +export { generate } from './generate'; +export type { GenerateOptions, GenerateResult, GenerateTargetResult } from './generate'; + // Types export * from './types'; @@ -14,3 +21,24 @@ export { QueryBuilder, MetaObject } from './query-builder'; // Meta object utilities export { validateMetaObject, convertFromMetaSchema } from './meta-object'; + +// Configuration loading and resolution +export * from './config'; + +// Code generation +export * from './codegen'; + +// Schema introspection +export * from './introspect'; + +// Codegen pipeline +export * from './pipeline'; + +// File output +export * from './output'; + +// Watch mode +export * from './watch'; + +// Database schema utilities +export * from './database'; diff --git a/graphql/codegen/src/cli/introspect/fetch-schema.ts b/graphql/codegen/src/core/introspect/fetch-schema.ts similarity index 60% rename from graphql/codegen/src/cli/introspect/fetch-schema.ts rename to graphql/codegen/src/core/introspect/fetch-schema.ts index 3002ac944..383552f78 100644 --- a/graphql/codegen/src/cli/introspect/fetch-schema.ts +++ b/graphql/codegen/src/core/introspect/fetch-schema.ts @@ -1,9 +1,46 @@ /** * Fetch GraphQL schema introspection from an endpoint */ +import dns from 'node:dns'; +import { Agent } from 'undici'; import { SCHEMA_INTROSPECTION_QUERY } from './schema-query'; import type { IntrospectionQueryResponse } from '../../types/introspection'; +/** + * 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)) { + 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; +} + export interface FetchSchemaOptions { /** GraphQL endpoint URL */ endpoint: string; @@ -30,6 +67,10 @@ export async function fetchSchema( ): Promise { const { endpoint, authorization, headers = {}, timeout = 30000 } = options; + // Parse the endpoint URL to check for localhost + const url = new URL(endpoint); + const useLocalhostAgent = isLocalhostHostname(url.hostname); + // Build headers const requestHeaders: Record = { 'Content-Type': 'application/json', @@ -37,6 +78,11 @@ export async function fetchSchema( ...headers, }; + // Set Host header for localhost subdomains to preserve routing + if (useLocalhostAgent && url.hostname !== 'localhost') { + requestHeaders['Host'] = url.hostname; + } + if (authorization) { requestHeaders['Authorization'] = authorization; } @@ -45,16 +91,24 @@ export async function fetchSchema( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); + // Build fetch options + const fetchOptions: RequestInit & { dispatcher?: Agent } = { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ + query: SCHEMA_INTROSPECTION_QUERY, + variables: {}, + }), + signal: controller.signal, + }; + + // Use custom agent for localhost to fix DNS resolution on macOS + if (useLocalhostAgent) { + fetchOptions.dispatcher = getLocalhostAgent(); + } + try { - const response = await fetch(endpoint, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify({ - query: SCHEMA_INTROSPECTION_QUERY, - variables: {}, - }), - signal: controller.signal, - }); + const response = await fetch(endpoint, fetchOptions); clearTimeout(timeoutId); diff --git a/graphql/codegen/src/cli/introspect/index.ts b/graphql/codegen/src/core/introspect/index.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/index.ts rename to graphql/codegen/src/core/introspect/index.ts diff --git a/graphql/codegen/src/cli/introspect/infer-tables.ts b/graphql/codegen/src/core/introspect/infer-tables.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/infer-tables.ts rename to graphql/codegen/src/core/introspect/infer-tables.ts diff --git a/graphql/codegen/src/cli/introspect/schema-query.ts b/graphql/codegen/src/core/introspect/schema-query.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/schema-query.ts rename to graphql/codegen/src/core/introspect/schema-query.ts diff --git a/graphql/codegen/src/core/introspect/source/api-schemas.ts b/graphql/codegen/src/core/introspect/source/api-schemas.ts new file mode 100644 index 000000000..4ff79229f --- /dev/null +++ b/graphql/codegen/src/core/introspect/source/api-schemas.ts @@ -0,0 +1,152 @@ +/** + * API Schemas Resolution + * + * Utilities for resolving PostgreSQL schema names from API names + * by querying the services_public.api_schemas table. + */ +import { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import { getPgEnvOptions } from 'pg-env'; + +/** + * Result of validating services schema requirements + */ +export interface ServicesSchemaValidation { + valid: boolean; + error?: string; +} + +/** + * Validate that the required services schemas exist in the database + * + * Checks for: + * - services_public schema with apis and api_schemas tables + * - metaschema_public schema with schema table + * + * @param pool - Database connection pool + * @returns Validation result + */ +export async function validateServicesSchemas( + pool: Pool +): Promise { + try { + // Check for services_public.apis table + const apisCheck = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'services_public' + AND table_name = 'apis' + `); + if (apisCheck.rows.length === 0) { + return { + valid: false, + error: 'services_public.apis table not found. The database must have the services schema deployed.', + }; + } + + // Check for services_public.api_schemas table + const apiSchemasCheck = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'services_public' + AND table_name = 'api_schemas' + `); + if (apiSchemasCheck.rows.length === 0) { + return { + valid: false, + error: 'services_public.api_schemas table not found. The database must have the services schema deployed.', + }; + } + + // Check for metaschema_public.schema table + const metaschemaCheck = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'metaschema_public' + AND table_name = 'schema' + `); + if (metaschemaCheck.rows.length === 0) { + return { + valid: false, + error: 'metaschema_public.schema table not found. The database must have the metaschema deployed.', + }; + } + + return { valid: true }; + } catch (err) { + return { + valid: false, + error: `Failed to validate services schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, + }; + } +} + +/** + * Resolve schema names from API names by querying services_public.api_schemas + * + * Joins services_public.apis, services_public.api_schemas, and metaschema_public.schema + * to get the actual PostgreSQL schema names for the given API names. + * + * @param pool - Database connection pool + * @param apiNames - Array of API names to resolve + * @returns Array of PostgreSQL schema names + * @throws Error if validation fails or no schemas found + */ +export async function resolveApiSchemas( + pool: Pool, + apiNames: string[] +): Promise { + // First validate that the required schemas exist + const validation = await validateServicesSchemas(pool); + if (!validation.valid) { + throw new Error(validation.error); + } + + // Query to get schema names for the given API names + const result = await pool.query<{ schema_name: string }>( + ` + SELECT DISTINCT ms.schema_name + FROM services_public.api_schemas as_tbl + JOIN services_public.apis api ON api.id = as_tbl.api_id + JOIN metaschema_public.schema ms ON ms.id = as_tbl.schema_id + WHERE api.name = ANY($1) + ORDER BY ms.schema_name + `, + [apiNames] + ); + + if (result.rows.length === 0) { + throw new Error( + `No schemas found for API names: ${apiNames.join(', ')}. ` + + 'Ensure the APIs exist and have schemas assigned in services_public.api_schemas.' + ); + } + + return result.rows.map((row) => row.schema_name); +} + +/** + * Create a database pool for the given database name or connection string + * + * @param database - Database name or connection string + * @returns Database connection pool + */ +export function createDatabasePool(database: string): Pool { + // Check if it's a connection string or just a database name + const isConnectionString = database.startsWith('postgres://') || database.startsWith('postgresql://'); + + if (isConnectionString) { + // Parse connection string and extract database name + // Format: postgres://user:password@host:port/database + const url = new URL(database); + const dbName = url.pathname.slice(1); // Remove leading slash + return getPgPool({ + host: url.hostname, + port: parseInt(url.port || '5432', 10), + user: url.username, + password: url.password, + database: dbName, + }); + } + + // Use environment variables for connection, just override database name + const config = getPgEnvOptions({ database }); + return getPgPool(config); +} diff --git a/graphql/codegen/src/core/introspect/source/database.ts b/graphql/codegen/src/core/introspect/source/database.ts new file mode 100644 index 000000000..4b77ea12e --- /dev/null +++ b/graphql/codegen/src/core/introspect/source/database.ts @@ -0,0 +1,137 @@ +/** + * Database Schema Source + * + * Loads GraphQL schema directly from a PostgreSQL database using PostGraphile + * introspection and converts it to introspection format. + */ +import { buildSchema, introspectionFromSchema } from 'graphql'; +import type { SchemaSource, SchemaSourceResult } from './types'; +import { SchemaSourceError } from './types'; +import type { IntrospectionQueryResponse } from '../../../types/introspection'; +import { buildSchemaSDLFromDatabase } from '../../database'; +import { createDatabasePool, resolveApiSchemas, validateServicesSchemas } from './api-schemas'; + +export interface DatabaseSchemaSourceOptions { + /** + * Database name or connection string + * Can be a simple database name (uses PGHOST, PGPORT, PGUSER, PGPASSWORD env vars) + * or a full connection string (postgres://user:pass@host:port/dbname) + */ + database: string; + + /** + * PostgreSQL schemas to include in introspection + * Mutually exclusive with apiNames + */ + schemas?: string[]; + + /** + * API names to resolve schemas from + * Queries services_public.api_schemas to get schema names + * Mutually exclusive with schemas + */ + apiNames?: string[]; +} + +/** + * Schema source that loads from a PostgreSQL database + * + * Uses PostGraphile to introspect the database and generate a GraphQL schema. + * The schema is built in-memory without writing to disk. + */ +export class DatabaseSchemaSource implements SchemaSource { + private readonly options: DatabaseSchemaSourceOptions; + + constructor(options: DatabaseSchemaSourceOptions) { + this.options = options; + } + + async fetch(): Promise { + const { database, apiNames } = this.options; + + // Resolve schemas - either from explicit schemas option or from apiNames + let schemas: string[]; + if (apiNames && apiNames.length > 0) { + // Validate services schemas exist at the beginning for database mode + const pool = createDatabasePool(database); + try { + const validation = await validateServicesSchemas(pool); + if (!validation.valid) { + throw new SchemaSourceError(validation.error!, this.describe()); + } + schemas = await resolveApiSchemas(pool, apiNames); + } catch (err) { + if (err instanceof SchemaSourceError) throw err; + throw new SchemaSourceError( + `Failed to resolve API schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + } else { + schemas = this.options.schemas ?? ['public']; + } + + // Build SDL from database + let sdl: string; + try { + sdl = await buildSchemaSDLFromDatabase({ + database, + schemas, + }); + } catch (err) { + throw new SchemaSourceError( + `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Validate non-empty + if (!sdl.trim()) { + throw new SchemaSourceError( + 'Database introspection returned empty schema', + this.describe() + ); + } + + // Parse SDL to GraphQL schema + let schema; + try { + schema = buildSchema(sdl); + } catch (err) { + throw new SchemaSourceError( + `Invalid GraphQL SDL from database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Convert to introspection format + let introspectionResult; + try { + introspectionResult = introspectionFromSchema(schema); + } catch (err) { + throw new SchemaSourceError( + `Failed to generate introspection: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Convert graphql-js introspection result to our mutable type + const introspection: IntrospectionQueryResponse = JSON.parse( + JSON.stringify(introspectionResult) + ) as IntrospectionQueryResponse; + + return { introspection }; + } + + describe(): string { + const { database, schemas, apiNames } = this.options; + if (apiNames && apiNames.length > 0) { + return `database: ${database} (apiNames: ${apiNames.join(', ')})`; + } + return `database: ${database} (schemas: ${(schemas ?? ['public']).join(', ')})`; + } +} diff --git a/graphql/codegen/src/cli/introspect/source/endpoint.ts b/graphql/codegen/src/core/introspect/source/endpoint.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/source/endpoint.ts rename to graphql/codegen/src/core/introspect/source/endpoint.ts diff --git a/graphql/codegen/src/cli/introspect/source/file.ts b/graphql/codegen/src/core/introspect/source/file.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/source/file.ts rename to graphql/codegen/src/core/introspect/source/file.ts diff --git a/graphql/codegen/src/core/introspect/source/index.ts b/graphql/codegen/src/core/introspect/source/index.ts new file mode 100644 index 000000000..9642201c0 --- /dev/null +++ b/graphql/codegen/src/core/introspect/source/index.ts @@ -0,0 +1,268 @@ +/** + * Schema Source Module + * + * Provides a unified interface for loading GraphQL schemas from different sources: + * - Live GraphQL endpoints (via introspection) + * - Static .graphql schema files + * - PostgreSQL databases (via PostGraphile introspection) + * - PGPM modules (via ephemeral database deployment) + */ +export * from './types'; +export * from './endpoint'; +export * from './file'; +export * from './database'; +export * from './pgpm-module'; +export * from './api-schemas'; + +import type { SchemaSource } from './types'; +import type { DbConfig, PgpmConfig } from '../../../types/config'; +import { EndpointSchemaSource } from './endpoint'; +import { FileSchemaSource } from './file'; +import { DatabaseSchemaSource } from './database'; +import { + PgpmModuleSchemaSource, + isPgpmModulePathOptions, + isPgpmWorkspaceOptions, +} from './pgpm-module'; + +/** + * Options for endpoint-based schema source + */ +export interface EndpointSourceOptions { + endpoint: string; + authorization?: string; + headers?: Record; + timeout?: number; +} + +/** + * Options for file-based schema source + */ +export interface FileSourceOptions { + schemaFile: string; +} + +/** + * Options for database-based schema source + */ +export interface DatabaseSourceOptions { + database: string; + schemas?: string[]; + apiNames?: string[]; +} + +/** + * Options for PGPM module-based schema source (direct path) + */ +export interface PgpmModulePathSourceOptions { + pgpmModulePath: string; + schemas?: string[]; + apiNames?: string[]; + keepDb?: boolean; +} + +/** + * Options for PGPM module-based schema source (workspace + module name) + */ +export interface PgpmWorkspaceSourceOptions { + pgpmWorkspacePath: string; + pgpmModuleName: string; + schemas?: string[]; + apiNames?: string[]; + keepDb?: boolean; +} + +export interface CreateSchemaSourceOptions { + /** + * GraphQL endpoint URL (for live introspection) + */ + endpoint?: string; + + /** + * Path to GraphQL schema file (.graphql) + */ + schemaFile?: string; + + /** + * Database configuration for direct database introspection or PGPM module + */ + db?: DbConfig; + + /** + * Optional authorization header for endpoint requests + */ + authorization?: string; + + /** + * Optional additional headers for endpoint requests + */ + headers?: Record; + + /** + * Request timeout in milliseconds (for endpoint requests) + */ + timeout?: number; +} + +/** + * Detect which source mode is being used based on options + */ +export type SourceMode = 'endpoint' | 'schemaFile' | 'database' | 'pgpm-module' | 'pgpm-workspace'; + +export function detectSourceMode(options: CreateSchemaSourceOptions): SourceMode | null { + if (options.endpoint) return 'endpoint'; + if (options.schemaFile) return 'schemaFile'; + if (options.db) { + // Check for PGPM modes first + if (options.db.pgpm?.modulePath) return 'pgpm-module'; + if (options.db.pgpm?.workspacePath && options.db.pgpm?.moduleName) return 'pgpm-workspace'; + // Default to database mode if db is specified without pgpm + return 'database'; + } + return null; +} + +/** + * Create a schema source based on configuration + * + * Supports five modes: + * - endpoint: Introspect from a live GraphQL endpoint + * - schemaFile: Load from a local .graphql file + * - database: Introspect directly from a PostgreSQL database + * - pgpm-module: Deploy a PGPM module to an ephemeral database and introspect + * - pgpm-workspace: Deploy a module from a PGPM workspace to an ephemeral database and introspect + * + * @param options - Source configuration + * @returns Appropriate SchemaSource implementation + * @throws Error if no valid source is provided + */ +export function createSchemaSource( + options: CreateSchemaSourceOptions +): SchemaSource { + const mode = detectSourceMode(options); + + switch (mode) { + case 'schemaFile': + return new FileSchemaSource({ + schemaPath: options.schemaFile!, + }); + + case 'endpoint': + return new EndpointSchemaSource({ + endpoint: options.endpoint!, + authorization: options.authorization, + headers: options.headers, + timeout: options.timeout, + }); + + case 'database': + // Database mode uses db.config for connection (falls back to env vars) + // and db.schemas or db.apiNames for schema selection + return new DatabaseSchemaSource({ + database: options.db?.config?.database ?? '', + schemas: options.db?.schemas, + apiNames: options.db?.apiNames, + }); + + case 'pgpm-module': + return new PgpmModuleSchemaSource({ + pgpmModulePath: options.db!.pgpm!.modulePath!, + schemas: options.db?.schemas, + apiNames: options.db?.apiNames, + keepDb: options.db?.keepDb, + }); + + case 'pgpm-workspace': + return new PgpmModuleSchemaSource({ + pgpmWorkspacePath: options.db!.pgpm!.workspacePath!, + pgpmModuleName: options.db!.pgpm!.moduleName!, + schemas: options.db?.schemas, + apiNames: options.db?.apiNames, + keepDb: options.db?.keepDb, + }); + + default: + throw new Error( + 'No source specified. Use one of: endpoint, schemaFile, or db (with optional pgpm for module deployment).' + ); + } +} + +/** + * Validate that source options are valid (exactly one source specified) + */ +export function validateSourceOptions(options: CreateSchemaSourceOptions): { + valid: boolean; + error?: string; +} { + // Count primary sources + const sources = [ + options.endpoint, + options.schemaFile, + options.db, + ].filter(Boolean); + + if (sources.length === 0) { + return { + valid: false, + error: + 'No source specified. Use one of: endpoint, schemaFile, or db.', + }; + } + + if (sources.length > 1) { + return { + valid: false, + error: + 'Multiple sources specified. Use only one of: endpoint, schemaFile, or db.', + }; + } + + // Validate pgpm workspace mode has both required fields + if (options.db?.pgpm) { + const pgpm = options.db.pgpm; + if (pgpm.workspacePath && !pgpm.moduleName) { + return { + valid: false, + error: 'db.pgpm.workspacePath requires db.pgpm.moduleName to be specified.', + }; + } + + if (pgpm.moduleName && !pgpm.workspacePath) { + return { + valid: false, + error: 'db.pgpm.moduleName requires db.pgpm.workspacePath to be specified.', + }; + } + + // Must have either modulePath or workspacePath+moduleName + if (!pgpm.modulePath && !(pgpm.workspacePath && pgpm.moduleName)) { + return { + valid: false, + error: 'db.pgpm requires either modulePath or both workspacePath and moduleName.', + }; + } + } + + // For database mode, validate schemas/apiNames mutual exclusivity + if (options.db) { + const hasSchemas = options.db.schemas && options.db.schemas.length > 0; + const hasApiNames = options.db.apiNames && options.db.apiNames.length > 0; + + if (hasSchemas && hasApiNames) { + return { + valid: false, + error: 'Cannot specify both db.schemas and db.apiNames. Use one or the other.', + }; + } + + if (!hasSchemas && !hasApiNames) { + return { + valid: false, + error: 'Must specify either db.schemas or db.apiNames for database mode.', + }; + } + } + + return { valid: true }; +} diff --git a/graphql/codegen/src/core/introspect/source/pgpm-module.ts b/graphql/codegen/src/core/introspect/source/pgpm-module.ts new file mode 100644 index 000000000..a246d769f --- /dev/null +++ b/graphql/codegen/src/core/introspect/source/pgpm-module.ts @@ -0,0 +1,321 @@ +/** + * PGPM Module Schema Source + * + * Loads GraphQL schema from a PGPM module by: + * 1. Creating an ephemeral database + * 2. Deploying the module to the database + * 3. Introspecting the database with PostGraphile + * 4. Cleaning up the ephemeral database (unless keepDb is true) + */ +import { buildSchema, introspectionFromSchema } from 'graphql'; +import { PgpmPackage } from '@pgpmjs/core'; +import { createEphemeralDb, type EphemeralDbResult } from 'pgsql-client'; +import { deployPgpm } from 'pgsql-seed'; +import { getPgPool } from 'pg-cache'; + +import type { SchemaSource, SchemaSourceResult } from './types'; +import { SchemaSourceError } from './types'; +import type { IntrospectionQueryResponse } from '../../../types/introspection'; +import { buildSchemaSDLFromDatabase } from '../../database'; +import { resolveApiSchemas, validateServicesSchemas } from './api-schemas'; + +/** + * Options for PGPM module schema source using direct module path + */ +export interface PgpmModulePathOptions { + /** + * Path to the PGPM module directory + * The directory should contain a pgpm.plan file and .control file + */ + pgpmModulePath: string; + + /** + * PostgreSQL schemas to include in introspection + * Mutually exclusive with apiNames + */ + schemas?: string[]; + + /** + * API names to resolve schemas from + * Queries services_public.api_schemas to get schema names + * Mutually exclusive with schemas + */ + apiNames?: string[]; + + /** + * If true, keeps the ephemeral database after introspection (useful for debugging) + * @default false + */ + keepDb?: boolean; +} + +/** + * Options for PGPM module schema source using workspace + module name + */ +export interface PgpmWorkspaceOptions { + /** + * Path to the PGPM workspace directory + * The directory should contain a pgpm.config.yaml or similar workspace config + */ + pgpmWorkspacePath: string; + + /** + * Name of the module within the workspace + */ + pgpmModuleName: string; + + /** + * PostgreSQL schemas to include in introspection + * Mutually exclusive with apiNames + */ + schemas?: string[]; + + /** + * API names to resolve schemas from + * Queries services_public.api_schemas to get schema names + * Mutually exclusive with schemas + */ + apiNames?: string[]; + + /** + * If true, keeps the ephemeral database after introspection (useful for debugging) + * @default false + */ + keepDb?: boolean; +} + +export type PgpmModuleSchemaSourceOptions = PgpmModulePathOptions | PgpmWorkspaceOptions; + +/** + * Type guard to check if options use direct module path + */ +export function isPgpmModulePathOptions( + options: PgpmModuleSchemaSourceOptions +): options is PgpmModulePathOptions { + return 'pgpmModulePath' in options; +} + +/** + * Type guard to check if options use workspace + module name + */ +export function isPgpmWorkspaceOptions( + options: PgpmModuleSchemaSourceOptions +): options is PgpmWorkspaceOptions { + return 'pgpmWorkspacePath' in options && 'pgpmModuleName' in options; +} + +/** + * Schema source that loads from a PGPM module + * + * Creates an ephemeral database, deploys the module, introspects the schema, + * and cleans up. Supports both direct module path and workspace + module name modes. + */ +export class PgpmModuleSchemaSource implements SchemaSource { + private readonly options: PgpmModuleSchemaSourceOptions; + private ephemeralDb: EphemeralDbResult | null = null; + + constructor(options: PgpmModuleSchemaSourceOptions) { + this.options = options; + } + + async fetch(): Promise { + const keepDb = this.getKeepDb(); + const apiNames = this.getApiNames(); + + // Resolve the module path + let modulePath: string; + try { + modulePath = this.resolveModulePath(); + } catch (err) { + throw new SchemaSourceError( + `Failed to resolve module path: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Validate the module exists + const pkg = new PgpmPackage(modulePath); + if (!pkg.isInModule()) { + throw new SchemaSourceError( + `Not a valid PGPM module: ${modulePath}. Directory must contain pgpm.plan and .control files.`, + this.describe() + ); + } + + // Create ephemeral database + try { + this.ephemeralDb = createEphemeralDb({ + prefix: 'codegen_pgpm_', + verbose: false, + }); + } catch (err) { + throw new SchemaSourceError( + `Failed to create ephemeral database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + const { config: dbConfig, teardown } = this.ephemeralDb; + + try { + // Deploy the module to the ephemeral database + try { + await deployPgpm(dbConfig, modulePath, false); + } catch (err) { + throw new SchemaSourceError( + `Failed to deploy PGPM module: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Resolve schemas - either from explicit schemas option or from apiNames (after deployment) + let schemas: string[]; + if (apiNames && apiNames.length > 0) { + // For PGPM mode, validate services schemas AFTER migration + const pool = getPgPool(dbConfig); + try { + const validation = await validateServicesSchemas(pool); + if (!validation.valid) { + throw new SchemaSourceError(validation.error!, this.describe()); + } + schemas = await resolveApiSchemas(pool, apiNames); + } catch (err) { + if (err instanceof SchemaSourceError) throw err; + throw new SchemaSourceError( + `Failed to resolve API schemas: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + } else { + schemas = this.getSchemas(); + } + + // Build SDL from the deployed database + let sdl: string; + try { + sdl = await buildSchemaSDLFromDatabase({ + database: dbConfig.database, + schemas, + }); + } catch (err) { + throw new SchemaSourceError( + `Failed to introspect database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Validate non-empty + if (!sdl.trim()) { + throw new SchemaSourceError( + 'Database introspection returned empty schema', + this.describe() + ); + } + + // Parse SDL to GraphQL schema + let schema; + try { + schema = buildSchema(sdl); + } catch (err) { + throw new SchemaSourceError( + `Invalid GraphQL SDL from database: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Convert to introspection format + let introspectionResult; + try { + introspectionResult = introspectionFromSchema(schema); + } catch (err) { + throw new SchemaSourceError( + `Failed to generate introspection: ${err instanceof Error ? err.message : 'Unknown error'}`, + this.describe(), + err instanceof Error ? err : undefined + ); + } + + // Convert graphql-js introspection result to our mutable type + const introspection: IntrospectionQueryResponse = JSON.parse( + JSON.stringify(introspectionResult) + ) as IntrospectionQueryResponse; + + return { introspection }; + } finally { + // Clean up the ephemeral database + teardown({ keepDb }); + + if (keepDb) { + console.log(`[pgpm-module] Kept ephemeral database: ${dbConfig.database}`); + } + } + } + + describe(): string { + const apiNames = this.getApiNames(); + if (isPgpmModulePathOptions(this.options)) { + if (apiNames && apiNames.length > 0) { + return `pgpm module: ${this.options.pgpmModulePath} (apiNames: ${apiNames.join(', ')})`; + } + const schemas = this.options.schemas ?? ['public']; + return `pgpm module: ${this.options.pgpmModulePath} (schemas: ${schemas.join(', ')})`; + } else { + if (apiNames && apiNames.length > 0) { + return `pgpm workspace: ${this.options.pgpmWorkspacePath}, module: ${this.options.pgpmModuleName} (apiNames: ${apiNames.join(', ')})`; + } + const schemas = this.options.schemas ?? ['public']; + return `pgpm workspace: ${this.options.pgpmWorkspacePath}, module: ${this.options.pgpmModuleName} (schemas: ${schemas.join(', ')})`; + } + } + + private resolveModulePath(): string { + if (isPgpmModulePathOptions(this.options)) { + return this.options.pgpmModulePath; + } + + // Workspace + module name mode + const { pgpmWorkspacePath, pgpmModuleName } = this.options; + const workspace = new PgpmPackage(pgpmWorkspacePath); + + if (!workspace.workspacePath) { + throw new Error(`Not a valid PGPM workspace: ${pgpmWorkspacePath}`); + } + + // Get the module from the workspace + const moduleProject = workspace.getModuleProject(pgpmModuleName); + const modulePath = moduleProject.getModulePath(); + + if (!modulePath) { + throw new Error(`Module "${pgpmModuleName}" not found in workspace`); + } + + return modulePath; + } + + private getSchemas(): string[] { + if (isPgpmModulePathOptions(this.options)) { + return this.options.schemas ?? ['public']; + } + return this.options.schemas ?? ['public']; + } + + private getApiNames(): string[] | undefined { + if (isPgpmModulePathOptions(this.options)) { + return this.options.apiNames; + } + return this.options.apiNames; + } + + private getKeepDb(): boolean { + if (isPgpmModulePathOptions(this.options)) { + return this.options.keepDb ?? false; + } + return this.options.keepDb ?? false; + } +} diff --git a/graphql/codegen/src/cli/introspect/source/types.ts b/graphql/codegen/src/core/introspect/source/types.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/source/types.ts rename to graphql/codegen/src/core/introspect/source/types.ts diff --git a/graphql/codegen/src/cli/introspect/transform-schema.ts b/graphql/codegen/src/core/introspect/transform-schema.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/transform-schema.ts rename to graphql/codegen/src/core/introspect/transform-schema.ts diff --git a/graphql/codegen/src/cli/introspect/transform.ts b/graphql/codegen/src/core/introspect/transform.ts similarity index 100% rename from graphql/codegen/src/cli/introspect/transform.ts rename to graphql/codegen/src/core/introspect/transform.ts diff --git a/graphql/codegen/src/core/output/index.ts b/graphql/codegen/src/core/output/index.ts new file mode 100644 index 000000000..3d6344d9c --- /dev/null +++ b/graphql/codegen/src/core/output/index.ts @@ -0,0 +1,11 @@ +/** + * Output module exports + */ + +export { + writeGeneratedFiles, + formatOutput, + type GeneratedFile, + type WriteResult, + type WriteOptions, +} from './writer'; diff --git a/graphql/codegen/src/core/output/writer.ts b/graphql/codegen/src/core/output/writer.ts new file mode 100644 index 000000000..ae7dd71b9 --- /dev/null +++ b/graphql/codegen/src/core/output/writer.ts @@ -0,0 +1,163 @@ +/** + * File writing utilities + * + * Pure functions for writing generated files to disk and formatting them. + * These are core utilities that can be used programmatically or by the CLI. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +import type { GeneratedFile } from '../codegen'; + +export type { GeneratedFile }; + +/** + * Result of writing files + */ +export interface WriteResult { + success: boolean; + filesWritten?: string[]; + errors?: string[]; +} + +/** + * Options for writing files + */ +export interface WriteOptions { + /** Show progress output (default: true) */ + showProgress?: boolean; + /** Format files with prettier after writing (default: true) */ + formatFiles?: boolean; +} + +/** + * Write generated files to disk + * + * @param files - Array of files to write + * @param outputDir - Base output directory + * @param subdirs - Subdirectories to create + * @param options - Write options + */ +export async function writeGeneratedFiles( + files: GeneratedFile[], + outputDir: string, + subdirs: string[], + options: WriteOptions = {} +): Promise { + const { showProgress = true, formatFiles = true } = options; + const errors: string[] = []; + const written: string[] = []; + const total = files.length; + const isTTY = process.stdout.isTTY; + + // Ensure output directory exists + try { + fs.mkdirSync(outputDir, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + success: false, + errors: [`Failed to create output directory: ${message}`], + }; + } + + // Create subdirectories + for (const subdir of subdirs) { + const subdirPath = path.join(outputDir, subdir); + try { + fs.mkdirSync(subdirPath, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + errors.push(`Failed to create directory ${subdirPath}: ${message}`); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const filePath = path.join(outputDir, file.path); + + // Show progress + if (showProgress) { + const progress = Math.round(((i + 1) / total) * 100); + if (isTTY) { + process.stdout.write( + `\rWriting files: ${i + 1}/${total} (${progress}%)` + ); + } else if (i % 100 === 0 || i === total - 1) { + // Non-TTY: periodic updates for CI/CD + console.log(`Writing files: ${i + 1}/${total}`); + } + } + + // Ensure parent directory exists + const parentDir = path.dirname(filePath); + try { + fs.mkdirSync(parentDir, { recursive: true }); + } catch { + // Ignore if already exists + } + + try { + fs.writeFileSync(filePath, file.content, 'utf-8'); + written.push(filePath); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + errors.push(`Failed to write ${filePath}: ${message}`); + } + } + + // Clear progress line + if (showProgress && isTTY) { + process.stdout.write('\r' + ' '.repeat(40) + '\r'); + } + + // Format all generated files with prettier + if (formatFiles && errors.length === 0) { + if (showProgress) { + console.log('Formatting generated files...'); + } + const formatResult = formatOutput(outputDir); + if (!formatResult.success && showProgress) { + console.warn( + 'Warning: Failed to format generated files:', + formatResult.error + ); + } + } + + return { + success: errors.length === 0, + filesWritten: written, + errors: errors.length > 0 ? errors : undefined, + }; +} + +/** + * Format generated files using prettier + * + * Runs prettier on the output directory after all files are written. + */ +export function formatOutput( + outputDir: string +): { success: boolean; error?: string } { + const absoluteOutputDir = path.resolve(outputDir); + + try { + execSync( + `npx prettier --write --single-quote --trailing-comma all --tab-width 2 --semi "${absoluteOutputDir}"`, + { + stdio: 'pipe', + encoding: 'utf-8', + } + ); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } +} diff --git a/graphql/codegen/src/cli/commands/shared.ts b/graphql/codegen/src/core/pipeline/index.ts similarity index 95% rename from graphql/codegen/src/cli/commands/shared.ts rename to graphql/codegen/src/core/pipeline/index.ts index 2defb6b24..65aba4e21 100644 --- a/graphql/codegen/src/cli/commands/shared.ts +++ b/graphql/codegen/src/core/pipeline/index.ts @@ -8,7 +8,7 @@ * - Operation transformation * - Filtering */ -import type { ResolvedConfig } from '../../types/config'; +import type { GraphQLSDKConfigTarget } from '../../types/config'; import type { CleanTable, CleanOperation, @@ -24,6 +24,10 @@ import { getCustomOperations, } from '../introspect/transform-schema'; +// Re-export for convenience +export type { SchemaSource } from '../introspect/source'; +export { createSchemaSource, validateSourceOptions } from '../introspect/source'; + // ============================================================================ // Pipeline Types // ============================================================================ @@ -35,9 +39,9 @@ export interface CodegenPipelineOptions { source: SchemaSource; /** - * Resolved configuration + * Configuration */ - config: ResolvedConfig; + config: GraphQLSDKConfigTarget; /** * Enable verbose logging diff --git a/graphql/codegen/src/cli/watch/cache.ts b/graphql/codegen/src/core/watch/cache.ts similarity index 100% rename from graphql/codegen/src/cli/watch/cache.ts rename to graphql/codegen/src/core/watch/cache.ts diff --git a/graphql/codegen/src/cli/watch/debounce.ts b/graphql/codegen/src/core/watch/debounce.ts similarity index 100% rename from graphql/codegen/src/cli/watch/debounce.ts rename to graphql/codegen/src/core/watch/debounce.ts diff --git a/graphql/codegen/src/cli/watch/hash.ts b/graphql/codegen/src/core/watch/hash.ts similarity index 100% rename from graphql/codegen/src/cli/watch/hash.ts rename to graphql/codegen/src/core/watch/hash.ts diff --git a/graphql/codegen/src/cli/watch/index.ts b/graphql/codegen/src/core/watch/index.ts similarity index 100% rename from graphql/codegen/src/cli/watch/index.ts rename to graphql/codegen/src/core/watch/index.ts diff --git a/graphql/codegen/src/cli/watch/orchestrator.ts b/graphql/codegen/src/core/watch/orchestrator.ts similarity index 75% rename from graphql/codegen/src/cli/watch/orchestrator.ts rename to graphql/codegen/src/core/watch/orchestrator.ts index 5c86db07c..c530c113e 100644 --- a/graphql/codegen/src/cli/watch/orchestrator.ts +++ b/graphql/codegen/src/core/watch/orchestrator.ts @@ -4,18 +4,35 @@ * Coordinates schema polling, change detection, and code regeneration */ -import type { ResolvedConfig } from '../../types/config'; +import type { GraphQLSDKConfigTarget } from '../../types/config'; import type { GeneratorType, WatchOptions, PollEvent } from './types'; import { SchemaPoller } from './poller'; import { debounce } from './debounce'; -import { generateReactQuery, type GenerateResult } from '../commands/generate'; -import { - generateOrm, - type GenerateOrmResult, -} from '../commands/generate-orm'; + +// These will be injected by the CLI layer to avoid circular dependencies +// The watch orchestrator doesn't need to know about the full generate commands +export interface GenerateFunction { + (options: { + config?: string; + target?: string; + endpoint?: string; + output?: string; + authorization?: string; + verbose?: boolean; + skipCustomOperations?: boolean; + }): Promise; +} + +export interface GenerateResult { + success: boolean; + message: string; + tables?: string[]; + filesWritten?: string[]; + errors?: string[]; +} export interface WatchOrchestratorOptions { - config: ResolvedConfig; + config: GraphQLSDKConfigTarget; generatorType: GeneratorType; verbose: boolean; authorization?: string; @@ -23,10 +40,14 @@ export interface WatchOrchestratorOptions { configPath?: string; /** Target name for multi-target configs */ target?: string; - /** Override output directory (for ORM) */ + /** Override output directory */ outputDir?: string; /** Skip custom operations flag */ skipCustomOperations?: boolean; + /** Generator function for React Query SDK */ + generateReactQuery: GenerateFunction; + /** Generator function for ORM client */ + generateOrm: GenerateFunction; } export interface WatchStatus { @@ -192,30 +213,34 @@ export class WatchOrchestrator { this.log('Regenerating...'); try { - let result: GenerateResult | GenerateOrmResult; - - if (this.options.generatorType === 'generate') { - result = await generateReactQuery({ - config: this.options.configPath, - target: this.options.target, - endpoint: this.options.config.endpoint, - output: this.options.outputDir ?? this.options.config.output, - authorization: this.options.authorization, - verbose: this.watchOptions.verbose, - skipCustomOperations: this.options.skipCustomOperations, - }); - } else { - result = await generateOrm({ - config: this.options.configPath, - target: this.options.target, - endpoint: this.options.config.endpoint, - output: this.options.outputDir ?? this.options.config.orm.output, - authorization: this.options.authorization, - verbose: this.watchOptions.verbose, - skipCustomOperations: this.options.skipCustomOperations, - }); + let generateFn: GenerateFunction; + let outputDir: string | undefined; + + switch (this.options.generatorType) { + case 'react-query': + generateFn = this.options.generateReactQuery; + // React Query hooks go to {output}/hooks + outputDir = this.options.outputDir ?? `${this.options.config.output}/hooks`; + break; + case 'orm': + generateFn = this.options.generateOrm; + // ORM client goes to {output}/orm + outputDir = this.options.outputDir ?? `${this.options.config.output}/orm`; + break; + default: + throw new Error(`Unknown generator type: ${this.options.generatorType}`); } + const result = await generateFn({ + config: this.options.configPath, + target: this.options.target, + endpoint: this.options.config.endpoint, + output: outputDir, + authorization: this.options.authorization, + verbose: this.watchOptions.verbose, + skipCustomOperations: this.options.skipCustomOperations, + }); + const duration = Date.now() - startTime; if (result.success) { @@ -260,10 +285,17 @@ export class WatchOrchestrator { } private logHeader(): void { - const generatorName = - this.options.generatorType === 'generate' - ? 'React Query hooks' - : 'ORM client'; + let generatorName: string; + switch (this.options.generatorType) { + case 'react-query': + generatorName = 'React Query hooks'; + break; + case 'orm': + generatorName = 'ORM client'; + break; + default: + throw new Error(`Unknown generator type: ${this.options.generatorType}`); + } console.log(`\n${'─'.repeat(50)}`); console.log(`graphql-codegen watch mode (${generatorName})`); console.log(`Endpoint: ${this.options.config.endpoint}`); diff --git a/graphql/codegen/src/cli/watch/poller.ts b/graphql/codegen/src/core/watch/poller.ts similarity index 100% rename from graphql/codegen/src/cli/watch/poller.ts rename to graphql/codegen/src/core/watch/poller.ts diff --git a/graphql/codegen/src/cli/watch/types.ts b/graphql/codegen/src/core/watch/types.ts similarity index 96% rename from graphql/codegen/src/cli/watch/types.ts rename to graphql/codegen/src/core/watch/types.ts index ae5f595d7..cc9d4bd5f 100644 --- a/graphql/codegen/src/cli/watch/types.ts +++ b/graphql/codegen/src/core/watch/types.ts @@ -73,4 +73,4 @@ export interface PollEvent { /** * Generator type for watch mode */ -export type GeneratorType = 'generate' | 'generate-orm'; +export type GeneratorType = 'react-query' | 'orm'; diff --git a/graphql/codegen/src/index.ts b/graphql/codegen/src/index.ts index cd1db7b06..f85898f2d 100644 --- a/graphql/codegen/src/index.ts +++ b/graphql/codegen/src/index.ts @@ -1,7 +1,7 @@ /** * @constructive-io/graphql-codegen * - * CLI-based GraphQL SDK generator for PostGraphile endpoints. + * GraphQL SDK generator for Constructive databases. * Introspects via _meta query and generates typed queries, mutations, * and React Query v5 hooks. */ @@ -21,21 +21,19 @@ export * from './client'; // Config definition helper export { defineConfig } from './types/config'; -// CLI command exports (for packages/cli consumption) -export { - generateReactQuery, - generateOrm, - findConfigFile, - loadConfigFile, -} from './cli/commands'; +// Main generate function (orchestrates the entire pipeline) +export { generate } from './core/generate'; +export type { GenerateOptions, GenerateResult, GenerateTargetResult } from './core/generate'; + +// Config utilities +export { findConfigFile, loadConfigFile } from './core/config'; +// Database schema utilities (re-exported from core for convenience) +export { + buildSchemaFromDatabase, + buildSchemaSDLFromDatabase, +} from './core/database'; export type { - GenerateOptions, - GenerateResult, - GenerateTargetResult, - GenerateOrmOptions, - GenerateOrmResult, - GenerateOrmTargetResult, - InitOptions, - InitResult, -} from './cli/commands'; + BuildSchemaFromDatabaseOptions, + BuildSchemaFromDatabaseResult, +} from './core/database'; diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 0663eee61..f521943e2 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -3,6 +3,7 @@ */ import deepmerge from 'deepmerge'; +import type { PgConfig } from 'pg-env'; /** * Array merge strategy that replaces arrays (source wins over target). @@ -72,22 +73,94 @@ export interface QueryKeyConfig { generateMutationKeys?: boolean; } +/** + * PGPM module configuration for ephemeral database creation + */ +export interface PgpmConfig { + /** + * Path to a PGPM module directory + * Creates an ephemeral database, deploys the module, and introspects + */ + modulePath?: string; + + /** + * Path to a PGPM workspace directory + * Must be used together with `moduleName` + */ + workspacePath?: string; + + /** + * Name of the module within the PGPM workspace + * Must be used together with `workspacePath` + */ + moduleName?: string; +} + +/** + * Database configuration for direct database introspection + */ +export interface DbConfig { + /** + * PostgreSQL connection configuration + * Falls back to environment variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE) + * via @pgpmjs/env when not specified + */ + config?: Partial; + + /** + * PGPM module configuration for ephemeral database creation + * When specified, creates an ephemeral database from the module + */ + pgpm?: PgpmConfig; + + /** + * PostgreSQL schemas to introspect + * Mutually exclusive with `apiNames` + * @example ['public', 'app_public'] + */ + schemas?: string[]; + + /** + * API names to resolve schemas from + * Queries services_public.api_schemas to automatically determine schemas + * Mutually exclusive with `schemas` + * @example ['my_api'] + */ + apiNames?: string[]; + + /** + * Keep the ephemeral database after introspection (for debugging) + * Only applies when using pgpm + * @default false + */ + keepDb?: boolean; +} + /** * Target configuration for graphql-codegen * Represents a single schema source and output destination. + * + * Source options (choose one): + * - endpoint: GraphQL endpoint URL for live introspection + * - schemaFile: Path to GraphQL schema file (.graphql) + * - db: Database configuration for direct introspection or PGPM module */ export interface GraphQLSDKConfigTarget { /** * GraphQL endpoint URL for live introspection - * Either endpoint or schema must be provided */ endpoint?: string; /** * Path to GraphQL schema file (.graphql) for file-based generation - * Either endpoint or schema must be provided */ - schema?: string; + schemaFile?: string; + + /** + * Database configuration for direct database introspection or PGPM module + * Use db.schemas or db.apiNames to specify which schemas to introspect + */ + db?: DbConfig; /** * Headers to include in introspection requests @@ -174,40 +247,19 @@ export interface GraphQLSDKConfigTarget { }; /** - * ORM client generation options - * When set, generates a Prisma-like ORM client in addition to or instead of React Query hooks - */ - orm?: { - /** - * Whether to generate ORM client - * @default false - */ - enabled?: boolean; - /** - * Output directory for generated ORM client - * @default './generated/orm' - */ - output?: string; - /** - * Whether to import shared types from hooks output or generate standalone - * When true, ORM types.ts will re-export from ../graphql/types - * @default true - */ - useSharedTypes?: boolean; - }; + * Whether to generate ORM client + * When enabled, generates a Prisma-like ORM client to {output}/orm + * @default false + */ + orm?: boolean; /** - * React Query integration options - * Controls whether React Query hooks are generated + * Whether to generate React Query hooks + * When enabled, generates React Query hooks to {output}/hooks + * When false, only standalone fetch functions are generated (no React dependency) + * @default false */ - reactQuery?: { - /** - * Whether to generate React Query hooks (useQuery, useMutation) - * When false, only standalone fetch functions are generated (no React dependency) - * @default false - */ - enabled?: boolean; - }; + reactQuery?: boolean; /** * Query key generation configuration @@ -220,6 +272,33 @@ export interface GraphQLSDKConfigTarget { * When enabled via CLI --watch flag, the CLI will poll the endpoint for schema changes */ watch?: WatchConfig; + + // ============================================================================ + // Runtime options (used when calling generate() programmatically) + // ============================================================================ + + /** + * Authorization header value (convenience option, also available in headers) + */ + authorization?: string; + + /** + * Enable verbose output + * @default false + */ + verbose?: boolean; + + /** + * Dry run - don't write files, just show what would be generated + * @default false + */ + dryRun?: boolean; + + /** + * Skip custom operations (only generate table CRUD) + * @default false + */ + skipCustomOperations?: boolean; } /** @@ -275,95 +354,20 @@ export interface WatchConfig { clearScreen?: boolean; } -/** - * Resolved watch configuration with defaults applied - */ -export interface ResolvedWatchConfig { - pollInterval: number; - debounce: number; - touchFile: string | null; - clearScreen: boolean; -} - -/** - * Resolved query key configuration with defaults applied - */ -export interface ResolvedQueryKeyConfig { - style: 'flat' | 'hierarchical'; - relationships: Record; - generateScopedKeys: boolean; - generateCascadeHelpers: boolean; - generateMutationKeys: boolean; -} - -/** - * Resolved configuration with defaults applied - */ -export interface ResolvedConfig { - /** - * GraphQL endpoint URL (empty string if using schema file) - */ - endpoint: string; - /** - * Path to GraphQL schema file (null if using endpoint) - */ - schema: string | null; - headers: Record; - output: string; - tables: { - include: string[]; - exclude: string[]; - systemExclude: string[]; - }; - queries: { - include: string[]; - exclude: string[]; - systemExclude: string[]; - }; - mutations: { - include: string[]; - exclude: string[]; - systemExclude: string[]; - }; - excludeFields: string[]; - hooks: { - queries: boolean; - mutations: boolean; - queryKeyPrefix: string; - }; - postgraphile: { - schema: string; - }; - codegen: { - maxFieldDepth: number; - skipQueryField: boolean; - }; - orm: { - enabled: boolean; - output: string; - useSharedTypes: boolean; - }; - reactQuery: { - enabled: boolean; - }; - queryKeys: ResolvedQueryKeyConfig; - watch: ResolvedWatchConfig; -} - /** * Default watch configuration values */ -export const DEFAULT_WATCH_CONFIG: ResolvedWatchConfig = { +export const DEFAULT_WATCH_CONFIG: WatchConfig = { pollInterval: 3000, debounce: 800, - touchFile: null, + touchFile: undefined, clearScreen: true, }; /** * Default query key configuration values */ -export const DEFAULT_QUERY_KEY_CONFIG: ResolvedQueryKeyConfig = { +export const DEFAULT_QUERY_KEY_CONFIG: QueryKeyConfig = { style: 'hierarchical', relationships: {}, generateScopedKeys: true, @@ -374,9 +378,8 @@ export const DEFAULT_QUERY_KEY_CONFIG: ResolvedQueryKeyConfig = { /** * Default configuration values */ -export const DEFAULT_CONFIG: ResolvedConfig = { +export const DEFAULT_CONFIG: GraphQLSDKConfigTarget = { endpoint: '', - schema: null, headers: {}, output: './generated/graphql', tables: { @@ -407,14 +410,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { maxFieldDepth: 2, skipQueryField: true, }, - orm: { - enabled: false, - output: './generated/orm', - useSharedTypes: true, - }, - reactQuery: { - enabled: false, - }, + orm: false, + reactQuery: false, queryKeys: DEFAULT_QUERY_KEY_CONFIG, watch: DEFAULT_WATCH_CONFIG, }; @@ -428,11 +425,11 @@ export function defineConfig(config: GraphQLSDKConfig): GraphQLSDKConfig { } /** - * Resolved target configuration helper + * Target configuration with name (used after resolution) */ -export interface ResolvedTargetConfig { +export interface TargetConfig { name: string; - config: ResolvedConfig; + config: GraphQLSDKConfigTarget; } /** @@ -457,18 +454,27 @@ export function mergeConfig( return deepmerge(base, overrides, { arrayMerge: replaceArrays }); } +/** + * Get configuration options by merging defaults with user config. + * Similar to getEnvOptions pattern from @pgpmjs/env. + */ +export function getConfigOptions( + overrides: GraphQLSDKConfigTarget = {} +): GraphQLSDKConfigTarget { + return deepmerge(DEFAULT_CONFIG, overrides, { arrayMerge: replaceArrays }); +} + /** * Resolve configuration by applying defaults. - * Uses deepmerge with array replacement strategy. + * For single-target configs only - throws for multi-target configs. */ -export function resolveConfig(config: GraphQLSDKConfig): ResolvedConfig { +export function resolveConfig(config: GraphQLSDKConfig): GraphQLSDKConfigTarget { if (isMultiConfig(config)) { throw new Error( 'Multi-target config cannot be resolved with resolveConfig(). Use resolveConfigTargets().' ); } - - return deepmerge(DEFAULT_CONFIG, config, { arrayMerge: replaceArrays }) as ResolvedConfig; + return getConfigOptions(config); } /** @@ -476,11 +482,11 @@ export function resolveConfig(config: GraphQLSDKConfig): ResolvedConfig { */ export function resolveConfigTargets( config: GraphQLSDKMultiConfig -): ResolvedTargetConfig[] { +): TargetConfig[] { const defaults = config.defaults ?? {}; return Object.entries(config.targets).map(([name, target]) => ({ name, - config: resolveConfig(mergeConfig(defaults, target)), + config: getConfigOptions(mergeConfig(defaults, target)), })); } diff --git a/graphql/codegen/src/types/index.ts b/graphql/codegen/src/types/index.ts index 0498e391e..b88fafff7 100644 --- a/graphql/codegen/src/types/index.ts +++ b/graphql/codegen/src/types/index.ts @@ -53,13 +53,13 @@ export type { GraphQLSDKConfig, GraphQLSDKConfigTarget, GraphQLSDKMultiConfig, - ResolvedConfig, - ResolvedTargetConfig, + TargetConfig, } from './config'; export { defineConfig, - resolveConfig, + getConfigOptions, + mergeConfig, resolveConfigTargets, DEFAULT_CONFIG, } from './config'; diff --git a/graphql/server/package.json b/graphql/server/package.json index dc90a6b77..91abf4803 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -78,7 +78,6 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.971.0", - "@constructive-io/graphql-codegen": "workspace:*", "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/graphql-upload": "^8.0.12", diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts index 833dc89b7..83fc8ac0b 100644 --- a/packages/cli/__tests__/cli.test.ts +++ b/packages/cli/__tests__/cli.test.ts @@ -1,10 +1,4 @@ -jest.mock('@constructive-io/graphql-codegen/cli/commands/generate', () => ({ - generateCommand: async () => ({ success: true, message: 'ok' }) -})); import { Inquirerer, Question } from 'inquirerer'; -jest.mock('@constructive-io/graphql-codegen/cli/commands/generate', () => ({ - generateCommand: jest.fn(async () => ({ success: true, message: 'Generated SDK', filesWritten: [] as string[] })) -})) import { KEY_SEQUENCES, setupTests, TestEnvironment } from '../test-utils'; diff --git a/packages/cli/__tests__/codegen.test.ts b/packages/cli/__tests__/codegen.test.ts index c396ffbe2..0323a471c 100644 --- a/packages/cli/__tests__/codegen.test.ts +++ b/packages/cli/__tests__/codegen.test.ts @@ -1,14 +1,15 @@ import type { ParsedArgs } from 'inquirerer' import codegenCommand from '../src/commands/codegen' -import { generateReactQuery } from '@constructive-io/graphql-codegen/cli/commands/generate' -jest.mock('@constructive-io/graphql-codegen/cli/commands/generate', () => ({ - generateReactQuery: jest.fn(async () => ({ success: true, message: 'Generated SDK', filesWritten: [] as string[] })) +jest.mock('@constructive-io/graphql-codegen', () => ({ + generate: jest.fn(async () => ({ success: true, message: 'Generated SDK', filesWritten: [] as string[] })), + findConfigFile: jest.fn((): string | undefined => undefined), })) -jest.mock('@constructive-io/graphql-server', () => ({ - buildSchemaSDL: jest.fn(async () => 'type Query { hello: String }\nschema { query: Query }') -})) +// Create a mock prompter that returns the argv values directly +const createMockPrompter = () => ({ + prompt: jest.fn(async (argv: any, _questions: any) => argv) +}) describe('codegen command', () => { beforeEach(() => { @@ -20,8 +21,9 @@ describe('codegen command', () => { const spyExit = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error('exit:' + code) }) as any) const argv: Partial = { help: true } + const mockPrompter = createMockPrompter() - await expect(codegenCommand(argv, {} as any, {} as any)).rejects.toThrow('exit:0') + await expect(codegenCommand(argv, mockPrompter as any, {} as any)).rejects.toThrow('exit:0') expect(spyLog).toHaveBeenCalled() const first = (spyLog.mock.calls[0]?.[0] as string) || '' expect(first).toContain('Constructive GraphQL Codegen') @@ -30,43 +32,49 @@ describe('codegen command', () => { spyExit.mockRestore() }) - it('calls generateReactQuery with endpoint flow options', async () => { + it('calls generate with endpoint flow options', async () => { + const { generate: mockGenerate } = require('@constructive-io/graphql-codegen') const argv: Partial = { endpoint: 'http://localhost:3000/graphql', auth: 'Bearer testtoken', out: 'graphql/codegen/dist', verbose: true, - 'dry-run': true + dryRun: true, + reactQuery: true, } + const mockPrompter = createMockPrompter() - await codegenCommand(argv, {} as any, {} as any) + await codegenCommand(argv, mockPrompter as any, {} as any) - expect(generateReactQuery).toHaveBeenCalled() - const call = (generateReactQuery as jest.Mock).mock.calls[0][0] + expect(mockGenerate).toHaveBeenCalled() + const call = mockGenerate.mock.calls[0][0] expect(call).toMatchObject({ endpoint: 'http://localhost:3000/graphql', output: 'graphql/codegen/dist', authorization: 'Bearer testtoken', verbose: true, - dryRun: true + dryRun: true, + reactQuery: true }) }) - it('builds schema file and calls generateReactQuery with schema when DB options provided', async () => { + it('calls generate with db options when schemas provided', async () => { + const { generate: mockGenerate } = require('@constructive-io/graphql-codegen') const argv: Partial = { - database: 'constructive_db', - schemas: 'public', - out: 'graphql/codegen/dist' + schemas: 'public,app', + out: 'graphql/codegen/dist', + reactQuery: true, } + const mockPrompter = createMockPrompter() - await codegenCommand(argv, {} as any, {} as any) + await codegenCommand(argv, mockPrompter as any, {} as any) - expect(generateReactQuery).toHaveBeenCalled() - const call = (generateReactQuery as jest.Mock).mock.calls[0][0] - expect(call.schema).toBe('graphql/codegen/dist/schema.graphql') + expect(mockGenerate).toHaveBeenCalled() + const call = mockGenerate.mock.calls[0][0] + expect(call.db).toEqual({ schemas: ['public', 'app'], apiNames: undefined }) expect(call.output).toBe('graphql/codegen/dist') - expect(call.endpoint).toBeUndefined() + expect(call.reactQuery).toBe(true) }) }) diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts index dbd6a28aa..343014899 100644 --- a/packages/cli/src/commands/codegen.ts +++ b/packages/cli/src/commands/codegen.ts @@ -1,64 +1,74 @@ -import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { buildSchemaSDL } from '@constructive-io/graphql-server'; -import { - generateReactQuery, - generateOrm, - findConfigFile, - type GenerateResult, - type GenerateTargetResult, - type GenerateOrmResult, - type GenerateOrmTargetResult, -} from '@constructive-io/graphql-codegen'; -import { getEnvOptions } from '@constructive-io/graphql-env'; +import { CLIOptions, Inquirerer, Question } from 'inquirerer'; +import { generate, findConfigFile } from '@constructive-io/graphql-codegen'; const usage = ` Constructive GraphQL Codegen: cnc codegen [OPTIONS] -Options: - --help, -h Show this help message +Source Options (choose one): --config Path to graphql-codegen config file - --target Target name in config file --endpoint GraphQL endpoint URL - --auth Authorization header value (e.g., "Bearer 123") - --out Output directory (default: codegen) - --dry-run Preview without writing files - -v, --verbose Verbose output + --schemaFile Path to GraphQL schema file - --orm Generate Prisma-like ORM client instead of React Query hooks - - --database Database override for DB mode (defaults to PGDATABASE) - --schemas Comma-separated schemas (required for DB mode) -`; +Database Options: + --schemas Comma-separated PostgreSQL schemas + --apiNames Comma-separated API names -interface CodegenOptions { - endpoint?: string; - config?: string; - target?: string; - output?: string; - auth?: string; - database?: string; - schemas?: string[]; - dryRun: boolean; - verbose: boolean; - orm: boolean; -} +Generator Options: + --reactQuery Generate React Query hooks (default) + --orm Generate ORM client + --target Target name in config file + --out Output directory (default: codegen) + --auth Authorization header value + --dryRun Preview without writing files + --verbose Verbose output -type SourceMode = - | { type: 'endpoint'; endpoint: string } - | { type: 'database'; database: string; schemas: string[] } - | { type: 'config'; configPath: string } - | { type: 'none' }; + --help, -h Show this help message +`; -type AnyResult = GenerateResult | GenerateOrmResult; -type AnyTargetResult = GenerateTargetResult | GenerateOrmTargetResult; +const questions: Question[] = [ + { + name: 'config', + message: 'Path to config file', + type: 'text', + required: false, + }, + { + name: 'endpoint', + message: 'GraphQL endpoint URL', + type: 'text', + required: false, + }, + { + name: 'out', + message: 'Output directory', + type: 'text', + required: false, + default: 'codegen', + useDefault: true, + }, + { + name: 'reactQuery', + message: 'Generate React Query hooks?', + type: 'confirm', + required: false, + default: true, + useDefault: true, + }, + { + name: 'orm', + message: 'Generate ORM client?', + type: 'confirm', + required: false, + default: false, + useDefault: true, + }, +]; export default async ( - argv: Partial, - _prompter: Inquirerer, + argv: Partial>, + prompter: Inquirerer, _options: CLIOptions ) => { if (argv.help || argv.h) { @@ -66,126 +76,49 @@ export default async ( process.exit(0); } - const opts = parseArgs(argv); - const mode = determineMode(opts); - - if (mode.type === 'none') { - console.error( - 'Error: No source specified. Use --endpoint, --config, or --schemas for database mode.' - ); - process.exit(1); - } - - // Build schema from database if needed - const outDir = opts.output || 'codegen'; - const schemaPath = - mode.type === 'database' - ? await buildSchemaFromDatabase(mode.database, mode.schemas, outDir) - : undefined; - - const commandOptions = { - config: opts.config, - target: opts.target, - endpoint: mode.type === 'endpoint' ? mode.endpoint : undefined, - schema: schemaPath, - output: opts.config ? opts.output : outDir, - authorization: opts.auth, - verbose: opts.verbose, - dryRun: opts.dryRun, + const normalizedArgv = { + ...argv, + config: argv.config || findConfigFile() || undefined, + endpoint: argv.endpoint, + out: argv.out, + reactQuery: argv.reactQuery, + orm: argv.orm, }; - const result = opts.orm - ? await generateOrm(commandOptions) - : await generateReactQuery(commandOptions); - - printResult(result); + const { + config, + endpoint, + out, + reactQuery, + orm, + } = await prompter.prompt(normalizedArgv, questions); + + const schemasArg = argv.schemas as string | undefined; + const apiNamesArg = argv.apiNames as string | undefined; + const db = (schemasArg || apiNamesArg) ? { + schemas: schemasArg ? schemasArg.split(',').map((s) => s.trim()) : undefined, + apiNames: apiNamesArg ? apiNamesArg.split(',').map((s) => s.trim()) : undefined, + } : undefined; + + const result = await generate({ + config: config as string | undefined, + target: argv.target as string | undefined, + endpoint: endpoint as string | undefined, + schemaFile: argv.schemaFile as string | undefined, + db, + output: out as string | undefined, + authorization: argv.auth as string | undefined, + reactQuery: reactQuery as boolean, + orm: orm as boolean, + dryRun: !!argv.dryRun, + verbose: !!argv.verbose, + }); - if (!result.success) { + if (result.success) { + console.log('[ok]', result.message); + } else { + console.error('x', result.message); + result.errors?.forEach((e) => console.error(' -', e)); process.exit(1); } }; - -function parseArgs(argv: Partial): CodegenOptions { - const schemasArg = (argv.schemas as string) || ''; - return { - endpoint: (argv.endpoint as string) || undefined, - config: (argv.config as string) || findConfigFile() || undefined, - target: (argv.target as string) || undefined, - output: (argv.out as string) || undefined, - auth: (argv.auth as string) || undefined, - database: (argv.database as string) || undefined, - schemas: schemasArg - ? schemasArg.split(',').map((s) => s.trim()).filter(Boolean) - : undefined, - dryRun: !!(argv['dry-run'] || argv.dryRun), - verbose: !!(argv.verbose || argv.v), - orm: !!argv.orm, - }; -} - -function determineMode(opts: CodegenOptions): SourceMode { - if (opts.endpoint) { - return { type: 'endpoint', endpoint: opts.endpoint }; - } - if (opts.schemas?.length) { - const database = opts.database || getEnvOptions().pg.database; - return { type: 'database', database, schemas: opts.schemas }; - } - if (opts.config) { - return { type: 'config', configPath: opts.config }; - } - return { type: 'none' }; -} - -function printTargetResult(target: AnyTargetResult): void { - const status = target.success ? '[ok]' : 'x'; - console.log(`\n${status} ${target.message}`); - - if (target.tables?.length) { - console.log(' Tables:'); - target.tables.forEach((t) => console.log(` - ${t}`)); - } - if (target.filesWritten?.length) { - console.log(' Files written:'); - target.filesWritten.forEach((f) => console.log(` - ${f}`)); - } - if (!target.success && target.errors?.length) { - target.errors.forEach((e) => console.error(` - ${e}`)); - } -} - -function printResult(result: AnyResult): void { - const targets = result.targets ?? []; - const isMultiTarget = - targets.length > 1 || (targets.length === 1 && targets[0]?.name !== 'default'); - - if (isMultiTarget) { - console.log(result.message); - targets.forEach(printTargetResult); - return; - } - - if (!result.success) { - console.error(result.message); - result.errors?.forEach((e) => console.error(' -', e)); - } else { - console.log(result.message); - result.filesWritten?.forEach((f) => console.log(f)); - } -} - -async function buildSchemaFromDatabase( - database: string, - schemas: string[], - outDir: string -): Promise { - await fs.promises.mkdir(outDir, { recursive: true }); - const sdl = await buildSchemaSDL({ - database, - schemas, - graphile: { pgSettings: async () => ({ role: 'administrator' }) }, - }); - const schemaPath = path.join(outDir, 'schema.graphql'); - await fs.promises.writeFile(schemaPath, sdl, 'utf-8'); - return schemaPath; -} diff --git a/packages/cli/src/commands/get-graphql-schema.ts b/packages/cli/src/commands/get-graphql-schema.ts index 8916d7052..6645095a1 100644 --- a/packages/cli/src/commands/get-graphql-schema.ts +++ b/packages/cli/src/commands/get-graphql-schema.ts @@ -1,78 +1,112 @@ -import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer' -import { promises as fs } from 'fs' -import { buildSchemaSDL, fetchEndpointSchemaSDL } from '@constructive-io/graphql-server' +import { CLIOptions, Inquirerer, Question } from 'inquirerer'; +import { promises as fs } from 'fs'; +import { buildSchemaSDL, fetchEndpointSchemaSDL } from '@constructive-io/graphql-server'; const usage = ` Constructive Get GraphQL Schema: cnc get-graphql-schema [OPTIONS] -Options: - --help, -h Show this help message +Source Options (choose one): + --endpoint GraphQL endpoint to fetch schema via introspection --database Database name (default: constructive) + +Options: --schemas Comma-separated schemas to include - --endpoint GraphQL endpoint to fetch schema via introspection - --headerHost Optional Host header to send with endpoint requests - --auth Optional Authorization header value (e.g., "Bearer 123") - --header "Name: Value" Optional HTTP header; repeat to add multiple headers + --headerHost Host header to send with endpoint requests + --auth Authorization header value --out Output file path (default: print to stdout) -` + + --help, -h Show this help message +`; const defaultSchemas = [ 'metaschema_public', 'metaschema_modules_public', - 'services_public' -] + 'services_public', +]; + +const questions: Question[] = [ + { + name: 'endpoint', + message: 'GraphQL endpoint URL', + type: 'text', + required: false, + }, + { + name: 'database', + message: 'Database name', + type: 'text', + required: false, + default: 'constructive', + useDefault: true, + }, + { + name: 'schemas', + message: 'Comma-separated schemas', + type: 'text', + required: false, + default: defaultSchemas.join(','), + useDefault: true, + }, + { + name: 'out', + message: 'Output file path', + type: 'text', + required: false, + }, +]; export default async ( - argv: Partial, + argv: Partial>, prompter: Inquirerer, _options: CLIOptions ) => { if (argv.help || argv.h) { - console.log(usage) - process.exit(0) + console.log(usage); + process.exit(0); } - const endpoint = (argv.endpoint as string) ?? '' - const headerHost = (argv.headerHost as string) ?? '' - const auth = (argv.auth as string) ?? '' - const database = (argv.database as string) ?? 'constructive' - const schemasArg = (argv.schemas as string) ?? defaultSchemas.join(',') - const out = (argv.out as string) ?? '' + const { + endpoint, + database, + schemas: schemasArg, + out, + } = await prompter.prompt(argv, questions); - const headerArg = argv.header as string | string[] | undefined - const headerList = Array.isArray(headerArg) ? headerArg : headerArg ? [headerArg] : [] - const headers: Record = {} + const schemas = String(schemasArg).split(',').map((s) => s.trim()).filter(Boolean); + + // Parse repeated --header values into headers object + const headerArg = argv.header as string | string[] | undefined; + const headerList = Array.isArray(headerArg) ? headerArg : headerArg ? [headerArg] : []; + const headers: Record = {}; for (const h of headerList) { - const idx = typeof h === 'string' ? h.indexOf(':') : -1 - if (idx <= 0) continue - const name = h.slice(0, idx).trim() - const value = h.slice(idx + 1).trim() - if (!name) continue - headers[name] = value + const idx = typeof h === 'string' ? h.indexOf(':') : -1; + if (idx <= 0) continue; + const name = h.slice(0, idx).trim(); + const value = h.slice(idx + 1).trim(); + if (!name) continue; + headers[name] = value; } - const schemas = schemasArg.split(',').map(s => s.trim()).filter(Boolean) - - let sdl: string + let sdl: string; if (endpoint) { - const opts: any = {} - if (headerHost) opts.headerHost = headerHost - if (auth) opts.auth = auth - if (Object.keys(headers).length) opts.headers = headers - sdl = await (fetchEndpointSchemaSDL as any)(endpoint, opts) + const opts: Record = {}; + if (argv.headerHost) opts.headerHost = argv.headerHost; + if (argv.auth) opts.auth = argv.auth; + if (Object.keys(headers).length) opts.headers = headers; + sdl = await (fetchEndpointSchemaSDL as (url: string, opts?: Record) => Promise)( + endpoint as string, + opts + ); } else { - // The server package already depends on postgraphile and graphql, - // and exporting a reusable programmatic builder from there - // avoids adding new dependencies to cli and prevents duplication. - sdl = await buildSchemaSDL({ database, schemas }) + sdl = await buildSchemaSDL({ database: database as string, schemas }); } if (out) { - await fs.writeFile(out, sdl, 'utf8') - console.log(`Wrote schema SDL to ${out}`) + await fs.writeFile(out as string, sdl, 'utf8'); + console.log(`Wrote schema SDL to ${out}`); } else { - process.stdout.write(sdl + '\n') + process.stdout.write(sdl + '\n'); } -} +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f8d8481d..0d6f8f215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -727,12 +727,18 @@ importers: '@babel/types': specifier: ^7.28.6 version: 7.28.6 + '@constructive-io/graphql-server': + specifier: workspace:^ + version: link:../server/dist '@constructive-io/graphql-types': specifier: workspace:^ version: link:../types/dist '@inquirerer/utils': specifier: ^3.2.0 version: 3.2.0 + '@pgpmjs/core': + specifier: workspace:^ + version: link:../../pgpm/core/dist ajv: specifier: ^8.17.1 version: 8.17.1 @@ -757,9 +763,24 @@ importers: jiti: specifier: ^2.6.1 version: 2.6.1 + pg-cache: + specifier: workspace:^ + version: link:../../postgres/pg-cache/dist + pg-env: + specifier: workspace:^ + version: link:../../postgres/pg-env/dist + pgsql-client: + specifier: workspace:^ + version: link:../../postgres/pgsql-client/dist + pgsql-seed: + specifier: workspace:^ + version: link:../../postgres/pgsql-seed/dist prettier: specifier: ^3.7.4 version: 3.8.0 + undici: + specifier: ^7.19.0 + version: 7.19.0 devDependencies: '@tanstack/react-query': specifier: ^5.90.19 @@ -1096,9 +1117,6 @@ importers: '@aws-sdk/client-s3': specifier: ^3.971.0 version: 3.971.0 - '@constructive-io/graphql-codegen': - specifier: workspace:* - version: link:../codegen/dist '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -8237,6 +8255,10 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} + undici@7.19.0: + resolution: {integrity: sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==} + engines: {node: '>=20.18.1'} + unique-filename@3.0.0: resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -16089,6 +16111,8 @@ snapshots: undici@7.16.0: {} + undici@7.19.0: {} + unique-filename@3.0.0: dependencies: unique-slug: 4.0.0 diff --git a/postgres/pgsql-client/src/ephemeral.ts b/postgres/pgsql-client/src/ephemeral.ts new file mode 100644 index 000000000..59a4df1a1 --- /dev/null +++ b/postgres/pgsql-client/src/ephemeral.ts @@ -0,0 +1,165 @@ +/** + * Ephemeral Database Utilities + * + * Provides utilities for creating and managing temporary PostgreSQL databases + * for testing, code generation, and other ephemeral use cases. + */ +import { randomUUID } from 'crypto'; +import { getPgEnvOptions, PgConfig } from 'pg-env'; + +import { DbAdmin } from './admin'; + +/** + * Options for creating an ephemeral database + */ +export interface EphemeralDbOptions { + /** + * Database name prefix (default: 'ephemeral_') + */ + prefix?: string; + + /** + * PostgreSQL extensions to install after creation + */ + extensions?: string[]; + + /** + * Base PostgreSQL configuration (host, port, user, password) + * If not provided, uses environment variables via pg-env + */ + baseConfig?: Partial; + + /** + * Enable verbose logging + */ + verbose?: boolean; +} + +/** + * Options for tearing down an ephemeral database + */ +export interface TeardownOptions { + /** + * If true, keeps the database instead of dropping it (useful for debugging) + */ + keepDb?: boolean; +} + +/** + * Result of creating an ephemeral database + */ +export interface EphemeralDbResult { + /** + * The name of the created database + */ + name: string; + + /** + * Full PostgreSQL configuration for connecting to the ephemeral database + */ + config: PgConfig; + + /** + * Database admin instance for additional operations + */ + admin: DbAdmin; + + /** + * Teardown function to clean up the ephemeral database + * Call this when done to drop the database (unless keepDb is true) + */ + teardown: (opts?: TeardownOptions) => void; +} + +/** + * Create an ephemeral (temporary) PostgreSQL database + * + * Creates a new database with a unique UUID-based name. The database + * can be used for testing, code generation, or other temporary purposes. + * + * @example + * ```typescript + * const { config, teardown } = createEphemeralDb(); + * + * // Use the database... + * const pool = new Pool(config); + * await pool.query('SELECT 1'); + * await pool.end(); + * + * // Clean up + * teardown(); + * + * // Or keep for debugging + * teardown({ keepDb: true }); + * ``` + */ +export function createEphemeralDb(options: EphemeralDbOptions = {}): EphemeralDbResult { + const { + prefix = 'ephemeral_', + extensions = [], + baseConfig = {}, + verbose = false, + } = options; + + // Generate unique database name + const dbName = `${prefix}${randomUUID().replace(/-/g, '_')}`; + + // Get base config from environment, merged with any provided config + const config: PgConfig = getPgEnvOptions({ + ...baseConfig, + database: dbName, + }); + + // Create admin instance for database operations + const admin = new DbAdmin(config, verbose); + + // Create the database + admin.create(dbName); + + // Install extensions if specified + if (extensions.length > 0) { + admin.installExtensions(extensions, dbName); + } + + // Create teardown function + const teardown = (opts: TeardownOptions = {}) => { + const { keepDb = false } = opts; + + if (keepDb) { + if (verbose) { + console.log(`[ephemeral-db] Keeping database: ${dbName}`); + } + return; + } + + try { + admin.drop(dbName); + if (verbose) { + console.log(`[ephemeral-db] Dropped database: ${dbName}`); + } + } catch (err) { + if (verbose) { + console.error(`[ephemeral-db] Failed to drop database ${dbName}:`, err); + } + } + }; + + return { + name: dbName, + config, + admin, + teardown, + }; +} + +/** + * Create an ephemeral database asynchronously + * + * Same as createEphemeralDb but returns a Promise for consistency + * with async workflows. + */ +export async function createEphemeralDbAsync( + options: EphemeralDbOptions = {} +): Promise { + return createEphemeralDb(options); +} diff --git a/postgres/pgsql-client/src/index.ts b/postgres/pgsql-client/src/index.ts index 7629d5920..5d9fa3bea 100644 --- a/postgres/pgsql-client/src/index.ts +++ b/postgres/pgsql-client/src/index.ts @@ -1,5 +1,6 @@ export * from './admin'; export * from './client'; export * from './context-utils'; +export * from './ephemeral'; export * from './roles'; export { streamSql } from './stream';