/** * @factory/database-engine/formula — static type inference + dependency * extraction (TASK-0084 R6). * * `inferType(ast, properties)` → the static return type (stored in * `formulas.returnType`). Uninferable (divergent `if` branches that can't * unify) → a TYPE FormulaError at save time (never stored). `usesNow` is a * separate boolean (header note 3: `now()` re-evaluates on each read). * `extractDependencies` → sorted, deduped property names (the recompute * worker's input — it uses this, not string matching). */ import type { AstNode, FormulaContext, FormulaType } from "./types.js"; import { FormulaError } from "./types.js"; import { FUNCTIONS } from "./functions.js"; function typeOfProperty(name: string, ctx: FormulaContext): FormulaType { const pt = ctx.propertyTypes?.[name]; const v = ctx.properties[name]; if (v === undefined) throw new FormulaError("TYPE", `unknown property '${name}'`, undefined, name); if (pt === "number" || pt === "created_time" || pt === "last_edited_time" || pt === "unique_id") return "number"; if (pt === "date") return "date"; if (pt === "checkbox") return "bool"; if ( pt === "select" || pt === "status" || pt === "text" || pt === "rich_text" || pt === "title" || pt === "email" || pt === "url" || pt === "created_by" || pt === "last_edited_by" ) return "string"; if (pt === "date_range") return "date"; if (pt === "multi_select" || pt === "person" || pt === "relation" || pt === "files") return "array"; if (typeof v === "number") return "number"; if (typeof v === "boolean") return "bool"; if (typeof v === "string") return "string"; if (Array.isArray(v)) return "array"; return "unknown"; } /** Static type inference. Throws TYPE when it cannot infer (save-time 400). */ export function inferType(ast: AstNode, ctx: FormulaContext): FormulaType { switch (ast.type) { case "Literal": return ast.literalType === "number" ? "number" : ast.literalType === "string" ? "string" : ast.literalType === "date" ? "date" : "bool"; case "NullLiteral": return "null"; case "ArrayLiteral": return "array"; case "Prop": return typeOfProperty(ast.name, ctx); case "Call": { if (ast.name === "prop") { const a = ast.args[0]; if (!a || a.type !== "Literal" || a.literalType !== "string") { throw new FormulaError("TYPE", "prop() requires a string literal"); } return typeOfProperty(a.value as string, ctx); } const fn = FUNCTIONS[ast.name]; if (!fn) throw new FormulaError("UNKNOWN_FUNCTION", `unknown function "${ast.name}"`); const [min, max] = fn.arity; if (ast.args.length < min || (Number.isFinite(max) && ast.args.length > max)) { throw new FormulaError("TYPE", `${ast.name}() bad arity`); } const argTypes = ast.args.map((a) => inferType(a, ctx)); return fn.returns(argTypes); } case "UnaryOp": { const t = inferType(ast.operand, ctx); if (ast.op === "-") { if (t !== "number" && t !== "null") throw new FormulaError("TYPE", `-x requires a number`); return "number"; } if (t !== "bool" && t !== "null") throw new FormulaError("TYPE", `not requires a bool`); return "bool"; } case "BinaryOp": { const l = inferType(ast.left, ctx); const r = inferType(ast.right, ctx); const op = ast.op; if (op === "and" || op === "or") { if (l !== "bool" && l !== "null") throw new FormulaError("TYPE", `${op} requires bool operands`); if (r !== "bool" && r !== "null") throw new FormulaError("TYPE", `${op} requires bool operands`); return "bool"; } if (op === "==" || op === "!=") { if (l !== "null" && r !== "null" && l !== r) throw new FormulaError("TYPE", `cannot compare ${l} with ${r}`); return "bool"; } if (op === "<" || op === "<=" || op === ">" || op === ">=") { if (l !== "null" && r !== "null" && l !== r) throw new FormulaError("TYPE", `cannot compare ${l} with ${r}`); if (l !== "number" && l !== "string" && l !== "date") throw new FormulaError("TYPE", `ordering requires number/string/date`); return "bool"; } // arithmetic if (op === "+" && (l === "date" || r === "date")) throw new FormulaError("TYPE", "date arithmetic requires dateAdd"); if (l === "null" || r === "null") return "null"; if (l !== "number" || r !== "number") throw new FormulaError("TYPE", `arithmetic requires number operands, got ${l} and ${r}`); return "number"; } case "If": { const c = inferType(ast.cond, ctx); if (c !== "bool" && c !== "null") throw new FormulaError("TYPE", `if(cond) requires a bool`); const t = inferType(ast.then, ctx); const e = inferType(ast.else, ctx); if (t !== "null" && e !== "null" && t !== e) { throw new FormulaError("TYPE", `if branches diverge: ${t} vs ${e}`); } return t === "null" ? e : e === "null" ? t : t; } } } /** Sorted, deduped list of property names referenced by the AST. */ export function extractDependencies(ast: AstNode): string[] { const set = new Set(); visit(ast, (n) => { if (n.type === "Prop") set.add(n.name); }); return [...set].sort(); } /** True when the AST references `now()`. */ export function usesNow(ast: AstNode): boolean { let found = false; visit(ast, (n) => { if (n.type === "Call" && n.name === "now") found = true; }); return found; } function propDependency(node: Extract): string | null { if (node.name !== "prop") return null; const a = node.args[0]; return a && a.type === "Literal" && a.literalType === "string" ? (a.value as string) : null; } function visit(node: AstNode, fn: (n: AstNode) => void): void { fn(node); const dep = node.type === "Call" ? propDependency(node) : null; if (dep !== null) fn({ type: "Prop", name: dep }); switch (node.type) { case "Call": for (const a of node.args) visit(a, fn); break; case "BinaryOp": visit(node.left, fn); visit(node.right, fn); break; case "UnaryOp": visit(node.operand, fn); break; case "If": visit(node.cond, fn); visit(node.then, fn); visit(node.else, fn); break; default: break; } }