feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
+33
View File
@@ -0,0 +1,33 @@
export type OpCode =
| "+" | "-" | "*" | "/" | "%"
| "==" | "!=" | ">" | "<" | ">=" | "<="
| "neg" | "not";
export type FormulaAST =
| { t: "num"; v: number }
| { t: "str"; v: string }
| { t: "bool"; v: boolean }
| { t: "null" }
| { t: "prop"; id: string }
| { t: "op"; op: OpCode; args: FormulaAST[] }
| { t: "if"; cond: FormulaAST; then: FormulaAST; else: FormulaAST }
| { t: "and"; args: FormulaAST[] }
| { t: "or"; args: FormulaAST[] }
| { t: "call"; fn: string; args: FormulaAST[] };
/*
* Raw AST: what the parser produces before resolving property names to IDs.
* Only the `propName` variant differs from FormulaAST — every other node is
* reused directly. We deliberately keep this type-level to avoid duplicating
* the tree shape.
*/
export type RawFormulaAST =
| Exclude<FormulaAST, { t: "prop" }>
| { t: "propName"; name: string }
| { t: "op"; op: OpCode; args: RawFormulaAST[] }
| { t: "if"; cond: RawFormulaAST; then: RawFormulaAST; else: RawFormulaAST }
| { t: "and"; args: RawFormulaAST[] }
| { t: "or"; args: RawFormulaAST[] }
| { t: "call"; fn: string; args: RawFormulaAST[] };
export const AST_VERSION = 1 as const;
+41
View File
@@ -0,0 +1,41 @@
import type { ErrorCell, ErrorCode } from "./types";
export type ParseErrorCode =
| "UNEXPECTED_TOKEN"
| "UNEXPECTED_EOF"
| "UNKNOWN_PROPERTY"
| "UNKNOWN_FUNCTION"
| "ARITY_MISMATCH"
| "TYPE_MISMATCH"
| "CYCLE"
| "INPUT_TOO_LONG"
| "DEPTH_EXCEEDED";
export type ParseError = {
code: ParseErrorCode;
message: string;
span: { start: number; end: number };
hint?: string;
};
export class FormulaParseError extends Error {
readonly errors: ParseError[];
constructor(errors: ParseError[]) {
super(errors.map((e) => `${e.code}: ${e.message}`).join("; "));
this.errors = errors;
this.name = "FormulaParseError";
}
}
export function makeErrorCell(code: ErrorCode, msg: string): ErrorCell {
return { __err: code, msg, v: 1 };
}
export function isErrorCell(v: unknown): v is ErrorCell {
return (
typeof v === "object" &&
v !== null &&
"__err" in v &&
typeof (v as { __err: unknown }).__err === "string"
);
}
+125
View File
@@ -0,0 +1,125 @@
import { makeErrorCell, isErrorCell } from "./error";
import { MAX_EVAL_DEPTH } from "./types";
import type { FormulaAST, OpCode } from "./ast";
import type { Value, EvalContext } from "./types";
export function evaluate(
ast: FormulaAST,
row: Record<string, unknown>,
ctx: EvalContext,
astDepth = 0,
): Value {
// astDepth bounds AST tree-walk recursion (guards a hand-crafted deep
// typeOptions.ast); ctx.depth separately bounds nested-formula hops.
const depth = astDepth + 1;
if (depth > MAX_EVAL_DEPTH) {
return makeErrorCell("DEPTH_EXCEEDED", `formula too deeply nested (max ${MAX_EVAL_DEPTH})`);
}
switch (ast.t) {
case "num": return ast.v;
case "str": return ast.v;
case "bool": return ast.v;
case "null": return null;
case "prop": return evalProp(ast.id, row, ctx, depth);
case "op": return evalOp(ast.op, ast.args, row, ctx, depth);
case "if": {
const c = evaluate(ast.cond, row, ctx, depth);
if (isErrorCell(c)) return c;
return evaluate(c === true ? ast.then : ast.else, row, ctx, depth);
}
case "and": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === false) return false;
if (v == null) return null;
}
return true;
}
case "or": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === true) return true;
}
return false;
}
case "call": {
const fn = ctx.registry.get(ast.fn.toLowerCase());
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
const xs = ast.args;
const args: Value[] = new Array(xs.length);
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
args[i] = v;
}
try { return fn.eval(args, ctx); }
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
}
}
}
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext, astDepth: number): Value {
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
const prop = ctx.properties.get(id);
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
if (prop.type !== "formula") return normalize(row[id] ?? null);
// astDepth continues (not reset) across the nested-formula boundary.
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
const opts: any = prop.typeOptions;
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
const v = evaluate(opts.ast, row, nested, astDepth);
ctx.memo.set(id, v);
return v;
}
function normalize(v: unknown): Value {
if (v === undefined) return null;
if (v === null) return null;
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
if (isErrorCell(v)) return v;
return null;
}
function evalOp(
op: OpCode,
args: FormulaAST[],
row: Record<string, unknown>,
ctx: EvalContext,
astDepth: number,
): Value {
const a = evaluate(args[0], row, ctx, astDepth);
if (isErrorCell(a)) return { ...a, __err: "DEPENDENCY_ERROR" };
if (op === "neg") return a == null ? null : -Number(a);
if (op === "not") return a == null ? null : !Boolean(a);
const b = evaluate(args[1], row, ctx, astDepth);
if (isErrorCell(b)) return { ...b, __err: "DEPENDENCY_ERROR" };
switch (op as Exclude<OpCode, "neg" | "not">) {
case "+":
if (typeof a === "string" || typeof b === "string") return (a == null ? "" : String(a)) + (b == null ? "" : String(b));
if (a == null || b == null) return null;
return Number(a) + Number(b);
case "-": return a == null || b == null ? null : Number(a) - Number(b);
case "*": return a == null || b == null ? null : Number(a) * Number(b);
case "/":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return Number(a) / Number(b);
case "%":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return Number(a) % Number(b);
case "==": return a === b;
case "!=": return a !== b;
case ">": return a != null && b != null && (a as any) > (b as any);
case "<": return a != null && b != null && (a as any) < (b as any);
case ">=": return a != null && b != null && (a as any) >= (b as any);
case "<=": return a != null && b != null && (a as any) <= (b as any);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { FormulaAST, OpCode } from "./ast";
const OP_STR: Partial<Record<OpCode, string>> = {
"+": " + ", "-": " - ", "*": " * ", "/": " / ", "%": " % ",
"==": " == ", "!=": " != ", ">": " > ", "<": " < ", ">=": " >= ", "<=": " <= ",
};
export function format(
ast: FormulaAST,
idToName: ReadonlyMap<string, string>,
): string {
switch (ast.t) {
case "num": return String(ast.v);
case "str": return `"${ast.v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
case "bool": return ast.v ? "true" : "false";
case "null": return "null";
case "prop": return `prop("${idToName.get(ast.id) ?? ast.id}")`;
case "op":
if (ast.op === "neg") return `-${format(ast.args[0], idToName)}`;
if (ast.op === "not") return `not ${format(ast.args[0], idToName)}`;
return `(${format(ast.args[0], idToName)}${OP_STR[ast.op]}${format(ast.args[1], idToName)})`;
case "if":
return `if(${format(ast.cond, idToName)}, ${format(ast.then, idToName)}, ${format(ast.else, idToName)})`;
case "and":
return `(${ast.args.map((a) => format(a, idToName)).join(" and ")})`;
case "or":
return `(${ast.args.map((a) => format(a, idToName)).join(" or ")})`;
case "call":
return `${ast.fn}(${ast.args.map((a) => format(a, idToName)).join(", ")})`;
}
}
@@ -0,0 +1,16 @@
import { register } from "./registry";
register({
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
eval: ([v]) => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
doc: "Parses the value as a number, or null.", category: "coercion",
});
register({
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
eval: ([v]) => v == null ? "" : String(v),
doc: "Converts the value to a string.", category: "coercion",
});
@@ -0,0 +1,53 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
const toDate = (v: unknown): Date | null => {
if (v == null) return null;
const d = new Date(String(v));
return isNaN(d.getTime()) ? null : d;
};
register({
name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => new Date().toISOString(),
doc: "Current timestamp.", category: "date",
});
register({
name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => {
const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString();
},
doc: "Midnight UTC of today.", category: "date",
});
register({
name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date",
eval: ([base, amt, unit]) => {
const d = toDate(base);
if (!d) return makeErrorCell("DATE_INVALID", "invalid date");
const n = Number(amt);
const u = String(unit);
const r = new Date(d);
if (u === "days") r.setUTCDate(r.getUTCDate() + n);
else if (u === "hours") r.setUTCHours(r.getUTCHours() + n);
else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n);
else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n);
else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n);
else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
return r.toISOString();
},
doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date",
});
register({
name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number",
eval: ([a, b, unit]) => {
const da = toDate(a), db = toDate(b);
if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date");
const ms = db.getTime() - da.getTime();
const u = String(unit);
if (u === "days") return Math.floor(ms / 86_400_000);
if (u === "hours") return Math.floor(ms / 3_600_000);
if (u === "minutes") return Math.floor(ms / 60_000);
return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
},
doc: "Difference between two dates in a given unit.", category: "date",
});
@@ -0,0 +1,7 @@
import "./logic";
import "./math";
import "./string";
import "./date";
import "./coercion";
export { registry, register } from "./registry";
export type { FormulaFn } from "./registry";
@@ -0,0 +1,11 @@
import { register } from "./registry";
register({
name: "empty",
arity: { min: 1, max: 1 },
paramTypes: "any",
returnType: "boolean",
eval: ([v]) => v == null || v === "" || (typeof v === "object" && v !== null && "__err" in v),
doc: "Returns true if the value is null or empty string or an error.",
category: "logic",
});
+160
View File
@@ -0,0 +1,160 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
import type { Value } from "../types";
const num = (v: unknown): number | null => v == null ? null : Number(v);
register({
name: "round", arity: { min: 1, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([v, places]) => {
const n = num(v);
if (n == null) return null;
const p = places == null ? 0 : Math.trunc(Number(places));
const factor = Math.pow(10, p);
return Math.round(n * factor) / factor;
},
doc: "Rounds to the nearest integer, or to `places` decimals if given.", category: "math",
});
register({
name: "floor", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.floor(n); },
doc: "Rounds down.", category: "math",
});
register({
name: "ceil", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.ceil(n); },
doc: "Rounds up.", category: "math",
});
register({
name: "abs", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.abs(n); },
doc: "Absolute value.", category: "math",
});
register({
name: "min", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.min(...nums) : null;
},
doc: "Minimum of the arguments.", category: "math",
});
register({
name: "max", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.max(...nums) : null;
},
doc: "Maximum of the arguments.", category: "math",
});
register({
name: "mod", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return na % nb;
},
doc: "Remainder after division.", category: "math",
});
register({
name: "add", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na + nb;
},
doc: "Sum of two numbers.", category: "math",
});
register({
name: "subtract", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na - nb;
},
doc: "Difference of two numbers.", category: "math",
});
register({
name: "multiply", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na * nb;
},
doc: "Product of two numbers.", category: "math",
});
register({
name: "divide", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return na / nb;
},
doc: "Quotient of two numbers.", category: "math",
});
register({
name: "pow", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : Math.pow(na, nb);
},
doc: "Base raised to an exponent.", category: "math",
});
register({
name: "sqrt", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => {
const n = num(v);
if (n == null) return null;
if (n < 0) return makeErrorCell("TYPE_MISMATCH", "sqrt of negative number");
return Math.sqrt(n);
},
doc: "Positive square root.", category: "math",
});
register({
name: "sum", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
// Null propagates as 0 so `sum(prop("A"), prop("B"))` still works when
// some cells are empty — matches Airtable/Notion semantics.
let total = 0;
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) total += n;
}
return total;
},
doc: "Sum of the arguments.", category: "math",
});
const meanEval = (args: Value[]): Value => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
return nums.reduce((a, b) => a + b, 0) / nums.length;
};
register({
name: "mean", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments.", category: "math",
});
register({
name: "average", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments (alias of mean).", category: "math",
});
register({
name: "median", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
nums.sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
return nums.length % 2 === 0
? (nums[mid - 1] + nums[mid]) / 2
: nums[mid];
},
doc: "Middle value of the arguments.", category: "math",
});
@@ -0,0 +1,24 @@
import type { FormulaResultType, Value, EvalContext } from "../types";
export type FormulaFn = {
name: string;
arity: { min: number; max: number | null };
paramTypes: FormulaResultType[] | "any" | "variadic-any";
returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType);
eval: (args: Value[], ctx: EvalContext) => Value;
doc: string;
category: "logic" | "math" | "string" | "date" | "coercion";
};
export const registry: Map<string, FormulaFn> = new Map();
export function register(fn: FormulaFn): void {
// Functions are looked up case-insensitively (see eval/typecheck), so the
// registry is keyed by the lowercased name. fn.name keeps its canonical
// casing for display in the function picker and `format()`.
const key = fn.name.toLowerCase();
if (registry.has(key)) {
throw new Error(`Duplicate formula function: ${fn.name}`);
}
registry.set(key, fn);
}
@@ -0,0 +1,34 @@
import { register } from "./registry";
const s = (v: unknown): string => v == null ? "" : String(v);
register({
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
eval: (args) => args.map(s).join(""),
doc: "Concatenates strings.", category: "string",
});
register({
name: "length", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "number",
eval: ([v]) => s(v).length,
doc: "Length of a string.", category: "string",
});
register({
name: "contains", arity: { min: 2, max: 2 }, paramTypes: ["string", "string"], returnType: "boolean",
eval: ([a, b]) => s(a).includes(s(b)),
doc: "Returns true if the first string contains the second.", category: "string",
});
register({
name: "lower", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toLowerCase(),
doc: "Lowercases the string.", category: "string",
});
register({
name: "upper", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toUpperCase(),
doc: "Uppercases the string.", category: "string",
});
register({
name: "trim", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).trim(),
doc: "Strips whitespace from both ends.", category: "string",
});
+88
View File
@@ -0,0 +1,88 @@
type PropLike = { id: string; type: string; typeOptions: unknown };
export class BaseFormulaGraph {
private readonly direct = new Map<string, string[]>();
private readonly reverse = new Map<string, Set<string>>();
constructor(properties: PropLike[]) {
for (const p of properties) {
if (p.type !== "formula") continue;
const deps: string[] = Array.isArray((p.typeOptions as any)?.dependencies)
? ((p.typeOptions as any).dependencies as string[])
: [];
this.direct.set(p.id, deps);
for (const d of deps) {
if (!this.reverse.has(d)) this.reverse.set(d, new Set());
this.reverse.get(d)!.add(p.id);
}
}
}
directDeps(propId: string): string[] { return this.direct.get(propId) ?? []; }
dependents(propId: string): string[] { return Array.from(this.reverse.get(propId) ?? []); }
affectedFormulas(changedPropIds: string[]): string[] {
const out = new Set<string>();
const stack = [...changedPropIds];
while (stack.length) {
const id = stack.pop()!;
for (const d of this.reverse.get(id) ?? []) {
if (!out.has(d)) { out.add(d); stack.push(d); }
}
}
return Array.from(out).sort();
}
evalOrder(): string[] {
const order: string[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
if (temp.has(id)) return;
temp.add(id);
for (const d of this.direct.get(id) ?? []) visit(d);
temp.delete(id);
visited.add(id);
order.push(id);
};
for (const id of this.direct.keys()) visit(id);
return order;
}
/*
* Returns the cycle path (list of prop IDs) if introducing `newProp` (or
* keeping its current deps) would create one, else null. `newProp` may be
* either a property already registered or a hypothetical replacement; we
* re-read its deps at call time, so pass the candidate object.
*/
detectCycle(newProp: PropLike): string[] | null {
const local = new Map(this.direct);
if (newProp.type === "formula") {
local.set(newProp.id, (newProp.typeOptions as any)?.dependencies ?? []);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const path: string[] = [];
const dfs = (id: string): string[] | null => {
color.set(id, GRAY);
path.push(id);
for (const d of local.get(id) ?? []) {
const c = color.get(d) ?? WHITE;
if (c === GRAY) { return [...path.slice(path.indexOf(d)), d]; }
if (c === WHITE) { const r = dfs(d); if (r) return r; }
}
path.pop();
color.set(id, BLACK);
return null;
};
for (const id of local.keys()) {
if ((color.get(id) ?? WHITE) === WHITE) {
const r = dfs(id);
if (r) return r;
}
}
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
// Client-side public surface: parse, typecheck, cycle-detect, pretty-print.
import "./functions/index";
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";
+14
View File
@@ -0,0 +1,14 @@
// Server-side public surface: everything in client + evaluator + registry.
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
import "./functions/index"; // side-effect: populate registry
export { registry, register } from "./functions/index";
export type { FormulaFn } from "./functions/index";
export * from "./graph";
export * from "./eval";
+201
View File
@@ -0,0 +1,201 @@
import { tokenize, Token, TokenKind } from "./tokenizer";
import { FormulaParseError } from "./error";
import { MAX_PARSE_DEPTH } from "./types";
import type { OpCode } from "./ast";
import type { RawFormulaAST } from "./ast";
/*
* Pratt parser. Top-level entry parses a full expression and then asserts EOF.
* Binary operators are dispatched through a precedence table in `bp` below.
* `prop(...)`, `if(...)`, `and(...)`, `or(...)` are intercepted when an
* identifier is followed by `(` so they become their dedicated AST nodes.
*/
export function parseRaw(src: string): RawFormulaAST {
const tokens = tokenize(src);
const p = new Parser(tokens);
const expr = p.parseExpr(0);
p.expect(TokenKind.EOF, "Expected end of input");
return expr;
}
const BP: Partial<Record<TokenKind, number>> = {
[TokenKind.OR]: 10,
[TokenKind.AND]: 20,
[TokenKind.EQ]: 30, [TokenKind.NEQ]: 30,
[TokenKind.LT]: 40, [TokenKind.GT]: 40,
[TokenKind.LTE]: 40, [TokenKind.GTE]: 40,
[TokenKind.PLUS]: 50, [TokenKind.MINUS]: 50,
[TokenKind.STAR]: 60, [TokenKind.SLASH]: 60, [TokenKind.PERCENT]: 60,
};
const TOK_TO_OP: Partial<Record<TokenKind, OpCode>> = {
[TokenKind.PLUS]: "+", [TokenKind.MINUS]: "-",
[TokenKind.STAR]: "*", [TokenKind.SLASH]: "/", [TokenKind.PERCENT]: "%",
[TokenKind.EQ]: "==", [TokenKind.NEQ]: "!=",
[TokenKind.LT]: "<", [TokenKind.GT]: ">",
[TokenKind.LTE]: "<=", [TokenKind.GTE]: ">=",
};
class Parser {
private i = 0;
private depth = 0;
constructor(private tokens: Token[]) {}
peek(): Token { return this.tokens[this.i]; }
next(): Token { return this.tokens[this.i++]; }
expect(kind: TokenKind, msg: string): Token {
const t = this.peek();
if (t.kind !== kind) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN", message: msg, span: { start: t.start, end: t.end },
}]);
}
return this.next();
}
// Bound recursive descent. Every path that recurses (parens, unary chains,
// binary rhs, call args) funnels through parseExpr/parseUnary, so guarding
// their entry caps the JS stack and turns pathological nesting into a
// catchable FormulaParseError instead of a RangeError.
private enter(): void {
if (++this.depth > MAX_PARSE_DEPTH) {
const t = this.peek();
throw new FormulaParseError([{
code: "DEPTH_EXCEEDED",
message: `Formula nesting too deep (max ${MAX_PARSE_DEPTH})`,
span: { start: t.start, end: t.end },
}]);
}
}
parseExpr(minBp: number): RawFormulaAST {
this.enter();
try {
return this.parseExprInner(minBp);
} finally {
this.depth--;
}
}
private parseExprInner(minBp: number): RawFormulaAST {
let lhs = this.parseUnary();
while (true) {
const tok = this.peek();
if (tok.kind === TokenKind.AND) {
if (BP[TokenKind.AND]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.AND]! + 1);
lhs = { t: "and", args: [lhs, rhs] };
continue;
}
if (tok.kind === TokenKind.OR) {
if (BP[TokenKind.OR]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.OR]! + 1);
lhs = { t: "or", args: [lhs, rhs] };
continue;
}
const bp = BP[tok.kind];
if (bp == null || bp < minBp) break;
this.next();
const rhs = this.parseExpr(bp + 1);
const op = TOK_TO_OP[tok.kind]!;
lhs = { t: "op", op, args: [lhs, rhs] };
}
return lhs;
}
parseUnary(): RawFormulaAST {
const tok = this.peek();
if (tok.kind === TokenKind.MINUS) {
this.next();
this.enter();
try {
return { t: "op", op: "neg", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
if (tok.kind === TokenKind.NOT) {
this.next();
this.enter();
try {
return { t: "op", op: "not", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
return this.parsePrimary();
}
parsePrimary(): RawFormulaAST {
const tok = this.next();
switch (tok.kind) {
case TokenKind.NUMBER: return { t: "num", v: Number(tok.text) };
case TokenKind.STRING: return { t: "str", v: tok.text };
case TokenKind.TRUE: return { t: "bool", v: true };
case TokenKind.FALSE: return { t: "bool", v: false };
case TokenKind.NULL: return { t: "null" };
case TokenKind.LPAREN: {
const e = this.parseExpr(0);
this.expect(TokenKind.RPAREN, "Expected ')'");
return e;
}
case TokenKind.AND:
case TokenKind.OR:
case TokenKind.IDENT: {
if (this.peek().kind !== TokenKind.LPAREN) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected identifier '${tok.text}' (did you mean prop("${tok.text}")?)`,
span: { start: tok.start, end: tok.end },
}]);
}
this.next(); // LPAREN
const args: RawFormulaAST[] = [];
if (this.peek().kind !== TokenKind.RPAREN) {
args.push(this.parseExpr(0));
while (this.peek().kind === TokenKind.COMMA) {
this.next();
args.push(this.parseExpr(0));
}
}
this.expect(TokenKind.RPAREN, "Expected ')'");
// Match special-form/keyword names case-insensitively; the `call`
// node below keeps the raw casing the user typed.
const head = tok.text.toLowerCase();
if (head === "prop") {
if (args.length !== 1 || args[0].t !== "str") {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: 'prop() expects exactly one string literal argument',
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "propName", name: args[0].v };
}
if (head === "if") {
if (args.length !== 3) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: "if() expects exactly 3 arguments",
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "if", cond: args[0], then: args[1], else: args[2] };
}
if (head === "and") return { t: "and", args };
if (head === "or") return { t: "or", args };
return { t: "call", fn: tok.text, args };
}
default:
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected token '${tok.text || tok.kind}'`,
span: { start: tok.start, end: tok.end },
}]);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, RawFormulaAST } from "./ast";
export type ResolveResult = {
ast: FormulaAST;
dependencies: string[];
};
export function resolve(
raw: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
): ResolveResult {
const deps = new Set<string>();
const ast = walk(raw, nameToId, deps);
return { ast, dependencies: Array.from(deps).sort() };
}
function walk(
node: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
deps: Set<string>,
): FormulaAST {
switch (node.t) {
case "num": case "str": case "bool": case "null":
return node as FormulaAST;
case "propName": {
const id = nameToId.get(node.name);
if (!id) {
throw new FormulaParseError([{
code: "UNKNOWN_PROPERTY",
message: `Unknown property '${node.name}'`,
span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse
}]);
}
deps.add(id);
return { t: "prop", id };
}
case "op":
return {
t: "op",
op: (node as any).op,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "if":
return {
t: "if",
cond: walk((node as any).cond, nameToId, deps),
then: walk((node as any).then, nameToId, deps),
else: walk((node as any).else, nameToId, deps),
};
case "and":
return {
t: "and",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "or":
return {
t: "or",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "call":
return {
t: "call",
fn: (node as any).fn,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
}
}
+158
View File
@@ -0,0 +1,158 @@
import { FormulaParseError } from "./error";
import { MAX_FORMULA_SOURCE_LENGTH } from "./types";
export enum TokenKind {
NUMBER = "NUMBER",
STRING = "STRING",
IDENT = "IDENT",
TRUE = "TRUE",
FALSE = "FALSE",
NULL = "NULL",
AND = "AND",
OR = "OR",
NOT = "NOT",
PLUS = "PLUS",
MINUS = "MINUS",
STAR = "STAR",
SLASH = "SLASH",
PERCENT = "PERCENT",
EQ = "EQ",
NEQ = "NEQ",
LT = "LT",
GT = "GT",
LTE = "LTE",
GTE = "GTE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
COMMA = "COMMA",
EOF = "EOF",
}
export type Token = {
kind: TokenKind;
text: string;
start: number;
end: number;
};
const KEYWORDS: Record<string, TokenKind> = {
true: TokenKind.TRUE,
false: TokenKind.FALSE,
null: TokenKind.NULL,
and: TokenKind.AND,
or: TokenKind.OR,
not: TokenKind.NOT,
};
export function tokenize(src: string): Token[] {
if (src.length > MAX_FORMULA_SOURCE_LENGTH) {
throw new FormulaParseError([{
code: "INPUT_TOO_LONG",
message: `Formula is too long (${src.length} chars; max ${MAX_FORMULA_SOURCE_LENGTH})`,
span: { start: 0, end: MAX_FORMULA_SOURCE_LENGTH },
}]);
}
const tokens: Token[] = [];
let i = 0;
const push = (kind: TokenKind, text: string, start: number, end: number) =>
tokens.push({ kind, text, start, end });
while (i < src.length) {
const ch = src[i];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
if (ch >= "0" && ch <= "9") {
const start = i;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
if (src[i] === ".") {
i++;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
}
push(TokenKind.NUMBER, src.slice(start, i), start, i);
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
const start = i;
i++;
let body = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\") {
if (i + 1 >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated escape in string",
span: { start, end: i + 1 },
}]);
}
const esc = src[i + 1];
body += esc === "n" ? "\n" : esc === "t" ? "\t" : esc;
i += 2;
} else {
body += src[i];
i++;
}
}
if (i >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated string literal",
span: { start, end: src.length },
}]);
}
i++;
push(TokenKind.STRING, body, start, i);
continue;
}
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_") {
const start = i;
while (
i < src.length &&
(
(src[i] >= "a" && src[i] <= "z") ||
(src[i] >= "A" && src[i] <= "Z") ||
(src[i] >= "0" && src[i] <= "9") ||
src[i] === "_"
)
) i++;
const text = src.slice(start, i);
// Keywords and function names are case-insensitive: match on the
// lowercased text but keep `text` raw on the token so error messages
// and `format()` preserve the user's casing.
// hasOwnProperty guards against inherited Object.prototype names
// (toString, valueOf, hasOwnProperty, …) matching the KEYWORDS lookup —
// those are valid identifiers/function names (e.g. the toString() fn).
const lower = text.toLowerCase();
const kw = Object.prototype.hasOwnProperty.call(KEYWORDS, lower) ? KEYWORDS[lower] : undefined;
push(kw ?? TokenKind.IDENT, text, start, i);
continue;
}
const start = i;
const two = src.slice(i, i + 2);
if (two === "==") { push(TokenKind.EQ, two, start, i + 2); i += 2; continue; }
if (two === "!=") { push(TokenKind.NEQ, two, start, i + 2); i += 2; continue; }
if (two === "<=") { push(TokenKind.LTE, two, start, i + 2); i += 2; continue; }
if (two === ">=") { push(TokenKind.GTE, two, start, i + 2); i += 2; continue; }
const singleMap: Record<string, TokenKind> = {
"+": TokenKind.PLUS, "-": TokenKind.MINUS, "*": TokenKind.STAR,
"/": TokenKind.SLASH, "%": TokenKind.PERCENT,
"<": TokenKind.LT, ">": TokenKind.GT,
"(": TokenKind.LPAREN, ")": TokenKind.RPAREN, ",": TokenKind.COMMA,
};
if (singleMap[ch]) { push(singleMap[ch], ch, start, i + 1); i++; continue; }
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected character '${ch}'`,
span: { start: i, end: i + 1 },
}]);
}
tokens.push({ kind: TokenKind.EOF, text: "", start: i, end: i });
return tokens;
}
+89
View File
@@ -0,0 +1,89 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, OpCode } from "./ast";
import type { FormulaResultType } from "./types";
import type { FormulaFn } from "./functions";
export type PropertyTypeMap = ReadonlyMap<string, FormulaResultType>;
export type TypecheckResult = { resultType: FormulaResultType };
const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"];
const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="];
export function typecheck(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): TypecheckResult {
return { resultType: infer(ast, propertyTypes, registry) };
}
function infer(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): FormulaResultType {
switch (ast.t) {
case "num": return "number";
case "str": return "string";
case "bool": return "boolean";
case "null": return "null";
case "prop": return propertyTypes.get(ast.id) ?? "null";
case "op": {
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (ARITH_OPS.includes(ast.op)) {
// '+' is overloaded to match the evaluator: any string operand makes
// it string concatenation; otherwise it's numeric addition.
if (ast.op === "+" && argTypes.some((t) => t === "string")) return "string";
const allow = argTypes.every((t) => t === "number" || t === "null");
if (!allow) throw typeErr(`Operator '${ast.op}' needs numbers`);
return "number";
}
if (CMP_OPS.includes(ast.op)) return "boolean";
if (ast.op === "neg") {
if (argTypes[0] !== "number" && argTypes[0] !== "null") throw typeErr("Unary '-' needs number");
return "number";
}
if (ast.op === "not") {
if (argTypes[0] !== "boolean" && argTypes[0] !== "null") throw typeErr("'not' needs boolean");
return "boolean";
}
return "null";
}
case "if": {
const thenT = infer(ast.then, propertyTypes, registry);
const elseT = infer(ast.else, propertyTypes, registry);
if (thenT === elseT) return thenT;
if (thenT === "null") return elseT;
if (elseT === "null") return thenT;
throw typeErr(`if() branches have different types: ${thenT} vs ${elseT}`);
}
case "and": case "or":
ast.args.forEach((a) => {
const t = infer(a, propertyTypes, registry);
if (t !== "boolean" && t !== "null") throw typeErr(`'${ast.t}' needs boolean args`);
});
return "boolean";
case "call": {
const fn = registry.get(ast.fn.toLowerCase());
if (!fn) throw new FormulaParseError([{
code: "UNKNOWN_FUNCTION",
message: `Unknown function '${ast.fn}'`,
span: { start: 0, end: 0 },
}]);
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (argTypes.length < fn.arity.min || (fn.arity.max != null && argTypes.length > fn.arity.max)) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: `${fn.name}() expects ${fn.arity.min}-${fn.arity.max ?? "∞"} args, got ${argTypes.length}`,
span: { start: 0, end: 0 },
}]);
}
return typeof fn.returnType === "function" ? fn.returnType(argTypes) : fn.returnType;
}
}
}
function typeErr(message: string): FormulaParseError {
return new FormulaParseError([{ code: "TYPE_MISMATCH", message, span: { start: 0, end: 0 } }]);
}
+74
View File
@@ -0,0 +1,74 @@
import type { FormulaAST } from "./ast";
export type FormulaResultType =
| "number"
| "string"
| "boolean"
| "date"
| "null";
export type FormulaTypeOptions = {
source: string;
ast: FormulaAST;
resultType: FormulaResultType;
dependencies: string[];
astVersion: 1;
formatOptions?: Record<string, unknown>;
};
/*
* The runtime value produced by evaluating a node. Strings and numbers are
* their JS equivalents; dates are ISO 8601 UTC strings (matches how the date
* property type already stores cells); booleans are booleans; missing or
* filtered-out values are null. Errors are distinguishable from all valid
* values because they are objects with a `__err` key.
*/
export type Value = number | string | boolean | null | ErrorCell;
export type ErrorCell = {
__err: ErrorCode;
msg: string;
v: 1;
};
export type ErrorCode =
| "MISSING_PROP"
| "TYPE_MISMATCH"
| "DIV_BY_ZERO"
| "DATE_INVALID"
| "DEPTH_EXCEEDED"
| "DEPENDENCY_ERROR";
/*
* EvalContext carries everything the evaluator needs that isn't in the AST:
* the function registry (server-only), the property map for resolving `prop`
* nodes to their formula ASTs when nested, and the current recursion depth.
*/
export type EvalContext = {
registry: ReadonlyMap<string, import("./functions/registry").FormulaFn>;
properties: ReadonlyMap<string, PropertyLookup>;
depth: number;
maxDepth: number;
memo: Map<string, Value>; // keyed by propId for the current row-eval
};
export type PropertyLookup = {
id: string;
type: string;
typeOptions: unknown;
};
export const DEFAULT_MAX_DEPTH = 64;
/*
* DoS guards. A formula source longer than MAX_FORMULA_SOURCE_LENGTH is
* rejected before tokenizing (cheap backstop against pathological input like
* "(".repeat(50000)). MAX_PARSE_DEPTH bounds recursive-descent nesting so a
* deeply nested source throws a catchable FormulaParseError instead of
* overflowing the JS stack with a RangeError. MAX_EVAL_DEPTH bounds the
* tree-walking evaluator so an oversized AST that slipped past the parser
* degrades to an error cell instead of crashing the recompute worker.
*/
export const MAX_FORMULA_SOURCE_LENGTH = 10_000;
export const MAX_PARSE_DEPTH = 256;
export const MAX_EVAL_DEPTH = 512;