feat(graph): implement query methods — topologicalOrder, hasCycles, findCycles, ancestors, descendants, reachableFrom, and call graph queries (filterByStatus, getRoots, children, duration, lineage)

This commit is contained in:
2026-05-21 21:15:58 +00:00
parent d63ef886d8
commit 750ef2d4b7
4 changed files with 695 additions and 7 deletions

View File

@@ -1,12 +1,17 @@
import { DirectedGraph } from "graphology"; import { DirectedGraph } from "graphology";
import type { TSchema, Static } from "@alkdev/typebox"; import type { TSchema, Static } from "@alkdev/typebox";
import { willCreateCycle } from "graphology-dag"; import { willCreateCycle, topologicalSort, hasCycle } from "graphology-dag";
import { import {
DuplicateNodeError, DuplicateNodeError,
DuplicateEdgeError, DuplicateEdgeError,
NodeNotFoundError, NodeNotFoundError,
CycleError, CycleError,
} from "../error/index.js"; } from "../error/index.js";
import type { CallStatus } from "../error/index.js";
import {
findCycles,
reachableFrom as reachableFromFn,
} from "./queries.js";
export interface FlowGraphOptions { export interface FlowGraphOptions {
type?: "directed"; type?: "directed";
@@ -154,6 +159,134 @@ export class FlowGraph<
return this._graph.outNeighbors(nodeId) ?? []; 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<string>();
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<string>();
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<string> {
return reachableFromFn(this._graph, nodeIds);
}
filterByStatus(status: CallStatus): string[] {
const result: string[] = [];
this._graph.forEachNode((key, attrs) => {
if ((attrs as Record<string, unknown>).status === status) {
result.push(key);
}
});
return result;
}
getRoots(): string[] {
const result: string[] = [];
this._graph.forEachNode((key, attrs) => {
if ((attrs as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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( static fromSpecs(
_specs: unknown[], _specs: unknown[],
): FlowGraph<TSchema, TSchema> { ): FlowGraph<TSchema, TSchema> {

View File

@@ -1 +1,9 @@
export { FlowGraph, type FlowGraphOptions } from "./construction.js"; export { FlowGraph, type FlowGraphOptions } from "./construction.js";
export {
topologicalOrder,
hasCycles,
findCycles,
ancestors,
descendants,
reachableFrom,
} from "./queries.js";

View File

@@ -1 +1,114 @@
export {}; 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<string, number>();
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<string>();
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<string>();
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<string> {
const visited = new Set<string>();
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;
}

View File

@@ -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', () => { function buildDiamondDag(): FlowGraph {
it('placeholder', () => { const fg = new FlowGraph();
expect(true).toBe(true); 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"]));
}); });
}); });