From 974724465324f9c590821938b70ea24a195c0f42 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:06:24 +0000 Subject: [PATCH] feat(schema): add SerializedGraph factory, OperationGraphSerialized, CallGraphSerialized, FlowGraphSerialized --- src/schema/edge.ts | 3 +- src/schema/graph.ts | 48 ++++- src/schema/index.ts | 4 +- test/schema/graph.test.ts | 379 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 test/schema/graph.test.ts diff --git a/src/schema/edge.ts b/src/schema/edge.ts index 03f8487..4d1474e 100644 --- a/src/schema/edge.ts +++ b/src/schema/edge.ts @@ -23,7 +23,8 @@ export type TriggeredEdgeAttrs = Static; export const DependencyEdgeAttrs = Type.Object({}); export type DependencyEdgeAttrs = Static; -export type CallEdgeAttrs = TriggeredEdgeAttrs | DependencyEdgeAttrs; +export const CallEdgeAttrs = Type.Union([TriggeredEdgeAttrs, DependencyEdgeAttrs]); +export type CallEdgeAttrs = Static; export const TemplateEdgeAttrs = Type.Object({ edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]), diff --git a/src/schema/graph.ts b/src/schema/graph.ts index 8cec2e9..28b32cd 100644 --- a/src/schema/graph.ts +++ b/src/schema/graph.ts @@ -1 +1,47 @@ -export {}; \ No newline at end of file +import { Type, type Static, type TSchema } from "@alkdev/typebox"; +import { OperationNodeAttrs, CallNodeAttrs } from "./node.js"; +import { OperationEdgeAttrs, CallEdgeAttrs } from "./edge.js"; + +export const SerializedGraph = ( + NodeAttrs: N, + EdgeAttrs: E, + GraphAttrs: G, +) => + Type.Object({ + attributes: GraphAttrs, + options: Type.Object({ + type: Type.Literal("directed"), + multi: Type.Literal(false), + allowSelfLoops: Type.Literal(false), + }), + nodes: Type.Array( + Type.Object({ + key: Type.String(), + attributes: NodeAttrs, + }), + ), + edges: Type.Array( + Type.Object({ + key: Type.String(), + source: Type.String(), + target: Type.String(), + attributes: EdgeAttrs, + }), + ), + }); + +export const OperationGraphSerialized = SerializedGraph( + OperationNodeAttrs, + OperationEdgeAttrs, + Type.Object({}), +); +export type OperationGraphSerialized = Static; + +export const CallGraphSerialized = SerializedGraph( + CallNodeAttrs, + CallEdgeAttrs, + Type.Object({}), +); +export type CallGraphSerialized = Static; + +export type FlowGraphSerialized = OperationGraphSerialized | CallGraphSerialized; \ No newline at end of file diff --git a/src/schema/index.ts b/src/schema/index.ts index dbf83d2..0708c9e 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -12,4 +12,6 @@ export { export * from "./node.js"; -export * from "./edge.js"; \ No newline at end of file +export * from "./edge.js"; + +export * from "./graph.js"; \ No newline at end of file diff --git a/test/schema/graph.test.ts b/test/schema/graph.test.ts new file mode 100644 index 0000000..9927c9b --- /dev/null +++ b/test/schema/graph.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from "vitest"; +import { Value } from "@alkdev/typebox/value"; +import { Type } from "@alkdev/typebox"; +import { + SerializedGraph, + OperationGraphSerialized, + type OperationGraphSerialized as OperationGraphSerializedType, + CallGraphSerialized, + type CallGraphSerialized as CallGraphSerializedType, + type FlowGraphSerialized, +} from "../../src/schema/graph"; + +describe("SerializedGraph", () => { + it("produces a valid TypeBox schema with custom node/edge/graph attrs", () => { + const schema = SerializedGraph( + Type.Object({ label: Type.String() }), + Type.Object({ weight: Type.Number() }), + Type.Object({ name: Type.String() }), + ); + + const valid = { + attributes: { name: "test-graph" }, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [{ key: "a", attributes: { label: "node-a" } }], + edges: [ + { key: "a->b", source: "a", target: "b", attributes: { weight: 1 } }, + ], + }; + + expect(Value.Check(schema, valid)).toBe(true); + }); + + it("rejects missing required fields", () => { + const schema = SerializedGraph( + Type.Object({ label: Type.String() }), + Type.Object({}), + Type.Object({}), + ); + + expect(Value.Check(schema, {})).toBe(false); + expect(Value.Check(schema, { attributes: {} })).toBe(false); + expect(Value.Check(schema, { attributes: {}, options: {} })).toBe(false); + }); + + it("rejects wrong options type", () => { + const schema = SerializedGraph( + Type.Object({}), + Type.Object({}), + Type.Object({}), + ); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "undirected", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(false); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: true, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(false); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: true }, + nodes: [], + edges: [], + }), + ).toBe(false); + }); + + it("rejects invalid node attributes", () => { + const schema = SerializedGraph( + Type.Object({ label: Type.String() }), + Type.Object({}), + Type.Object({}), + ); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [{ key: "a", attributes: { label: 42 } }], + edges: [], + }), + ).toBe(false); + }); + + it("rejects invalid edge attributes", () => { + const schema = SerializedGraph( + Type.Object({}), + Type.Object({ weight: Type.Number() }), + Type.Object({}), + ); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [ + { key: "a->b", source: "a", target: "b", attributes: { weight: "heavy" } }, + ], + }), + ).toBe(false); + }); + + it("rejects missing edge key/source/target", () => { + const schema = SerializedGraph( + Type.Object({}), + Type.Object({}), + Type.Object({}), + ); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [{ source: "a", target: "b", attributes: {} }], + }), + ).toBe(false); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [{ key: "a->b", target: "b", attributes: {} }], + }), + ).toBe(false); + }); + + it("accepts empty nodes and edges arrays", () => { + const schema = SerializedGraph( + Type.Object({}), + Type.Object({}), + Type.Object({}), + ); + + expect( + Value.Check(schema, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(true); + }); +}); + +describe("OperationGraphSerialized", () => { + const valid: OperationGraphSerializedType = { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [ + { + key: "task.classify", + attributes: { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + }, + }, + ], + edges: [ + { + key: "task.classify->task.enrich", + source: "task.classify", + target: "task.enrich", + attributes: { compatible: true }, + }, + ], + }; + + it("accepts valid operation graph serialization", () => { + expect(Value.Check(OperationGraphSerialized, valid)).toBe(true); + }); + + it("accepts operation graph with empty nodes and edges", () => { + expect( + Value.Check(OperationGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(true); + }); + + it("rejects operation node with missing required fields", () => { + expect( + Value.Check(OperationGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [{ key: "x", attributes: { name: "x" } }], + edges: [], + }), + ).toBe(false); + }); + + it("rejects operation edge missing required compatible field", () => { + expect( + Value.Check(OperationGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [ + { + key: "a->b", + source: "a", + target: "b", + attributes: {}, + }, + ], + }), + ).toBe(false); + }); + + it("rejects wrong options type", () => { + expect( + Value.Check(OperationGraphSerialized, { + attributes: {}, + options: { type: "undirected", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(false); + }); +}); + +describe("CallGraphSerialized", () => { + const valid: CallGraphSerializedType = { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [ + { + key: "req_abc123", + attributes: { + requestId: "req_abc123", + operationId: "task.classify", + status: "pending", + input: { text: "hello" }, + }, + }, + ], + edges: [ + { + key: "req_abc123->req_def456", + source: "req_abc123", + target: "req_def456", + attributes: {}, + }, + ], + }; + + it("accepts valid call graph serialization", () => { + expect(Value.Check(CallGraphSerialized, valid)).toBe(true); + }); + + it("accepts call graph with depends_on edge key convention", () => { + const withDepends: CallGraphSerializedType = { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [ + { + key: "req_abc123", + attributes: { + requestId: "req_abc123", + operationId: "task.classify", + status: "completed", + input: {}, + }, + }, + { + key: "req_def456", + attributes: { + requestId: "req_def456", + operationId: "task.enrich", + status: "running", + input: {}, + }, + }, + ], + edges: [ + { + key: "req_abc123->req_def456", + source: "req_abc123", + target: "req_def456", + attributes: {}, + }, + { + key: "req_abc123->req_def456:depends_on", + source: "req_abc123", + target: "req_def456", + attributes: {}, + }, + ], + }; + expect(Value.Check(CallGraphSerialized, withDepends)).toBe(true); + }); + + it("accepts call graph with empty nodes and edges", () => { + expect( + Value.Check(CallGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(true); + }); + + it("rejects call node with missing required fields", () => { + expect( + Value.Check(CallGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [{ key: "x", attributes: { requestId: "x" } }], + edges: [], + }), + ).toBe(false); + }); + + it("rejects call node with invalid status", () => { + expect( + Value.Check(CallGraphSerialized, { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [ + { + key: "req_1", + attributes: { + requestId: "req_1", + operationId: "task.x", + status: "idle", + input: {}, + }, + }, + ], + edges: [], + }), + ).toBe(false); + }); + + it("rejects wrong options type", () => { + expect( + Value.Check(CallGraphSerialized, { + attributes: {}, + options: { type: "undirected", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }), + ).toBe(false); + }); +}); + +describe("FlowGraphSerialized", () => { + it("FlowGraphSerialized is a type alias for the union", () => { + const op: FlowGraphSerialized = { + attributes: {}, + options: { type: "directed", multi: false, allowSelfLoops: false }, + nodes: [], + edges: [], + }; + expect(op.options.type).toBe("directed"); + }); +}); \ No newline at end of file