diff --git a/src/graph/construction.ts b/src/graph/construction.ts index d5c37ee..3f8b6dd 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1,13 +1,15 @@ import { DirectedGraph } from "graphology"; import type { TSchema, Static } from "@alkdev/typebox"; +import { Value } from "@alkdev/typebox/value"; import { willCreateCycle, topologicalSort, hasCycle } from "graphology-dag"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, + InvalidInputError, } from "../error/index.js"; -import type { CallStatus, AnyValidationError } from "../error/index.js"; +import type { CallStatus, AnyValidationError, ValidationError } from "../error/index.js"; import { findCycles, reachableFrom as reachableFromFn, @@ -16,8 +18,10 @@ import { validate as _validate } from "./validation.js"; import { OperationNodeAttrs as OperationNodeAttrsSchema, OperationEdgeAttrs as OperationEdgeAttrsSchema, + OperationGraphSerialized, + CallGraphSerialized, } from "../schema/index.js"; -import type { OperationNodeAttrs } from "../schema/index.js"; +import type { OperationNodeAttrs, FlowGraphSerialized } from "../schema/index.js"; import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js"; export interface FlowGraphOptions { @@ -367,10 +371,64 @@ export class FlowGraph< throw new Error("not implemented"); } + export(): FlowGraphSerialized { + return this._graph.export() as unknown as FlowGraphSerialized; + } + + toJSON(): FlowGraphSerialized { + return this.export(); + } + + toString(): string { + return JSON.stringify(this.export()); + } + static fromJSON( - _data: unknown, + data: FlowGraphSerialized, ): FlowGraph { - throw new Error("not implemented"); + const opCheck = Value.Check(OperationGraphSerialized, data); + const callCheck = Value.Check(CallGraphSerialized, data); + if (!opCheck && !callCheck) { + const errors: ValidationError[] = []; + const opIter = Value.Errors(OperationGraphSerialized, data as Record); + for (const err of opIter) { + errors.push({ + type: "schema", + nodeKey: "", + field: err.path.replace(/^\//, "") || err.path, + message: err.message, + value: err.value, + }); + } + if (errors.length === 0) { + const callIter = Value.Errors(CallGraphSerialized, data as Record); + for (const err of callIter) { + errors.push({ + type: "schema", + nodeKey: "", + field: err.path.replace(/^\//, "") || err.path, + message: err.message, + value: err.value, + }); + } + } + throw new InvalidInputError(errors); + } + + const fg = new FlowGraph(); + for (const node of data.nodes) { + fg._graph.addNode(node.key, node.attributes as Attrs); + } + for (const edge of data.edges) { + fg._graph.addEdgeWithKey(edge.key, edge.source, edge.target, edge.attributes as Attrs); + } + + if (hasCycle(fg._graph)) { + const cycles = findCycles(fg._graph); + throw new CycleError(cycles); + } + + return fg; } private _findPath(from: string, to: string): string[] { diff --git a/test/graph/construction.test.ts b/test/graph/construction.test.ts index 68d3f8c..19c99ac 100644 --- a/test/graph/construction.test.ts +++ b/test/graph/construction.test.ts @@ -303,10 +303,6 @@ describe("FlowGraph static stubs", () => { it("fromCallEvents throws not implemented", () => { expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented"); }); - - it("fromJSON throws not implemented", () => { - expect(() => FlowGraph.fromJSON({})).toThrow("not implemented"); - }); }); describe("FlowGraph.addOperation", () => { diff --git a/test/graph/serialization.test.ts b/test/graph/serialization.test.ts new file mode 100644 index 0000000..2a5f37c --- /dev/null +++ b/test/graph/serialization.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { Type } from "@alkdev/typebox"; +import { FlowGraph } from "../../src/graph/construction.js"; +import type { OperationSpec } from "../../src/graph/construction.js"; +import { InvalidInputError, CycleError } from "../../src/error/index.js"; + +describe("FlowGraph.export", () => { + it("returns graphology native JSON format for empty graph", () => { + const fg = new FlowGraph(); + const data = fg.export(); + expect(data.options).toEqual({ type: "directed", multi: false, allowSelfLoops: false }); + expect(data.attributes).toEqual({}); + expect(data.nodes).toEqual([]); + expect(data.edges).toEqual([]); + }); + + it("returns graphology native JSON format for operation graph", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + const data = graph.export(); + expect(data.options.type).toBe("directed"); + expect(data.nodes.length).toBe(2); + expect(data.edges.length).toBeGreaterThan(0); + }); +}); + +describe("FlowGraph.toJSON", () => { + it("is an alias for export", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" } as never); + const exported = fg.export(); + const jsoned = fg.toJSON(); + expect(jsoned).toEqual(exported); + }); +}); + +describe("FlowGraph.toString", () => { + it("returns JSON.stringify of export()", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" } as never); + expect(fg.toString()).toBe(JSON.stringify(fg.export())); + }); + + it("round-trips through JSON.parse", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" } as never); + const parsed = JSON.parse(fg.toString()); + expect(parsed.options).toEqual({ type: "directed", multi: false, allowSelfLoops: false }); + }); +}); + +describe("FlowGraph.fromJSON", () => { + it("round-trips fromSpecs -> export -> fromJSON", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }, + ]; + const original = FlowGraph.fromSpecs(specs); + const data = original.export(); + const restored = FlowGraph.fromJSON(data); + expect(restored.order).toBe(original.order); + expect(restored.size).toBe(original.size); + for (const node of original.nodes()) { + expect(restored.hasNode(node)).toBe(true); + const origAttrs = original.getNodeAttributes(node as never) as Record; + const restAttrs = restored.getNodeAttributes(node as never) as Record; + expect(restAttrs.name).toBe(origAttrs.name); + expect(restAttrs.namespace).toBe(origAttrs.namespace); + } + for (const edge of original.edges()) { + const source = edge.split("->")[0]!; + const target = edge.split("->")[1]!; + expect(restored.hasEdge(source, target)).toBe(true); + } + }); + + it("round-trips empty graph", () => { + const fg = new FlowGraph(); + const data = fg.export(); + const restored = FlowGraph.fromJSON(data); + expect(restored.order).toBe(0); + expect(restored.size).toBe(0); + expect(restored.export()).toEqual(data); + }); + + it("throws InvalidInputError on invalid input", () => { + expect(() => FlowGraph.fromJSON({})).toThrow(InvalidInputError); + }); + + it("InvalidInputError contains errors array", () => { + try { + FlowGraph.fromJSON({}); + expect.unreachable("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidInputError); + const err = e as InvalidInputError; + expect(err.errors.length).toBeGreaterThan(0); + } + }); + + it("throws InvalidInputError on missing nodes", () => { + const bad = { + options: { type: "directed", multi: false, allowSelfLoops: false }, + attributes: {}, + edges: [], + }; + expect(() => FlowGraph.fromJSON(bad as never)).toThrow(InvalidInputError); + }); + + it("throws CycleError on cyclic input", () => { + const cyclicData = { + options: { type: "directed", multi: false, allowSelfLoops: false }, + attributes: {}, + nodes: [ + { key: "a", attributes: { requestId: "a", operationId: "op.a", status: "completed", input: {} } }, + { key: "b", attributes: { requestId: "b", operationId: "op.b", status: "completed", input: {} } }, + ], + edges: [ + { key: "a->b", source: "a", target: "b", attributes: {} }, + { key: "b->a", source: "b", target: "a", attributes: {} }, + ], + }; + expect(() => FlowGraph.fromJSON(cyclicData as never)).toThrow(CycleError); + }); + + it("CycleError contains cycle paths on cyclic input", () => { + const cyclicData = { + options: { type: "directed", multi: false, allowSelfLoops: false }, + attributes: {}, + nodes: [ + { key: "a", attributes: { requestId: "a", operationId: "op.a", status: "completed", input: {} } }, + { key: "b", attributes: { requestId: "b", operationId: "op.b", status: "completed", input: {} } }, + ], + edges: [ + { key: "a->b", source: "a", target: "b", attributes: {} }, + { key: "b->a", source: "b", target: "a", attributes: {} }, + ], + }; + try { + FlowGraph.fromJSON(cyclicData as never); + expect.unreachable("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(CycleError); + const ce = e as CycleError; + expect(ce.cycles.length).toBeGreaterThan(0); + } + }); + + it("preserves node attributes through round-trip", () => { + const specs: OperationSpec[] = [ + { name: "classify", namespace: "task", version: "2.0.0", type: "mutation", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), description: "Classifies text", tags: ["nlp"] }, + ]; + const original = FlowGraph.fromSpecs(specs); + const data = original.export(); + const restored = FlowGraph.fromJSON(data); + const origAttrs = original.getNodeAttributes("task.classify" as never) as Record; + const restAttrs = restored.getNodeAttributes("task.classify" as never) as Record; + expect(restAttrs.name).toBe(origAttrs.name); + expect(restAttrs.namespace).toBe(origAttrs.namespace); + expect(restAttrs.version).toBe(origAttrs.version); + expect(restAttrs.type).toBe(origAttrs.type); + expect(restAttrs.description).toBe(origAttrs.description); + expect(restAttrs.tags).toEqual(origAttrs.tags); + }); + + it("preserves edge attributes through round-trip", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }, + ]; + const original = FlowGraph.fromSpecs(specs); + const data = original.export(); + const restored = FlowGraph.fromJSON(data); + const origEdgeAttrs = original.getEdgeAttributes("task.extract", "task.classify") as Record; + const restEdgeAttrs = restored.getEdgeAttributes("task.extract", "task.classify") as Record; + expect(restEdgeAttrs.edgeType).toBe(origEdgeAttrs.edgeType); + expect(restEdgeAttrs.compatible).toBe(origEdgeAttrs.compatible); + }); + + it("double round-trip is lossless", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }, + ]; + const original = FlowGraph.fromSpecs(specs); + const first = original.export(); + const restored1 = FlowGraph.fromJSON(first); + const second = restored1.export(); + const restored2 = FlowGraph.fromJSON(second); + const third = restored2.export(); + expect(third).toEqual(first); + expect(third).toEqual(second); + }); + + it("accepts valid call graph serialized data", () => { + const callData = { + options: { type: "directed", multi: false, allowSelfLoops: false }, + attributes: {}, + nodes: [ + { + key: "req_1", + attributes: { + requestId: "req_1", + operationId: "task.classify", + status: "completed", + input: { text: "hello" }, + output: { label: "greeting" }, + }, + }, + ], + edges: [], + }; + const fg = FlowGraph.fromJSON(callData as never); + expect(fg.order).toBe(1); + expect(fg.hasNode("req_1")).toBe(true); + }); + + it("throws InvalidInputError for invalid node attributes", () => { + const bad = { + options: { type: "directed", multi: false, allowSelfLoops: false }, + attributes: {}, + nodes: [ + { key: "a", attributes: { invalid: true } }, + ], + edges: [], + }; + expect(() => FlowGraph.fromJSON(bad as never)).toThrow(InvalidInputError); + }); +}); \ No newline at end of file