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 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<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(
_specs: unknown[],
): 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;
}