feat(rete): add JSON serialize/deserialize round-trip (P1.6)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:44:14 -06:00
commit c4b1eb0a61
No known key found for this signature in database
4 changed files with 301 additions and 0 deletions

View file

@ -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"
}

View file

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

View file

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

View file

@ -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<typeof RULE_SCHEMA_V1>;
/**
* 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;
}