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