/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /* eslint-disable @typescript-eslint/no-explicit-any -- graphiti traverses untyped schema/introspection JSON; see follow-up to replace with `unknown` + narrowing */ import fs from "fs"; import { buildClientSchema, buildASTSchema, parse, printSchema, type GraphQLSchema, type GraphQLField, type GraphQLNamedType, type GraphQLArgument, type GraphQLInputField, isObjectType, isInputObjectType, isEnumType, isUnionType, isInterfaceType, isScalarType, isListType, isNonNullType, getNamedType, type GraphQLOutputType, type GraphQLType, } from "graphql"; import { SchemaError, UserInputError } from "./errors.js"; import { atomicWriteText } from "./fs-utils.js"; import { loadIntrospectionResult, getSchemaFilePath, normalizeInstanceUrl } from "./introspect.js"; import { type OperationType } from "./session.js"; // ── Fuzzy matching ─────────────────────────────────────────────────────────── function levenshtein(a: string, b: string): number { const la = a.length; const lb = b.length; if (la === 0) return lb; if (lb === 0) return la; const prev = Array.from({ length: lb + 1 }, (_, i) => i); for (let i = 1; i <= la; i++) { let prevDiag = prev[0]; prev[0] = i; for (let j = 1; j <= lb; j++) { const tmp = prev[j]; prev[j] = a[i - 1] === b[j - 1] ? prevDiag : 1 + Math.min(prevDiag, prev[j - 1], prev[j]); prevDiag = tmp; } } return prev[lb]; } function findClosestFields(segment: string, available: string[], maxSuggestions = 3): string[] { const lower = segment.toLowerCase(); const threshold = Math.max(3, Math.floor(segment.length * 0.5)); const scored = available .map((name) => ({ name, dist: levenshtein(lower, name.toLowerCase()) })) .filter((e) => e.dist <= threshold) .sort((a, b) => a.dist - b.dist); return scored.slice(0, maxSuggestions).map((e) => e.name); } // ── Schema loading ─────────────────────────────────────────────────────────── const schemaCache = new Map(); /** * Salesforce sometimes emits INPUT_OBJECT types with no fields * (e.g. `*_SearchOrderBy` types and a few mutation Representation types). * That violates the GraphQL spec, so `validateSchema()` rejects the result * and downstream `validate(schema, document)` calls throw before they ever * see the user's query. Patch the introspection in-memory by adding a * synthetic `_placeholder: String` field — keeps the type referenceable * without fabricating real fields. */ function patchEmptyInputObjects(data: any): void { const types: any[] = data?.__schema?.types ?? []; for (const t of types) { if (t?.kind === "INPUT_OBJECT" && Array.isArray(t.inputFields) && t.inputFields.length === 0) { t.inputFields = [ { name: "_placeholder", description: "Synthetic placeholder added by graphiti to satisfy GraphQL schema validation; unused.", type: { kind: "SCALAR", name: "String", ofType: null }, defaultValue: null, }, ]; } } } /** * Builds a GraphQLSchema from an introspection file, using a persisted SDL * side-file as a cache to avoid re-running buildClientSchema on every cold start. * * The SDL file (.graphql) sits next to the introspection JSON and is * regenerated whenever the introspection file is newer. */ function buildSchemaWithSdlCache(introspectionFilePath: string): GraphQLSchema { const sdlPath = introspectionFilePath.replace(/\.json$/, ".graphql"); try { const introspMtime = fs.statSync(introspectionFilePath).mtimeMs; if (fs.existsSync(sdlPath)) { const sdlMtime = fs.statSync(sdlPath).mtimeMs; if (sdlMtime >= introspMtime) { const sdl = fs.readFileSync(sdlPath, "utf-8"); // `assumeValid: true` because `printSchema` drops the body of // validated-but-empty input types; the SDL came from a schema we // already loaded successfully via the JSON path below. return buildASTSchema(parse(sdl), { assumeValid: true }); } } } catch { // Fall through to full build. } const raw = JSON.parse(fs.readFileSync(introspectionFilePath, "utf-8")); const data = raw?.data ?? raw; patchEmptyInputObjects(data); const schema = buildClientSchema(data); // Persist SDL for the next invocation. Atomic temp+rename (not a bare // writeFileSync) so a crash mid-write or a concurrent CLI+MCP writer can't // leave a torn `.graphql` — readers see either the old or the fully-written // file. (The read path also self-heals from the atomic JSON, but this avoids // the wasted rebuild.) try { atomicWriteText(sdlPath, printSchema(schema)); } catch { // Non-critical — writable filesystem is not guaranteed. } return schema; } export class MutationContextError extends Error { constructor(message: string) { super(message); this.name = "MutationContextError"; } } export function getSchema(instanceUrl: string): GraphQLSchema { const cacheKey = /^https?:\/\//i.test(instanceUrl) ? normalizeInstanceUrl(instanceUrl) : instanceUrl; // test-primed direct key const cached = schemaCache.get(cacheKey); if (cached) return cached; let schema: GraphQLSchema; try { const filePath = getSchemaFilePath(instanceUrl); schema = buildSchemaWithSdlCache(filePath); } catch { const introspection = loadIntrospectionResult(instanceUrl); const data = introspection?.data ?? introspection; schema = buildClientSchema(data); } schemaCache.set(cacheKey, schema); return schema; } export function clearSchemaCache(instanceUrl?: string): void { if (instanceUrl) { schemaCache.delete(instanceUrl); schemaCache.delete(normalizeInstanceUrl(instanceUrl)); } else { schemaCache.clear(); } } /** * Evict the parsed schema for an instance URL AND its derived on-disk SDL * side-file (`.graphql`). Extends {@link clearSchemaCache} (in-memory * only) with the SDL removal the refresh path needs: after a forced refresh * rewrites the introspection JSON, `buildSchemaWithSdlCache` would otherwise * prefer a stale SDL whenever `sdlMtime >= introspMtime` — true on a * same-mtime-tick edge — resurrecting the old schema on the next cold read and * defeating the refresh's coherence guarantee. SDL removal is best-effort. */ export function clearSchemaCacheByUrl(instanceUrl: string): void { clearSchemaCache(instanceUrl); try { const sdlPath = getSchemaFilePath(instanceUrl).replace(/\.json$/, ".graphql"); fs.rmSync(sdlPath, { force: true }); } catch { // best-effort; a stale SDL only matters on the rare same-mtime-tick edge. } } export function primeSchemaCache(alias: string, schema: GraphQLSchema): void { schemaCache.set(alias, schema); } // ── Type info structures ───────────────────────────────────────────────────── export interface FieldInfo { name: string; typeName: string; typeKind: TypeInfo["kind"]; isNonNull: boolean; isList: boolean; description: string | null; args: ArgInfo[]; } export interface ArgInfo { name: string; typeName: string; typeKind: string; isNonNull: boolean; description: string | null; defaultValue: string | undefined; enumValues?: string[]; } export interface TypeInfo { name: string; kind: "OBJECT" | "INPUT_OBJECT" | "ENUM" | "UNION" | "INTERFACE" | "SCALAR"; description: string | null; fields: FieldInfo[]; inputFields: InputFieldInfo[]; enumValues: EnumValueInfo[]; possibleTypes: string[]; interfaces: string[]; } export interface InputFieldInfo { name: string; typeName: string; typeKind: string; isNonNull: boolean; description: string | null; defaultValue: string | undefined; enumValues?: string[]; } export interface EnumValueInfo { name: string; description: string | null; } // ── Data Cloud field detection ─────────────────────────────────────────────── const DATA_CLOUD_RE = /__dlm$/; /** * Returns true for Salesforce Data Cloud (Data Lake Model) objects. * These have names ending in `__dlm` (e.g. `ssot__Account__dlm`). */ export function isDataCloudField(field: FieldInfo): boolean { return DATA_CLOUD_RE.test(field.name); } export function filterDataCloudFields(fields: FieldInfo[], includeDataCloud: boolean): FieldInfo[] { if (includeDataCloud) return fields; return fields.filter((f) => !isDataCloudField(f)); } // ── Type formatting ────────────────────────────────────────────────────────── function formatType(type: GraphQLType): string { if (isNonNullType(type)) { return `${formatType(type.ofType)}!`; } if (isListType(type)) { return `[${formatType(type.ofType)}]`; } return (type as GraphQLNamedType).name; } export function getTypeKind(type: GraphQLNamedType): TypeInfo["kind"] { if (isObjectType(type)) return "OBJECT"; if (isInputObjectType(type)) return "INPUT_OBJECT"; if (isEnumType(type)) return "ENUM"; if (isUnionType(type)) return "UNION"; if (isInterfaceType(type)) return "INTERFACE"; return "SCALAR"; } function validateFragmentTarget( schema: GraphQLSchema, currentType: GraphQLNamedType, targetTypeName: string, ): GraphQLNamedType { const namedType = schema.getType(targetTypeName); if (!namedType) { // User named an inline-fragment target type that does not exist. throw new UserInputError(`Type "${targetTypeName}" not found in schema`); } if (isUnionType(currentType)) { const allowed = currentType.getTypes().map((type) => type.name); if (!allowed.includes(targetTypeName)) { throw new UserInputError( `Type "${targetTypeName}" is not a possible type of union ${currentType.name}. Allowed: ${allowed.join(", ")}`, ); } return namedType; } if (isInterfaceType(currentType)) { const allowed = schema.getPossibleTypes(currentType).map((type) => type.name); if (!allowed.includes(targetTypeName)) { throw new UserInputError( `Type "${targetTypeName}" does not implement interface ${currentType.name}. Allowed: ${allowed.join(", ")}`, ); } return namedType; } if (isObjectType(currentType)) { if (currentType.name !== targetTypeName) { // Look for relationship fields on this object whose type is a union/interface containing the target type const hints: string[] = []; const fields = currentType.getFields(); for (const [fieldName, field] of Object.entries(fields)) { const fieldNamedType = getNamedType(field.type); if (!fieldNamedType) continue; if (isUnionType(fieldNamedType)) { const members = fieldNamedType.getTypes().map((t) => t.name); if (members.includes(targetTypeName)) { hints.push(`${fieldName}/on:${targetTypeName}`); } } else if (isInterfaceType(fieldNamedType)) { const impls = schema.getPossibleTypes(fieldNamedType).map((t) => t.name); if (impls.includes(targetTypeName)) { hints.push(`${fieldName}/on:${targetTypeName}`); } } } let msg = `Type "${targetTypeName}" is not a valid inline-fragment target for object ${currentType.name}.`; if (hints.length > 0) { msg += `\nDid you mean to use it through a relationship? Try: ${hints.join(" or ")}`; } throw new UserInputError(msg); } return namedType; } throw new UserInputError( `Cannot apply an inline fragment at ${currentType.name} (${getTypeKind(currentType)}).`, ); } // ── Field extraction ───────────────────────────────────────────────────────── function extractArgInfo(arg: GraphQLArgument): ArgInfo { const named = getNamedType(arg.type); const typeKind = named ? getTypeKind(named) : "SCALAR"; return { name: arg.name, typeName: formatType(arg.type), typeKind, isNonNull: isNonNullType(arg.type), description: arg.description ?? null, defaultValue: arg.defaultValue !== undefined ? JSON.stringify(arg.defaultValue) : undefined, enumValues: typeKind === "ENUM" && named && isEnumType(named) ? named.getValues().map((v) => v.name) : undefined, }; } function extractFieldInfo(field: GraphQLField): FieldInfo { const named = getNamedType(field.type); return { name: field.name, typeName: formatType(field.type), typeKind: named ? getTypeKind(named) : "SCALAR", isNonNull: isNonNullType(field.type), isList: isListType(isNonNullType(field.type) ? field.type.ofType : field.type), description: field.description ?? null, args: field.args.map(extractArgInfo), }; } function extractInputFieldInfo(field: GraphQLInputField): InputFieldInfo { const named = getNamedType(field.type); const typeKind = named ? getTypeKind(named) : "SCALAR"; return { name: field.name, typeName: formatType(field.type), typeKind, isNonNull: isNonNullType(field.type), description: field.description ?? null, defaultValue: field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : undefined, enumValues: typeKind === "ENUM" && named && isEnumType(named) ? named.getValues().map((v) => v.name) : undefined, }; } // ── Walker result ──────────────────────────────────────────────────────────── export interface WalkerResult { type: GraphQLNamedType; typeName: string; kind: TypeInfo["kind"]; fields: FieldInfo[]; args: ArgInfo[]; possibleTypes: string[]; isLeaf: boolean; /** True when inside a mutation result record type where only scalar/value fields are selectable. */ inMutationRecord: boolean; /** Relationship fields hidden from selection in mutation results (shown as [query-only] in ls -l). */ mutationHiddenFields: FieldInfo[]; } // ── Deduplication helper ───────────────────────────────────────────────────── function deduplicateByName(items: T[]): T[] { const seen = new Set(); return items.filter((item) => { if (seen.has(item.name)) return false; seen.add(item.name); return true; }); } // ── Mutation return type field filtering ───────────────────────────────────── /** * Returns the set of type names that implement the FieldValue interface. * These are the value-wrapper types (StringValue, PicklistValue, etc.) * that ARE available in mutation return payloads. * * Falls back to name-based detection (*Value pattern) if the interface * is absent from the schema. */ function getFieldValueTypeNames(schema: GraphQLSchema): Set { const fieldValueInterface = schema.getType("FieldValue"); if (fieldValueInterface && isInterfaceType(fieldValueInterface)) { return new Set(schema.getPossibleTypes(fieldValueInterface).map((t) => t.name)); } // Fallback: match common Salesforce value wrapper naming convention const names = new Set(); const typeMap = schema.getTypeMap(); for (const typeName of Object.keys(typeMap)) { if (/Value$/.test(typeName) && !typeName.startsWith("__")) { names.add(typeName); } } return names; } /** * Returns true when the given named type represents a relationship/connection * that is NOT available in mutation return payloads. Scalars, enums, and types * that implement the FieldValue interface are allowed; everything else is blocked. */ function isRelationshipType( schema: GraphQLSchema, type: GraphQLNamedType, fieldValueTypes: Set, ): boolean { if (isScalarType(type) || isEnumType(type)) return false; if (fieldValueTypes.has(type.name)) return false; // Object types not in the FieldValue set are SObjects, connections, or aggregates return true; } // ── Root type resolution ───────────────────────────────────────────────────── export function getRootType(schema: GraphQLSchema, operation: OperationType): GraphQLNamedType { const rootType = operation === "mutation" ? schema.getMutationType() : schema.getQueryType(); if (!rootType) { throw new SchemaError(`Schema has no ${operation} type`); } return rootType; } export function getRootFields(schema: GraphQLSchema, operation: OperationType): FieldInfo[] { const rootType = getRootType(schema, operation); if (!isObjectType(rootType)) return []; return Object.values(rootType.getFields()).map(extractFieldInfo); } // ── Path resolution ────────────────────────────────────────────────────────── /** * Walks the schema graph along a path and returns what's available at the end. * Handles regular fields, inline fragment segments [TypeName], and resolves * through NonNull/List wrappers. * * Returns the args of the *last* field in the path (not the terminal type's fields' args). */ export function resolvePath( schema: GraphQLSchema, operation: OperationType, pathSegments: string[], ): WalkerResult { let currentType: GraphQLNamedType = getRootType(schema, operation); let lastFieldArgs: ArgInfo[] = []; // Tracks whether we've entered the record-level type inside a mutation payload. // Relationship fields (SObjects, unions, connections) are not available in // mutation results once we're inside the Record type. let inMutationRecord = false; for (const segment of pathSegments) { if ((segment.startsWith("[") && segment.endsWith("]")) || segment.startsWith("on:")) { const typeName = segment.startsWith("on:") ? segment.slice(3) : segment.slice(1, -1); const namedType = validateFragmentTarget(schema, currentType, typeName); currentType = namedType; lastFieldArgs = []; continue; } if (segment.startsWith("#")) { // Named fragment reference — resolve to its target type // The caller should handle fragment cursor paths by looking up the fragment's onType const typeName = segment.slice(1); const namedType = schema.getType(typeName); if (!namedType) { throw new Error(`Fragment target type "${typeName}" not found in schema`); } currentType = namedType; lastFieldArgs = []; continue; } // Regular field — look it up on the current object/interface type if (!isObjectType(currentType) && !isInterfaceType(currentType)) { if (isUnionType(currentType)) { const members = currentType.getTypes().map((t) => t.name); throw new Error( `Cannot select field "${segment}" on ${currentType.name} (UNION). ` + `Use inline fragment syntax: on:${members[0]}/${segment}\n` + `Possible types: ${members.join(", ")}\n` + `Example: select .../on:${members[0]}/${segment}`, ); } throw new Error( `Cannot select field "${segment}" on ${currentType.name} (${getTypeKind(currentType)}). Only OBJECT and INTERFACE types have fields.`, ); } const fields = currentType.getFields(); const field = fields[segment]; if (!field) { const available = Object.keys(fields); const suggestions = findClosestFields(segment, available); let hint = suggestions.length > 0 ? `Did you mean: ${suggestions.join(", ")}?` : `Available: ${available.slice(0, 10).join(", ")}${available.length > 10 ? "..." : ""}`; if (operation === "query" && /(?:Create|Update|Delete)$/.test(segment)) { hint += `\nHint: "${segment}" looks like a mutation. Create a mutation session with: new --name --mutation`; } throw new Error(`Field "${segment}" not found on type ${currentType.name}. ${hint}`); } lastFieldArgs = field.args.map(extractArgInfo); const namedReturnType = getNamedType(field.type); if (!namedReturnType) { throw new Error(`Could not resolve return type of field ${currentType.name}.${segment}`); } // Detect mutation record context: when navigating FROM a Payload type INTO the record type if (operation === "mutation") { if (currentType.name.endsWith("Payload") && !namedReturnType.name.endsWith("Payload")) { inMutationRecord = true; } else if (inMutationRecord) { // Inside mutation record: block navigation into relationship/connection types if (isRelationshipType(schema, namedReturnType, getFieldValueTypeNames(schema))) { throw new MutationContextError( `"${segment}" is not available in mutation results. ` + `Only scalar fields and value wrappers (e.g. Name/, Status/) can be selected.\n` + `Tip: query the record by Id after the mutation to fetch related data.`, ); } } } currentType = namedReturnType; } // Build the result based on the terminal type const kind = getTypeKind(currentType); const fields: FieldInfo[] = []; const mutationHiddenFields: FieldInfo[] = []; const possibleTypes: string[] = []; if (isObjectType(currentType) || isInterfaceType(currentType)) { const allFields = Object.values(currentType.getFields()).map(extractFieldInfo); if (inMutationRecord) { const fieldValueTypes = getFieldValueTypeNames(schema); for (const f of allFields) { const namedTypeName = f.typeName.replace(/[![\]]/g, ""); const ft = schema.getType(namedTypeName); if (!ft || !isRelationshipType(schema, ft, fieldValueTypes)) { fields.push(f); } else { mutationHiddenFields.push(f); } } } else { fields.push(...allFields); } } if (!inMutationRecord) { if (isUnionType(currentType)) { possibleTypes.push(...currentType.getTypes().map((t) => t.name)); } if (isInterfaceType(currentType)) { const impls = schema.getPossibleTypes(currentType); possibleTypes.push(...impls.map((t) => t.name)); } } const isLeaf = isScalarType(currentType) || isEnumType(currentType); const deduplicatedArgs = deduplicateByName(lastFieldArgs); return { type: currentType, typeName: currentType.name, kind, fields, args: deduplicatedArgs, possibleTypes, isLeaf, inMutationRecord, mutationHiddenFields, }; } // ── Resolve a field by name on a given parent path ─────────────────────────── /** * Given a parent path + field name, resolves the field's schema info. * Used to determine whether a field returns an object type (needs sub-selections) * or is a leaf. */ export function resolveFieldOnPath( schema: GraphQLSchema, operation: OperationType, parentPath: string[], fieldName: string, ): WalkerResult { return resolvePath(schema, operation, [...parentPath, fieldName]); } export function getFragmentTargets(schema: GraphQLSchema, type: GraphQLNamedType): string[] { if (isUnionType(type)) { return type.getTypes().map((item) => item.name); } if (isInterfaceType(type)) { return schema.getPossibleTypes(type).map((item) => item.name); } if (isObjectType(type)) { return [type.name]; } return []; } // ── Type inspection ────────────────────────────────────────────────────────── export function inspectType(schema: GraphQLSchema, typeName: string): TypeInfo { const type = schema.getType(typeName); if (!type) { throw new Error(`Type "${typeName}" not found in schema`); } const info: TypeInfo = { name: type.name, kind: getTypeKind(type), description: ("description" in type ? type.description : null) ?? null, fields: [], inputFields: [], enumValues: [], possibleTypes: [], interfaces: [], }; if (isObjectType(type) || isInterfaceType(type)) { info.fields = Object.values(type.getFields()).map(extractFieldInfo); if (isObjectType(type)) { info.interfaces = type.getInterfaces().map((i) => i.name); } } if (isInputObjectType(type)) { info.inputFields = Object.values(type.getFields()).map(extractInputFieldInfo); } if (isEnumType(type)) { info.enumValues = type.getValues().map((v) => ({ name: v.name, description: v.description ?? null, })); } if (isUnionType(type)) { info.possibleTypes = type.getTypes().map((t) => t.name); } if (isInterfaceType(type)) { const impls = schema.getPossibleTypes(type); info.possibleTypes = impls.map((t) => t.name); } return info; } // ── Input type walking ─────────────────────────────────────────────────────── export interface InputWalkerResult { type: GraphQLNamedType; typeName: string; kind: TypeInfo["kind"]; inputFields: InputFieldInfo[]; enumValues: EnumValueInfo[]; isLeaf: boolean; isList: boolean; isNonNull: boolean; } /** * Walks an INPUT_OBJECT type structure along a path. * Numeric segments index into list types (unwrapping [T] → T). * Returns info about the terminal type: its input fields if navigable, * or isLeaf:true for SCALAR/ENUM. */ export function resolveInputPath( schema: GraphQLSchema, inputTypeName: string, pathSegments: string[], ): InputWalkerResult { const startType = schema.getType(inputTypeName); if (!startType) throw new Error(`Type "${inputTypeName}" not found in schema`); let currentType: GraphQLNamedType = getNamedType(startType) ?? (startType as GraphQLNamedType); let currentIsList = isListType(isNonNullType(startType) ? (startType as any).ofType : startType); let currentIsNonNull = isNonNullType(startType); for (const segment of pathSegments) { // Numeric segments index into a list → unwrap to the item type if (/^\d+$/.test(segment)) { if (!currentIsList && !isInputObjectType(currentType)) { throw new Error(`Cannot index into non-list type ${currentType.name} with "${segment}".`); } currentIsList = false; currentIsNonNull = false; continue; } if (!isInputObjectType(currentType)) { throw new Error( `Cannot navigate into field "${segment}" on ${currentType.name} (${getTypeKind(currentType)}). ` + `Only INPUT_OBJECT types have navigable fields.`, ); } const fields = currentType.getFields(); const field = fields[segment]; if (!field) { const available = Object.keys(fields); const suggestions = findClosestFields(segment, available); const hint = suggestions.length > 0 ? `Did you mean: ${suggestions.join(", ")}?` : `Available: ${available.slice(0, 10).join(", ")}${available.length > 10 ? "..." : ""}`; throw new Error(`Field "${segment}" not found on input type ${currentType.name}. ${hint}`); } const rawType = field.type; const namedType = getNamedType(rawType); if (!namedType) { throw new Error(`Could not resolve type of input field ${currentType.name}.${segment}`); } currentIsNonNull = isNonNullType(rawType); currentIsList = isListType(isNonNullType(rawType) ? rawType.ofType : rawType); currentType = namedType; } const kind = getTypeKind(currentType); const isLeaf = isScalarType(currentType) || isEnumType(currentType); const inputFields: InputFieldInfo[] = []; const enumValues: EnumValueInfo[] = []; if (isInputObjectType(currentType)) { inputFields.push( ...deduplicateByName(Object.values(currentType.getFields()).map(extractInputFieldInfo)), ); } if (isEnumType(currentType)) { enumValues.push( ...deduplicateByName( currentType.getValues().map((v) => ({ name: v.name, description: v.description ?? null, })), ), ); } return { type: currentType, typeName: currentType.name, kind, inputFields, enumValues, isLeaf: isLeaf && !currentIsList, isList: currentIsList, isNonNull: currentIsNonNull, }; } /** * Resolves a single argument by name from a WalkerResult and returns * its type info for navigation into @args/. */ export function resolveArgByName( schema: GraphQLSchema, walkerResult: WalkerResult, argName: string, ): { typeName: string; isNonNull: boolean; isList: boolean; typeKind: string } { const arg = walkerResult.args.find((a) => a.name === argName); if (!arg) { const available = walkerResult.args.map((a) => a.name); const suggestions = findClosestFields(argName, available); const hint = suggestions.length > 0 ? `Did you mean: ${suggestions.join(", ")}?` : `Available args: ${available.join(", ")}`; throw new Error(`Argument "${argName}" not found on field at this path. ${hint}`); } const rawTypeName = arg.typeName.replace(/[![\]]/g, ""); const isList = arg.typeName.includes("["); return { typeName: rawTypeName, isNonNull: arg.isNonNull, isList, typeKind: arg.typeKind, }; } // ── Raw field type lookup (preserves NonNull/List wrappers) ────────────────── /** * Resolves the parent type for a schema path and returns the raw * GraphQLField for the given field name. Used by codegen to read the * field's output type (with NonNull/List wrappers) and its description * directly from the schema. * * Returns null if the parent path can't be resolved or the field doesn't exist. */ function resolveParentField( schema: GraphQLSchema, operation: OperationType, parentPath: string[], fieldName: string, ): GraphQLField | null { try { const parentResult = resolvePath(schema, operation, parentPath); const parentType = parentResult.type; if (!isObjectType(parentType) && !isInterfaceType(parentType)) return null; return parentType.getFields()[fieldName] ?? null; } catch { return null; } } export function getRawFieldType( schema: GraphQLSchema, operation: OperationType, parentPath: string[], fieldName: string, ): GraphQLOutputType | null { return resolveParentField(schema, operation, parentPath, fieldName)?.type ?? null; } export function getFieldDescription( schema: GraphQLSchema, operation: OperationType, parentPath: string[], fieldName: string, ): string | null { return resolveParentField(schema, operation, parentPath, fieldName)?.description ?? null; } // ── Search ─────────────────────────────────────────────────────────────────── export interface SearchResult { typeName: string; kind: string; fieldName?: string; fieldType?: string; description?: string | null; } export interface SearchMatcher { test(input: string): boolean; } /** * Splits a CamelCase/PascalCase identifier into word segments. * e.g. "AccountId" → ["Account", "Id"], "CSNDesktopTask" → ["CSN", "Desktop", "Task"] */ function splitCamelCase(name: string): string[] { return name .replace(/([a-z0-9])([A-Z])/g, "$1\0$2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2") .split(/[\0_]/) .filter(Boolean); } /** * Builds a SearchMatcher from a plain-text search string. * * The pattern is split by whitespace into terms. Each term is * prefix-matched (case-insensitive) against CamelCase word segments * of the field name. "Id" matches "AccountId" but not "Hide". */ export function parseSearchTerms(pattern: string): SearchMatcher { const terms = pattern.split(/\s+/).filter(Boolean); if (terms.length === 0) return { test: () => true }; const termRegexes = terms.map((t) => new RegExp(`^${t}`, "i")); return { test(input: string): boolean { if (termRegexes.some((re) => re.test(input))) return true; const segments = splitCamelCase(input); return termRegexes.some((re) => segments.some((seg) => re.test(seg))); }, }; } /** * Builds a SearchMatcher from a regex pattern string. * * Supports `/pattern/flags` syntax for explicit flags. * Without delimiters, defaults to case-insensitive matching. */ export function parseSearchRegex(pattern: string): SearchMatcher { const delimited = pattern.match(/^\/(.+)\/([gimsuy]*)$/); if (delimited) { return new RegExp(delimited[1], delimited[2]); } return new RegExp(pattern, "i"); } export function searchSchema( schema: GraphQLSchema, pattern: string, maxResults = 50, ): SearchResult[] { const regex = parseSearchRegex(pattern); const results: SearchResult[] = []; const typeMap = schema.getTypeMap(); for (const [typeName, type] of Object.entries(typeMap)) { if (typeName.startsWith("__")) continue; if (results.length >= maxResults) break; if (regex.test(typeName)) { results.push({ typeName, kind: getTypeKind(type as GraphQLNamedType), description: ("description" in type ? type.description : null) ?? null, }); } // Search fields if ( (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) && results.length < maxResults ) { const fields = type.getFields(); for (const [fieldName, field] of Object.entries(fields)) { if (results.length >= maxResults) break; if (regex.test(fieldName)) { results.push({ typeName, kind: getTypeKind(type), fieldName, fieldType: formatType((field as any).type), description: field.description ?? null, }); } } } } return results; }