diff --git a/graphile/graphile-search/src/adapters/pgvector.ts b/graphile/graphile-search/src/adapters/pgvector.ts index 93286c8ca..ea40e5e21 100644 --- a/graphile/graphile-search/src/adapters/pgvector.ts +++ b/graphile/graphile-search/src/adapters/pgvector.ts @@ -52,6 +52,10 @@ export function createPgvectorAdapter( filterPrefix, + // pgvector operates on embedding vectors, not text search — its presence + // alone should NOT trigger supplementary adapters like trgm. + isIntentionalSearch: false, + supportsTextSearch: false, // pgvector requires a vector array, not plain text — no buildTextSearchInput diff --git a/graphile/graphile-search/src/adapters/trgm.ts b/graphile/graphile-search/src/adapters/trgm.ts index 8248048ce..aa50508b8 100644 --- a/graphile/graphile-search/src/adapters/trgm.ts +++ b/graphile/graphile-search/src/adapters/trgm.ts @@ -25,16 +25,40 @@ export interface TrgmAdapterOptions { * @default 0.3 */ defaultThreshold?: number; + + /** + * When true, trgm only activates on tables that have an "intentional" + * search column detected by another adapter (e.g. a tsvector column or + * a BM25 index). This prevents trgm similarity fields from being added + * to every table with text columns. + * + * The plugin's `getAdapterColumns` orchestrates this by running + * non-supplementary adapters first, then only running supplementary + * adapters on codecs that already have search columns. + * + * @default true + */ + requireIntentionalSearch?: boolean; } export function createTrgmAdapter( options: TrgmAdapterOptions = {} ): SearchAdapter { - const { filterPrefix = 'trgm', defaultThreshold = 0.3 } = options; + const { + filterPrefix = 'trgm', + defaultThreshold = 0.3, + requireIntentionalSearch = true, + } = options; return { name: 'trgm', + /** + * When true, this adapter is "supplementary" — it only activates on + * tables that already have columns detected by a non-supplementary adapter. + */ + isSupplementary: requireIntentionalSearch, + scoreSemantics: { metric: 'similarity', lowerIsBetter: false, diff --git a/graphile/graphile-search/src/plugin.ts b/graphile/graphile-search/src/plugin.ts index e20d337bb..0758705d9 100644 --- a/graphile/graphile-search/src/plugin.ts +++ b/graphile/graphile-search/src/plugin.ts @@ -88,6 +88,15 @@ export function createUnifiedSearchPlugin( /** * Get (or compute) the adapter columns for a given codec. + * + * Runs non-supplementary adapters first (e.g. tsvector, BM25, pgvector). + * Supplementary adapters (e.g. trgm with requireIntentionalSearch) are only + * run if at least one adapter with `isIntentionalSearch: true` found columns. + * + * This distinction matters because pgvector (embeddings) is NOT intentional + * text search — its presence alone should not trigger trgm similarity fields. + * Only tsvector and BM25, which represent explicit search infrastructure, + * count as intentional search. */ function getAdapterColumns(codec: PgCodecWithAttributes, build: any): AdapterColumnCache[] { const cacheKey = codec.name; @@ -95,13 +104,36 @@ export function createUnifiedSearchPlugin( return codecCache.get(cacheKey)!; } + const primaryAdapters = adapters.filter((a) => !a.isSupplementary); + const supplementaryAdapters = adapters.filter((a) => a.isSupplementary); + + // Phase 1: Run non-supplementary adapters (tsvector, BM25, pgvector, etc.) const results: AdapterColumnCache[] = []; - for (const adapter of adapters) { + let hasIntentionalSearch = false; + for (const adapter of primaryAdapters) { const columns = adapter.detectColumns(codec, build); if (columns.length > 0) { results.push({ adapter, columns }); + // Track whether any "intentional search" adapter found columns. + // isIntentionalSearch defaults to true when not explicitly set. + if (adapter.isIntentionalSearch !== false) { + hasIntentionalSearch = true; + } } } + + // Phase 2: Only run supplementary adapters if at least one primary + // adapter with isIntentionalSearch found columns on this codec. + // pgvector (isIntentionalSearch: false) alone won't trigger trgm. + if (hasIntentionalSearch) { + for (const adapter of supplementaryAdapters) { + const columns = adapter.detectColumns(codec, build); + if (columns.length > 0) { + results.push({ adapter, columns }); + } + } + } + codecCache.set(cacheKey, results); return results; } @@ -170,9 +202,11 @@ export function createUnifiedSearchPlugin( provides: ['default'], before: ['inferred', 'override', 'PgAttributesPlugin'], callback(behavior, [codec, attributeName], build) { - // Check if any adapter claims this column - for (const adapter of adapters) { - const columns = adapter.detectColumns(codec, build); + // Use getAdapterColumns which respects isSupplementary logic, + // so trgm columns only appear when intentional search exists + if (!codec?.attributes) return behavior; + const adapterColumns = getAdapterColumns(codec as PgCodecWithAttributes, build); + for (const { columns } of adapterColumns) { if (columns.some((c) => c.attributeName === attributeName)) { return [ 'unifiedSearch:orderBy', diff --git a/graphile/graphile-search/src/types.ts b/graphile/graphile-search/src/types.ts index 1e41c1d55..5d9f49d53 100644 --- a/graphile/graphile-search/src/types.ts +++ b/graphile/graphile-search/src/types.ts @@ -75,6 +75,35 @@ export interface SearchAdapter { /** Score semantics for this algorithm. */ scoreSemantics: ScoreSemantics; + /** + * When true, this adapter is "supplementary" — it only activates on + * tables that already have at least one column detected by an adapter + * whose `isIntentionalSearch` is true (e.g. tsvector or BM25). + * + * This prevents adapters like pg_trgm from adding similarity fields + * to every table with text columns when there is no intentional search setup. + * + * pgvector (embeddings) does NOT count as intentional search because it + * operates on vector columns, not text search — so its presence alone + * won't trigger supplementary adapters. + * + * @default false + */ + isSupplementary?: boolean; + + /** + * When true, this adapter represents "intentional search" — its presence + * on a table signals that the table was explicitly set up for search and + * should trigger supplementary adapters (e.g. trgm). + * + * Adapters that check for real infrastructure (tsvector columns, BM25 + * indexes) should set this to true. Adapters that operate on a different + * domain (pgvector embeddings) should set this to false. + * + * @default true + */ + isIntentionalSearch?: boolean; + /** * The filter prefix used for filter field names on the connection filter input. * The field name is: `{filterPrefix}{ColumnName}` (camelCase). diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap index 343ff2312..52c49afd7 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap @@ -1,238 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`cli docs generator generates CLI AGENTS.md 1`] = ` -"# myapp CLI - Agent Reference - - -> This document is structured for LLM/agent consumption. - -## OVERVIEW - -\`myapp\` is a CLI tool for interacting with a GraphQL API. -All commands output JSON to stdout. All commands accept \`--help\` or \`-h\` for usage. -Configuration is stored at \`~/.myapp/config/\` via appstash. - -## PREREQUISITES - -Before running any data commands, you must: - -1. Create a context: \`myapp context create --endpoint \` -2. Activate it: \`myapp context use \` -3. Authenticate: \`myapp auth set-token \` - -## TOOLS - -### TOOL: context - -Manage named API endpoint contexts (like kubectl contexts). - -\`\`\` -SUBCOMMANDS: - myapp context create --endpoint Create a new context - myapp context list List all contexts - myapp context use Set active context - myapp context current Show active context - myapp context delete Delete a context - -INPUT: - name: string (required) - Context identifier - endpoint: string (required for create) - GraphQL endpoint URL - -OUTPUT: JSON - create: { name, endpoint } - list: [{ name, endpoint, isCurrent, hasCredentials }] - use: { name, endpoint } - current: { name, endpoint } - delete: { deleted: name } -\`\`\` - -### TOOL: auth - -Manage authentication tokens per context. - -\`\`\` -SUBCOMMANDS: - myapp auth set-token Store bearer token for current context - myapp auth status Show auth status for all contexts - myapp auth logout Remove credentials for current context - -INPUT: - token: string (required for set-token) - Bearer token value - -OUTPUT: JSON - set-token: { context, status: "authenticated" } - status: [{ context, authenticated: boolean }] - logout: { context, status: "logged out" } -\`\`\` - -### TOOL: config - -Manage per-context key-value configuration variables. - -\`\`\` -SUBCOMMANDS: - myapp config get Get a config value - myapp config set Set a config value - myapp config list List all config values - myapp config delete Delete a config value - -INPUT: - key: string (required for get/set/delete) - Variable name - value: string (required for set) - Variable value - -OUTPUT: JSON - get: { key, value } - set: { key, value } - list: { vars: { key: value, ... } } - delete: { deleted: key } -\`\`\` - -### TOOL: car - -CRUD operations for Car records. - -\`\`\` -SUBCOMMANDS: - myapp car list List all records - myapp car get --id Get one record - myapp car create --make --model --year --isElectric - myapp car update --id [--make ] [--model ] [--year ] [--isElectric ] - myapp car delete --id Delete one record - -INPUT FIELDS: - id: UUID (primary key) - make: String - model: String - year: Int - isElectric: Boolean - createdAt: Datetime - -EDITABLE FIELDS (for create/update): - make: String - model: String - year: Int - isElectric: Boolean - -OUTPUT: JSON - list: [{ id, make, model, year, isElectric, createdAt }] - get: { id, make, model, year, isElectric, createdAt } - create: { id, make, model, year, isElectric, createdAt } - update: { id, make, model, year, isElectric, createdAt } - delete: { id } -\`\`\` - -### TOOL: driver - -CRUD operations for Driver records. - -\`\`\` -SUBCOMMANDS: - myapp driver list List all records - myapp driver get --id Get one record - myapp driver create --name --licenseNumber - myapp driver update --id [--name ] [--licenseNumber ] - myapp driver delete --id Delete one record - -INPUT FIELDS: - id: UUID (primary key) - name: String - licenseNumber: String - -EDITABLE FIELDS (for create/update): - name: String - licenseNumber: String - -OUTPUT: JSON - list: [{ id, name, licenseNumber }] - get: { id, name, licenseNumber } - create: { id, name, licenseNumber } - update: { id, name, licenseNumber } - delete: { id } -\`\`\` - -### TOOL: current-user - -Get the currently authenticated user - -\`\`\` -TYPE: query -USAGE: myapp current-user - -INPUT: none - -OUTPUT: JSON -\`\`\` - -### TOOL: login - -Authenticate a user - -\`\`\` -TYPE: mutation -USAGE: myapp login --email --password - -INPUT: - email: String (required) - password: String (required) - -OUTPUT: JSON -\`\`\` - -## WORKFLOWS - -### Initial setup - -\`\`\`bash -myapp context create dev --endpoint http://localhost:5000/graphql -myapp context use dev -myapp auth set-token eyJhbGciOiJIUzI1NiIs... -\`\`\` - -### CRUD workflow (car) - -\`\`\`bash -# List all -myapp car list - -# Create -myapp car create --make "value" --model "value" --year "value" --isElectric "value" - -# Get by id -myapp car get --id - -# Update -myapp car update --id --make "new-value" - -# Delete -myapp car delete --id -\`\`\` - -### Piping output - -\`\`\`bash -# Pretty print -myapp car list | jq '.' - -# Extract field -myapp car list | jq '.[].id' - -# Count results -myapp car list | jq 'length' -\`\`\` - -## ERROR HANDLING - -All errors are written to stderr. Exit codes: -- \`0\`: Success -- \`1\`: Error (auth failure, not found, validation error, network error) - -Common errors: -- "No active context": Run \`context use \` first -- "Not authenticated": Run \`auth set-token \` first -- "Record not found": The requested ID does not exist -" -`; - exports[`cli docs generator generates CLI README 1`] = ` "# myapp CLI @@ -1582,209 +1349,6 @@ export function getClient(contextName?: string) { }" `; -exports[`hooks docs generator generates hooks AGENTS.md 1`] = ` -"# React Query Hooks - Agent Reference - - -> This document is structured for LLM/agent consumption. - -## OVERVIEW - -React Query hooks wrapping ORM operations for data fetching and mutations. -All query hooks return \`UseQueryResult\`. All mutation hooks return \`UseMutationResult\`. - -## SETUP - -\`\`\`typescript -import { configure } from './hooks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -configure({ endpoint: 'https://api.example.com/graphql' }); -const queryClient = new QueryClient(); -// Wrap app in -\`\`\` - -## HOOKS - -### HOOK: useCarsQuery - -List all cars. - -\`\`\` -TYPE: query -USAGE: useCarsQuery({ selection: { fields: { ... } } }) - -INPUT: - selection: { fields: Record } - Fields to select - -OUTPUT: UseQueryResult> -\`\`\` - -### HOOK: useCarQuery - -Get a single car by id. - -\`\`\` -TYPE: query -USAGE: useCarQuery({ id: '', selection: { fields: { ... } } }) - -INPUT: - id: string (required) - selection: { fields: Record } - Fields to select - -OUTPUT: UseQueryResult<{ - id: string - make: string - model: string - year: number - isElectric: boolean - createdAt: string -}> -\`\`\` - -### HOOK: useCreateCarMutation - -Create a new car. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useCreateCarMutation({ selection: { fields: { ... } } }) - -OUTPUT: UseMutationResult -\`\`\` - -### HOOK: useUpdateCarMutation - -Update an existing car. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useUpdateCarMutation({ selection: { fields: { ... } } }) - -OUTPUT: UseMutationResult -\`\`\` - -### HOOK: useDeleteCarMutation - -Delete a car. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useDeleteCarMutation({}) - -OUTPUT: UseMutationResult -\`\`\` - -### HOOK: useDriversQuery - -List all drivers. - -\`\`\` -TYPE: query -USAGE: useDriversQuery({ selection: { fields: { ... } } }) - -INPUT: - selection: { fields: Record } - Fields to select - -OUTPUT: UseQueryResult> -\`\`\` - -### HOOK: useDriverQuery - -Get a single driver by id. - -\`\`\` -TYPE: query -USAGE: useDriverQuery({ id: '', selection: { fields: { ... } } }) - -INPUT: - id: string (required) - selection: { fields: Record } - Fields to select - -OUTPUT: UseQueryResult<{ - id: string - name: string - licenseNumber: string -}> -\`\`\` - -### HOOK: useCreateDriverMutation - -Create a new driver. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useCreateDriverMutation({ selection: { fields: { ... } } }) - -OUTPUT: UseMutationResult -\`\`\` - -### HOOK: useUpdateDriverMutation - -Update an existing driver. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useUpdateDriverMutation({ selection: { fields: { ... } } }) - -OUTPUT: UseMutationResult -\`\`\` - -### HOOK: useDeleteDriverMutation - -Delete a driver. - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useDeleteDriverMutation({}) - -OUTPUT: UseMutationResult -\`\`\` - -## CUSTOM OPERATION HOOKS - -### HOOK: useCurrentUserQuery - -Get the currently authenticated user - -\`\`\` -TYPE: query -USAGE: useCurrentUserQuery() - -INPUT: none - -OUTPUT: UseQueryResult -\`\`\` - -### HOOK: useLoginMutation - -Authenticate a user - -\`\`\` -TYPE: mutation -USAGE: const { mutate } = useLoginMutation() - mutate({ email: , password: }) - -INPUT: - email: String (required) - password: String (required) - -OUTPUT: UseMutationResult -\`\`\` -" -`; - exports[`hooks docs generator generates hooks README 1`] = ` "# React Query Hooks @@ -2080,308 +1644,6 @@ See the \`references/\` directory for detailed per-entity API documentation: " `; -exports[`multi-target cli docs generates multi-target AGENTS.md 1`] = ` -"# myapp CLI - Agent Reference - - -> This document is structured for LLM/agent consumption. - -## OVERVIEW - -\`myapp\` is a unified multi-target CLI for interacting with multiple GraphQL APIs. -All commands output JSON to stdout. All commands accept \`--help\` or \`-h\` for usage. -Configuration is stored at \`~/.myapp/config/\` via appstash. - -TARGETS: - auth: http://auth.localhost/graphql - members: http://members.localhost/graphql - app: http://app.localhost/graphql - -COMMAND FORMAT: - myapp : [flags] Target-specific commands - myapp context [flags] Context management - myapp credentials [flags] Authentication - myapp config [flags] Config key-value store - -## PREREQUISITES - -Before running any data commands, you must: - -1. Create a context: \`myapp context create \` - (prompts for per-target endpoints, defaults baked from config) -2. Activate it: \`myapp context use \` -3. Authenticate: \`myapp credentials set-token \` - -For local development, create a context accepting all defaults: - -\`\`\`bash -myapp context create local -myapp context use local -myapp credentials set-token -\`\`\` - -## TOOLS - -### TOOL: context - -Manage named API endpoint contexts. Each context stores per-target endpoint overrides. - -\`\`\` -SUBCOMMANDS: - myapp context create Create a new context - myapp context list List all contexts - myapp context use Set active context - myapp context current Show active context - myapp context delete Delete a context - -CREATE OPTIONS: - --auth-endpoint: string (default: http://auth.localhost/graphql) - --members-endpoint: string (default: http://members.localhost/graphql) - --app-endpoint: string (default: http://app.localhost/graphql) - -OUTPUT: JSON - create: { name, endpoint, targets } - list: [{ name, endpoint, isCurrent, hasCredentials }] - use: { name, endpoint } - current: { name, endpoint } - delete: { deleted: name } -\`\`\` - -### TOOL: credentials - -Manage authentication tokens per context. One shared token across all targets. - -\`\`\` -SUBCOMMANDS: - myapp credentials set-token Store bearer token for current context - myapp credentials status Show auth status for all contexts - myapp credentials logout Remove credentials for current context - -INPUT: - token: string (required for set-token) - Bearer token value - -OUTPUT: JSON - set-token: { context, status: "authenticated" } - status: [{ context, authenticated: boolean }] - logout: { context, status: "logged out" } -\`\`\` - -### TOOL: config - -Manage per-context key-value configuration variables. - -\`\`\` -SUBCOMMANDS: - myapp config get Get a config value - myapp config set Set a config value - myapp config list List all config values - myapp config delete Delete a config value - -INPUT: - key: string (required for get/set/delete) - Variable name - value: string (required for set) - Variable value - -OUTPUT: JSON - get: { key, value } - set: { key, value } - list: { vars: { key: value, ... } } - delete: { deleted: key } -\`\`\` - -### TOOL: helpers (SDK) - -Typed client factories for use in scripts and services (generated helpers.ts). -Resolves credentials via: appstash store -> env vars -> throw. - -\`\`\` -FACTORIES: - createAuthClient(contextName?) Create a configured auth ORM client - createMembersClient(contextName?) Create a configured members ORM client - createAppClient(contextName?) Create a configured app ORM client - -USAGE: - import { createAuthClient } from './helpers'; - const client = createAuthClient(); - -CREDENTIAL RESOLUTION: - 1. appstash store (~/.myapp/config/) - 2. env vars (MYAPP_TOKEN, MYAPP__ENDPOINT) - 3. throws with actionable error message -\`\`\` - -### TOOL: auth:user - -CRUD operations for User records (auth target). - -\`\`\` -SUBCOMMANDS: - myapp auth:user list List all records - myapp auth:user get --id Get one record - myapp auth:user create --email --name - myapp auth:user update --id [--email ] [--name ] - myapp auth:user delete --id Delete one record - -INPUT FIELDS: - id: UUID (primary key) - email: String - name: String - -EDITABLE FIELDS (for create/update): - email: String - name: String - -OUTPUT: JSON - list: [{ id, email, name }] - get: { id, email, name } - create: { id, email, name } - update: { id, email, name } - delete: { id } -\`\`\` - -### TOOL: auth:current-user - -Get the currently authenticated user - -\`\`\` -TYPE: query -USAGE: myapp auth:current-user - -INPUT: none - -OUTPUT: JSON -\`\`\` - -### TOOL: auth:login - -Authenticate a user - -\`\`\` -TYPE: mutation -USAGE: myapp auth:login --email --password - -INPUT: - email: String (required) - password: String (required) - -FLAGS: - --save-token: boolean - Auto-save returned token to credentials - -OUTPUT: JSON -\`\`\` - -### TOOL: members:member - -CRUD operations for Member records (members target). - -\`\`\` -SUBCOMMANDS: - myapp members:member list List all records - myapp members:member get --id Get one record - myapp members:member create --role - myapp members:member update --id [--role ] - myapp members:member delete --id Delete one record - -INPUT FIELDS: - id: UUID (primary key) - role: String - -EDITABLE FIELDS (for create/update): - role: String - -OUTPUT: JSON - list: [{ id, role }] - get: { id, role } - create: { id, role } - update: { id, role } - delete: { id } -\`\`\` - -### TOOL: app:car - -CRUD operations for Car records (app target). - -\`\`\` -SUBCOMMANDS: - myapp app:car list List all records - myapp app:car get --id Get one record - myapp app:car create --make --model --year --isElectric - myapp app:car update --id [--make ] [--model ] [--year ] [--isElectric ] - myapp app:car delete --id Delete one record - -INPUT FIELDS: - id: UUID (primary key) - make: String - model: String - year: Int - isElectric: Boolean - createdAt: Datetime - -EDITABLE FIELDS (for create/update): - make: String - model: String - year: Int - isElectric: Boolean - -OUTPUT: JSON - list: [{ id, make, model, year, isElectric, createdAt }] - get: { id, make, model, year, isElectric, createdAt } - create: { id, make, model, year, isElectric, createdAt } - update: { id, make, model, year, isElectric, createdAt } - delete: { id } -\`\`\` - -## WORKFLOWS - -### Initial setup - -\`\`\`bash -myapp context create dev -myapp context use dev -myapp credentials set-token eyJhbGciOiJIUzI1NiIs... -\`\`\` - -### Switch environment - -\`\`\`bash -myapp context create production \\ - --auth-endpoint https://auth.prod.example.com/graphql \\ - --members-endpoint https://members.prod.example.com/graphql \\ - --app-endpoint https://app.prod.example.com/graphql -myapp context use production -\`\`\` - -### CRUD workflow (auth:user) - -\`\`\`bash -myapp auth:user list -myapp auth:user create --email "value" --name "value" -myapp auth:user get --id -myapp auth:user update --id --email "new-value" -myapp auth:user delete --id -\`\`\` - -### Piping output - -\`\`\`bash -myapp auth:user list | jq '.' -myapp auth:user list | jq '.[].id' -myapp auth:user list | jq 'length' -\`\`\` - -## ERROR HANDLING - -All errors are written to stderr. Exit codes: -- \`0\`: Success -- \`1\`: Error (auth failure, not found, validation error, network error) - -Common errors: -- "No active context": Run \`context use \` first -- "Not authenticated": Run \`credentials set-token \` first -- "Unknown target": The target name is not recognized -- "Record not found": The requested ID does not exist -" -`; - exports[`multi-target cli docs generates multi-target MCP tools 1`] = ` [ { @@ -4941,140 +4203,6 @@ async function handleDelete(argv: Partial>, prompter: In }" `; -exports[`orm docs generator generates ORM AGENTS.md 1`] = ` -"# ORM Client - Agent Reference - - -> This document is structured for LLM/agent consumption. - -## OVERVIEW - -Prisma-like ORM client for interacting with a GraphQL API. -All methods return a query builder. Call \`.execute()\` to run the query. - -## SETUP - -\`\`\`typescript -import { createClient } from './orm'; - -const db = createClient({ - endpoint: 'https://api.example.com/graphql', - headers: { Authorization: 'Bearer ' }, -}); -\`\`\` - -## MODELS - -### MODEL: car - -Access: \`db.car\` - -\`\`\` -METHODS: - db.car.findMany({ select, where?, orderBy?, first?, offset? }) - db.car.findOne({ id, select }) - db.car.create({ data: { make, model, year, isElectric }, select }) - db.car.update({ where: { id }, data, select }) - db.car.delete({ where: { id } }) - -FIELDS: - id: string (primary key) - make: string - model: string - year: number - isElectric: boolean - createdAt: string - -EDITABLE FIELDS: - make: string - model: string - year: number - isElectric: boolean - -OUTPUT: Promise - findMany: [{ id, make, model, year, isElectric, createdAt }] - findOne: { id, make, model, year, isElectric, createdAt } - create: { id, make, model, year, isElectric, createdAt } - update: { id, make, model, year, isElectric, createdAt } - delete: { id } -\`\`\` - -### MODEL: driver - -Access: \`db.driver\` - -\`\`\` -METHODS: - db.driver.findMany({ select, where?, orderBy?, first?, offset? }) - db.driver.findOne({ id, select }) - db.driver.create({ data: { name, licenseNumber }, select }) - db.driver.update({ where: { id }, data, select }) - db.driver.delete({ where: { id } }) - -FIELDS: - id: string (primary key) - name: string - licenseNumber: string - -EDITABLE FIELDS: - name: string - licenseNumber: string - -OUTPUT: Promise - findMany: [{ id, name, licenseNumber }] - findOne: { id, name, licenseNumber } - create: { id, name, licenseNumber } - update: { id, name, licenseNumber } - delete: { id } -\`\`\` - -## CUSTOM OPERATIONS - -### OPERATION: currentUser - -Get the currently authenticated user - -\`\`\` -TYPE: query -ACCESS: db.query.currentUser -USAGE: db.query.currentUser().execute() - -INPUT: none - -OUTPUT: Promise -\`\`\` - -### OPERATION: login - -Authenticate a user - -\`\`\` -TYPE: mutation -ACCESS: db.mutation.login -USAGE: db.mutation.login({ email: , password: }).execute() - -INPUT: - email: String (required) - password: String (required) - -OUTPUT: Promise -\`\`\` - -## PATTERNS - -\`\`\`typescript -// All methods require .execute() to run -const result = await db.modelName.findMany({ select: { id: true } }).execute(); - -// Select specific fields -const partial = await db.modelName.findMany({ select: { id: true, name: true } }).execute(); - -// Filter with where clause -const filtered = await db.modelName.findMany({ select: { id: true }, where: { name: 'test' } }).execute(); -\`\`\` -" -`; - exports[`orm docs generator generates ORM README 1`] = ` "# ORM Client diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index a8584ec26..f4372e7a6 100644 --- a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts @@ -1,11 +1,9 @@ import { generateCli, generateMultiTargetCli, resolveBuiltinNames } from '../../core/codegen/cli'; import { generateReadme as generateCliReadme, - generateAgentsDocs as generateCliAgentsDocs, getCliMcpTools, generateSkills as generateCliSkills, generateMultiTargetReadme, - generateMultiTargetAgentsDocs, getMultiTargetCliMcpTools, generateMultiTargetSkills, } from '../../core/codegen/cli/docs-generator'; @@ -13,13 +11,11 @@ import type { MultiTargetDocsInput } from '../../core/codegen/cli/docs-generator import { resolveDocsConfig } from '../../core/codegen/docs-utils'; import { generateOrmReadme, - generateOrmAgentsDocs, getOrmMcpTools, generateOrmSkills, } from '../../core/codegen/orm/docs-generator'; import { generateHooksReadme, - generateHooksAgentsDocs, getHooksMcpTools, generateHooksSkills, } from '../../core/codegen/hooks-docs-generator'; @@ -268,12 +264,6 @@ describe('cli docs generator', () => { expect(readme.content).toMatchSnapshot(); }); - it('generates CLI AGENTS.md', () => { - const agents = generateCliAgentsDocs([carTable, driverTable], allCustomOps, 'myapp'); - expect(agents.fileName).toBe('AGENTS.md'); - expect(agents.content).toMatchSnapshot(); - }); - it('generates CLI MCP tools', () => { const tools = getCliMcpTools([carTable, driverTable], allCustomOps, 'myapp'); expect(tools.length).toBeGreaterThan(0); @@ -300,12 +290,6 @@ describe('orm docs generator', () => { expect(readme.content).toMatchSnapshot(); }); - it('generates ORM AGENTS.md', () => { - const agents = generateOrmAgentsDocs([carTable, driverTable], allCustomOps); - expect(agents.fileName).toBe('AGENTS.md'); - expect(agents.content).toMatchSnapshot(); - }); - it('generates ORM MCP tools', () => { const tools = getOrmMcpTools([carTable, driverTable], allCustomOps); expect(tools.length).toBeGreaterThan(0); @@ -332,12 +316,6 @@ describe('hooks docs generator', () => { expect(readme.content).toMatchSnapshot(); }); - it('generates hooks AGENTS.md', () => { - const agents = generateHooksAgentsDocs([carTable, driverTable], allCustomOps); - expect(agents.fileName).toBe('AGENTS.md'); - expect(agents.content).toMatchSnapshot(); - }); - it('generates hooks MCP tools', () => { const tools = getHooksMcpTools([carTable, driverTable], allCustomOps); expect(tools.length).toBeGreaterThan(0); @@ -735,15 +713,6 @@ describe('multi-target cli docs', () => { expect(readme.content).toMatchSnapshot(); }); - it('generates multi-target AGENTS.md', () => { - const agents = generateMultiTargetAgentsDocs(docsInput); - expect(agents.fileName).toBe('AGENTS.md'); - expect(agents.content).toContain('auth:user'); - expect(agents.content).toContain('members:member'); - expect(agents.content).toContain('app:car'); - expect(agents.content).toMatchSnapshot(); - }); - it('generates multi-target MCP tools', () => { const tools = getMultiTargetCliMcpTools(docsInput); expect(tools.length).toBeGreaterThan(0); diff --git a/graphql/codegen/src/core/codegen/cli/docs-generator.ts b/graphql/codegen/src/core/codegen/cli/docs-generator.ts index 049b32b4b..ea2c4eb6d 100644 --- a/graphql/codegen/src/core/codegen/cli/docs-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/docs-generator.ts @@ -6,6 +6,9 @@ import { flattenedArgsToFlags, cleanTypeName, getEditableFields, + getSearchFields, + categorizeSpecialFields, + buildSpecialFieldsMarkdown, getReadmeHeader, getReadmeFooter, gqlTypeToJsonSchemaType, @@ -112,7 +115,7 @@ export function generateReadme( const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); lines.push(`### \`${kebab}\``); lines.push(''); @@ -146,6 +149,8 @@ export function generateReadme( if (requiredCreate.length === 0 && optionalCreate.length === 0) { lines.push(`**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}`); } + const specialGroups = categorizeSpecialFields(table, registry); + lines.push(...buildSpecialFieldsMarkdown(specialGroups)); lines.push(''); } } @@ -207,244 +212,49 @@ export function generateAgentsDocs( tables: CleanTable[], customOperations: CleanOperation[], toolName: string, - registry?: TypeRegistry, + _registry?: TypeRegistry, ): GeneratedDocFile { const lines: string[] = []; + const tableCount = tables.length; + const customOpCount = customOperations.length; - lines.push(`# ${toolName} CLI - Agent Reference`); + lines.push(`# ${toolName} CLI`); lines.push(''); lines.push(''); - lines.push('> This document is structured for LLM/agent consumption.'); - lines.push(''); - - lines.push('## OVERVIEW'); - lines.push(''); - lines.push(`\`${toolName}\` is a CLI tool for interacting with a GraphQL API.`); - lines.push('All commands output JSON to stdout. All commands accept `--help` or `-h` for usage.'); - lines.push(`Configuration is stored at \`~/.${toolName}/config/\` via appstash.`); - lines.push(''); - - lines.push('## PREREQUISITES'); - lines.push(''); - lines.push('Before running any data commands, you must:'); - lines.push(''); - lines.push(`1. Create a context: \`${toolName} context create --endpoint \``); - lines.push(`2. Activate it: \`${toolName} context use \``); - lines.push(`3. Authenticate: \`${toolName} auth set-token \``); - lines.push(''); - - lines.push('## TOOLS'); - lines.push(''); - - lines.push('### TOOL: context'); - lines.push(''); - lines.push('Manage named API endpoint contexts (like kubectl contexts).'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} context create --endpoint Create a new context`); - lines.push(` ${toolName} context list List all contexts`); - lines.push(` ${toolName} context use Set active context`); - lines.push(` ${toolName} context current Show active context`); - lines.push(` ${toolName} context delete Delete a context`); - lines.push(''); - lines.push('INPUT:'); - lines.push(' name: string (required) - Context identifier'); - lines.push(' endpoint: string (required for create) - GraphQL endpoint URL'); - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' create: { name, endpoint }'); - lines.push(' list: [{ name, endpoint, isCurrent, hasCredentials }]'); - lines.push(' use: { name, endpoint }'); - lines.push(' current: { name, endpoint }'); - lines.push(' delete: { deleted: name }'); - lines.push('```'); lines.push(''); - lines.push('### TOOL: auth'); + lines.push('## Stack'); lines.push(''); - lines.push('Manage authentication tokens per context.'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} auth set-token Store bearer token for current context`); - lines.push(` ${toolName} auth status Show auth status for all contexts`); - lines.push(` ${toolName} auth logout Remove credentials for current context`); - lines.push(''); - lines.push('INPUT:'); - lines.push(' token: string (required for set-token) - Bearer token value'); - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' set-token: { context, status: "authenticated" }'); - lines.push(' status: [{ context, authenticated: boolean }]'); - lines.push(' logout: { context, status: "logged out" }'); - lines.push('```'); + lines.push(`- Generated CLI for a GraphQL API (TypeScript)`); + lines.push(`- ${tableCount} table${tableCount !== 1 ? 's' : ''}${customOpCount > 0 ? `, ${customOpCount} custom operation${customOpCount !== 1 ? 's' : ''}` : ''}`); + lines.push(`- Config stored at \`~/.${toolName}/config/\` via appstash`); lines.push(''); - lines.push('### TOOL: config'); - lines.push(''); - lines.push('Manage per-context key-value configuration variables.'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} config get Get a config value`); - lines.push(` ${toolName} config set Set a config value`); - lines.push(` ${toolName} config list List all config values`); - lines.push(` ${toolName} config delete Delete a config value`); - lines.push(''); - lines.push('INPUT:'); - lines.push(' key: string (required for get/set/delete) - Variable name'); - lines.push(' value: string (required for set) - Variable value'); - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' get: { key, value }'); - lines.push(' set: { key, value }'); - lines.push(' list: { vars: { key: value, ... } }'); - lines.push(' delete: { deleted: key }'); - lines.push('```'); - lines.push(''); - - for (const table of tables) { - const { singularName } = getTableNames(table); - const kebab = toKebabCase(singularName); - const pk = getPrimaryKeyInfo(table)[0]; - const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); - const defaultFields = getFieldsWithDefaults(table, registry); - const requiredCreateFields = editableFields.filter((f) => !defaultFields.has(f.name)); - const optionalCreateFields = editableFields.filter((f) => defaultFields.has(f.name)); - const createFlags = [ - ...requiredCreateFields.map((f) => `--${f.name} `), - ...optionalCreateFields.map((f) => `[--${f.name} ]`), - ].join(' '); - - lines.push(`### TOOL: ${kebab}`); - lines.push(''); - lines.push(`CRUD operations for ${table.name} records.`); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} ${kebab} list List all records`); - lines.push(` ${toolName} ${kebab} get --${pk.name} Get one record`); - lines.push(` ${toolName} ${kebab} create ${createFlags}`); - lines.push(` ${toolName} ${kebab} update --${pk.name} ${editableFields.map((f) => `[--${f.name} ]`).join(' ')}`); - lines.push(` ${toolName} ${kebab} delete --${pk.name} Delete one record`); - lines.push(''); - lines.push('INPUT FIELDS:'); - for (const f of scalarFields) { - const isPk = f.name === pk.name; - lines.push(` ${f.name}: ${cleanTypeName(f.type.gqlType)}${isPk ? ' (primary key)' : ''}`); - } - lines.push(''); - lines.push('EDITABLE FIELDS (for create/update):'); - for (const f of editableFields) { - const optLabel = defaultFields.has(f.name) ? ' (optional, has backend default)' : ''; - lines.push(` ${f.name}: ${cleanTypeName(f.type.gqlType)}${optLabel}`); - } - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(` list: [{ ${scalarFields.map((f) => f.name).join(', ')} }]`); - lines.push(` get: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` create: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` update: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` delete: { ${pk.name} }`); - lines.push('```'); - lines.push(''); - } - - for (const op of customOperations) { - const kebab = toKebabCase(op.name); - const flat = flattenArgs(op.args, registry); - - lines.push(`### TOOL: ${kebab}`); - lines.push(''); - lines.push(op.description || op.name); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: ${op.kind}`); - if (flat.length > 0) { - const flags = flattenedArgsToFlags(flat); - lines.push(`USAGE: ${toolName} ${kebab} ${flags}`); - lines.push(''); - lines.push('INPUT:'); - for (const a of flat) { - const reqLabel = a.required ? ' (required)' : ''; - lines.push(` ${a.flag}: ${a.type}${reqLabel}`); - } - } else { - lines.push(`USAGE: ${toolName} ${kebab}`); - lines.push(''); - lines.push('INPUT: none'); - } - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push('```'); - lines.push(''); - } - - lines.push('## WORKFLOWS'); - lines.push(''); - lines.push('### Initial setup'); + lines.push('## Quick Start'); lines.push(''); lines.push('```bash'); - lines.push(`${toolName} context create dev --endpoint http://localhost:5000/graphql`); + lines.push(`${toolName} context create dev --endpoint `); lines.push(`${toolName} context use dev`); - lines.push(`${toolName} auth set-token eyJhbGciOiJIUzI1NiIs...`); + lines.push(`${toolName} auth set-token `); lines.push('```'); lines.push(''); - if (tables.length > 0) { - const firstTable = tables[0]; - const { singularName } = getTableNames(firstTable); - const kebab = toKebabCase(singularName); - const editableFields = getEditableFields(firstTable); - const pk = getPrimaryKeyInfo(firstTable)[0]; - - lines.push(`### CRUD workflow (${kebab})`); - lines.push(''); - lines.push('```bash'); - lines.push(`# List all`); - lines.push(`${toolName} ${kebab} list`); - lines.push(''); - lines.push(`# Create`); - lines.push(`${toolName} ${kebab} create ${editableFields.map((f) => `--${f.name} "value"`).join(' ')}`); - lines.push(''); - lines.push(`# Get by ${pk.name}`); - lines.push(`${toolName} ${kebab} get --${pk.name} `); - lines.push(''); - lines.push(`# Update`); - lines.push(`${toolName} ${kebab} update --${pk.name} --${editableFields[0]?.name || 'field'} "new-value"`); - lines.push(''); - lines.push(`# Delete`); - lines.push(`${toolName} ${kebab} delete --${pk.name} `); - lines.push('```'); - lines.push(''); - } - - lines.push('### Piping output'); + lines.push('## Resources'); lines.push(''); - lines.push('```bash'); - lines.push(`# Pretty print`); - lines.push(`${toolName} car list | jq '.'`); + lines.push(`- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${tableCount} tables`); + lines.push('- **Schema types:** [types.ts](./types.ts)'); lines.push(''); - lines.push(`# Extract field`); - lines.push(`${toolName} car list | jq '.[].id'`); + + lines.push('## Conventions'); lines.push(''); - lines.push(`# Count results`); - lines.push(`${toolName} car list | jq 'length'`); - lines.push('```'); + lines.push('- All commands output JSON to stdout'); + lines.push('- Use `--help` on any command for usage'); + lines.push('- Exit 0 = success, 1 = error'); lines.push(''); - lines.push('## ERROR HANDLING'); + lines.push('## Boundaries'); lines.push(''); - lines.push('All errors are written to stderr. Exit codes:'); - lines.push('- `0`: Success'); - lines.push('- `1`: Error (auth failure, not found, validation error, network error)'); - lines.push(''); - lines.push('Common errors:'); - lines.push('- "No active context": Run `context use ` first'); - lines.push('- "Not authenticated": Run `auth set-token ` first'); - lines.push('- "Record not found": The requested ID does not exist'); + lines.push('All files in this directory are generated. Do not edit manually.'); lines.push(''); return { @@ -582,7 +392,7 @@ export function getCliMcpTools( const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const requiredCreateFieldNames = editableFields .filter((f) => !defaultFields.has(f.name)) @@ -808,7 +618,7 @@ export function generateSkills( const { singularName } = getTableNames(table); const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const createFlags = [ ...editableFields.filter((f) => !defaultFields.has(f.name)).map((f) => `--${f.name} `), @@ -817,11 +627,17 @@ export function generateSkills( referenceNames.push(kebab); + const skillSpecialGroups = categorizeSpecialFields(table, registry); + const skillSpecialDesc = skillSpecialGroups.length > 0 + ? `CRUD operations for ${table.name} records via ${toolName} CLI\n\n` + + skillSpecialGroups.map((g) => `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}`).join('\n\n') + : `CRUD operations for ${table.name} records via ${toolName} CLI`; + files.push({ fileName: `${skillName}/references/${kebab}.md`, content: buildSkillReference({ title: singularName, - description: `CRUD operations for ${table.name} records via ${toolName} CLI`, + description: skillSpecialDesc, usage: [ `${toolName} ${kebab} list`, `${toolName} ${kebab} get --${pk.name} `, @@ -1117,7 +933,7 @@ export function generateMultiTargetReadme( const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); lines.push(`### \`${tgt.name}:${kebab}\``); @@ -1151,6 +967,8 @@ export function generateMultiTargetReadme( if (requiredCreate.length === 0 && optionalCreate.length === 0) { lines.push(`**Create fields:** ${editableFields.map((f) => `\`${f.name}\``).join(', ')}`); } + const mtSpecialGroups = categorizeSpecialFields(table, registry); + lines.push(...buildSpecialFieldsMarkdown(mtSpecialGroups)); lines.push(''); } @@ -1218,300 +1036,57 @@ export function generateMultiTargetReadme( export function generateMultiTargetAgentsDocs( input: MultiTargetDocsInput, ): GeneratedDocFile { - const { toolName, builtinNames, targets, registry } = input; + const { toolName, builtinNames, targets } = input; const lines: string[] = []; + const totalTables = targets.reduce((sum, t) => sum + t.tables.length, 0); + const totalCustomOps = targets.reduce((sum, t) => sum + t.customOperations.length, 0); - lines.push(`# ${toolName} CLI - Agent Reference`); + lines.push(`# ${toolName} CLI`); lines.push(''); lines.push(''); - lines.push('> This document is structured for LLM/agent consumption.'); lines.push(''); - lines.push('## OVERVIEW'); - lines.push(''); - lines.push(`\`${toolName}\` is a unified multi-target CLI for interacting with multiple GraphQL APIs.`); - lines.push('All commands output JSON to stdout. All commands accept `--help` or `-h` for usage.'); - lines.push(`Configuration is stored at \`~/.${toolName}/config/\` via appstash.`); - lines.push(''); - lines.push('TARGETS:'); - for (const tgt of targets) { - lines.push(` ${tgt.name}: ${tgt.endpoint}`); - } + lines.push('## Stack'); lines.push(''); - lines.push('COMMAND FORMAT:'); - lines.push(` ${toolName} : [flags] Target-specific commands`); - lines.push(` ${toolName} ${builtinNames.context} [flags] Context management`); - lines.push(` ${toolName} ${builtinNames.auth} [flags] Authentication`); - lines.push(` ${toolName} ${builtinNames.config} [flags] Config key-value store`); + lines.push(`- Unified multi-target CLI for GraphQL APIs (TypeScript)`); + lines.push(`- ${targets.length} target${targets.length !== 1 ? 's' : ''}: ${targets.map((t) => t.name).join(', ')}`); + lines.push(`- ${totalTables} table${totalTables !== 1 ? 's' : ''}${totalCustomOps > 0 ? `, ${totalCustomOps} custom operation${totalCustomOps !== 1 ? 's' : ''}` : ''}`); + lines.push(`- Config stored at \`~/.${toolName}/config/\` via appstash`); lines.push(''); - lines.push('## PREREQUISITES'); - lines.push(''); - lines.push('Before running any data commands, you must:'); - lines.push(''); - lines.push(`1. Create a context: \`${toolName} ${builtinNames.context} create \``); - lines.push(` (prompts for per-target endpoints, defaults baked from config)`); - lines.push(`2. Activate it: \`${toolName} ${builtinNames.context} use \``); - lines.push(`3. Authenticate: \`${toolName} ${builtinNames.auth} set-token \``); - lines.push(''); - lines.push('For local development, create a context accepting all defaults:'); + lines.push('## Quick Start'); lines.push(''); lines.push('```bash'); - lines.push(`${toolName} ${builtinNames.context} create local`); - lines.push(`${toolName} ${builtinNames.context} use local`); + lines.push(`${toolName} ${builtinNames.context} create dev`); + lines.push(`${toolName} ${builtinNames.context} use dev`); lines.push(`${toolName} ${builtinNames.auth} set-token `); lines.push('```'); lines.push(''); - lines.push('## TOOLS'); - lines.push(''); - - lines.push(`### TOOL: ${builtinNames.context}`); - lines.push(''); - lines.push('Manage named API endpoint contexts. Each context stores per-target endpoint overrides.'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} ${builtinNames.context} create Create a new context`); - lines.push(` ${toolName} ${builtinNames.context} list List all contexts`); - lines.push(` ${toolName} ${builtinNames.context} use Set active context`); - lines.push(` ${toolName} ${builtinNames.context} current Show active context`); - lines.push(` ${toolName} ${builtinNames.context} delete Delete a context`); - lines.push(''); - lines.push('CREATE OPTIONS:'); - for (const tgt of targets) { - lines.push(` --${tgt.name}-endpoint: string (default: ${tgt.endpoint})`); - } - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' create: { name, endpoint, targets }'); - lines.push(' list: [{ name, endpoint, isCurrent, hasCredentials }]'); - lines.push(' use: { name, endpoint }'); - lines.push(' current: { name, endpoint }'); - lines.push(' delete: { deleted: name }'); - lines.push('```'); - lines.push(''); - - lines.push(`### TOOL: ${builtinNames.auth}`); - lines.push(''); - lines.push('Manage authentication tokens per context. One shared token across all targets.'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} ${builtinNames.auth} set-token Store bearer token for current context`); - lines.push(` ${toolName} ${builtinNames.auth} status Show auth status for all contexts`); - lines.push(` ${toolName} ${builtinNames.auth} logout Remove credentials for current context`); - lines.push(''); - lines.push('INPUT:'); - lines.push(' token: string (required for set-token) - Bearer token value'); - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' set-token: { context, status: "authenticated" }'); - lines.push(' status: [{ context, authenticated: boolean }]'); - lines.push(' logout: { context, status: "logged out" }'); - lines.push('```'); - lines.push(''); - - lines.push(`### TOOL: ${builtinNames.config}`); - lines.push(''); - lines.push('Manage per-context key-value configuration variables.'); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} ${builtinNames.config} get Get a config value`); - lines.push(` ${toolName} ${builtinNames.config} set Set a config value`); - lines.push(` ${toolName} ${builtinNames.config} list List all config values`); - lines.push(` ${toolName} ${builtinNames.config} delete Delete a config value`); - lines.push(''); - lines.push('INPUT:'); - lines.push(' key: string (required for get/set/delete) - Variable name'); - lines.push(' value: string (required for set) - Variable value'); - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(' get: { key, value }'); - lines.push(' set: { key, value }'); - lines.push(' list: { vars: { key: value, ... } }'); - lines.push(' delete: { deleted: key }'); - lines.push('```'); - lines.push(''); - - lines.push('### TOOL: helpers (SDK)'); - lines.push(''); - lines.push('Typed client factories for use in scripts and services (generated helpers.ts).'); - lines.push('Resolves credentials via: appstash store -> env vars -> throw.'); + lines.push('## Command Format'); lines.push(''); lines.push('```'); - lines.push('FACTORIES:'); - for (const tgt of targets) { - const pascalName = tgt.name.charAt(0).toUpperCase() + tgt.name.slice(1); - lines.push(` create${pascalName}Client(contextName?) Create a configured ${tgt.name} ORM client`); - } - lines.push(''); - lines.push('USAGE:'); - lines.push(` import { create${targets[0] ? targets[0].name.charAt(0).toUpperCase() + targets[0].name.slice(1) : 'Target'}Client } from './helpers';`); - lines.push(` const client = create${targets[0] ? targets[0].name.charAt(0).toUpperCase() + targets[0].name.slice(1) : 'Target'}Client();`); - lines.push(''); - lines.push('CREDENTIAL RESOLUTION:'); - lines.push(` 1. appstash store (~/.${toolName}/config/)`); - const envPrefix = toolName.toUpperCase().replace(/-/g, '_'); - lines.push(` 2. env vars (${envPrefix}_TOKEN, ${envPrefix}__ENDPOINT)`); - lines.push(' 3. throws with actionable error message'); - lines.push('```'); - lines.push(''); - - for (const tgt of targets) { - for (const table of tgt.tables) { - const { singularName } = getTableNames(table); - const kebab = toKebabCase(singularName); - const pk = getPrimaryKeyInfo(table)[0]; - const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); - const defaultFields = getFieldsWithDefaults(table, registry); - const requiredCreateFields = editableFields.filter((f) => !defaultFields.has(f.name)); - const optionalCreateFields = editableFields.filter((f) => defaultFields.has(f.name)); - const createFlags = [ - ...requiredCreateFields.map((f) => `--${f.name} `), - ...optionalCreateFields.map((f) => `[--${f.name} ]`), - ].join(' '); - - lines.push(`### TOOL: ${tgt.name}:${kebab}`); - lines.push(''); - lines.push(`CRUD operations for ${table.name} records (${tgt.name} target).`); - lines.push(''); - lines.push('```'); - lines.push('SUBCOMMANDS:'); - lines.push(` ${toolName} ${tgt.name}:${kebab} list List all records`); - lines.push(` ${toolName} ${tgt.name}:${kebab} get --${pk.name} Get one record`); - lines.push(` ${toolName} ${tgt.name}:${kebab} create ${createFlags}`); - lines.push(` ${toolName} ${tgt.name}:${kebab} update --${pk.name} ${editableFields.map((f) => `[--${f.name} ]`).join(' ')}`); - lines.push(` ${toolName} ${tgt.name}:${kebab} delete --${pk.name} Delete one record`); - lines.push(''); - lines.push('INPUT FIELDS:'); - for (const f of scalarFields) { - const isPk = f.name === pk.name; - lines.push(` ${f.name}: ${cleanTypeName(f.type.gqlType)}${isPk ? ' (primary key)' : ''}`); - } - lines.push(''); - lines.push('EDITABLE FIELDS (for create/update):'); - for (const f of editableFields) { - const optLabel = defaultFields.has(f.name) ? ' (optional, has backend default)' : ''; - lines.push(` ${f.name}: ${cleanTypeName(f.type.gqlType)}${optLabel}`); - } - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push(` list: [{ ${scalarFields.map((f) => f.name).join(', ')} }]`); - lines.push(` get: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` create: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` update: { ${scalarFields.map((f) => f.name).join(', ')} }`); - lines.push(` delete: { ${pk.name} }`); - lines.push('```'); - lines.push(''); - } - - for (const op of tgt.customOperations) { - const kebab = toKebabCase(op.name); - const flat = flattenArgs(op.args, registry); - - lines.push(`### TOOL: ${tgt.name}:${kebab}`); - lines.push(''); - lines.push(op.description || op.name); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: ${op.kind}`); - if (flat.length > 0) { - const flags = flattenedArgsToFlags(flat); - lines.push(`USAGE: ${toolName} ${tgt.name}:${kebab} ${flags}`); - lines.push(''); - lines.push('INPUT:'); - for (const a of flat) { - const reqLabel = a.required ? ' (required)' : ''; - lines.push(` ${a.flag}: ${a.type}${reqLabel}`); - } - } else { - lines.push(`USAGE: ${toolName} ${tgt.name}:${kebab}`); - lines.push(''); - lines.push('INPUT: none'); - } - if (tgt.isAuthTarget && op.kind === 'mutation') { - lines.push(''); - lines.push('FLAGS:'); - lines.push(' --save-token: boolean - Auto-save returned token to credentials'); - } - lines.push(''); - lines.push('OUTPUT: JSON'); - lines.push('```'); - lines.push(''); - } - } - - lines.push('## WORKFLOWS'); - lines.push(''); - lines.push('### Initial setup'); - lines.push(''); - lines.push('```bash'); - lines.push(`${toolName} ${builtinNames.context} create dev`); - lines.push(`${toolName} ${builtinNames.context} use dev`); - lines.push(`${toolName} ${builtinNames.auth} set-token eyJhbGciOiJIUzI1NiIs...`); + lines.push(`${toolName} : [flags]`); lines.push('```'); lines.push(''); - lines.push('### Switch environment'); + lines.push('## Resources'); lines.push(''); - lines.push('```bash'); - lines.push(`${toolName} ${builtinNames.context} create production \\`); - for (let i = 0; i < targets.length; i++) { - const tgt = targets[i]; - const continuation = i < targets.length - 1 ? ' \\' : ''; - lines.push(` --${tgt.name}-endpoint https://${tgt.name}.prod.example.com/graphql${continuation}`); - } - lines.push(`${toolName} ${builtinNames.context} use production`); - lines.push('```'); + lines.push(`- **Full API reference:** [README.md](./README.md) — CRUD docs for all ${totalTables} tables across ${targets.length} targets`); + lines.push('- **Schema types:** [types.ts](./types.ts)'); + lines.push('- **SDK helpers:** [helpers.ts](./helpers.ts) — typed client factories'); lines.push(''); - if (targets.length > 0 && targets[0].tables.length > 0) { - const tgt = targets[0]; - const table = tgt.tables[0]; - const { singularName } = getTableNames(table); - const kebab = toKebabCase(singularName); - const editableFields = getEditableFields(table); - const pk = getPrimaryKeyInfo(table)[0]; - - lines.push(`### CRUD workflow (${tgt.name}:${kebab})`); - lines.push(''); - lines.push('```bash'); - lines.push(`${toolName} ${tgt.name}:${kebab} list`); - lines.push(`${toolName} ${tgt.name}:${kebab} create ${editableFields.map((f) => `--${f.name} "value"`).join(' ')}`); - lines.push(`${toolName} ${tgt.name}:${kebab} get --${pk.name} `); - lines.push(`${toolName} ${tgt.name}:${kebab} update --${pk.name} --${editableFields[0]?.name || 'field'} "new-value"`); - lines.push(`${toolName} ${tgt.name}:${kebab} delete --${pk.name} `); - lines.push('```'); - lines.push(''); - } - - lines.push('### Piping output'); + lines.push('## Conventions'); lines.push(''); - lines.push('```bash'); - if (targets.length > 0 && targets[0].tables.length > 0) { - const tgt = targets[0]; - const kebab = toKebabCase(getTableNames(tgt.tables[0]).singularName); - lines.push(`${toolName} ${tgt.name}:${kebab} list | jq '.'`); - lines.push(`${toolName} ${tgt.name}:${kebab} list | jq '.[].id'`); - lines.push(`${toolName} ${tgt.name}:${kebab} list | jq 'length'`); - } - lines.push('```'); + lines.push('- All commands output JSON to stdout'); + lines.push('- Use `--help` on any command for usage'); + lines.push('- Exit 0 = success, 1 = error'); lines.push(''); - lines.push('## ERROR HANDLING'); - lines.push(''); - lines.push('All errors are written to stderr. Exit codes:'); - lines.push('- `0`: Success'); - lines.push('- `1`: Error (auth failure, not found, validation error, network error)'); + lines.push('## Boundaries'); lines.push(''); - lines.push('Common errors:'); - lines.push(`- "No active context": Run \`${builtinNames.context} use \` first`); - lines.push(`- "Not authenticated": Run \`${builtinNames.auth} set-token \` first`); - lines.push('- "Unknown target": The target name is not recognized'); - lines.push('- "Record not found": The requested ID does not exist'); + lines.push('All files in this directory are generated. Do not edit manually.'); lines.push(''); return { @@ -1654,7 +1229,7 @@ export function getMultiTargetCliMcpTools( const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const requiredCreateFieldNames = editableFields .filter((f) => !defaultFields.has(f.name)) @@ -1941,7 +1516,7 @@ export function generateMultiTargetSkills( const { singularName } = getTableNames(table); const kebab = toKebabCase(singularName); const pk = getPrimaryKeyInfo(table)[0]; - const editableFields = getEditableFields(table); + const editableFields = getEditableFields(table, registry); const defaultFields = getFieldsWithDefaults(table, registry); const createFlags = [ ...editableFields.filter((f) => !defaultFields.has(f.name)).map((f) => `--${f.name} `), @@ -1951,11 +1526,17 @@ export function generateMultiTargetSkills( tgtReferenceNames.push(kebab); + const mtSkillSpecialGroups = categorizeSpecialFields(table, registry); + const mtSkillSpecialDesc = mtSkillSpecialGroups.length > 0 + ? `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)\n\n` + + mtSkillSpecialGroups.map((g) => `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}`).join('\n\n') + : `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)`; + files.push({ fileName: `${tgtSkillName}/references/${kebab}.md`, content: buildSkillReference({ title: singularName, - description: `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)`, + description: mtSkillSpecialDesc, usage: [ `${toolName} ${cmd} list`, `${toolName} ${cmd} get --${pk.name} `, diff --git a/graphql/codegen/src/core/codegen/docs-utils.ts b/graphql/codegen/src/core/codegen/docs-utils.ts index 33ecf6ef8..a56db2d6b 100644 --- a/graphql/codegen/src/core/codegen/docs-utils.ts +++ b/graphql/codegen/src/core/codegen/docs-utils.ts @@ -1,6 +1,6 @@ import type { DocsConfig } from '../../types/config'; import type { CleanArgument, CleanField, CleanOperation, CleanTable, CleanTypeRef, TypeRegistry } from '../../types/schema'; -import { getScalarFields, getPrimaryKeyInfo } from './utils'; +import { getScalarFields, getPrimaryKeyInfo, getWritableFieldNames } from './utils'; export interface GeneratedDocFile { fileName: string; @@ -103,17 +103,184 @@ export function formatTypeRef( return t.name ?? 'unknown'; } -export function getEditableFields(table: CleanTable): CleanField[] { +export function getEditableFields(table: CleanTable, typeRegistry?: TypeRegistry): CleanField[] { const pk = getPrimaryKeyInfo(table)[0]; + const writableFields = getWritableFieldNames(table, typeRegistry); return getScalarFields(table).filter( (f) => f.name !== pk.name && f.name !== 'nodeId' && f.name !== 'createdAt' && - f.name !== 'updatedAt', + f.name !== 'updatedAt' && + // When a TypeRegistry is available, filter out computed/plugin-added + // fields (e.g. search scores, trgm similarity) that aren't real columns + (writableFields === null || writableFields.has(f.name)), ); } +/** + * Identify search/computed fields on a table — fields present in the GraphQL + * type but NOT in the create input type. These are plugin-added fields like + * trgm similarity scores, tsvector ranks, searchScore, etc. + */ +export function getSearchFields(table: CleanTable, typeRegistry?: TypeRegistry): CleanField[] { + const writableFields = getWritableFieldNames(table, typeRegistry); + if (writableFields === null) return []; + const pk = getPrimaryKeyInfo(table)[0]; + return getScalarFields(table).filter( + (f) => + f.name !== pk.name && + f.name !== 'nodeId' && + f.name !== 'createdAt' && + f.name !== 'updatedAt' && + !writableFields.has(f.name), + ); +} + +// --------------------------------------------------------------------------- +// Special field categorization — PostGIS, pgvector, Unified Search +// --------------------------------------------------------------------------- + +export interface SpecialFieldGroup { + /** Category key */ + category: 'geospatial' | 'embedding' | 'search'; + /** Human-readable label */ + label: string; + /** One-line description of this category */ + description: string; + /** Fields belonging to this category */ + fields: CleanField[]; +} + +function isPostGISField(f: CleanField): boolean { + const pgType = f.type.pgType?.toLowerCase(); + if (pgType === 'geometry' || pgType === 'geography') return true; + const gql = f.type.gqlType; + if (/^(GeoJSON|GeographyPoint|GeographyLineString|GeographyPolygon|GeometryPoint|GeometryLineString|GeometryPolygon|GeographyMulti|GeometryMulti|GeometryCollection|GeographyCollection)/i.test(gql)) return true; + return false; +} + +function isEmbeddingField(f: CleanField): boolean { + const pgType = f.type.pgType?.toLowerCase(); + if (pgType === 'vector') return true; + if (/embedding$/i.test(f.name) && f.type.isArray && f.type.gqlType === 'Float') return true; + return false; +} + +function isTsvectorField(f: CleanField): boolean { + const pgType = f.type.pgType?.toLowerCase(); + return pgType === 'tsvector'; +} + +function isSearchComputedField(f: CleanField): boolean { + if (f.name === 'searchScore') return true; + if (/TrgmSimilarity$/.test(f.name)) return true; + if (/TsvectorRank$/.test(f.name)) return true; + if (/Bm25Score$/.test(f.name)) return true; + return false; +} + +/** + * Categorize "special" fields on a table into PostGIS, pgvector, and + * Unified Search groups. Returns only non-empty groups. + * + * The function inspects ALL scalar fields (not just computed ones) so that + * real columns (geometry, vector, tsvector) are also surfaced with + * descriptive context in generated docs. + */ +export function categorizeSpecialFields( + table: CleanTable, + typeRegistry?: TypeRegistry, +): SpecialFieldGroup[] { + const allFields = getScalarFields(table); + const computedFields = getSearchFields(table, typeRegistry); + const computedSet = new Set(computedFields.map((f) => f.name)); + + const geospatial: CleanField[] = []; + const embedding: CleanField[] = []; + const search: CleanField[] = []; + + for (const f of allFields) { + if (isPostGISField(f)) { + geospatial.push(f); + } else if (isEmbeddingField(f)) { + embedding.push(f); + } else if (isTsvectorField(f)) { + search.push(f); + } else if (computedSet.has(f.name) && isSearchComputedField(f)) { + search.push(f); + } + } + + const groups: SpecialFieldGroup[] = []; + + if (geospatial.length > 0) { + groups.push({ + category: 'geospatial', + label: 'PostGIS geospatial fields', + description: + 'Geographic/geometric columns managed by PostGIS. Supports spatial queries (distance, containment, intersection) via the Unified Search API PostGIS adapter.', + fields: geospatial, + }); + } + + if (embedding.length > 0) { + groups.push({ + category: 'embedding', + label: 'pgvector embedding fields', + description: + 'High-dimensional vector columns for semantic similarity search. Query via the Unified Search API pgvector adapter using cosine, L2, or inner-product distance.', + fields: embedding, + }); + } + + if (search.length > 0) { + groups.push({ + category: 'search', + label: 'Unified Search API fields', + description: + 'Fields provided by the Unified Search plugin. Includes full-text search (tsvector/BM25), trigram similarity scores, and the combined searchScore. Computed fields are read-only and cannot be set in create/update operations.', + fields: search, + }); + } + + return groups; +} + +/** + * Build markdown lines describing special fields for README-style docs. + * Returns empty array when there are no special fields. + */ +export function buildSpecialFieldsMarkdown(groups: SpecialFieldGroup[]): string[] { + if (groups.length === 0) return []; + const lines: string[] = []; + for (const g of groups) { + const fieldList = g.fields.map((f) => `\`${f.name}\``).join(', '); + lines.push(`> **${g.label}:** ${fieldList}`); + lines.push(`> ${g.description}`); + lines.push(''); + } + return lines; +} + +/** + * Build plain-text lines describing special fields for AGENTS-style docs. + * Returns empty array when there are no special fields. + */ +export function buildSpecialFieldsPlain(groups: SpecialFieldGroup[]): string[] { + if (groups.length === 0) return []; + const lines: string[] = []; + lines.push('SPECIAL FIELDS:'); + for (const g of groups) { + lines.push(` [${g.label}]`); + lines.push(` ${g.description}`); + for (const f of g.fields) { + lines.push(` ${f.name}: ${cleanTypeName(f.type.gqlType)}`); + } + } + return lines; +} + /** * Represents a flattened argument for docs/skills generation. * INPUT_OBJECT args are expanded to dot-notation fields. diff --git a/graphql/codegen/src/core/codegen/hooks-docs-generator.ts b/graphql/codegen/src/core/codegen/hooks-docs-generator.ts index 18453f953..4d455973d 100644 --- a/graphql/codegen/src/core/codegen/hooks-docs-generator.ts +++ b/graphql/codegen/src/core/codegen/hooks-docs-generator.ts @@ -184,24 +184,22 @@ export function generateHooksAgentsDocs( customOperations: CleanOperation[], ): GeneratedDocFile { const lines: string[] = []; + const tableCount = tables.length; + const customOpCount = customOperations.length; - lines.push('# React Query Hooks - Agent Reference'); + lines.push('# React Query Hooks'); lines.push(''); lines.push(''); - lines.push('> This document is structured for LLM/agent consumption.'); lines.push(''); - lines.push('## OVERVIEW'); + lines.push('## Stack'); lines.push(''); - lines.push( - 'React Query hooks wrapping ORM operations for data fetching and mutations.', - ); - lines.push( - 'All query hooks return `UseQueryResult`. All mutation hooks return `UseMutationResult`.', - ); + lines.push('- React Query hooks wrapping ORM operations (TypeScript)'); + lines.push(`- ${tableCount} table${tableCount !== 1 ? 's' : ''}${customOpCount > 0 ? `, ${customOpCount} custom operation${customOpCount !== 1 ? 's' : ''}` : ''}`); + lines.push('- Query hooks return `UseQueryResult`, mutation hooks return `UseMutationResult`'); lines.push(''); - lines.push('## SETUP'); + lines.push('## Quick Start'); lines.push(''); lines.push('```typescript'); lines.push("import { configure } from './hooks';"); @@ -213,150 +211,24 @@ export function generateHooksAgentsDocs( lines.push('```'); lines.push(''); - lines.push('## HOOKS'); + lines.push('## Resources'); + lines.push(''); + lines.push(`- **Full API reference:** [README.md](./README.md) — hook docs for all ${tableCount} tables`); + lines.push('- **Schema types:** [types.ts](./types.ts)'); + lines.push('- **Hooks module:** [hooks.ts](./hooks.ts)'); lines.push(''); - for (const table of tables) { - const { singularName, pluralName } = getTableNames(table); - const pk = getPrimaryKeyInfo(table)[0]; - const scalarFields = getScalarFields(table); - - lines.push(`### HOOK: ${getListQueryHookName(table)}`); - lines.push(''); - lines.push(`${table.description || `List all ${pluralName}`}.`); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: query`); - lines.push( - `USAGE: ${getListQueryHookName(table)}({ selection: { fields: { ... } } })`, - ); - lines.push(''); - lines.push('INPUT:'); - lines.push( - ' selection: { fields: Record } - Fields to select', - ); - lines.push(''); - lines.push('OUTPUT: UseQueryResult>'); - lines.push('```'); - lines.push(''); - - if (hasValidPrimaryKey(table)) { - lines.push(`### HOOK: ${getSingleQueryHookName(table)}`); - lines.push(''); - lines.push(`${table.description || `Get a single ${singularName} by ${pk.name}`}.`); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: query`); - lines.push( - `USAGE: ${getSingleQueryHookName(table)}({ ${pk.name}: '', selection: { fields: { ... } } })`, - ); - lines.push(''); - lines.push('INPUT:'); - lines.push(` ${pk.name}: ${pk.tsType} (required)`); - lines.push( - ' selection: { fields: Record } - Fields to select', - ); - lines.push(''); - lines.push('OUTPUT: UseQueryResult<{'); - for (const f of scalarFields) { - lines.push(` ${f.name}: ${fieldTypeToTs(f.type)}`); - } - lines.push('}>'); - lines.push('```'); - lines.push(''); - } - - lines.push(`### HOOK: ${getCreateMutationHookName(table)}`); - lines.push(''); - lines.push(`${table.description || `Create a new ${singularName}`}.`); - lines.push(''); - lines.push('```'); - lines.push('TYPE: mutation'); - lines.push( - `USAGE: const { mutate } = ${getCreateMutationHookName(table)}({ selection: { fields: { ... } } })`, - ); - lines.push(''); - lines.push('OUTPUT: UseMutationResult'); - lines.push('```'); - lines.push(''); - - if (hasValidPrimaryKey(table)) { - lines.push(`### HOOK: ${getUpdateMutationHookName(table)}`); - lines.push(''); - lines.push(`${table.description || `Update an existing ${singularName}`}.`); - lines.push(''); - lines.push('```'); - lines.push('TYPE: mutation'); - lines.push( - `USAGE: const { mutate } = ${getUpdateMutationHookName(table)}({ selection: { fields: { ... } } })`, - ); - lines.push(''); - lines.push('OUTPUT: UseMutationResult'); - lines.push('```'); - lines.push(''); - - lines.push(`### HOOK: ${getDeleteMutationHookName(table)}`); - lines.push(''); - lines.push(`${table.description || `Delete a ${singularName}`}.`); - lines.push(''); - lines.push('```'); - lines.push('TYPE: mutation'); - lines.push( - `USAGE: const { mutate } = ${getDeleteMutationHookName(table)}({})`, - ); - lines.push(''); - lines.push('OUTPUT: UseMutationResult'); - lines.push('```'); - lines.push(''); - } - } - - if (customOperations.length > 0) { - lines.push('## CUSTOM OPERATION HOOKS'); - lines.push(''); - - for (const op of customOperations) { - const hookName = getCustomHookName(op); + lines.push('## Conventions'); + lines.push(''); + lines.push('- Query hooks: `useQuery`, `useQuery`'); + lines.push('- Mutation hooks: `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`'); + lines.push('- All hooks accept a `selection` parameter to pick fields'); + lines.push(''); - lines.push(`### HOOK: ${hookName}`); - lines.push(''); - lines.push(op.description || op.name); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: ${op.kind}`); - if (op.args.length > 0) { - if (op.kind === 'mutation') { - lines.push(`USAGE: const { mutate } = ${hookName}()`); - lines.push( - ` mutate({ ${op.args.map((a) => `${a.name}: `).join(', ')} })`, - ); - } else { - lines.push( - `USAGE: ${hookName}({ ${op.args.map((a) => `${a.name}: `).join(', ')} })`, - ); - } - lines.push(''); - lines.push('INPUT:'); - for (const arg of op.args) { - lines.push(` ${arg.name}: ${formatArgType(arg)}`); - } - } else { - lines.push(`USAGE: ${hookName}()`); - lines.push(''); - lines.push('INPUT: none'); - } - lines.push(''); - lines.push( - `OUTPUT: ${op.kind === 'query' ? 'UseQueryResult' : 'UseMutationResult'}`, - ); - lines.push('```'); - lines.push(''); - } - } + lines.push('## Boundaries'); + lines.push(''); + lines.push('All files in this directory are generated. Do not edit manually.'); + lines.push(''); return { fileName: 'AGENTS.md', diff --git a/graphql/codegen/src/core/codegen/orm/docs-generator.ts b/graphql/codegen/src/core/codegen/orm/docs-generator.ts index fb2c05798..7e6bfae7a 100644 --- a/graphql/codegen/src/core/codegen/orm/docs-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/docs-generator.ts @@ -6,6 +6,8 @@ import { buildSkillReference, formatArgType, getEditableFields, + categorizeSpecialFields, + buildSpecialFieldsMarkdown, getReadmeHeader, getReadmeFooter, gqlTypeToJsonSchemaType, @@ -104,6 +106,8 @@ export function generateOrmReadme( ); lines.push('```'); lines.push(''); + const ormSpecialGroups = categorizeSpecialFields(table); + lines.push(...buildSpecialFieldsMarkdown(ormSpecialGroups)); } } @@ -157,24 +161,22 @@ export function generateOrmAgentsDocs( customOperations: CleanOperation[], ): GeneratedDocFile { const lines: string[] = []; + const tableCount = tables.length; + const customOpCount = customOperations.length; - lines.push('# ORM Client - Agent Reference'); + lines.push('# ORM Client'); lines.push(''); lines.push(''); - lines.push('> This document is structured for LLM/agent consumption.'); lines.push(''); - lines.push('## OVERVIEW'); + lines.push('## Stack'); lines.push(''); - lines.push( - 'Prisma-like ORM client for interacting with a GraphQL API.', - ); - lines.push( - 'All methods return a query builder. Call `.execute()` to run the query.', - ); + lines.push('- Prisma-like ORM client for a GraphQL API (TypeScript)'); + lines.push(`- ${tableCount} model${tableCount !== 1 ? 's' : ''}${customOpCount > 0 ? `, ${customOpCount} custom operation${customOpCount !== 1 ? 's' : ''}` : ''}`); + lines.push('- All methods return a query builder; call `.execute()` to run'); lines.push(''); - lines.push('## SETUP'); + lines.push('## Quick Start'); lines.push(''); lines.push('```typescript'); lines.push("import { createClient } from './orm';"); @@ -186,121 +188,24 @@ export function generateOrmAgentsDocs( lines.push('```'); lines.push(''); - lines.push('## MODELS'); + lines.push('## Resources'); + lines.push(''); + lines.push(`- **Full API reference:** [README.md](./README.md) — model docs for all ${tableCount} tables`); + lines.push('- **Schema types:** [types.ts](./types.ts)'); + lines.push('- **ORM client:** [orm.ts](./orm.ts)'); lines.push(''); - for (const table of tables) { - const { singularName } = getTableNames(table); - const pk = getPrimaryKeyInfo(table)[0]; - const scalarFields = getScalarFields(table); - const editableFields = getEditableFields(table); - - lines.push(`### MODEL: ${singularName}`); - lines.push(''); - lines.push(`Access: \`db.${singularName}\``); - lines.push(''); - lines.push('```'); - lines.push('METHODS:'); - lines.push( - ` db.${singularName}.findMany({ select, where?, orderBy?, first?, offset? })`, - ); - lines.push( - ` db.${singularName}.findOne({ ${pk.name}, select })`, - ); - lines.push( - ` db.${singularName}.create({ data: { ${editableFields.map((f) => f.name).join(', ')} }, select })`, - ); - lines.push( - ` db.${singularName}.update({ where: { ${pk.name} }, data, select })`, - ); - lines.push(` db.${singularName}.delete({ where: { ${pk.name} } })`); - lines.push(''); - lines.push('FIELDS:'); - for (const f of scalarFields) { - const isPk = f.name === pk.name; - lines.push( - ` ${f.name}: ${fieldTypeToTs(f.type)}${isPk ? ' (primary key)' : ''}`, - ); - } - lines.push(''); - lines.push('EDITABLE FIELDS:'); - for (const f of editableFields) { - lines.push(` ${f.name}: ${fieldTypeToTs(f.type)}`); - } - lines.push(''); - lines.push('OUTPUT: Promise'); - lines.push( - ` findMany: [{ ${scalarFields.map((f) => f.name).join(', ')} }]`, - ); - lines.push( - ` findOne: { ${scalarFields.map((f) => f.name).join(', ')} }`, - ); - lines.push( - ` create: { ${scalarFields.map((f) => f.name).join(', ')} }`, - ); - lines.push( - ` update: { ${scalarFields.map((f) => f.name).join(', ')} }`, - ); - lines.push(` delete: { ${pk.name} }`); - lines.push('```'); - lines.push(''); - } - - if (customOperations.length > 0) { - lines.push('## CUSTOM OPERATIONS'); - lines.push(''); - - for (const op of customOperations) { - const accessor = op.kind === 'query' ? 'query' : 'mutation'; - - lines.push(`### OPERATION: ${op.name}`); - lines.push(''); - lines.push(op.description || op.name); - lines.push(''); - lines.push('```'); - lines.push(`TYPE: ${op.kind}`); - lines.push(`ACCESS: db.${accessor}.${op.name}`); - if (op.args.length > 0) { - lines.push( - `USAGE: db.${accessor}.${op.name}({ ${op.args.map((a) => `${a.name}: `).join(', ')} }).execute()`, - ); - lines.push(''); - lines.push('INPUT:'); - for (const arg of op.args) { - lines.push(` ${arg.name}: ${formatArgType(arg)}`); - } - } else { - lines.push( - `USAGE: db.${accessor}.${op.name}().execute()`, - ); - lines.push(''); - lines.push('INPUT: none'); - } - lines.push(''); - lines.push('OUTPUT: Promise'); - lines.push('```'); - lines.push(''); - } - } - - lines.push('## PATTERNS'); + lines.push('## Conventions'); lines.push(''); - lines.push('```typescript'); - lines.push('// All methods require .execute() to run'); - lines.push( - 'const result = await db.modelName.findMany({ select: { id: true } }).execute();', - ); + lines.push('- Access models via `db.` (e.g. `db.User`)'); + lines.push('- CRUD methods: `findMany`, `findOne`, `create`, `update`, `delete`'); + lines.push('- Always call `.execute()` to run the query'); + lines.push('- Custom operations via `db.query.` or `db.mutation.`'); lines.push(''); - lines.push('// Select specific fields'); - lines.push( - 'const partial = await db.modelName.findMany({ select: { id: true, name: true } }).execute();', - ); + + lines.push('## Boundaries'); lines.push(''); - lines.push('// Filter with where clause'); - lines.push( - "const filtered = await db.modelName.findMany({ select: { id: true }, where: { name: 'test' } }).execute();", - ); - lines.push('```'); + lines.push('All files in this directory are generated. Do not edit manually.'); lines.push(''); return { @@ -468,11 +373,18 @@ export function generateOrmSkills( const refName = toKebabCase(singularName); referenceNames.push(refName); + const ormSkillSpecialGroups = categorizeSpecialFields(table); + const ormSkillBaseDesc = table.description || `ORM operations for ${table.name} records`; + const ormSkillSpecialDesc = ormSkillSpecialGroups.length > 0 + ? ormSkillBaseDesc + '\n\n' + + ormSkillSpecialGroups.map((g) => `**${g.label}:** ${g.fields.map((f) => `\`${f.name}\``).join(', ')}\n${g.description}`).join('\n\n') + : ormSkillBaseDesc; + files.push({ fileName: `${skillName}/references/${refName}.md`, content: buildSkillReference({ title: singularName, - description: table.description || `ORM operations for ${table.name} records`, + description: ormSkillSpecialDesc, language: 'typescript', usage: [ `db.${modelName}.findMany({ select: { id: true } }).execute()`, diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index b0d8fa9e7..b70e8d045 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -300,31 +300,6 @@ type Post { """The method to use when ordering \`Comment\`.""" orderBy: [CommentOrderBy!] = [PRIMARY_KEY_ASC] ): CommentConnection! - - """ - TRGM similarity when searching \`title\`. Returns null when no trgm search filter is active. - """ - titleTrgmSimilarity: Float - - """ - TRGM similarity when searching \`slug\`. Returns null when no trgm search filter is active. - """ - slugTrgmSimilarity: Float - - """ - TRGM similarity when searching \`content\`. Returns null when no trgm search filter is active. - """ - contentTrgmSimilarity: Float - - """ - TRGM similarity when searching \`excerpt\`. Returns null when no trgm search filter is active. - """ - excerptTrgmSimilarity: Float - - """ - Composite search relevance score (0..1, higher = more relevant). Computed by normalizing and averaging all active search signals. Returns null when no search filters are active. - """ - searchScore: Float } """A connection to a list of \`Tag\` values, with data from \`PostTag\`.""" @@ -409,31 +384,6 @@ type Tag { """The method to use when ordering \`PostTag\`.""" orderBy: [PostTagOrderBy!] = [PRIMARY_KEY_ASC] ): PostTagConnection! - - """ - TRGM similarity when searching \`name\`. Returns null when no trgm search filter is active. - """ - nameTrgmSimilarity: Float - - """ - TRGM similarity when searching \`slug\`. Returns null when no trgm search filter is active. - """ - slugTrgmSimilarity: Float - - """ - TRGM similarity when searching \`description\`. Returns null when no trgm search filter is active. - """ - descriptionTrgmSimilarity: Float - - """ - TRGM similarity when searching \`color\`. Returns null when no trgm search filter is active. - """ - colorTrgmSimilarity: Float - - """ - Composite search relevance score (0..1, higher = more relevant). Computed by normalizing and averaging all active search signals. Returns null when no search filters are active. - """ - searchScore: Float } """A connection to a list of \`Post\` values, with data from \`PostTag\`.""" @@ -542,26 +492,6 @@ input PostFilter { """\`comments\` exist.""" commentsExist: Boolean - - """TRGM search on the \`title\` column.""" - trgmTitle: TrgmSearchInput - - """TRGM search on the \`slug\` column.""" - trgmSlug: TrgmSearchInput - - """TRGM search on the \`content\` column.""" - trgmContent: TrgmSearchInput - - """TRGM search on the \`excerpt\` column.""" - trgmExcerpt: TrgmSearchInput - - """ - Composite full-text search. Provide a search string and it will be dispatched - to all text-compatible search algorithms (tsvector, BM25, pg_trgm) - simultaneously. Rows matching ANY algorithm are returned. All matching score - fields are populated. - """ - fullTextSearch: String } """ @@ -938,29 +868,6 @@ input UserFilter { """\`authoredComments\` exist.""" authoredCommentsExist: Boolean - - """TRGM search on the \`email\` column.""" - trgmEmail: TrgmSearchInput - - """TRGM search on the \`username\` column.""" - trgmUsername: TrgmSearchInput - - """TRGM search on the \`display_name\` column.""" - trgmDisplayName: TrgmSearchInput - - """TRGM search on the \`bio\` column.""" - trgmBio: TrgmSearchInput - - """TRGM search on the \`role\` column.""" - trgmRole: TrgmSearchInput - - """ - Composite full-text search. Provide a search string and it will be dispatched - to all text-compatible search algorithms (tsvector, BM25, pg_trgm) - simultaneously. Rows matching ANY algorithm are returned. All matching score - fields are populated. - """ - fullTextSearch: String } """ @@ -1048,17 +955,6 @@ input CommentFilter { """\`childComments\` exist.""" childCommentsExist: Boolean - - """TRGM search on the \`content\` column.""" - trgmContent: TrgmSearchInput - - """ - Composite full-text search. Provide a search string and it will be dispatched - to all text-compatible search algorithms (tsvector, BM25, pg_trgm) - simultaneously. Rows matching ANY algorithm are returned. All matching score - fields are populated. - """ - fullTextSearch: String } """ @@ -1157,26 +1053,6 @@ input TagFilter { """\`postTags\` exist.""" postTagsExist: Boolean - - """TRGM search on the \`name\` column.""" - trgmName: TrgmSearchInput - - """TRGM search on the \`slug\` column.""" - trgmSlug: TrgmSearchInput - - """TRGM search on the \`description\` column.""" - trgmDescription: TrgmSearchInput - - """TRGM search on the \`color\` column.""" - trgmColor: TrgmSearchInput - - """ - Composite full-text search. Provide a search string and it will be dispatched - to all text-compatible search algorithms (tsvector, BM25, pg_trgm) - simultaneously. Rows matching ANY algorithm are returned. All matching score - fields are populated. - """ - fullTextSearch: String } """ @@ -1222,16 +1098,6 @@ enum PostOrderBy { PUBLISHED_AT_DESC CREATED_AT_ASC CREATED_AT_DESC - TITLE_TRGM_SIMILARITY_ASC - TITLE_TRGM_SIMILARITY_DESC - SLUG_TRGM_SIMILARITY_ASC - SLUG_TRGM_SIMILARITY_DESC - CONTENT_TRGM_SIMILARITY_ASC - CONTENT_TRGM_SIMILARITY_DESC - EXCERPT_TRGM_SIMILARITY_ASC - EXCERPT_TRGM_SIMILARITY_DESC - SEARCH_SCORE_ASC - SEARCH_SCORE_DESC } """Methods to use when ordering \`PostTag\`.""" @@ -1269,16 +1135,6 @@ enum TagOrderBy { NAME_DESC SLUG_ASC SLUG_DESC - NAME_TRGM_SIMILARITY_ASC - NAME_TRGM_SIMILARITY_DESC - SLUG_TRGM_SIMILARITY_ASC - SLUG_TRGM_SIMILARITY_DESC - DESCRIPTION_TRGM_SIMILARITY_ASC - DESCRIPTION_TRGM_SIMILARITY_DESC - COLOR_TRGM_SIMILARITY_ASC - COLOR_TRGM_SIMILARITY_DESC - SEARCH_SCORE_ASC - SEARCH_SCORE_DESC } type User { @@ -1349,36 +1205,6 @@ type User { """The method to use when ordering \`Comment\`.""" orderBy: [CommentOrderBy!] = [PRIMARY_KEY_ASC] ): CommentConnection! - - """ - TRGM similarity when searching \`email\`. Returns null when no trgm search filter is active. - """ - emailTrgmSimilarity: Float - - """ - TRGM similarity when searching \`username\`. Returns null when no trgm search filter is active. - """ - usernameTrgmSimilarity: Float - - """ - TRGM similarity when searching \`displayName\`. Returns null when no trgm search filter is active. - """ - displayNameTrgmSimilarity: Float - - """ - TRGM similarity when searching \`bio\`. Returns null when no trgm search filter is active. - """ - bioTrgmSimilarity: Float - - """ - TRGM similarity when searching \`role\`. Returns null when no trgm search filter is active. - """ - roleTrgmSimilarity: Float - - """ - Composite search relevance score (0..1, higher = more relevant). Computed by normalizing and averaging all active search signals. Returns null when no search filters are active. - """ - searchScore: Float } """A connection to a list of \`Post\` values.""" @@ -1472,16 +1298,6 @@ type Comment { """The method to use when ordering \`Comment\`.""" orderBy: [CommentOrderBy!] = [PRIMARY_KEY_ASC] ): CommentConnection! - - """ - TRGM similarity when searching \`content\`. Returns null when no trgm search filter is active. - """ - contentTrgmSimilarity: Float - - """ - Composite search relevance score (0..1, higher = more relevant). Computed by normalizing and averaging all active search signals. Returns null when no search filters are active. - """ - searchScore: Float } """Methods to use when ordering \`Comment\`.""" @@ -1499,10 +1315,6 @@ enum CommentOrderBy { PARENT_ID_DESC CREATED_AT_ASC CREATED_AT_DESC - CONTENT_TRGM_SIMILARITY_ASC - CONTENT_TRGM_SIMILARITY_DESC - SEARCH_SCORE_ASC - SEARCH_SCORE_DESC } """A \`Comment\` edge in the connection.""" @@ -1588,18 +1400,6 @@ enum UserOrderBy { USERNAME_DESC CREATED_AT_ASC CREATED_AT_DESC - EMAIL_TRGM_SIMILARITY_ASC - EMAIL_TRGM_SIMILARITY_DESC - USERNAME_TRGM_SIMILARITY_ASC - USERNAME_TRGM_SIMILARITY_DESC - DISPLAY_NAME_TRGM_SIMILARITY_ASC - DISPLAY_NAME_TRGM_SIMILARITY_DESC - BIO_TRGM_SIMILARITY_ASC - BIO_TRGM_SIMILARITY_DESC - ROLE_TRGM_SIMILARITY_ASC - ROLE_TRGM_SIMILARITY_DESC - SEARCH_SCORE_ASC - SEARCH_SCORE_DESC } """Root meta schema type"""