Merge branch 'feat/schema-graph-schemas'
This commit is contained in:
@@ -23,7 +23,8 @@ export type TriggeredEdgeAttrs = Static<typeof TriggeredEdgeAttrs>;
|
|||||||
export const DependencyEdgeAttrs = Type.Object({});
|
export const DependencyEdgeAttrs = Type.Object({});
|
||||||
export type DependencyEdgeAttrs = Static<typeof DependencyEdgeAttrs>;
|
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({
|
export const TemplateEdgeAttrs = Type.Object({
|
||||||
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
|
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 "./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