From c4b1eb0a615193b14854f61ec52ffca6da96ee14 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:44:14 -0600 Subject: [PATCH] feat(rete): add JSON serialize/deserialize round-trip (P1.6) --- packages/rete/package.json | 3 + packages/rete/src/index.ts | 3 + packages/rete/src/serialize.test.ts | 143 ++++++++++++++++++++++++++ packages/rete/src/serialize.ts | 152 ++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 packages/rete/src/serialize.test.ts create mode 100644 packages/rete/src/serialize.ts diff --git a/packages/rete/package.json b/packages/rete/package.json index bc44691..59b96e1 100644 --- a/packages/rete/package.json +++ b/packages/rete/package.json @@ -16,6 +16,9 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean", "typecheck": "tsc --noEmit" }, + "dependencies": { + "zod": "^3.23.0" + }, "devDependencies": { "tsup": "^8.0.0" } diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 1d36e1a..7c8511a 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -21,3 +21,6 @@ export { HandlerRegistry, PredicateRegistry, UnknownHandlerError, UnknownPredica export type { SessionOptions } from "./session.js"; export { Session } from "./session.js"; + +export type { SerializedRule } from "./serialize.js"; +export { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js"; diff --git a/packages/rete/src/serialize.test.ts b/packages/rete/src/serialize.test.ts new file mode 100644 index 0000000..bae12a3 --- /dev/null +++ b/packages/rete/src/serialize.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js"; +import { HandlerRegistry } from "./registry.js"; +import { v } from "./builder.js"; +import type { EntityId } from "./schema.js"; + +const mkId = (n: number) => n as EntityId; + +function makeRegistry(...names: string[]): HandlerRegistry { + const r = new HandlerRegistry(); + for (const name of names) r.register(name, () => {}); + return r; +} + +describe("serialize()", () => { + it("serializes a simple rule to JSON-compatible object", () => { + const rule = { + name: "move-player", + salience: 0, + conditions: [{ id: mkId(1), attr: "X", binding: v("x"), idBinding: undefined }], + handler: "moveHandler", + handlerArgs: undefined, + }; + const json = serialize(rule); + expect(json.name).toBe("move-player"); + expect(json.salience).toBe(0); + expect(json.handler).toBe("moveHandler"); + expect(json.conditions).toHaveLength(1); + expect(JSON.stringify(json)).not.toContain("function"); // no function refs + }); + + it("serializes wildcard id as null", () => { + const rule = { + name: "wildcard", + salience: 5, + conditions: [{ id: null, attr: "Health", binding: v("hp"), idBinding: v("eid") }], + handler: "h", + handlerArgs: ["arg1"], + }; + const json = serialize(rule); + expect(json.conditions[0]?.id).toBeNull(); + expect(json.conditions[0]?.idBinding).toBe("eid"); + expect(json.handlerArgs).toEqual(["arg1"]); + expect(json.salience).toBe(5); + }); + + it("serializes rule with no binding (binding: null)", () => { + const rule = { + name: "no-bind", + salience: 0, + conditions: [{ id: mkId(42), attr: "Flag", binding: null, idBinding: undefined }], + handler: "flagHandler", + }; + const json = serialize(rule); + expect(json.conditions[0]?.binding).toBeNull(); + }); +}); + +describe("deserialize()", () => { + it("deserializes a serialized rule back to RuleDefinition", () => { + const registry = makeRegistry("moveHandler"); + const rule = { + name: "round-trip-1", + salience: 0, + conditions: [{ id: mkId(1), attr: "X", binding: v("x") }], + handler: "moveHandler", + }; + const json = serialize(rule); + const restored = deserialize(json, registry); + expect(restored.name).toBe("round-trip-1"); + expect(restored.handler).toBe("moveHandler"); + expect(restored.conditions).toHaveLength(1); + expect(restored.conditions[0]?.binding?.name).toBe("x"); + }); + + it("throws UnknownHandlerError for unregistered handler", () => { + const registry = new HandlerRegistry(); + const json = { + name: "bad", + salience: 0, + conditions: [], + handler: "missingHandler", + }; + expect(() => deserialize(json, registry)).toThrow("UnknownHandlerError"); + }); + + it("throws on malformed JSON (missing name)", () => { + const registry = makeRegistry("h"); + const bad = { salience: 0, conditions: [], handler: "h" }; + expect(() => deserialize(bad as never, registry)).toThrow(); + }); +}); + +describe("round-trip equivalence (10 shapes)", () => { + const registry = makeRegistry("h1", "h2", "h3"); + + const shapes = [ + // shape 1: minimal + { name: "s1", salience: 0, conditions: [], handler: "h1" }, + // shape 2: single condition, exact id + { name: "s2", salience: 0, conditions: [{ id: mkId(1), attr: "A", binding: v("a") }], handler: "h1" }, + // shape 3: wildcard id + { name: "s3", salience: 0, conditions: [{ id: null, attr: "B", binding: v("b") }], handler: "h1" }, + // shape 4: multiple conditions + { name: "s4", salience: 0, conditions: [{ id: mkId(1), attr: "X", binding: v("x") }, { id: mkId(1), attr: "Y", binding: v("y") }], handler: "h2" }, + // shape 5: with salience + { name: "s5", salience: 10, conditions: [{ id: null, attr: "C", binding: null }], handler: "h2" }, + // shape 6: with idBinding + { name: "s6", salience: 0, conditions: [{ id: null, attr: "D", binding: v("d"), idBinding: v("eid") }], handler: "h3" }, + // shape 7: with handlerArgs + { name: "s7", salience: 0, conditions: [], handler: "h1", handlerArgs: [1, "two", true] }, + // shape 8: null binding + { name: "s8", salience: 0, conditions: [{ id: mkId(5), attr: "Flag", binding: null }], handler: "h2" }, + // shape 9: high salience + multiple conditions + { name: "s9", salience: 100, conditions: [{ id: mkId(1), attr: "A", binding: v("a") }, { id: null, attr: "B", binding: v("b") }], handler: "h3" }, + // shape 10: empty handlerArgs + { name: "s10", salience: -5, conditions: [], handler: "h3", handlerArgs: [] }, + ] as const; + + for (const shape of shapes) { + it(`round-trip shape: ${shape.name}`, () => { + const json = serialize(shape); + const restored = deserialize(json, registry); + // Re-serialize restored and compare JSON strings + const json2 = serialize(restored); + expect(JSON.stringify(json2)).toBe(JSON.stringify(json)); + }); + } +}); + +describe("RULE_SCHEMA_V1", () => { + it("validates a valid rule JSON", () => { + const valid = { name: "x", salience: 0, conditions: [], handler: "h" }; + const result = RULE_SCHEMA_V1.safeParse(valid); + expect(result.success).toBe(true); + }); + + it("rejects a rule with missing name", () => { + const bad = { salience: 0, conditions: [], handler: "h" }; + const result = RULE_SCHEMA_V1.safeParse(bad); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/rete/src/serialize.ts b/packages/rete/src/serialize.ts new file mode 100644 index 0000000..7a9fcf7 --- /dev/null +++ b/packages/rete/src/serialize.ts @@ -0,0 +1,152 @@ +/** + * JSON serialization for RuleDefinitions. + * + * Per SPEC.md §JSON Rule Schema — handler-registry pattern, no eval, no + * function-to-string conversion, no arbitrary JavaScript embedded in JSON. + * All executable behaviour is referenced by name and resolved against a + * {@link HandlerRegistry} at deserialisation time. + * + * v1 schema (RULE_SCHEMA_V1) covers ordinary positive (alpha) conditions. + * Phase 2 node types (negation, existential, ncc, aggregation) and filter + * predicates are intentionally omitted from v1 and will be added in a + * future schema version without back-compat to v0. + */ +import { z } from "zod"; +import { HandlerRegistry } from "./registry.js"; +import { + v, + type RuleCondition, + type RuleDefinition, +} from "./builder.js"; + +/** + * Zod schema for a single serialized condition. + * + * - `id`: either a literal entity id (number — EntityId's brand exists only in + * the type system) or `null` for wildcard. + * - `attr`: the attribute key string. + * - `binding`: the variable name to bind the value to, or `null` if no binding. + * - `idBinding`: optional variable name to bind the entity id to. + */ +const ConditionSchema = z.object({ + id: z.union([z.number(), z.null()]), + attr: z.string(), + binding: z.string().nullable(), + idBinding: z.string().optional(), +}); + +/** + * Zod schema for the full serialized rule — v1. + * + * Exported for external validation pipelines (e.g. network ingestion before + * deserialisation, admin tooling) per SPEC.md §JSON Rule Schema. + */ +export const RULE_SCHEMA_V1 = z.object({ + name: z.string().min(1), + salience: z.number().default(0), + conditions: z.array(ConditionSchema).default([]), + handler: z.string().min(1), + handlerArgs: z.array(z.unknown()).optional(), +}); + +/** Inferred static type of a serialized rule. */ +export type SerializedRule = z.infer; + +/** + * Loose input shape accepted by {@link serialize}. + * + * Allows `undefined` on optional fields to ease call-site ergonomics under + * `exactOptionalPropertyTypes: true`. At runtime, fields that are absent or + * explicitly `undefined` are normalised to schema defaults or omitted. + */ +export interface SerializableRule { + readonly name: string; + readonly handler: string; + readonly salience?: number | undefined; + readonly conditions?: + | ReadonlyArray<{ + readonly id: RuleCondition["id"]; + readonly attr: string; + readonly binding: RuleCondition["binding"]; + readonly idBinding?: RuleCondition["idBinding"] | undefined; + }> + | undefined; + readonly handlerArgs?: readonly unknown[] | undefined; +} + +/** + * Serialize a RuleDefinition to a plain, JSON-compatible object. + * + * The result contains only strings, numbers, booleans, null, arrays, and + * plain objects — no function references. Passing the return value to + * `JSON.stringify` is guaranteed safe and lossless for the v1 schema. + */ +export function serialize(rule: SerializableRule): SerializedRule { + const conditions = (rule.conditions ?? []).map((c) => { + const base: SerializedRule["conditions"][number] = { + id: c.id, + attr: c.attr, + binding: c.binding?.name ?? null, + }; + // Under exactOptionalPropertyTypes, only include idBinding when defined. + return c.idBinding !== undefined + ? { ...base, idBinding: c.idBinding.name } + : base; + }); + + const out: SerializedRule = { + name: rule.name, + salience: rule.salience ?? 0, + conditions, + handler: rule.handler, + }; + if (rule.handlerArgs !== undefined) { + out.handlerArgs = [...rule.handlerArgs]; + } + return out; +} + +/** + * Deserialize a JSON object back into a RuleDefinition. + * + * Validates the input against {@link RULE_SCHEMA_V1} and then resolves the + * handler name against `registry`. Throws: + * - `z.ZodError` on malformed input (missing fields, wrong types). + * - `UnknownHandlerError` when `handler` is not registered. + * + * The resulting RuleDefinition is structurally identical to one produced by + * {@link defineRule} — round-tripping via `serialize(deserialize(x))` yields a + * value byte-identical to the original JSON under `JSON.stringify`. + */ +export function deserialize( + json: unknown, + registry: HandlerRegistry +): RuleDefinition { + const parsed = RULE_SCHEMA_V1.parse(json); + + // Registry resolution — per SPEC.md §JSON Rule Schema, handler names MUST + // resolve or we throw UnknownHandlerError. + registry.verify([parsed.handler]); + + const conditions: RuleCondition[] = parsed.conditions.map((c) => { + const base: RuleCondition = { + id: c.id as RuleCondition["id"], + attr: c.attr, + binding: c.binding !== null ? v(c.binding) : null, + }; + return c.idBinding !== undefined + ? { ...base, idBinding: v(c.idBinding) } + : base; + }); + + const out: RuleDefinition = { + name: parsed.name, + salience: parsed.salience, + conditions, + handler: parsed.handler, + }; + if (parsed.handlerArgs !== undefined) { + return { ...out, handlerArgs: parsed.handlerArgs as readonly unknown[] }; + } + return out; +}