Calculated Fields
A calculated field is a column the pipeline computes from the row's
other columns and writes back onto the same table — next to the raw
data, not into a separate mirror. The classic example: a vehicle whose
speed is 0 while rpm is climbing is at idle, so kontak (ignition)
is 1 — computed on the raw row itself, for every table kind.
SELECT speed, rpm, kontak FROM vehicle_events; -- raw and computed, side by side
The field belongs to the table
Each definition lives as data in the database's x_calc_columns
table — one row per (target_table, column), versioned, enabled or not.
The pipeline that computes a table is always the same three nodes,
sink pointed back at the source:
TableTrigger(vehicle_events) → CalcColumns(vehicle_events) → TableSink(vehicle_events)
CalcColumns fetches the target's definitions fresh at every run —
editing one is a hot reload. No republish, no restart: the next data
that arrives is computed the new way.
The column is born the moment you define it, carrying the CALCULATED
attribute. That attribute changes one thing in the engine: when the
pipeline rewrites a computed value at a coordinate it already wrote, the
read resolves to the newest write — a recompute is a correction, not
a second observation. Raw columns are untouched; every differing sample
is still kept.
A definition is name + type + triggers + events + script
| part | meaning |
|---|---|
| name | the column the field writes (reserved prefixes x_, _ refused) |
| type | bool / int / float64 / string — the column is born with it, and every result is checked against it (a mismatch is dropped, not written) |
| triggers | run only when one of these columns arrives (raw) or changes (a computed sibling); empty = every row |
| on events | run only on insert / update / delete; empty = all |
| script | the Lua below |
Writing the script
Define compute(row, prev, event) and return the column's value. You may
declare your own helper functions and constants alongside it:
local function band(v)
if v < 1000 then return 'low' elseif v < 2000 then return 'mid' else return 'high' end
end
function compute(row, prev, event)
return band(row.rpm)
end
For the simple case a bare function body works too — it is wrapped into
compute for you:
return row.speed == 0 and row.rpm > 0
Each field's script loads in its own closure, so helpers stay private and never collide between fields.
row — the current row
row carries every source column (row.entity, row.ts, row.speed,
…) and every calculated sibling your script reads. Fields are
scheduled by their row.<col> references, so a field reading
row.p00_dev runs after p00_dev, however the names sort. Circular
references are refused by name. A missing value is nil (sparse
fragments are normal) — guard with or.
prev — the previous row
prev holds this entity's previous row (its trigger and computed
columns), so change over time is a subtraction:
function compute(row, prev, event)
if event == 'insert' then return false end
return row.rpm > (prev.rpm or 0) -- "rising"
end
prev and event advance only as the entity moves forward in time
(rev). A replay, or the pipeline's own feed echo, is left untouched — so
a calculated value never flickers and the loop converges.
event — what happened
event is "insert" (the first time this entity is seen), "update"
(later), or "delete" (a soft-deleted rows-table row). Gate a field to
specific events with on events, or branch on it in the script.
state — persistent memory
A script that reads the global state gets a per-entity memory: a
Lua table keyed by the row's identity (entity for timeseries, xid for
rows), alive across the chunk, loaded from the x_calc_state kv store
before the run and persisted after — a running EWMA survives runs,
restarts and daemon failover:
function compute(row, prev, event)
state.v = (state.v or row.p00) * 0.9 + row.p00 * 0.1
return state.v
end
Keys beginning __ are reserved (that is where prev is kept).
At-least-once caveat: if a run fails after the state advanced, the
replayed rows are seen by the state twice.
Scripts run in the runner's Lua sandbox: no filesystem, no network, a hard time and memory ceiling, in a throwaway child process. The worst a field can do is fail its own run.
The console
Under each table the sidebar has a Calculated Fields section: its menu offers New calculated field, and each field carries an Edit / Delete menu (delete is confirmed and removes only the formula — the column and its computed values stay). The editor is a workspace tab: field name (renamable), type, triggers, on-events, a Lua editor with a Preview that runs your script in the browser, and an honest status line naming the pipeline that computes this table. A computed column wears an fx badge in the Fields list.
Everything the console does is plain SQL on x_calc_columns, so
automation can do the same:
INSERT INTO x_calc_columns
(target_table, col, script, version, enabled, type, trigger_cols, on_events)
VALUES ('vehicle_events', 'kontak',
'return row.speed == 0 and row.rpm > 0',
1, true, 'bool', '["speed","rpm"]', NULL);
Durability and the past
Column births follow the database's durability mode (see Durability). A saved formula applies to data from now on — rows computed under the old version stay as they were, often exactly what you want. To recompute history, empty the table and clear the pipeline's cursor:
TRUNCATE TABLE vehicle_events;
DELETE FROM x_re_cursors WHERE key = 'vehicle-calc/vehicle_events';
— and the trigger replays from zero through the current formulas on its next wakeup. (Truncating a synced table is louder: its epoch bump refuses the old cursor and forces the same full replay on its own.)