/** * apps/worker — automation condition evaluator (TASK-0111). * * A stored AND/OR/NOT expression tree (shared `CONDITION_EXPRESSION_SCHEMA`, * validated by `validateConditionTree`) is evaluated as a PURE function * `(expr, ctx) => decision` over: * - `event`: the triggerEvent object (dot-path ≤ 3 into it), and * - `property`: the CURRENT, already-normalized property values of the * triggering record (post-change state). * * The context is built by {@link buildConditionContext} (≤ 1 extra query). * Evaluation must be deterministic (same ctx → same result), must not touch * the network, and is bounded to a 50 ms wall-clock budget (over → * CONDITION_TIMEOUT, the run fails rather than hanging). * * Fail-closed rules (the "true by default is impossible" invariant): * - a corrupt / over-complex stored tree → CONDITION_INVALID (the run * fails; it is never treated as "true"), * - a `property.*` predicate with no record (or an unknown property name) * → false (logged), with the single exception that `is_empty` is true * for an absent value, * - a type mismatch for an ordering operator (date vs string, …) → false * (warned, never throws), * - the only "true with no evaluation" case is an explicit empty condition * (the API stores no AutomationCondition row → the processor skips * evaluation entirely and treats the gate as true). */ import type { PrismaClient } from "@prisma/client"; import { normalizeConditionValue, validateConditionTree } from "@factory/shared"; /** Raised when a stored tree is corrupt/over-complex or evaluation times out. */ export class ConditionError extends Error { constructor( public readonly code: "CONDITION_INVALID" | "CONDITION_TIMEOUT", message: string ) { super(message); this.name = "ConditionError"; } } /** The 50 ms evaluation budget (spec TASK-0111 §5). */ export const CONDITION_EVAL_BUDGET_MS = 50; /** * Evaluation context. `record` is a map of property NAME → already-normalized * value (SELECT/STATUS → option name; MULTI_SELECT → sorted option-name array; * NUMBER → number; DATE → ISO string; CHECKBOX → boolean; …) plus `id`. * `null` means no record is available for this trigger (e.g. PAGE_CREATED). */ export interface ConditionContext { event: Record; record: Record | null; page: Record | null; } export interface ConditionDecision { result: boolean; evaluatedNodes: number; ms: number; warnings: string[]; } // --------------------------------------------------------------------------- // Context construction // --------------------------------------------------------------------------- /** * Unwrap a Phase-5 stored value envelope ({text}, {value}, {optionId}, * {optionIds}, {userIds}, {start,end}, {fileIds}) to its bare value. * Non-envelope values pass through. */ export function unwrapEnvelope(type: string, v: unknown): unknown { if (v === null || v === undefined) return null; if (typeof v !== "object" || Array.isArray(v)) return v; const o = v as Record; switch (type) { case "TEXT": case "RICH_TEXT": case "TITLE": return typeof o.text === "string" ? o.text : v; case "NUMBER": case "EMAIL": case "PHONE": case "CHECKBOX": return "value" in o ? o.value : v; case "SELECT": case "STATUS": return typeof o.optionId === "string" ? o.optionId : v; case "MULTI_SELECT": return Array.isArray(o.optionIds) ? o.optionIds : v; case "PERSON": return Array.isArray(o.userIds) ? o.userIds : v; case "DATE": case "DATE_RANGE": if (typeof o.start === "string") return o.end ? `${o.start}..${o.end}` : o.start; return v; case "URL": return typeof o.value === "string" ? o.value : v; case "FILES": return Array.isArray(o.fileIds) ? o.fileIds : v; default: return v; } } /** * Build the evaluation context. Loads the record (if `recordId` given) and * normalizes its property values (envelope → bare → type-normalized) so the * pure evaluator can compare them. One query (record + its properties). */ export async function buildConditionContext( prisma: PrismaClient, triggerEvent: Record, recordId?: string | null ): Promise { let record: Record | null = null; let page: Record | null = null; if (recordId) { const row = await prisma.databaseRecord.findUnique({ where: { id: recordId }, select: { id: true, page: { select: { id: true, title: true, workspaceId: true } }, properties: { select: { value: true, property: { select: { name: true, type: true, config: true, isTitle: true } } } } } }); if (row) { const flat: Record = { id: row.id }; for (const rp of row.properties) { const unwrapped = unwrapEnvelope(rp.property.type as string, rp.value); flat[rp.property.name] = normalizeConditionValue( rp.property.type as string, rp.property.config as Record | null, unwrapped ); } record = flat; if (row.page) page = row.page; } } else if (typeof triggerEvent.pageId === "string") { const p = await prisma.page.findUnique({ where: { id: triggerEvent.pageId }, select: { id: true, title: true, workspaceId: true } }); if (p) page = p; } return { event: triggerEvent, record, page }; } // --------------------------------------------------------------------------- // Value helpers (pure) // --------------------------------------------------------------------------- const isArr = (v: unknown): v is unknown[] => Array.isArray(v); function toNum(v: unknown): number | null { if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v); return null; } function toTs(v: unknown): number | null { if (typeof v === "string" && v.includes("..") === false) { const t = Date.parse(v); if (!Number.isNaN(t)) return t; } if (v instanceof Date) return v.getTime(); return null; } function setEq(a: unknown[], b: unknown[]): boolean { if (a.length !== b.length) return false; const key = (x: unknown) => (typeof x === "object" ? JSON.stringify(x) : String(x)); const as = a.map(key).sort(); const bs = b.map(key).sort(); return as.every((v, i) => v === bs[i]); } /** Normalized equality (arrays → set-equality; numbers compared numerically). */ function normEq(a: unknown, b: unknown): boolean { const aN = a === undefined ? null : a; const bN = b === undefined ? null : b; if (aN === null && bN === null) return true; if (aN === null || bN === null) return false; if (isArr(aN) && isArr(bN)) return setEq(aN, bN); if (isArr(aN) !== isArr(bN)) return false; if (typeof aN === "number" || typeof bN === "number") { const an = toNum(aN); const bn = toNum(bN); return an !== null && bn !== null && an === bn; } if (typeof aN === "boolean" || typeof bN === "boolean") return aN === bN; if (typeof aN === "string" && typeof bN === "string") return aN === bN; if (typeof aN === "object" && typeof bN === "object") return JSON.stringify(aN) === JSON.stringify(bN); return false; } /** Ordering compare (numbers or ISO dates); null when incomparable. */ function cmp(a: unknown, b: unknown): number | null { const da = toTs(a); const db = toTs(b); if (da !== null && db !== null) return da - db; const an = toNum(a); const bn = toNum(b); if (an !== null && bn !== null) return an - bn; return null; } function isEmptyValue(v: unknown): boolean { if (v === undefined || v === null) return true; if (typeof v === "string") return v.length === 0; if (Array.isArray(v)) return v.length === 0; if (typeof v === "object") return Object.keys(v as object).length === 0; return false; } function ciStartsWith(actual: string, value: string): boolean { return actual.toLowerCase().startsWith(value.toLowerCase()); } function ciEndsWith(actual: string, value: string): boolean { return actual.toLowerCase().endsWith(value.toLowerCase()); } function ciIncludes(actual: string, value: string): boolean { return actual.toLowerCase().includes(value.toLowerCase()); } // --------------------------------------------------------------------------- // Field resolution // --------------------------------------------------------------------------- /** Dot-path (≤ 3 segments) into a nested object. */ function resolveDot(obj: unknown, path: string): unknown { let cur: unknown = obj; for (const part of path.split(".").slice(0, 3)) { if (cur === null || cur === undefined || typeof cur !== "object") return undefined; cur = (cur as Record)[part]; } return cur; } function readField( field: { ref: string; name: string }, ctx: ConditionContext, warnings: string[] ): { value: unknown; present: boolean } { if (field.ref === "event") { const v = resolveDot(ctx.event, field.name); return { value: v, present: v !== undefined }; } // property.* if (!ctx.record) { warnings.push(`property "${field.name}": no record available (fail closed)`); return { value: undefined, present: false }; } if (!(field.name in ctx.record)) { warnings.push(`property "${field.name}": unknown property (fail closed)`); return { value: undefined, present: false }; } const v = ctx.record[field.name]; return { value: v, present: v !== undefined }; } // --------------------------------------------------------------------------- // Predicate + tree evaluation // --------------------------------------------------------------------------- interface PredicateNode { field: { ref: string; name: string }; operator: string; value?: unknown; } function isPredicate(node: Record | PredicateNode): node is PredicateNode { const f = node.field; return ( typeof f === "object" && f !== null && (f as { ref?: unknown }).ref !== undefined && typeof node.operator === "string" ); } function evalPredicate(pred: PredicateNode, ctx: ConditionContext, warnings: string[]): boolean { const { value: actual, present } = readField(pred.field, ctx, warnings); const op = pred.operator; // is_empty / is_not_empty are meaningful for absent values. if (op === "is_empty") return isEmptyValue(actual); if (op === "is_not_empty") return !isEmptyValue(actual); // Fail closed: a missing field is false for every other operator. if (!present || actual === undefined) { warnings.push(`predicate ${pred.field.ref}.${pred.field.name} ${op}: field absent (fail closed)`); return false; } const v = pred.value; switch (op) { case "eq": return normEq(actual, v); case "neq": return !normEq(actual, v); case "contains": if (typeof actual === "string" && typeof v === "string") return ciIncludes(actual, v); if (isArr(actual)) return (actual as unknown[]).some((x) => normEq(x, v)); warnings.push(`contains on non-string/non-array actual`); return false; case "not_contains": if (typeof actual === "string" && typeof v === "string") return !ciIncludes(actual, v); if (isArr(actual)) return !(actual as unknown[]).some((x) => normEq(x, v)); warnings.push(`not_contains on non-string/non-array actual`); return false; case "starts_with": if (typeof actual === "string" && typeof v === "string") return ciStartsWith(actual, v); warnings.push(`starts_with on non-string actual`); return false; case "ends_with": if (typeof actual === "string" && typeof v === "string") return ciEndsWith(actual, v); warnings.push(`ends_with on non-string actual`); return false; case "in": { const list = isArr(v) ? (v as unknown[]) : [v]; if (isArr(actual)) return (actual as unknown[]).some((x) => list.some((y) => normEq(x, y))); return list.some((y) => normEq(actual, y)); } case "not_in": { const list = isArr(v) ? (v as unknown[]) : [v]; if (isArr(actual)) return !(actual as unknown[]).some((x) => list.some((y) => normEq(x, y))); return !list.some((y) => normEq(actual, y)); } case "gt": case "gte": case "lt": case "lte": { const c = cmp(actual, v); if (c === null) { warnings.push(`${op}: incomparable ${String(typeof actual)} vs ${String(typeof v)} (fail closed)`); return false; } if (op === "gt") return c > 0; if (op === "gte") return c >= 0; if (op === "lt") return c < 0; return c <= 0; } default: warnings.push(`unknown operator ${op}`); return false; } } interface EvalState { nodes: number; warnings: string[]; } function walk(node: unknown, ctx: ConditionContext, st: EvalState): boolean { st.nodes += 1; if (node === null || typeof node !== "object") { st.warnings.push("malformed condition node (not an object)"); return false; } const n = node as Record; if (n.op === "AND" || n.op === "OR" || n.op === "NOT") { if (n.op === "NOT") { return !walk(n.child, ctx, st); } const children = Array.isArray(n.children) ? (n.children as unknown[]) : []; if (n.op === "AND") return children.every((c) => walk(c, ctx, st)); return children.length > 0 && children.some((c) => walk(c, ctx, st)); } if (isPredicate(n)) return evalPredicate(n, ctx, st.warnings); st.warnings.push(`unrecognized condition node`); return false; } /** * Evaluate a stored condition tree. * - null/undefined tree → { result: true, evaluatedNodes: 0 } (the empty * "always true" case; the processor also skips calling this at all), * - corrupt / over-complex tree → throws ConditionError("CONDITION_INVALID"), * - evaluation over the 50 ms budget → throws ConditionError("CONDITION_TIMEOUT"). */ export function evaluateCondition( tree: unknown, ctx: ConditionContext, now: () => number = () => Date.now() ): ConditionDecision { if (tree === null || tree === undefined) { return { result: true, evaluatedNodes: 0, ms: 0, warnings: [] }; } const check = validateConditionTree(tree); if (!check.ok) { throw new ConditionError( "CONDITION_INVALID", check.message ?? `corrupt condition tree (${check.code})` ); } const t0 = now(); const st: EvalState = { nodes: 0, warnings: [] }; const result = walk(tree, ctx, st); const ms = Math.max(0, now() - t0); if (ms > CONDITION_EVAL_BUDGET_MS) { throw new ConditionError( "CONDITION_TIMEOUT", `condition evaluation exceeded ${CONDITION_EVAL_BUDGET_MS} ms (${ms} ms, ${st.nodes} nodes)` ); } return { result, evaluatedNodes: st.nodes, ms, warnings: st.warnings }; }