diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..907fe7cd1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development Commands + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm build + +# Build all packages (dev mode - faster, no optimizations) +pnpm build:dev + +# Lint all packages (auto-fix enabled) +pnpm lint + +# Clean build artifacts +pnpm clean + +# Update dependencies interactively +pnpm deps +``` + +### Per-Package Commands + +Navigate to any package directory (e.g., `cd pgpm/cli`) and run: + +```bash +pnpm build # Build the package +pnpm lint # Lint with auto-fix +pnpm test # Run tests +pnpm test:watch # Run tests in watch mode +pnpm dev # Run in development mode (where available) +``` + +### Running a Single Test File + +```bash +cd packages/cli +pnpm test -- path/to/test.test.ts +# or with pattern matching: +pnpm test -- --testNamePattern="test name pattern" +``` + +## Project Architecture + +This is a **pnpm monorepo** using Lerna for versioning/publishing. The workspace is organized into domain-specific directories: + +### Core Package Groups + +| Directory | Purpose | +|-----------|---------| +| `pgpm/` | PostgreSQL Package Manager - CLI, core engine, types | +| `graphql/` | GraphQL layer - server, codegen, React hooks, testing | +| `graphile/` | PostGraphile plugins - filters, i18n, meta-schema, PostGIS | +| `postgres/` | PostgreSQL utilities - introspection, testing, seeding, AST | +| `packages/` | Shared utilities - CLI, ORM, query builder | +| `uploads/` | File streaming - S3, ETags, content-type detection | +| `jobs/` | Job scheduling and worker infrastructure | + +### Key Packages + +**pgpm (PostgreSQL Package Manager)** +- `pgpm/cli` - Main CLI tool (`pgpm` command) +- `pgpm/core` - Migration engine, dependency resolution, deployment + +**GraphQL Stack** +- `graphql/server` - Express + PostGraphile API server +- `graphql/codegen` - SDK generator (React Query hooks or Prisma-like ORM) +- `graphql/query` - Fluent GraphQL query builder + +**Testing Infrastructure** +- `postgres/pgsql-test` - Isolated PostgreSQL test environments with transaction rollback +- `graphile/graphile-test` - GraphQL testing utilities + +### Testing Pattern + +Tests use `pgsql-test` for database testing with per-test transaction rollback: + +```typescript +import { getConnections } from 'pgsql-test'; + +let db, teardown; + +beforeAll(async () => { + ({ db, teardown } = await getConnections()); +}); + +beforeEach(() => db.beforeEach()); +afterEach(() => db.afterEach()); +afterAll(() => teardown()); + +test('example', async () => { + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': '123' }); + const result = await db.query('SELECT current_user_id()'); + expect(result.rows[0].current_user_id).toBe('123'); +}); +``` + +### Database Configuration + +Tests require PostgreSQL. Standard PG environment variables: +- `PGHOST` (default: localhost) +- `PGPORT` (default: 5432) +- `PGUSER` (default: postgres) +- `PGPASSWORD` (default: password) + +For S3/MinIO tests: `MINIO_ENDPOINT`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION` + +### Build System + +- Uses `makage` for TypeScript compilation (handles both CJS and ESM output) +- Jest with ts-jest for testing +- ESLint with TypeScript support +- Each package has its own `tsconfig.json` extending root config + +### Code Conventions + +- TypeScript with `strict: true` (but `strictNullChecks: false`) +- Target: ES2022, Module: CommonJS +- Packages publish to npm from `dist/` directory +- Workspace dependencies use `workspace:^` protocol diff --git a/graphql/codegen/README.md b/graphql/codegen/README.md index 95f5d54d1..d88cf7ad1 100644 --- a/graphql/codegen/README.md +++ b/graphql/codegen/README.md @@ -837,12 +837,12 @@ 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 }] }); }, @@ -852,6 +852,151 @@ function CreateCarWithInvalidation() { } ``` +### 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 diff --git a/graphql/codegen/docs/QUERY-KEY-FACTORY.md b/graphql/codegen/docs/QUERY-KEY-FACTORY.md new file mode 100644 index 000000000..390927bdf --- /dev/null +++ b/graphql/codegen/docs/QUERY-KEY-FACTORY.md @@ -0,0 +1,420 @@ +# Query Key Factory Design Document + +This document describes the centralized query key factory feature for `@constructive-io/graphql-codegen`, following the [lukemorales query-key-factory](https://tanstack.com/query/docs/framework/react/community/lukemorales-query-key-factory) pattern. + +## Overview + +The query key factory provides: + +- **Centralized query keys** - Single source of truth for all React Query cache keys +- **Type-safe key access** - Full TypeScript autocomplete support +- **Hierarchical invalidation** - Invalidate all queries for an entity with one call +- **Mutation key tracking** - Track in-flight mutations for optimistic updates +- **Cache invalidation helpers** - Type-safe utilities for cache management + +## Generated Files + +When `queryKeys.generateScopedKeys` is enabled (default), the following files are generated: + +| File | Purpose | +|------|---------| +| `query-keys.ts` | Centralized query key factories for all entities | +| `mutation-keys.ts` | Mutation key factories for tracking mutations | +| `invalidation.ts` | Type-safe cache invalidation helpers | + +## Architecture + +### Query Key Structure + +Query keys follow a hierarchical pattern: + +```typescript +// Entity key factory +export const userKeys = { + all: ['user'] as const, + lists: () => [...userKeys.all, 'list'] as const, + list: (variables?: object) => [...userKeys.lists(), variables] as const, + details: () => [...userKeys.all, 'detail'] as const, + detail: (id: string | number) => [...userKeys.details(), id] as const, +} as const; +``` + +This enables granular cache invalidation: + +```typescript +// Invalidate ALL user queries +queryClient.invalidateQueries({ queryKey: userKeys.all }); + +// Invalidate only user list queries +queryClient.invalidateQueries({ queryKey: userKeys.lists() }); + +// Invalidate a specific user +queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) }); +``` + +### Mutation Key Structure + +Mutation keys track in-flight mutations: + +```typescript +export const userMutationKeys = { + all: ['mutation', 'user'] as const, + create: () => ['mutation', 'user', 'create'] as const, + update: (id: string | number) => ['mutation', 'user', 'update', id] as const, + delete: (id: string | number) => ['mutation', 'user', 'delete', id] as const, +} as const; +``` + +Usage with `useIsMutating`: + +```typescript +import { useIsMutating } from '@tanstack/react-query'; + +// Check if any user mutations are in progress +const isMutating = useIsMutating({ mutationKey: userMutationKeys.all }); + +// Check if a specific user is being updated +const isUpdating = useIsMutating({ mutationKey: userMutationKeys.update(userId) }); +``` + +### Invalidation Helpers + +Type-safe cache invalidation utilities: + +```typescript +export const invalidate = { + user: { + all: (queryClient: QueryClient) => + queryClient.invalidateQueries({ queryKey: userKeys.all }), + lists: (queryClient: QueryClient) => + queryClient.invalidateQueries({ queryKey: userKeys.lists() }), + detail: (queryClient: QueryClient, id: string | number) => + queryClient.invalidateQueries({ queryKey: userKeys.detail(id) }), + }, +}; + +// Usage +invalidate.user.all(queryClient); +invalidate.user.detail(queryClient, userId); +``` + +## Configuration + +### Config Options + +```typescript +// graphql-codegen.config.ts +export default defineConfig({ + queryKeys: { + // Key structure style (default: 'hierarchical') + style: 'hierarchical', + + // Entity relationships for cascade invalidation + relationships: { + table: { parent: 'database', foreignKey: 'databaseId' }, + field: { parent: 'table', foreignKey: 'tableId' }, + }, + + // Generate scope-aware keys (default: true) + generateScopedKeys: true, + + // Generate cascade invalidation helpers (default: true) + generateCascadeHelpers: true, + + // Generate mutation keys (default: true) + generateMutationKeys: true, + }, +}); +``` + +### Relationship Configuration + +For parent-child entity relationships, configure the `relationships` option to enable scoped queries and cascade invalidation: + +```typescript +relationships: { + // Child entity -> parent relationship + database: { parent: 'organization', foreignKey: 'organizationId' }, + table: { parent: 'database', foreignKey: 'databaseId' }, + field: { + parent: 'table', + foreignKey: 'tableId', + ancestors: ['database', 'organization'], // For deep cascade + }, +} +``` + +This generates scoped key factories: + +```typescript +export const tableKeys = { + all: ['table'] as const, + + // Scoped by parent + byDatabase: (databaseId: string) => ['table', { databaseId }] as const, + + // Scope-aware helpers + lists: (scope?: TableScope) => [...tableKeys.scoped(scope), 'list'] as const, + detail: (id: string | number, scope?: TableScope) => + [...tableKeys.details(scope), id] as const, +} as const; +``` + +## Hook Integration + +Generated hooks automatically use centralized keys: + +```typescript +// Generated useUsersQuery hook +export function useUsersQuery(variables?: UsersQueryVariables, options?: ...) { + return useQuery({ + queryKey: userKeys.list(variables), // Uses centralized key + queryFn: () => execute(...), + ...options, + }); +} + +// Generated useCreateUserMutation hook +export function useCreateUserMutation(options?: ...) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: userMutationKeys.all, // Uses centralized key + mutationFn: (variables) => execute(...), + onSuccess: () => { + // Auto-invalidates list queries + queryClient.invalidateQueries({ queryKey: userKeys.lists() }); + }, + ...options, + }); +} +``` + +## Type Design Decisions + +### Variables Type: `object` vs `Record` + +Query key functions use `object` for variables: + +```typescript +list: (variables?: object) => [...userKeys.lists(), variables] as const, +``` + +**Why not `Record`?** + +TypeScript interfaces don't satisfy `Record` because they lack an index signature: + +```typescript +interface UserFilter { name?: string; } + +// Error: Index signature missing +const fn = (vars: Record) => {}; +fn({ name: 'test' } as UserFilter); // Type error! + +// Works with 'object' +const fn2 = (vars: object) => {}; +fn2({ name: 'test' } as UserFilter); // OK +``` + +### ID Type: `string | number` + +Detail key functions accept both string and number IDs: + +```typescript +detail: (id: string | number) => [...userKeys.details(), id] as const, +``` + +This supports both UUID primary keys (`string`) and serial/integer primary keys (`number`). + +### Mutation Keys: Static vs Dynamic + +Mutation hooks use static keys (`mutationKeys.all`) rather than per-mutation keys: + +```typescript +// Generated hook uses static key +return useMutation({ + mutationKey: userMutationKeys.all, // Not: userMutationKeys.create() + mutationFn: (variables) => execute(...), +}); +``` + +**Why?** + +React Query's `mutationKey` is evaluated when `useMutation` is called, not per-mutation. The `variables` parameter is only available inside `mutationFn`, so dynamic keys like `mutationKeys.delete(variables.id)` would fail. + +Users who need per-mutation tracking can: +1. Use `onMutate` callbacks to track specific mutations +2. Override `mutationKey` in options when calling the hook + +## Scalar Type Handling + +### Why Hardcoded Mappings? + +GraphQL introspection only provides scalar **names**, not TypeScript type mappings: + +```json +{ + "kind": "SCALAR", + "name": "UUID", + "description": "A universally unique identifier as defined by RFC 4122." +} +``` + +There's no field indicating `UUID` → `string` or `JSON` → `unknown`. The GraphQL spec leaves scalar implementation to the server. + +### Current Approach + +Scalars are mapped in `src/cli/codegen/scalars.ts`: + +```typescript +export const SCALAR_TS_MAP: Record = { + // Standard GraphQL + String: 'string', + Int: 'number', + Float: 'number', + Boolean: 'boolean', + ID: 'string', + + // PostGraphile + UUID: 'string', + Datetime: 'string', + JSON: 'unknown', + BigInt: 'string', + + // Geometry (PostGIS) + GeoJSON: 'unknown', + GeometryPoint: 'unknown', + + // ... more scalars +}; +``` + +Unknown scalars default to `unknown` (type-safe fallback). + +### Adding Custom Scalars + +To add a new scalar, update `SCALAR_TS_MAP` in `scalars.ts`: + +```typescript +// Add custom scalar mapping +MyCustomScalar: 'string', +GeometryPolygon: '{ type: string; coordinates: number[][] }', +``` + +## File Structure + +``` +generated/ +├── index.ts # Main barrel export +├── client.ts # GraphQL client with configure() and execute() +├── types.ts # Entity interfaces and filter types +├── schema-types.ts # Input/payload/enum types from schema +├── query-keys.ts # Centralized query key factories +├── mutation-keys.ts # Mutation key factories +├── invalidation.ts # Cache invalidation helpers +├── queries/ +│ ├── index.ts # Query hooks barrel +│ ├── useUsersQuery.ts # List query hook +│ ├── useUserQuery.ts # Single item query hook +│ └── ... +└── mutations/ + ├── index.ts # Mutation hooks barrel + ├── useCreateUserMutation.ts + ├── useUpdateUserMutation.ts + ├── useDeleteUserMutation.ts + └── ... +``` + +## Usage Examples + +### Basic Query with Cache Key + +```typescript +import { useUsersQuery, userKeys } from './generated'; + +function UserList() { + const { data } = useUsersQuery({ first: 10 }); + + // Manual cache access using same key + const cachedData = queryClient.getQueryData(userKeys.list({ first: 10 })); +} +``` + +### Prefetching + +```typescript +import { prefetchUsersQuery, userKeys } from './generated'; + +// In a route loader or server component +await prefetchUsersQuery(queryClient, { first: 10 }); +``` + +### Optimistic Updates + +```typescript +import { useCreateUserMutation, userKeys } from './generated'; + +const mutation = useCreateUserMutation({ + onMutate: async (newUser) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: userKeys.lists() }); + + // Snapshot previous value + const previous = queryClient.getQueryData(userKeys.list()); + + // Optimistically update + queryClient.setQueryData(userKeys.list(), (old) => ({ + ...old, + users: { ...old.users, nodes: [...old.users.nodes, newUser] }, + })); + + return { previous }; + }, + onError: (err, variables, context) => { + // Rollback on error + queryClient.setQueryData(userKeys.list(), context.previous); + }, +}); +``` + +### Cascade Invalidation (with relationships configured) + +```typescript +import { invalidate, tableKeys } from './generated'; + +// When a database is updated, invalidate all its tables +function onDatabaseUpdate(databaseId: string) { + // Invalidate tables scoped to this database + queryClient.invalidateQueries({ + queryKey: tableKeys.byDatabase(databaseId) + }); +} +``` + +## Implementation Files + +| Source File | Purpose | +|-------------|---------| +| `src/cli/codegen/query-keys.ts` | Query key factory generator | +| `src/cli/codegen/mutation-keys.ts` | Mutation key factory generator | +| `src/cli/codegen/invalidation.ts` | Invalidation helpers generator | +| `src/cli/codegen/queries.ts` | Query hook generator (uses centralized keys) | +| `src/cli/codegen/mutations.ts` | Mutation hook generator (uses centralized keys) | +| `src/cli/codegen/scalars.ts` | Scalar type mappings | +| `src/types/config.ts` | Configuration types (`QueryKeyConfig`) | + +## Testing + +Run the test suite: + +```bash +pnpm test +``` + +Generate example SDK and verify TypeScript: + +```bash +pnpm example:codegen:sdk +cd examples/output/generated-sdk +npx tsc --noEmit +``` diff --git a/graphql/codegen/examples/react-query-sdk.ts b/graphql/codegen/examples/react-query-sdk.ts index 48c66780d..e13a7ee07 100644 --- a/graphql/codegen/examples/react-query-sdk.ts +++ b/graphql/codegen/examples/react-query-sdk.ts @@ -183,7 +183,7 @@ async function main() { TablesQueryResult, TablesQueryVariables >(tablesQueryDocument, { - first: 5, + first: 10, filter: tableFilter, orderBy: ['NAME_ASC'], }); diff --git a/graphql/codegen/src/cli/codegen/barrel.ts b/graphql/codegen/src/cli/codegen/barrel.ts index bc85b4625..87f28805b 100644 --- a/graphql/codegen/src/cli/codegen/barrel.ts +++ b/graphql/codegen/src/cli/codegen/barrel.ts @@ -71,6 +71,12 @@ export function generateMutationsBarrel(tables: CleanTable[]): string { export interface MainBarrelOptions { hasSchemaTypes?: boolean; hasMutations?: boolean; + /** Whether query-keys.ts was generated */ + hasQueryKeys?: boolean; + /** Whether mutation-keys.ts was generated */ + hasMutationKeys?: boolean; + /** Whether invalidation.ts was generated */ + hasInvalidation?: boolean; } export function generateMainBarrel( @@ -83,7 +89,13 @@ export function generateMainBarrel( ? { hasSchemaTypes: options, hasMutations: true } : options; - const { hasSchemaTypes = false, hasMutations = true } = opts; + const { + hasSchemaTypes = false, + hasMutations = true, + hasQueryKeys = false, + hasMutationKeys = false, + hasInvalidation = false, + } = opts; const tableNames = tables.map((t) => t.name).join(', '); const schemaTypesExport = hasSchemaTypes @@ -97,6 +109,27 @@ export * from './schema-types'; ? ` // Mutation hooks export * from './mutations'; +` + : ''; + + const queryKeysExport = hasQueryKeys + ? ` +// Centralized query keys (for cache management) +export * from './query-keys'; +` + : ''; + + const mutationKeysExport = hasMutationKeys + ? ` +// Centralized mutation keys (for tracking in-flight mutations) +export * from './mutation-keys'; +` + : ''; + + const invalidationExport = hasInvalidation + ? ` +// Cache invalidation helpers +export * from './invalidation'; ` : ''; @@ -135,7 +168,7 @@ export * from './client'; // Entity and filter types export * from './types'; -${schemaTypesExport} +${schemaTypesExport}${queryKeysExport}${mutationKeysExport}${invalidationExport} // Query hooks export * from './queries'; ${mutationsExport}`; diff --git a/graphql/codegen/src/cli/codegen/client.ts b/graphql/codegen/src/cli/codegen/client.ts index dd572f883..5db32717d 100644 --- a/graphql/codegen/src/cli/codegen/client.ts +++ b/graphql/codegen/src/cli/codegen/client.ts @@ -197,5 +197,66 @@ export async function executeWithErrors number); + }; + mutations?: { + retry?: number | boolean; + retryDelay?: number | ((attemptIndex: number) => number); + }; + }; +} + +// Note: createQueryClient is available when using with @tanstack/react-query +// Import QueryClient from '@tanstack/react-query' and use these options: +// +// import { QueryClient } from '@tanstack/react-query'; +// const queryClient = new QueryClient(defaultQueryClientOptions); +// +// Or merge with your own options: +// const queryClient = new QueryClient({ +// ...defaultQueryClientOptions, +// defaultOptions: { +// ...defaultQueryClientOptions.defaultOptions, +// queries: { +// ...defaultQueryClientOptions.defaultOptions.queries, +// staleTime: 30000, // Override specific options +// }, +// }, +// }); `; } diff --git a/graphql/codegen/src/cli/codegen/custom-mutations.ts b/graphql/codegen/src/cli/codegen/custom-mutations.ts index 46c73b933..fdee4dce9 100644 --- a/graphql/codegen/src/cli/codegen/custom-mutations.ts +++ b/graphql/codegen/src/cli/codegen/custom-mutations.ts @@ -57,6 +57,8 @@ export interface GenerateCustomMutationHookOptions { reactQueryEnabled?: boolean; /** Table entity type names (for import path resolution) */ tableTypeNames?: Set; + /** Whether to use centralized mutation keys from mutation-keys.ts (default: true) */ + useCentralizedKeys?: boolean; } /** @@ -85,7 +87,7 @@ export function generateCustomMutationHook( function generateCustomMutationHookInternal( options: GenerateCustomMutationHookOptions ): GeneratedCustomMutationFile { - const { operation, typeRegistry, maxDepth = 2, skipQueryField = true, tableTypeNames } = options; + const { operation, typeRegistry, maxDepth = 2, skipQueryField = true, tableTypeNames, useCentralizedKeys = true } = options; const project = createProject(); const hookName = getOperationHookName(operation.name, 'mutation'); @@ -93,6 +95,8 @@ function generateCustomMutationHookInternal( const variablesTypeName = getOperationVariablesTypeName(operation.name, 'mutation'); const resultTypeName = getOperationResultTypeName(operation.name, 'mutation'); const documentConstName = getDocumentConstName(operation.name, 'mutation'); + // For centralized keys, use customMutationKeys.operationName + const centralizedKeyRef = `customMutationKeys.${operation.name}`; // Create type tracker to collect referenced types (with table type awareness) const tracker = createTypeTracker({ tableTypeNames }); @@ -162,6 +166,16 @@ function generateCustomMutationHookInternal( ); } + // Import centralized mutation keys + if (useCentralizedKeys) { + imports.push( + createImport({ + moduleSpecifier: '../mutation-keys', + namedImports: ['customMutationKeys'], + }) + ); + } + sourceFile.addImportDeclarations(imports); // Add mutation document constant @@ -181,7 +195,7 @@ function generateCustomMutationHookInternal( // Generate hook function const hookParams = generateHookParameters(operation, variablesTypeName, resultTypeName); - const hookBody = generateHookBody(operation, documentConstName, variablesTypeName, resultTypeName); + const hookBody = generateHookBody(operation, documentConstName, variablesTypeName, resultTypeName, useCentralizedKeys ? centralizedKeyRef : undefined); // Note: docs can cause ts-morph issues with certain content, so we skip them sourceFile.addFunction({ @@ -248,13 +262,15 @@ function generateHookBody( operation: CleanOperation, documentConstName: string, variablesTypeName: string, - resultTypeName: string + resultTypeName: string, + mutationKeyRef?: string ): string { const hasArgs = operation.args.length > 0; + const mutationKeyLine = mutationKeyRef ? `mutationKey: ${mutationKeyRef}(),\n ` : ''; if (hasArgs) { return `return useMutation({ - mutationFn: (variables: ${variablesTypeName}) => + ${mutationKeyLine}mutationFn: (variables: ${variablesTypeName}) => execute<${resultTypeName}, ${variablesTypeName}>( ${documentConstName}, variables @@ -263,7 +279,7 @@ function generateHookBody( });`; } else { return `return useMutation({ - mutationFn: () => execute<${resultTypeName}>(${documentConstName}), + ${mutationKeyLine}mutationFn: () => execute<${resultTypeName}>(${documentConstName}), ...options, });`; } @@ -284,6 +300,8 @@ export interface GenerateAllCustomMutationHooksOptions { reactQueryEnabled?: boolean; /** Table entity type names (for import path resolution) */ tableTypeNames?: Set; + /** Whether to use centralized mutation keys from mutation-keys.ts (default: true) */ + useCentralizedKeys?: boolean; } /** @@ -293,7 +311,7 @@ export interface GenerateAllCustomMutationHooksOptions { export function generateAllCustomMutationHooks( options: GenerateAllCustomMutationHooksOptions ): GeneratedCustomMutationFile[] { - const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames } = options; + const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames, useCentralizedKeys = true } = options; return operations .filter((op) => op.kind === 'mutation') @@ -305,6 +323,7 @@ export function generateAllCustomMutationHooks( skipQueryField, reactQueryEnabled, tableTypeNames, + useCentralizedKeys, }) ) .filter((result): result is GeneratedCustomMutationFile => result !== null); diff --git a/graphql/codegen/src/cli/codegen/custom-queries.ts b/graphql/codegen/src/cli/codegen/custom-queries.ts index 286cb4381..ab355f8ad 100644 --- a/graphql/codegen/src/cli/codegen/custom-queries.ts +++ b/graphql/codegen/src/cli/codegen/custom-queries.ts @@ -59,6 +59,8 @@ export interface GenerateCustomQueryHookOptions { reactQueryEnabled?: boolean; /** Table entity type names (for import path resolution) */ tableTypeNames?: Set; + /** Whether to use centralized query keys from query-keys.ts (default: true) */ + useCentralizedKeys?: boolean; } /** @@ -67,7 +69,7 @@ export interface GenerateCustomQueryHookOptions { export function generateCustomQueryHook( options: GenerateCustomQueryHookOptions ): GeneratedCustomQueryFile { - const { operation, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames } = options; + const { operation, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames, useCentralizedKeys = true } = options; const project = createProject(); const hookName = getOperationHookName(operation.name, 'query'); @@ -76,6 +78,8 @@ export function generateCustomQueryHook( const resultTypeName = getOperationResultTypeName(operation.name, 'query'); const documentConstName = getDocumentConstName(operation.name, 'query'); const queryKeyName = getQueryKeyName(operation.name); + // For centralized keys, use customQueryKeys.operationName + const centralizedKeyRef = `customQueryKeys.${operation.name}`; // Create type tracker to collect referenced types (with table type awareness) const tracker = createTypeTracker({ tableTypeNames }); @@ -151,6 +155,16 @@ export function generateCustomQueryHook( ); } + // Import centralized query keys + if (useCentralizedKeys) { + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: ['customQueryKeys'], + }) + ); + } + sourceFile.addImportDeclarations(imports); // Add query document constant @@ -168,8 +182,15 @@ export function generateCustomQueryHook( // Add result interface sourceFile.addInterface(createInterface(resultTypeName, resultProps)); - // Query key factory - if (operation.args.length > 0) { + // Query key factory - either re-export from centralized keys or define inline + if (useCentralizedKeys) { + // Re-export the query key function from centralized keys + sourceFile.addVariableStatement( + createConst(queryKeyName, centralizedKeyRef, { + docs: ['Query key factory - re-exported from query-keys.ts'], + }) + ); + } else if (operation.args.length > 0) { sourceFile.addVariableStatement( createConst( queryKeyName, @@ -540,6 +561,8 @@ export interface GenerateAllCustomQueryHooksOptions { reactQueryEnabled?: boolean; /** Table entity type names (for import path resolution) */ tableTypeNames?: Set; + /** Whether to use centralized query keys from query-keys.ts (default: true) */ + useCentralizedKeys?: boolean; } /** @@ -548,7 +571,7 @@ export interface GenerateAllCustomQueryHooksOptions { export function generateAllCustomQueryHooks( options: GenerateAllCustomQueryHooksOptions ): GeneratedCustomQueryFile[] { - const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames } = options; + const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true, tableTypeNames, useCentralizedKeys = true } = options; return operations .filter((op) => op.kind === 'query') @@ -560,6 +583,7 @@ export function generateAllCustomQueryHooks( skipQueryField, reactQueryEnabled, tableTypeNames, + useCentralizedKeys, }) ); } diff --git a/graphql/codegen/src/cli/codegen/index.ts b/graphql/codegen/src/cli/codegen/index.ts index 4d4b65629..163a5acd8 100644 --- a/graphql/codegen/src/cli/codegen/index.ts +++ b/graphql/codegen/src/cli/codegen/index.ts @@ -28,7 +28,8 @@ import type { CleanOperation, TypeRegistry, } from '../../types/schema'; -import type { ResolvedConfig } from '../../types/config'; +import type { ResolvedConfig, ResolvedQueryKeyConfig } from '../../types/config'; +import { DEFAULT_QUERY_KEY_CONFIG } from '../../types/config'; import { generateClientFile } from './client'; import { generateTypesFile } from './types'; @@ -37,6 +38,9 @@ import { generateAllQueryHooks } from './queries'; import { generateAllMutationHooks } from './mutations'; import { generateAllCustomQueryHooks } from './custom-queries'; import { generateAllCustomMutationHooks } from './custom-mutations'; +import { generateQueryKeysFile } from './query-keys'; +import { generateMutationKeysFile } from './mutation-keys'; +import { generateInvalidationFile } from './invalidation'; import { generateQueriesBarrel, generateMutationsBarrel, @@ -108,6 +112,11 @@ export function generate(options: GenerateOptions): GenerateResult { const skipQueryField = config.codegen.skipQueryField; const reactQueryEnabled = config.reactQuery.enabled; + // Query key configuration (use defaults if not provided) + const queryKeyConfig: ResolvedQueryKeyConfig = config.queryKeys ?? DEFAULT_QUERY_KEY_CONFIG; + const useCentralizedKeys = queryKeyConfig.generateScopedKeys; + const hasRelationships = Object.keys(queryKeyConfig.relationships).length > 0; + // 1. Generate client.ts files.push({ path: 'client.ts', @@ -146,8 +155,56 @@ export function generate(options: GenerateOptions): GenerateResult { }), }); + // 3b. Generate centralized query keys (query-keys.ts) + let hasQueryKeys = false; + if (useCentralizedKeys) { + const queryKeysResult = generateQueryKeysFile({ + tables, + customQueries: customOperations?.queries ?? [], + config: queryKeyConfig, + }); + files.push({ + path: queryKeysResult.fileName, + content: queryKeysResult.content, + }); + hasQueryKeys = true; + } + + // 3c. Generate centralized mutation keys (mutation-keys.ts) + let hasMutationKeys = false; + if (useCentralizedKeys && queryKeyConfig.generateMutationKeys) { + const mutationKeysResult = generateMutationKeysFile({ + tables, + customMutations: customOperations?.mutations ?? [], + config: queryKeyConfig, + }); + files.push({ + path: mutationKeysResult.fileName, + content: mutationKeysResult.content, + }); + hasMutationKeys = true; + } + + // 3d. Generate cache invalidation helpers (invalidation.ts) + let hasInvalidation = false; + if (useCentralizedKeys && queryKeyConfig.generateCascadeHelpers) { + const invalidationResult = generateInvalidationFile({ + tables, + config: queryKeyConfig, + }); + files.push({ + path: invalidationResult.fileName, + content: invalidationResult.content, + }); + hasInvalidation = true; + } + // 4. Generate table-based query hooks (queries/*.ts) - const queryHooks = generateAllQueryHooks(tables, { reactQueryEnabled }); + const queryHooks = generateAllQueryHooks(tables, { + reactQueryEnabled, + useCentralizedKeys, + hasRelationships, + }); for (const hook of queryHooks) { files.push({ path: `queries/${hook.fileName}`, @@ -169,6 +226,7 @@ export function generate(options: GenerateOptions): GenerateResult { skipQueryField, reactQueryEnabled, tableTypeNames, + useCentralizedKeys, }); for (const hook of customQueryHooks) { @@ -195,6 +253,8 @@ export function generate(options: GenerateOptions): GenerateResult { const mutationHooks = generateAllMutationHooks(tables, { reactQueryEnabled, enumsFromSchemaTypes: generatedEnumNames, + useCentralizedKeys, + hasRelationships, }); for (const hook of mutationHooks) { files.push({ @@ -217,6 +277,7 @@ export function generate(options: GenerateOptions): GenerateResult { skipQueryField, reactQueryEnabled, tableTypeNames, + useCentralizedKeys, }); for (const hook of customMutationHooks) { @@ -247,7 +308,13 @@ export function generate(options: GenerateOptions): GenerateResult { // 9. Generate main index.ts barrel (with schema-types if present) files.push({ path: 'index.ts', - content: generateMainBarrel(tables, { hasSchemaTypes, hasMutations }), + content: generateMainBarrel(tables, { + hasSchemaTypes, + hasMutations, + hasQueryKeys, + hasMutationKeys, + hasInvalidation, + }), }); return { @@ -295,3 +362,6 @@ export { generateCustomQueriesBarrel, generateCustomMutationsBarrel, } from './barrel'; +export { generateQueryKeysFile } from './query-keys'; +export { generateMutationKeysFile } from './mutation-keys'; +export { generateInvalidationFile } from './invalidation'; diff --git a/graphql/codegen/src/cli/codegen/invalidation.ts b/graphql/codegen/src/cli/codegen/invalidation.ts new file mode 100644 index 000000000..3596989b8 --- /dev/null +++ b/graphql/codegen/src/cli/codegen/invalidation.ts @@ -0,0 +1,330 @@ +/** + * Cache invalidation helpers generator + * + * Generates type-safe cache invalidation utilities with cascade support + * for parent-child entity relationships. + */ +import type { CleanTable } from '../../types/schema'; +import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import { getTableNames, getGeneratedFileHeader, ucFirst, lcFirst } from './utils'; + +export interface InvalidationGeneratorOptions { + tables: CleanTable[]; + config: ResolvedQueryKeyConfig; +} + +export interface GeneratedInvalidationFile { + fileName: string; + content: string; +} + +/** + * Build a map of parent -> children for cascade invalidation + */ +function buildChildrenMap( + relationships: Record +): Map { + const childrenMap = new Map(); + + for (const [child, rel] of Object.entries(relationships)) { + const parent = rel.parent.toLowerCase(); + if (!childrenMap.has(parent)) { + childrenMap.set(parent, []); + } + childrenMap.get(parent)!.push(child); + } + + return childrenMap; +} + +/** + * Get all descendants (children, grandchildren, etc.) of an entity + */ +function getAllDescendants( + entity: string, + childrenMap: Map +): string[] { + const descendants: string[] = []; + const queue = [entity.toLowerCase()]; + + while (queue.length > 0) { + const current = queue.shift()!; + const children = childrenMap.get(current) ?? []; + for (const child of children) { + descendants.push(child); + queue.push(child); + } + } + + return descendants; +} + +/** + * Generate cascade invalidation helper for a single entity + */ +function generateEntityCascadeHelper( + table: CleanTable, + relationships: Record, + childrenMap: Map, + allTables: CleanTable[] +): string { + const { typeName, singularName } = getTableNames(table); + const entityKey = typeName.toLowerCase(); + + const descendants = getAllDescendants(entityKey, childrenMap); + const hasDescendants = descendants.length > 0; + const relationship = relationships[entityKey]; + const hasParent = !!relationship; + + const lines: string[] = []; + + // Simple invalidate helper (just this entity) + lines.push(` /**`); + lines.push(` * Invalidate ${singularName} queries`); + lines.push(` */`); + lines.push(` ${singularName}: {`); + + // All queries for this entity + lines.push(` /** Invalidate all ${singularName} queries */`); + lines.push(` all: (queryClient: QueryClient) =>`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.all }),`); + + // List queries + lines.push(``); + lines.push(` /** Invalidate ${singularName} list queries */`); + if (hasParent) { + const scopeTypeName = `${typeName}Scope`; + lines.push(` lists: (queryClient: QueryClient, scope?: ${scopeTypeName}) =>`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.lists(scope) }),`); + } else { + lines.push(` lists: (queryClient: QueryClient) =>`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.lists() }),`); + } + + // Specific item + lines.push(``); + lines.push(` /** Invalidate a specific ${singularName} */`); + if (hasParent) { + const scopeTypeName = `${typeName}Scope`; + lines.push(` detail: (queryClient: QueryClient, id: string | number, scope?: ${scopeTypeName}) =>`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id, scope) }),`); + } else { + lines.push(` detail: (queryClient: QueryClient, id: string | number) =>`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id) }),`); + } + + // With children (cascade) + if (hasDescendants) { + lines.push(``); + lines.push(` /**`); + lines.push(` * Invalidate ${singularName} and all child entities`); + lines.push(` * Cascades to: ${descendants.join(', ')}`); + lines.push(` */`); + lines.push(` withChildren: (queryClient: QueryClient, id: string | number) => {`); + + // Invalidate the entity itself + lines.push(` // Invalidate this ${singularName}`); + if (hasParent) { + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id) });`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.lists() });`); + } else { + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id) });`); + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(typeName)}Keys.lists() });`); + } + + // Invalidate each descendant using scoped keys + lines.push(``); + lines.push(` // Cascade to child entities`); + + for (const descendant of descendants) { + const descendantTable = allTables.find( + (t) => getTableNames(t).typeName.toLowerCase() === descendant + ); + if (descendantTable) { + const { typeName: descTypeName } = getTableNames(descendantTable); + const descRel = relationships[descendant]; + + if (descRel) { + // Find the foreign key that links to our entity + // Could be direct parent or ancestor + let fkField: string | null = null; + if (descRel.parent.toLowerCase() === entityKey) { + fkField = descRel.foreignKey; + } else if (descRel.ancestors?.includes(typeName.toLowerCase())) { + // It's an ancestor relationship + fkField = `${lcFirst(typeName)}Id`; + } + + if (fkField) { + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(descTypeName)}Keys.by${ucFirst(typeName)}(id) });`); + } else { + // Fallback to invalidating all of that entity type + lines.push(` queryClient.invalidateQueries({ queryKey: ${lcFirst(descTypeName)}Keys.all });`); + } + } + } + } + + lines.push(` },`); + } + + lines.push(` },`); + + return lines.join('\n'); +} + +/** + * Generate remove (for delete operations) helpers + */ +function generateRemoveHelpers( + tables: CleanTable[], + relationships: Record +): string { + const lines: string[] = []; + + lines.push(`/**`); + lines.push(` * Remove queries from cache (for delete operations)`); + lines.push(` *`); + lines.push(` * Use these when an entity is deleted to remove it from cache`); + lines.push(` * instead of just invalidating (which would trigger a refetch).`); + lines.push(` */`); + lines.push(`export const remove = {`); + + for (let i = 0; i < tables.length; i++) { + const table = tables[i]; + const { typeName, singularName } = getTableNames(table); + const relationship = relationships[typeName.toLowerCase()]; + + lines.push(` /** Remove ${singularName} from cache */`); + if (relationship) { + const scopeTypeName = `${typeName}Scope`; + lines.push(` ${singularName}: (queryClient: QueryClient, id: string | number, scope?: ${scopeTypeName}) => {`); + lines.push(` queryClient.removeQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id, scope) });`); + } else { + lines.push(` ${singularName}: (queryClient: QueryClient, id: string | number) => {`); + lines.push(` queryClient.removeQueries({ queryKey: ${lcFirst(typeName)}Keys.detail(id) });`); + } + lines.push(` },`); + + if (i < tables.length - 1) { + lines.push(``); + } + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate the complete invalidation.ts file + */ +export function generateInvalidationFile( + options: InvalidationGeneratorOptions +): GeneratedInvalidationFile { + const { tables, config } = options; + const { relationships, generateCascadeHelpers } = config; + + const childrenMap = buildChildrenMap(relationships); + + const lines: string[] = []; + + // File header + lines.push(getGeneratedFileHeader('Cache invalidation helpers')); + lines.push(``); + + // Description + lines.push(`// ============================================================================`); + lines.push(`// Type-safe cache invalidation utilities`); + lines.push(`//`); + lines.push(`// Features:`); + lines.push(`// - Simple invalidation helpers per entity`); + lines.push(`// - Cascade invalidation for parent-child relationships`); + lines.push(`// - Remove helpers for delete operations`); + lines.push(`// ============================================================================`); + lines.push(``); + + // Imports + lines.push(`import type { QueryClient } from '@tanstack/react-query';`); + + // Import query keys + const keyImports: string[] = []; + for (const table of tables) { + const { typeName } = getTableNames(table); + keyImports.push(`${lcFirst(typeName)}Keys`); + } + lines.push(`import { ${keyImports.join(', ')} } from './query-keys';`); + + // Import scope types if needed + const scopeTypes: string[] = []; + for (const table of tables) { + const { typeName } = getTableNames(table); + if (relationships[typeName.toLowerCase()]) { + scopeTypes.push(`${typeName}Scope`); + } + } + if (scopeTypes.length > 0) { + lines.push(`import type { ${scopeTypes.join(', ')} } from './query-keys';`); + } + + lines.push(``); + + // Generate invalidate helpers + lines.push(`// ============================================================================`); + lines.push(`// Invalidation Helpers`); + lines.push(`// ============================================================================`); + lines.push(``); + + lines.push(`/**`); + lines.push(` * Type-safe query invalidation helpers`); + lines.push(` *`); + lines.push(` * @example`); + lines.push(` * \`\`\`ts`); + lines.push(` * // Invalidate all user queries`); + lines.push(` * invalidate.user.all(queryClient);`); + lines.push(` *`); + lines.push(` * // Invalidate user lists`); + lines.push(` * invalidate.user.lists(queryClient);`); + lines.push(` *`); + lines.push(` * // Invalidate specific user`); + lines.push(` * invalidate.user.detail(queryClient, userId);`); + + // Add cascade example if relationships exist + if (generateCascadeHelpers && Object.keys(relationships).length > 0) { + lines.push(` *`); + lines.push(` * // Cascade invalidate (entity + all children)`); + lines.push(` * invalidate.database.withChildren(queryClient, databaseId);`); + } + + lines.push(` * \`\`\``); + lines.push(` */`); + lines.push(`export const invalidate = {`); + + for (let i = 0; i < tables.length; i++) { + const table = tables[i]; + lines.push( + generateEntityCascadeHelper(table, relationships, childrenMap, tables) + ); + if (i < tables.length - 1) { + lines.push(``); + } + } + + lines.push(`} as const;`); + + // Generate remove helpers + lines.push(``); + lines.push(`// ============================================================================`); + lines.push(`// Remove Helpers (for delete operations)`); + lines.push(`// ============================================================================`); + lines.push(``); + + lines.push(generateRemoveHelpers(tables, relationships)); + + lines.push(``); + + return { + fileName: 'invalidation.ts', + content: lines.join('\n'), + }; +} diff --git a/graphql/codegen/src/cli/codegen/mutation-keys.ts b/graphql/codegen/src/cli/codegen/mutation-keys.ts new file mode 100644 index 000000000..aae8b20a6 --- /dev/null +++ b/graphql/codegen/src/cli/codegen/mutation-keys.ts @@ -0,0 +1,218 @@ +/** + * Mutation key factory generator + * + * Generates centralized mutation keys for tracking in-flight mutations. + * Useful for: + * - Optimistic updates with rollback + * - Mutation deduplication + * - Tracking mutation state with useIsMutating + */ +import type { CleanTable, CleanOperation } from '../../types/schema'; +import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import { getTableNames, getGeneratedFileHeader, lcFirst } from './utils'; + +export interface MutationKeyGeneratorOptions { + tables: CleanTable[]; + customMutations: CleanOperation[]; + config: ResolvedQueryKeyConfig; +} + +export interface GeneratedMutationKeysFile { + fileName: string; + content: string; +} + +/** + * Generate mutation keys for a single table entity + */ +function generateEntityMutationKeys( + table: CleanTable, + relationships: Record +): string { + const { typeName, singularName } = getTableNames(table); + const entityKey = typeName.toLowerCase(); + const keysName = `${lcFirst(typeName)}MutationKeys`; + + const relationship = relationships[entityKey]; + + const lines: string[] = []; + + lines.push(`export const ${keysName} = {`); + lines.push(` /** All ${singularName} mutation keys */`); + lines.push(` all: ['mutation', '${entityKey}'] as const,`); + + // Create mutation + lines.push(``); + lines.push(` /** Create ${singularName} mutation key */`); + if (relationship) { + // Include optional parent scope for tracking creates within a parent context + lines.push(` create: (${relationship.foreignKey}?: string) =>`); + lines.push(` ${relationship.foreignKey}`); + lines.push(` ? ['mutation', '${entityKey}', 'create', { ${relationship.foreignKey} }] as const`); + lines.push(` : ['mutation', '${entityKey}', 'create'] as const,`); + } else { + lines.push(` create: () => ['mutation', '${entityKey}', 'create'] as const,`); + } + + // Update mutation + lines.push(``); + lines.push(` /** Update ${singularName} mutation key */`); + lines.push(` update: (id: string | number) =>`); + lines.push(` ['mutation', '${entityKey}', 'update', id] as const,`); + + // Delete mutation + lines.push(``); + lines.push(` /** Delete ${singularName} mutation key */`); + lines.push(` delete: (id: string | number) =>`); + lines.push(` ['mutation', '${entityKey}', 'delete', id] as const,`); + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate mutation keys for custom mutations + */ +function generateCustomMutationKeys(operations: CleanOperation[]): string { + if (operations.length === 0) return ''; + + const lines: string[] = []; + lines.push(`export const customMutationKeys = {`); + + for (const op of operations) { + const hasArgs = op.args.length > 0; + + lines.push(` /** Mutation key for ${op.name} */`); + if (hasArgs) { + // For mutations with args, include a way to identify the specific mutation + lines.push(` ${op.name}: (identifier?: string) =>`); + lines.push(` identifier`); + lines.push(` ? ['mutation', '${op.name}', identifier] as const`); + lines.push(` : ['mutation', '${op.name}'] as const,`); + } else { + lines.push(` ${op.name}: () => ['mutation', '${op.name}'] as const,`); + } + lines.push(``); + } + + // Remove trailing empty line + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate the unified mutation keys store object + */ +function generateUnifiedMutationStore( + tables: CleanTable[], + hasCustomMutations: boolean +): string { + const lines: string[] = []; + + lines.push(`/**`); + lines.push(` * Unified mutation key store`); + lines.push(` *`); + lines.push(` * Use this for tracking in-flight mutations with useIsMutating.`); + lines.push(` *`); + lines.push(` * @example`); + lines.push(` * \`\`\`ts`); + lines.push(` * import { useIsMutating } from '@tanstack/react-query';`); + lines.push(` * import { mutationKeys } from './generated';`); + lines.push(` *`); + lines.push(` * // Check if any user mutations are in progress`); + lines.push(` * const isMutatingUser = useIsMutating({ mutationKey: mutationKeys.user.all });`); + lines.push(` *`); + lines.push(` * // Check if a specific user is being updated`); + lines.push(` * const isUpdating = useIsMutating({ mutationKey: mutationKeys.user.update(userId) });`); + lines.push(` * \`\`\``); + lines.push(` */`); + lines.push(`export const mutationKeys = {`); + + for (const table of tables) { + const { typeName } = getTableNames(table); + const keysName = `${lcFirst(typeName)}MutationKeys`; + lines.push(` ${lcFirst(typeName)}: ${keysName},`); + } + + if (hasCustomMutations) { + lines.push(` custom: customMutationKeys,`); + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate the complete mutation-keys.ts file + */ +export function generateMutationKeysFile( + options: MutationKeyGeneratorOptions +): GeneratedMutationKeysFile { + const { tables, customMutations, config } = options; + const { relationships } = config; + + const lines: string[] = []; + + // File header + lines.push(getGeneratedFileHeader('Centralized mutation key factory')); + lines.push(``); + + // Description comments + lines.push(`// ============================================================================`); + lines.push(`// Mutation keys for tracking in-flight mutations`); + lines.push(`//`); + lines.push(`// Benefits:`); + lines.push(`// - Track mutation state with useIsMutating`); + lines.push(`// - Implement optimistic updates with proper rollback`); + lines.push(`// - Deduplicate identical mutations`); + lines.push(`// - Coordinate related mutations`); + lines.push(`// ============================================================================`); + lines.push(``); + + // Generate entity mutation keys + lines.push(`// ============================================================================`); + lines.push(`// Entity Mutation Keys`); + lines.push(`// ============================================================================`); + lines.push(``); + + for (let i = 0; i < tables.length; i++) { + const table = tables[i]; + lines.push(generateEntityMutationKeys(table, relationships)); + if (i < tables.length - 1) { + lines.push(``); + } + } + + // Generate custom mutation keys + const mutationOperations = customMutations.filter((op) => op.kind === 'mutation'); + if (mutationOperations.length > 0) { + lines.push(``); + lines.push(`// ============================================================================`); + lines.push(`// Custom Mutation Keys`); + lines.push(`// ============================================================================`); + lines.push(``); + lines.push(generateCustomMutationKeys(mutationOperations)); + } + + // Generate unified store + lines.push(``); + lines.push(`// ============================================================================`); + lines.push(`// Unified Mutation Key Store`); + lines.push(`// ============================================================================`); + lines.push(``); + lines.push(generateUnifiedMutationStore(tables, mutationOperations.length > 0)); + + lines.push(``); + + return { + fileName: 'mutation-keys.ts', + content: lines.join('\n'), + }; +} diff --git a/graphql/codegen/src/cli/codegen/mutations.ts b/graphql/codegen/src/cli/codegen/mutations.ts index f302ecfdc..65ae484d6 100644 --- a/graphql/codegen/src/cli/codegen/mutations.ts +++ b/graphql/codegen/src/cli/codegen/mutations.ts @@ -73,6 +73,10 @@ export interface MutationGeneratorOptions { reactQueryEnabled?: boolean; /** Enum type names that are available from schema-types.ts */ enumsFromSchemaTypes?: string[]; + /** Whether to use centralized mutation/query keys (default: true) */ + useCentralizedKeys?: boolean; + /** Whether this entity has parent relationships (for scoped invalidation) */ + hasRelationships?: boolean; } // ============================================================================ @@ -87,7 +91,7 @@ export function generateCreateMutationHook( table: CleanTable, options: MutationGeneratorOptions = {} ): GeneratedMutationFile | null { - const { reactQueryEnabled = true, enumsFromSchemaTypes = [] } = options; + const { reactQueryEnabled = true, enumsFromSchemaTypes = [], useCentralizedKeys = true, hasRelationships = false } = options; // Mutations require React Query - skip generation when disabled if (!reactQueryEnabled) { @@ -99,6 +103,9 @@ export function generateCreateMutationHook( const project = createProject(); const { typeName, singularName } = getTableNames(table); const hookName = getCreateMutationHookName(table); + const keysName = `${lcFirst(typeName)}Keys`; + const mutationKeysName = `${lcFirst(typeName)}MutationKeys`; + const scopeTypeName = `${typeName}Scope`; const mutationName = getCreateMutationName(table); const scalarFields = getScalarFields(table); @@ -150,6 +157,27 @@ export function generateCreateMutationHook( ); } + // Add centralized key imports + if (useCentralizedKeys) { + // Import query keys for invalidation + const queryKeyImports = [keysName]; + const queryKeyTypeImports = hasRelationships ? [scopeTypeName] : []; + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: queryKeyImports, + typeOnlyNamedImports: queryKeyTypeImports, + }) + ); + // Import mutation keys for tracking + imports.push( + createImport({ + moduleSpecifier: '../mutation-keys', + namedImports: [mutationKeysName], + }) + ); + } + // Add imports sourceFile.addImportDeclarations(imports); @@ -217,18 +245,26 @@ export function generateCreateMutationHook( sourceFile.addStatements('// Hook'); sourceFile.addStatements('// ============================================================================\n'); - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { - name: 'options', - type: `Omit, 'mutationFn'>`, - hasQuestionToken: true, - }, - ], - statements: `const queryClient = useQueryClient(); + // Hook function body depends on whether we use centralized keys + let hookBody: string; + if (useCentralizedKeys) { + hookBody = `const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ${mutationKeysName}.create(), + mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => + execute<${ucFirst(mutationName)}MutationResult, ${ucFirst(mutationName)}MutationVariables>( + ${mutationName}MutationDocument, + variables + ), + onSuccess: () => { + // Invalidate list queries to refetch + queryClient.invalidateQueries({ queryKey: ${keysName}.lists() }); + }, + ...options, + });`; + } else { + hookBody = `const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => @@ -241,7 +277,20 @@ export function generateCreateMutationHook( queryClient.invalidateQueries({ queryKey: ['${typeName.toLowerCase()}', 'list'] }); }, ...options, - });`, + });`; + } + + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: [ + { + name: 'options', + type: `Omit, 'mutationFn'>`, + hasQuestionToken: true, + }, + ], + statements: hookBody, docs: [ { description: `Mutation hook for creating a ${typeName} @@ -280,7 +329,7 @@ export function generateUpdateMutationHook( table: CleanTable, options: MutationGeneratorOptions = {} ): GeneratedMutationFile | null { - const { reactQueryEnabled = true, enumsFromSchemaTypes = [] } = options; + const { reactQueryEnabled = true, enumsFromSchemaTypes = [], useCentralizedKeys = true, hasRelationships = false } = options; // Mutations require React Query - skip generation when disabled if (!reactQueryEnabled) { @@ -299,6 +348,9 @@ export function generateUpdateMutationHook( const hookName = getUpdateMutationHookName(table); const mutationName = getUpdateMutationName(table); const scalarFields = getScalarFields(table); + const keysName = `${lcFirst(typeName)}Keys`; + const mutationKeysName = `${lcFirst(typeName)}MutationKeys`; + const scopeTypeName = `${typeName}Scope`; // Get primary key info dynamically from table constraints const pkFields = getPrimaryKeyInfo(table); @@ -350,6 +402,27 @@ export function generateUpdateMutationHook( ); } + // Add centralized key imports + if (useCentralizedKeys) { + // Import query keys for invalidation + const queryKeyImports = [keysName]; + const queryKeyTypeImports = hasRelationships ? [scopeTypeName] : []; + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: queryKeyImports, + typeOnlyNamedImports: queryKeyTypeImports, + }) + ); + // Import mutation keys for tracking + imports.push( + createImport({ + moduleSpecifier: '../mutation-keys', + namedImports: [mutationKeysName], + }) + ); + } + // Add imports sourceFile.addImportDeclarations(imports); @@ -418,18 +491,27 @@ export function generateUpdateMutationHook( sourceFile.addStatements('// Hook'); sourceFile.addStatements('// ============================================================================\n'); - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { - name: 'options', - type: `Omit, 'mutationFn'>`, - hasQuestionToken: true, - }, - ], - statements: `const queryClient = useQueryClient(); + // Hook function body depends on whether we use centralized keys + let hookBody: string; + if (useCentralizedKeys) { + hookBody = `const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ${mutationKeysName}.all, + mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => + execute<${ucFirst(mutationName)}MutationResult, ${ucFirst(mutationName)}MutationVariables>( + ${mutationName}MutationDocument, + variables + ), + onSuccess: (_, variables) => { + // Invalidate specific item and list queries + queryClient.invalidateQueries({ queryKey: ${keysName}.detail(variables.input.${pkField.name}) }); + queryClient.invalidateQueries({ queryKey: ${keysName}.lists() }); + }, + ...options, + });`; + } else { + hookBody = `const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => @@ -443,7 +525,20 @@ export function generateUpdateMutationHook( queryClient.invalidateQueries({ queryKey: ['${typeName.toLowerCase()}', 'list'] }); }, ...options, - });`, + });`; + } + + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: [ + { + name: 'options', + type: `Omit, 'mutationFn'>`, + hasQuestionToken: true, + }, + ], + statements: hookBody, docs: [ { description: `Mutation hook for updating a ${typeName} @@ -483,7 +578,7 @@ export function generateDeleteMutationHook( table: CleanTable, options: MutationGeneratorOptions = {} ): GeneratedMutationFile | null { - const { reactQueryEnabled = true } = options; + const { reactQueryEnabled = true, useCentralizedKeys = true, hasRelationships = false } = options; // Mutations require React Query - skip generation when disabled if (!reactQueryEnabled) { @@ -499,6 +594,9 @@ export function generateDeleteMutationHook( const { typeName } = getTableNames(table); const hookName = getDeleteMutationHookName(table); const mutationName = getDeleteMutationName(table); + const keysName = `${lcFirst(typeName)}Keys`; + const mutationKeysName = `${lcFirst(typeName)}MutationKeys`; + const scopeTypeName = `${typeName}Scope`; // Get primary key info dynamically from table constraints const pkFields = getPrimaryKeyInfo(table); @@ -513,8 +611,8 @@ export function generateDeleteMutationHook( // Add file header sourceFile.insertText(0, createFileHeader(`Delete mutation hook for ${typeName}`) + '\n\n'); - // Add imports - sourceFile.addImportDeclarations([ + // Build imports + const imports = [ createImport({ moduleSpecifier: '@tanstack/react-query', namedImports: ['useMutation', 'useQueryClient'], @@ -524,7 +622,31 @@ export function generateDeleteMutationHook( moduleSpecifier: '../client', namedImports: ['execute'], }), - ]); + ]; + + // Add centralized key imports + if (useCentralizedKeys) { + // Import query keys for invalidation + const queryKeyImports = [keysName]; + const queryKeyTypeImports = hasRelationships ? [scopeTypeName] : []; + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: queryKeyImports, + typeOnlyNamedImports: queryKeyTypeImports, + }) + ); + // Import mutation keys for tracking + imports.push( + createImport({ + moduleSpecifier: '../mutation-keys', + namedImports: [mutationKeysName], + }) + ); + } + + // Add imports + sourceFile.addImportDeclarations(imports); // Add section comment sourceFile.addStatements('\n// ============================================================================'); @@ -571,18 +693,27 @@ export function generateDeleteMutationHook( sourceFile.addStatements('// Hook'); sourceFile.addStatements('// ============================================================================\n'); - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { - name: 'options', - type: `Omit, 'mutationFn'>`, - hasQuestionToken: true, - }, - ], - statements: `const queryClient = useQueryClient(); + // Hook function body depends on whether we use centralized keys + let hookBody: string; + if (useCentralizedKeys) { + hookBody = `const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ${mutationKeysName}.all, + mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => + execute<${ucFirst(mutationName)}MutationResult, ${ucFirst(mutationName)}MutationVariables>( + ${mutationName}MutationDocument, + variables + ), + onSuccess: (_, variables) => { + // Remove from cache and invalidate list + queryClient.removeQueries({ queryKey: ${keysName}.detail(variables.input.${pkField.name}) }); + queryClient.invalidateQueries({ queryKey: ${keysName}.lists() }); + }, + ...options, + });`; + } else { + hookBody = `const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: ${ucFirst(mutationName)}MutationVariables) => @@ -596,7 +727,20 @@ export function generateDeleteMutationHook( queryClient.invalidateQueries({ queryKey: ['${typeName.toLowerCase()}', 'list'] }); }, ...options, - });`, + });`; + } + + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: [ + { + name: 'options', + type: `Omit, 'mutationFn'>`, + hasQuestionToken: true, + }, + ], + statements: hookBody, docs: [ { description: `Mutation hook for deleting a ${typeName} diff --git a/graphql/codegen/src/cli/codegen/queries.ts b/graphql/codegen/src/cli/codegen/queries.ts index bc83baffc..82a7f5d74 100644 --- a/graphql/codegen/src/cli/codegen/queries.ts +++ b/graphql/codegen/src/cli/codegen/queries.ts @@ -40,6 +40,7 @@ import { getPrimaryKeyInfo, toScreamingSnake, ucFirst, + lcFirst, } from './utils'; export interface GeneratedQueryFile { @@ -50,6 +51,10 @@ export interface GeneratedQueryFile { export interface QueryGeneratorOptions { /** Whether to generate React Query hooks (default: true for backwards compatibility) */ reactQueryEnabled?: boolean; + /** Whether to use centralized query keys from query-keys.ts (default: true) */ + useCentralizedKeys?: boolean; + /** Whether this entity has parent relationships (for scope support) */ + hasRelationships?: boolean; } // ============================================================================ @@ -63,7 +68,7 @@ export function generateListQueryHook( table: CleanTable, options: QueryGeneratorOptions = {} ): GeneratedQueryFile { - const { reactQueryEnabled = true } = options; + const { reactQueryEnabled = true, useCentralizedKeys = true, hasRelationships = false } = options; const project = createProject(); const { typeName, pluralName } = getTableNames(table); const hookName = getListQueryHookName(table); @@ -71,6 +76,8 @@ export function generateListQueryHook( const filterTypeName = getFilterTypeName(table); const orderByTypeName = getOrderByTypeName(table); const scalarFields = getScalarFields(table); + const keysName = `${lcFirst(typeName)}Keys`; + const scopeTypeName = `${typeName}Scope`; // Generate GraphQL document via AST const queryAST = buildListQueryAST({ table }); @@ -115,6 +122,20 @@ export function generateListQueryHook( typeOnlyNamedImports: [typeName, ...Array.from(filterTypesUsed)], }) ); + + // Import centralized query keys + if (useCentralizedKeys) { + const queryKeyImports = [keysName]; + const queryKeyTypeImports = hasRelationships ? [scopeTypeName] : []; + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: queryKeyImports, + typeOnlyNamedImports: queryKeyTypeImports, + }) + ); + } + sourceFile.addImportDeclarations(imports); // Re-export entity type @@ -197,14 +218,26 @@ export function generateListQueryHook( sourceFile.addStatements('// Query Key'); sourceFile.addStatements('// ============================================================================\n'); - // Query key factory - sourceFile.addVariableStatement( - createConst( - `${queryName}QueryKey`, - `(variables?: ${ucFirst(pluralName)}QueryVariables) => + // Query key - either re-export from centralized keys or define inline + if (useCentralizedKeys) { + // Re-export the query key function from centralized keys + sourceFile.addVariableStatement( + createConst( + `${queryName}QueryKey`, + `${keysName}.list`, + { docs: ['Query key factory - re-exported from query-keys.ts'] } + ) + ); + } else { + // Legacy: Define inline query key factory + sourceFile.addVariableStatement( + createConst( + `${queryName}QueryKey`, + `(variables?: ${ucFirst(pluralName)}QueryVariables) => ['${typeName.toLowerCase()}', 'list', variables] as const` - ) - ); + ) + ); + } // Add React Query hook section (only if enabled) if (reactQueryEnabled) { @@ -212,33 +245,63 @@ export function generateListQueryHook( sourceFile.addStatements('// Hook'); sourceFile.addStatements('// ============================================================================\n'); - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { - name: 'variables', - type: `${ucFirst(pluralName)}QueryVariables`, - hasQuestionToken: true, - }, - { - name: 'options', - type: `Omit, 'queryKey' | 'queryFn'>`, - hasQuestionToken: true, - }, - ], - statements: `return useQuery({ + // Hook parameters - add scope support when entity has relationships + const hookParameters: Array<{ name: string; type: string; hasQuestionToken?: boolean }> = [ + { + name: 'variables', + type: `${ucFirst(pluralName)}QueryVariables`, + hasQuestionToken: true, + }, + ]; + + // Options type - include scope if entity has relationships + let optionsType: string; + if (hasRelationships && useCentralizedKeys) { + optionsType = `Omit, 'queryKey' | 'queryFn'> & { scope?: ${scopeTypeName} }`; + } else { + optionsType = `Omit, 'queryKey' | 'queryFn'>`; + } + + hookParameters.push({ + name: 'options', + type: optionsType, + hasQuestionToken: true, + }); + + // Hook body - use scope if available + let hookBody: string; + if (hasRelationships && useCentralizedKeys) { + hookBody = `const { scope, ...queryOptions } = options ?? {}; + return useQuery({ + queryKey: ${keysName}.list(variables, scope), + queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( + ${queryName}QueryDocument, + variables + ), + ...queryOptions, + });`; + } else if (useCentralizedKeys) { + hookBody = `return useQuery({ + queryKey: ${keysName}.list(variables), + queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( + ${queryName}QueryDocument, + variables + ), + ...options, + });`; + } else { + hookBody = `return useQuery({ queryKey: ${queryName}QueryKey(variables), queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( ${queryName}QueryDocument, variables ), ...options, - });`, - docs: [ - { - description: `Query hook for fetching ${typeName} list + });`; + } + + // Doc example + let docExample = `Query hook for fetching ${typeName} list @example \`\`\`tsx @@ -247,9 +310,27 @@ const { data, isLoading } = ${hookName}({ filter: { name: { equalTo: "example" } }, orderBy: ['CREATED_AT_DESC'], }); -\`\`\``, - }, - ], +\`\`\``; + + if (hasRelationships && useCentralizedKeys) { + docExample += ` + +@example With scope for hierarchical cache invalidation +\`\`\`tsx +const { data } = ${hookName}( + { first: 10 }, + { scope: { parentId: 'parent-id' } } +); +\`\`\``; + } + + // Hook function + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: hookParameters, + statements: hookBody, + docs: [{ description: docExample }], }); } @@ -302,35 +383,56 @@ const data = await queryClient.fetchQuery({ // Prefetch function (for SSR/QueryClient) - only if React Query is enabled if (reactQueryEnabled) { - sourceFile.addFunction({ - name: `prefetch${ucFirst(pluralName)}Query`, - isExported: true, - isAsync: true, - parameters: [ - { - name: 'queryClient', - type: 'QueryClient', - }, - { - name: 'variables', - type: `${ucFirst(pluralName)}QueryVariables`, - hasQuestionToken: true, - }, - { - name: 'options', - type: 'ExecuteOptions', - hasQuestionToken: true, - }, - ], - returnType: 'Promise', - statements: `await queryClient.prefetchQuery({ + // Prefetch parameters - add scope support when entity has relationships + const prefetchParams: Array<{ name: string; type: string; hasQuestionToken?: boolean }> = [ + { name: 'queryClient', type: 'QueryClient' }, + { name: 'variables', type: `${ucFirst(pluralName)}QueryVariables`, hasQuestionToken: true }, + ]; + + if (hasRelationships && useCentralizedKeys) { + prefetchParams.push({ name: 'scope', type: scopeTypeName, hasQuestionToken: true }); + } + + prefetchParams.push({ name: 'options', type: 'ExecuteOptions', hasQuestionToken: true }); + + // Prefetch body + let prefetchBody: string; + if (hasRelationships && useCentralizedKeys) { + prefetchBody = `await queryClient.prefetchQuery({ + queryKey: ${keysName}.list(variables, scope), + queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( + ${queryName}QueryDocument, + variables, + options + ), + });`; + } else if (useCentralizedKeys) { + prefetchBody = `await queryClient.prefetchQuery({ + queryKey: ${keysName}.list(variables), + queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( + ${queryName}QueryDocument, + variables, + options + ), + });`; + } else { + prefetchBody = `await queryClient.prefetchQuery({ queryKey: ${queryName}QueryKey(variables), queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( ${queryName}QueryDocument, variables, options ), - });`, + });`; + } + + sourceFile.addFunction({ + name: `prefetch${ucFirst(pluralName)}Query`, + isExported: true, + isAsync: true, + parameters: prefetchParams, + returnType: 'Promise', + statements: prefetchBody, docs: [ { description: `Prefetch ${typeName} list for SSR or cache warming @@ -361,11 +463,13 @@ export function generateSingleQueryHook( table: CleanTable, options: QueryGeneratorOptions = {} ): GeneratedQueryFile { - const { reactQueryEnabled = true } = options; + const { reactQueryEnabled = true, useCentralizedKeys = true, hasRelationships = false } = options; const project = createProject(); const { typeName, singularName } = getTableNames(table); const hookName = getSingleQueryHookName(table); const queryName = getSingleRowQueryName(table); + const keysName = `${lcFirst(typeName)}Keys`; + const scopeTypeName = `${typeName}Scope`; // Get primary key info dynamically from table constraints const pkFields = getPrimaryKeyInfo(table); @@ -409,6 +513,20 @@ export function generateSingleQueryHook( typeOnlyNamedImports: [typeName], }) ); + + // Import centralized query keys + if (useCentralizedKeys) { + const queryKeyImports = [keysName]; + const queryKeyTypeImports = hasRelationships ? [scopeTypeName] : []; + imports.push( + createImport({ + moduleSpecifier: '../query-keys', + namedImports: queryKeyImports, + typeOnlyNamedImports: queryKeyTypeImports, + }) + ); + } + sourceFile.addImportDeclarations(imports); // Re-export entity type @@ -448,14 +566,26 @@ export function generateSingleQueryHook( sourceFile.addStatements('// Query Key'); sourceFile.addStatements('// ============================================================================\n'); - // Query key factory - use dynamic PK field name and type - sourceFile.addVariableStatement( - createConst( - `${queryName}QueryKey`, - `(${pkName}: ${pkTsType}) => + // Query key - either re-export from centralized keys or define inline + if (useCentralizedKeys) { + // Re-export the query key function from centralized keys + sourceFile.addVariableStatement( + createConst( + `${queryName}QueryKey`, + `${keysName}.detail`, + { docs: ['Query key factory - re-exported from query-keys.ts'] } + ) + ); + } else { + // Legacy: Define inline query key factory + sourceFile.addVariableStatement( + createConst( + `${queryName}QueryKey`, + `(${pkName}: ${pkTsType}) => ['${typeName.toLowerCase()}', 'detail', ${pkName}] as const` - ) - ); + ) + ); + } // Add React Query hook section (only if enabled) if (reactQueryEnabled) { @@ -463,19 +593,50 @@ export function generateSingleQueryHook( sourceFile.addStatements('// Hook'); sourceFile.addStatements('// ============================================================================\n'); - // Hook function - use dynamic PK field name and type - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { name: pkName, type: pkTsType }, - { - name: 'options', - type: `Omit, 'queryKey' | 'queryFn'>`, - hasQuestionToken: true, - }, - ], - statements: `return useQuery({ + // Hook parameters - add scope support when entity has relationships + const hookParameters: Array<{ name: string; type: string; hasQuestionToken?: boolean }> = [ + { name: pkName, type: pkTsType }, + ]; + + // Options type - include scope if entity has relationships + let optionsType: string; + if (hasRelationships && useCentralizedKeys) { + optionsType = `Omit, 'queryKey' | 'queryFn'> & { scope?: ${scopeTypeName} }`; + } else { + optionsType = `Omit, 'queryKey' | 'queryFn'>`; + } + + hookParameters.push({ + name: 'options', + type: optionsType, + hasQuestionToken: true, + }); + + // Hook body - use scope if available + let hookBody: string; + if (hasRelationships && useCentralizedKeys) { + hookBody = `const { scope, ...queryOptions } = options ?? {}; + return useQuery({ + queryKey: ${keysName}.detail(${pkName}, scope), + queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( + ${queryName}QueryDocument, + { ${pkName} } + ), + enabled: !!${pkName} && (queryOptions?.enabled !== false), + ...queryOptions, + });`; + } else if (useCentralizedKeys) { + hookBody = `return useQuery({ + queryKey: ${keysName}.detail(${pkName}), + queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( + ${queryName}QueryDocument, + { ${pkName} } + ), + enabled: !!${pkName} && (options?.enabled !== false), + ...options, + });`; + } else { + hookBody = `return useQuery({ queryKey: ${queryName}QueryKey(${pkName}), queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( ${queryName}QueryDocument, @@ -483,7 +644,15 @@ export function generateSingleQueryHook( ), enabled: !!${pkName} && (options?.enabled !== false), ...options, - });`, + });`; + } + + // Hook function + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: hookParameters, + statements: hookBody, docs: [ { description: `Query hook for fetching a single ${typeName} by primary key @@ -539,28 +708,56 @@ const data = await fetch${ucFirst(singularName)}Query(${pkTsType === 'string' ? // Prefetch function (for SSR/QueryClient) - only if React Query is enabled, use dynamic PK if (reactQueryEnabled) { - sourceFile.addFunction({ - name: `prefetch${ucFirst(singularName)}Query`, - isExported: true, - isAsync: true, - parameters: [ - { name: 'queryClient', type: 'QueryClient' }, - { name: pkName, type: pkTsType }, - { - name: 'options', - type: 'ExecuteOptions', - hasQuestionToken: true, - }, - ], - returnType: 'Promise', - statements: `await queryClient.prefetchQuery({ + // Prefetch parameters - add scope support when entity has relationships + const prefetchParams: Array<{ name: string; type: string; hasQuestionToken?: boolean }> = [ + { name: 'queryClient', type: 'QueryClient' }, + { name: pkName, type: pkTsType }, + ]; + + if (hasRelationships && useCentralizedKeys) { + prefetchParams.push({ name: 'scope', type: scopeTypeName, hasQuestionToken: true }); + } + + prefetchParams.push({ name: 'options', type: 'ExecuteOptions', hasQuestionToken: true }); + + // Prefetch body + let prefetchBody: string; + if (hasRelationships && useCentralizedKeys) { + prefetchBody = `await queryClient.prefetchQuery({ + queryKey: ${keysName}.detail(${pkName}, scope), + queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( + ${queryName}QueryDocument, + { ${pkName} }, + options + ), + });`; + } else if (useCentralizedKeys) { + prefetchBody = `await queryClient.prefetchQuery({ + queryKey: ${keysName}.detail(${pkName}), + queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( + ${queryName}QueryDocument, + { ${pkName} }, + options + ), + });`; + } else { + prefetchBody = `await queryClient.prefetchQuery({ queryKey: ${queryName}QueryKey(${pkName}), queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( ${queryName}QueryDocument, { ${pkName} }, options ), - });`, + });`; + } + + sourceFile.addFunction({ + name: `prefetch${ucFirst(singularName)}Query`, + isExported: true, + isAsync: true, + parameters: prefetchParams, + returnType: 'Promise', + statements: prefetchBody, docs: [ { description: `Prefetch a single ${typeName} for SSR or cache warming diff --git a/graphql/codegen/src/cli/codegen/query-keys.ts b/graphql/codegen/src/cli/codegen/query-keys.ts new file mode 100644 index 000000000..fc10a7348 --- /dev/null +++ b/graphql/codegen/src/cli/codegen/query-keys.ts @@ -0,0 +1,375 @@ +/** + * Query key factory generator + * + * Generates centralized query keys following the lukemorales query-key-factory pattern. + * Supports hierarchical scoped keys for parent-child entity relationships. + * + * @see https://tanstack.com/query/docs/framework/react/community/lukemorales-query-key-factory + */ +import type { CleanTable, CleanOperation } from '../../types/schema'; +import type { ResolvedQueryKeyConfig, EntityRelationship } from '../../types/config'; +import { getTableNames, getGeneratedFileHeader, ucFirst, lcFirst } from './utils'; + +export interface QueryKeyGeneratorOptions { + tables: CleanTable[]; + customQueries: CleanOperation[]; + config: ResolvedQueryKeyConfig; +} + +export interface GeneratedQueryKeysFile { + fileName: string; + content: string; +} + +/** + * Get all ancestor entities for a given entity based on relationships + */ +function getAncestors( + entityName: string, + relationships: Record +): string[] { + const relationship = relationships[entityName.toLowerCase()]; + if (!relationship) return []; + + // Use explicit ancestors if defined, otherwise traverse parent chain + if (relationship.ancestors && relationship.ancestors.length > 0) { + return relationship.ancestors; + } + + // Build ancestor chain by following parent relationships + const ancestors: string[] = []; + let current = relationship.parent; + while (current) { + ancestors.push(current); + const parentRel = relationships[current.toLowerCase()]; + current = parentRel?.parent ?? null; + } + return ancestors; +} + +/** + * Generate scope type for an entity + */ +function generateScopeType( + entityName: string, + relationships: Record +): { typeName: string; typeDefinition: string } | null { + const relationship = relationships[entityName.toLowerCase()]; + if (!relationship) return null; + + const ancestors = getAncestors(entityName, relationships); + const allParents = [relationship.parent, ...ancestors]; + + const typeName = `${ucFirst(entityName)}Scope`; + const fields = allParents.map((parent) => { + const rel = relationships[entityName.toLowerCase()]; + // Find the foreign key for this parent + let fkField = `${lcFirst(parent)}Id`; + if (rel && rel.parent === parent) { + fkField = rel.foreignKey; + } else { + // Check if any ancestor has a direct relationship + const directRel = Object.entries(relationships).find( + ([, r]) => r.parent === parent + ); + if (directRel) { + fkField = directRel[1].foreignKey; + } + } + return `${fkField}?: string`; + }); + + const typeDefinition = `export type ${typeName} = { ${fields.join('; ')} };`; + return { typeName, typeDefinition }; +} + +/** + * Generate query keys for a single table entity + */ +function generateEntityKeys( + table: CleanTable, + relationships: Record, + generateScopedKeys: boolean +): string { + const { typeName, singularName } = getTableNames(table); + const entityKey = typeName.toLowerCase(); + const keysName = `${lcFirst(typeName)}Keys`; + + const relationship = relationships[entityKey]; + const hasRelationship = !!relationship && generateScopedKeys; + + const lines: string[] = []; + + lines.push(`export const ${keysName} = {`); + lines.push(` /** All ${singularName} queries */`); + lines.push(` all: ['${entityKey}'] as const,`); + + if (hasRelationship) { + // Generate scope factories for parent relationships + const ancestors = getAncestors(typeName, relationships); + const allParents = [relationship.parent, ...ancestors]; + + // Add scope factories for each parent level + for (const parent of allParents) { + const parentUpper = ucFirst(parent); + const parentLower = lcFirst(parent); + let fkField = `${parentLower}Id`; + + // Try to find the correct foreign key + if (relationship.parent === parent) { + fkField = relationship.foreignKey; + } + + lines.push(``); + lines.push(` /** ${typeName} queries scoped to a specific ${parentLower} */`); + lines.push(` by${parentUpper}: (${fkField}: string) =>`); + lines.push(` ['${entityKey}', { ${fkField} }] as const,`); + } + + // Scoped helper function + const scopeTypeName = `${typeName}Scope`; + lines.push(``); + lines.push(` /** Get scope-aware base key */`); + lines.push(` scoped: (scope?: ${scopeTypeName}) => {`); + + // Generate scope resolution logic (most specific first) + const scopeChecks: string[] = []; + if (relationship.parent) { + scopeChecks.push( + ` if (scope?.${relationship.foreignKey}) return ${keysName}.by${ucFirst(relationship.parent)}(scope.${relationship.foreignKey});` + ); + } + for (const ancestor of ancestors) { + const ancestorLower = lcFirst(ancestor); + const fkField = `${ancestorLower}Id`; + scopeChecks.push( + ` if (scope?.${fkField}) return ${keysName}.by${ucFirst(ancestor)}(scope.${fkField});` + ); + } + + lines.push(...scopeChecks); + lines.push(` return ${keysName}.all;`); + lines.push(` },`); + + // Lists with scope + lines.push(``); + lines.push(` /** List query keys (optionally scoped) */`); + lines.push(` lists: (scope?: ${scopeTypeName}) =>`); + lines.push(` [...${keysName}.scoped(scope), 'list'] as const,`); + + lines.push(``); + lines.push(` /** List query key with variables */`); + lines.push(` list: (variables?: object, scope?: ${scopeTypeName}) =>`); + lines.push(` [...${keysName}.lists(scope), variables] as const,`); + + // Details with scope + lines.push(``); + lines.push(` /** Detail query keys (optionally scoped) */`); + lines.push(` details: (scope?: ${scopeTypeName}) =>`); + lines.push(` [...${keysName}.scoped(scope), 'detail'] as const,`); + + lines.push(``); + lines.push(` /** Detail query key for specific item */`); + lines.push(` detail: (id: string | number, scope?: ${scopeTypeName}) =>`); + lines.push(` [...${keysName}.details(scope), id] as const,`); + } else { + // Simple non-scoped keys + lines.push(``); + lines.push(` /** List query keys */`); + lines.push(` lists: () => [...${keysName}.all, 'list'] as const,`); + + lines.push(``); + lines.push(` /** List query key with variables */`); + lines.push(` list: (variables?: object) =>`); + lines.push(` [...${keysName}.lists(), variables] as const,`); + + lines.push(``); + lines.push(` /** Detail query keys */`); + lines.push(` details: () => [...${keysName}.all, 'detail'] as const,`); + + lines.push(``); + lines.push(` /** Detail query key for specific item */`); + lines.push(` detail: (id: string | number) =>`); + lines.push(` [...${keysName}.details(), id] as const,`); + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate query keys for custom operations (non-table queries) + */ +function generateCustomQueryKeys(operations: CleanOperation[]): string { + if (operations.length === 0) return ''; + + const lines: string[] = []; + lines.push(`export const customQueryKeys = {`); + + for (const op of operations) { + const hasArgs = op.args.length > 0; + const hasRequiredArgs = op.args.some( + (arg) => arg.type.kind === 'NON_NULL' + ); + + if (hasArgs) { + const argsType = hasRequiredArgs ? 'object' : 'object | undefined'; + lines.push(` /** Query key for ${op.name} */`); + lines.push(` ${op.name}: (variables${hasRequiredArgs ? '' : '?'}: ${argsType}) =>`); + lines.push(` ['${op.name}', variables] as const,`); + } else { + lines.push(` /** Query key for ${op.name} */`); + lines.push(` ${op.name}: () => ['${op.name}'] as const,`); + } + lines.push(``); + } + + // Remove trailing empty line + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate the unified query keys store object + */ +function generateUnifiedStore( + tables: CleanTable[], + hasCustomQueries: boolean +): string { + const lines: string[] = []; + + lines.push(`/**`); + lines.push(` * Unified query key store`); + lines.push(` *`); + lines.push(` * Use this for type-safe query key access across your application.`); + lines.push(` *`); + lines.push(` * @example`); + lines.push(` * \`\`\`ts`); + lines.push(` * // Invalidate all user queries`); + lines.push(` * queryClient.invalidateQueries({ queryKey: queryKeys.user.all });`); + lines.push(` *`); + lines.push(` * // Invalidate user list queries`); + lines.push(` * queryClient.invalidateQueries({ queryKey: queryKeys.user.lists() });`); + lines.push(` *`); + lines.push(` * // Invalidate specific user`); + lines.push(` * queryClient.invalidateQueries({ queryKey: queryKeys.user.detail(userId) });`); + lines.push(` * \`\`\``); + lines.push(` */`); + lines.push(`export const queryKeys = {`); + + for (const table of tables) { + const { typeName } = getTableNames(table); + const keysName = `${lcFirst(typeName)}Keys`; + lines.push(` ${lcFirst(typeName)}: ${keysName},`); + } + + if (hasCustomQueries) { + lines.push(` custom: customQueryKeys,`); + } + + lines.push(`} as const;`); + + return lines.join('\n'); +} + +/** + * Generate the complete query-keys.ts file + */ +export function generateQueryKeysFile( + options: QueryKeyGeneratorOptions +): GeneratedQueryKeysFile { + const { tables, customQueries, config } = options; + const { relationships, generateScopedKeys } = config; + + const lines: string[] = []; + + // File header + lines.push(getGeneratedFileHeader('Centralized query key factory')); + lines.push(``); + + // Imports + lines.push(`// ============================================================================`); + lines.push(`// This file provides a centralized, type-safe query key factory following`); + lines.push(`// the lukemorales query-key-factory pattern for React Query.`); + lines.push(`//`); + lines.push(`// Benefits:`); + lines.push(`// - Single source of truth for all query keys`); + lines.push(`// - Type-safe key access with autocomplete`); + lines.push(`// - Hierarchical invalidation (invalidate all 'user.*' queries)`); + lines.push(`// - Scoped keys for parent-child relationships`); + lines.push(`// ============================================================================`); + lines.push(``); + + // Generate scope types for entities with relationships + if (generateScopedKeys && Object.keys(relationships).length > 0) { + lines.push(`// ============================================================================`); + lines.push(`// Scope Types`); + lines.push(`// ============================================================================`); + lines.push(``); + + const generatedScopes = new Set(); + for (const table of tables) { + const { typeName } = getTableNames(table); + const scopeType = generateScopeType(typeName, relationships); + if (scopeType && !generatedScopes.has(scopeType.typeName)) { + lines.push(scopeType.typeDefinition); + generatedScopes.add(scopeType.typeName); + } + } + + if (generatedScopes.size > 0) { + lines.push(``); + } + } + + // Generate entity keys + lines.push(`// ============================================================================`); + lines.push(`// Entity Query Keys`); + lines.push(`// ============================================================================`); + lines.push(``); + + for (let i = 0; i < tables.length; i++) { + const table = tables[i]; + lines.push(generateEntityKeys(table, relationships, generateScopedKeys)); + if (i < tables.length - 1) { + lines.push(``); + } + } + + // Generate custom query keys + const queryOperations = customQueries.filter((op) => op.kind === 'query'); + if (queryOperations.length > 0) { + lines.push(``); + lines.push(`// ============================================================================`); + lines.push(`// Custom Query Keys`); + lines.push(`// ============================================================================`); + lines.push(``); + lines.push(generateCustomQueryKeys(queryOperations)); + } + + // Generate unified store + lines.push(``); + lines.push(`// ============================================================================`); + lines.push(`// Unified Query Key Store`); + lines.push(`// ============================================================================`); + lines.push(``); + lines.push(generateUnifiedStore(tables, queryOperations.length > 0)); + + // Export type for query key scope + lines.push(``); + lines.push(`/** Type representing all available query key scopes */`); + lines.push(`export type QueryKeyScope = keyof typeof queryKeys;`); + + lines.push(``); + + return { + fileName: 'query-keys.ts', + content: lines.join('\n'), + }; +} diff --git a/graphql/codegen/src/cli/codegen/scalars.ts b/graphql/codegen/src/cli/codegen/scalars.ts index 36f59bed9..f659cb095 100644 --- a/graphql/codegen/src/cli/codegen/scalars.ts +++ b/graphql/codegen/src/cli/codegen/scalars.ts @@ -23,6 +23,7 @@ export const SCALAR_TS_MAP: Record = { // Geometry types GeoJSON: 'unknown', Geometry: 'unknown', + GeometryPoint: 'unknown', Point: 'unknown', // Interval diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 412c39af0..68da99efb 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -2,6 +2,67 @@ * SDK Configuration types */ +/** + * Entity relationship definition for cascade invalidation + */ +export interface EntityRelationship { + /** Parent entity name (e.g., 'database' for a table) */ + parent: string; + /** Foreign key field name that references the parent (e.g., 'databaseId') */ + foreignKey: string; + /** Optional transitive ancestors for deep invalidation (e.g., ['database', 'organization']) */ + ancestors?: string[]; +} + +/** + * Query key generation configuration + */ +export interface QueryKeyConfig { + /** + * Key structure style + * - 'flat': Simple ['entity', 'scope', data] structure + * - 'hierarchical': Nested factory pattern with scope support (lukemorales-style) + * @default 'hierarchical' + */ + style?: 'flat' | 'hierarchical'; + + /** + * Define entity relationships for cascade invalidation and scoped keys + * Key: child entity name (lowercase), Value: relationship definition + * + * @example + * ```ts + * relationships: { + * database: { parent: 'organization', foreignKey: 'organizationId' }, + * table: { parent: 'database', foreignKey: 'databaseId', ancestors: ['organization'] }, + * field: { parent: 'table', foreignKey: 'tableId', ancestors: ['database', 'organization'] }, + * } + * ``` + */ + relationships?: Record; + + /** + * Generate scope-aware query keys for entities with relationships + * When true, keys include optional scope parameters for hierarchical invalidation + * @default true + */ + generateScopedKeys?: boolean; + + /** + * Generate cascade invalidation helpers + * Creates helpers that invalidate parent entities and all their children + * @default true + */ + generateCascadeHelpers?: boolean; + + /** + * Generate mutation keys for tracking in-flight mutations + * Useful for optimistic updates and mutation deduplication + * @default true + */ + generateMutationKeys?: boolean; +} + /** * Main configuration for graphql-codegen */ @@ -127,6 +188,12 @@ export interface GraphQLSDKConfig { enabled?: boolean; }; + /** + * Query key generation configuration + * Controls how query keys are structured for cache management + */ + queryKeys?: QueryKeyConfig; + /** * Watch mode configuration (dev-only feature) * When enabled via CLI --watch flag, the CLI will poll the endpoint for schema changes @@ -177,6 +244,17 @@ export interface ResolvedWatchConfig { 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 */ @@ -223,6 +301,7 @@ export interface ResolvedConfig { reactQuery: { enabled: boolean; }; + queryKeys: ResolvedQueryKeyConfig; watch: ResolvedWatchConfig; } @@ -236,6 +315,17 @@ export const DEFAULT_WATCH_CONFIG: ResolvedWatchConfig = { clearScreen: true, }; +/** + * Default query key configuration values + */ +export const DEFAULT_QUERY_KEY_CONFIG: ResolvedQueryKeyConfig = { + style: 'hierarchical', + relationships: {}, + generateScopedKeys: true, + generateCascadeHelpers: true, + generateMutationKeys: true, +}; + /** * Default configuration values */ @@ -271,6 +361,7 @@ export const DEFAULT_CONFIG: Omit = { reactQuery: { enabled: true, // React Query hooks enabled by default for generate command }, + queryKeys: DEFAULT_QUERY_KEY_CONFIG, watch: DEFAULT_WATCH_CONFIG, }; @@ -336,6 +427,13 @@ export function resolveConfig(config: GraphQLSDKConfig): ResolvedConfig { reactQuery: { enabled: config.reactQuery?.enabled ?? DEFAULT_CONFIG.reactQuery.enabled, }, + queryKeys: { + style: config.queryKeys?.style ?? DEFAULT_QUERY_KEY_CONFIG.style, + relationships: config.queryKeys?.relationships ?? DEFAULT_QUERY_KEY_CONFIG.relationships, + generateScopedKeys: config.queryKeys?.generateScopedKeys ?? DEFAULT_QUERY_KEY_CONFIG.generateScopedKeys, + generateCascadeHelpers: config.queryKeys?.generateCascadeHelpers ?? DEFAULT_QUERY_KEY_CONFIG.generateCascadeHelpers, + generateMutationKeys: config.queryKeys?.generateMutationKeys ?? DEFAULT_QUERY_KEY_CONFIG.generateMutationKeys, + }, watch: { pollInterval: config.watch?.pollInterval ?? DEFAULT_WATCH_CONFIG.pollInterval,