152 lines
4.9 KiB
TypeScript
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;
|
|
}
|