/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { type GraphQLSchema } from "graphql"; import { getSchemaWithPriming } from "./get-schema-with-priming.js"; import { type DiscoverOutput, type DiscoverSpec, type FieldDescription, type ObjectDescription, } from "./types.js"; import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "../lib/auth.js"; import { type FieldMetadata, getObjectInfo as realGetObjectInfo, getRequiredCreateFields, type ObjectInfoResult, } from "../lib/object-info.js"; import { type PrimeDeps } from "../lib/prime-schema.js"; import { resolvePath } from "../lib/walker.js"; import { neutralizeControlChars } from "../schemas/tool-adapter.js"; export interface DiscoverDeps { primeDeps?: PrimeDeps; getOrgAuth?: (orgAlias: string) => Promise; getObjectInfo?: ( auth: OrgAuth, orgAlias: string, sObjectName: string, ) => Promise; } /** * Build the `sf_gql_discover` payload. Three modes: * * - `list_objects`: enumerate queryable SObjects from the schema's * `uiapi.query` selection. Optional `search` substring-filters names. * - `describe_object`: fetch ObjectInfo for `spec.object` and project it * onto the spec-defined `ObjectDescription` shape. * - `describe_field`: same as `describe_object`, narrowed to one field. * * Schema priming follows FR-13.3 — first call against an unprimed org * triggers introspection and surfaces a `Note: Primed schema cache ...` * warning. ObjectInfo carries its own per-object cache inside * `lib/object-info.ts` (1-hour TTL), so describe_* modes do not * re-introspect on every call. */ export async function buildDiscover( spec: DiscoverSpec, deps: DiscoverDeps = {}, ): Promise { const { schema, primingNote } = await getSchemaWithPriming(spec.org, deps.primeDeps); const warnings = primingNote ? [primingNote] : []; if (spec.mode === "list_objects") { const objects = listQueryableObjects(schema, spec.search); return { mode: "list_objects", objects, ...(warnings.length ? { warnings } : {}) }; } if (!spec.object) { throw new Error(`sf_gql_discover mode "${spec.mode}" requires "object".`); } const getAuth = deps.getOrgAuth ?? realGetOrgAuth; const fetchObjectInfo = deps.getObjectInfo ?? realGetObjectInfo; const auth = await getAuth(spec.org); // ObjectInfo failures (auth expired, network blip, object missing) propagate // to the caller. Earlier work tried a schema-only fallback that returned // field names from the cached GraphQL schema, but that response cannot // satisfy ObjectDescription (no picklists, no filterable/sortable flags, // no requiredOnCreate) and silently invites consumers to treat absent // metadata as authoritative absence. A loud error forces the right // recovery (refresh auth, prime schema, retry). const info = await fetchObjectInfo(auth, spec.org, spec.object); if (spec.mode === "describe_field") { if (!spec.field) { throw new Error('sf_gql_discover mode "describe_field" requires "field".'); } const field = info.fields.find((f) => f.apiName === spec.field); if (!field) { throw new Error( `Field "${spec.field}" not found on "${spec.object}". Use mode "describe_object" to list fields.`, ); } return { mode: "describe_field", field: toFieldDescription(field, info), ...(warnings.length ? { warnings } : {}), }; } if (spec.mode === "describe_object") { return { mode: "describe_object", object: toObjectDescription(spec.object, info), ...(warnings.length ? { warnings } : {}), }; } // Exhaustiveness guard: any new DiscoverMode added to types.ts must // surface as a TS error here instead of silently falling through to // describe_object. throw new Error(`Unhandled discover mode: ${exhaustive(spec.mode)}`); } function exhaustive(value: never): never { throw new Error(`Unexpected value: ${String(value)}`); } function listQueryableObjects( schema: GraphQLSchema, search?: string, ): { name: string; label?: string }[] { let result: { name: string; label?: string }[]; try { const walker = resolvePath(schema, "query", ["uiapi", "query"]); // uiapi.query exposes per-SObject Connection fields (Account, Contact, etc.) // alongside non-SObject helpers (search, aggregate). Filter to fields whose // return type follows the *Connection convention so list_objects matches // the spec contract — "queryable SObjects" — instead of leaking helpers. result = walker.fields .filter((f) => /Connection$/.test(f.typeName.replace(/[![\]]/g, ""))) .map((f) => ({ // `name` is a GraphQL field name (charset-guarded identifier) — left // as-is. W-23336442: `label` is free-text org schema description // reflected verbatim into the success envelope, so neutralize its // Cc/Cf (bidi/zero-width/DEL) before it reaches the host. name: f.name, ...(f.description ? { label: neutralizeControlChars(f.description) } : {}), })); } catch { return []; } if (search && search.trim().length > 0) { const needle = search.toLowerCase(); result = result.filter((o) => o.name.toLowerCase().includes(needle)); } result.sort((a, b) => a.name.localeCompare(b.name)); return result; } function toFieldDescription(field: FieldMetadata, info: ObjectInfoResult): FieldDescription { const picklist = info.picklists.find((p) => p.apiName === field.apiName); // `parseObjectInfoResponse` already drops null-valued entries, but the // PicklistValue type still allows null — filter narrows to string[]. // W-23336442: picklist `value` is free-text org metadata reflected verbatim // into the success envelope — neutralize Cc/Cf before it reaches the host. const picklistValues = picklist?.values .map((v) => v.value) .filter((v): v is string => v !== null) .map(neutralizeControlChars) ?? []; return { // W-23336442: `name`/`type` are identifier/system-enum (already charset- // safe) so pass through; `label` is free-text org metadata, so neutralize // its Cc/Cf (bidi/zero-width/DEL) on this success-envelope reflection path. name: field.apiName, label: neutralizeControlChars(field.label ?? field.apiName), type: field.dataType ?? "UNKNOWN", filterable: field.filterable, sortable: field.sortable, nameField: field.nameField, compound: field.compound, defaultedOnCreate: field.defaultedOnCreate, ...(picklistValues.length > 0 ? { picklistValues } : {}), }; } function toObjectDescription(name: string, info: ObjectInfoResult): ObjectDescription { const fields = info.fields.map((f) => toFieldDescription(f, info)); const childRelationships = info.childRelationships .filter((cr) => cr.relationshipName !== null) .map((cr) => ({ relationshipName: cr.relationshipName as string, childObject: cr.childObjectApiName, })); const parentReferences = info.fields .filter((f) => f.reference && f.referenceToInfos.length > 0) .map((f) => ({ field: f.apiName, targetObjects: f.referenceToInfos.map((r) => r.apiName), })); const filterableFields = info.fields .filter((f) => f.filterable && !f.compound) .map((f) => f.apiName); const sortable = info.fields.find((f) => f.sortable && !f.compound); const orderByExample: Record = sortable ? { [sortable.apiName]: { order: "DESC" } } : {}; const requiredOnCreate = getRequiredCreateFields(info).map((f) => f.apiName); return { name, fields, childRelationships, parentReferences, filterableFields, orderByExample, requiredOnCreate, }; }