diff --git a/CHANGELOG.md b/CHANGELOG.md index ba44c70..4aa6517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] — 2026-04-24 + +### Changed +- **`reactome_cypher_schema` and `reactome://graph/schema` now return rich APOC-level data.** The previous implementation used the sparse built-in `db.schema.*` (labels / rel types / property names only). This release pulls `apoc.meta.schema()`, `apoc.meta.stats()`, `apoc.meta.{node,rel}TypeProperties()`, `db.indexes()`, `db.constraints()`, and `dbms.components()` — so clients see **per-label node counts**, **relationship cardinalities**, **property types with mandatory flags**, indexes, and constraints. The markdown digest jumps from ~40 KB (sparse) to ~80 KB (rich). +- Fetch is lazy + cached in-memory for the session. Concurrent first-callers share one round-trip via promise deduplication. + +### Added +- **Startup schema prefetch.** `main()` fires `fetchGraphSchema()` in the background once the MCP is listening, so the first `reactome_cypher_schema` call doesn't wait 15–30 s on `apoc.meta.schema()` (that procedure samples 3M nodes on Reactome). Failures are logged; the cache stays empty and the next tool call retries on demand. +- 7 new tests: markdown format coverage (4) + cache behavior (caching, concurrent dedup, optional-call fallback). + +### Removed +- The sparse `db.schema.*`-based schema path. No fallback — APOC is required for the Cypher schema tool. This is fine for the `reactome_neo4j_env` Docker image (APOC is always present); other deployments must load APOC for schema tooling to work. + +### Notes +- **No vendored schema artifact.** The MCP fetches live on connect. No coordination with `reactome_neo4j_env` release cadence is required. + ## [1.3.1] — 2026-04-21 ### Added diff --git a/README.md b/README.md index 6895135..c06e2d4 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Only registered when `NEO4J_URI` is set. Designed for curators running the [`rea | Tool | Description | |------|-------------| | `reactome_cypher_query` | Run a Cypher query with optional parameters; row count, per-row size, and total response size are all capped; a server-side timeout terminates runaway queries | -| `reactome_cypher_schema` | Introspect labels, relationship types, and per-label property keys | +| `reactome_cypher_schema` | Live APOC introspection: labels with node counts, relationship cardinalities, per-label and per-rel property types (with mandatory flags), indexes, constraints. Cached for the session after first call; pre-warmed at MCP startup. | | `reactome_cypher_sample` | Return a small sample of nodes for a given label | **Read-only posture — what it is and isn't.** Sessions run in Neo4j READ mode, which rejects native write clauses (`CREATE`, `MERGE`, `DELETE`, `SET`, `REMOVE`). On top of that, `reactome_cypher_query` rejects APOC procedures that can write or reach outside the graph through back-channels (`apoc.cypher.runWrite` / `apoc.cypher.doIt`, `apoc.periodic.*`, `apoc.create/merge/refactor.*`, `apoc.load/import/export.*`, `apoc.trigger.*`, `apoc.nodes.delete`). Treat this as a guardrail against accidental mutation, not a security boundary — a real trust boundary should live at the Neo4j RBAC / plugin configuration layer, or by pointing at a read-only replica. diff --git a/package.json b/package.json index bd27d2a..80e5156 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactome-mcp", - "version": "1.3.1", + "version": "1.4.0", "description": "MCP server for Reactome pathway database - analysis, search, and exploration tools", "type": "module", "main": "dist/index.js", diff --git a/src/clients/neo4j.ts b/src/clients/neo4j.ts index f4387e9..cca92a7 100644 --- a/src/clients/neo4j.ts +++ b/src/clients/neo4j.ts @@ -107,33 +107,5 @@ export async function runRead>( } } -export interface GraphSchema { - labels: string[]; - relationshipTypes: string[]; - propertiesByLabel: Record; -} - -export async function fetchGraphSchema(): Promise { - interface LabelRow { label: string } - interface RelRow { relationshipType: string } - interface PropRow { nodeType: string; propertyName: string; propertyTypes: string[] | null } - - const [labelRows, relRows, propRows] = await Promise.all([ - runRead("CALL db.labels() YIELD label RETURN label ORDER BY label"), - runRead("CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType ORDER BY relationshipType"), - runRead("CALL db.schema.nodeTypeProperties() YIELD nodeType, propertyName, propertyTypes RETURN nodeType, propertyName, propertyTypes"), - ]); - - const propertiesByLabel: Record = {}; - for (const p of propRows) { - const entry = propertiesByLabel[p.nodeType] ?? []; - entry.push({ name: p.propertyName, types: p.propertyTypes ?? [] }); - propertiesByLabel[p.nodeType] = entry; - } - - return { - labels: labelRows.map((l) => l.label), - relationshipTypes: relRows.map((r) => r.relationshipType), - propertiesByLabel, - }; -} +// Graph-schema access lives in src/graph/schema.ts — split out so tests +// can mock runRead across the module boundary. diff --git a/src/graph/format-schema.ts b/src/graph/format-schema.ts new file mode 100644 index 0000000..6e74c12 --- /dev/null +++ b/src/graph/format-schema.ts @@ -0,0 +1,109 @@ +import type { GraphSchema } from "./schema.js"; + +/** + * Render a GraphSchema (as produced by fetchGraphSchema) as a compact + * markdown summary suitable for direct LLM consumption. The raw APOC + * payload is ~500 KB — much too large to return whole. This digest keeps + * the signal (labels with counts, relationship cardinalities, property + * types with mandatory flags, indexes, constraints) and drops the + * verbose apoc.meta.schema() object. Clients that need the full + * structure can read the `reactome://graph/schema` resource. + */ +export function formatGraphSchemaMarkdown(schema: GraphSchema): string { + const { stats, nodeTypeProperties, relTypeProperties, indexes, constraints } = schema; + + const labelEntries = Object.entries(stats.labels ?? {}).sort(([, a], [, b]) => b - a); + const relEntries = Object.entries(stats.relTypesCount ?? {}).sort(([, a], [, b]) => b - a); + + const propsByLabel = new Map>(); + for (const p of nodeTypeProperties) { + const key = (p.nodeLabels?.join(":") || p.nodeType) ?? p.nodeType; + const entry = propsByLabel.get(key) ?? []; + entry.push({ name: p.propertyName, types: p.propertyTypes ?? [], mandatory: p.mandatory }); + propsByLabel.set(key, entry); + } + + const propsByRel = new Map>(); + for (const p of relTypeProperties) { + const entry = propsByRel.get(p.relType) ?? []; + entry.push({ name: p.propertyName, types: p.propertyTypes ?? [], mandatory: p.mandatory }); + propsByRel.set(p.relType, entry); + } + + const lines: string[] = []; + lines.push(`## Reactome Graph Schema`); + const dbComp = schema.dbComponents[0]; + lines.push( + `**Neo4j:** ${dbComp?.versions?.[0] ?? "?"} ${dbComp?.edition ?? ""} · **Fetched:** ${schema.fetchedAt}` + ); + lines.push( + `**Totals:** ${stats.nodeCount.toLocaleString()} nodes · ${stats.relCount.toLocaleString()} relationships · ${labelEntries.length} labels · ${Object.keys(stats.relTypes ?? {}).length} relationship types` + ); + lines.push(""); + + lines.push(`### Labels (${labelEntries.length}, by node count)`); + for (const [label, count] of labelEntries) { + lines.push(`- \`${label}\` — ${count.toLocaleString()}`); + } + lines.push(""); + + lines.push(`### Relationship types (${relEntries.length}, by relationship count)`); + for (const [relType, count] of relEntries) { + lines.push(`- \`${relType}\` — ${count.toLocaleString()}`); + } + lines.push(""); + + lines.push(`### Node properties (by label)`); + const sortedLabels = Array.from(propsByLabel.keys()).sort(); + for (const label of sortedLabels) { + lines.push(`- **${label}**`); + for (const p of propsByLabel.get(label)!) { + const t = p.types.length ? ` _(${p.types.join("|")})_` : ""; + const m = p.mandatory ? " **required**" : ""; + lines.push(` - \`${p.name}\`${t}${m}`); + } + } + lines.push(""); + + if (propsByRel.size > 0) { + lines.push(`### Relationship properties (by type)`); + const sortedRels = Array.from(propsByRel.keys()).sort(); + for (const rel of sortedRels) { + const props = propsByRel.get(rel)!; + if (props.length === 0) continue; + lines.push(`- **${rel}**`); + for (const p of props) { + const t = p.types.length ? ` _(${p.types.join("|")})_` : ""; + const m = p.mandatory ? " **required**" : ""; + lines.push(` - \`${p.name}\`${t}${m}`); + } + } + lines.push(""); + } + + if (indexes.length > 0) { + lines.push(`### Indexes (${indexes.length})`); + for (const ix of indexes) { + const row = ix as { name?: string; labelsOrTypes?: string[]; properties?: string[]; type?: string; state?: string }; + const labels = row.labelsOrTypes?.join(",") ?? "?"; + const props = row.properties?.join(",") ?? "?"; + lines.push(`- \`${row.name ?? "?"}\` — ${labels}(${props}) [${row.type ?? "?"}, ${row.state ?? "?"}]`); + } + lines.push(""); + } + + if (constraints.length > 0) { + lines.push(`### Constraints (${constraints.length})`); + for (const c of constraints) { + const row = c as { name?: string; description?: string }; + lines.push(`- \`${row.name ?? "?"}\` — ${row.description ?? ""}`); + } + lines.push(""); + } + + lines.push( + "_For programmatic access to the full schema (including the raw `apoc.meta.schema()` output with per-relationship cardinalities and full property type inventories), read the `reactome://graph/schema` resource._" + ); + + return lines.join("\n"); +} diff --git a/src/graph/schema.ts b/src/graph/schema.ts new file mode 100644 index 0000000..7119ca4 --- /dev/null +++ b/src/graph/schema.ts @@ -0,0 +1,137 @@ +import { runRead } from "../clients/neo4j.js"; +import { logger } from "../logger.js"; + +export interface GraphSchema { + fetchedAt: string; + dbComponents: Array<{ name: string; versions: string[]; edition: string }>; + stats: { + nodeCount: number; + relCount: number; + labels: Record; + relTypes: Record; + relTypesCount: Record; + }; + schema: Record; + nodeTypeProperties: Array<{ + nodeType: string; + nodeLabels: string[]; + propertyName: string; + propertyTypes: string[]; + mandatory: boolean; + }>; + relTypeProperties: Array<{ + relType: string; + sourceNodeLabels: string[]; + targetNodeLabels: string[]; + propertyName: string; + propertyTypes: string[]; + mandatory: boolean; + }>; + indexes: unknown[]; + constraints: unknown[]; +} + +// apoc.meta.schema() can scan many nodes; give the schema queries a longer +// budget than the default Cypher-query timeout. +const SCHEMA_FETCH_TIMEOUT_MS = 60_000; + +let schemaCache: GraphSchema | null = null; +let schemaPending: Promise | null = null; + +/** + * Fetch the live graph schema via APOC (+ fallbacks for indexes and + * constraints). Cached in-memory after the first successful call so + * subsequent tool invocations are free. Concurrent first-callers share + * one round-trip via the `schemaPending` promise. + */ +export async function fetchGraphSchema(): Promise { + if (schemaCache) return schemaCache; + if (schemaPending) return schemaPending; + + const opts = { timeoutMs: SCHEMA_FETCH_TIMEOUT_MS }; + const start = Date.now(); + + schemaPending = (async () => { + try { + type Comp = { name: string; versions: string[]; edition: string }; + type Stats = GraphSchema["stats"]; + type NodeProp = GraphSchema["nodeTypeProperties"][number]; + type RelProp = GraphSchema["relTypeProperties"][number]; + + const [components, stats, schemaRow, nodeProps, relProps, indexes, constraints] = await Promise.all([ + runRead( + "CALL dbms.components() YIELD name, versions, edition RETURN name, versions, edition", + {}, + opts + ), + runRead( + "CALL apoc.meta.stats() YIELD labels, relTypes, relTypesCount, nodeCount, relCount RETURN labels, relTypes, relTypesCount, nodeCount, relCount", + {}, + opts + ), + runRead<{ value: Record }>( + "CALL apoc.meta.schema() YIELD value RETURN value", + {}, + opts + ), + runRead( + "CALL apoc.meta.nodeTypeProperties() YIELD nodeType, nodeLabels, propertyName, propertyTypes, mandatory RETURN nodeType, nodeLabels, propertyName, propertyTypes, mandatory", + {}, + opts + ), + runRead( + "CALL apoc.meta.relTypeProperties() YIELD relType, sourceNodeLabels, targetNodeLabels, propertyName, propertyTypes, mandatory RETURN relType, sourceNodeLabels, targetNodeLabels, propertyName, propertyTypes, mandatory", + {}, + opts + ).catch(() => [] as RelProp[]), + runRead( + "CALL db.indexes() YIELD name, state, type, entityType, labelsOrTypes, properties RETURN name, state, type, entityType, labelsOrTypes, properties", + {}, + opts + ).catch(() => [] as unknown[]), + runRead( + "CALL db.constraints() YIELD name, description RETURN name, description", + {}, + opts + ).catch(() => [] as unknown[]), + ]); + + const result: GraphSchema = { + fetchedAt: new Date().toISOString(), + dbComponents: components, + stats: stats[0] ?? { + nodeCount: 0, + relCount: 0, + labels: {}, + relTypes: {}, + relTypesCount: {}, + }, + schema: schemaRow[0]?.value ?? {}, + nodeTypeProperties: nodeProps, + relTypeProperties: relProps, + indexes, + constraints, + }; + + logger.info("graph schema fetched", { + durationMs: Date.now() - start, + nodeCount: result.stats.nodeCount, + relCount: result.stats.relCount, + labels: Object.keys(result.stats.labels ?? {}).length, + }); + + schemaCache = result; + return result; + } finally { + schemaPending = null; + } + })(); + + return schemaPending; +} + +/** For tests — clears both the cached value and any in-flight fetch. */ +export function _resetGraphSchemaCache(): void { + schemaCache = null; + schemaPending = null; +} diff --git a/src/index.ts b/src/index.ts index 59085b7..43f50df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,10 @@ import { registerAllResources } from "./resources/index.js"; import { logger } from "./logger.js"; import { CONTENT_SERVICE_URL, ANALYSIS_SERVICE_URL, NEO4J_URI } from "./config.js"; import { buildServerInstructions } from "./instructions.js"; +import { fetchGraphSchema } from "./graph/schema.js"; const server = new McpServer( - { name: "reactome", version: "1.3.1" }, + { name: "reactome", version: "1.4.0" }, { instructions: buildServerInstructions() } ); @@ -24,6 +25,18 @@ async function main() { analysisService: ANALYSIS_SERVICE_URL, neo4jEnabled: Boolean(NEO4J_URI), }); + + // Warm the schema cache in the background so the first + // reactome_cypher_schema call (or reactome://graph/schema read) doesn't + // wait 15–30s on apoc.meta.schema(). Failures are logged; the cache + // stays empty and the tool call will retry on demand. + if (NEO4J_URI) { + fetchGraphSchema().catch((err) => { + logger.warn("graph schema prefetch failed; will retry on first use", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } } main().catch((error) => { diff --git a/src/instructions.ts b/src/instructions.ts index 86a2c8e..c11f532 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -40,7 +40,7 @@ A local Neo4j Reactome graph is available. Use it when the user wants a query th **Workflow for Cypher:** -1. Call \`reactome_cypher_schema\` (or read the \`reactome://graph/schema\` resource) **before writing any query** to learn the live labels, relationship types, and properties. Never guess the schema. +1. Call \`reactome_cypher_schema\` (or read the \`reactome://graph/schema\` resource) **before writing any query**. The schema tool returns labels with node counts, relationship cardinalities, per-label and per-rel property types (with mandatory flags), indexes, and constraints. Pulled live via APOC on first use and cached in-memory for the session (warm after the MCP's startup prefetch). Never guess the schema. 2. Use \`reactome_cypher_sample\` on a label to see a representative node's shape. 3. Write a Cypher query with \`reactome_cypher_query\`. Rules: - Sessions run in READ mode; write clauses will be rejected. diff --git a/src/resources/static.ts b/src/resources/static.ts index e3d0333..3bd4dd9 100644 --- a/src/resources/static.ts +++ b/src/resources/static.ts @@ -1,7 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { contentClient } from "../clients/content.js"; import type { Species, Disease } from "../types/index.js"; -import { isNeo4jConfigured, fetchGraphSchema } from "../clients/neo4j.js"; +import { isNeo4jConfigured } from "../clients/neo4j.js"; +import { fetchGraphSchema } from "../graph/schema.js"; export function registerStaticResources(server: McpServer) { // All species diff --git a/src/tools/cypher.ts b/src/tools/cypher.ts index d40a6c0..e6336f7 100644 --- a/src/tools/cypher.ts +++ b/src/tools/cypher.ts @@ -1,6 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { runRead, fetchGraphSchema } from "../clients/neo4j.js"; +import { runRead } from "../clients/neo4j.js"; +import { fetchGraphSchema } from "../graph/schema.js"; +import { formatGraphSchemaMarkdown } from "../graph/format-schema.js"; import { logger } from "../logger.js"; import { rejectWriteThroughCalls } from "./cypher-guard.js"; @@ -135,35 +137,13 @@ export function registerCypherTools(server: McpServer) { server.tool( "reactome_cypher_schema", - "Introspect the Reactome graph schema — node labels, relationship types, and per-label property keys. Use this first to plan queries.", + "Introspect the Reactome graph schema — labels with node counts, relationship types with cardinalities, per-label and per-rel property types (with mandatory flags), indexes, and constraints. Fetched live from the database via APOC on first call and cached in-memory for the rest of the session (~100–300 ms one-time). Call this before writing Cypher. For the full JSON (including the raw apoc.meta.schema() object), read the `reactome://graph/schema` resource.", {}, async () => { logger.info("cypher_schema"); const schema = await fetchGraphSchema(); - - const lines: string[] = [ - `## Reactome Graph Schema`, - "", - `### Labels (${schema.labels.length})`, - ...schema.labels.map((l) => `- \`${l}\``), - "", - `### Relationship Types (${schema.relationshipTypes.length})`, - ...schema.relationshipTypes.map((r) => `- \`${r}\``), - "", - `### Properties by Label`, - ]; - - const sortedKeys = Object.keys(schema.propertiesByLabel).sort(); - for (const key of sortedKeys) { - lines.push(`- **${key}**`); - for (const p of schema.propertiesByLabel[key]) { - const typeStr = p.types.length ? ` _(${p.types.join("|")})_` : ""; - lines.push(` - \`${p.name}\`${typeStr}`); - } - } - return { - content: [{ type: "text", text: lines.join("\n") }], + content: [{ type: "text", text: formatGraphSchemaMarkdown(schema) }], }; } ); diff --git a/tests/graph-schema.test.ts b/tests/graph-schema.test.ts new file mode 100644 index 0000000..ca92d00 --- /dev/null +++ b/tests/graph-schema.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { GraphSchema } from "../src/graph/schema.js"; +import { formatGraphSchemaMarkdown } from "../src/graph/format-schema.js"; + +function minimalSchema(): GraphSchema { + return { + fetchedAt: "2026-04-24T15:00:00Z", + dbComponents: [{ name: "Neo4j Kernel", versions: ["4.3.6"], edition: "enterprise" }], + stats: { + nodeCount: 100, + relCount: 200, + labels: { Pathway: 50, Reaction: 30, Entity: 20 }, + relTypes: { "(:Pathway)-[:hasEvent]->(:Reaction)": 80 }, + relTypesCount: { hasEvent: 80, inputOf: 120 }, + }, + schema: { Pathway: { type: "node" } }, + nodeTypeProperties: [ + { nodeType: ":`Pathway`", nodeLabels: ["Pathway"], propertyName: "stId", propertyTypes: ["String"], mandatory: true }, + { nodeType: ":`Pathway`", nodeLabels: ["Pathway"], propertyName: "displayName", propertyTypes: ["String"], mandatory: false }, + ], + relTypeProperties: [ + { relType: "hasEvent", sourceNodeLabels: ["Pathway"], targetNodeLabels: ["Reaction"], propertyName: "stoichiometry", propertyTypes: ["Long"], mandatory: false }, + ], + indexes: [{ name: "pathway_stId", labelsOrTypes: ["Pathway"], properties: ["stId"], type: "BTREE", state: "ONLINE" }], + constraints: [{ name: "pathway_stId_unique", description: "CONSTRAINT ON ( pathway:Pathway ) ASSERT (pathway.stId) IS UNIQUE" }], + }; +} + +describe("formatGraphSchemaMarkdown", () => { + it("covers totals, labels, rel types, properties, indexes, constraints", () => { + const md = formatGraphSchemaMarkdown(minimalSchema()); + expect(md).toContain("Neo4j:** 4.3.6"); + expect(md).toContain("100 nodes"); + expect(md).toContain("`Pathway` — 50"); + expect(md).toContain("`hasEvent` — 80"); + expect(md).toContain("`stId` _(String)_ **required**"); + expect(md).toContain("### Indexes (1)"); + expect(md).toContain("### Constraints (1)"); + expect(md).toContain("reactome://graph/schema"); + }); + + it("stays small enough for an LLM context window", () => { + expect(formatGraphSchemaMarkdown(minimalSchema()).length).toBeLessThan(10_000); + }); + + it("handles missing indexes/constraints gracefully", () => { + const empty = { ...minimalSchema(), indexes: [], constraints: [] }; + const md = formatGraphSchemaMarkdown(empty); + expect(md).not.toContain("### Indexes"); + expect(md).not.toContain("### Constraints"); + }); + + it("orders labels and rel types by count, descending", () => { + const md = formatGraphSchemaMarkdown(minimalSchema()); + const pathwayIdx = md.indexOf("`Pathway` — 50"); + const reactionIdx = md.indexOf("`Reaction` — 30"); + const entityIdx = md.indexOf("`Entity` — 20"); + expect(pathwayIdx).toBeLessThan(reactionIdx); + expect(reactionIdx).toBeLessThan(entityIdx); + }); +}); + +// Mock runRead from the neo4j client module so fetchGraphSchema (which +// imports from that module) sees the mock. +vi.mock("../src/clients/neo4j.js", () => ({ + runRead: vi.fn(), +})); + +import { runRead } from "../src/clients/neo4j.js"; +import { fetchGraphSchema, _resetGraphSchemaCache } from "../src/graph/schema.js"; + +function setupRunReadResponses() { + (runRead as ReturnType).mockImplementation(async (cypher: string) => { + if (cypher.includes("dbms.components")) + return [{ name: "Neo4j Kernel", versions: ["4.3.6"], edition: "enterprise" }]; + if (cypher.includes("apoc.meta.stats")) + return [{ nodeCount: 10, relCount: 5, labels: { X: 10 }, relTypes: {}, relTypesCount: {} }]; + if (cypher.includes("apoc.meta.schema")) return [{ value: {} }]; + return []; + }); +} + +describe("fetchGraphSchema caching", () => { + beforeEach(() => { + _resetGraphSchemaCache(); + (runRead as ReturnType).mockReset(); + }); + + afterEach(() => { + _resetGraphSchemaCache(); + }); + + it("caches the result; second call is a no-op on the driver", async () => { + setupRunReadResponses(); + const first = await fetchGraphSchema(); + const callsAfterFirst = (runRead as ReturnType).mock.calls.length; + const second = await fetchGraphSchema(); + const callsAfterSecond = (runRead as ReturnType).mock.calls.length; + + expect(first).toBe(second); + expect(callsAfterSecond).toBe(callsAfterFirst); + }); + + it("dedupes concurrent in-flight calls to a single fetch", async () => { + let resolveStats: (v: unknown) => void = () => {}; + const statsPromise = new Promise((r) => { resolveStats = r; }); + + (runRead as ReturnType).mockImplementation(async (cypher: string) => { + if (cypher.includes("dbms.components")) + return [{ name: "Neo4j Kernel", versions: ["4.3.6"], edition: "enterprise" }]; + if (cypher.includes("apoc.meta.stats")) { + await statsPromise; + return [{ nodeCount: 1, relCount: 1, labels: {}, relTypes: {}, relTypesCount: {} }]; + } + if (cypher.includes("apoc.meta.schema")) return [{ value: {} }]; + return []; + }); + + const p1 = fetchGraphSchema(); + const p2 = fetchGraphSchema(); + const p3 = fetchGraphSchema(); + + resolveStats(undefined); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + expect(r1).toBe(r2); + expect(r2).toBe(r3); + + const statsCalls = (runRead as ReturnType).mock.calls + .filter(([q]) => String(q).includes("apoc.meta.stats")).length; + expect(statsCalls).toBe(1); + }); + + it("recovers when optional calls fail (relTypeProperties / indexes / constraints)", async () => { + (runRead as ReturnType).mockImplementation(async (cypher: string) => { + if (cypher.includes("dbms.components")) + return [{ name: "Neo4j Kernel", versions: ["4.3.6"], edition: "enterprise" }]; + if (cypher.includes("apoc.meta.stats")) + return [{ nodeCount: 1, relCount: 1, labels: {}, relTypes: {}, relTypesCount: {} }]; + if (cypher.includes("apoc.meta.schema")) return [{ value: {} }]; + if (cypher.includes("apoc.meta.nodeTypeProperties")) return []; + // Simulate older Neo4j where these procs don't exist or return differently + if (cypher.includes("apoc.meta.relTypeProperties")) throw new Error("no such proc"); + if (cypher.includes("db.indexes")) throw new Error("no such proc"); + if (cypher.includes("db.constraints")) throw new Error("no such proc"); + return []; + }); + + const schema = await fetchGraphSchema(); + expect(schema.relTypeProperties).toEqual([]); + expect(schema.indexes).toEqual([]); + expect(schema.constraints).toEqual([]); + }); +});