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,