/** * 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 crypto from "crypto"; import fs from "fs"; import path from "path"; import { Org, type Connection } from "@salesforce/core"; import type { OrgAuth } from "./auth.js"; import { SchemaError } from "./errors.js"; import { atomicWriteJson, graphitiHome } from "./fs-utils.js"; // Re-export for backward compatibility with existing graphiti consumers. export { graphitiHome }; /** API version used for display-only endpoints where no live connection is available * to query the org's actual version (live requests use connection.getApiVersion()). */ export const DEFAULT_API_VERSION = "67.0"; async function getConnection(auth: OrgAuth): Promise { const org = await Org.create({ aliasOrUsername: auth.alias }); return org.getConnection(); } // Per-request options for the introspection POST (W-22845606). `@salesforce/core` // passes these straight through to jsforce: // - `timeout` bounds EACH attempt (jsforce defaults to 30 min) so a wedged gateway // can't hang the schema-lock holder indefinitely. A single attempt (5 min) aborts // before prime-schema's 7-min STALE_LOCK_MS, so the holder normally releases the // lock before any waiter reclaims it. A timeout *plus* the retry below can run // ~2x the timeout and exceed STALE_LOCK_MS — a waiter may then reclaim mid-retry // and run a second introspection: benign, it degrades to the redundant-download // case coalescing already tolerates (atomic writes keep the cache correct). A // strict bound would require timeout × (maxRetries + 1) < STALE_LOCK_MS. // - jsforce does NOT retry POST by default, so we opt this idempotent introspection // read into one retry on transient HTTP/network failures (with jsforce's built-in // exponential backoff). Network errnos (ECONNRESET/ETIMEDOUT/…) are covered by // jsforce's default `errorCodes`. const INTROSPECTION_TIMEOUT_MS = 5 * 60_000; const INTROSPECTION_REQUEST_OPTIONS = { timeout: INTROSPECTION_TIMEOUT_MS, retry: { methods: ["POST"], maxRetries: 1, statusCodes: [420, 429, 500, 502, 503, 504] }, }; /** Resolves the directory holding cached introspection JSON files. */ export function schemaDir(): string { return path.join(graphitiHome(), "schemas"); } const INTROSPECTION_QUERY = ` query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { kind name description fields(includeDeprecated: false) { name description args { name description type { ...TypeRef } defaultValue } type { ...TypeRef } } inputFields { name description type { ...TypeRef } defaultValue } interfaces { ...TypeRef } enumValues(includeDeprecated: false) { name description } possibleTypes { ...TypeRef } } directives { name description locations args { name description type { ...TypeRef } defaultValue } } } } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } }`; // ── DLM/SSOT stripping ─────────────────────────────────────────────────────── const DLM_SSOT_RE = /__dlm/i; function getNamedTypeNameFromRef(typeRef: any): string | null { if (!typeRef) return null; if (typeRef.name) return typeRef.name; return getNamedTypeNameFromRef(typeRef.ofType); } /** * Strips Data Cloud (DLM/SSOT) types and their field references from a raw * introspection result. Matches type names containing `__dlm` (case-insensitive) * or starting with `ssot__`. Returns the mutated result and a count of removed types. */ export function stripDataCloudTypes(raw: any): { result: any; removedCount: number } { const schema = raw?.data?.__schema ?? raw?.__schema; if (!schema?.types) return { result: raw, removedCount: 0 }; const removedNames = new Set(); for (const type of schema.types) { if (type.name && (DLM_SSOT_RE.test(type.name) || type.name.startsWith("ssot__"))) { removedNames.add(type.name); } } if (removedNames.size === 0) return { result: raw, removedCount: 0 }; const isRemoved = (ref: any) => { const name = getNamedTypeNameFromRef(ref); return name != null && removedNames.has(name); }; schema.types = schema.types.filter((t: any) => !removedNames.has(t.name)); for (const type of schema.types) { if (type.fields) { type.fields = type.fields.filter((f: any) => !isRemoved(f.type)); } if (type.inputFields) { type.inputFields = type.inputFields.filter((f: any) => !isRemoved(f.type)); } if (type.possibleTypes) { type.possibleTypes = type.possibleTypes.filter( (t: any) => !t.name || !removedNames.has(t.name), ); } if (type.interfaces) { type.interfaces = type.interfaces.filter((t: any) => !t.name || !removedNames.has(t.name)); } } return { result: raw, removedCount: removedNames.size }; } function _schemaPath(alias: string): string { return path.join(schemaDir(), `${alias}.json`); } export function normalizeInstanceUrl(instanceUrl: string): string { return instanceUrl.replace(/\/+$/, "").toLowerCase(); } export function schemaCacheKeyForInstanceUrl(instanceUrl: string): string { const normalized = normalizeInstanceUrl(instanceUrl); return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16); } function schemaPathForInstanceUrl(instanceUrl: string): string { const cacheKey = schemaCacheKeyForInstanceUrl(instanceUrl); return path.join(schemaDir(), `${cacheKey}.json`); } export function schemaExists(instanceUrl: string): boolean { return fs.existsSync(schemaPathForInstanceUrl(normalizeInstanceUrl(instanceUrl))); } export interface SchemaMetadata { cacheKey: string; instanceUrl: string; typeCount: number; downloadedAt: string; filePath: string; strippedDataCloudTypes?: number; } export function getSchemaMetadata(instanceUrl: string): SchemaMetadata | null { const normalized = normalizeInstanceUrl(instanceUrl); const fp = schemaPathForInstanceUrl(normalized); if (!fs.existsSync(fp)) return null; try { const raw = JSON.parse(fs.readFileSync(fp, "utf-8")); const types = (raw?.data?.__schema?.types ?? raw?.__schema?.types ?? []) as any[]; const stat = fs.statSync(fp); return { cacheKey: schemaCacheKeyForInstanceUrl(normalized), instanceUrl: normalized, typeCount: types.filter((t: any) => !t.name.startsWith("__")).length, downloadedAt: stat.mtime.toISOString(), filePath: fp, }; } catch { return null; } } export function listCachedSchemas(): SchemaMetadata[] { const dir = schemaDir(); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((f) => f.endsWith(".json")) .map((fileName) => { try { const fp = path.join(dir, fileName); const raw = JSON.parse(fs.readFileSync(fp, "utf-8")); const types = (raw?.data?.__schema?.types ?? raw?.__schema?.types ?? []) as any[]; const stat = fs.statSync(fp); const instanceUrl = raw?.__graphiti?.instanceUrl; if (typeof instanceUrl !== "string") return null; return { cacheKey: fileName.replace(".json", ""), instanceUrl, typeCount: types.filter((t: any) => !t.name.startsWith("__")).length, downloadedAt: stat.mtime.toISOString(), filePath: fp, }; } catch { return null; } }) .filter((m): m is SchemaMetadata => m !== null); } export async function downloadSchema(auth: OrgAuth): Promise { const connection = await getConnection(auth); const url = `${auth.instanceUrl}/services/data/v${connection.getApiVersion()}/graphql`; const rawResult = (await connection.request( { method: "POST", url, body: JSON.stringify({ query: INTROSPECTION_QUERY }), headers: { "Content-Type": "application/json", "X-Chatter-Entity-Encoding": "false" }, }, INTROSPECTION_REQUEST_OPTIONS, )) as any; if (rawResult?.errors?.length) { const messages = (rawResult.errors as any[]) .map((e: any) => e.message ?? JSON.stringify(e)) .join("\n "); // GraphQL errors in a 200 body are deterministic — retrying the identical // query won't help (W-23148365). throw new SchemaError(`Introspection query returned errors:\n ${messages}`, { retry: "no" }); } const rawSchema = rawResult?.data?.__schema ?? rawResult?.__schema; if (!rawSchema) { // A well-formed 200 with no __schema is a permanent shape problem. throw new SchemaError("Introspection query did not return a __schema field", { retry: "no", }); } const { result, removedCount } = stripDataCloudTypes(rawResult); const schema = result?.data?.__schema ?? result?.__schema; const instanceUrl = normalizeInstanceUrl(auth.instanceUrl); const filePath = schemaPathForInstanceUrl(instanceUrl); const payload = { ...result, __graphiti: { instanceUrl, alias: auth.alias, cachedAt: new Date().toISOString(), cacheKey: schemaCacheKeyForInstanceUrl(instanceUrl), strippedDataCloudTypes: removedCount, }, }; atomicWriteJson(filePath, payload); const typeCount = (schema.types as any[]).filter((t: any) => !t.name.startsWith("__")).length; return { cacheKey: schemaCacheKeyForInstanceUrl(instanceUrl), instanceUrl, typeCount, downloadedAt: new Date().toISOString(), filePath, strippedDataCloudTypes: removedCount || undefined, }; } export function loadIntrospectionResult(instanceUrl: string): any { const fp = schemaPathForInstanceUrl(normalizeInstanceUrl(instanceUrl)); if (!fs.existsSync(fp)) { // Retrying this read won't help — the org was never primed. The fix is to // prime (a different action), so this is permanent (W-23148365). throw new SchemaError( `No cached schema for "${instanceUrl}". Run \`graphiti connect \` first.`, { retry: "no" }, ); } return JSON.parse(fs.readFileSync(fp, "utf-8")); } /** Returns the absolute path to the cached introspection JSON file. */ export function getSchemaFilePath(instanceUrl: string): string { return schemaPathForInstanceUrl(normalizeInstanceUrl(instanceUrl)); } export async function executeGraphQL( auth: OrgAuth, query: string, variables?: Record, ): Promise { const connection = await getConnection(auth); const url = `${auth.instanceUrl}/services/data/v${connection.getApiVersion()}/graphql`; return connection.request({ method: "POST", url, body: JSON.stringify({ query, variables }), headers: { "Content-Type": "application/json", "X-Chatter-Entity-Encoding": "false" }, }); }