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:
@@ -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> {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user