houserules/packages/rete/src/serialize.ts

152 lines
4.9 KiB
TypeScript

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