/** * Per-process schema cache for the graph-mcp proxy. * * Caches { labels, relationshipTypes } from the connected Neo4j instance so * the cypher validator can look up tokens without a round-trip per call. * * Refresh triggers: * - boot — async refresh kicked off by start(); validator fails * OPEN until the first refresh resolves. * - interval — every 60s (configurable) while the process is alive. * - stale-miss — an unknown token with a near match (edit distance ≤ 2) * triggers a debounced refresh, catching fresh schema * additions without requiring operators to wait out the * interval. * * Failure posture: * - A refresh that throws preserves the last good snapshot and leaves * ready() as it was. `[schema-cache] refresh failure ...` is logged * loudly so operators see persistent Neo4j unreachability. * - A boot refresh that fails leaves ready()=false and the snapshot empty. * The validator's own fail-open branch handles this (empty snapshot → * ok=true, no rejections), so the graph-mcp proxy keeps working against * a still-reachable Neo4j instance that's just slow to respond to the * initial `db.labels()` call. */ import type { SchemaSnapshot, UnknownToken } from "./cypher-validate.js"; export interface SchemaFetcher { labels(): Promise; relationshipTypes(): Promise; } export interface SchemaCacheOptions { /** Interval between automatic refreshes. 0 disables the timer (tests). */ refreshIntervalMs?: number; /** Minimum ms between stale-miss-triggered refreshes. Default 5000. */ staleMissDebounceMs?: number; /** Emit a log line. Default: stderr. Tests inject to capture. */ emit?: (line: string) => void; } const DEFAULT_REFRESH_INTERVAL_MS = 60_000; const DEFAULT_STALE_MISS_DEBOUNCE_MS = 5_000; const STALE_MISS_DISTANCE_CEILING = 2; export class SchemaCache { private _labels = new Set(); private _rels = new Set(); private _ready = false; private _timer: ReturnType | null = null; private _refreshInFlight: Promise | null = null; private _lastStaleMissAt = 0; constructor( private readonly fetcher: SchemaFetcher, private readonly options: SchemaCacheOptions = {}, ) {} snapshot(): SchemaSnapshot { return { labels: this._labels, relationshipTypes: this._rels }; } ready(): boolean { return this._ready; } async start(): Promise { await this.refresh("boot"); const interval = this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS; if (interval > 0) { this._timer = setInterval(() => { void this.refresh("interval"); }, interval); // Allow the process to exit without waiting for the timer. if (typeof this._timer === "object" && "unref" in this._timer) { (this._timer as { unref?: () => void }).unref?.(); } } } stop(): void { if (this._timer) { clearInterval(this._timer); this._timer = null; } } /** * Refresh the cache. Single-flight: concurrent calls await the same in-flight * promise rather than triggering overlapping Neo4j reads. Returns true on * successful update, false on failure (last good snapshot preserved). */ async refresh(reason: string): Promise { if (this._refreshInFlight) return this._refreshInFlight; const run = async (): Promise => { const started = Date.now(); try { const [labels, rels] = await Promise.all([ this.fetcher.labels(), this.fetcher.relationshipTypes(), ]); this._labels = new Set(labels); this._rels = new Set(rels); this._ready = true; this.emit( `[schema-cache] refresh reason=${reason} labels=${this._labels.size} relationshipTypes=${this._rels.size} ms=${Date.now() - started}`, ); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.emit( `[schema-cache] refresh failure reason=${reason} ms=${Date.now() - started} error="${msg.replace(/"/g, "'")}"`, ); return false; } }; this._refreshInFlight = run(); try { return await this._refreshInFlight; } finally { this._refreshInFlight = null; } } /** * If the validator returned unknown tokens whose nearest known token is * within STALE_MISS_DISTANCE_CEILING edits, trigger an out-of-band refresh * (debounced). The heuristic: a near-miss is likely a fresh schema * change (renamed / newly added label/edge), where a far-miss is almost * certainly a typo and a refresh would not change the outcome. Returns * true iff a refresh was actually run this call. */ async maybeRebuildOnStaleMiss(unknown: UnknownToken[]): Promise { if (unknown.length === 0) return false; const now = Date.now(); const debounceMs = this.options.staleMissDebounceMs ?? DEFAULT_STALE_MISS_DEBOUNCE_MS; if (now - this._lastStaleMissAt < debounceMs) return false; const anyNearMiss = unknown.some((u) => { if (u.nearest.length === 0) return false; return editDistance(u.token, u.nearest[0]) <= STALE_MISS_DISTANCE_CEILING; }); if (!anyNearMiss) return false; this._lastStaleMissAt = now; await this.refresh("stale-miss"); return true; } private emit(line: string): void { if (this.options.emit) { this.options.emit(line); } else { process.stderr.write(`${line}\n`); } } } /** * Module-scope driver singleton. Both the schema-cache and the * write-path (graph-mcp/src/index.ts `runShimWriteCypher`) borrow sessions * from the same `neo4j-driver` Driver instance — one connection pool per * process. The first call binds the URI/credentials; subsequent calls * return the same driver regardless of arguments. The driver lives for * the process lifetime; closing it is the process exit's responsibility. * * The import is deferred so test files can exercise the SchemaCache class * without pulling neo4j-driver into their module graph. */ let sharedDriverPromise: Promise | null = null; export async function getSharedDriver( uri: string, user: string, password: string, ): Promise { if (!sharedDriverPromise) { // Clear the cached promise on rejection so a transient failure (e.g. a // slow-to-import neo4j-driver during boot) doesn't wedge every subsequent // call for the process lifetime (review F4). sharedDriverPromise = (async () => { const neo4j = await import("neo4j-driver"); return neo4j.default.driver(uri, neo4j.default.auth.basic(user, password)); })().catch((err) => { sharedDriverPromise = null; throw err; }); } return sharedDriverPromise; } /** * Build a SchemaFetcher over the shared neo4j-driver Driver. Each call * opens a fresh session (cheap, sub-ms) and closes it in finally. */ export async function neo4jSchemaFetcher( uri: string, user: string, password: string, ): Promise { const driver = (await getSharedDriver(uri, user, password)) as { session: () => { run: (q: string) => Promise<{ records: Array<{ get: (k: number) => unknown }> }>; close: () => Promise }; }; const query = async (cypher: string): Promise => { const session = driver.session(); try { const result = await session.run(cypher); return result.records .map((r) => r.get(0)) .filter((v): v is string => typeof v === "string"); } finally { await session.close(); } }; return { async labels() { return query("CALL db.labels() YIELD label RETURN label"); }, async relationshipTypes() { return query( "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType", ); }, }; } function editDistance(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; let prev = new Array(b.length + 1); let curr = new Array(b.length + 1); for (let j = 0; j <= b.length; j++) prev[j] = j; for (let i = 1; i <= a.length; i++) { curr[0] = i; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[b.length]; }