From 73d8dd2d9d4ebc235d8d78959acd9ccbde2cafcc Mon Sep 17 00:00:00 2001 From: Bonobo Date: Wed, 2 Sep 2026 13:34:35 +0200 Subject: [PATCH] fix(neo4j): reconcile removed application artifacts --- src/build/neo4j/bolt.ts | 23 +++++++++++++++++++++++ src/build/neo4j/cypher.ts | 4 ++++ test/artifacts.test.ts | 9 ++++++++- test/neo4j-bolt.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/build/neo4j/bolt.ts b/src/build/neo4j/bolt.ts index 2e1246b..3429580 100644 --- a/src/build/neo4j/bolt.ts +++ b/src/build/neo4j/bolt.ts @@ -18,6 +18,7 @@ * (json) output entirely. */ +import type { ManagedTransaction } from "neo4j-driver"; import type { Logger } from "../../utils"; import type { EdgeRow, GraphRows, NodeRow, Prop } from "./rows"; import { chunk } from "./rows"; @@ -130,6 +131,28 @@ export async function boltWriter( ); } } + // Full runs reconcile the current application's artifact ownership. Artifact and ConfigKey + // ids are neutral across sibling analyzers, so remove only this app's stale ownership edges + // first and delete the shared subtree only when no Application still owns the artifact. + if (fullRun && appId !== null) { + const artifactIds = rows.nodes + .filter((n) => n.labels.includes("Artifact")) + .map((n) => n.value); + await withSession(session, async (s) => { + await s.executeWrite(async (tx: ManagedTransaction) => { + await tx.run( + `MATCH (a:Application {id: $appId})-[r:HAS_ARTIFACT]->(artifact:Artifact) ` + + `WHERE NOT artifact.id IN $artifactIds DELETE r`, + { appId, artifactIds }, + ); + await tx.run( + `MATCH (artifact:Artifact) WHERE NOT ()-[:HAS_ARTIFACT]->(artifact) ` + + `OPTIONAL MATCH (artifact)-[:DEFINES_CONFIG]->(config:ConfigKey) ` + + `DETACH DELETE config, artifact`, + ); + }); + }); + } // 3. diff content_hash. const dbHash = new Map(); diff --git a/src/build/neo4j/cypher.ts b/src/build/neo4j/cypher.ts index 986d7b0..256ba3b 100644 --- a/src/build/neo4j/cypher.ts +++ b/src/build/neo4j/cypher.ts @@ -40,6 +40,10 @@ function wipe(appId: string): string { "OPTIONAL MATCH (a)-[:TS_HAS_MODULE]->(m:TSModule)", "OPTIONAL MATCH (m)-[:TS_DECLARES|TS_HAS_METHOD|TS_HAS_FIELD|TS_HAS_BODY_NODE*1..]->(x)", "DETACH DELETE x, m, a;", + "MATCH (artifact:Artifact)", + "WHERE NOT ()-[:HAS_ARTIFACT]->(artifact)", + "OPTIONAL MATCH (artifact)-[:DEFINES_CONFIG]->(config:ConfigKey)", + "DETACH DELETE config, artifact;", ].join("\n"); } diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index b408624..ea7c4e8 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -10,7 +10,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { analyze } from "../src/core"; -import { project } from "../src/build/neo4j"; +import { project, renderCypher } from "../src/build/neo4j"; import type { AnalysisOptions } from "../src/options"; const FIXTURE = path.resolve(import.meta.dir, "fixtures/artifacts-app"); @@ -246,4 +246,11 @@ describe("Neo4j projection — neutral :Artifact/:Package (#101)", () => { rows.edges.some((e) => e.type === "TS_UNRESOLVED_IMPORT" && String(e.to.value).endsWith("/@external/left-pad")), ).toBe(true); }); + + test("snapshot cleanup removes only unowned artifact subtrees", () => { + const cypher = renderCypher(rows, root.id); + expect(cypher).toContain("WHERE NOT ()-[:HAS_ARTIFACT]->(artifact)"); + expect(cypher).toContain("OPTIONAL MATCH (artifact)-[:DEFINES_CONFIG]->(config:ConfigKey)"); + expect(cypher).toContain("DETACH DELETE config, artifact"); + }); }); diff --git a/test/neo4j-bolt.test.ts b/test/neo4j-bolt.test.ts index 9015a52..468dff9 100644 --- a/test/neo4j-bolt.test.ts +++ b/test/neo4j-bolt.test.ts @@ -163,6 +163,30 @@ containerSuite("neo4j bolt writer", () => { 120_000, ); + test( + "a full run prunes an artifact no longer owned by the application", + async () => { + const rows = project((await analyze(optsFor())).application); + const victim = rows.nodes.find((n) => n.labels.includes("Artifact"))?.value; + expect(victim).toBeDefined(); + + const reduced = { + nodes: rows.nodes.filter((n) => n.value !== victim), + edges: rows.edges.filter((e) => e.from.value !== victim && e.to.value !== victim), + }; + await boltWriter(reduced, cfg, log, true); + + expect( + await num( + "MATCH (:Application)-[:HAS_ARTIFACT]->(a:Artifact {id:$id}) RETURN count(a)", + { id: victim }, + ), + ).toBe(0); + expect(await num("MATCH (a:Artifact {id:$id}) RETURN count(a)", { id: victim })).toBe(0); + }, + 120_000, + ); + test( "migrates a 1.1.0-shaped graph to the current schema, wiping legacy residue (#46)", async () => {