/** * 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 path from "path"; import type { OrgAuth } from "./auth.js"; import { graphitiHome } from "./fs-utils.js"; import { executeGraphQL } from "./introspect.js"; // Re-read GRAPHITI_HOME on each call so tests redirecting via env vars // see the current value, not a frozen import-time snapshot. function cacheDir(): string { return path.join(graphitiHome(), "cache", "objectInfos"); } const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const MASTER_RECORD_TYPE_ID = "012000000000000AAA"; export interface PicklistValue { value: string | null; label: string | null; } export interface PicklistFieldInfo { apiName: string; label: string; required: boolean; values: PicklistValue[]; } export interface FieldMetadata { apiName: string; label: string | null; dataType: string | null; required: boolean; createable: boolean; updateable: boolean; calculated: boolean; custom: boolean; filterable: boolean; sortable: boolean; nameField: boolean; reference: boolean; relationshipName: string | null; compound: boolean; compoundFieldName: string | null; defaultedOnCreate: boolean; extraTypeInfo: string | null; inlineHelpText: string | null; precision: number; scale: number; referenceToInfos: { apiName: string; nameFields: string[] }[]; controllerName: string | null; controllingFields: string[]; } export interface ObjectInfoResult { apiName: string; label: string | null; labelPlural: string | null; createable: boolean; deletable: boolean; updateable: boolean; queryable: boolean; searchable: boolean; custom: boolean; keyPrefix: string | null; nameFields: string[]; defaultRecordTypeId: string | null; fields: FieldMetadata[]; childRelationships: { childObjectApiName: string; fieldName: string | null; relationshipName: string | null; }[]; recordTypeInfos: { recordTypeId: string; name: string | null; available: boolean; master: boolean; defaultRecordTypeMapping: boolean; }[]; picklists: PicklistFieldInfo[]; fetchedAt: string; } const OBJECT_INFO_QUERY = ` query ObjectInfoQuery($inputs: [ObjectInfoInput!]) { uiapi { objectInfos(objectInfoInputs: $inputs) { ApiName label labelPlural createable deletable updateable queryable searchable custom keyPrefix nameFields defaultRecordTypeId recordTypeInfos { recordTypeId name available master defaultRecordTypeMapping } childRelationships { childObjectApiName fieldName relationshipName } fields { ApiName label dataType required createable updateable calculated custom filterable sortable nameField reference relationshipName compound compoundFieldName defaultedOnCreate extraTypeInfo inlineHelpText precision scale controllerName controllingFields referenceToInfos { ApiName nameFields } ... on PicklistField { picklistValuesByRecordTypeIDs { recordTypeID picklistValues { value label } } } } } } }`; // Process-lifetime in-memory cache. Unbounded by design: graphiti runs as // short-lived CLI invocations and per-MCP-session servers, so the working // set is naturally capped by the SObjects an agent walks during one session. const memoryCache = new Map(); function cacheKey(orgAlias: string, sObjectName: string): string { return `${orgAlias}:${sObjectName}`; } // Defense in depth: callers (the MCP boundary) already validate API-name // charsets, but cache paths are also reachable from CLI/library callers, so // re-validate here before joining and assert the resolved path stays inside // the cache root. const ORG_ALIAS_PATH_RE = /^[A-Za-z0-9_-]{1,80}$/; const SOBJECT_NAME_PATH_RE = /^[A-Za-z][A-Za-z0-9_]{0,79}$/; function cacheFilePath(orgAlias: string, sObjectName: string): string { if (!ORG_ALIAS_PATH_RE.test(orgAlias)) { throw new Error(`Invalid org alias for cache path: "${orgAlias}"`); } if (!SOBJECT_NAME_PATH_RE.test(sObjectName)) { throw new Error(`Invalid SObject name for cache path: "${sObjectName}"`); } const rootResolved = path.resolve(cacheDir()); const resolved = path.resolve(rootResolved, orgAlias, `${sObjectName}.json`); if (!resolved.startsWith(rootResolved + path.sep)) { throw new Error("Refusing to access cache path outside cache root"); } return resolved; } function readDiskCache(orgAlias: string, sObjectName: string): ObjectInfoResult | null { let fp: string; try { fp = cacheFilePath(orgAlias, sObjectName); } catch { return null; } if (!fs.existsSync(fp)) return null; try { const raw = JSON.parse(fs.readFileSync(fp, "utf-8")) as ObjectInfoResult; const age = Date.now() - new Date(raw.fetchedAt).getTime(); // Reject NaN (corrupt or missing fetchedAt) so a hand-edited cache // can't read as fresh forever. if (Number.isNaN(age) || age > CACHE_TTL_MS) return null; return raw; } catch { return null; } } function writeDiskCache(orgAlias: string, sObjectName: string, data: ObjectInfoResult): void { let filePath: string; try { filePath = cacheFilePath(orgAlias, sObjectName); } catch { return; // invalid inputs — refuse to write } try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); } catch { // Non-critical } } function parseObjectInfoResponse(raw: any): ObjectInfoResult { const fields: FieldMetadata[] = (raw.fields ?? []).map((f: any) => ({ apiName: f.ApiName, label: f.label ?? null, dataType: f.dataType ?? null, required: f.required ?? false, createable: f.createable ?? false, updateable: f.updateable ?? false, calculated: f.calculated ?? false, custom: f.custom ?? false, filterable: f.filterable ?? false, sortable: f.sortable ?? false, nameField: f.nameField ?? false, reference: f.reference ?? false, relationshipName: f.relationshipName ?? null, compound: f.compound ?? false, compoundFieldName: f.compoundFieldName ?? null, defaultedOnCreate: f.defaultedOnCreate ?? false, extraTypeInfo: f.extraTypeInfo ?? null, inlineHelpText: f.inlineHelpText ?? null, precision: f.precision ?? 0, scale: f.scale ?? 0, referenceToInfos: (f.referenceToInfos ?? []).map((r: any) => ({ apiName: r.ApiName, nameFields: r.nameFields ?? [], })), controllerName: f.controllerName ?? null, controllingFields: f.controllingFields ?? [], })); const picklists: PicklistFieldInfo[] = (raw.fields ?? []) .filter((f: any) => f.picklistValuesByRecordTypeIDs) .map((f: any) => { const allValues = (f.picklistValuesByRecordTypeIDs ?? []).flatMap( (rt: any) => rt.picklistValues ?? [], ); // Drop entries with no value rather than recording `value: null`. // Keeps the on-disk cache shape identical to pre-change behavior so // consumers that don't null-guard (commands/type.ts, commands/review.ts) // can't read explicit nulls back out after a rollback. return { apiName: f.ApiName, label: f.label ?? f.ApiName, required: f.required ?? false, values: allValues .filter((v: any) => v?.value != null) .map((v: any) => ({ value: v.value, label: v.label ?? null })), }; }); return { apiName: raw.ApiName, label: raw.label ?? null, labelPlural: raw.labelPlural ?? null, createable: raw.createable ?? false, deletable: raw.deletable ?? false, updateable: raw.updateable ?? false, queryable: raw.queryable ?? false, searchable: raw.searchable ?? false, custom: raw.custom ?? false, keyPrefix: raw.keyPrefix ?? null, nameFields: raw.nameFields ?? [], defaultRecordTypeId: raw.defaultRecordTypeId ?? null, fields, childRelationships: (raw.childRelationships ?? []).map((cr: any) => ({ childObjectApiName: cr.childObjectApiName, fieldName: cr.fieldName ?? null, relationshipName: cr.relationshipName ?? null, })), recordTypeInfos: (raw.recordTypeInfos ?? []).map((rt: any) => ({ recordTypeId: rt.recordTypeId, name: rt.name ?? null, available: rt.available ?? false, master: rt.master ?? false, defaultRecordTypeMapping: rt.defaultRecordTypeMapping ?? false, })), picklists, fetchedAt: new Date().toISOString(), }; } export async function getObjectInfo( auth: OrgAuth, orgAlias: string, sObjectName: string, refresh = false, ): Promise { const key = cacheKey(orgAlias, sObjectName); if (!refresh) { const mem = memoryCache.get(key); if (mem) { const age = Date.now() - new Date(mem.fetchedAt).getTime(); if (age <= CACHE_TTL_MS) return mem; } const disk = readDiskCache(orgAlias, sObjectName); if (disk) { memoryCache.set(key, disk); return disk; } } // Master record type is the union of all picklist values for the field; // non-master record types only restrict (hide) values, never add them. So // a single fetch with [MASTER_RECORD_TYPE_ID] returns the complete set. const result = await executeGraphQL(auth, OBJECT_INFO_QUERY, { inputs: [{ apiName: sObjectName, recordTypeIDs: [MASTER_RECORD_TYPE_ID] }], }); const infos = result?.data?.uiapi?.objectInfos; if (!infos || infos.length === 0) { throw new Error( `No objectInfo returned for "${sObjectName}". Check that the object exists and is accessible.`, ); } const parsed = parseObjectInfoResponse(infos[0]); memoryCache.set(key, parsed); writeDiskCache(orgAlias, sObjectName, parsed); return parsed; } export function getCachedObjectInfo( orgAlias: string, sObjectName: string, ): ObjectInfoResult | null { const key = cacheKey(orgAlias, sObjectName); const mem = memoryCache.get(key); if (mem) return mem; const disk = readDiskCache(orgAlias, sObjectName); if (disk) { memoryCache.set(key, disk); return disk; } return null; } // Seed the in-memory ObjectInfo cache directly. Lets callers prime codegen // without going through the live UIAPI fetch in `getObjectInfo`. Used by // tests today; the planned MCP intent-layer prewarm helper (W-22694063) // will reuse the same write path after a successful network fetch. export function setCachedObjectInfo( orgAlias: string, sObjectName: string, info: ObjectInfoResult, ): void { memoryCache.set(cacheKey(orgAlias, sObjectName), info); } export function getRequiredCreateFields(info: ObjectInfoResult): FieldMetadata[] { return info.fields.filter((f) => f.required && f.createable && !f.defaultedOnCreate); } export function getPicklistValues( info: ObjectInfoResult, fieldName: string, ): PicklistValue[] | null { const picklist = info.picklists.find((p) => p.apiName === fieldName); return picklist?.values ?? null; } export function clearObjectInfoCache(orgAlias?: string): void { if (orgAlias) { for (const [key] of memoryCache) { if (key.startsWith(`${orgAlias}:`)) memoryCache.delete(key); } if (!ORG_ALIAS_PATH_RE.test(orgAlias)) return; const root = path.resolve(cacheDir()); const dir = path.resolve(root, orgAlias); if (!dir.startsWith(root + path.sep)) return; try { fs.rmSync(dir, { recursive: true }); } catch { /* ok */ } } else { memoryCache.clear(); try { fs.rmSync(cacheDir(), { recursive: true }); } catch { /* ok */ } } }