feat(schema): add SerializedGraph factory, OperationGraphSerialized, CallGraphSerialized, FlowGraphSerialized
This commit is contained in:
@@ -23,7 +23,8 @@ export type TriggeredEdgeAttrs = Static<typeof TriggeredEdgeAttrs>;
|
||||
export const DependencyEdgeAttrs = Type.Object({});
|
||||
export type DependencyEdgeAttrs = Static<typeof DependencyEdgeAttrs>;
|
||||
|
||||
export type CallEdgeAttrs = TriggeredEdgeAttrs | DependencyEdgeAttrs;
|
||||
export const CallEdgeAttrs = Type.Union([TriggeredEdgeAttrs, DependencyEdgeAttrs]);
|
||||
export type CallEdgeAttrs = Static<typeof CallEdgeAttrs>;
|
||||
|
||||
export const TemplateEdgeAttrs = Type.Object({
|
||||
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
|
||||
|
||||
@@ -1 +1,47 @@
|
||||
export {};
|
||||
import { Type, type Static, type TSchema } from "@alkdev/typebox";
|
||||
import { OperationNodeAttrs, CallNodeAttrs } from "./node.js";
|
||||
import { OperationEdgeAttrs, CallEdgeAttrs } from "./edge.js";
|
||||
|
||||
export const SerializedGraph = <N extends TSchema, E extends TSchema, G extends TSchema>(
|
||||
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<typeof OperationGraphSerialized>;
|
||||
|
||||
export const CallGraphSerialized = SerializedGraph(
|
||||
CallNodeAttrs,
|
||||
CallEdgeAttrs,
|
||||
Type.Object({}),
|
||||
);
|
||||
export type CallGraphSerialized = Static<typeof CallGraphSerialized>;
|
||||
|
||||
export type FlowGraphSerialized = OperationGraphSerialized | CallGraphSerialized;
|
||||
@@ -12,4 +12,6 @@ export {
|
||||
|
||||
export * from "./node.js";
|
||||
|
||||
export * from "./edge.js";
|
||||
export * from "./edge.js";
|
||||
|
||||
export * from "./graph.js";
|
||||
379
test/schema/graph.test.ts
Normal file
379
test/schema/graph.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user