feat(ee): bases (#2295)

* feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension.

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

* feat(base): add focused-cell atom and cell coordinate types

* feat(base): add cell focus-ring style

* feat(base): add pure next-cell navigation helper

* feat(base): keyboard navigation controller and grid wiring

* update offerings

* feat(base): cell focus ring, click-to-focus, and gridcell ARIA

* feat(base): row ARIA index and selected state

* feat(base): seed editor value on type-to-edit for free-text cells

* feat(base): make column headers keyboard-focusable as tab stops

* fix(base): remove focus outline on grid container

* fix(base): show cell focus ring only while the grid is focused

* feat(base): keyboard-navigate the row-number column for selection

* fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order

* fix(base): tab from long-text editor moves to next cell instead of leaving the table

* fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop

* fix(base): show cell focus ring only while the grid body itself is focused

* fix(base): render view-tab rename as an inline pill so the tab band height stays put

* fix(base): refer to the feature as 'base' rather than 'database'

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

* fix: preserve sidebar title/icon on partial page updates

* fix: skip duplicate row fetch when opening new kanban card

* fix refetch

* fix focus

* fix spacing

* fix(base): select grid cell on mousedown to avoid stale focus ring flash

The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.

* fix: activate New row button via keyboard (Enter/Space)

The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.

* fix(base): keep add-property popover within viewport on mobile

Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.

* fix(base): enable grid cell editing on touch devices

Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.

* feat(editor): add base and kanban inserts to the toolbar

* feat(base): insert row below via Shift+Enter on the primary cell

* fix(base): place caret at end instead of selecting all when editing cells

* fix(base): prevent popover inputs from losing focus on mobile in row detail modal

* fix grid cells on mobile

* sync

* fix: read-only export

* feat(base): add prefixed nanoid id schemas and generators

* feat(base): enforce strict property/choice id validation

* feat(base): make property id varchar with per-base composite pk

* feat(base): pass property id as text to cell extractors

* feat(base): scope property lookups per base and generate property ids in repo

* feat(base): generate status template choice ids as nanoid

* feat(base): generate choice ids as nanoid on the client

* chore(base): seed choice ids with nanoid

* fix(base): mint kanban choice ids as nanoid

* sync

* sync

* sync
This commit is contained in:
Philip Okugbe
2026-06-21 03:07:14 +01:00
committed by GitHub
parent e0fdc0c178
commit 89f13c3fbc
249 changed files with 23435 additions and 183 deletions
+2
View File
@@ -0,0 +1,2 @@
dist/
tsconfig.tsbuildinfo
+37
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.
@@ -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();
+28
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": {}
}
+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"
);
}
+126
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);
}
}
+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,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",
});
@@ -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,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",
});
+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;
}
}
+14
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";
+15
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";
+10
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);
}
+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;
+12
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"]
}
+1 -1
View File
@@ -33,8 +33,8 @@ export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export {
pageNodeToDocxBuffer,
type DocxImageResolver,
} from "./lib/prosemirror-docx";
export * from "./lib/base-embed";
@@ -0,0 +1,133 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
export interface BaseEmbedOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
baseEmbed: {
insertBaseEmbed: (attrs: {
pageId: string | null;
pendingKey?: string | null;
}) => ReturnType;
};
}
}
export const BaseEmbed = Node.create<BaseEmbedOptions>({
name: 'base',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addOptions() {
return { HTMLAttributes: {} };
},
// prosemirror-dropcursor draws a block-boundary indicator on every
// `dragover` it sees. Pragmatic-dnd (used for column / choice reorder
// inside the embed) fires native `dragstart`/`dragover`, which bubble
// up to the editor and trigger dropcursor — visible as a stray blue
// line above or below the embed during an internal drag. The cursor
// event lands over the atom node, so dropcursor consults
// `disableDropCursor` on this node spec; returning true suppresses
// the indicator while still letting pragmatic-dnd handle the drag.
extendNodeSchema(extension) {
return extension.name === 'base'
? { disableDropCursor: true }
: {};
},
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el) => el.getAttribute('data-page-id'),
renderHTML: (attrs) =>
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
},
// Transient marker set when the slash command inserts the embed
// before the server has assigned a pageId. The view renders a
// skeleton in this state. Cleared once the API responds and the
// real pageId is patched in. Not serialized — embeds saved with
// a pendingKey would orphan if the page were closed mid-request.
pendingKey: {
default: null,
parseHTML: () => null,
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="base-embed"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'base-embed',
}),
];
},
addCommands() {
return {
insertBaseEmbed:
(attrs) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs,
}),
};
},
addKeyboardShortcuts() {
// Block Backspace / Delete when the base embed itself is the
// current selection — the "click on the embed and hit delete"
// accidental-delete path. Returning true tells TipTap we've
// handled the key, preventing the default removal. Range
// selections covering the node and programmatic deletes still
// work normally.
const isThisNodeSelected = (): boolean => {
const { selection } = this.editor.state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === this.name
);
};
return {
Backspace: () => isThisNodeSelected(),
Delete: () => isThisNodeSelected(),
};
},
addProseMirrorPlugins() {
// Same idea as the Backspace/Delete shortcuts above, but for the
// other accidental-delete path: when the embed is the selection,
// a typed character or paste would replace the whole node. These
// hooks return true (handled, no-op) so the node stays put. The
// user can still press an arrow key to deselect and then type.
const nodeName = this.name;
const isThisNodeSelected = (state: EditorState): boolean => {
const { selection } = state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === nodeName
);
};
return [
new Plugin({
props: {
handleTextInput: (view) => isThisNodeSelected(view.state),
handlePaste: (view) => isThisNodeSelected(view.state),
},
}),
];
},
});
@@ -0,0 +1,2 @@
export { BaseEmbed } from './base-embed';
export type { BaseEmbedOptions } from './base-embed';
@@ -1 +1,2 @@
export { TableHeaderPin } from './extension';
export { pinOffsetWatcher, EDITOR_PIN_OFFSET_VAR, computePinTop } from './offset';