@xenosystem/data-core
v0.1.0
Published
The shared data contract for the XENO data-panel family — XenoResultSet down, XenoQuery up — plus the pure pipeline (filter, sort, group, aggregate, paginate, format, pivot). Zero dependencies, zero DOM.
Readme
@xenosystem/data-core
XenoResultSetflows down,XenoQueryflows up. One contract for the whole data-panel family, plus the pure pipeline that realises it.
Six panels — table · connectors · fields · chart · metrics · calendar — and every host
resolver speak exactly this vocabulary. That is what makes connectors → table → chart wireable at
all: all four ports carry the same two shapes.
Zero dependencies. Zero DOM. Zero React. Importable from a panel, a host, a worker, or a test.
The contract was invented twice before it was written down
sheets' SQLEngine.SQLResult and DatabaseConnector.DatabaseQueryResult are field-for-field
identical ({columns, rows, rowCount, executionTime, error?}) — convergent invention inside one
repo. This package is that shape, widened by a 23-surface survey and hardened against the twelve
landmines in XENO DATA PANELS - UNION SPEC.md §6.
Install
npm install @xenosystem/data-coreQuickstart — a host resolver
import { runQuery } from '@xenosystem/data-core'
// A creative app answering a panel's queryRequest from its own engine.
host.on('queryRequest', ({ requestId, query }) => {
const result = runQuery(engine.allRows(), query, { columns: engine.columns, rev: engine.rev })
host.emit('result', { requestId, result })
})That is the whole seam. The panel never reads the network; the host never renders a cell.
What the types enforce
Several fields exist specifically to make a shipped bug unrepresentable:
| Field | Prevents |
|---|---|
| XenoRow.id (mandatory) | positional row identity — sort/filter/page a positional result and selection, editing and drill-down all target the wrong row |
| XenoColumn.id ≠ name | resolving a column by display name; a JOIN with two same-named columns silently picks the first |
| XenoAggregate.totalFn | grand totals that always sum — an average column whose total is the sum of the averages |
| XenoColumn.type | comparators chosen by runtime typeof, which degrade a numeric column to lexicographic on one stray string |
| XenoWindow.timeZone (mandatory) | UTC day-bucketing that puts a 23:00-local event on the wrong day |
| XenoGroup.key: XenoScalar[] | group-key collisions — a real value "No Value" merging into the empty bucket |
| XenoFilterOp.relative | hardcoded 7/30/90-day tabs, which is what one product shipped for want of this primitive |
| XenoValueFormat.nullText | a hardcoded empty-display word that a real value can collide with |
Two of those are wrong answers currently on screen in shipping products. Each has a named
regression test in test/landmines.test.ts.
The pipeline
filter → sort → group → aggregate → paginate → (format at render)Stage order is not negotiable. Aggregating after paging computes the page's average, not the column's. Grouping after paging invents groups that do not exist. And totals always come from the full filtered set — the page is a viewport, not a population.
| Stage | Module | The decision |
|---|---|---|
| filter | filter.ts | One operator enum over four incompatible vocabularies. relative stores {unit, n, anchor} as data, so a saved query still means what it said tomorrow. An empty group passes — a half-authored filter must not blank the table. |
| sort | compare.ts | One comparator pass, not N stable passes. Comparators come from column.type. Nulls are placed (last by default, in both directions), not compared. Documented tiebreak on row.id so paging can never repeat one row and drop another. |
| group | group.ts | Structured XenoScalar[] keys with a control-character-delimited internal serialization; null is a sentinel, never a word. Date buckets go through the zone; numeric buckets are fixed-width. |
| aggregate | aggregate.ts | 17 functions incl. percentiles (R-7 / PERCENTILE.INC, stated because definitions differ) and sample stdev/var (n-1). Non-numeric cells are excluded, never counted as 0. |
| totals | aggregate.ts | totalFn ?? fn, computed from the rows — never rolled up from the group results. DECOMPOSABLE_AGGREGATES names the exact set for which a roll-up would have been safe. |
| pivot | pivot.ts | Each (column-key × measure) becomes a synthetic leaf column, so a grid reads group.aggregates[column.id] with no pivot-specific lookup. This is why pivot is a view mode and not a sixth panel. |
| paginate | pipeline.ts | Last. Clamps nonsense input rather than returning garbage. |
| format | format.ts | Presentation only — never fed back into a comparator. (A grid that sorts formatted strings puts '1,000' before '900'.) |
Time is where two products got a wrong answer
A date is a calendar day with no instant — '2026-03-15' is the 15th everywhere, and applying
a zone to it is a bug. A datetime is an instant, which only becomes a day once a zone is
applied. That distinction is why the two types are separate, and time.ts honours it:
bucketKey(parseTime('2026-03-15T00:30:00+02:00', 'datetime')!, 'day', 'Europe/Bucharest') // '2026-03-15'
bucketKey(parseTime('2026-03-15T00:30:00+02:00', 'datetime')!, 'day', 'UTC') // '2026-03-14' ← the bug
bucketKey(parseTime('2026-03-15', 'date')!, 'day', 'America/Los_Angeles') // '2026-03-15' ← never shiftsCalendar shifts also clamp rather than overflow: one month before 31 March is 28 February, not 3 March. (Found by a test written against this package, not against the survey.)
The data-access seam
queryRequest out / result in, correlated by a mandatory requestId — the panel keeps only
the newest outstanding id and drops stale results. Without it a slow page-1 response lands after
page-2 and the grid silently shows the wrong page.
Two resolvers, one contract. In a creative app the host answers from its own engine; in
xeno-apps the connectors panel is the resolver, wired in the user's graph. Neither the table
nor the chart can tell the difference — which is the property that makes this family serve both
goals at once.
Consequence: table, chart, metrics, calendar and fields declare storage.local and
nothing else. connectors is the sole capability holder.
Scripts
npm run typecheck # tsc --noEmit
npm test # vitest run (106 tests)
npm run build # tsup → dist (esm + cjs + d.ts)See INTEGRATION.md and examples/ (a node-runnable resolver).
