/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * Library helpers that close two correctness gaps in any consumer * (CLI, MCP intent layer, eval harness) that turns a typed JSON spec * into a GraphQL operation: * * - `promoteVariables` finds `$varName` string leaves anywhere in * a filter / orderBy / scope value, infers each one's GraphQL * type from the schema, and registers it on the session so the * rendered operation declares the variables it references. * * - `normalizeOrderBy` collapses an array-shaped orderBy input to * a singleton object, matching Salesforce's connection-field * `_OrderBy` schema. Tolerating the array shape is * necessary for clients (and earlier MCP versions) that inferred * a list type. * * Both are pure (or session-mutating but otherwise pure) and have no * dependency on the MCP server, the CLI, or any I/O. They live next * to `path-selection.ts` as the second piece of the "what every * graphiti consumer needs" library surface. */ import { type GraphQLSchema } from "graphql"; import { GRAPHQL_NAME_RE } from "./graphql-name.js"; import { addVariable, type QuerySession } from "./session.js"; import { inferTypeFromArgsPath } from "../commands/query-helpers.js"; const VAR_PLACEHOLDER_RE = /^\$([A-Za-z_][\w]*)$/; /** * Recursively walks `value` looking for `$varName` string leaves. For * each one, infers the GraphQL type at that input path via * `inferTypeFromArgsPath` and calls `addVariable(session, name, type)`. * Promoted variables strip a trailing `!` (query variables are * nullable by convention; callers requiring NonNull use the `var` * command directly). * * If type inference throws — for example because the input path * doesn't exist on the schema — the helper falls back to `String` * rather than failing the whole render. A wrong-type variable is * easier for a user to debug than a hard error before they see their * query. * * @param session Session to add variables to. * @param schema The loaded GraphQL schema. * @param fieldSchemaPath Path of the field whose argument we are * promoting against (e.g. `["uiapi", "query", * "Account"]`). * @param argName The argument name (`"where"`, `"orderBy"`, * `"scope"`). * @param value The argument value — can be a primitive, * array, or nested object. * @param warnings Optional warnings sink. Strings starting with * `$` that don't form a valid GraphQL Name * (e.g. `$1var`, `$foo-bar`) push a warning * here so callers see the typo instead of * silently rendering the literal. */ export function promoteVariables( session: QuerySession, schema: GraphQLSchema, fieldSchemaPath: string[], argName: string, value: unknown, warnings?: string[], ): void { walk(session, schema, fieldSchemaPath, argName, value, [], warnings); } function walk( session: QuerySession, schema: GraphQLSchema, fieldSchemaPath: string[], argName: string, value: unknown, pathInsideArg: string[], warnings: string[] | undefined, ): void { if (typeof value === "string") { const m = VAR_PLACEHOLDER_RE.exec(value); if (!m || !m[1]) { if (value.startsWith("$") && warnings) { const argPath = [argName, ...pathInsideArg].join("."); warnings.push( `Variable: '${value}' at '${argPath}' starts with '$' but is not a valid variable placeholder; rendered as a literal string. Variable names must match /^[A-Za-z_][A-Za-z0-9_]*$/.`, ); } return; } const varName = m[1]; let declared: string; try { const { inferredType } = inferTypeFromArgsPath(schema, session.operation, fieldSchemaPath, [ argName, ...pathInsideArg, ]); declared = inferredType.endsWith("!") ? inferredType.slice(0, -1) : inferredType; } catch { declared = "String"; } const collision = addVariable(session, varName, declared); if (collision && warnings) { warnings.push( `Variable: type collision for $${collision.name} — first declaration as '${collision.existingType}' kept, later inference '${collision.ignoredType}' ignored. UIAPI requires one type per variable; reference $${collision.name} consistently or use distinct variable names.`, ); } return; } if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { walk( session, schema, fieldSchemaPath, argName, value[i], [...pathInsideArg, String(i)], warnings, ); } return; } if (value && typeof value === "object") { for (const [k, v] of Object.entries(value as Record)) { // Object keys are rendered RAW as field/operator names by // query-builder's valueToGraphQL (input-object keys cannot be quoted in // GraphQL), so an unvalidated key like `Id: {eq:"1"}}) { evil { value }` is // a selection-set / argument breakout. Validate every key (at every depth) // as a GraphQL Name and hard-fail — values are separately quoted and safe. // This is the Class-B half of W-22735537; runs before deepSetArg renders it. if (!GRAPHQL_NAME_RE.test(k)) { throw new Error( `${argName}: key '${k}' is not a valid GraphQL Name (must match ${GRAPHQL_NAME_RE}); filter/orderBy keys render as field/operator names in the query.`, ); } walk(session, schema, fieldSchemaPath, argName, v, [...pathInsideArg, k], warnings); } } } /** * Result of promoting one whole argument. `rendered` is what the caller * hands to `deepSetArg`: the raw `$var` reference when the entire argument * was a single `$varName` string, or the JSON-stringified value otherwise. */ export interface ArgPromotion { rendered: string; } /** * Promote a `where` / `orderBy` argument that may be EITHER a normal * object (whose `$varName` leaves promote via `promoteVariables`) OR a * single top-level `$varName` string standing in for the whole argument. * * The whole-argument form is the idiomatic UIAPI pattern for an * optionally-applied filter: a `null`-bound `where` means "no constraint → * all rows", which a nullable leaf cannot express. When `value` is a * whole-arg placeholder we infer the argument's own input type * (e.g. `Case_Filter`, `Case_OrderBy`), declare one nullable variable, and * return its `$ref` so the caller renders `where: $filter` directly. * * Type inference for a real `where`/`orderBy` argument effectively never * fails, but if it does we still emit the variable as `String` and push a * loud warning — a best-effort query the user can debug beats a hard error * before they see anything. */ export function promoteArg( session: QuerySession, schema: GraphQLSchema, fieldSchemaPath: string[], argName: string, value: unknown, warnings?: string[], ): ArgPromotion { if (typeof value === "string") { const m = VAR_PLACEHOLDER_RE.exec(value); if (m && m[1]) { const varName = m[1]; try { const { inferredType } = inferTypeFromArgsPath(schema, session.operation, fieldSchemaPath, [ argName, ]); const declared = inferredType.endsWith("!") ? inferredType.slice(0, -1) : inferredType; addVariable(session, varName, declared); } catch (err) { addVariable(session, varName, "String"); const hint = err instanceof Error ? ` (${err.message})` : ""; warnings?.push( `Variable: could not infer the GraphQL type for whole-argument '${value}' on '${argName}'${hint}; declared as 'String'. The rendered query may be invalid — verify the variable type.`, ); } return { rendered: value }; } // A `$`-prefixed string that is not a valid placeholder, or any other // bare string: fall through to leaf handling, which emits the existing // invalid-placeholder warning and renders the literal. } promoteVariables(session, schema, fieldSchemaPath, argName, value, warnings); return { rendered: JSON.stringify(value) }; } /** * Collapses an array-shaped orderBy input to its first element, so * callers can pass either shape without knowing Salesforce's * connection-field schema requires a singleton `_OrderBy`. * * - `[{Name: "ASC"}, ...]` → `{Name: "ASC"}` * - `[]` → `undefined` * - `{Name: "ASC"}` → unchanged * - `undefined` → `undefined` */ export function normalizeOrderBy(orderBy: T | T[] | undefined): T | undefined { if (orderBy === undefined) return undefined; if (Array.isArray(orderBy)) { return orderBy.length > 0 ? orderBy[0] : undefined; } return orderBy; }