"use strict";
/**
 * 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.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.SchemaCache = void 0;
exports.getSharedDriver = getSharedDriver;
exports.neo4jSchemaFetcher = neo4jSchemaFetcher;
const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
const DEFAULT_STALE_MISS_DEBOUNCE_MS = 5_000;
const STALE_MISS_DISTANCE_CEILING = 2;
class SchemaCache {
    fetcher;
    options;
    _labels = new Set();
    _rels = new Set();
    _ready = false;
    _timer = null;
    _refreshInFlight = null;
    _lastStaleMissAt = 0;
    constructor(fetcher, options = {}) {
        this.fetcher = fetcher;
        this.options = options;
    }
    snapshot() {
        return { labels: this._labels, relationshipTypes: this._rels };
    }
    ready() {
        return this._ready;
    }
    async start() {
        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.unref?.();
            }
        }
    }
    stop() {
        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) {
        if (this._refreshInFlight)
            return this._refreshInFlight;
        const run = async () => {
            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) {
        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;
    }
    emit(line) {
        if (this.options.emit) {
            this.options.emit(line);
        }
        else {
            process.stderr.write(`${line}\n`);
        }
    }
}
exports.SchemaCache = SchemaCache;
/**
 * 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 = null;
async function getSharedDriver(uri, user, password) {
    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.
 */
async function neo4jSchemaFetcher(uri, user, password) {
    const driver = (await getSharedDriver(uri, user, password));
    const query = async (cypher) => {
        const session = driver.session();
        try {
            const result = await session.run(cypher);
            return result.records
                .map((r) => r.get(0))
                .filter((v) => 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, b) {
    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];
}
//# sourceMappingURL=schema-cache.js.map