/** * 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 type { GraphQLSchema } from "graphql"; import { CommandError, EXIT_CODES, getSessionSchema, formatAliasContext, printQuery, detectSObjectName, } from "./query-helpers.js"; import { getOrgAuth } from "../lib/auth.js"; import { generateTypes, collectSessionSObjects, normalizeCodegenLanguage, SUPPORTED_CODEGEN_LANGUAGES, } from "../lib/codegen.js"; import { formatNavigationPath, formatValidationErrors } from "../lib/formatter.js"; import { DEFAULT_API_VERSION, executeGraphQL } from "../lib/introspect.js"; import { getCachedObjectInfo, getObjectInfo, getRequiredCreateFields, type FieldMetadata, type ObjectInfoResult, } from "../lib/object-info.js"; import { renderQuery } from "../lib/query-builder.js"; import { loadSession, listSessions, buildRuntimeVariables, getNavigationContext, isInArgsContext, queryNavToSchemaPath, getChildren, findVariableReferences, formatPath, type FieldProjectionNode, type QuerySession, } from "../lib/session.js"; import { validateQuery } from "../lib/validator.js"; import { resolvePath } from "../lib/walker.js"; export function queryShowJson(sessionId: string): void { const session = loadSession(sessionId); const queryString = renderQuery(session); const variables = buildRuntimeVariables(session); const result: Record = { command: "show", session: { id: session.id, name: session.name ?? null, orgAlias: session.orgAlias, instanceUrl: session.instanceUrl ?? null, operation: session.operation, navigationPath: formatPath(session.navigationPath), }, query: queryString, variables: session.variables.map((v) => ({ name: v.name, type: v.type, defaultValue: v.defaultValue ?? null, runtimeValue: v.runtimeValue ?? null, references: findVariableReferences(session, v.name).map((ref) => ({ fieldPath: formatPath(ref.fieldPath), argPath: ref.subPath.length > 0 ? `${ref.argName}.${ref.subPath.join(".")}` : ref.argName, })), })), resolvedVariables: Object.keys(variables).length > 0 ? variables : null, selectedFields: session.nodes.filter( (n) => n.kind === "field" && getChildren(session, n.id).length === 0, ).length, totalNodes: session.nodes.length, }; console.log(JSON.stringify(result, null, 2)); } export function queryShowQueryOnly(sessionId: string): void { const session = loadSession(sessionId); const queryString = renderQuery(session); console.log(queryString); } export function queryShow(sessionId: string): void { const session = loadSession(sessionId); console.log("Session:"); console.log(` ID: ${session.id}${session.name ? ` (${session.name})` : ""}`); console.log(` Org: ${session.orgAlias}`); console.log(` Operation: ${session.operation}`); if (session.instanceUrl) console.log(` Instance: ${session.instanceUrl}`); console.log(""); console.log(formatNavigationPath(session.navigationPath)); const aliasCtx = formatAliasContext(session); if (aliasCtx) console.log(aliasCtx); const ctx = getNavigationContext(session.navigationPath); if (ctx === "query" && !isInArgsContext(session.navigationPath)) { const schemaPath = queryNavToSchemaPath(session.navigationPath); if (schemaPath.length > 0) { try { const statusSchema = getSessionSchema(session); const wr = resolvePath(statusSchema, session.operation, schemaPath); console.log(`Type: ${wr.typeName} (${wr.kind})`); } catch { /* path may be stale */ } } } console.log(""); printQuery(session); if (session.variables.length > 0) { console.log(""); console.log("Variables:"); for (const v of session.variables) { let line = ` $${v.name}: ${v.type}`; if (v.defaultValue !== undefined) line += ` (default: ${v.defaultValue})`; if (v.runtimeValue !== undefined) line += ` = ${v.runtimeValue}`; console.log(line); const refs = findVariableReferences(session, v.name); for (const ref of refs) { const fieldDesc = formatPath(ref.fieldPath); const argPath = ref.subPath.length > 0 ? `${ref.argName}.${ref.subPath.join(".")}` : ref.argName; console.log(` → ${fieldDesc} @args/${argPath}`); } } const resolved = buildRuntimeVariables(session); if (Object.keys(resolved).length > 0) { console.log(""); console.log("Resolved variable values (sent at execution):"); console.log(JSON.stringify(resolved, null, 2)); } } else { console.log(""); console.log("Variables: (none)"); } } export async function queryValidate( sessionId: string, _strict = false, opts?: { codegen?: boolean }, ): Promise { const session = loadSession(sessionId); const schema = getSessionSchema(session); const queryString = renderQuery(session); console.log("Validating query..."); console.log(""); console.log(queryString); console.log(""); const errors = validateQuery(schema, queryString); console.log(formatValidationErrors(errors)); if (errors.length > 0) throw new CommandError( "Query has validation errors (see above).", EXIT_CODES.VALIDATION_FAILURE, ); // Salesforce-semantic validation const warnings = runSemanticValidation(session, schema); if (warnings.length > 0) { console.log("Salesforce semantic warnings:"); for (const w of warnings) { console.log(` ⚠ ${w}`); } console.log(""); } if (session.operation === "mutation") { await validateStrictMutation(session); } // --codegen flag: generate types after successful validation if (opts?.codegen) { // Pre-warm ObjectInfo cache for picklist enrichment const sObjects = collectSessionSObjects(session, schema); if (sObjects.size > 0) { try { const auth = await getOrgAuth(session.orgAlias); await Promise.all( [...sObjects].map((name) => getObjectInfo(auth, session.orgAlias, name).catch(() => { /* non-fatal: ObjectInfo warm-up is best-effort */ }), ), ); } catch { /* non-critical */ } } console.log(""); const code = generateTypes(session, schema); console.log(code); } } export function queryValidateAll(): void { const sessions = listSessions().sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id)); if (sessions.length === 0) { console.log("No sessions found."); return; } let passed = 0; let failed = 0; const failures: { name: string; errors: string[] }[] = []; for (const sessionMeta of sessions) { const identifier = sessionMeta.name ?? sessionMeta.id; try { const session = loadSession(sessionMeta.id); const schema = getSessionSchema(session); const queryString = renderQuery(session); const errors = validateQuery(schema, queryString); if (errors.length === 0) { console.log(` ✓ ${identifier}`); passed++; } else { const errorMsgs = errors.map((e) => typeof e === "string" ? e : ((e as any).message ?? String(e)), ); console.log(` ✗ ${identifier} (${errors.length} error${errors.length !== 1 ? "s" : ""})`); failures.push({ name: identifier, errors: errorMsgs }); failed++; } } catch (err: any) { console.log(` ✗ ${identifier} — ${err.message}`); failures.push({ name: identifier, errors: [err.message] }); failed++; } } console.log(""); console.log(`${passed + failed} sessions: ${passed} valid, ${failed} failed`); if (failures.length > 0) { console.log(""); for (const f of failures) { console.log(`${f.name}:`); for (const e of f.errors) { console.log(` - ${e}`); } } throw new CommandError( `${failed} session${failed !== 1 ? "s" : ""} failed validation.`, EXIT_CODES.VALIDATION_FAILURE, ); } } /** * Performs Salesforce-specific semantic validation beyond GraphQL schema checks: * - Hint when `first` is not set on connection fields (optional but recommended) * - Non-filterable fields used in `where` clauses * - Non-sortable fields used in `orderBy` clauses * - Empty selection sets on connection types */ function runSemanticValidation(session: QuerySession, schema: GraphQLSchema): string[] { const warnings: string[] = []; for (const node of session.nodes) { if (node.kind !== "field") continue; const fieldNode = node as FieldProjectionNode; try { const wr = resolvePath(schema, session.operation, fieldNode.schemaPath); const isConnection = wr.typeName.endsWith("Connection") || (wr.fields.some((f) => f.name === "edges") && wr.fields.some((f) => f.name === "pageInfo")); // Hint (not error): `first` is optional but recommended for predictable pagination if (isConnection && !fieldNode.args["first"]) { const pathStr = formatPath(fieldNode.schemaPath); warnings.push( `${pathStr}: no \`first\` argument set. Consider adding \`first\` to control page size.`, ); } if (isConnection) { const children = getChildren(session, fieldNode.id); if (children.length === 0) { const pathStr = formatPath(fieldNode.schemaPath); warnings.push( `${pathStr}: connection has no selected fields. Add selections under edges/node/.`, ); } // Warn when `after` arg is bound (pagination) but pageInfo is not selected if (fieldNode.args["after"]) { const hasPageInfo = children.some( (c) => c.kind === "field" && (c as FieldProjectionNode).fieldName === "pageInfo", ); if (!hasPageInfo) { const pathStr = formatPath(fieldNode.schemaPath); const prefix = pathStr.replace(/^\//, ""); warnings.push( `${pathStr}: \`after\` is bound but \`pageInfo\` is not selected. Add \`select ${prefix}/pageInfo/hasNextPage ${prefix}/pageInfo/endCursor\` for pagination.`, ); } } } } catch { /* path may not resolve, skip */ } // Check non-filterable where and non-sortable orderBy if (fieldNode.args["where"] || fieldNode.args["orderBy"]) { const sObjectName = detectSObjectName(session, fieldNode.schemaPath); if (sObjectName) { const objInfo = getCachedObjectInfo(session.orgAlias, sObjectName); if (objInfo) { const metaByApi = new Map(objInfo.fields.map((f) => [f.apiName, f])); const pathStr = formatPath(fieldNode.schemaPath); if (fieldNode.args["where"]) { try { const whereVal = JSON.parse(fieldNode.args["where"]); checkFilterableFields(whereVal, metaByApi, pathStr, warnings); } catch { /* not JSON, skip */ } } if (fieldNode.args["orderBy"]) { try { const orderVal = JSON.parse(fieldNode.args["orderBy"]); const items = Array.isArray(orderVal) ? orderVal : [orderVal]; for (const item of items) { if (typeof item === "object" && item !== null) { for (const fn of Object.keys(item)) { const meta = metaByApi.get(fn); if (meta && !meta.sortable) { warnings.push( `${pathStr}: orderBy uses ${fn} which is not sortable per ObjectInfo.`, ); } } } } } catch { /* not JSON, skip */ } } } } } } return warnings; } function checkFilterableFields( obj: unknown, metaByApi: Map, pathStr: string, warnings: string[], ): void { if (typeof obj !== "object" || obj === null) return; for (const [key, val] of Object.entries(obj as Record)) { if (key === "and" || key === "or") { const items = Array.isArray(val) ? val : [val]; for (const item of items) checkFilterableFields(item, metaByApi, pathStr, warnings); } else if (key === "not") { checkFilterableFields(val, metaByApi, pathStr, warnings); } else { const meta = metaByApi.get(key); if (meta && !meta.filterable) { warnings.push( `${pathStr}: where clause uses ${key} which is not filterable per ObjectInfo.`, ); } } } } export function buildSampleInputFields( requiredFields: FieldMetadata[], _sObjectName: string, info: ObjectInfoResult, ): Record { const sampleFields: Record = {}; for (const f of requiredFields) { const picklist = info.picklists.find((p) => p.apiName === f.apiName); if (picklist && picklist.values.length > 0) { sampleFields[f.apiName] = `"${picklist.values[0].value}"`; } else if (f.dataType === "STRING" || f.dataType === "TEXTAREA") { sampleFields[f.apiName] = `"<${f.label ?? f.apiName}>"`; } else if ( f.dataType === "CURRENCY" || f.dataType === "DOUBLE" || f.dataType === "INT" || f.dataType === "PERCENT" ) { sampleFields[f.apiName] = "0"; } else if (f.dataType === "DATE") { sampleFields[f.apiName] = `"${new Date().toISOString().split("T")[0]}"`; } else if (f.dataType === "DATETIME") { sampleFields[f.apiName] = `"${new Date().toISOString()}"`; } else if (f.dataType === "BOOLEAN") { sampleFields[f.apiName] = "false"; } else if (f.dataType === "REFERENCE") { sampleFields[f.apiName] = '""'; } else { sampleFields[f.apiName] = `"<${f.apiName}>"`; } } return sampleFields; } export async function validateStrictMutation(session: QuerySession): Promise { const rootChildren = getChildren(session, null); if (rootChildren.length === 0) return; for (const child of rootChildren) { if (child.kind !== "field") continue; const grandChildren = getChildren(session, child.id); for (const gc of grandChildren) { if (gc.kind !== "field") continue; const match = gc.fieldName.match(/^(\w+)(Create|Update)$/); if (!match) continue; const sObjectName = match[1]; const mutationType = match[2]; try { let auth; try { auth = await getOrgAuth(session.orgAlias); } catch (authErr: any) { throw new CommandError( `Authentication failed for "${session.orgAlias}": ${authErr.message}`, EXIT_CODES.AUTH_FAILURE, ); } const info = await getObjectInfo(auth, session.orgAlias, sObjectName); const requiredFields = getRequiredCreateFields(info); if (requiredFields.length === 0) continue; const inputVar = session.variables.find((v) => { const typeLower = v.type.toLowerCase(); return ( typeLower.includes(sObjectName.toLowerCase()) && typeLower.includes(mutationType.toLowerCase()) ); }); if (!inputVar?.runtimeValue) { const sampleFields = buildSampleInputFields(requiredFields, sObjectName, info); const cleaned: Record = {}; for (const [k, v] of Object.entries(sampleFields)) { try { cleaned[k] = JSON.parse(v); } catch { cleaned[k] = v; } } const sampleJson = JSON.stringify({ [sObjectName]: cleaned }, null, 2); const varName = inputVar?.name ?? "input"; console.log(""); console.log(`Strict validation for ${sObjectName}${mutationType}:`); console.log( ` Required fields (must provide): ${requiredFields.map((f) => f.apiName).join(", ")}`, ); console.log(` No runtime variable value set — cannot verify field coverage.`); console.log(""); console.log(` Fillable template — set this with:`); console.log(` vars value ${varName} '${sampleJson}'`); continue; } try { const parsed = JSON.parse(inputVar.runtimeValue); const innerData = parsed[sObjectName] ?? parsed; const providedFields = Object.keys(innerData); const missing = requiredFields.filter((f) => !providedFields.includes(f.apiName)); console.log(""); console.log(`Strict validation for ${sObjectName}${mutationType}:`); if (missing.length === 0) { console.log(` All required fields provided.`); } else { console.log( ` Missing required fields: ${missing.map((f) => `${f.apiName} (${f.label ?? f.apiName})`).join(", ")}`, ); const fieldDetails = missing.map((f) => { let detail = ` ${f.apiName}`; if (f.label && f.label !== f.apiName) detail += ` — "${f.label}"`; detail += ` (${f.dataType})`; return detail; }); console.log(" Details:"); fieldDetails.forEach((d) => console.log(d)); throw new CommandError( `Strict validation failed: ${missing.length} required field(s) missing.`, EXIT_CODES.VALIDATION_FAILURE, ); } } catch (e) { if (e instanceof CommandError) throw e; } } catch (e) { if (e instanceof CommandError) throw e; console.log(` (Could not fetch objectInfos for ${sObjectName}: ${(e as Error).message})`); } } } } export async function queryExecute( sessionId: string, overrides?: Record, dryRun?: boolean, ): Promise { const session = loadSession(sessionId); const schema = getSessionSchema(session); const queryString = renderQuery(session); const errors = validateQuery(schema, queryString); if (errors.length > 0) { throw new CommandError( `Query has validation errors:\n${formatValidationErrors(errors)}`, EXIT_CODES.VALIDATION_FAILURE, ); } const variables = buildRuntimeVariables(session, overrides); if (dryRun) { console.log("--- Dry Run ---"); console.log(""); console.log("Operation: " + session.operation); console.log(""); console.log("Query:"); console.log(queryString); if (Object.keys(variables).length > 0) { console.log(""); console.log("Variables:"); console.log(JSON.stringify(variables, null, 2)); } console.log(""); console.log("Validation: passed"); console.log(`Org: ${session.orgAlias}`); if (session.instanceUrl) { console.log(`Instance: ${session.instanceUrl}`); console.log(`Endpoint: ${session.instanceUrl}/services/data/v${DEFAULT_API_VERSION}/graphql`); } if (session.operation === "mutation") { console.log(""); console.log( "WARNING: This is a MUTATION — it will modify data when executed without --dry-run.", ); } return; } let auth; try { auth = await getOrgAuth(session.orgAlias); } catch (e: any) { throw new CommandError( `Authentication failed for "${session.orgAlias}": ${e.message}`, EXIT_CODES.AUTH_FAILURE, ); } let result; try { result = await executeGraphQL( auth, queryString, Object.keys(variables).length > 0 ? variables : undefined, ); } catch (e: any) { throw new CommandError(`Execution failed: ${e.message}`, EXIT_CODES.EXECUTION_FAILURE); } if (result && typeof result === "object" && "errors" in result) { const errors = (result as { errors?: { message: string }[] }).errors; if (errors && errors.length > 0) { console.log(JSON.stringify(result, null, 2)); throw new CommandError( `Server returned ${errors.length} error(s): ${errors[0].message}`, EXIT_CODES.EXECUTION_FAILURE, ); } } console.log(JSON.stringify(result, null, 2)); } export async function queryCodegen(sessionId: string, rest: string[]): Promise { const session = loadSession(sessionId); const schema = getSessionSchema(session); // --check flag: validate before generating types if (rest.includes("--check")) { const queryString = renderQuery(session); const errors = validateQuery(schema, queryString); if (errors.length > 0) { console.log(formatValidationErrors(errors)); throw new CommandError( "Query has validation errors (see above). Fix them before generating types.", EXIT_CODES.VALIDATION_FAILURE, ); } console.log("Validation passed."); console.log(""); } // Pre-warm ObjectInfo cache for all SObjects referenced in the session. // This ensures picklist enrichment works even when the disk cache has expired. const sObjects = collectSessionSObjects(session, schema); if (sObjects.size > 0) { try { const auth = await getOrgAuth(session.orgAlias); await Promise.all( [...sObjects].map((name) => getObjectInfo(auth, session.orgAlias, name).catch(() => { /* non-fatal: ObjectInfo warm-up is best-effort */ }), ), ); } catch { /* auth failure is non-critical for codegen */ } } const nameFlag = rest.find((_, i) => rest[i - 1] === "--name") ?? undefined; const outFlag = rest.find((_, i) => rest[i - 1] === "--out") ?? undefined; const languageFlag = rest.find( (_, i) => rest[i - 1] === "--language" || rest[i - 1] === "--lang" || rest[i - 1] === "-l", ) ?? undefined; const language = languageFlag ? normalizeCodegenLanguage(languageFlag) : "typescript"; if (!language) { throw new CommandError( `Unsupported codegen language: "${languageFlag}". Supported languages: ${SUPPORTED_CODEGEN_LANGUAGES.join(", ")}.`, EXIT_CODES.USER_ERROR, ); } const code = generateTypes(session, schema, { typeName: nameFlag, language }); if (outFlag) { const fs = await import("fs"); fs.writeFileSync(outFlag, code, "utf-8"); console.log(`Types written to ${outFlag}`); } else { console.log(code); } }