snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)

This commit is contained in:
upstream-mirror
2026-09-15 15:15:34 +05:00
commit 2653349432
1615 changed files with 202632 additions and 0 deletions

2
packages/base-formula/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
tsconfig.tsbuildinfo

View File

@@ -0,0 +1,37 @@
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,329 @@
import {
parseRaw,
resolve,
typecheck,
evaluate,
registry,
DEFAULT_MAX_DEPTH,
} from "../src/index.server";
import type {
FormulaAST,
EvalContext,
PropertyLookup,
Value,
FormulaResultType,
} from "../src/index.server";
// sample row: properties a..j (numbers), name (string)
const NUM_PROPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
const cells: Record<string, unknown> = {
prop_name: "widget",
};
NUM_PROPS.forEach((p, idx) => {
cells[`prop_${p}`] = (idx + 1) * 7.3 - idx; // arbitrary non-trivial floats
});
const nameToId = new Map<string, string>([
["name", "prop_name"],
...NUM_PROPS.map((p) => [p, `prop_${p}`] as [string, string]),
]);
const propertyTypes = new Map<string, FormulaResultType>([
["prop_name", "string"],
...NUM_PROPS.map(
(p) => [`prop_${p}`, "number"] as [string, FormulaResultType],
),
]);
// Base (non-formula) property lookup. Nested-formula cases extend this.
const baseProps = new Map<string, PropertyLookup>([
["prop_name", { id: "prop_name", type: "string", typeOptions: {} }],
...NUM_PROPS.map(
(p) =>
[`prop_${p}`, { id: `prop_${p}`, type: "number", typeOptions: {} }] as [
string,
PropertyLookup,
],
),
]);
function mkCtx(properties: ReadonlyMap<string, PropertyLookup>): EvalContext {
return {
registry,
properties,
depth: 0,
maxDepth: DEFAULT_MAX_DEPTH,
memo: new Map<string, Value>(),
};
}
//AST shape metrics
function astStats(ast: FormulaAST): { nodes: number; depth: number } {
let nodes = 0;
const walk = (n: FormulaAST, d: number): number => {
nodes++;
let max = d;
const kids: FormulaAST[] = [];
switch (n.t) {
case "op":
kids.push(...n.args);
break;
case "and":
case "or":
kids.push(...n.args);
break;
case "call":
kids.push(...n.args);
break;
case "if":
kids.push(n.cond, n.then, n.else);
break;
}
for (const k of kids) max = Math.max(max, walk(k, d + 1));
return max;
};
const depth = walk(ast, 1);
return { nodes, depth };
}
// timing harness
function timed(
fn: () => void,
targetMs = 600,
): { opsPerSec: number; nsPerOp: number } {
// warmup ~150ms to let V8 JIT settle
const warmEnd = performance.now() + 150;
while (performance.now() < warmEnd) fn();
// measure: 5 samples, keep the fastest (least noise from GC/scheduler)
let bestNsPerOp = Infinity;
for (let s = 0; s < 5; s++) {
// calibrate batch so each sample ~ targetMs
let iters = 1024;
let elapsedMs = 0;
while (true) {
const t0 = process.hrtime.bigint();
for (let i = 0; i < iters; i++) fn();
const t1 = process.hrtime.bigint();
elapsedMs = Number(t1 - t0) / 1e6;
if (elapsedMs >= targetMs) break;
iters = Math.ceil(
iters * Math.max(2, targetMs / Math.max(elapsedMs, 0.01)),
);
}
const nsPerOp = (elapsedMs * 1e6) / iters;
bestNsPerOp = Math.min(bestNsPerOp, nsPerOp);
}
return { opsPerSec: 1e9 / bestNsPerOp, nsPerOp: bestNsPerOp };
}
// formula corpus
type Case = { tier: string; name: string; src: string };
function buildArithChain(n: number): string {
// ((((a + b) * c) - d) ... ) cycling through props/ops
const ops = ["+", "*", "-"];
let expr = 'prop("a")';
for (let i = 0; i < n; i++) {
const p = NUM_PROPS[(i + 1) % NUM_PROPS.length];
const op = ops[i % ops.length];
expr = `(${expr} ${op} prop("${p}"))`;
}
return expr;
}
function buildIfChain(tiers: number): string {
// if(a>t1, "1", if(a>t2, "2", ... "fallback"))
let expr = '"fallback"';
for (let i = tiers; i >= 1; i--) {
expr = `if(prop("a") > ${i * 5}, "${i}", ${expr})`;
}
return expr;
}
function buildBalancedAddTree(depth: number): string {
// add(add(.., ..), add(.., ..)) = full binary tree of `add` calls
const leaf = () =>
`prop("${NUM_PROPS[Math.floor(Math.random() * NUM_PROPS.length)]}")`;
const build = (d: number): string =>
d === 0 ? leaf() : `add(${build(d - 1)}, ${build(d - 1)})`;
return build(depth);
}
const cases: Case[] = [
// BASIC
{ tier: "basic", name: "literal add", src: "1 + 2" },
{ tier: "basic", name: "two-prop add", src: 'prop("a") + prop("b")' },
{ tier: "basic", name: "comparison", src: 'prop("a") > 10' },
{ tier: "basic", name: "neg + mul", src: '-prop("a") * 2' },
// INTERMEDIATE
{
tier: "intermediate",
name: "round(mul)",
src: 'round(prop("a") * 1.5, 2)',
},
{
tier: "intermediate",
name: "if/then/else",
src: 'if(prop("a") > prop("b"), "hi", "lo")',
},
{
tier: "intermediate",
name: "string concat",
src: 'concat(upper(prop("name")), "-", toString(prop("a")))',
},
{
tier: "intermediate",
name: "bool and/or",
src: 'and(prop("a") > 0, or(prop("b") < 100, prop("c") == 0))',
},
// COMPLEX
{
tier: "complex",
name: "hypotenuse",
src: 'sqrt(pow(prop("a"), 2) + pow(prop("b"), 2))',
},
{
tier: "complex",
name: "sum(10 props)",
src: `sum(${NUM_PROPS.map((p) => `prop("${p}")`).join(", ")})`,
},
{
tier: "complex",
name: "nested if (4-tier grade)",
src: 'if(prop("a") > 90, "A", if(prop("a") > 80, "B", if(prop("a") > 70, "C", "F")))',
},
{
tier: "complex",
name: "mixed math+string+logic",
src: 'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
},
// DEEPLY NESTED
{ tier: "deep", name: "arith chain x20", src: buildArithChain(20) },
{ tier: "deep", name: "nested if x10 tiers", src: buildIfChain(10) },
{ tier: "deep", name: "balanced fn tree d6", src: buildBalancedAddTree(6) },
];
// nested-formula (cross-property) case
// prop_total (formula) -> prop_sub (formula) -> raw props. Exercises evalProp
// recursion + per-row memoization, the multi-formula recompute hot path.
function buildNestedFormulaCtx(): {
ast: FormulaAST;
ctx: EvalContext;
stats: { nodes: number; depth: number };
} {
const subRaw = resolve(
parseRaw('round((prop("a") + prop("b") + prop("c")) / 3, 2)'),
nameToId,
).ast;
const totalRaw = resolve(
parseRaw('prop("sub") * prop("d") + prop("e")'),
// @ts-ignore
new Map([...nameToId, ["sub", "prop_sub"]]),
).ast;
const props = new Map<string, PropertyLookup>(baseProps);
props.set("prop_sub", {
id: "prop_sub",
type: "formula",
typeOptions: {
ast: subRaw,
source: "",
resultType: "number",
dependencies: [],
astVersion: 1,
},
});
return { ast: totalRaw, ctx: mkCtx(props), stats: astStats(totalRaw) };
}
// run
const fmt = (n: number) =>
n >= 1e6
? `${(n / 1e6).toFixed(2)}M`
: n >= 1e3
? `${(n / 1e3).toFixed(1)}K`
: n.toFixed(0);
console.log(`\nnode ${process.version} | base-formula engine benchmark\n`);
console.log(
"tier".padEnd(13) +
"formula".padEnd(28) +
"nodes".padStart(6) +
"depth".padStart(6) +
"compile op/s".padStart(15) +
"eval op/s".padStart(13) +
"eval ns/op".padStart(13),
);
console.log("-".repeat(94));
for (const c of cases) {
const raw = parseRaw(c.src);
const { ast } = resolve(raw, nameToId);
const stats = astStats(ast);
const ctx = mkCtx(baseProps);
const compile = timed(() => {
const r = resolve(parseRaw(c.src), nameToId);
typecheck(r.ast, propertyTypes, registry);
});
const ev = timed(() => {
ctx.memo.clear(); // fresh per "row" — matches production new Map() per row
evaluate(ast, cells, ctx);
});
console.log(
c.tier.padEnd(13) +
c.name.padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
fmt(compile.opsPerSec).padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// nested cross-property formula
{
const { ast, ctx, stats } = buildNestedFormulaCtx();
const ev = timed(() => {
ctx.memo.clear();
evaluate(ast, cells, ctx);
});
console.log(
"nested-prop".padEnd(13) +
"total->sub->raw".padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
"-".padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// whole-table simulation: eval N rows for the complex grade formula
console.log(
"\nwhole-table recompute simulation (mixed math+string+logic formula):",
);
const tableAst = resolve(
parseRaw(
'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
),
nameToId,
).ast;
for (const rows of [1_000, 10_000, 100_000]) {
const ctx = mkCtx(baseProps);
const t0 = process.hrtime.bigint();
for (let r = 0; r < rows; r++) {
ctx.memo.clear();
evaluate(tableAst, cells, ctx);
}
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(
` ${fmt(rows).padStart(6)} rows -> ${ms.toFixed(1)} ms (${fmt((rows / ms) * 1000)} rows/sec)`,
);
}
console.log();

View File

@@ -0,0 +1,28 @@
{
"name": "@docmost/base-formula",
"homepage": "https://docmost.com",
"private": true,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch",
"bench": "tsx bench/formula-bench.ts"
},
"main": "dist/index.server.js",
"module": "./dist/index.server.js",
"exports": {
"./client": {
"types": "./dist/index.client.d.ts",
"default": "./src/index.client.ts"
},
"./server": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
},
".": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
}
},
"types": "dist/index.server.d.ts",
"dependencies": {}
}

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;

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"
);
}

