Skip to content

Tables & formulas

The table element is a spreadsheet grid living inside a node: cells hold values or formulas, formulas can reference other cells on the same table, cells on a differently named table anywhere in the knowledge base, or labeled nodes on other pages, and every dependent recomputes automatically when something it reads changes. It follows the node-frame pattern, the same as every other element.

A named table element inside a page

A named pipeline! table lives inline in the page, addressable from formulas anywhere in the graph.

A table shows column letters (A, B, …), an optional named-column band, and 1-based data rows, the same conventions as any spreadsheet. Click a cell to edit it; Enter or blur commits the change, Escape cancels it. The action area in the frame header carries + row and + col.

Every cell stores a small pair:

type Cell = { v: unknown; f?: string };

v is the last computed value; f, present only on formula cells, is the formula source. Typing a plain number stores it as a number; typing anything else stores it as text. Typing a value that starts with = stores it in f and the grid computes v from it.

Give a table element a name (element.name) and every other table or formula in the knowledge base can address it by that name, regardless of which page it lives on:

=pipeline!B1

This is what makes tables composable across pages: a dashboard page can pull live numbers out of a dozen project pages’ tables without duplicating the data. Renaming or deleting a named table re-binds every formula that referenced it: dependents of the old name resolve to #REF, and any formula that was already written against the new name (previously dangling) binds and computes immediately. Creating a table under a name that formulas were already waiting on binds the same way, so you can write =roadmap!C4 before the roadmap table exists and it lights up the moment you create it.

Formulas are parsed and evaluated by @subspace/formula, a pure package with no database knowledge: it takes a formula string and a resolution context and returns a value plus the set of references it read. That separation is what lets the same engine run identically on the client (for instant local recompute) and the server (for the authoritative result).

Addressing. Formulas use standard A1 addressing plus three ways to reach outside the current cell:

Form Meaning
=C2 A cell on the same table
=SUM(C1:C2) A range on the same table
=pipeline!B1 A cell on a table named pipeline, anywhere in the KB
=[[Q3 planning]]:cash The value of a labeled node named cash on the page Q3 planning

Functions. SUM, AVERAGE (alias AVG), MIN, MAX, COUNT, IF, ROUND, ABS, CONCAT. Operators: + - * / % ^ for arithmetic and & for string concatenation, plus the usual comparison operators.

Coercion. Formulas try hard to make loose input arithmetic-ready: $610,000, 812k, and 1.5M (even with a trailing note like 1.5M projected, the numeric prefix is what coerces) all coerce to numbers when used in arithmetic.

Errors are values, not crashes. A bad formula never throws into the UI; it resolves to one of:

Error Meaning
#DIV/0 Division by zero
#REF Reference to a cell, table, or label that no longer exists
#VALUE An operand couldn’t coerce to the type an operator needs
#CYCLE The formula participates in a circular dependency
#PARSE The formula string didn’t parse

Every formula reference the engine reads while evaluating a cell is recorded as an edge in kb.deps. On every commit, an outbox consumer walks the affected slice of that dependency graph in topological order, cycle-guarded so a circular reference resolves every participant to #CYCLE instead of looping forever, recomputes each dependent cell, writes the new value, and publishes a ref: patch that live-updates every open window. This closure walk crosses pages freely (a formula on page A depending on a table on page B recomputes the moment B’s table changes) and typically lands in well under 100ms.

Bidirectional liveness means recompute isn’t limited to “cell changed”: editing a referenced cell re-renders every node that depends on it, and editing a label (the text before the : in a [[Page]]:label reference) recalculates every formula that reads that label. Either direction of edit propagates.

Table edits are per-cell (and per-row, per-col for structural changes) commands, not whole-table rewrites. Two people editing different cells of the same table concurrently each produce independent commands that both apply cleanly, the same last-write-wins convergence the command model gives every other kind of edit, just scoped narrowly enough that “different cells” almost never collides.