/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import fs from "node:fs"; import path from "node:path"; import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "./auth.js"; import { AuthError, classifyCause, isAuthError, type RetryHint, SchemaError } from "./errors.js"; import { downloadSchema as realDownloadSchema, getSchemaMetadata, normalizeInstanceUrl, schemaCacheKeyForInstanceUrl, schemaDir, type SchemaMetadata, } from "./introspect.js"; import { clearObjectInfoCache } from "./object-info.js"; import { clearSchemaCacheByUrl } from "./walker.js"; // FR-13.7 originally suggested a 10-30s introspection budget. Empirical // smoke testing against real Salesforce UIAPI schemas measures ~135s for // a ~40MB introspection. We size the stale-lock threshold and the waiter // timeout for ~3× that observed worst case (~7 min): comfortable headroom // for normal operation while keeping crashed-holder recovery within a few // minutes. If introspection ever legitimately exceeds 5 min the design // assumption ("priming completes in minutes, not tens of minutes") has // changed and these constants should be revisited together (likely with // a heartbeat that refreshes the lock dir's mtime while held). const STALE_LOCK_MS = 7 * 60_000; const POLL_MS = 100; const MAX_WAIT_MS = 7 * 60_000; /** * Acquire a filesystem advisory lock at `${finalPath}.lock` (a directory, * since `mkdir` is atomic on POSIX), run `work`, then release the lock. * * If another process holds the lock, this polls every 100ms for the lock * to be released. On release, if the work is already satisfied (per `skipIf`), * the work function is skipped (the other process already did it). Otherwise * this acquires the lock and runs `work`. * * Contract: `work` will NOT run when `skipIf()` is true at any point during * the lock dance — including immediately after we acquire the lock (a holder * may have finished and released the lock between our last EEXIST poll and * our successful `mkdir`). In that case the lock is released and the * function returns `undefined`. * * `skipIf` defaults to "`finalPath` exists" — the lazy-prime contract (any * cached schema is good enough). A forced refresh passes a predicate that is * true only once the file has been *replaced* since the refresh was requested, * which coalesces concurrent refreshes onto a single introspection. * * Stale locks (older than STALE_LOCK_MS) are reclaimed. */ export async function withSchemaLock( finalPath: string, work: () => Promise, opts: { skipIf?: () => boolean } = {}, ): Promise { const skip = opts.skipIf ?? (() => fs.existsSync(finalPath)); const lockPath = `${finalPath}.lock`; fs.mkdirSync(path.dirname(finalPath), { recursive: true }); const startedWaitingAt = Date.now(); while (true) { try { fs.mkdirSync(lockPath); // atomic; throws EEXIST if held break; } catch (e: unknown) { const err = e as NodeJS.ErrnoException; if (err.code !== "EEXIST") throw e; // If the work is already satisfied, another holder finished — short-circuit. if (skip()) return undefined; // Stale-lock reclaim. Known TOCTOU: between this `statSync` and // the `rmSync` below, another process may have already reclaimed // the stale lock and re-created a fresh one — in which case we'd // remove the fresh lock. Window is microseconds and the next // `mkdirSync` retry self-heals (we'd re-acquire or re-block); // not worth fcntl-level locking to close. try { const stat = fs.statSync(lockPath); if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) { // Recursive removal: a stray file inside the lock dir // (e.g. macOS `.DS_Store`) would make `rmdirSync` throw // ENOTEMPTY, which the bare catch below would swallow, // leaving the stale lock in place. fs.rmSync(lockPath, { recursive: true }); continue; } } catch { // Lock vanished between EEXIST and stat — loop and retry mkdir. } if (Date.now() - startedWaitingAt > MAX_WAIT_MS) { // Pure filesystem contention — another holder was downloading; an // immediate retry likely finds a fresh cache. No live org round-trip // occurred, so retry=now (W-23148365). Not covered by the connection // layer's built-in retry, so the host's retry is the only one left. throw new SchemaError( `Timed out waiting ${MAX_WAIT_MS}ms for schema priming lock at ${lockPath}`, { retry: "now" }, ); } await new Promise((r) => setTimeout(r, POLL_MS)); } } // We now hold the lock. Re-check `skip`: a holder that finished between our // last EEXIST poll and our successful mkdir may have just satisfied it. // Avoid running `work` redundantly. if (skip()) { try { fs.rmSync(lockPath, { recursive: true }); } catch { // best-effort } return undefined; } try { return await work(); } finally { // Recursive remove tolerates stray files inside the lock dir (e.g. // macOS `.DS_Store`) that would otherwise make `rmdirSync` throw // ENOTEMPTY and leak the lock until the 7-min stale timeout. try { fs.rmSync(lockPath, { recursive: true }); } catch { // Already gone — fine. } } } export interface PrimeResult { cached: boolean; /** True only when this call performed a forced re-download (forceRefresh). */ refreshed: boolean; filePath: string; durationMs: number; /** Resolved instance URL (already auth-resolved). */ instanceUrl: string; /** * Non-`__` type count from the just-downloaded schema. Present only when this * call performed a download (`cached: false`); a cache hit or coalesced * refresh leaves it undefined to avoid re-parsing the (large) introspection * JSON just to count types. */ typeCount?: number; } /** * Thrown when a forced refresh fails to download a new schema. The previously * cached schema (if any) is left intact on disk and in memory — we only clear * the caches *after* a successful download. `staleSince` is the surviving * cache's download timestamp (ISO) so callers can render a staleness-aware * message; it is undefined when no prior cache existed (a true hard failure) — * or when a cache file is present but unparseable, since `getSchemaMetadata` * returns null for a corrupt file, which correctly degrades to a hard failure * (a corrupt cache is not usable). `instanceUrl` is always the resolved org URL * (known before the download). * * Carries a {@link RetryHint} (`retry`, default `"no"`) for the MCP error surface * (W-23148365), derived from the underlying cause via {@link classifyCause}. Note * the surviving-cache case never reaches the MCP token surface: `buildConnect` * intercepts a `SchemaRefreshError` with a defined `staleSince` (i.e. a cache * survived) and returns a soft `warnings[]` success before `runTool` can stamp a * token — so `retry` is only ever observed for the no-surviving-cache hard failure. */ export class SchemaRefreshError extends Error { staleSince?: string; instanceUrl: string; readonly retry: RetryHint; constructor( message: string, opts: { instanceUrl: string; staleSince?: string; cause?: unknown; retry?: RetryHint }, ) { super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined); this.name = "SchemaRefreshError"; this.staleSince = opts.staleSince; this.instanceUrl = opts.instanceUrl; this.retry = opts.retry ?? "no"; } } function formatAge(ms: number): string { const min = Math.floor(ms / 60_000); if (min < 1) return "just now"; if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; return `${Math.floor(hr / 24)}d ago`; } function buildStaleMessage(orgAlias: string, surviving: SchemaMetadata | null): string { if (!surviving) { return `Schema refresh for "${orgAlias}" failed and no cached schema exists yet. Resolve connectivity/auth and run \`graphiti connect ${orgAlias}\` again.`; } const age = formatAge(Date.now() - new Date(surviving.downloadedAt).getTime()); return `Schema refresh for "${orgAlias}" failed; keeping the previously cached schema (downloaded ${age}). The cached schema is still usable — try refreshing again shortly.`; } /** * Dependencies that `primeSchemaWithLock` calls. Defaulted to the real * graphiti implementations; tests pass stubs. */ export interface PrimeDeps { getOrgAuth: (orgAlias: string) => Promise; downloadSchema: (auth: OrgAuth) => Promise; } const REAL_DEPS: PrimeDeps = { getOrgAuth: realGetOrgAuth, downloadSchema: realDownloadSchema, }; /** * Lazily prime — or, with `forceRefresh`, re-download — the on-disk schema * cache for `orgAlias`. * * Lazy prime (`forceRefresh` falsy): * - If `/.graphiti/schemas/.json` already exists, returns * `{cached: true, refreshed: false}` immediately. * - Otherwise: resolves auth via `deps.getOrgAuth` (throws verbatim if the * alias is not authenticated, per FR-13.5), acquires the schema lock via * `withSchemaLock`, calls `deps.downloadSchema(auth)` (which writes the * cache atomically), releases the lock, and returns `{cached: false}`. Per * FR-13.6, partial caches never reach disk because writes are atomic. * `downloadSchema` bounds the request with a timeout and retries transient * failures (network / 5xx) once at the connection layer; deterministic * failures (4xx, GraphQL errors in a 200 body, missing `__schema`) and missing * auth surface immediately. Unlike a forced refresh, a lazy-prime failure is * re-thrown verbatim (there is no prior cache to keep, so no `SchemaRefreshError`). * * Forced refresh (`forceRefresh: true`) — W-22845606: * - Re-downloads even though the file exists, then clears all three caches * coherently: the on-disk introspection JSON (overwritten atomically), this * process's in-memory parsed-schema cache, and the ObjectInfo cache (memory * + disk). Clearing happens only AFTER a successful download, so a failed * refresh leaves every cache intact. * - Coalesces concurrent refreshes (CLI + MCP) into a single introspection via * a request-time mtime snapshot: a peer that replaced the file since this * refresh was requested satisfies us, and we skip the redundant download. * - The download retries transient failures (network / 5xx) once at the * connection layer. On terminal failure throws {@link SchemaRefreshError} * carrying the surviving cache's age; the old caches are kept. * * Concurrent callers in the same or different processes serialize on the lock * dir; only one performs introspection. */ export async function primeSchemaWithLock( orgAlias: string, deps: PrimeDeps = REAL_DEPS, opts: { forceRefresh?: boolean } = {}, ): Promise { // We must call `deps.getOrgAuth` first because it is the only injected // source of the org's `instanceUrl`, which the cache key is derived from. // In tests this returns a stub; in production it is memoized inside // `auth.ts`'s in-process cache, so the org is resolved via `@salesforce/core` // at most once per org alias per process. const auth = await deps.getOrgAuth(orgAlias); const instanceUrl = normalizeInstanceUrl(auth.instanceUrl); const cacheKey = schemaCacheKeyForInstanceUrl(instanceUrl); const filePath = path.join(schemaDir(), `${cacheKey}.json`); const forceRefresh = !!opts.forceRefresh; // Snapshot the cached file's mtime before we (might) overwrite it. // `atomicWriteJson` installs the new JSON via rename, so a successful // download bumps mtime forward. The refresh skip-predicate compares // against this snapshot (mtime-to-mtime, immune to wall-clock skew) so a // peer that replaced the file since we were asked coalesces us out. // Best-effort: on filesystems with coarse mtime resolution two refreshes in // the same tick may each download (redundant, never incorrect — strict `>` // can only cause an extra download, never an erroneous skip of a real refresh). const mtimeAtRequest = fs.existsSync(filePath) ? fs.statSync(filePath).mtimeMs : -1; if (fs.existsSync(filePath) && !forceRefresh) { return { cached: true, refreshed: false, filePath, durationMs: 0, instanceUrl }; } const start = Date.now(); let primed = false; let typeCount: number | undefined; await withSchemaLock( filePath, async () => { let meta: SchemaMetadata; try { // `downloadSchema` bounds the request with a timeout and retries // transient (network/5xx) failures once with backoff at the // connection layer (jsforce); deterministic failures throw straight // through. meta = await deps.downloadSchema(auth); } catch (cause) { // A 401/403 from the introspection POST is an AUTH failure, not a // schema/cache problem (W-23335328): surface it as AuthError so the MCP // boundary emits `Auth:` (re-authenticate) and the agent re-auths instead // of futilely re-priming. Applies to BOTH lazy prime and forced refresh, // and — per the W-23148365 N3 note — is keyed off the cause SHAPE // (isAuthError), never off retry-token absence or cache survival. On a // forced refresh this INTENTIONALLY bypasses the SchemaRefreshError // soft-surviving-cache path below: a cached schema is worthless once the // session is dead (every subsequent live query 401s too), so a hard Auth // error — not a silent soft-success warning — is the correct signal, and // it is what makes the 401/403 actually route to `Auth:` rather than a // non-erroring `refreshed:false` return. AuthError carries no retry token // (Auth is uniformly non-retryable), preserving the existing contract. if (isAuthError(cause)) { const msg = cause instanceof Error ? cause.message : String(cause); throw new AuthError( `Schema priming failed for "${orgAlias}" — the org session is expired or unauthorized. Re-authenticate with \`sf org login web --alias ${orgAlias}\`.\n${msg}`, { cause }, ); } // Lazy prime (no existing cache): no stale cache to keep, so surface // the underlying failure (FR-13.5/13.6). Wrap untyped causes (e.g. a // raw @salesforce/core/jsforce network error from connection.request) // in SchemaError so the MCP boundary classifies priming failures as // `Schema:`; a cause that is already SchemaError passes through. The // retry hint is derived from the cause (W-23148365): a 5xx/network // failure that outlived the connection layer's one retry → backoff; // a 4xx / unrecognized failure → no (permanent). if (!forceRefresh) { if (cause instanceof SchemaError) throw cause; const msg = cause instanceof Error ? cause.message : String(cause); throw new SchemaError(`Schema priming failed for "${orgAlias}": ${msg}`, { cause, retry: classifyCause(cause), }); } // Forced refresh: atomic writes mean the old JSON is still on // disk, so surface a staleness-aware error and leave every cache // untouched. const surviving = getSchemaMetadata(instanceUrl); // The `retry` disposition is the raw cause disposition. When a cache // survives, `buildConnect` intercepts this error (it keys on a defined // `staleSince`) and returns a soft `warnings[]` success *before* the MCP // adapter stamps a token — so `retry` is only ever surfaced on the // no-surviving-cache hard failure, where the cause disposition is exactly // what the host should act on. (Do not reintroduce a `surviving`-floored // "now": it would be dead code on the MCP surface and no consumer reads it.) throw new SchemaRefreshError(buildStaleMessage(orgAlias, surviving), { staleSince: surviving?.downloadedAt, instanceUrl, cause, retry: classifyCause(cause), }); } typeCount = meta.typeCount; // On a forced refresh, drop this process's in-memory copies so the // next read rebuilds from the freshly-downloaded JSON. A lazy prime // has nothing stale to clear (this org wasn't cached before). // // `clearObjectInfoCache` takes the raw alias. Its on-disk path guard // (object-info.ts ORG_ALIAS_PATH_RE) is intentionally stricter than the // `org` charset `connect` accepts — it gates a filesystem path, so it // must reject `.`/`@`/`+` to stay traversal-safe. For an email-shaped // alias the disk clear no-ops, but that is safe: the ObjectInfo disk // *write* uses the same strict guard, so no on-disk entry can exist for // such an alias; the in-memory clear (no guard) always runs. Do NOT // loosen the path guard to "align" the two regexes. if (forceRefresh) { clearSchemaCacheByUrl(instanceUrl); clearObjectInfoCache(orgAlias); } primed = true; }, forceRefresh ? { skipIf: () => fs.existsSync(filePath) && fs.statSync(filePath).mtimeMs > mtimeAtRequest } : undefined, ); if (!primed) { // Either a lazy prime found the file already present, or a concurrent // refresh produced the new schema while we waited (coalesced). In the // coalesced-refresh case a long-lived process (the MCP server) may still // hold stale in-memory copies of the OLD schema, so drop them now that // disk is fresh — the next read rebuilds from the peer's download. if (forceRefresh && fs.existsSync(filePath) && fs.statSync(filePath).mtimeMs > mtimeAtRequest) { clearSchemaCacheByUrl(instanceUrl); clearObjectInfoCache(orgAlias); } return { cached: true, refreshed: false, filePath, durationMs: Date.now() - start, instanceUrl, }; } return { cached: false, refreshed: forceRefresh, filePath, durationMs: Date.now() - start, instanceUrl, typeCount, }; }