import test from "node:test"; import assert from "node:assert/strict"; import { SchemaCache, type SchemaFetcher } from "../schema-cache.js"; function makeFetcher( labels: string[], rels: string[], opts: { failOnce?: boolean } = {}, ): SchemaFetcher & { calls: number } { let called = 0; let currentLabels = labels; let currentRels = rels; return { get calls() { return called; }, async labels() { called++; if (opts.failOnce && called === 1) throw new Error("transient"); return currentLabels; }, async relationshipTypes() { return currentRels; }, } as SchemaFetcher & { calls: number }; } test("start() populates the snapshot and sets ready()", async () => { const fetcher = makeFetcher(["Conversation", "Message"], ["PART_OF"]); const emitted: string[] = []; const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0, emit: (l) => emitted.push(l), }); assert.equal(cache.ready(), false); await cache.start(); assert.equal(cache.ready(), true); const snap = cache.snapshot(); assert.ok(snap.labels.has("Conversation")); assert.ok(snap.labels.has("Message")); assert.ok(snap.relationshipTypes.has("PART_OF")); assert.ok(emitted.some((l) => l.includes("[schema-cache] refresh"))); cache.stop(); }); test("start() with Neo4j unreachable leaves cache empty and not ready, but does not throw", async () => { const fetcher: SchemaFetcher = { async labels() { throw new Error("ECONNREFUSED"); }, async relationshipTypes() { throw new Error("ECONNREFUSED"); }, }; const emitted: string[] = []; const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0, emit: (l) => emitted.push(l), }); await cache.start(); assert.equal(cache.ready(), false); assert.equal(cache.snapshot().labels.size, 0); assert.ok( emitted.some((l) => l.includes("[schema-cache]") && l.includes("failure")), "expected a failure log line", ); cache.stop(); }); test("refresh() picks up a newly-added relationship type", async () => { let rels = ["PART_OF"]; const fetcher: SchemaFetcher = { async labels() { return ["Conversation"]; }, async relationshipTypes() { return rels; }, }; const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0 }); await cache.start(); assert.equal(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE"), false); rels = ["PART_OF", "MIGRATED_EDGE"]; await cache.refresh("interval"); assert.ok(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE")); cache.stop(); }); test("refresh() preserves last good cache on transient failure", async () => { let fail = false; const fetcher: SchemaFetcher = { async labels() { if (fail) throw new Error("transient"); return ["Conversation"]; }, async relationshipTypes() { if (fail) throw new Error("transient"); return ["PART_OF"]; }, }; const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0 }); await cache.start(); assert.equal(cache.ready(), true); fail = true; const ok = await cache.refresh("interval"); assert.equal(ok, false); assert.equal(cache.ready(), true, "should remain ready after a transient failure"); assert.ok(cache.snapshot().labels.has("Conversation")); cache.stop(); }); test("maybeRebuildOnStaleMiss() triggers refresh when an unknown token has a near match", async () => { let rels = ["PART_OF"]; const fetcher: SchemaFetcher = { async labels() { return ["Conversation"]; }, async relationshipTypes() { return rels; }, }; const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0, staleMissDebounceMs: 0, }); await cache.start(); rels = ["PART_OF", "PART_OFF"]; const triggered = await cache.maybeRebuildOnStaleMiss([ { token: "PART_OFF", kind: "relationship", nearest: ["PART_OF"], hint: "" }, ]); assert.equal(triggered, true); assert.ok(cache.snapshot().relationshipTypes.has("PART_OFF")); cache.stop(); }); test("maybeRebuildOnStaleMiss() skips when no near match (likely typo, not migration)", async () => { const fetcher = makeFetcher(["Conversation"], ["PART_OF"]); const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0, staleMissDebounceMs: 0, }); await cache.start(); const callsBefore = fetcher.calls; const triggered = await cache.maybeRebuildOnStaleMiss([ { token: "COMPLETELY_DIFFERENT", kind: "relationship", nearest: ["PART_OF"], hint: "" }, ]); assert.equal(triggered, false); assert.equal(fetcher.calls, callsBefore, "no extra fetches when edit distance is far"); cache.stop(); }); test("maybeRebuildOnStaleMiss() debounces repeated triggers", async () => { const fetcher = makeFetcher(["Conversation"], ["PART_OF"]); const cache = new SchemaCache(fetcher, { refreshIntervalMs: 0, staleMissDebounceMs: 5000, }); await cache.start(); const callsBefore = fetcher.calls; const unknown = [ { token: "PART_OFF", kind: "relationship" as const, nearest: ["PART_OF"], hint: "" }, ]; await cache.maybeRebuildOnStaleMiss(unknown); const afterFirst = fetcher.calls; await cache.maybeRebuildOnStaleMiss(unknown); assert.equal(fetcher.calls, afterFirst, "second call within debounce should not refetch"); assert.ok(afterFirst > callsBefore, "first call did refetch"); cache.stop(); });