/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { buildOutput } from "./build-output.js"; import { getSchemaWithPriming } from "./get-schema-with-priming.js"; import { selectChildRelationship } from "./select-child-relationship.js"; import { type DetailSpec, type ToolOutput } from "./types.js"; import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js"; import { selectDottedFieldPath } from "../lib/path-selection.js"; import { type PrimeDeps } from "../lib/prime-schema.js"; import { addVariable, createSession, deepSetArg } from "../lib/session.js"; import { connectionNodePath, connectionPath } from "../lib/uiapi.js"; /** * Build a UIAPI single-record detail query against a Salesforce org. Implements * `sf_gql_detail` intent for the graphiti MCP server (FR-5.5). * * Implicit behaviors not visible in the signature: * - Always declares `: ID!` (default name `id`) and binds it via * `where: { Id: { eq: $ } }`, with `first: 1` set on the * connection. Single-record by Id is the whole point of this tool. * - Unlike `sf_gql_list`: no `$after` cursor, no `pageInfo` selection, and no * top-level `filter` / `orderBy` / `scope`. * - Scalar fields auto-wrap with `{ value }` per UIAPI; `Id` is selected bare. * - Dotted paths in `fields` / `parentFields` expand polymorphic unions into * per-member inline fragments; members lacking the field are silently skipped. * - `childRelationships` render as ` { edges { node { ... } } }` * connections with their own `first` / `filter` / `orderBy` (and `$varName` * leaves promote the same way they do on `sf_gql_list`). * - Operation name defaults to `Detail`. * * Throws on invalid `object` or `operationName` (must be valid GraphQL Names), * auth-missing or introspection failure (via `getSchemaWithPriming`), on FR-5.5 * spec violations (empty `idVariable`, collision with a child-filter `$varName` * of a different type); never throws on validation or codegen failure (those * surface as `warnings[]`). */ export async function buildDetail(spec: DetailSpec, deps?: PrimeDeps): Promise { if (spec.idVariable !== undefined && spec.idVariable.length === 0) { throw new Error("buildDetail: idVariable must be a non-empty string (FR-5.5)"); } assertGraphqlName(spec.object, "buildDetail", "object"); const { schema, primingNote, instanceUrl } = await getSchemaWithPriming(spec.org, deps); const session = createSession(spec.org, "query", instanceUrl); session.operationName = spec.operationName ?? `${spec.object}Detail`; assertGraphqlName(session.operationName, "buildDetail", "operationName"); const connection = connectionPath(spec.object); const node = connectionNodePath(connection); for (const field of spec.fields) { assertDottedGraphqlName(field, "buildDetail", "fields entry"); selectDottedFieldPath(session, schema, node, field); } if (spec.parentFields) { for (const pf of spec.parentFields) { assertDottedGraphqlName(pf, "buildDetail", "parentFields entry"); selectDottedFieldPath(session, schema, node, pf); } } const extraWarnings: string[] = []; if (spec.childRelationships) { for (const child of spec.childRelationships) { selectChildRelationship(session, schema, node, child, extraWarnings); } } // FR-5.5: : ID! + where: { Id: { eq: $ } } + first: 1. // Per-leaf deepSetArg is the cleanest form for a hardcoded shape: session.ts // preserves the leading `$` raw, and query-builder.ts emits it as an unquoted // variable reference. Same machinery the aggregate builder uses for groupBy. const idVar = spec.idVariable ?? "id"; // Children promote variables before the ID var is added; if a child filter // already declared a variable with this name AND a different type, we'd // silently overwrite it to `ID!` — corrupting the user's filter binding. // Detect and throw rather than mask the conflict. const existing = session.variables.find((v) => v.name === idVar); if (existing && existing.type !== "ID!") { throw new Error( `buildDetail: idVariable "${idVar}" collides with a $${idVar} reference in childRelationships ` + `(declared as ${existing.type}); choose a different idVariable.`, ); } addVariable(session, idVar, "ID!"); deepSetArg(session, connection, "where", ["Id", "eq"], `$${idVar}`); deepSetArg(session, connection, "first", [], "1"); return buildOutput(session, schema, primingNote, extraWarnings); }