feat(rete): add JSON serialize/deserialize round-trip (P1.6)
This commit is contained in:
parent
f9b17346c3
commit
c4b1eb0a61
4 changed files with 301 additions and 0 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
143
packages/rete/src/serialize.test.ts
Normal file
143
packages/rete/src/serialize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
packages/rete/src/serialize.ts
Normal file
152
packages/rete/src/serialize.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue