Design spec for include/morph/table/: the toolkit-free engine that sorts,
filters and maps the view of a table's rows, the TableQuery a server-mode
list action receives, and the selection and cell-edit state a table keeps.
The program-level design is spec 7
(docs/superpowers/specs/2026-10-08-table-engine-design.md); this file is the
reference for the code as it is.
Read this before changing anything under include/morph/table/, and before
writing a renderer list model or a server list action against it.
- Shape
- Rows: sources and snapshots
- Keys and ordering
- Filters
- ViewChange
- The engine
- Execution and threading
- Changes to the rows
- Server mode
- Selection and edits
- Schema integration
- API reference
- Measurements
- Failure modes
- Design decisions
- Not here yet
- Cross-references
renderer list model applies ViewChange as keyed list operations
│ reads
table::Engine sort chain, filter, view ↔ source mapping (owner-affine)
│ runs │ uses
detail::ViewJob Services: TextCollator · DateParser · comparators · ProgressSink
│ reads
RowSnapshot (immutable) ◄── DataSource (owner-affine): columns, snapshot(), change notifications
Header-only, in the base morph target. Nothing here includes a GUI toolkit
or the reactive runtime.
| Header | Holds |
|---|---|
data_source.hpp |
ColumnKind, ColumnInfo, Cell, QuantityCell, RowId, TableError, RowSnapshot, TableSnapshot, DataSource, VectorSource, RowPatcher, the services, parseDecimal, displayText |
sort.hpp |
SortKey, SortChain, CellState, KeyColumn, buildKeys, compareKeys, RowOrder, ChunkedMergeSort, sortRows |
filter.hpp |
FilterEntry, Combine, ColumnFilter, GroupFilter, FilterSpec, CompiledFilter, compileFilter, FilterRun |
view_change.hpp |
ViewOp, ViewChange, diffViews, applyViewChange |
engine.hpp |
Engine, EngineOptions, EngineStats, ReorderPolicy, ColumnLayout |
query.hpp |
PageRequest, TableQuery, Page<Row>, QueryLimits, validate, escapeLikePattern, ApplyOptions, PageRows, apply, RowsSource<Row>, PageWindow<Row> |
selection.hpp |
TableMode, SelectionMode, Selection, TableCounts, counts |
edits.hpp |
EditRequest, FieldError, EditCommit, CellEdits |
A DataSource is owner-affine and publishes three things: its columns(), a
snapshot() of its rows, and change notifications to ChangeListeners. Cells
are read only through a RowSnapshot, which is immutable once published
and safe to read from any thread for as long as it is held. That split is what
lets a job read 100,000 rows on a worker while the owner keeps changing the
source: a change publishes a new snapshot and leaves the old one alone.
TableSnapshot stores rows in shared, immutable 256-row chunks.
withRow, withInserted and withRemoved return a new snapshot that shares
every chunk the change does not touch, so an update against a snapshot a job
still reads costs one chunk copy. VectorSource is the in-memory source built
on it (tests, servers, sources that cannot share their storage); it also
implements RowPatcher, replacing a row by key. RowsSource<Row> (in
query.hpp) is a source over a shared std::vector<Row> by reflection.
readColumn(column, sink, first, last) hands a range of one column to a
ColumnSink in runs, so building keys for a column is one virtual call per
run, not per cell, and stops cleanly between ranges.
A Cell is monostate (empty), int64, Rational, double, string,
bool or QuantityCell (amount in its own unit and the toCanonical
factor). A string in a non-text column is parsed for the column's kind.
Each column a sort or filter uses gets a KeyColumn, built once per snapshot
and shared immutably between jobs: one typed array and a CellState (Valid,
Empty, Invalid) per row.
| Kind | Key | Read from |
|---|---|---|
Integer, Key |
int64 |
int64, an integral Rational, integer text |
Decimal, Quantity |
Rational |
Rational, QuantityCell (checkedMul; overflow is invalid), int64, decimal text (parseDecimal) |
Number |
double (NaN is invalid) |
double, int64, Rational, number text |
Date, DateTime |
int64 days / UTC seconds |
int64, text through the DateParser and ColumnInfo::format |
Bool |
0 / 1 | bool, 0/1, "true"/"false" |
Text |
collation key, fold, raw text | any cell's display text |
Custom |
the cell, or the display text's collation key | through the named comparator, or by text when the name is unknown |
Normative ordering rules (compareKeys, RowOrder):
- Empty and invalid cells sort after valid ones in either direction; among themselves they tie.
- Each key of a chain has its own direction.
- Ties break by source row, so
RowOrderis a strict total order and every sort is stable. - Decimals and quantities compare exactly (128-bit cross products); no exact key passes through a double.
ChunkedMergeSort sorts 512-row runs, then merges pass by pass. Every 2,048
elements it looks at the clock and the stop token; run returns at a deadline
or a stop request and resumes from the same place.
FilterSpec is data, glaze-readable in spec 7 §6's shape:
{ "columns": { id: { "combine", "include", "exclude" } }, "groups": [ { "columns", "combine", "include" } ] }.
A FilterEntry sets exactly one operator.
| Kind | Operators |
|---|---|
Text |
eq, ne, contains, startsWith, endsWith |
Integer, Key, Decimal, Quantity, Number, Date, DateTime |
eq, ne, lt, le, gt, ge, between (inclusive) |
Bool |
eq, ne |
| every kind | isEmpty, notEmpty |
Semantics, normative:
includeentries of one column combine bycombine(any, the default, orall); a match on anyexcludeentry rejects the row; columns and groups combine by AND.- No operator but
isEmptyandnotEmptymatches an empty or invalid cell, of any kind —neincluded.isEmptymatches empty cells only. - Values are parsed once, for the column's kind, by
compileFilter. Integer columns compare against exact decimals. - Text operators compare the collator's
foldof both sides; an entry withcaseSensitivecompares the raw text.%and_are ordinary characters. - A group matches a row when its entries match on any of its columns. An entry that a group column cannot evaluate (operator undefined for the kind, or a value that does not parse) does not apply to that column; it is an error only when it applies to none.
compileFilter returns TableError with UnknownColumn, InvalidSpec (no
operator or several), UnsupportedOperator or InvalidValue, naming the
column. FilterRun evaluates column at a time over a shrinking candidate
list, compacting in place and keeping the candidates' order — which is what
lets a filter over a cached sorted order stay sorted.
diffViews(before, after, changed, identityLimit, resetThreshold) computes the
list operations that turn one view order into another, over integer row
identities. Replay contract, which applyViewChange implements and every test
checks against:
Removed{first, count}, back to front;Moved{first, to}— erase atfirst, insert attoof the shortened list;Inserted{first, count}, front to back, rows taken from the new view;Changed{first, count}in final positions, for rows in both views whose content changed;- or a single
Resetwhen there would be more thanresetThreshold(default 5,000) operations.
Rows on a longest increasing subsequence of the new order stay put; only the rest move, so moving one row is one operation. Move indices come from a Fenwick tree over gap slots, so a diff is O(n log n).
Engine(std::shared_ptr<DataSource>, EngineOptions) subscribes to its source
for its lifetime. The initial view is every row in source order.
setSort(SortChain)andsetFilter(FilterSpec)returnstd::expected; on an error (unknown column, a filter that does not compile, orServiceFailedwhen the job ran inside the call and an injected service threw) the previous state stays. Neither throws. Columns are checked against the source's latest columns.columns()are the applied view's columns, whatcellAtindexes; a column past them reads an empty cell. A reset that changes the columns changescolumns()when its view is applied, and publishes oneReset.viewRowCount,sourceRowCount,sourceRowOf,viewRowOf,rowIdAt,viewRowOfKey,cellAt,viewread the applied view, which keeps showing while a job is pending.onViewChangereceives eachViewChange, after the view changed;onPendingreceivespending()transitions;onErrorreceives aServiceFailedfrom a job that did not run inside the call that asked for it (the sort and filter then go back to those of the view shown).- A filter change with a sort active filters the cached sorted order of the full source (no re-sort). A sort change with a filter active sorts only the surviving rows (no cache is made). Key columns are reused across requests on the same snapshot.
stats()counts jobs, full and subset sorts, key builds, repairs, dropped results, owner steps and the longest owner step.
ColumnLayout keeps column order and visibility apart from the view: at least
one column stays visible, reset restores the defaults, and rebuild adopts a
new column list keeping what remains.
Every recomputation is a detail::ViewJob: a snapshot, the key columns it may
reuse, the compiled sort and filter, the previous view, and phases (keys, then
order, then diff) it runs in steps. A job never names the engine.
| Situation | Where the job runs | pending() |
|---|---|---|
EngineOptions::owner == nullptr |
inside the call, one step | never |
fewer than smallTable (2,000) rows |
inside the call, one step | never |
| a worker executor | the worker, to completion or a stop | until the result is applied |
| no worker | steps posted to the owner, each within frameBudget (8 ms) |
until the result is applied |
- One job at a time. A request that makes the running job's result useless (a new sort, filter, column set or structural change) requests stop on it; when it reports back its result is dropped and the work starts from the latest state. Updated rows do not stop a job: they wait and are repaired after its result is applied, so a stream of updates cannot starve the view.
- Pending work. Dirty rows (changed since the key columns were built) and
rows owed a
Changedare handed to a job and handed back if it is dropped or fails, so aChangedis never lost to supersession. The cached sorted order is only ever of the rows the keys were built from. - Callbacks. Any handler (
onViewChange,onPending,onError, the progress sink, the settle scheduler, a source's listener, aCellEditscommit, a patch's notification) may call back in or destroy its caller. Each object calls out last, or takes aCallbackScopetoken before the call and returns if it expired. A handler that starts a job keepspending()true. A listener unsubscribed during a notification is not called after that, even if it was registered when the notification began. - Exceptions. A collator, date parser or comparator that throws ends its
job (
ViewJob::failure); the work it took is handed back and the error isServiceFailed. - Lifetime. Completions are posted to the owner wrapped in the engine's
CallbackScopeguard, so a job that outlives its engine has its result dropped. The destructor requests stop on the running job. The owner and worker executors must outlive every job the engine started. - Services on a worker.
Services::forTask()fills defaults and clones a collator or date parser whosecloneForTask()returns one. Comparators are called from the worker and must be safe to call concurrently. AProgressSinkhears one call per job phase on a worker, one per step with no worker, on the owner. - What touches what. Owner: the engine, the source,
onViewChange,onPending,ProgressSink. Worker: the job's own state, shared immutable snapshots and key columns, the cloned services. A repair copies the key columns it patches rather than writing ones another job may read. - Profiler zones (
MORPH_ENABLE_TRACY):table.snapshot,table.keyBuild,table.sortandtable.filter(with the chain or filter size as zone text),table.apply,table.change; plotstable.sourceRows,table.viewRows,table.keyCacheColumns.
Notifications are coalesced: the first one in an owner turn posts a flush; the flush takes the source's new snapshot and handles everything notified since. With no owner executor the flush runs inside the notification.
- Updated rows are repaired: their keys are rebuilt on copies of the key
columns, they are filtered, sorted among themselves and merged into the
previous order (and into the cached full order). No sort of the whole table
happens. The change is
MovedandChangedoperations. - Inserted, removed and reset recompute: key caches are dropped,
the view is computed afresh, and the old view maps to the new rows by
RowId. A reset re-reads the columns and keeps the sort keys and filter entries whose columns remain; a remaining filter entry that no longer compiles clears the filter. - A recompute while updates are pending re-keys the dirty rows on copies and merges them into the cached full order before it filters.
Deferred reorder (ReorderPolicy::Deferred): after a repair, every
updated row that was in the view, and every row between beginEdit and
endEdit, keeps its displayed index; the rest of the view takes its true
order around it. A held row stays even if its update took it out of the
filter. scheduleSettle(settle, fire) arms a timer per repair; only the most
recent one releases (any update in between makes earlier timers stale), and it
releases every held row not being edited. endEdit releases its row at once, and the
update its commit patched, which reaches the engine a turn later, does not hold it again: a committed
edit moves its row once.
The release stands for the notifications up to endEdit: a view that had
already taken in the commit's update drops it, so a later update holds the
row as usual. Holds released while a job runs are laid out with its result.
settleNow() releases without a timer. A sort or filter change, or a
structural change, releases everything when its view is applied, rows being
edited included; the next repair holds them again.
TableQuery { sort, filters, page },PageRequest { offset = 0, limit = 200 },Page<Row> { rows, total, offset }— glaze-readable, in spec 7 §9's shape.validate(query, columns, limits, mayRead)appliesQueryLimits:page.limitis clamped tomaxLimit(1,000); a negative limit, an offset outside[0, maxOffset], too many sort keys, filtered columns, entries, groups or group columns, or a value longer thanmaxTextLength, isLimitExceeded; a column that is not one of the row's isUnknownColumn; onemayReadrefuses isColumnNotReadable. A hidden column is not a security control, so the check is here.apply(const DataSource&, query, options)validates, compiles the filter against the server's services, runs oneViewJobin the calling thread and slices the page.totalis omitted aboveQueryLimits::totalCap(100,000).apply(rows, key, query, options, kinds)does the same over typed rows and returns aPage<Row>. Text order is the server's collator; a client in server mode does not re-sort a page.escapeLikePatternis for a database-backed server that mapscontainsto SQLLIKE: it keeps%and_ordinary.TableErrorserialises with its code by name ("unsupportedOperator"), so a server returns it as the action's typed error.PageWindow<Row>is the client's view of a server-mode table:visible(first, count)returns thePageFetches (aPageRequesttagged with the query) to send around the visible rows (one page of margin each side, nothing past a known total, nothing complete or in flight);accept(fetch, page)stores a reply and evicts the pages farthest from the visible rows beyondmaxPages;fail(fetch, error)records the error the table shows until the next reply;resetdrops everything and starts a new query when the sort or filter changes. A reply or failure for an older query is ignored. A reply shorter than asked (a server's clamped limit) leaves its page incomplete, and the rest is fetched from where it ended; a known total survives a reply that omits it.
RowsSource<Row> has one column per key glaze writes for the row, read
through the row's glz::meta when it has one (which may omit, reorder or
compute members), and infers kinds from the value types: bool → Bool, other
integers → Integer (an unsigned value above INT64_MAX is an invalid cell),
floating point → Number, Rational → Decimal, Quantity → Quantity,
DateTime and Timestamp → DateTime, a type glaze writes as one of its
members (Ranged, Choice, Tagged) → that member's kind, anything else →
Text (enums by their glaze name). std::optional empties, and anything glaze
writes as null, are empty cells. A kinds map overrides the inference by
column id (an int64 of epoch days is a Date); a comparators map names a
Custom column's comparator. apply compiles the filter with its own
Services::forTask() copy.
Selection::create(SelectionOptions) refuses pruneOnFilter in server mode.
The selection is a set of RowIds (in selection order) plus an active key, so
it survives sort, filter and refetch. Single replaces, None refuses.
selectAll(engine) needs Multiple and client rows. viewChanged(engine)
prunes rows that left the view when pruneOnFilter is set. activate is
static: it reports the row at a view row and reaches no selection.
counts(engine, selection) gives a status line its numbers.
CellEdits(patcher, commit, engine) commits EditRequests through the
injected commit (the table's mutation), which settles each with the patched
row or a FieldError. Edits to one row run one at a time in commit order;
other rows run alongside. A cell is stale from its commit until it settles;
an error stays on its cell until an edit of that cell succeeds. A success
patches the row through the RowPatcher. With an engine, a row is held
(beginEdit) from its first commit until its last edit settles. A completion
after the CellEdits is destroyed is ignored.
- A member type with
static constexpr std::string_view schemaMarker() noexceptmarks its property in a forms schema with that keyword set totrue.TableQuery::schemaMarker()is"x-table": a client recognises a list action that takes a table query from its schema. morph::views::detail::deriveColumnsemits each column'skindin theColumnKindnames, plustitle,x-unitAlternatives,x-comparatorandenum, and skipsx-hiddenmembers. Astd::optionalmember, which glaze writes asanyOfits type andnull, derives from its type's alternative (../forms/views.md).
| Symbol | Contract |
|---|---|
DataSource::snapshot() |
Cheap; the snapshot is immutable and thread-safe to read. |
Engine::setSort / setFilter |
std::expected<void, TableError>; state unchanged on error. |
Engine::pending() |
True from an asynchronous request until its result is applied. |
Engine::onViewChange |
Called on the owner after the view changed; replay per ViewChange. |
Engine::beginEdit / endEdit / settleNow |
Deferred-reorder holds. |
compileFilter |
Parses every value once; errors name the column. |
diffViews / applyViewChange |
The list-operation contract. |
validate |
Clamps the limit, refuses every other bound. |
apply |
Synchronous reference implementation of server mode. |
PageWindow |
Pages around the visible rows, bounded, with an error state. |
Selection |
Keyed selection; server mode has no select-all or pruning. |
CellEdits |
Ordered per-row commits with stale and error state. |
AMD Ryzen 5 7600X (6 cores, 12 threads), Linux, GCC 16.2.1, Release (-O3),
morph_table_tests "[.benchmark]", on the code as shipped. The machine also
hosts CI runners, which were busy: load average 14.4 during the run. An
earlier revision measured on the idle machine (load 2.6) ran 1.5 to 2 times
faster, so read these as an upper bound. Median and 10th-90th percentile,
milliseconds; keys are built inside every timing.
| Scenario | 1,000 rows | 10,000 rows | 100,000 rows |
|---|---|---|---|
| sort: one numeric key | 0.13 (0.12-0.16) | 1.77 (1.73-1.81) | 33.01 (30.70-35.21) |
| sort: one text key | 0.23 (0.21-0.24) | 2.68 (2.65-2.75) | 46.28 (44.41-47.41) |
| sort: three keys | 0.17 (0.13-0.18) | 2.18 (2.13-2.25) | 40.35 (39.44-41.78) |
| filter: text contains | 0.09 (0.08-0.10) | 0.94 (0.91-0.96) | 9.25 (9.18-9.34) |
| filter: numeric compare | 0.03 (0.03-0.03) | 0.33 (0.32-0.36) | 2.63 (2.60-2.73) |
| filter: group | 0.10 (0.09-0.11) | 1.00 (0.97-1.04) | 9.06 (8.94-9.23) |
| sort then filter | 0.16 (0.15-0.18) | 2.15 (2.12-2.21) | 35.71 (34.77-36.37) |
| filter then sort | 0.09 (0.08-0.10) | 1.41 (1.40-1.43) | 16.18 (15.28-18.26) |
| toggle one filter ten times, sorted | 0.16 (0.15-0.17) | 2.30 (2.28-2.35) | 17.88 (17.17-19.37) |
| 1,000 updates while sorted (one repair, no sort) | 6.51 (6.32-6.61) | 8.38 (8.19-8.65) | 27.39 (27.13-28.09) |
Owner blocking, three-key sort then a text filter, per run:
| Execution | Rows | Worst owner step, median of runs | Worst of all runs | Request to result, median |
|---|---|---|---|---|
one step (below smallTable) |
1,000 | 0.18 | 0.21 | 0.29 |
| owner steps, 8 ms budget | 10,000 | 2.22 | 2.52 | 3.27 |
| worker | 10,000 | 0.00 | 0.01 | 4.05 |
| owner steps, 8 ms budget | 100,000 | 9.61 | 11.61 | 57.32 |
| worker | 100,000 | 0.03 | 0.04 | 95.71 |
A step runs past its 8 ms budget by at most one chunk of work plus whatever the scheduler takes from it; on the idle machine the worst step was 8.03 ms.
Rows per envelope: a page of rows of ten small cells costs 212.2 bytes per row
inside an ok envelope; 39,361 rows fit in kMaxEnvelopeBytes (8 MiB), checked
against wire::decode both ways.
| Situation | Behaviour |
|---|---|
| A filter value that does not parse | InvalidValue naming the column; filter unchanged. |
| A sort on an unknown column | UnknownColumn; sort unchanged. |
| A quantity whose canonical value overflows | The cell is invalid and sorts last. |
| A sort, filter or structural request while a job runs | The job is stopped and its result dropped; the latest state runs next. |
| An update while a job runs | It waits; a repair follows the job's result. |
| An injected service throws | ServiceFailed; the sort and filter stay those of the view shown. |
A handler destroys the engine, source or CellEdits |
The caller returns without touching itself. |
| The engine destroyed with a job in flight | The job is stopped; its completion is a no-op. |
| A reset that removes a sorted or filtered column | That sort key or filter entry is dropped. |
| A server query over a bound | LimitExceeded (the limit itself is clamped). |
A commit's completion after its CellEdits is gone |
Ignored; the destructor ended the row's engine edit. |
- Cells through snapshots, not the source. A worker cannot read a source the owner is changing, so cells are read from an immutable snapshot the source publishes, and the source itself is touched only on the owner.
- Chunked snapshots. Copying the whole table on each update would make a stream of updates quadratic; with 256-row chunks one update is one chunk copy.
- Results dropped, not merged, on supersession. Applying a stale result
and then the new one would emit two
ViewChanges for one user action and a flicker; dropping it costs only the stopped work. - A repair merges; a structural change recomputes. Merging needs stable row indices; inserts and removes shift them, and are rarer.
- Self-timed benchmarks. The spec asks for medians and spread; Catch2's
BENCHMARKreports a mean and a standard deviation.
-
With no worker, a recompute's diff is one owner step (about 30 ms for an insert into 100,000 rows), and applying a
Deferredrepair lays out and diffs on the owner (10 to 55 ms at 100,000 rows). -
RowsSourcecannot name a date column's text format. -
query_rows_source.hpp(QueryRowsSource, a source over amorph::reactivequery's rows, diffing a refetch by key) needs the reactive module, which is not onmasteryet. -
The
tabledocument node (spec 5 §9, spec 7 §12), its renderer list model, and the mounting measurement belong to the UI document interpreter.
- Spec 7:
docs/superpowers/specs/2026-10-08-table-engine-design.md. - ../core/callback_scope.md — the lifetime gate on every completion.
- ../core/executor.md — owner and worker executors.
- ../util/rational.md — exact keys.
- ../forms/views.md — derived column metadata.