View File

@@ -0,0 +1,126 @@
import { makeErrorCell, isErrorCell } from "./error";
import { valueToString } from "./number";
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 valueToString(a) + valueToString(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);
}
}

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(", ")})`;
}
}

View File

@@ -0,0 +1,17 @@
import { register } from "./registry";
import { valueToString } from "../number";
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]) => valueToString(v),
doc: "Converts the value to a string.", category: "coercion",
});

View File

@@ -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",
});

View File

@@ -0,0 +1,7 @@
import "./logic";
import "./math";
import "./string";
import "./date";
import "./coercion";
export { registry, register } from "./registry";
export type { FormulaFn } from "./registry";

View File

@@ -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",
});

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",
});

View File

@@ -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);
}

View File

@@ -0,0 +1,35 @@
import { register } from "./registry";
import { valueToString } from "../number";
const s = (v: unknown): string => valueToString(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",
});

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;
}
}

View File

@@ -0,0 +1,14 @@
// 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";
export * from "./number";

View File

@@ -0,0 +1,15 @@
// 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";
export * from "./number";

View File

@@ -0,0 +1,10 @@
export function snapNumber(n: number): number {
if (!Number.isFinite(n)) return n;
return Number(n.toPrecision(15));
}
export function valueToString(v: unknown): string {
if (v == null) return "";
if (typeof v === "number") return String(snapNumber(v));
return String(v);
}

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 },
}]);
}
}
}

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)),
};
}
}

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;
}

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 } }]);
}

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;

View File

@@ -0,0 +1,12 @@
{
"extends": "../editor-ext/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"composite": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}