From 750ef2d4b71c1545daf696db9033d2636d254d05 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:15:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20implement=20query=20methods=20?= =?UTF-8?q?=E2=80=94=20topologicalOrder,=20hasCycles,=20findCycles,=20ance?= =?UTF-8?q?stors,=20descendants,=20reachableFrom,=20and=20call=20graph=20q?= =?UTF-8?q?ueries=20(filterByStatus,=20getRoots,=20children,=20duration,?= =?UTF-8?q?=20lineage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/construction.ts | 135 ++++++++++- src/graph/index.ts | 10 +- src/graph/queries.ts | 115 +++++++++- test/graph/queries.test.ts | 442 ++++++++++++++++++++++++++++++++++++- 4 files changed, 695 insertions(+), 7 deletions(-) diff --git a/src/graph/construction.ts b/src/graph/construction.ts index c00cbad..5e4a27e 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1,12 +1,17 @@ import { DirectedGraph } from "graphology"; import type { TSchema, Static } from "@alkdev/typebox"; -import { willCreateCycle } from "graphology-dag"; +import { willCreateCycle, topologicalSort, hasCycle } from "graphology-dag"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, } from "../error/index.js"; +import type { CallStatus } from "../error/index.js"; +import { + findCycles, + reachableFrom as reachableFromFn, +} from "./queries.js"; export interface FlowGraphOptions { type?: "directed"; @@ -154,6 +159,134 @@ export class FlowGraph< return this._graph.outNeighbors(nodeId) ?? []; } + topologicalOrder(): string[] { + if (hasCycle(this._graph)) { + const cycles = findCycles(this._graph); + throw new CycleError(cycles); + } + return topologicalSort(this._graph); + } + + hasCycles(): boolean { + return hasCycle(this._graph); + } + + findCycles(): string[][] { + return findCycles(this._graph); + } + + ancestors(nodeId: string): string[] { + if (!this._graph.hasNode(nodeId)) { + throw new NodeNotFoundError(nodeId); + } + const visited = new Set(); + const queue: string[] = [nodeId]; + visited.add(nodeId); + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = this._graph.inNeighbors(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + visited.delete(nodeId); + return Array.from(visited); + } + + descendants(nodeId: string): string[] { + if (!this._graph.hasNode(nodeId)) { + throw new NodeNotFoundError(nodeId); + } + const visited = new Set(); + const queue: string[] = [nodeId]; + visited.add(nodeId); + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = this._graph.outNeighbors(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + visited.delete(nodeId); + return Array.from(visited); + } + + reachableFrom(nodeIds: string[]): Set { + return reachableFromFn(this._graph, nodeIds); + } + + filterByStatus(status: CallStatus): string[] { + const result: string[] = []; + this._graph.forEachNode((key, attrs) => { + if ((attrs as Record).status === status) { + result.push(key); + } + }); + return result; + } + + getRoots(): string[] { + const result: string[] = []; + this._graph.forEachNode((key, attrs) => { + if ((attrs as Record).parentRequestId === undefined) { + result.push(key); + } + }); + return result; + } + + children(requestId: string): string[] { + if (!this._graph.hasNode(requestId)) { + throw new NodeNotFoundError(requestId); + } + const result: string[] = []; + const outEdges = this._graph.outEdges(requestId) ?? []; + for (const edge of outEdges) { + const target = this._graph.target(edge); + const edgeAttrs = this._graph.getEdgeAttributes(edge) as Record; + if (edgeAttrs.edgeType === "triggered") { + result.push(target); + } + } + return result; + } + + duration(requestId: string): number { + if (!this._graph.hasNode(requestId)) { + throw new NodeNotFoundError(requestId); + } + const attrs = this._graph.getNodeAttributes(requestId) as Record; + const startedAt = attrs.startedAt as string | undefined; + const completedAt = attrs.completedAt as string | undefined; + if (!startedAt || !completedAt) { + throw new Error(`Call "${requestId}" does not have both startedAt and completedAt`); + } + return new Date(completedAt).getTime() - new Date(startedAt).getTime(); + } + + lineage(requestId: string): string[] { + if (!this._graph.hasNode(requestId)) { + throw new NodeNotFoundError(requestId); + } + const chain: string[] = [requestId]; + let current = requestId; + while (true) { + const attrs = this._graph.getNodeAttributes(current) as Record; + const parentRequestId = attrs.parentRequestId as string | undefined; + if (!parentRequestId) break; + if (!this._graph.hasNode(parentRequestId)) break; + chain.unshift(parentRequestId); + current = parentRequestId; + } + return chain; + } + static fromSpecs( _specs: unknown[], ): FlowGraph { diff --git a/src/graph/index.ts b/src/graph/index.ts index 86cd5b9..c622228 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1 +1,9 @@ -export { FlowGraph, type FlowGraphOptions } from "./construction.js"; \ No newline at end of file +export { FlowGraph, type FlowGraphOptions } from "./construction.js"; +export { + topologicalOrder, + hasCycles, + findCycles, + ancestors, + descendants, + reachableFrom, +} from "./queries.js"; \ No newline at end of file diff --git a/src/graph/queries.ts b/src/graph/queries.ts index 8cec2e9..af92b88 100644 --- a/src/graph/queries.ts +++ b/src/graph/queries.ts @@ -1 +1,114 @@ -export {}; \ No newline at end of file +import type { DirectedGraph } from "graphology"; +import { topologicalSort, hasCycle } from "graphology-dag"; + +export function topologicalOrder(graph: DirectedGraph): string[] { + return topologicalSort(graph); +} + +export function hasCycles(graph: DirectedGraph): boolean { + return hasCycle(graph); +} + +export function findCycles(graph: DirectedGraph): string[][] { + const cycles: string[][] = []; + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + const path: string[] = []; + + graph.forEachNode((node) => { + color.set(node, WHITE); + }); + + function dfs(node: string): void { + color.set(node, GRAY); + path.push(node); + + const neighbors = graph.outNeighbors(node) ?? []; + for (const neighbor of neighbors) { + const neighborColor = color.get(neighbor)!; + if (neighborColor === GRAY) { + const cycleStart = path.indexOf(neighbor); + if (cycleStart !== -1) { + const cycle = path.slice(cycleStart); + cycles.push([...cycle, neighbor]); + } + } else if (neighborColor === WHITE) { + dfs(neighbor); + } + } + + path.pop(); + color.set(node, BLACK); + } + + graph.forEachNode((node) => { + if (color.get(node) === WHITE) { + dfs(node); + } + }); + + return cycles; +} + +export function ancestors(graph: DirectedGraph, nodeId: string): string[] { + const visited = new Set(); + const queue: string[] = [nodeId]; + visited.add(nodeId); + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = graph.inNeighbors(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + visited.delete(nodeId); + return Array.from(visited); +} + +export function descendants(graph: DirectedGraph, nodeId: string): string[] { + const visited = new Set(); + const queue: string[] = [nodeId]; + visited.add(nodeId); + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = graph.outNeighbors(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + visited.delete(nodeId); + return Array.from(visited); +} + +export function reachableFrom( + graph: DirectedGraph, + nodeIds: string[], +): Set { + const visited = new Set(); + const queue: string[] = []; + for (const id of nodeIds) { + if (graph.hasNode(id) && !visited.has(id)) { + visited.add(id); + queue.push(id); + } + } + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = graph.outNeighbors(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + return visited; +} \ No newline at end of file diff --git a/test/graph/queries.test.ts b/test/graph/queries.test.ts index a1e918d..050db01 100644 --- a/test/graph/queries.test.ts +++ b/test/graph/queries.test.ts @@ -1,7 +1,441 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { FlowGraph } from "../../src/graph/construction.js"; +import { + topologicalOrder, + hasCycles, + findCycles, + ancestors, + descendants, + reachableFrom, +} from "../../src/graph/queries.js"; +import { CycleError, NodeNotFoundError } from "../../src/error/index.js"; +import type { DirectedGraph } from "graphology"; -describe('graph queries', () => { - it('placeholder', () => { - expect(true).toBe(true); +function buildDiamondDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("top", { name: "top" }); + fg.addNode("left", { name: "left" }); + fg.addNode("right", { name: "right" }); + fg.addNode("bottom", { name: "bottom" }); + fg.addEdge("top", "left"); + fg.addEdge("top", "right"); + fg.addEdge("left", "bottom"); + fg.addEdge("right", "bottom"); + return fg; +} + +function buildChainDag(): FlowGraph { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addNode("d", { name: "d" }); + fg.addEdge("a", "b"); + fg.addEdge("b", "c"); + fg.addEdge("c", "d"); + return fg; +} + +describe("FlowGraph topologicalOrder", () => { + it("returns topological order for a chain", () => { + const fg = buildChainDag(); + const order = fg.topologicalOrder(); + expect(order).toEqual(["a", "b", "c", "d"]); + }); + + it("returns topological order for a diamond DAG", () => { + const fg = buildDiamondDag(); + const order = fg.topologicalOrder(); + expect(order[0]).toBe("top"); + expect(order[3]).toBe("bottom"); + expect(order).toContain("left"); + expect(order).toContain("right"); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(fg.topologicalOrder()).toEqual([]); + }); + + it("returns single node for graph with one node", () => { + const fg = new FlowGraph(); + fg.addNode("only", { name: "only" }); + expect(fg.topologicalOrder()).toEqual(["only"]); + }); + + it("throws CycleError when graph has cycles (via raw graph access)", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg._edgeKey; + fg.graph.addNode("c"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->c", "b", "c"); + fg.graph.addEdgeWithKey("c->a", "c", "a"); + expect(() => fg.topologicalOrder()).toThrow(CycleError); + }); +}); + +describe("FlowGraph hasCycles", () => { + it("returns false for a DAG", () => { + const fg = buildDiamondDag(); + expect(fg.hasCycles()).toBe(false); + }); + + it("returns false for empty graph", () => { + const fg = new FlowGraph(); + expect(fg.hasCycles()).toBe(false); + }); + + it("returns false for a chain", () => { + const fg = buildChainDag(); + expect(fg.hasCycles()).toBe(false); + }); + + it("returns true when graph has cycles (via raw graph)", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->a", "b", "a"); + expect(fg.hasCycles()).toBe(true); + }); +}); + +describe("FlowGraph findCycles", () => { + it("returns empty array for a DAG", () => { + const fg = buildDiamondDag(); + expect(fg.findCycles()).toEqual([]); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(fg.findCycles()).toEqual([]); + }); + + it("finds a simple cycle (via raw graph)", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->a", "b", "a"); + const cycles = fg.findCycles(); + expect(cycles.length).toBeGreaterThan(0); + expect(cycles[0]!.length).toBeGreaterThan(0); + }); + + it("finds a three-node cycle (via raw graph)", () => { + const fg = new FlowGraph(); + fg.graph.addNode("a"); + fg.graph.addNode("b"); + fg.graph.addNode("c"); + fg.graph.addEdgeWithKey("a->b", "a", "b"); + fg.graph.addEdgeWithKey("b->c", "b", "c"); + fg.graph.addEdgeWithKey("c->a", "c", "a"); + const cycles = fg.findCycles(); + expect(cycles.length).toBeGreaterThan(0); + }); +}); + +describe("FlowGraph ancestors", () => { + it("returns all ancestors for a node in a chain", () => { + const fg = buildChainDag(); + expect(fg.ancestors("d")).toEqual(["c", "b", "a"]); + }); + + it("returns all ancestors for a node in a diamond DAG", () => { + const fg = buildDiamondDag(); + const anc = fg.ancestors("bottom"); + expect(anc).toContain("left"); + expect(anc).toContain("right"); + expect(anc).toContain("top"); + expect(anc.length).toBe(3); + }); + + it("returns empty array for a root node", () => { + const fg = buildChainDag(); + expect(fg.ancestors("a")).toEqual([]); + }); + + it("returns empty array for a node in a graph with no edges", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(fg.ancestors("a")).toEqual([]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => fg.ancestors("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph descendants", () => { + it("returns all descendants for a node in a chain", () => { + const fg = buildChainDag(); + expect(fg.descendants("a")).toEqual(["b", "c", "d"]); + }); + + it("returns all descendants for a node in a diamond DAG", () => { + const fg = buildDiamondDag(); + const desc = fg.descendants("top"); + expect(desc).toContain("left"); + expect(desc).toContain("right"); + expect(desc).toContain("bottom"); + expect(desc.length).toBe(3); + }); + + it("returns only direct and transitive children from left in diamond", () => { + const fg = buildDiamondDag(); + const desc = fg.descendants("left"); + expect(desc).toEqual(["bottom"]); + }); + + it("returns empty array for a leaf node", () => { + const fg = buildChainDag(); + expect(fg.descendants("d")).toEqual([]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => fg.descendants("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph reachableFrom", () => { + it("returns all reachable nodes from a single start node", () => { + const fg = buildChainDag(); + const reachable = fg.reachableFrom(["a"]); + expect(reachable).toEqual(new Set(["a", "b", "c", "d"])); + }); + + it("returns only the start node if it's a leaf", () => { + const fg = buildChainDag(); + const reachable = fg.reachableFrom(["d"]); + expect(reachable).toEqual(new Set(["d"])); + }); + + it("returns union of reachable nodes from multiple start nodes", () => { + const fg = buildDiamondDag(); + const reachable = fg.reachableFrom(["left", "right"]); + expect(reachable).toEqual(new Set(["left", "right", "bottom"])); + }); + + it("returns empty set for empty input", () => { + const fg = buildChainDag(); + expect(fg.reachableFrom([])).toEqual(new Set()); + }); + + it("skips nodes that don't exist in graph", () => { + const fg = buildChainDag(); + const reachable = fg.reachableFrom(["a", "missing"]); + expect(reachable).toEqual(new Set(["a", "b", "c", "d"])); + }); +}); + +describe("FlowGraph filterByStatus", () => { + it("returns node keys with matching status", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed" }); + fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running" }); + expect(fg.filterByStatus("running").sort()).toEqual(["r1", "r3"]); + expect(fg.filterByStatus("completed")).toEqual(["r2"]); + }); + + it("returns empty array when no nodes match", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); + expect(fg.filterByStatus("failed")).toEqual([]); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(fg.filterByStatus("pending")).toEqual([]); + }); +}); + +describe("FlowGraph getRoots", () => { + it("returns top-level call nodes", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending", parentRequestId: "r1" }); + fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "completed" }); + const roots = fg.getRoots().sort(); + expect(roots).toEqual(["r1", "r3"]); + }); + + it("returns all nodes if none have parentRequestId", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); + const roots = fg.getRoots().sort(); + expect(roots).toEqual(["r1", "r2"]); + }); + + it("returns empty array for empty graph", () => { + const fg = new FlowGraph(); + expect(fg.getRoots()).toEqual([]); + }); +}); + +describe("FlowGraph children", () => { + it("returns direct children via triggered edges", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); + fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "pending" }); + fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "triggered" }); + fg.graph.addEdgeWithKey("r1->r3", "r1", "r3", { edgeType: "triggered" }); + const children = fg.children("r1").sort(); + expect(children).toEqual(["r2", "r3"]); + }); + + it("excludes non-triggered edges", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" }); + fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "depends_on" }); + expect(fg.children("r1")).toEqual([]); + }); + + it("returns empty array for a leaf node", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" }); + expect(fg.children("r1")).toEqual([]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => fg.children("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph duration", () => { + it("returns duration in milliseconds", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { + requestId: "r1", + operationId: "op1", + status: "completed", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:05.000Z", + }); + expect(fg.duration("r1")).toBe(5000); + }); + + it("returns 0 for simultaneous start and end", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { + requestId: "r1", + operationId: "op1", + status: "completed", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:00.000Z", + }); + expect(fg.duration("r1")).toBe(0); + }); + + it("throws if startedAt is missing", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { + requestId: "r1", + operationId: "op1", + status: "running", + completedAt: "2026-01-01T00:00:05.000Z", + }); + expect(() => fg.duration("r1")).toThrow(); + }); + + it("throws if completedAt is missing", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { + requestId: "r1", + operationId: "op1", + status: "running", + startedAt: "2026-01-01T00:00:00.000Z", + }); + expect(() => fg.duration("r1")).toThrow(); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => fg.duration("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph lineage", () => { + it("returns ancestor chain from root to node", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" }); + fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed", parentRequestId: "r1" }); + fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running", parentRequestId: "r2" }); + expect(fg.lineage("r3")).toEqual(["r1", "r2", "r3"]); + }); + + it("returns single element for root node", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" }); + expect(fg.lineage("r1")).toEqual(["r1"]); + }); + + it("stops if parentRequestId node does not exist in graph", () => { + const fg = new FlowGraph(); + fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running", parentRequestId: "r0" }); + expect(fg.lineage("r1")).toEqual(["r1"]); + }); + + it("throws NodeNotFoundError for missing node", () => { + const fg = new FlowGraph(); + expect(() => fg.lineage("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("standalone topologicalOrder", () => { + it("returns topological order for a graph", () => { + const fg = buildChainDag(); + expect(topologicalOrder(fg.graph)).toEqual(["a", "b", "c", "d"]); + }); +}); + +describe("standalone hasCycles", () => { + it("returns false for DAG", () => { + const fg = buildChainDag(); + expect(hasCycles(fg.graph)).toBe(false); + }); +}); + +describe("standalone findCycles", () => { + it("returns empty for DAG", () => { + const fg = buildChainDag(); + expect(findCycles(fg.graph)).toEqual([]); + }); +}); + +describe("standalone ancestors", () => { + it("returns ancestors for a node", () => { + const fg = buildChainDag(); + expect(ancestors(fg.graph, "d")).toEqual(["c", "b", "a"]); + }); +}); + +describe("standalone descendants", () => { + it("returns descendants for a node", () => { + const fg = buildChainDag(); + expect(descendants(fg.graph, "a")).toEqual(["b", "c", "d"]); + }); +}); + +describe("standalone reachableFrom", () => { + it("returns all reachable nodes", () => { + const fg = buildDiamondDag(); + const result = reachableFrom(fg.graph, ["top"]); + expect(result).toEqual(new Set(["top", "left", "right", "bottom"])); + }); + + it("returns union from multiple starts", () => { + const fg = buildDiamondDag(); + const result = reachableFrom(fg.graph, ["left", "right"]); + expect(result).toEqual(new Set(["left", "right", "bottom"])); }); }); \ No newline at end of file