diff --git a/src/schema/index.ts b/src/schema/index.ts index acc9d94..31d47f2 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -9,3 +9,5 @@ export { type EdgeType, Nullable, } from "./enums.js"; + +export * from "./node.js"; diff --git a/src/schema/node.ts b/src/schema/node.ts index 8cec2e9..7aa20a1 100644 --- a/src/schema/node.ts +++ b/src/schema/node.ts @@ -1 +1,40 @@ -export {}; \ No newline at end of file +import { Type, type Static } from "@alkdev/typebox"; +import { OperationTypeEnum, CallStatusEnum } from "./enums.js"; + +export const OperationNodeAttrs = Type.Object({ + name: Type.String(), + namespace: Type.String(), + version: Type.String(), + type: OperationTypeEnum, + inputSchema: Type.Unknown(), + outputSchema: Type.Unknown(), + description: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())), +}); +export type OperationNodeAttrs = Static; + +export const CallNodeAttrs = Type.Object({ + requestId: Type.String(), + operationId: Type.String(), + status: CallStatusEnum, + parentRequestId: Type.Optional(Type.String()), + input: Type.Unknown(), + output: Type.Optional(Type.Unknown()), + error: Type.Optional( + Type.Object({ + code: Type.String(), + message: Type.String(), + details: Type.Optional(Type.Unknown()), + }), + ), + identity: Type.Optional( + Type.Object({ + id: Type.String(), + scopes: Type.Array(Type.String()), + resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))), + }), + ), + startedAt: Type.Optional(Type.String()), + completedAt: Type.Optional(Type.String()), +}); +export type CallNodeAttrs = Static; \ No newline at end of file diff --git a/test/schema/node.test.ts b/test/schema/node.test.ts new file mode 100644 index 0000000..050e6b4 --- /dev/null +++ b/test/schema/node.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { Value } from "@alkdev/typebox/value"; +import { + OperationNodeAttrs, + type OperationNodeAttrs as OperationNodeAttrsType, + CallNodeAttrs, + type CallNodeAttrs as CallNodeAttrsType, +} from "../../src/schema/node"; + +describe("OperationNodeAttrs", () => { + const valid: OperationNodeAttrsType = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + }; + + it("accepts valid attributes without optional fields", () => { + expect(Value.Check(OperationNodeAttrs, valid)).toBe(true); + }); + + it("accepts valid attributes with all optional fields", () => { + const withOptional: OperationNodeAttrsType = { + ...valid, + description: "Classifies input", + tags: ["ml", "nlp"], + }; + expect(Value.Check(OperationNodeAttrs, withOptional)).toBe(true); + }); + + it("accepts all operation types", () => { + for (const type of ["query", "mutation", "subscription"] as const) { + expect(Value.Check(OperationNodeAttrs, { ...valid, type })).toBe(true); + } + }); + + it("rejects missing required fields", () => { + expect(Value.Check(OperationNodeAttrs, {})).toBe(false); + expect(Value.Check(OperationNodeAttrs, { name: "classify" })).toBe(false); + }); + + it("rejects invalid operation type", () => { + expect( + Value.Check(OperationNodeAttrs, { ...valid, type: "invalid" }), + ).toBe(false); + }); + + it("rejects wrong types for optional tags", () => { + expect(Value.Check(OperationNodeAttrs, { ...valid, tags: [1, 2] })).toBe( + false, + ); + }); +}); + +describe("CallNodeAttrs", () => { + const valid: CallNodeAttrsType = { + requestId: "req_abc123", + operationId: "task.classify", + status: "pending", + input: { text: "hello" }, + }; + + it("accepts valid attributes without optional fields", () => { + expect(Value.Check(CallNodeAttrs, valid)).toBe(true); + }); + + it("accepts valid attributes with all optional fields", () => { + const withOptional: CallNodeAttrsType = { + ...valid, + parentRequestId: "req_parent", + output: { label: "greeting" }, + error: { + code: "INTERNAL", + message: "Something went wrong", + details: { stack: "..." }, + }, + identity: { + id: "user_1", + scopes: ["read", "write"], + resources: { org: ["org_1"] }, + }, + startedAt: "2026-05-21T10:00:00Z", + completedAt: "2026-05-21T10:00:01Z", + }; + expect(Value.Check(CallNodeAttrs, withOptional)).toBe(true); + }); + + it("accepts all call statuses", () => { + for (const status of ["pending", "running", "completed", "failed", "aborted"] as const) { + expect(Value.Check(CallNodeAttrs, { ...valid, status })).toBe(true); + } + }); + + it("accepts error without optional details", () => { + const withError: CallNodeAttrsType = { + ...valid, + error: { code: "NOT_FOUND", message: "Operation not found" }, + }; + expect(Value.Check(CallNodeAttrs, withError)).toBe(true); + }); + + it("accepts identity without optional resources", () => { + const withIdentity: CallNodeAttrsType = { + ...valid, + identity: { id: "user_1", scopes: ["read"] }, + }; + expect(Value.Check(CallNodeAttrs, withIdentity)).toBe(true); + }); + + it("rejects missing required fields", () => { + expect(Value.Check(CallNodeAttrs, {})).toBe(false); + expect(Value.Check(CallNodeAttrs, { requestId: "req_1" })).toBe(false); + }); + + it("rejects invalid call status", () => { + expect( + Value.Check(CallNodeAttrs, { ...valid, status: "idle" }), + ).toBe(false); + }); + + it("rejects error with missing required fields", () => { + expect( + Value.Check(CallNodeAttrs, { ...valid, error: { code: "X" } }), + ).toBe(false); + expect( + Value.Check(CallNodeAttrs, { ...valid, error: { message: "X" } }), + ).toBe(false); + }); +}); \ No newline at end of file