/** * @factory/database-engine/formula — the fixed function table (TASK-0084 R3). * * Closed set — a name not here is UNKNOWN_FUNCTION at parse time, never * resolved against globalThis or any host object. Record the documented * decisions here: * - sum([]) = 0 (explicit list → identity), average([]) = null, * min/max([]) = null (the asymmetry is asserted in goldens). * - replace = literal (not regex), all occurrences. * - contains = case-sensitive (single function, no CI variant). * - length: string → length, array → length, null → 0, anything else → TYPE. * - concat stringify rules: numbers as-is, dates as ISO, null → "", arrays * comma-joined. * - dateAdd month/year overflow clamps to month-end; negative amounts ok. */ import type { EValue, FormulaType, FormulaContext } from "./types.js"; import { FormulaError } from "./types.js"; import { addCalendarUnit, daysBetween, DAY_MS } from "../values/dates.js"; /** Mutable step budget shared by the evaluator and function impls. */ export interface Budget { steps: number; charge(n: number): void; } export interface FunctionDef { arity: [min: number, max: number]; /** Static return type given inferred argument types. */ returns: (argTypes: FormulaType[]) => FormulaType; impl: (args: EValue[], ctx: FormulaContext, budget: Budget) => EValue; } const err = (code: "TYPE" | "RUNTIME", message: string): never => { throw new FormulaError(code, message); }; function asBool(v: EValue, what: string): boolean { if (v.t !== "bool") err("TYPE", `${what} must be a bool, got ${v.t}`); return v.v as boolean; } function asNumber(v: EValue, what: string): number { if (v.t !== "number") err("TYPE", `${what} must be a number, got ${v.t}`); return v.v as number; } function asDate(v: EValue, what: string): string { if (v.t !== "date") err("TYPE", `${what} must be a date, got ${v.t}`); return v.v as string; } function asString(v: EValue, what: string): string { if (v.t !== "string") err("TYPE", `${what} must be a string, got ${v.t}`); return v.v as string; } function asArray(v: EValue, what: string): EValue[] { if (v.t !== "array") err("TYPE", `${what} must be an array, got ${v.t}`); return v.v as EValue[]; } function numDateKey(v: EValue): number { if (v.t === "number") return v.v as number; const d = v.v as string; return new Date(d.length === 10 ? d + "T00:00:00Z" : d).getTime(); } /** Concatenation stringify rules (recorded). */ export function stringify(v: EValue): string { switch (v.t) { case "null": return ""; case "array": return (v.v as EValue[]).map(stringify).join(","); default: return String(v.v); } } function numList(args: EValue[], name: string): number[] { const arr = asArray(args[0]!, `${name}(list)`); const out: number[] = []; for (const x of arr) { if (x.t === "null") continue; // nulls excluded from numeric aggregates out.push(asNumber(x, `${name}(…)`)); } return out; } export const FUNCTIONS: Record = { if: { arity: [3, 3], returns: (ts) => ts[1]!, impl: (args, _ctx, budget) => { const cond = asBool(args[0]!, "if(cond)"); budget.charge(1); return cond ? args[1]! : args[2]!; // laziness: only the taken branch is evaluated }, }, and: { arity: [2, Infinity], returns: () => "bool", impl: (args) => { for (const a of args) if (!asBool(a, "and(…)")) return { v: false, t: "bool" }; return { v: true, t: "bool" }; }, }, or: { arity: [2, Infinity], returns: () => "bool", impl: (args) => { for (const a of args) if (asBool(a, "or(…)")) return { v: true, t: "bool" }; return { v: false, t: "bool" }; }, }, not: { arity: [1, 1], returns: () => "bool", // null propagates (not(null) = null), per the null semantics matrix impl: (args) => { const a = args[0]!; if (a.t === "null") return { v: null, t: "null" }; return { v: !asBool(a, "not(x)"), t: "bool" }; }, }, sum: { arity: [1, 1], returns: () => "number", impl: (args, _ctx, budget) => { const nums = numList(args, "sum"); budget.charge(nums.length); return { v: nums.reduce((a, b) => a + b, 0), t: "number" }; // sum([]) = 0 }, }, average: { arity: [1, 1], returns: () => "number", impl: (args, _ctx, budget) => { const nums = numList(args, "average"); budget.charge(nums.length); if (nums.length === 0) return { v: null, t: "null" }; // average([]) = null return { v: nums.reduce((a, b) => a + b, 0) / nums.length, t: "number" }; }, }, min: { arity: [1, 1], returns: (ts) => (ts[0] === "date" ? "date" : "number"), impl: (args) => minMax(args, false), }, max: { arity: [1, 1], returns: (ts) => (ts[0] === "date" ? "date" : "number"), impl: (args) => minMax(args, true), }, dateAdd: { arity: [3, 3], returns: () => "date", impl: (args) => { const date = asDate(args[0]!, "dateAdd(date)"); const amount = asNumber(args[1]!, "dateAdd(amount)"); const unit = asString(args[2]!, "dateAdd(unit)"); if (!["days", "weeks", "months", "years"].includes(unit)) err("TYPE", `dateAdd unit must be days|weeks|months|years, got "${unit}"`); return { v: addCalendarUnit(date, amount, unit as "days" | "weeks" | "months" | "years"), t: "date", }; }, }, dateBetween: { arity: [2, 2], returns: () => "number", impl: (args) => { const a = asDate(args[0]!, "dateBetween(a)"); const b = asDate(args[1]!, "dateBetween(b)"); return { v: daysBetween(a, b), t: "number" }; }, }, now: { arity: [0, 0], returns: () => "date", impl: (_args, ctx) => ({ v: new Date(ctx.now).toISOString().slice(0, 10), t: "date" }), }, concat: { arity: [2, Infinity], returns: () => "string", impl: (args, _ctx, budget) => { budget.charge(args.length); return { v: args.map(stringify).join(""), t: "string" }; }, }, replace: { arity: [3, 3], returns: () => "string", impl: (args) => { const s = asString(args[0]!, "replace(s)"); const from = asString(args[1]!, "replace(from)"); const to = asString(args[2]!, "replace(to)"); // literal, all occurrences (not regex — recorded) if (from === "") return { v: s, t: "string" }; return { v: s.split(from).join(to), t: "string" }; }, }, contains: { arity: [2, 2], returns: () => "bool", impl: (args) => { const hay = asString(args[0]!, "contains(hay)"); const needle = asString(args[1]!, "contains(needle)"); return { v: hay.includes(needle), t: "bool" }; // case-sensitive }, }, length: { arity: [1, 1], returns: () => "number", impl: (args): EValue => { const v = args[0]!; if (v.t === "null") return { v: 0, t: "number" }; // null → 0 if (v.t === "string") return { v: (v.v as string).length, t: "number" }; if (v.t === "array") return { v: (v.v as EValue[]).length, t: "number" }; return err("TYPE", `length() accepts a string or array, got ${v.t}`); }, }, /** Special: parsed to a Prop node; the impl is the property lookup. */ prop: { arity: [1, 1], returns: (ts) => ts[0]!, impl: (args, ctx) => { const name = asString(args[0]!, "prop(name)"); const v = ctx.properties[name]; if (v === undefined) { throw new FormulaError("TYPE", unknownPropertyMessage(name, ctx), undefined, name); } return normalizeProperty(v, ctx, name); }, }, }; function minMax(args: EValue[], isMax: boolean): EValue { const name = isMax ? "max" : "min"; const arr = asArray(args[0]!, `${name}(list)`); const typed = arr.filter((x) => x.t === "number" || x.t === "date"); if (typed.length === 0) return { v: null, t: "null" }; // min/max([]) = null let hasNumber = false; let hasDate = false; for (const x of typed) { if (x.t === "number") hasNumber = true; else hasDate = true; } if (hasNumber && hasDate) err("TYPE", `${name}() list mixes numbers and dates`); let best = typed[0]!; for (const x of typed) { const better = isMax ? numDateKey(x) > numDateKey(best) : numDateKey(x) < numDateKey(best); if (better) best = x; } return best; } /** Suggest the closest known property for unknown-name errors (Levenshtein ≤ 2). */ export function unknownPropertyMessage(name: string, ctx: FormulaContext): string { const keys = Object.keys(ctx.properties); let best: string | null = null; let bestDist = 3; for (const k of keys) { const d = levenshtein(name, k); if (d < bestDist) { bestDist = d; best = k; } } return best ? `Unknown property '${name}'. Did you mean '${best}'?` : `Unknown property '${name}'. Available: ${keys.join(", ") || "(none)"}`; } function levenshtein(a: string, b: string): number { if (Math.abs(a.length - b.length) > 2) return 3; const dp: number[][] = []; for (let i = 0; i <= a.length; i++) { dp[i] = [i]; } for (let j = 0; j <= b.length; j++) dp[0]![j] = j; for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost); } } return dp[a.length]![b.length]!; } /** The names the parser accepts as function calls (the closed table). */ export const FUNCTION_NAMES: ReadonlySet = new Set(Object.keys(FUNCTIONS)); /** Normalize a raw property value into a typed EValue. */ export function normalizeProperty(v: unknown, ctx: FormulaContext, name: string): EValue { const propType = ctx.propertyTypes?.[name]; if (v === null || v === undefined) return { v: null, t: "null" }; if (typeof v === "number") return { v, t: "number" }; if (typeof v === "boolean") return { v, t: "bool" }; if (typeof v === "string") { if (propType === "date") return { v, t: "date" }; if (propType === "date_range") return { v: v, t: "date" }; // use as start (documented) return { v, t: "string" }; } if (Array.isArray(v)) { return { v: v.map((x): EValue => x === null || x === undefined ? { v: null, t: "null" } : { v: String(x), t: "string" }, ), t: "array", }; } if (typeof v === "object") { const o = v as Record; if (typeof o["start"] === "string") return { v: o["start"], t: "date" }; // date_range object → start } return err("TYPE", `property '${name}' has an unsupported value type`); } export { DAY_MS };