diff --git a/graphql/codegen/SPEC.md b/graphql/codegen/SPEC.md new file mode 100644 index 000000000..31e1a8315 --- /dev/null +++ b/graphql/codegen/SPEC.md @@ -0,0 +1,521 @@ +# GraphQL Codegen ORM - Technical Specification + +This document specifies the design and behavior of the ORM-style GraphQL SDK generator. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Type System Design](#type-system-design) +4. [Select Type Safety](#select-type-safety) +5. [Generated Code Structure](#generated-code-structure) +6. [API Reference](#api-reference) +7. [Testing Requirements](#testing-requirements) + +--- + +## Overview + +The GraphQL Codegen ORM generates a Prisma-like TypeScript SDK from a GraphQL schema. It provides: + +- **Type-safe queries and mutations** with full IntelliSense support +- **Select-based field selection** that infers return types from the select object +- **Fluent API** with `.execute()`, `.unwrap()`, and `.unwrapOr()` methods +- **Query inspection** via `.toGraphQL()` for debugging + +### Design Goals + +1. **Type Safety**: Compile-time validation of all operations +2. **Developer Experience**: Autocomplete, type inference, clear error messages +3. **Zero Runtime Overhead**: All type checking happens at compile time +4. **GraphQL Fidelity**: Generated queries match the schema exactly + +--- + +## Architecture + +### Code Generation Pipeline + +``` +GraphQL Schema (endpoint or .graphql file) + │ + ▼ +┌─────────────────────────────────────┐ +│ Schema Introspection │ +│ - Parse types, fields, relations │ +│ - Identify tables vs custom ops │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Code Generation │ +│ - input-types.ts (types + filters) │ +│ - select-types.ts (utilities) │ +│ - models/*.ts (table models) │ +│ - query/index.ts (custom queries) │ +│ - mutation/index.ts (custom muts) │ +│ - index.ts (createClient factory) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Output Formatting (oxfmt) │ +└─────────────────────────────────────┘ +``` + +### Key Components + +| Component | File | Purpose | +|-----------|------|---------| +| Schema Source | `src/cli/introspect/source.ts` | Fetch schema from endpoint or file | +| Table Inference | `src/cli/introspect/infer-tables.ts` | Identify tables from schema | +| Input Types Generator | `src/cli/codegen/orm/input-types-generator.ts` | Generate TypeScript types | +| Model Generator | `src/cli/codegen/orm/model-generator.ts` | Generate table model classes | +| Custom Ops Generator | `src/cli/codegen/orm/custom-ops-generator.ts` | Generate query/mutation operations | +| Client Generator | `src/cli/codegen/orm/client-generator.ts` | Generate runtime utilities | + +--- + +## Type System Design + +### Entity Types + +For each GraphQL type (table), we generate: + +```typescript +// The entity interface (matches GraphQL type) +export interface User { + id: string; + username: string | null; + displayName: string | null; + createdAt: string | null; + // ... all fields +} + +// Entity with relations (includes related entities) +export interface UserWithRelations extends User { + posts?: PostConnection; + profile?: Profile | null; + // ... all relations +} +``` + +### Select Types + +For each entity, we generate a select type that defines what fields can be selected: + +```typescript +export type UserSelect = { + id?: boolean; + username?: boolean; + displayName?: boolean; + createdAt?: boolean; + // Relation fields allow nested select + posts?: boolean | { + select?: PostSelect; + first?: number; + // ... pagination args + }; + profile?: boolean | { + select?: ProfileSelect; + }; +}; +``` + +### Filter Types + +PostGraphile filter types for `where` clauses: + +```typescript +export type UserFilter = { + id?: UUIDFilter; + username?: StringFilter; + createdAt?: DatetimeFilter; + // Logical operators + and?: UserFilter[]; + or?: UserFilter[]; + not?: UserFilter; +}; +``` + +### Payload Types (for Mutations) + +Custom mutations return payload types: + +```typescript +export interface SignInPayload { + clientMutationId?: string | null; + apiToken?: ApiToken | null; +} + +export type SignInPayloadSelect = { + clientMutationId?: boolean; + apiToken?: boolean | { + select?: ApiTokenSelect; + }; +}; +``` + +--- + +## Select Type Safety + +### Requirements + +The select system MUST enforce these invariants: + +1. **Only valid fields**: Selecting a field that doesn't exist in the schema MUST produce a TypeScript error +2. **Nested validation**: Invalid fields in nested selects MUST also produce errors +3. **Mixed field handling**: Invalid fields MUST be caught even when mixed with valid fields +4. **Permissive for valid cases**: Empty selects, boolean shorthand, and omitting select entirely MUST work + +### The Problem: TypeScript's Excess Property Checking + +TypeScript has a quirk where excess property checking behaves differently depending on context: + +```typescript +type UserSelect = { id?: boolean; name?: boolean; }; + +// ERROR: TypeScript catches this (only invalid field) +const a: UserSelect = { invalid: true }; + +// NO ERROR: TypeScript allows this (valid + invalid mixed) +function fn(s: T) {} +fn({ id: true, invalid: true }); // Compiles! +``` + +This is because: +1. Direct assignment uses "freshness" checking +2. Generic type parameters use structural subtyping +3. An object with extra optional properties is still a valid subtype + +### Solution: DeepExact Utility Type + +We use a recursive type that explicitly rejects excess keys: + +```typescript +/** + * Recursively validates select objects, rejecting unknown keys. + * Returns `never` if any excess keys are found at any nesting level. + */ +export type DeepExact = T extends Shape + ? Exclude extends never + ? { + [K in keyof T]: K extends keyof Shape + ? T[K] extends { select: infer NS } + ? Shape[K] extends { select?: infer ShapeNS } + ? { select: DeepExact> } + : T[K] + : T[K] + : never + } + : never // Has excess keys at this level + : never; // Doesn't extend Shape at all +``` + +**How it works:** + +1. `T extends Shape` - Basic structural check +2. `Exclude extends never` - Check for excess keys +3. If excess keys exist, return `never` (causes type error) +4. For nested `{ select: ... }` objects, recursively apply validation + +### Application in Generated Code + +The `DeepExact` type is applied in function signatures: + +```typescript +// Table model methods +findMany( + args?: FindManyArgs, UserFilter, UsersOrderBy> +): QueryBuilder<...> + +// Custom mutations +signIn( + args: SignInVariables, + options?: { select?: DeepExact } +): QueryBuilder<...> +``` + +### Expected Behavior + +```typescript +// MUST ERROR: Invalid nested field +db.mutation.signIn( + { input: { email: 'e', password: 'p' } }, + { select: { apiToken: { select: { refreshToken: true } } } } + // ~~~~~~~~~~~~ Error! +); + +// MUST ERROR: Invalid field mixed with valid +db.user.findMany({ + select: { id: true, invalid: true } + // ~~~~~~~ Error! +}); + +// MUST WORK: Valid fields only +db.user.findMany({ + select: { id: true, username: true } +}); + +// MUST WORK: Empty select (returns all fields) +db.user.findMany({ select: {} }); + +// MUST WORK: No select parameter +db.user.findMany({ where: { id: { equalTo: '123' } } }); + +// MUST WORK: Boolean shorthand for relations +db.mutation.signIn( + { input: { email: 'e', password: 'p' } }, + { select: { apiToken: true } } +); +``` + +--- + +## Generated Code Structure + +### Output Directory Layout + +``` +generated-orm/ +├── index.ts # createClient factory, re-exports +├── client.ts # OrmClient class, QueryResult types +├── query-builder.ts # QueryBuilder class, document builders +├── select-types.ts # Type utilities (DeepExact, InferSelectResult, etc.) +├── input-types.ts # All TypeScript types (entities, filters, inputs) +├── types.ts # Scalar type mappings +├── models/ +│ ├── user.ts # UserModel class +│ ├── post.ts # PostModel class +│ └── ... # One file per table +├── query/ +│ └── index.ts # Custom query operations +└── mutation/ + └── index.ts # Custom mutation operations +``` + +### createClient Factory + +```typescript +export function createClient(config: OrmClientConfig) { + const client = new OrmClient(config); + return { + // Table models + user: new UserModel(client), + post: new PostModel(client), + // ... + + // Custom operations + query: createQueryOperations(client), + mutation: createMutationOperations(client), + }; +} +``` + +### Table Model Class + +```typescript +export class UserModel { + constructor(private client: OrmClient) {} + + findMany( + args?: FindManyArgs, UserFilter, UsersOrderBy> + ): QueryBuilder<{ users: ConnectionResult> }> { + // Build GraphQL document and return QueryBuilder + } + + findFirst( + args?: FindFirstArgs, UserFilter> + ): QueryBuilder<{ users: { nodes: InferSelectResult[] } }> { + // ... + } + + create( + args: CreateArgs, CreateUserInput['user']> + ): QueryBuilder<{ createUser: { user: InferSelectResult } }> { + // ... + } + + update( + args: UpdateArgs, UserFilter, UserPatch> + ): QueryBuilder<{ updateUser: { user: InferSelectResult } }> { + // ... + } + + delete(args: DeleteArgs): QueryBuilder<{ deleteUser: { deletedUserId: string } }> { + // ... + } +} +``` + +### QueryBuilder Class + +```typescript +export class QueryBuilder { + constructor(private config: QueryBuilderConfig) {} + + // Get the generated GraphQL query string + toGraphQL(): string { ... } + + // Execute and return discriminated union + async execute(): Promise> { ... } + + // Execute and throw on error + async unwrap(): Promise { ... } + + // Execute with fallback on error + async unwrapOr(defaultValue: D): Promise { ... } +} +``` + +### InferSelectResult Type + +Maps the select object to the result type: + +```typescript +export type InferSelectResult = TSelect extends undefined + ? TEntity + : { + [K in keyof TSelect as TSelect[K] extends false | undefined ? never : K]: + TSelect[K] extends true + ? K extends keyof TEntity + ? TEntity[K] + : never + : TSelect[K] extends { select: infer NestedSelect } + ? K extends keyof TEntity + ? InferSelectResult, NestedSelect> + : never + : K extends keyof TEntity + ? TEntity[K] + : never; + }; +``` + +--- + +## API Reference + +### Client Configuration + +```typescript +interface OrmClientConfig { + endpoint: string; + headers?: Record; +} +``` + +### QueryResult (Discriminated Union) + +```typescript +type QueryResult = + | { ok: true; data: T; errors: undefined } + | { ok: false; data: null; errors: GraphQLError[] }; +``` + +### FindManyArgs + +```typescript +interface FindManyArgs { + select?: TSelect; + where?: TWhere; + orderBy?: TOrderBy[]; + first?: number; + last?: number; + after?: string; + before?: string; + offset?: number; +} +``` + +### ConnectionResult + +```typescript +interface ConnectionResult { + nodes: T[]; + totalCount: number; + pageInfo: PageInfo; +} + +interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; +} +``` + +--- + +## Testing Requirements + +### Type-Level Tests + +The following scenarios MUST produce TypeScript compile errors: + +1. **Invalid field in select** (top-level) +2. **Invalid field in nested select** +3. **Invalid field mixed with valid fields** +4. **Invalid field in relation select** +5. **Typo in field name** + +### Type-Level Tests (Must Compile) + +The following scenarios MUST compile successfully: + +1. **All valid fields** +2. **Subset of valid fields** +3. **Empty select object** +4. **No select parameter** +5. **Boolean shorthand for relations** +6. **Nested select with valid fields** +7. **Deep nesting (3+ levels)** + +### Runtime Tests + +1. **Execute returns correct data structure** +2. **Error handling works (execute, unwrap, unwrapOr)** +3. **Generated GraphQL matches expected format** +4. **Variables are correctly passed** +5. **Pagination parameters work** +6. **Filters work correctly** + +### Integration Tests + +1. **Full flow: createClient -> query -> execute** +2. **Authentication flow with token refresh** +3. **Complex nested queries** +4. **Mutation with optimistic updates** + +--- + +## Appendix: TypeScript Behavior Notes + +### Why `const` Type Parameters? + +The `const` modifier on type parameters (TypeScript 5.0+) enables: + +1. **Literal type inference**: `{ id: true }` is inferred as `{ id: true }` not `{ id: boolean }` +2. **Precise select tracking**: We know exactly which fields were selected +3. **Accurate return types**: The result type only includes selected fields + +### Why Structural Typing Allows Excess Properties + +TypeScript uses structural typing, meaning a type is compatible if it has at least the required properties. For optional properties, an object with extra properties is still a valid subtype: + +```typescript +type A = { x?: number }; +type B = { x?: number; y: string }; +// B extends A is true, because B has all of A's properties +``` + +This is why we need `DeepExact` to explicitly check for and reject excess keys. + +### Error Message Quality + +When `DeepExact` rejects a select object, TypeScript produces errors like: + +- `Type 'true' is not assignable to type 'never'` +- `Object literal may only specify known properties` + +These are not the most intuitive, but they do indicate the location of the invalid field. diff --git a/graphql/codegen/examples/orm-sdk.ts b/graphql/codegen/examples/orm-sdk-test.ts similarity index 97% rename from graphql/codegen/examples/orm-sdk.ts rename to graphql/codegen/examples/orm-sdk-test.ts index ac88218fd..ef163b489 100644 --- a/graphql/codegen/examples/orm-sdk.ts +++ b/graphql/codegen/examples/orm-sdk-test.ts @@ -20,7 +20,14 @@ async function main() { const signInResult = await db.mutation .signIn( { input: { email: 'admin@gmail.com', password: 'password1111!@#$' } }, - { select: { apiToken: { select: { accessToken: true } } } } + { select: { apiToken: { select: { + accessToken: true, + accessTokenExpiresAt: true, + id: true, + userId: true, + createdAt: true, + updatedAt: true, + }}}} ) .execute(); @@ -67,7 +74,7 @@ async function main() { const stringFilters = await db.user .findMany({ select: { id: true, username: true }, - first: 5, + first: 10, where: { username: { includesInsensitive: 'seed' } }, }) .execute(); diff --git a/graphql/codegen/examples/react-hooks-test.tsx b/graphql/codegen/examples/react-hooks-sdk-test.tsx similarity index 100% rename from graphql/codegen/examples/react-hooks-test.tsx rename to graphql/codegen/examples/react-hooks-sdk-test.tsx diff --git a/graphql/codegen/examples/react-query-sdk.ts b/graphql/codegen/examples/react-query-sdk.ts deleted file mode 100644 index 4c1d265d8..000000000 --- a/graphql/codegen/examples/react-query-sdk.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * React Query SDK Example - * Run: pnpm exec tsx examples/react-query-sdk.ts - */ -import { - configure, - setHeader, - execute, - executeWithErrors, - GraphQLClientError, -} from './output/generated-sdk/client'; -import { - usersQueryDocument, - type UsersQueryResult, - type UsersQueryVariables, -} from './output/generated-sdk/queries/useUsersQuery'; -import { - userQueryDocument, - type UserQueryResult, - type UserQueryVariables, -} from './output/generated-sdk/queries/useUserQuery'; -import { - databasesQueryDocument, - type DatabasesQueryResult, - type DatabasesQueryVariables, -} from './output/generated-sdk/queries/useDatabasesQuery'; -import { - tablesQueryDocument, - type TablesQueryResult, - type TablesQueryVariables, -} from './output/generated-sdk/queries/useTablesQuery'; -import { - currentUserQueryDocument, - type CurrentUserQueryResult, -} from './output/generated-sdk/queries/useCurrentUserQuery'; -import { - userByUsernameQueryDocument, - type UserByUsernameQueryResult, - type UserByUsernameQueryVariables, -} from './output/generated-sdk/queries/useUserByUsernameQuery'; -import { - signInMutationDocument, - type SignInMutationResult, - type SignInMutationVariables, -} from './output/generated-sdk/mutations/useSignInMutation'; -import type { - UserFilter, - TableFilter, -} from './output/generated-sdk/schema-types'; - -const ENDPOINT = 'http://api.localhost:3000/graphql'; -const section = (title: string) => - console.log(`\n${'─'.repeat(50)}\n${title}\n${'─'.repeat(50)}`); - -async function main() { - console.log('React Query SDK Demo\n'); - - // ───────────────────────────────────────────────────────────────────────────── - // 1. Configuration - // ───────────────────────────────────────────────────────────────────────────── - section('1. Configuration'); - configure({ - endpoint: ENDPOINT, - headers: { 'Content-Type': 'application/json' }, - }); - console.log('✓ Client configured'); - - // ───────────────────────────────────────────────────────────────────────────── - // 2. SignIn Mutation - // ───────────────────────────────────────────────────────────────────────────── - section('2. SignIn Mutation'); - try { - const signInResult = await execute< - SignInMutationResult, - SignInMutationVariables - >(signInMutationDocument, { - input: { email: 'admin@gmail.com', password: 'password1111!@#$' }, - }); - const token = signInResult.signIn?.apiToken?.accessToken; - if (token) { - // Use setHeader() to update auth without re-configuring - setHeader('Authorization', `Bearer ${token}`); - console.log('✓ Signed in, token:', token.slice(0, 30) + '...'); - } - } catch (e) { - if (e instanceof GraphQLClientError) - console.log('SignIn failed:', e.errors[0]?.message); - else throw e; - } - - // ───────────────────────────────────────────────────────────────────────────── - // 3. List Query with Pagination - // ───────────────────────────────────────────────────────────────────────────── - section('3. List Query with Pagination'); - const { data, errors } = await executeWithErrors< - UsersQueryResult, - UsersQueryVariables - >(usersQueryDocument, { first: 5, orderBy: ['USERNAME_ASC'] }); - console.log( - 'Users:', - data?.users?.nodes?.map((u) => u.username) - ); - console.log( - 'Total:', - data?.users?.totalCount, - '| HasNext:', - data?.users?.pageInfo.hasNextPage - ); - - // ───────────────────────────────────────────────────────────────────────────── - // 4. PostGraphile Filters - // ───────────────────────────────────────────────────────────────────────────── - section('4. PostGraphile Filters'); - - // String filter - const filter1: UserFilter = { username: { includesInsensitive: 'seed' } }; - const r1 = await executeWithErrors( - usersQueryDocument, - { first: 5, filter: filter1 } - ); - console.log( - 'includesInsensitive "seed":', - r1.data?.users?.nodes?.map((u) => u.username) - ); - - // AND/OR/NOT composition - const filter2: UserFilter = { - and: [ - { username: { isNull: false } }, - { or: [{ type: { equalTo: 0 } }, { type: { greaterThan: 5 } }] }, - ], - }; - const r2 = await executeWithErrors( - usersQueryDocument, - { first: 5, filter: filter2 } - ); - console.log( - 'AND/OR filter:', - r2.data?.users?.nodes?.map((u) => ({ u: u.username, t: u.type })) - ); - - // ───────────────────────────────────────────────────────────────────────────── - // 5. Single Item & Unique Constraint Queries - // ───────────────────────────────────────────────────────────────────────────── - section('5. Single Item & Unique Constraint Queries'); - const userId = data?.users?.nodes?.[0]?.id; - if (userId) { - const { data: userData } = await executeWithErrors< - UserQueryResult, - UserQueryVariables - >(userQueryDocument, { id: userId }); - console.log('User by ID:', userData?.user?.username); - } - - const { data: byUsername } = await executeWithErrors< - UserByUsernameQueryResult, - UserByUsernameQueryVariables - >(userByUsernameQueryDocument, { username: 'seeder' }); - console.log('User by username:', byUsername?.userByUsername?.displayName); - - // ───────────────────────────────────────────────────────────────────────────── - // 6. Custom Queries - // ───────────────────────────────────────────────────────────────────────────── - section('6. Custom Queries'); - const { data: currentUser } = await executeWithErrors( - currentUserQueryDocument - ); - console.log('Current user:', currentUser?.currentUser?.username); - - // ───────────────────────────────────────────────────────────────────────────── - // 7. Relation Queries (Foreign Key Filter) - // ───────────────────────────────────────────────────────────────────────────── - section('7. Relation Queries'); - const { data: dbData } = await executeWithErrors< - DatabasesQueryResult, - DatabasesQueryVariables - >(databasesQueryDocument, { first: 1 }); - const databaseId = dbData?.databases?.nodes?.[0]?.id; - if (databaseId) { - const tableFilter: TableFilter = { databaseId: { equalTo: databaseId } }; - const { data: tablesData } = await executeWithErrors< - TablesQueryResult, - TablesQueryVariables - >(tablesQueryDocument, { - first: 10, - filter: tableFilter, - orderBy: ['NAME_ASC'], - }); - console.log( - 'Tables in DB:', - tablesData?.tables?.nodes?.map((t) => t.name) - ); - } - - // ───────────────────────────────────────────────────────────────────────────── - // 8. Error Handling - // ───────────────────────────────────────────────────────────────────────────── - section('8. Error Handling'); - - // executeWithErrors - graceful, returns { data, errors } - const { data: d1, errors: e1 } = await executeWithErrors< - UserQueryResult, - UserQueryVariables - >(userQueryDocument, { id: '00000000-0000-0000-0000-000000000000' }); - console.log( - 'executeWithErrors:', - d1?.user ?? 'null', - '| errors:', - e1?.[0]?.message ?? 'none' - ); - - // execute - throws on error - try { - await execute( - signInMutationDocument, - { input: { email: 'invalid@x.com', password: 'wrong' } } - ); - } catch (e) { - if (e instanceof GraphQLClientError) - console.log('execute() caught:', e.message); - } - - console.log('\n✓ All demos completed!'); -} - -main().catch((e) => { - console.error('Failed:', e); - process.exit(1); -}); diff --git a/graphql/codegen/package.json b/graphql/codegen/package.json index 9bcef917b..c7701a746 100644 --- a/graphql/codegen/package.json +++ b/graphql/codegen/package.json @@ -39,6 +39,8 @@ "build:dev": "makage build --dev", "dev": "ts-node ./src/index.ts", "lint": "eslint . --fix", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "test": "jest --passWithNoTests", "test:watch": "jest --watch", "example:codegen:sdk": "tsx src/cli/index.ts generate --config examples/multi-target.config.ts", @@ -58,7 +60,7 @@ "graphql": "15.10.1", "inflekt": "^0.2.0", "jiti": "^2.6.1", - "prettier": "^3.7.4" + "oxfmt": "^0.13.0" }, "peerDependencies": { "@tanstack/react-query": "^5.0.0", diff --git a/graphql/codegen/src/cli/codegen/orm/client-generator.ts b/graphql/codegen/src/cli/codegen/orm/client-generator.ts index d38a1a7dd..56b10a300 100644 --- a/graphql/codegen/src/cli/codegen/orm/client-generator.ts +++ b/graphql/codegen/src/cli/codegen/orm/client-generator.ts @@ -7,7 +7,6 @@ import type { CleanTable } from '../../../types/schema'; import * as t from '@babel/types'; import { generateCode, commentBlock } from '../babel-ast'; import { getTableNames, lcFirst, getGeneratedFileHeader } from '../utils'; - export interface GeneratedClientFile { fileName: string; content: string; @@ -150,6 +149,14 @@ export function generateQueryBuilderFile(): GeneratedClientFile { * DO NOT EDIT - changes will be overwritten */ +import * as t from 'gql-ast'; +import { parseType, print } from 'graphql'; +import type { + ArgumentNode, + FieldNode, + VariableDefinitionNode, + EnumValueNode, +} from 'graphql'; import { OrmClient, QueryResult, GraphQLRequestError } from './client'; export interface QueryBuilderConfig { @@ -223,19 +230,25 @@ export class QueryBuilder { } // ============================================================================ -// Document Builders +// Selection Builders // ============================================================================ -export function buildSelections(select: T): string { - if (!select) return ''; +export function buildSelections( + select: Record | undefined +): FieldNode[] { + if (!select) { + return []; + } - const fields: string[] = []; + const fields: FieldNode[] = []; for (const [key, value] of Object.entries(select)) { - if (value === false || value === undefined) continue; + if (value === false || value === undefined) { + continue; + } if (value === true) { - fields.push(key); + fields.push(t.field({ name: key })); continue; } @@ -245,39 +258,53 @@ export function buildSelections(select: T): string { first?: number; filter?: Record; orderBy?: string[]; - // New: connection flag to differentiate connection types from regular objects connection?: boolean; }; if (nested.select) { const nestedSelections = buildSelections(nested.select); - - // Check if this is a connection type (has pagination args or explicit connection flag) - const isConnection = nested.connection === true || nested.first !== undefined || nested.filter !== undefined; - + const isConnection = + nested.connection === true || + nested.first !== undefined || + nested.filter !== undefined; + const args = buildArgs([ + buildOptionalArg('first', nested.first), + nested.filter + ? t.argument({ name: 'filter', value: buildValueAst(nested.filter) }) + : null, + buildEnumListArg('orderBy', nested.orderBy), + ]); + if (isConnection) { - // Connection type - wrap in nodes/totalCount/pageInfo - const args: string[] = []; - if (nested.first !== undefined) args.push(\`first: \${nested.first}\`); - if (nested.orderBy?.length) args.push(\`orderBy: [\${nested.orderBy.join(', ')}]\`); - const argsStr = args.length > 0 ? \`(\${args.join(', ')})\` : ''; - - fields.push(\`\${key}\${argsStr} { - nodes { \${nestedSelections} } - totalCount - pageInfo { hasNextPage hasPreviousPage startCursor endCursor } - }\`); + fields.push( + t.field({ + name: key, + args, + selectionSet: t.selectionSet({ + selections: buildConnectionSelections(nestedSelections), + }), + }) + ); } else { - // Regular nested object - just wrap in braces - fields.push(\`\${key} { \${nestedSelections} }\`); + fields.push( + t.field({ + name: key, + args, + selectionSet: t.selectionSet({ selections: nestedSelections }), + }) + ); } } } } - return fields.join('\\n '); + return fields; } +// ============================================================================ +// Document Builders +// ============================================================================ + export function buildFindManyDocument( operationName: string, queryField: string, @@ -294,60 +321,44 @@ export function buildFindManyDocument( filterTypeName: string, orderByTypeName: string ): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; - const varDefs: string[] = []; - const queryArgs: string[] = []; + const variableDefinitions: VariableDefinitionNode[] = []; + const queryArgs: ArgumentNode[] = []; const variables: Record = {}; - if (args.where) { - varDefs.push(\`$where: \${filterTypeName}\`); - queryArgs.push('filter: $where'); - variables.where = args.where; - } - if (args.orderBy?.length) { - varDefs.push(\`$orderBy: [\${orderByTypeName}!]\`); - queryArgs.push('orderBy: $orderBy'); - variables.orderBy = args.orderBy; - } - if (args.first !== undefined) { - varDefs.push('$first: Int'); - queryArgs.push('first: $first'); - variables.first = args.first; - } - if (args.last !== undefined) { - varDefs.push('$last: Int'); - queryArgs.push('last: $last'); - variables.last = args.last; - } - if (args.after) { - varDefs.push('$after: Cursor'); - queryArgs.push('after: $after'); - variables.after = args.after; - } - if (args.before) { - varDefs.push('$before: Cursor'); - queryArgs.push('before: $before'); - variables.before = args.before; - } - if (args.offset !== undefined) { - varDefs.push('$offset: Int'); - queryArgs.push('offset: $offset'); - variables.offset = args.offset; - } - - const varDefsStr = varDefs.length > 0 ? \`(\${varDefs.join(', ')})\` : ''; - const queryArgsStr = queryArgs.length > 0 ? \`(\${queryArgs.join(', ')})\` : ''; - - const document = \`query \${operationName}Query\${varDefsStr} { - \${queryField}\${queryArgsStr} { - nodes { \${selections} } - totalCount - pageInfo { hasNextPage hasPreviousPage startCursor endCursor } - } -}\`; + addVariable({ varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'orderBy', typeName: '[' + orderByTypeName + '!]', value: args.orderBy?.length ? args.orderBy : undefined }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'first', typeName: 'Int', value: args.first }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'last', typeName: 'Int', value: args.last }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'after', typeName: 'Cursor', value: args.after }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'before', typeName: 'Cursor', value: args.before }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'offset', typeName: 'Int', value: args.offset }, variableDefinitions, queryArgs, variables); + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'query', + name: operationName + 'Query', + variableDefinitions: variableDefinitions.length ? variableDefinitions : undefined, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: queryField, + args: queryArgs.length ? queryArgs : undefined, + selectionSet: t.selectionSet({ + selections: buildConnectionSelections(selections), + }), + }), + ], + }), + }), + ], + }); - return { document, variables }; + return { document: print(document), variables }; } export function buildFindFirstDocument( @@ -357,25 +368,45 @@ export function buildFindFirstDocument( args: { where?: TWhere }, filterTypeName: string ): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; - const varDefs: string[] = ['$first: Int']; - const queryArgs: string[] = ['first: $first']; - const variables: Record = { first: 1 }; - - if (args.where) { - varDefs.push(\`$where: \${filterTypeName}\`); - queryArgs.push('filter: $where'); - variables.where = args.where; - } + const variableDefinitions: VariableDefinitionNode[] = []; + const queryArgs: ArgumentNode[] = []; + const variables: Record = {}; - const document = \`query \${operationName}Query(\${varDefs.join(', ')}) { - \${queryField}(\${queryArgs.join(', ')}) { - nodes { \${selections} } - } -}\`; + // Always add first: 1 for findFirst + addVariable({ varName: 'first', typeName: 'Int', value: 1 }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, variableDefinitions, queryArgs, variables); + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'query', + name: operationName + 'Query', + variableDefinitions, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: queryField, + args: queryArgs, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: 'nodes', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + }), + ], + }), + }), + ], + }); - return { document, variables }; + return { document: print(document), variables }; } export function buildCreateDocument( @@ -386,17 +417,27 @@ export function buildCreateDocument( data: TData, inputTypeName: string ): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; - - const document = \`mutation \${operationName}Mutation($input: \${inputTypeName}!) { - \${mutationField}(input: $input) { - \${entityField} { \${selections} } - } -}\`; + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; return { - document, - variables: { input: { [entityField]: data } }, + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + [entityField]: data, + }, + }, }; } @@ -409,17 +450,28 @@ export function buildUpdateDocument } { - const selections = select ? buildSelections(select) : 'id'; - - const document = \`mutation \${operationName}Mutation($input: \${inputTypeName}!) { - \${mutationField}(input: $input) { - \${entityField} { \${selections} } - } -}\`; + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; return { - document, - variables: { input: { id: where.id, patch: data } }, + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + id: where.id, + patch: data, + }, + }, }; } @@ -430,15 +482,25 @@ export function buildDeleteDocument( where: TWhere, inputTypeName: string ): { document: string; variables: Record } { - const document = \`mutation \${operationName}Mutation($input: \${inputTypeName}!) { - \${mutationField}(input: $input) { - \${entityField} { id } - } -}\`; - return { - document, - variables: { input: { id: where.id } }, + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ + selections: [t.field({ name: 'id' })], + }), + }), + ], + }), + variables: { + input: { + id: where.id, + }, + }, }; } @@ -450,21 +512,251 @@ export function buildCustomDocument( args: TArgs, variableDefinitions: Array<{ name: string; type: string }> ): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : ''; - - const varDefs = variableDefinitions.map(v => \`$\${v.name}: \${v.type}\`); - const fieldArgs = variableDefinitions.map(v => \`\${v.name}: $\${v.name}\`); - - const varDefsStr = varDefs.length > 0 ? \`(\${varDefs.join(', ')})\` : ''; - const fieldArgsStr = fieldArgs.length > 0 ? \`(\${fieldArgs.join(', ')})\` : ''; - const selectionsBlock = selections ? \` { \${selections} }\` : ''; - - const opType = operationType === 'query' ? 'query' : 'mutation'; - const document = \`\${opType} \${operationName}\${varDefsStr} { - \${fieldName}\${fieldArgsStr}\${selectionsBlock} -}\`; - - return { document, variables: (args ?? {}) as Record }; + let actualSelect = select; + let isConnection = false; + + if (select && typeof select === 'object' && 'select' in select) { + const wrapper = select as { select?: TSelect; connection?: boolean }; + if (wrapper.select) { + actualSelect = wrapper.select; + isConnection = wrapper.connection === true; + } + } + + const selections = actualSelect + ? buildSelections(actualSelect as Record) + : []; + + const variableDefs = variableDefinitions.map((definition) => + t.variableDefinition({ + variable: t.variable({ name: definition.name }), + type: parseType(definition.type), + }) + ); + const fieldArgs = variableDefinitions.map((definition) => + t.argument({ + name: definition.name, + value: t.variable({ name: definition.name }), + }) + ); + + const fieldSelections = isConnection + ? buildConnectionSelections(selections) + : selections; + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: operationType, + name: operationName, + variableDefinitions: variableDefs.length ? variableDefs : undefined, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: fieldName, + args: fieldArgs.length ? fieldArgs : undefined, + selectionSet: fieldSelections.length + ? t.selectionSet({ selections: fieldSelections }) + : undefined, + }), + ], + }), + }), + ], + }); + + return { + document: print(document), + variables: (args ?? {}) as Record, + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function buildArgs(args: Array): ArgumentNode[] { + return args.filter((arg): arg is ArgumentNode => arg !== null); +} + +function buildOptionalArg( + name: string, + value: number | string | undefined +): ArgumentNode | null { + if (value === undefined) { + return null; + } + const valueNode = + typeof value === 'number' + ? t.intValue({ value: value.toString() }) + : t.stringValue({ value }); + return t.argument({ name, value: valueNode }); +} + +function buildEnumListArg( + name: string, + values: string[] | undefined +): ArgumentNode | null { + if (!values || values.length === 0) { + return null; + } + return t.argument({ + name, + value: t.listValue({ + values: values.map((value) => buildEnumValue(value)), + }), + }); +} + +function buildEnumValue(value: string): EnumValueNode { + return { + kind: 'EnumValue', + value, + }; +} + +function buildPageInfoSelections(): FieldNode[] { + return [ + t.field({ name: 'hasNextPage' }), + t.field({ name: 'hasPreviousPage' }), + t.field({ name: 'startCursor' }), + t.field({ name: 'endCursor' }), + ]; +} + +function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { + return [ + t.field({ + name: 'nodes', + selectionSet: t.selectionSet({ selections: nodeSelections }), + }), + t.field({ name: 'totalCount' }), + t.field({ + name: 'pageInfo', + selectionSet: t.selectionSet({ selections: buildPageInfoSelections() }), + }), + ]; +} + +interface VariableSpec { + varName: string; + argName?: string; + typeName: string; + value: unknown; +} + +interface InputMutationConfig { + operationName: string; + mutationField: string; + inputTypeName: string; + resultSelections: FieldNode[]; +} + +function buildInputMutationDocument(config: InputMutationConfig): string { + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'mutation', + name: config.operationName + 'Mutation', + variableDefinitions: [ + t.variableDefinition({ + variable: t.variable({ name: 'input' }), + type: parseType(config.inputTypeName + '!'), + }), + ], + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: config.mutationField, + args: [ + t.argument({ + name: 'input', + value: t.variable({ name: 'input' }), + }), + ], + selectionSet: t.selectionSet({ + selections: config.resultSelections, + }), + }), + ], + }), + }), + ], + }); + return print(document); +} + +function addVariable( + spec: VariableSpec, + definitions: VariableDefinitionNode[], + args: ArgumentNode[], + variables: Record +): void { + if (spec.value === undefined) return; + + definitions.push( + t.variableDefinition({ + variable: t.variable({ name: spec.varName }), + type: parseType(spec.typeName), + }) + ); + args.push( + t.argument({ + name: spec.argName ?? spec.varName, + value: t.variable({ name: spec.varName }), + }) + ); + variables[spec.varName] = spec.value; +} + +function buildValueAst( + value: unknown +): + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | EnumValueNode { + if (value === null) { + return t.nullValue(); + } + + if (typeof value === 'boolean') { + return t.booleanValue({ value }); + } + + if (typeof value === 'number') { + return Number.isInteger(value) + ? t.intValue({ value: value.toString() }) + : t.floatValue({ value: value.toString() }); + } + + if (typeof value === 'string') { + return t.stringValue({ value }); + } + + if (Array.isArray(value)) { + return t.listValue({ + values: value.map((item) => buildValueAst(item)), + }); + } + + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + return t.objectValue({ + fields: Object.entries(obj).map(([key, val]) => + t.objectField({ + name: key, + value: buildValueAst(val), + }) + ), + }); + } + + throw new Error('Unsupported value type: ' + typeof value); } `; @@ -528,6 +820,44 @@ export interface DeleteArgs { where: TWhere; } +/** + * Recursively validates select objects, rejecting unknown keys. + * + * This type ensures that users can only select fields that actually exist + * in the GraphQL schema. It returns \`never\` if any excess keys are found + * at any nesting level, causing a TypeScript compile error. + * + * Why this is needed: + * TypeScript's excess property checking has a quirk where it only catches + * invalid fields when they are the ONLY fields. When mixed with valid fields + * (e.g., \`{ id: true, invalidField: true }\`), the structural typing allows + * the excess property through. This type explicitly checks for and rejects + * such cases. + * + * @example + * // This will cause a type error because 'invalid' doesn't exist: + * type Result = DeepExact<{ id: true, invalid: true }, { id?: boolean }>; + * // Result = never (causes assignment error) + * + * @example + * // This works because all fields are valid: + * type Result = DeepExact<{ id: true }, { id?: boolean; name?: boolean }>; + * // Result = { id: true } + */ +export type DeepExact = T extends Shape + ? Exclude extends never + ? { + [K in keyof T]: K extends keyof Shape + ? T[K] extends { select: infer NS } + ? Shape[K] extends { select?: infer ShapeNS } + ? { select: DeepExact> } + : T[K] + : T[K] + : never; + } + : never + : never; + /** * Infer result type from select configuration */ @@ -564,7 +894,10 @@ function createImportDeclaration( const specifiers = namedImports.map((name) => t.importSpecifier(t.identifier(name), t.identifier(name)) ); - const decl = t.importDeclaration(specifiers, t.stringLiteral(moduleSpecifier)); + const decl = t.importDeclaration( + specifiers, + t.stringLiteral(moduleSpecifier) + ); decl.importKind = typeOnly ? 'type' : 'value'; return decl; } @@ -582,22 +915,30 @@ export function generateCreateClientFile( // Add imports // Import OrmClient (value) and OrmClientConfig (type) separately statements.push(createImportDeclaration('./client', ['OrmClient'])); - statements.push(createImportDeclaration('./client', ['OrmClientConfig'], true)); + statements.push( + createImportDeclaration('./client', ['OrmClientConfig'], true) + ); // Import models for (const table of tables) { const { typeName } = getTableNames(table); const modelName = `${typeName}Model`; const fileName = lcFirst(typeName); - statements.push(createImportDeclaration(`./models/${fileName}`, [modelName])); + statements.push( + createImportDeclaration(`./models/${fileName}`, [modelName]) + ); } // Import custom operations if (hasCustomQueries) { - statements.push(createImportDeclaration('./query', ['createQueryOperations'])); + statements.push( + createImportDeclaration('./query', ['createQueryOperations']) + ); } if (hasCustomMutations) { - statements.push(createImportDeclaration('./mutation', ['createMutationOperations'])); + statements.push( + createImportDeclaration('./mutation', ['createMutationOperations']) + ); } // Re-export types and classes @@ -605,9 +946,18 @@ export function generateCreateClientFile( const typeExportDecl = t.exportNamedDeclaration( null, [ - t.exportSpecifier(t.identifier('OrmClientConfig'), t.identifier('OrmClientConfig')), - t.exportSpecifier(t.identifier('QueryResult'), t.identifier('QueryResult')), - t.exportSpecifier(t.identifier('GraphQLError'), t.identifier('GraphQLError')), + t.exportSpecifier( + t.identifier('OrmClientConfig'), + t.identifier('OrmClientConfig') + ), + t.exportSpecifier( + t.identifier('QueryResult'), + t.identifier('QueryResult') + ), + t.exportSpecifier( + t.identifier('GraphQLError'), + t.identifier('GraphQLError') + ), ], t.stringLiteral('./client') ); @@ -618,7 +968,12 @@ export function generateCreateClientFile( statements.push( t.exportNamedDeclaration( null, - [t.exportSpecifier(t.identifier('GraphQLRequestError'), t.identifier('GraphQLRequestError'))], + [ + t.exportSpecifier( + t.identifier('GraphQLRequestError'), + t.identifier('GraphQLRequestError') + ), + ], t.stringLiteral('./client') ) ); @@ -627,7 +982,12 @@ export function generateCreateClientFile( statements.push( t.exportNamedDeclaration( null, - [t.exportSpecifier(t.identifier('QueryBuilder'), t.identifier('QueryBuilder'))], + [ + t.exportSpecifier( + t.identifier('QueryBuilder'), + t.identifier('QueryBuilder') + ), + ], t.stringLiteral('./query-builder') ) ); @@ -653,7 +1013,9 @@ export function generateCreateClientFile( returnProperties.push( t.objectProperty( t.identifier('query'), - t.callExpression(t.identifier('createQueryOperations'), [t.identifier('client')]) + t.callExpression(t.identifier('createQueryOperations'), [ + t.identifier('client'), + ]) ) ); } @@ -662,7 +1024,9 @@ export function generateCreateClientFile( returnProperties.push( t.objectProperty( t.identifier('mutation'), - t.callExpression(t.identifier('createMutationOperations'), [t.identifier('client')]) + t.callExpression(t.identifier('createMutationOperations'), [ + t.identifier('client'), + ]) ) ); } @@ -678,7 +1042,9 @@ export function generateCreateClientFile( const returnStmt = t.returnStatement(t.objectExpression(returnProperties)); const configParam = t.identifier('config'); - configParam.typeAnnotation = t.tsTypeAnnotation(t.tsTypeReference(t.identifier('OrmClientConfig'))); + configParam.typeAnnotation = t.tsTypeAnnotation( + t.tsTypeReference(t.identifier('OrmClientConfig')) + ); const createClientFunc = t.functionDeclaration( t.identifier('createClient'), diff --git a/graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts b/graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts index 0c9942968..0e7a55cb0 100644 --- a/graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts +++ b/graphql/codegen/src/cli/codegen/orm/custom-ops-generator.ts @@ -179,12 +179,22 @@ function buildOperationMethod( const optionsParam = t.identifier('options'); optionsParam.optional = true; if (selectTypeName) { + // Use DeepExact to enforce strict field validation + // This catches invalid fields even when mixed with valid ones optionsParam.typeAnnotation = t.tsTypeAnnotation( t.tsTypeLiteral([ (() => { const prop = t.tsPropertySignature( t.identifier('select'), - t.tsTypeAnnotation(t.tsTypeReference(t.identifier('S'))) + t.tsTypeAnnotation( + t.tsTypeReference( + t.identifier('DeepExact'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier(selectTypeName)), + ]) + ) + ) ); prop.optional = true; return prop; @@ -292,7 +302,7 @@ export function generateCustomQueryOpsFile( // Add imports statements.push(createImportDeclaration('../client', ['OrmClient'])); statements.push(createImportDeclaration('../query-builder', ['QueryBuilder', 'buildCustomDocument'])); - statements.push(createImportDeclaration('../select-types', ['InferSelectResult'], true)); + statements.push(createImportDeclaration('../select-types', ['InferSelectResult', 'DeepExact'], true)); if (allTypeImports.length > 0) { statements.push(createImportDeclaration('../input-types', allTypeImports, true)); @@ -346,7 +356,7 @@ export function generateCustomMutationOpsFile( // Add imports statements.push(createImportDeclaration('../client', ['OrmClient'])); statements.push(createImportDeclaration('../query-builder', ['QueryBuilder', 'buildCustomDocument'])); - statements.push(createImportDeclaration('../select-types', ['InferSelectResult'], true)); + statements.push(createImportDeclaration('../select-types', ['InferSelectResult', 'DeepExact'], true)); if (allTypeImports.length > 0) { statements.push(createImportDeclaration('../input-types', allTypeImports, true)); diff --git a/graphql/codegen/src/cli/codegen/orm/model-generator.ts b/graphql/codegen/src/cli/codegen/orm/model-generator.ts index f5661f6f4..0498688b4 100644 --- a/graphql/codegen/src/cli/codegen/orm/model-generator.ts +++ b/graphql/codegen/src/cli/codegen/orm/model-generator.ts @@ -123,7 +123,7 @@ export function generateModelFile( ])); statements.push(createImportDeclaration('../select-types', [ 'ConnectionResult', 'FindManyArgs', 'FindFirstArgs', 'CreateArgs', - 'UpdateArgs', 'DeleteArgs', 'InferSelectResult', + 'UpdateArgs', 'DeleteArgs', 'InferSelectResult', 'DeepExact', ], true)); statements.push(createImportDeclaration('../input-types', [ typeName, relationTypeName, selectTypeName, whereTypeName, orderByTypeName, @@ -140,11 +140,15 @@ export function generateModelFile( classBody.push(t.classMethod('constructor', t.identifier('constructor'), [paramProp], t.blockStatement([]))); // findMany method + // Use DeepExact to enforce strict field validation const findManyParam = t.identifier('args'); findManyParam.optional = true; findManyParam.typeAnnotation = t.tsTypeAnnotation( t.tsTypeReference(t.identifier('FindManyArgs'), t.tsTypeParameterInstantiation([ - t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier('DeepExact'), t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier(selectTypeName)), + ])), t.tsTypeReference(t.identifier(whereTypeName)), t.tsTypeReference(t.identifier(orderByTypeName)), ])) @@ -190,7 +194,10 @@ export function generateModelFile( findFirstParam.optional = true; findFirstParam.typeAnnotation = t.tsTypeAnnotation( t.tsTypeReference(t.identifier('FindFirstArgs'), t.tsTypeParameterInstantiation([ - t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier('DeepExact'), t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier(selectTypeName)), + ])), t.tsTypeReference(t.identifier(whereTypeName)), ])) ); @@ -226,7 +233,10 @@ export function generateModelFile( const createParam = t.identifier('args'); createParam.typeAnnotation = t.tsTypeAnnotation( t.tsTypeReference(t.identifier('CreateArgs'), t.tsTypeParameterInstantiation([ - t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier('DeepExact'), t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier(selectTypeName)), + ])), t.tsIndexedAccessType(t.tsTypeReference(t.identifier(createInputTypeName)), t.tsLiteralType(t.stringLiteral(singularName))), ])) ); @@ -262,7 +272,10 @@ export function generateModelFile( const updateParam = t.identifier('args'); updateParam.typeAnnotation = t.tsTypeAnnotation( t.tsTypeReference(t.identifier('UpdateArgs'), t.tsTypeParameterInstantiation([ - t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier('DeepExact'), t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('S')), + t.tsTypeReference(t.identifier(selectTypeName)), + ])), t.tsTypeLiteral([t.tsPropertySignature(t.identifier('id'), t.tsTypeAnnotation(t.tsStringKeyword()))]), t.tsTypeReference(t.identifier(patchTypeName)), ])) diff --git a/graphql/codegen/src/cli/codegen/orm/query-builder.ts b/graphql/codegen/src/cli/codegen/orm/query-builder.ts deleted file mode 100644 index 0f7d1cc8a..000000000 --- a/graphql/codegen/src/cli/codegen/orm/query-builder.ts +++ /dev/null @@ -1,515 +0,0 @@ -/** - * Runtime query builder for ORM client - * - * This module provides the runtime functionality that builds GraphQL - * queries/mutations from the fluent API calls and executes them. - * - * This file will be copied to the generated output (not generated via AST) - * since it's runtime code that doesn't change based on schema. - */ - -import type { QueryResult, GraphQLError } from './select-types'; - -/** - * ORM client configuration - */ -export interface OrmClientConfig { - endpoint: string; - headers?: Record; -} - -/** - * Internal client state - */ -export class OrmClient { - private endpoint: string; - private headers: Record; - - constructor(config: OrmClientConfig) { - this.endpoint = config.endpoint; - this.headers = config.headers ?? {}; - } - - /** - * Execute a GraphQL query/mutation - */ - async execute( - document: string, - variables?: Record - ): Promise> { - const response = await fetch(this.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...this.headers, - }, - body: JSON.stringify({ - query: document, - variables: variables ?? {}, - }), - }); - - if (!response.ok) { - return { - data: null, - errors: [ - { - message: `HTTP ${response.status}: ${response.statusText}`, - }, - ], - }; - } - - const json = (await response.json()) as { - data?: T; - errors?: GraphQLError[]; - }; - - return { - data: json.data ?? null, - errors: json.errors, - }; - } - - /** - * Update headers (e.g., for auth token refresh) - */ - setHeaders(headers: Record): void { - this.headers = { ...this.headers, ...headers }; - } - - /** - * Get current endpoint - */ - getEndpoint(): string { - return this.endpoint; - } -} - -/** - * Configuration for building a query - */ -export interface QueryBuilderConfig { - client: OrmClient; - operation: 'query' | 'mutation'; - operationName: string; - fieldName: string; - document: string; - variables?: Record; -} - -/** - * Query builder that holds the query configuration and executes it - * - * Usage: - * ```typescript - * const result = await new QueryBuilder(config).execute(); - * ``` - */ -export class QueryBuilder { - private config: QueryBuilderConfig; - - constructor(config: QueryBuilderConfig) { - this.config = config; - } - - /** - * Execute the query and return the result - */ - async execute(): Promise> { - return this.config.client.execute( - this.config.document, - this.config.variables - ); - } - - /** - * Get the GraphQL document (useful for debugging) - */ - toGraphQL(): string { - return this.config.document; - } - - /** - * Get the variables (useful for debugging) - */ - getVariables(): Record | undefined { - return this.config.variables; - } -} - -// ============================================================================ -// GraphQL Document Builders (Runtime) -// ============================================================================ - -/** - * Build field selections from a select object - * - * Converts: - * { id: true, name: true, posts: { select: { title: true } } } - * - * To: - * id - * name - * posts { - * nodes { title } - * totalCount - * pageInfo { hasNextPage hasPreviousPage startCursor endCursor } - * } - */ -export function buildSelections( - select: Record | undefined, - fieldMeta?: Record -): string { - if (!select) { - return ''; - } - - const fields: string[] = []; - - for (const [key, value] of Object.entries(select)) { - if (value === false || value === undefined) { - continue; - } - - if (value === true) { - fields.push(key); - continue; - } - - // Nested select - if (typeof value === 'object' && value !== null) { - const nested = value as { - select?: Record; - first?: number; - filter?: Record; - orderBy?: string[]; - }; - - const meta = fieldMeta?.[key]; - const isConnection = meta?.isConnection ?? true; // Default to connection for relations - - if (nested.select) { - const nestedSelections = buildSelections(nested.select); - - // Build arguments for the relation field - const args: string[] = []; - if (nested.first !== undefined) { - args.push(`first: ${nested.first}`); - } - if (nested.filter) { - args.push(`filter: ${JSON.stringify(nested.filter)}`); - } - if (nested.orderBy && nested.orderBy.length > 0) { - args.push(`orderBy: [${nested.orderBy.join(', ')}]`); - } - - const argsStr = args.length > 0 ? `(${args.join(', ')})` : ''; - - if (isConnection) { - // Connection type - include nodes, totalCount, pageInfo - fields.push(`${key}${argsStr} { - nodes { ${nestedSelections} } - totalCount - pageInfo { hasNextPage hasPreviousPage startCursor endCursor } - }`); - } else { - // Direct relation (not a connection) - fields.push(`${key}${argsStr} { ${nestedSelections} }`); - } - } - } - } - - return fields.join('\n '); -} - -/** - * Field metadata for determining connection vs direct relation - */ -export interface FieldMeta { - isConnection: boolean; - isNullable: boolean; - typeName: string; -} - -/** - * Build a findMany query document - */ -export function buildFindManyDocument( - operationName: string, - queryField: string, - select: Record | undefined, - args: { - where?: Record; - orderBy?: string[]; - first?: number; - last?: number; - after?: string; - before?: string; - offset?: number; - }, - filterTypeName: string, - orderByTypeName: string -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; - - // Build variable definitions and query arguments - const varDefs: string[] = []; - const queryArgs: string[] = []; - const variables: Record = {}; - - if (args.where) { - varDefs.push(`$where: ${filterTypeName}`); - queryArgs.push('filter: $where'); - variables.where = args.where; - } - if (args.orderBy && args.orderBy.length > 0) { - varDefs.push(`$orderBy: [${orderByTypeName}!]`); - queryArgs.push('orderBy: $orderBy'); - variables.orderBy = args.orderBy; - } - if (args.first !== undefined) { - varDefs.push('$first: Int'); - queryArgs.push('first: $first'); - variables.first = args.first; - } - if (args.last !== undefined) { - varDefs.push('$last: Int'); - queryArgs.push('last: $last'); - variables.last = args.last; - } - if (args.after) { - varDefs.push('$after: Cursor'); - queryArgs.push('after: $after'); - variables.after = args.after; - } - if (args.before) { - varDefs.push('$before: Cursor'); - queryArgs.push('before: $before'); - variables.before = args.before; - } - if (args.offset !== undefined) { - varDefs.push('$offset: Int'); - queryArgs.push('offset: $offset'); - variables.offset = args.offset; - } - - const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(', ')})` : ''; - const queryArgsStr = queryArgs.length > 0 ? `(${queryArgs.join(', ')})` : ''; - - const document = `query ${operationName}Query${varDefsStr} { - ${queryField}${queryArgsStr} { - nodes { - ${selections} - } - totalCount - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } -}`; - - return { document, variables }; -} - -/** - * Build a findFirst query document - */ -export function buildFindFirstDocument( - operationName: string, - queryField: string, - select: Record | undefined, - args: { - where?: Record; - }, - filterTypeName: string -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; - - const varDefs: string[] = []; - const queryArgs: string[] = []; - const variables: Record = {}; - - if (args.where) { - varDefs.push(`$where: ${filterTypeName}`); - queryArgs.push('filter: $where'); - variables.where = args.where; - } - - // findFirst uses the list query with first: 1 - varDefs.push('$first: Int'); - queryArgs.push('first: $first'); - variables.first = 1; - - const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(', ')})` : ''; - const queryArgsStr = queryArgs.length > 0 ? `(${queryArgs.join(', ')})` : ''; - - const document = `query ${operationName}Query${varDefsStr} { - ${queryField}${queryArgsStr} { - nodes { - ${selections} - } - } -}`; - - return { document, variables }; -} - -/** - * Build a create mutation document - */ -export function buildCreateDocument( - operationName: string, - mutationField: string, - entityField: string, - select: Record | undefined, - data: Record, - inputTypeName: string -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; - - const document = `mutation ${operationName}Mutation($input: ${inputTypeName}!) { - ${mutationField}(input: $input) { - ${entityField} { - ${selections} - } - } -}`; - - return { - document, - variables: { - input: { - [entityField]: data, - }, - }, - }; -} - -/** - * Build an update mutation document - */ -export function buildUpdateDocument( - operationName: string, - mutationField: string, - entityField: string, - select: Record | undefined, - where: Record, - data: Record, - inputTypeName: string -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : 'id'; - - // PostGraphile update uses nodeId or primary key in input - const document = `mutation ${operationName}Mutation($input: ${inputTypeName}!) { - ${mutationField}(input: $input) { - ${entityField} { - ${selections} - } - } -}`; - - return { - document, - variables: { - input: { - id: where.id, // Assumes id-based where clause - patch: data, - }, - }, - }; -} - -/** - * Build a delete mutation document - */ -export function buildDeleteDocument( - operationName: string, - mutationField: string, - entityField: string, - where: Record, - inputTypeName: string -): { document: string; variables: Record } { - const document = `mutation ${operationName}Mutation($input: ${inputTypeName}!) { - ${mutationField}(input: $input) { - ${entityField} { - id - } - } -}`; - - return { - document, - variables: { - input: { - id: where.id, - }, - }, - }; -} - -/** - * Build a custom query document - */ -export function buildCustomQueryDocument( - operationName: string, - queryField: string, - select: Record | undefined, - args: Record | undefined, - variableDefinitions: Array<{ name: string; type: string }> -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : ''; - - const varDefs = variableDefinitions.map((v) => `$${v.name}: ${v.type}`); - const queryArgs = variableDefinitions.map((v) => `${v.name}: $${v.name}`); - - const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(', ')})` : ''; - const queryArgsStr = queryArgs.length > 0 ? `(${queryArgs.join(', ')})` : ''; - - const selectionsBlock = selections ? ` {\n ${selections}\n }` : ''; - - const document = `query ${operationName}Query${varDefsStr} { - ${queryField}${queryArgsStr}${selectionsBlock} -}`; - - return { - document, - variables: args ?? {}, - }; -} - -/** - * Build a custom mutation document - */ -export function buildCustomMutationDocument( - operationName: string, - mutationField: string, - select: Record | undefined, - args: Record | undefined, - variableDefinitions: Array<{ name: string; type: string }> -): { document: string; variables: Record } { - const selections = select ? buildSelections(select) : ''; - - const varDefs = variableDefinitions.map((v) => `$${v.name}: ${v.type}`); - const mutationArgs = variableDefinitions.map((v) => `${v.name}: $${v.name}`); - - const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(', ')})` : ''; - const mutationArgsStr = - mutationArgs.length > 0 ? `(${mutationArgs.join(', ')})` : ''; - - const selectionsBlock = selections ? ` {\n ${selections}\n }` : ''; - - const document = `mutation ${operationName}Mutation${varDefsStr} { - ${mutationField}${mutationArgsStr}${selectionsBlock} -}`; - - return { - document, - variables: args ?? {}, - }; -} diff --git a/graphql/codegen/src/cli/codegen/orm/select-types.ts b/graphql/codegen/src/cli/codegen/orm/select-types.ts index e500302e8..973b216c3 100644 --- a/graphql/codegen/src/cli/codegen/orm/select-types.ts +++ b/graphql/codegen/src/cli/codegen/orm/select-types.ts @@ -50,6 +50,44 @@ export interface NestedSelectConfig { orderBy?: string[]; } +/** + * Recursively validates select objects, rejecting unknown keys. + * + * This type ensures that users can only select fields that actually exist + * in the GraphQL schema. It returns `never` if any excess keys are found + * at any nesting level, causing a TypeScript compile error. + * + * Why this is needed: + * TypeScript's excess property checking has a quirk where it only catches + * invalid fields when they are the ONLY fields. When mixed with valid fields + * (e.g., `{ id: true, invalidField: true }`), the structural typing allows + * the excess property through. This type explicitly checks for and rejects + * such cases. + * + * @example + * // This will cause a type error because 'invalid' doesn't exist: + * type Result = DeepExact<{ id: true, invalid: true }, { id?: boolean }>; + * // Result = never (causes assignment error) + * + * @example + * // This works because all fields are valid: + * type Result = DeepExact<{ id: true }, { id?: boolean; name?: boolean }>; + * // Result = { id: true } + */ +export type DeepExact = T extends Shape + ? Exclude extends never + ? { + [K in keyof T]: K extends keyof Shape + ? T[K] extends { select: infer NS } + ? Shape[K] extends { select?: infer ShapeNS } + ? { select: DeepExact> } + : T[K] + : T[K] + : never; + } + : never + : never; + /** * Infers the result type from a select configuration * diff --git a/graphql/codegen/src/cli/commands/generate.ts b/graphql/codegen/src/cli/commands/generate.ts index eb40ede6b..4f36710b0 100644 --- a/graphql/codegen/src/cli/commands/generate.ts +++ b/graphql/codegen/src/cli/commands/generate.ts @@ -8,7 +8,7 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as prettier from 'prettier'; +import { execSync } from 'node:child_process'; import type { GraphQLSDKConfig, @@ -499,9 +499,7 @@ export async function writeGeneratedFiles( } try { - // Format with prettier - const formattedContent = await formatCode(file.content); - fs.writeFileSync(filePath, formattedContent, 'utf-8'); + fs.writeFileSync(filePath, file.content, 'utf-8'); written.push(filePath); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; @@ -514,6 +512,17 @@ export async function writeGeneratedFiles( process.stdout.write('\r' + ' '.repeat(40) + '\r'); } + // Format all generated files with oxfmt + if (errors.length === 0) { + if (showProgress) { + console.log('Formatting generated files...'); + } + const formatResult = formatOutput(outputDir); + if (!formatResult.success && showProgress) { + console.warn('Warning: Failed to format generated files:', formatResult.error); + } + } + return { success: errors.length === 0, filesWritten: written, @@ -521,16 +530,29 @@ export async function writeGeneratedFiles( }; } -async function formatCode(code: string): Promise { +/** + * Format generated files using oxfmt + * Runs oxfmt on the output directory after all files are written + */ +export function formatOutput(outputDir: string): { success: boolean; error?: string } { + // Resolve to absolute path for reliable execution + const absoluteOutputDir = path.resolve(outputDir); + try { - return await prettier.format(code, { - parser: 'typescript', - singleQuote: true, - trailingComma: 'es5', - tabWidth: 2, + // Find oxfmt binary from this package's node_modules/.bin + // oxfmt is a dependency of @constructive-io/graphql-codegen + const oxfmtPkgPath = require.resolve('oxfmt/package.json'); + const oxfmtDir = path.dirname(oxfmtPkgPath); + const oxfmtBin = path.join(oxfmtDir, 'bin', 'oxfmt'); + + execSync(`"${oxfmtBin}" "${absoluteOutputDir}"`, { + stdio: 'pipe', + encoding: 'utf-8', }); - } catch { - // If prettier fails, return unformatted code - return code; + return { success: true }; + } catch (err) { + // oxfmt may fail if files have syntax errors or if not installed + const message = err instanceof Error ? err.message : 'Unknown error'; + return { success: false, error: message }; } } diff --git a/graphql/codegen/src/cli/index.ts b/graphql/codegen/src/cli/index.ts index c30b8efd3..12d97f9aa 100644 --- a/graphql/codegen/src/cli/index.ts +++ b/graphql/codegen/src/cli/index.ts @@ -17,6 +17,18 @@ import { type ResolvedConfig, } from '../types/config'; +/** + * Format duration in a human-readable way + * - Under 1 second: show milliseconds (e.g., "123ms") + * - Over 1 second: show seconds with 2 decimal places (e.g., "1.23s") + */ +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + const program = new Command(); /** @@ -133,17 +145,19 @@ program './generated' ) .action(async (options) => { + const startTime = performance.now(); const result = await initCommand({ directory: options.directory, force: options.force, endpoint: options.endpoint, output: options.output, }); + const duration = formatDuration(performance.now() - startTime); if (result.success) { - console.log('[ok]', result.message); + console.log('[ok]', result.message, `(${duration})`); } else { - console.error('x', result.message); + console.error('x', result.message, `(${duration})`); process.exit(1); } }); @@ -182,6 +196,8 @@ program .option('--touch ', 'File to touch on schema change') .option('--no-clear', 'Do not clear terminal on regeneration') .action(async (options) => { + const startTime = performance.now(); + // Validate source options if (options.endpoint && options.schema) { console.error( @@ -226,6 +242,7 @@ program verbose: options.verbose, dryRun: options.dryRun, }); + const duration = formatDuration(performance.now() - startTime); const targetResults = result.targets ?? []; const hasNamedTargets = @@ -258,7 +275,7 @@ program } if (result.success) { - console.log('[ok]', result.message); + console.log('[ok]', result.message, `(${duration})`); if (result.tables && result.tables.length > 0) { console.log('\nTables:'); result.tables.forEach((t) => console.log(` - ${t}`)); @@ -268,7 +285,7 @@ program result.filesWritten.forEach((f) => console.log(` - ${f}`)); } } else { - console.error('x', result.message); + console.error('x', result.message, `(${duration})`); if (result.errors) { result.errors.forEach((e) => console.error(' -', e)); } @@ -317,6 +334,8 @@ program .option('--touch ', 'File to touch on schema change') .option('--no-clear', 'Do not clear terminal on regeneration') .action(async (options) => { + const startTime = performance.now(); + // Validate source options if (options.endpoint && options.schema) { console.error( @@ -363,6 +382,7 @@ program dryRun: options.dryRun, skipCustomOperations: options.skipCustomOperations, }); + const duration = formatDuration(performance.now() - startTime); const targetResults = result.targets ?? []; const hasNamedTargets = @@ -407,7 +427,7 @@ program } if (result.success) { - console.log('[ok]', result.message); + console.log('[ok]', result.message, `(${duration})`); if (result.tables && result.tables.length > 0) { console.log('\nTables:'); result.tables.forEach((t) => console.log(` - ${t}`)); @@ -425,7 +445,7 @@ program result.filesWritten.forEach((f) => console.log(` - ${f}`)); } } else { - console.error('x', result.message); + console.error('x', result.message, `(${duration})`); if (result.errors) { result.errors.forEach((e) => console.error(' -', e)); } @@ -444,6 +464,8 @@ program .option('-a, --authorization
', 'Authorization header value') .option('--json', 'Output as JSON', false) .action(async (options) => { + const startTime = performance.now(); + // Validate source options if (!options.endpoint && !options.schema) { console.error('x Either --endpoint or --schema must be provided.'); @@ -471,11 +493,12 @@ program const { introspection } = await source.fetch(); const tables = inferTablesFromIntrospection(introspection); + const duration = formatDuration(performance.now() - startTime); if (options.json) { console.log(JSON.stringify(tables, null, 2)); } else { - console.log(`\n[ok] Found ${tables.length} tables:\n`); + console.log(`\n[ok] Found ${tables.length} tables (${duration}):\n`); tables.forEach((table) => { const fieldCount = table.fields.length; const relationCount = @@ -489,9 +512,11 @@ program }); } } catch (err) { + const duration = formatDuration(performance.now() - startTime); console.error( 'x Failed to introspect schema:', - err instanceof Error ? err.message : err + err instanceof Error ? err.message : err, + `(${duration})` ); process.exit(1); } diff --git a/graphql/gql-ast/src/index.ts b/graphql/gql-ast/src/index.ts index 9ddeadbbf..2f357dcbc 100644 --- a/graphql/gql-ast/src/index.ts +++ b/graphql/gql-ast/src/index.ts @@ -5,12 +5,12 @@ import { DirectiveNode, DocumentNode, FieldNode, + FloatValueNode, FragmentDefinitionNode, IntValueNode, ListTypeNode, ListValueNode, NamedTypeNode, - // NameNode, NullValueNode, ObjectFieldNode, ObjectValueNode, @@ -19,6 +19,7 @@ import { SelectionSetNode, StringValueNode, TypeNode, + ValueNode, VariableDefinitionNode, VariableNode } from 'graphql'; @@ -118,7 +119,12 @@ export const booleanValue = ({ value }: { value: boolean }): BooleanValueNode => value }); -export const listValue = ({ values }: { values: any[] }): ListValueNode => ({ +export const floatValue = ({ value }: { value: string }): FloatValueNode => ({ + kind: 'FloatValue', + value +}); + +export const listValue = ({ values }: { values: ValueNode[] }): ListValueNode => ({ kind: 'ListValue', values }); @@ -148,7 +154,7 @@ export const fragmentDefinition = ({ selectionSet }); -export const objectField = ({ name, value }: { name: string; value: any }): ObjectFieldNode => ({ +export const objectField = ({ name, value }: { name: string; value: ValueNode }): ObjectFieldNode => ({ kind: 'ObjectField', name: { kind: 'Name', @@ -178,7 +184,7 @@ export const field = ({ selectionSet }); -export const argument = ({ name, value }: { name: string; value: any }): ArgumentNode => ({ +export const argument = ({ name, value }: { name: string; value: ValueNode }): ArgumentNode => ({ kind: 'Argument', name: { kind: 'Name', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 344d448a2..f0804e708 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -732,9 +732,9 @@ importers: jiti: specifier: ^2.6.1 version: 2.6.1 - prettier: - specifier: ^3.7.4 - version: 3.7.4 + oxfmt: + specifier: ^0.13.0 + version: 0.13.0 devDependencies: '@tanstack/react-query': specifier: ^5.90.16 @@ -1316,13 +1316,13 @@ importers: version: 0.16.0 '@pgpm/metaschema-modules': specifier: ^0.16.1 - version: 0.16.1 + version: 0.16.4 '@pgpm/metaschema-schema': specifier: ^0.16.1 - version: 0.16.1 + version: 0.16.3 '@pgpm/services': specifier: ^0.16.1 - version: 0.16.1 + version: 0.16.3 '@pgpm/types': specifier: ^0.16.0 version: 0.16.0 @@ -3376,6 +3376,46 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@oxfmt/darwin-arm64@0.13.0': + resolution: {integrity: sha512-WJKGJp9t8lMG3Vmsyz77qj4GIp2Z/z5KkS4Mpbn7nfiVZLVNdxf9k85vHnuGtBZcuxIAjJIRDgitePFPD+timA==} + cpu: [arm64] + os: [darwin] + + '@oxfmt/darwin-x64@0.13.0': + resolution: {integrity: sha512-b9r+uOrnsFIl8DEimw5G69/aXbY5XURzFz0j6Hr8GiAZIrp2GlMLz0B8zhylAXh882GalZGxtcVbLZt5SSe2jw==} + cpu: [x64] + os: [darwin] + + '@oxfmt/linux-arm64-gnu@0.13.0': + resolution: {integrity: sha512-v9+rE/d38wBRli0iYvhgGWlgSAgFBJnnK6kefwQ6POu6n6y/tGiQXjWIyvkBqpQhxxavGnk3z3WXP+DAJSC2eA==} + cpu: [arm64] + os: [linux] + + '@oxfmt/linux-arm64-musl@0.13.0': + resolution: {integrity: sha512-g9A8dOoM/XwToz70aq8XodQZMWwWWPjuTUCI9cxkB1uvpQe4JN6VcHRLMY6Ft1LLh4MIARqq3mCbuXwMVseKiA==} + cpu: [arm64] + os: [linux] + + '@oxfmt/linux-x64-gnu@0.13.0': + resolution: {integrity: sha512-CbMEtJ+0mVWnBHOF+Fx8CYApAs3Iywmo6E+buokXEli98167R2eJ/g7dqNiU6R8hBiO0n4KyoT4KaeYhmQp7KA==} + cpu: [x64] + os: [linux] + + '@oxfmt/linux-x64-musl@0.13.0': + resolution: {integrity: sha512-KqE6qmwLqxbC2I1t65JNqbu87qL4my3Bi1nsmwXzJBW/xFAVNS4OgZnKQwOpW9dDXw8Ng/IoBO24GgOOECkd/w==} + cpu: [x64] + os: [linux] + + '@oxfmt/win32-arm64@0.13.0': + resolution: {integrity: sha512-jVvlnkgdKHT/l13zIG9511KoVwCKGAvQ4CUtiwiP4Nv4K1F586dV4IcOawcRnKpw9KHTV/Q0E8jB5m3tnz2yeQ==} + cpu: [arm64] + os: [win32] + + '@oxfmt/win32-x64@0.13.0': + resolution: {integrity: sha512-HgC7Efv1Eqv8Ag/3LP2WjSvzIFHsxBLBaYOgMhvq4WhZMM3xG9zsb/1A3/pVqdPhvw+Kh62WaYm1WQM9J4l0lQ==} + cpu: [x64] + os: [win32] + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -3385,14 +3425,14 @@ packages: '@pgpm/inflection@0.16.0': resolution: {integrity: sha512-otjWGx+KkB113Wc5I9nsvoqPhBK6zD1ON2OcXw9PQRgqU43Y9f0yZjb559dDzZwDn5XUeiZMf6il5SIvJE5NPg==} - '@pgpm/metaschema-modules@0.16.1': - resolution: {integrity: sha512-qH0l4Xe0f0CSzXAC2nItu+ZpGliZ4eezl332HCLpI/bLkIMsmIZYlcjgiPmv7lZae+3uWbn7DQuDxeomsn5kBw==} + '@pgpm/metaschema-modules@0.16.4': + resolution: {integrity: sha512-sB3+5yljFEqUXTTHUOHBBxK52CwagHiUBumWjikHVN9C5w6NHUQ+xFde+3RJMCkoqnmcZn6HTGvWCF25QgciiA==} - '@pgpm/metaschema-schema@0.16.1': - resolution: {integrity: sha512-FwLy+z8pwfrBeQYErpcDpD55ZtB1X+Ghj6bbE28GVURBlUxmPY1llrLfKLqcA6xaKMyZ+aHOeBlKYRuyo9xdag==} + '@pgpm/metaschema-schema@0.16.3': + resolution: {integrity: sha512-sDIWJY+uNaqMMGjL8NWo8ezzXH1OT0qdaqsX+YDrBL6v1u0PphWprdjd7HySzdqIGpPSax8sIy5u4P2M96wR9Q==} - '@pgpm/services@0.16.1': - resolution: {integrity: sha512-9wp3nstcTtsARw5cuE/x9Dwq/v7FQUPXlzjsBR/2V6z7oHBjOI8HiQ8y+tc1pnrFL1PJtcthkZKvBZbQBQJbTw==} + '@pgpm/services@0.16.3': + resolution: {integrity: sha512-TfYALB8RKPyR2WZIFH2Pirb5qfx1q2EKbr7gzG/CcZcQMgTGYyDHBtvSqIO4nDfJ6GgYcASoip9T0lzQmwGtlA==} '@pgpm/types@0.16.0': resolution: {integrity: sha512-CioHCxZGQUnpLANw4aMOOq7Z6zi2SXCxJIRZ8CSBPJfJkWU1OgxX+EpSjnm4Td4bznJhOViXniLltibaaGkMPA==} @@ -7307,6 +7347,11 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + oxfmt@0.13.0: + resolution: {integrity: sha512-WhWYL1nRxevnezPK3GsGlZ2uPnO+rPlJ1U44TEfET+UwDPhKDVFyqlblduAgu3PFwTMgY/GRbaZwlWugvpfbWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -10769,6 +10814,30 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@oxfmt/darwin-arm64@0.13.0': + optional: true + + '@oxfmt/darwin-x64@0.13.0': + optional: true + + '@oxfmt/linux-arm64-gnu@0.13.0': + optional: true + + '@oxfmt/linux-arm64-musl@0.13.0': + optional: true + + '@oxfmt/linux-x64-gnu@0.13.0': + optional: true + + '@oxfmt/linux-x64-musl@0.13.0': + optional: true + + '@oxfmt/win32-arm64@0.13.0': + optional: true + + '@oxfmt/win32-x64@0.13.0': + optional: true + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -10781,22 +10850,22 @@ snapshots: dependencies: '@pgpm/verify': 0.16.0 - '@pgpm/metaschema-modules@0.16.1': + '@pgpm/metaschema-modules@0.16.4': dependencies: - '@pgpm/metaschema-schema': 0.16.1 - '@pgpm/services': 0.16.1 + '@pgpm/metaschema-schema': 0.16.3 + '@pgpm/services': 0.16.3 '@pgpm/verify': 0.16.0 - '@pgpm/metaschema-schema@0.16.1': + '@pgpm/metaschema-schema@0.16.3': dependencies: '@pgpm/database-jobs': 0.16.0 '@pgpm/inflection': 0.16.0 '@pgpm/types': 0.16.0 '@pgpm/verify': 0.16.0 - '@pgpm/services@0.16.1': + '@pgpm/services@0.16.3': dependencies: - '@pgpm/metaschema-schema': 0.16.1 + '@pgpm/metaschema-schema': 0.16.3 '@pgpm/verify': 0.16.0 '@pgpm/types@0.16.0': @@ -15859,6 +15928,17 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + oxfmt@0.13.0: + optionalDependencies: + '@oxfmt/darwin-arm64': 0.13.0 + '@oxfmt/darwin-x64': 0.13.0 + '@oxfmt/linux-arm64-gnu': 0.13.0 + '@oxfmt/linux-arm64-musl': 0.13.0 + '@oxfmt/linux-x64-gnu': 0.13.0 + '@oxfmt/linux-x64-musl': 0.13.0 + '@oxfmt/win32-arm64': 0.13.0 + '@oxfmt/win32-x64': 0.13.0 + p-finally@1.0.0: {} p-limit@1.3.0: