Merge branch 'feat/schema-graph-schemas'

This commit is contained in:
2026-05-21 21:06:43 +00:00
4 changed files with 431 additions and 3 deletions

View File

@@ -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")]),

View File

@@ -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;

View File

@@ -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
View 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");
});
});