developer guide
Sources and pushdown: JavaScript Data Grid Guide
The memory, paged, remote and streaming sources, pushing the grid's query down to an engine, high-rate live updates and wiring a WebSocket feed into the data router yourself, and grids built from other grids: derived, stacked into one list, joined, cross-filtering, and one grid holding the correlations, the time-series read or the comparison of another.
Developer guide › Sources and pushdown
Working with large data
A million rows in memory is fine: that is what the columnar store is for. Beyond that, or when the data lives behind an API, a source takes over.
| Source | For |
|---|---|
| memory | The default. Everything is present; the grid does all the work. |
| paged | A page at a time from a server that paginates. |
| remote | Blocks fetched on demand as the user scrolls, with sort and filter pushed to the server. |
| stream | Rows arriving over time, a query that streams, a socket. Promotes to memory once complete. |
Trimming the memory footprint
By default the store retains the row objects you hand it by reference, so
rows.data() returns those exact objects and row === sourceObject
holds. The ingest option controls that:
| Option | Type | Description |
|---|---|---|
| retainSource | boolean | Default true. Set false to keep only the packed columns and reconstruct a plain row object from them on demand. rows.data() then returns freshly reconstructed objects - a new object each call - so row === sourceObject and a custom renderer reading row.sourceObject no longer hold, and equality becomes value-based. Cell values are identical either way, so get(), byKey(), value() and values() are unaffected. On its own this drops only the store's reference array, not the objects: the source layer still holds them. |
| dropSourceRows | boolean | Default false. Set true to release the caller's row objects from the source layer and the grid config once the store is built, so the packed columns are the only resident copy. This is where the large reduction lives - roughly an order of magnitude at a million rows - because the caller's objects are the dominant term. Implies retainSource: false (the store must reconstruct), so it carries the same identity tradeoff. An impure computed column - a shadow or a rank/positional column - is never stored, so it cannot be served from the columns: it warns once and drops out rather than answering wrongly, and rows.move() is refused. Do not enable it on a grid that sorts, filters, groups or totals on such a column, or that reorders rows in place. |
Dropping retained source objects
createGrid(el, {
columns,
rowKey: 'id',
rows,
// Release the caller's objects entirely; the packed columns are the sole copy.
ingest: { dropSourceRows: true },
});
With retainSource: false alone the saving is only the store's own copy of the
object references, not the objects themselves: the source layer and the grid config still
hold the array, so whoever handed the grid its rows keeps them alive. dropSourceRows
releases those references too, so once the caller lets go the objects can be collected and the
grid becomes the sole holder of the data. Reach for it on a large, read-mostly grid where you
can let go of the source array and do not depend on caller identity through
rows.data().
A remote source
source: {
mode: 'remote',
pageSize: 100,
async fetch(req) {
const res = await api.rows({
offset: req.range.start,
limit: req.range.end - req.range.start,
sort: req.sort, // [{ col, dir }, …]
filters: req.filters, // the condition tree, as documented above
quick: req.quick,
}, { signal: req.signal }); // a superseded request aborts itself
return { rows: res.rows, total: res.total };
},
}
The request also carries groupBy, groupPath,
pivotBy, totals and your own context, so a server that
can group and aggregate does that work instead of the browser. Blocks are fetched as the
viewport reaches them and cached; changing the sort or the filter invalidates the cache and
re-queries.
The filters your callback receives is the same condition tree documented
above. You are not handed an opaque object to reverse-engineer, it is the published format,
and the same shape you would have written by hand.
Loading and updating data
Data usually arrives after the grid does. Build it empty, then load, the sort, filters, grouping and column layout you set up in the meantime all survive and apply to the new data.
- The automatic overlay and per-row treatment for a windowed source
- What an empty grid says
- Straight from a URL, with
createUrlSource
Full guide: Loading and updating data ›
Holding live updates
A pause button for incoming data. Changes are held and merged while paused, applied when play is pressed, and counted throughout, so the coalescing that makes a live grid fast is finally visible.
Full guide: Holding live updates ›
Data Router: a live WebSocket feed
createDataRouter is built for a continuous live feed - ordering,
de-duplication, batching, resume after a drop, and moving a row between routes rather
than duplicating it are all shipped. What it does not do is open the connection.
The host owns the connection; the router owns everything after the message
arrives. That is a boundary worth stating plainly, because nothing in the
router's own name says so, and the two questions every integrator asks first - “does it handle the socket?” and “how do I send it data?” - both turn on it.
- Ordering, dedupe, and batching a fast feed
- Reconnect and resume
- The knobs a live feed makes you reach for
- Several independent feeds, one router
Full guide: Data Router: a live WebSocket feed ›
Querying an engine directly
A remote source hands you the whole request and leaves the translation to you. A pushdown adapter inverts that: you declare what your engine can answer, and the grid works out what to send and finishes the rest itself.
An OData endpoint, with nothing to write
import { createPushdownSource, odataAdapter } from '@toclocoinc/lattice-grid';
createGrid(el, {
columns,
rowKey: 'id',
source: createPushdownSource({
adapter: odataAdapter({ url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders' }),
pageSize: 100,
}),
});
Eight adapters ship. odataAdapter writes $filter,
$orderby, $top and $skip, and follows
@odata.nextLink when the server pages on its own terms.
restAdapter covers an ordinary JSON endpoint whose parameter names are yours to
give. dfqlAdapter speaks the DFQL query API. duckdbAdapter
takes a live DuckDB connection.
graphqlAdapter POSTs a GraphQL operation: because GraphQL has no fixed query
semantics, you supply a buildQuery that turns the plan into
{ query, variables } and a parseResponse that reads
data back into rows and total, with defaults for an offset/limit list and a
Relay cursor connection. The default pushes only the window and the total, so anything the
schema cannot answer stays with the grid.
splunkAdapter speaks Splunk’s search REST API: it dispatches a search job for
the base SPL you give it, turns the grid’s filter into an SPL search expression
with every value quoted and escaped, pages through /results, takes the match count
from the job, and cancels the job the moment the grid asks something else. A filter on the time
column becomes the job’s own earliest_time/latest_time window where
Splunk’s boundaries map onto the operator exactly. Your token travels in
headers or in a fetch wrapper; the grid stores no credential.
clickhouseAdapter speaks ClickHouse’s HTTP interface: it POSTs SQL for the
table or query you name, with every filter value sent as a typed query
parameter rather than written into the statement, pages with LIMIT/OFFSET,
counts with a second count() statement, and computes source.aggregate()’s
sum, average, minimum, maximum, count and exact distinct count in the engine. Statements are sent
with readonly=1 and nothing else by default, so a read-only analyst account works
unchanged; set max_execution_time in settings on a writable or service
account. The grid’s grouped view is not pushed - the grid groups the rows it holds - and a
ClickHouse refusal reaches the grid as an error carrying ClickHouse’s own message.
elasticsearchAdapter speaks the Elasticsearch and OpenSearch search API for the
index you name: the grid’s filter becomes a Query DSL bool query built
as JSON, so no value is ever spliced into a query string; a page is one _search carrying
its exact count, and a page past the index’s result window is reached with
search_after over a point in time. It reads the index mapping once, so a text field is
compared, sorted and grouped through its .keyword sub-field, and a text field that has
none cannot be sorted: that is refused by name, with the fix, before anything is sent.
source.aggregate()’s sum, average, minimum, maximum, count and distinct count are
computed in the engine, grouped through terms aggregations. Your API key travels in
headers or a fetch wrapper, and in production a proxy in front of the
cluster keeps it out of the page altogether.
Declaring what an engine can do
No real engine answers the whole query. An adapter says what it can take, and everything undeclared stays with the grid.
An endpoint that pages and sorts, but does not filter
restAdapter({
url: '/api/readings',
params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
capabilities: { sort: 'single', range: true, total: true },
})
| Capability | Values | Means |
|---|---|---|
| filter | false | 'term' | 'flat' | 'tree' | Nothing, one field and term, a flat conjunction, or a full condition tree. |
| operators | string[] | Which comparisons the engine genuinely applies. Declare only those it does. |
| sort | false | 'single' | 'multi' | No ordering, one column, or several. |
| quick | boolean | Whether a free-text search across columns can be pushed. |
| range | boolean | Whether the engine can return a window rather than the whole result. |
| total | boolean | Whether it can report how many rows matched. |
| group | boolean | Whether it can group and aggregate. |
Everything is off unless declared. An adapter that declares nothing still works: the grid fetches and does all the work itself. That is the safe direction to be wrong in. Declaring an operator the engine does not really apply is the unsafe one, because the grid will trust it and stop checking.
Splitting a filter is not symmetric. An and group narrows
with each condition, so the supported conjuncts go to the engine and the rest stay behind:
the engine returns a superset and the grid narrows it. An or group widens with
each branch, so pushing only the supported branches would return fewer rows than the filter
allows, and the grid cannot recover rows that were never fetched. A disjunction that is not
fully supported therefore stays whole on the client. The same asymmetry governs column
pruning in the facet path.
Residual work needs the whole result. When anything is left over, the source stops asking for windows and asks for everything, applies the remainder, and pages from what it holds. Filtering a window on the client is not a slower route to the right answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page nine, and the total is whatever the engine happened to count.
Seeing what was pushed
The split is reported rather than hidden, which is the difference between a slow query you can diagnose and a slow query you cannot.
Asking after the last request
const source = createPushdownSource({ adapter });
createGrid(el, { columns, rowKey: 'id', source });
const plan = source.lastPlan();
plan.pushed; // the query the adapter was given
plan.residual; // { filters, sort, quick } the grid applied after
plan.unpushed; // ['filter'], the parts that stayed behind
plan.needsAll; // true when the whole result had to be fetched
plan.full; // true when fullDataset forced it, not just residual work
The grid also warns once, naming the predicate that could not be pushed, because the fix is
usually a better adapter rather than a bigger machine. It warns again if an adapter reports a
total larger than the rows it returned while residual work is outstanding: that
combination silently produces wrong answers, and it is worth knowing about.
A full analytical engine, without carrying one
duckdbAdapter takes a connection you created and imports nothing, so the grid
can drive DuckDB while this package stays at zero dependencies.
Parquet in the browser, no server
const db = await makeDuckDB(); // yours: @duckdb/duckdb-wasm
const conn = await db.connect();
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({
connection: conn,
from: "read_parquet('readings.parquet')",
}),
}),
});
from is any FROM expression, so
read_parquet('s3://bucket/*.parquet') is as valid as a table name. Values are
bound through prepared statements; a connection without prepare is used only for
unfiltered queries, because interpolating a user's filter into SQL is the one thing worse
than not filtering at all.
Column and table names are checked against an identifier pattern rather than escaped, and a name that fails is refused. Integers past the safe range are kept as strings instead of being rounded into a plausible lie.
A timestamp or date filter is bound through a typed placeholder - "ts" >=
CAST(? AS TIMESTAMP) - because a prepared statement binds an ISO string as
VARCHAR and DuckDB will not compare that with a TIMESTAMP,
TIMESTAMP WITH TIME ZONE or DATE column. The adapter reads the
column types once from the engine (DESCRIBE) and falls back to the grid column's
declared type, so both a typed and an untyped grid column over a timestamp filter correctly,
including a time window. Send instants ending in Z, as the grid's date filter
does: the cast is the engine's, and its zone rules apply (see the reference for the
TIMESTAMPTZ and naive-string cases).
demo/duckdb.html runs this against a Parquet file of several million readings
with no server involved, including a time-window filter on its TIMESTAMP column.
Whether a Parquet file streams or downloads whole
duckdbAdapter writes SQL and hands it to the connection you built; it never opens
the file itself, so it has no say in whether read_parquet(...) reads the whole
thing or only the bytes a query needs. That is decided by DuckDB's own configuration and by the
HTTP server the file is served from.
Enable range reads before the first query
const db = await makeDuckDB(); // yours: @duckdb/duckdb-wasm
const conn = await db.connect();
// Before the first read_parquet(...) call, on DuckDB-Wasm 1.32 and later:
await conn.query('LOAD httpfs;');
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({
connection: conn,
from: "read_parquet('https://example.com/readings.parquet')",
}),
}),
});
Measured against a 1.5 MB Parquet file on GitHub Pages: without
LOAD httpfs;, DuckDB-Wasm 1.32.0's default HTTP path issued zero Range requests
and read the whole file - 100% - for every query, chip filters included. With
it loaded on the connection first, the same query issued 25 Range requests and read
30% of the file. DuckDB-Wasm 1.29.0 read 119% of the file on the same test
(its ranges overlapped), so treat the exact percentages as a version's behaviour, not a fixed
promise - re-measure against the DuckDB build you ship.
The file host has to cooperate too: it must answer HEAD, advertise
Accept-Ranges: bytes, and answer a range request with 206 Partial
Content and a Content-Range header (GitHub Pages does all three). Cross-
origin, its CORS policy must also expose Content-Range and
Content-Length, or the browser cannot read them back. Any of that missing, and
DuckDB falls back to a full read with nothing said.
A file registered with db.registerFileBuffer(...) is always downloaded whole,
whatever version is loaded or however the host answers: a buffer has already been fetched in
full before DuckDB ever opens it, so there is nothing left to range-read. There is no
force_download option - it does not exist in DuckDB-Wasm; registering a buffer
is the way to force a whole download.
What a filtered count costs, and how to make it cost less
A grid needs two things from the engine: a page of rows and the size of the set they came from. On a remote Parquet file those two have wildly different prices, and the second one is the one that decides how your grid feels.
Measured on a 10 million-row Parquet file served over HTTP range requests, in Chrome:
- A page of 100 rows: about 0.1 s.
count(*)with no filter: about 25 ms and no data read at all - Parquet keeps the row count in its footer and DuckDB answers from there.count(*)with a filter: about 1.3 s and about 28 MB read. The predicate has to be evaluated, so the predicate's columns have to be read.
A filtered count is cheaper than fetching the rows to count them yourself. It is not cheap. Anyone who tells you a filtered count over a remote file is free is quoting the unfiltered number.
Three things you control, in the order they pay off:
Cluster the file on what you filter
-- when you write the Parquet, sort by the columns your users filter most
COPY (SELECT * FROM events ORDER BY country, risk_score)
TO 'events.parquet' (FORMAT PARQUET, ROW_GROUP_SIZE 1000000);
Sort (cluster) the file on the columns you filter most. Row-group statistics can only prune a group when the values in it are narrow, and that is what sorting makes them. On the same 10M-row file and the same filter, a file sorted on the filtered column pruned 87× more of the read than the unsorted one - the count went from reading tens of megabytes to reading a fraction of the file. This is the one change worth making first, and it costs you nothing at query time.
Write large row groups. ROW_GROUP_SIZE 1000000 roughly halved
the cost of a filtered count that could not be pruned, because a row group is the
unit of both the statistics and the read. Tiny row groups mean many range requests and much
more metadata.
Turn the count off when nobody reads it.
duckdbAdapter({ connection, from, count: false }) issues no count statement at
all. The grid scrolls open-ended - it discovers the end as you reach it - and the status bar
shows what it knows rather than a total. On a dashboard where nobody has ever asked "how many
rows match?", that is the whole 1.3 s back.
The total now arrives after the rows
When the count is on, duckdbAdapter no longer makes the rows wait for
it: a filtered query delivers the page as soon as it settles and the exact total a moment
later, and source:total fires when it lands. While it is still counting,
grid.rows.totalPending() is true,
grid.rows.totalCount() is null, and the status bar and pager say
they are counting rather than showing you the rows fetched so far. Nothing is ever estimated,
and the count still runs and still reads the same bytes - it just stops standing in front of
the rows. An unfiltered count is answered in milliseconds, so it still arrives with the rows,
exactly as before.
Rows first, the exact total when it lands
const { createHeadlessGrid, createPushdownSource } = await import('../packages/core/src/index.js');
// Stands in for duckdbAdapter over a large remote Parquet file: the page is
// ready at once, the filtered count(*) takes a second and a bit longer.
let finishCount;
const adapter = {
name: 'slow-count',
capabilities: { filter: 'tree', operators: ['eq'], range: true, total: true },
execute: async () => ({
rows: [{ id: 1, region: 'north' }],
pendingTotal: new Promise((resolve) => { finishCount = resolve; }),
}),
};
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ id: 'id', field: 'id' }, { id: 'region', field: 'region' }],
source: createPushdownSource({ adapter, pageSize: 1 }),
});
grid.filters.set({ region: { op: 'eq', value: 'north' } });
grid.rendererHost().hint(0, 1); // the renderer does this for you; a headless grid has to ask
await new Promise((r) => setTimeout(r, 20));
// The rows are here and the total is not - and the grid says which of the
// two kinds of "no total" this is.
const before = `${grid.rows.get(0).data.region} / ${grid.rows.totalPending()} / ${grid.rows.totalCount()}`;
let announced = null;
grid.on('source:total', (e) => { announced = e.total; });
finishCount(1204); // the engine's count(*) comes back
await new Promise((r) => setTimeout(r, 20));
const after = `${announced} / ${grid.rows.totalPending()} / ${grid.rows.totalCount()}`;
grid.destroy();
return `${before} | ${after}`;
Spatial pushdown
duckdbAdapter({ connection, from, spatial: true }) hands the
geometry column's work to DuckDB's spatial extension: the four
spatial filters, sorting by location, and binning into grid cells for
source.aggregate(). On first use it runs INSTALL spatial; LOAD spatial;
once per connection, in the connection's statement queue, then finds the geometry columns - any DuckDB types GEOMETRY, any a GeoParquet file's geo metadata names, and
spatial: { column }. Rows arrive as GeoJSON (ST_AsGeoJSON), which a
type: 'geometry' column reads. The operators belong to the
geometry module: without
import '@toclocoinc/lattice-grid/modules/geometry' the grid refuses a spatial
condition by name, and the adapter neither declares nor pushes one
(source.duckdb.spatial.module.<op>).
The statements a GeoParquet file of sites becomes, executed
const { duckdbAdapter, createPushdownSource, createHeadlessGrid } = await import('../packages/core/src/index.js');
await import('../packages/modules/geometry/index.js'); // the geometry type, and with it the spatial operators
// Stands in for a DuckDB-Wasm connection over sites.parquet (ten points, three
// polygons): it answers DESCRIBE the way DuckDB did once spatial was loaded, and
// records every statement it is sent.
const sent = [];
const connection = {
query: async (sql) => {
sent.push({ sql, params: [] });
return sql.startsWith('DESCRIBE')
? [{ column_name: 'id', column_type: 'BIGINT' }, { column_name: 'geometry', column_type: 'GEOMETRY' }]
: [];
},
prepare: async (sql) => ({ query: async (...params) => { sent.push({ sql, params }); return []; }, close: async () => {} }),
};
const adapter = duckdbAdapter({ connection, from: "read_parquet('sites.parquet')", spatial: true });
const source = createPushdownSource({ adapter });
const grid = createHeadlessGrid({ rowKey: 'id', source,
columns: [{ field: 'id' }, { field: 'geometry', type: 'geometry' }] });
await source.fetch({
filters: { col: 'geometry', op: 'withinBbox', value: [-1.8904, 51, 0, 53] },
sort: [{ col: 'geometry', dir: 'asc' }],
range: { start: 0, end: 50 },
});
const page = sent.find((s) => s.sql.includes('ORDER BY'));
const [where, order] = page.sql.split(' WHERE ')[1].split(' ORDER BY ');
const fns = (text) => text.match(/ST_\w+/g).join(', ');
// A table stored in British National Grid metres, named explicitly.
const bng = duckdbAdapter({ connection, from: 'depots', spatial: { column: 'geometry', crs: 27700 } });
await bng.ready();
grid.destroy();
return [
`first use: ${sent[0].sql}; ${sent[1].sql}`,
`withinBbox: ${fns(where)} bound to ${page.params}`,
`sort: ${fns(order)}`,
`pushed: ${source.lastPlan().unpushed.length === 0 ? 'yes' : 'no'}`,
`British grid: ${fns(bng.sqlFor({ filters: null, sort: [], range: null }).sql).split(', ')[1]}`,
].join(' | ');
Run against the real engine on that file (test/duckdb-spatial-browser.test.js:
DuckDB-Wasm 1.29.0 in Chrome, the file written by DuckDB and served over HTTP), the box returns
London, Birmingham and Oxford - Birmingham sits exactly on its west edge - and every
operator returns the same rows as the grid's own in-memory operator over the same data.
What each operator becomes. Each keeps its in-memory meaning, so the answer
does not change with who evaluates it. withinBbox is
ST_CoveredBy(geom, ST_MakeEnvelope(?, ?, ?, ?)) - edges count as inside, which
ST_Within would not. withinPolygon is ST_Within of the
centroid (of every point, for a MultiPoint), not of the whole shape. intersects is
ST_Intersects. withinDistance is the haversine distance from the
centroid on the same sphere, written out in SQL: the extension's
ST_DWithin_Spheroid returned a non-boolean on DuckDB-Wasm 1.29.0, takes latitude
first, and measures on the ellipsoid, up to 0.5% away from the in-memory answer. Every operand
is a bound parameter - GeoJSON to ST_GeomFromGeoJSON(?), WKT to
ST_GeomFromText(?), WKB to ST_GeomFromHEXWKB(?) - and an operand
that cannot be read matches no row and says so once.
Columns and coordinates. DuckDB's Parquet reader turns a GeoParquet column
into GEOMETRY by itself once the extension is loaded; a WKB BLOB named by
column is read through ST_GeomFromWKB. When the file's metadata
declares another CRS, or crs names one ('EPSG:27700' or
27700; it wins over the file), every read goes through
ST_Transform(geom, crs, 'EPSG:4326', always_xy := true), so filters, sorting and the
rows themselves are in longitude/latitude.
Sorting, grouping, buckets. A geometry column sorts by centroid latitude,
then longitude. Grouping the grid by one is refused by name: a shape is not a category.
source.aggregate() takes { col, bucket: 'grid', size } as a
groupBy key - the centroid binned into cells size degrees square - and hands each cell back as [x, y], covering longitudes
[x·size, (x+1)·size) and latitudes [y·size, (y+1)·size).
When the extension does not load, source.duckdb.spatial.load warns
once, the spatial operators and buckets leave the adapter's capabilities, and the pushdown source - which waits for the adapter's first use before planning - leaves the operators to the
grid. Pass compute to createPushdownSource so they are applied; geometry
then arrives as the stored WKB, which the column reads just the same, and sorting by it is refused
rather than ordered by its bytes.
What loading costs (measured 2026-09-27, headless Chrome, DuckDB-Wasm 1.29.0 = DuckDB
1.1.1). selectBundle picks the eh build on a page that is not
cross-origin isolated, and the extension then comes from
extensions.duckdb.org/v1.1.1/wasm_eh/spatial.duckdb_extension.wasm: 22.8 MB
(about 5.9 MB zstd on the wire), served with Access-Control-Allow-Origin: *, so no
proxy and no COOP/COEP headers are needed. INSTALL does nothing on Wasm (7 ms);
LOAD took 0.88-1.07 s, and the first query over the 13-row fixture that
returned GeoJSON about 1 s more. The mvp and threads builds have their
own files of the same size. Re-measure against the build you ship.
MotherDuck: the same adapter, a cloud catalogue
MotherDuck is DuckDB with a cloud catalogue attached, so duckdbAdapter needs no
MotherDuck adapter and no option: attach MotherDuck on the connection you hand it, and name
the table by its three-part name, <database>.<schema>.<table>.
Filter, sort, paging, the count, engine aggregates (plain and grouped), LIST
pushdown, spatial pushdown and write-back were all checked against a live MotherDuck database
(test/duckdb-motherduck.test.js, below). Only the connection differs, and it
differs between Node and the browser.
Node: @duckdb/node-api with ATTACH 'md:'
import { DuckDBInstance } from '@duckdb/node-api';
import { duckdbAdapter, createPushdownSource } from '@toclocoinc/lattice-grid';
// The motherduck extension reads the token from the environment:
// MOTHERDUCK_TOKEN (or motherduck_token). Never put it in the SQL text.
const instance = await DuckDBInstance.create(':memory:');
const conn = await instance.connect();
await conn.run("ATTACH 'md:lattice_demo'"); // or 'md:' for every database in the account
// The shape duckdbAdapter reads: query(sql) and prepare(sql).query(...params), each
// resolving to rows. node-api's runAndReadAll binds the values itself.
const run = async (sql, params) =>
(await conn.runAndReadAll(sql, params && params.length ? params : undefined)).getRowObjectsJS();
const connection = {
query: (sql) => run(sql),
prepare: async (sql) => ({ query: (...params) => run(sql, params), close: async () => {} }),
};
const source = createPushdownSource({
adapter: duckdbAdapter({ connection, from: 'lattice_demo.main.products' }),
});
Browser: @motherduck/wasm-client, token from your backend
import { MDConnection } from '@motherduck/wasm-client';
import { createGrid, duckdbAdapter, createPushdownSource } from '@toclocoinc/lattice-grid';
// Your backend mints a short-lived, read-scoped token per session.
const mdToken = await (await fetch('/api/motherduck-token', { credentials: 'same-origin' })).text();
const md = MDConnection.create({ mdToken });
await md.isInitialized();
// MotherDuck's rows carry DuckDB value objects for dates, timestamps, decimals and
// lists; toJson() turns each into plain JSON (a LIST into an array).
const plain = (v) => (v !== null && typeof v === 'object' && typeof v.toJson === 'function' ? v.toJson() : v);
const rows = (result) => result.data.toRows().map((row) =>
Object.fromEntries(Object.entries(row).map(([k, v]) => [k, plain(v)])));
const connection = {
query: async (sql) => rows(await md.evaluateQuery(sql)),
prepare: async (sql) => ({
query: async (...params) => rows(await md.evaluatePreparedStatement(sql, params)),
close: async () => {},
}),
};
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({ connection, from: 'lattice_demo.main.products' }),
}),
});
Why the browser needs the small wrapper. An MDConnection is
not a DuckDB-Wasm connection: it answers evaluateQuery and
evaluatePreparedStatement, not query and prepare. Do not
hand the adapter the underlying DuckDB-Wasm connection instead: the client's own README says an
AsyncPreparedStatement's query method can deadlock with the MotherDuck
extension, and prepare(sql).query(...) is exactly how the adapter binds a filter.
The wrapper routes both through the client's evaluate methods, which it serialises per
connection.
The token belongs to the host, not the page. A MotherDuck token in page
source is a key to the whole account for anyone who opens devtools. Mint a short-lived,
read-scoped token on your backend for the signed-in user and hand it to the page at run time,
as the example does; refresh it by calling the client's terminateDuckDB() and
creating a new connection. For a public page, publish the data as a read-only MotherDuck share
in an account that holds nothing else, and mint the token there. On Node the token lives in
the process environment (MOTHERDUCK_TOKEN; DuckDB 1.5.5 also accepts
motherduck_token) and never in SQL text, which ends up in logs.
What was measured, and what it means for your grid (2026-09-28, Node 22,
@duckdb/node-api 1.5.5-r.5 = DuckDB 1.5.5, 50,000 rows):
- Attaching took 0.7-1.8 s (
ATTACH 'md:'orATTACH 'md:lattice_demo', six runs); budget it once per process, not per query. - The first query (the adapter's
DESCRIBE, the first page and the count) took 57-105 ms over three runs; a filtered, sorted page at offset 100 with its count 89-121 ms. - Three-part names need nothing from you.
fromis a FROM expression, solattice_demo.main.products(and evenmd:lattice_demo.main.products, which DuckDB also resolves) reads as written. Write-back acceptsdatabase.schema.tablewhen every part is a plain identifier and was proven with anUPDATE … RETURNINGagainst MotherDuck. A name that needs quoting ("my-db".main.t) or carriesmd:is not a plain table, sowritable: trueis refused by name (refusing to mutate … is not a plain writable table) and the source stays read-only. Reading through such a name still works. - Result size. No limit was reached: reading all 50,000 rows of seven columns in one statement took 0.21-0.85 s. A pushdown grid reads a page at a time, so it never asks for that many.
- Extensions.
spatial: trueworks unchanged: the adapter'sINSTALL spatial; LOAD spatialran on the client, the table'sGEOMETRYcolumn was found byDESCRIBE, and a bounding box, a sort by location and GeoJSON output all matched the oracle. Themotherduckextension itself is fetched by DuckDB on the firstATTACH, so the machine needs to reach DuckDB's extension repository once.
The data behind these numbers. lattice_demo.main.products is
50,000 synthetic rows (id, name, category, price, qty, created, a VARCHAR[] of
tags, lon, lat and a GEOMETRY point), every column a pure function of the row
number. test/duckdb-motherduck.test.js holds the recipe and creates the table when
it is absent. That test skips, and says why, without MOTHERDUCK_TOKEN and
@duckdb/node-api; this package depends on neither.
The browser recipe, run once. The built dist/lattice-grid.esm.js
in headless Chrome, @motherduck/wasm-client 1.5.5-r.1 loaded from jsDelivr, the
token fetched from the serving process at run time (2026-09-28): the page did not need to be
cross-origin isolated (crossOriginIsolated was false); importing the
client took 395 ms and isInitialized() 4.6 s, which is the DuckDB-Wasm
download and start, once per page. The first page with its count took 165 ms, and a
filtered, sorted page at offset 100 with its count 120 ms, its rows and its total (4,998)
identical to a hand-written SQL query. A hasAny over the tags came back as arrays
and matched (20,000), and spatial: true loaded the extension in the browser, pushed
a bounding box (907, matched) and returned GeoJSON. Nothing was left to the grid. This was a
one-off check, not a gated test: it needs a token and the network.
The map viewport is a filter
viewportFilter: true | { column?, debounce? } on markermap,
bubblemap and choropleth: whenever the reader pans or zooms the map - a drag, the wheel or the keyboard - the chart writes ONE condition to its own grid
through filters.set, debounced (150 ms by default), replacing only its own
previous condition and leaving every other filter on the tree untouched. A map placed by a
geometry column writes a single withinBbox; a map placed
by lon/lat writes a pair of between conditions, one per
column. Because it is an ordinary filter, every other viewer over the same grid - another
chart, a KPI, the table itself - narrows with the map, and a pushdown
source does the narrowing in the engine rather than in the browser.
A map, a bar chart and a row-count reading over one grid, narrowed by one pan
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
await import('../packages/modules/geometry/index.js');
await import('../packages/modules/chart-markermap/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { NS } = await import('../packages/modules/charts/styles.js');
const { createTestDom, flushFrames, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const dom = createTestDom({ width: 600, height: 400 });
const mapEl = dom.document.createElement('div');
mapEl.rect = { width: 600, height: 400, top: 0, left: 0 };
dom.root.appendChild(mapEl);
const barsEl = dom.document.createElement('div');
barsEl.rect = { width: 600, height: 400, top: 0, left: 0 };
dom.root.appendChild(barsEl);
// Two geometry columns: `place`, which the map is drawn from, and
// `boundary`, a shared column other viewers already filter by.
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'name', type: 'text' },
{ field: 'place', type: 'geometry' },
{ field: 'boundary', type: 'geometry' },
{ field: 'load', type: 'number' },
],
rows: [
{ id: 'p1', name: 'West', place: 'POINT (-10 -10)', boundary: 'POINT (-10 -10)', load: 10 },
{ id: 'p2', name: 'East', place: 'POINT (10 10)', boundary: 'POINT (10 10)', load: 40 },
{ id: 'p3', name: 'Mid', place: 'POINT (0 0)', boundary: 'POINT (0 0)', load: 20 },
],
});
// The map places rows by `place`; `column` writes its condition to
// `boundary` instead - the column the rest of this host filters by.
// The bar chart and the row count know nothing about either: they are
// just two more readers of the same grid.
const map = createChart({ grid, container: mapEl, type: 'markermap', geometry: 'place', value: 'load',
viewportFilter: { debounce: 5, column: 'boundary' } });
const bars = createChart({ grid, container: barsEl, type: 'bar', x: 'name', y: 'load' });
const plot = mapEl.querySelector(`.${NS}__plot`);
plot.rect = { left: 0, top: 0, width: 600, height: 400 };
flushFrames();
const before = `rows ${grid.rows.count()} bars ${bars.data().categories.length}`;
// A wheel zoom, on the map only.
plot.dispatchEvent(new TestEvent('wheel', { deltaY: -1200 }));
flushFrames();
await new Promise((r) => { setTimeout(r, 20); }); // past the debounce
flushFrames(); // the write's own filter:changed
const after = `rows ${grid.rows.count()} bars ${bars.data().categories.length}`;
const on = grid.filters.get().col; // the condition landed on `boundary`, not `place`
grid.destroy();
return `${before} | ${after} | on ${on}`;
One condition, replaced, not stacked. A second pan replaces the first
viewport condition and leaves any other filter on the tree exactly where it was - grid.filters.get() after a pan on a grid that already carries an unrelated
filter shows both, an and of the unrelated one and the new box, never two boxes.
A "reset view" and destroying the chart both remove the condition; a map with nothing to pan - no shapes and no geometry column, so it never leaves its own
identity view - never writes one.
Two-way. A matching condition set on the grid from outside - a saved
view, a host, a sibling panel filtering the same column - pans and zooms the map to it
instead of being overwritten, and is not written straight back: the box the map ends up
showing may differ from the one it was handed when the plot's own aspect does not match the
box's (the same letterboxing fitted already gives a pack that does not fill its
panel), but once settled nothing writes again until the reader, or another `filters.set`,
moves the view again.
What withinBbox actually tests is that the row's own bounding
box is entirely inside the given box, edges included (ST_CoveredBy under DuckDB) - not, as a first reading of "the map's own condition should not drop a feature at the edge"
suggests, the row's centroid. A polygon whose shape straddles the viewport edge is therefore
excluded once its condition narrows its own grid, the same as a point outside the box is; a
centroid test would have kept it. This is stated here rather than changed, because the
operator's own rule (the geometry module) is not this feature's to
redefine.
Whole-dataset statistics over a remote source
A windowed source reduces a total or statistic over the rows it has loaded, not the whole
matching set - a footer median of the 200 rows on screen, which is wrong and looks right.
fullDataset makes the source hold the entire matching set client-side so those
figures are computed over everything.
Correct footer figures over a REST or DuckDB source
const source = createPushdownSource({
adapter: restAdapter({ url: '/api/trades' }),
fullDataset: {
enabled: true, // hold the whole matching set, once per query
maxRows: 1_000_000, // refuse (visible error) past this
maxBytesEstimate: 512 * 1024 * 1024,
},
});
It is off by default and reuses the same whole-result path that residual
work already takes: the flag ORs into needsAll, so once the set is held it is
ordinary in-memory data and the grid's existing total and statistics kernels reduce over all of
it, with no per-stat change. When it is active and within the limits, the whole matching set is
covered, so the windowed-statistic warning stays silent - the figure is now
honestly whole-dataset.
It is refused loudly, never truncated. A matching set past
maxRows or maxBytesEstimate is thrown and surfaced as a
source:error with no rows shown, rather than held as a fraction and presented as
the whole. A fraction shown as the whole is exactly the silent wrong answer this feature
exists to remove, so it is never how the feature fails. For a restAdapter, which
cannot compute, this is the only route to a correct whole-dataset statistic.
Refusing a partial result over residual work
When residual work has to run in the browser, the source asks the adapter for the whole matching set and pages from what it holds. An adapter that answers with a page of that result - it paged when told not to - leaves the client-side filter or sort running over the wrong rows: the ones that belong on page one may be in the fraction never fetched. A page presented as the full filtered set is a wrong answer, not a slow one.
Refused by default; opt in only when you knowingly accept it
// Default: a shortfall under residual work throws, surfaced as source:error.
const strict = createPushdownSource({ adapter });
// Knowing escape hatch: keep the old warn-once-and-proceed behaviour.
const lax = createPushdownSource({ adapter, allowPartialResults: true });
Off by default, refused loudly. Filtering or sorting a fraction of the result
does not lose rows quietly, it returns the wrong rows, so the default throws rather than
warns - nothing fails silently () means a wrong answer is never preferred to a
visible failure. The fix is an adapter that follows the engine's own paging, or holding the data
in memory. allowPartialResults: true is the deliberate opt-out - a caller who
accepts the permissive behaviour keeps the warn-once path - and it is never the default. It does
not touch the fullDataset memory guard or the no-residual short-return warning, both
of which stand regardless.
Running a host predicate over a pushdown source
A where predicate is your code - whether this user may see the row, whether you
hold a rate for its currency. No engine can evaluate it, so a pushdown source can only honour one
by fetching every matching row and filtering here. whereRowLimit is the ceiling on
doing that, because past a point it is no longer a filter, it is a full download.
The predicate runs under the limit; past it the source refuses and says so
const source = createPushdownSource({
adapter: duckdbAdapter({ connection, from: 'trades' }),
whereRowLimit: 50_000, // the default: run the predicate up to this many matching rows
});
grid.filters.where('visibleToMe', (row) => row.owner === me);
Why a limit at all. A pushdown source exists so a host does not download ten million rows. Honouring a twinless predicate means holding the whole matching set, so wiring it in without a gate would let one filter function silently convert a windowed grid into a full download - trading one silent surprise for another. Under the limit the predicate really runs and the counts are whole-dataset counts; at or past it the predicate is not applied, the rows it would exclude stay on screen, and one warning names the adapter, the matching-set size, the limit and the way out.
No row total counts as over the limit. The only way to learn the size from an adapter that cannot count is to fetch the set - which is the download being guarded against - so the source refuses rather than guesses its way into it.
A refusal costs nothing extra. Where the predicate alone would force the whole result, the size is learned from a bounded probe that is exactly the window fetch the request would otherwise have made, and that result is used as that fetch. A host over the limit pays what it paid before the gate existed.
Prefer the twin. A predicate registered with a { condition } twin
is pushed to the engine, which narrows the fetch itself - no limit applies, nothing is held
here, and it works at any size. Reach for whereRowLimit only when what you are testing
genuinely cannot be written as a condition. On a paged or remote source the twin
is the only route: those hold a block cache indexed by the server's own ranges and cannot run a host
function at all, and the grid warns once when you register one without a twin.
Pushing statistics down to the engine
A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
faster than pulling every row here to do it. The aggregates config decides, at
grid setup, which statistics the engine computes and which the grid does - a design-time
developer choice, fixed for the life of the grid, never a runtime toggle and never shown to an
end user.
Push the verified-identical stats, keep the rest exact
const source = createPushdownSource({
adapter: duckdbAdapter({ connection: conn, from: 'trades' }),
aggregates: {
// 'engine' pushes everything expressible; 'engine-if-identical' pushes only
// the stats whose engine result is verified identical to the grid kernel;
// 'client' (the default when absent) computes everything here.
default: 'engine-if-identical',
overrides: { mode: 'client' }, // I want the grid's null-when-distinct mode
},
});
Every statistic is classified IDENTICAL (the engine's result equals the
grid's own kernel, verified against it on the same data) or MAY-DIFFER (the
engine computes it by a method that can differ from the grid's definition - mode
returns a value where the grid returns null). The classification drives the docs and
build-time provenance, not whether a stat is pushed: that is your choice.
weightedQuantile is the one genuine fallback, always client-side, because the
engine cannot express the grid's midpoint convention. The published table of every stat, its
class and its SQL is generated from one map (STAT_PUSHDOWN) so it cannot drift.
No mixed provenance. An engine figure and a client figure never appear in
one result set. Aggregates are pushed only when the filter and the quick search are
both fully pushed; a residual filter the engine could not apply, or a quick search its
capabilities.quick refuses, forces every aggregate client-side, because an engine
number computed over a superset beside a client number over the real set would be
wrong-but-plausible. source.lastPlan().aggregates reports, per stat, whether the
engine or the grid computed it and the class it was assigned - inspection during the build, not
a per-figure runtime marker.
Grids built from other grids
A derived grid takes its rows from another grid rather than from a load: grouped and aggregated, unnested, filtered, ranked or profiled. It has its own element and its own columns, and it follows its source live.
A summary panel beside the detail grid
const detail = createGrid(left, { columns, rowKey: 'id', rows });
createGrid(right, {
columns: [
{ id: 'region', header: 'Region' },
{ id: 'total', header: 'Capacity', type: 'number' },
{ id: 'n', header: 'Sites', type: 'number' },
],
source: {
mode: 'derived',
from: detail,
groupBy: 'region',
select: {
total: { of: 'capacity', fn: 'sum' },
n: { fn: 'count' },
},
sort: [{ col: 'total', dir: 'desc' }],
},
});
follow chooses which of the source's rows are read:
filtered by default, or all, selected or
grouped. The pipeline runs unnest, then join, then
where, then bucket and groupBy, then
select, then sort and limit, so a condition or a total
can read a field that an earlier stage produced.
A change is patched, not re-derived. When a row changes in the source, the
derived grid updates the groups that row belongs to rather than rebuilding the lot. Five
hundred updates against a two hundred thousand row source cost under 300 ms in total. Set
refresh to live, manual or a number of milliseconds to
change the coalescing; idle is the default and settles to a frame.
manual means the host says when, and rows.load() is how it
says it. A manual derived grid never re-derives on its own: the source can
filter, edit and tick underneath it and the panel keeps showing what it last derived. Call
rows.load() on the derived grid, with no argument, and it re-reads its
from there and then and replaces its rows; call it again whenever you want the next
reading. A derived grid takes its rows from from, so anything passed to
load is not used. The derived grid's own sort and filters stay as they were. Press
it as often as you like - the source is left exactly as one press leaves it, and
destroying the panel leaves nothing of it attached there.
Derived grids are read-only. There is one copy of the data and it lives in
the source. Write there and the derived grid follows: an edit through the source's editing
API (edit.setCells, an inline commit, a fill, a paste, an undo, a redo or an
optimistic rollback) is announced once per batch, after the per-cell
cell:changed events, and the derived grid re-derives once for the batch. A
grouped derivation patches the groups the edited rows belong to; a derivation with a
where, an unnest, a producer, or an edit to a column the source
filters on re-reads in full, at the cost measured above. Use idle when a
derived child follows an editable grid. Under live the derivation runs
synchronously inside the edit call, so a keystroke's commit on a 200k-row source pays the
whole re-read before the editor closes; idle defers it to the next frame and
folds a burst of edits into one.
The key comes for free. A derived grid keys on __key, which
the source writes onto every row it produces: the group value, the profiled column, or the
source row's own key when nothing is grouped. Set rowKey only to override it.
A frozen panel, refreshed on a button press, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const detail = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'capacity', type: 'number' }],
rows: Array.from({ length: 20 }, (_, i) => ({ id: i, region: i % 2 ? 'North' : 'South', capacity: i })),
});
detail.rows.count();
// What the detail grid is listening with before any panel is attached.
const listening = () => Object.values(detail.diagnostics.events()).reduce((a, b) => a + b, 0);
const alone = listening();
// A summary that derives once, then waits to be told.
const panel = createHeadlessGrid({
columns: [{ field: 'region' }, { field: 'sites', type: 'number' }],
source: { mode: 'derived', from: detail, groupBy: 'region',
select: { sites: { fn: 'count' } }, refresh: 'manual' },
});
// The Refresh button. In a page this is your <button>; here any EventTarget will do.
const button = new EventTarget();
button.addEventListener('click', () => panel.rows.load()); // no argument
const sites = () => {
let n = 0;
panel.rows.forEach((row) => { n += panel.rows.value(row.key, 'sites'); });
return n;
};
// Longer than any automatic refresh would take to land, so "frozen" is a finding.
const settle = () => new Promise((done) => setTimeout(done, 50));
const attached = listening();
const seen = [sites()]; // 20
detail.filters.set({ col: 'capacity', op: 'lt', value: 10 });
await settle(); seen.push(sites()); // 20: the source moved, the panel did not
button.dispatchEvent(new Event('click'));
seen.push(sites()); // 10: re-derived on the press
detail.filters.set({ col: 'capacity', op: 'lt', value: 5 });
await settle(); seen.push(sites()); // 10: frozen again
button.dispatchEvent(new Event('click'));
seen.push(sites()); // 5
// Pressing it any number of times leaves the detail grid as one press does,
// and destroying the panel leaves nothing of it behind there.
const steady = listening() === attached ? 'steady' : 'grew';
panel.destroy();
const released = listening() === alone ? 'released' : 'left behind';
detail.destroy();
return `${seen.join(' ')}; ${steady}; ${released}`;
Other shapes
| Option | Does |
|---|---|
| unnest | Expands an array property, one row per element, before anything else runs. |
| bucket | Rounds a date column down to a day, week, month, quarter or year and groups on that. |
| limitPer | Applies limit within each value of a column rather than overall: a top three per region. |
| cumulative | Keeps rows until their running share of the total reaches a fraction: the Pareto head. |
| profile | One row per column with the statistics as columns, or one row per statistic with orient: 'metrics'. |
Combining several grids into one
A join matches two grids on a shared key. Some questions have no key to share at
all - "the worst performers across two regional datasets" when the two regions use
unrelated ids - and for those, from takes an array of sources instead of one
grid: every source is read, concatenated in the order you declared them, and only then does the
rest of the pipeline run, once, over the combined set.
Full guide: Combining several grids into one ›
The relational statistics, as rows
One grid is the data; a second is the analysis of it. statistics projects
the figures that need two or more columns - or a second grid - into rows you can
sort, filter, chart and export like any others.
You probably do not need it for a single-column statistic. Those already have
a route: a derived select reduces a group with any kernel the totals row uses, and
that table is a superset of the statistics one. select: { p95: { of: 'amount', fn: 'p95'
} } works today, and so do median, stddev, gini,
iqr, entropy, trimmedMean and the rest.
statistics is for what select structurally cannot reach.
Say whether the figure is approximate. Every
statistics row carries n, the rows the figure was computed over - but
n alone cannot tell you whether that was all of them. Over a windowed
parent it quietly is not: a stream with maxRows: 200 that has had 2,000 rows
through it computes over the 200 it is holding, and the parent's
rows.count(), rows.matchCount() and rows.totalCount()
all report 200 as well, so nothing in those numbers says a thing has been left out. Ask the
parent grid instead: parent.rows.coverage() returns
{ covered, total, windowed }, and covered < total, or
total === null, means the figure is approximate. For that stream it
reads { covered: 200, total: 2000, windowed: true } - the 1,800 rows that
have aged out are visible there and nowhere else. total is null,
never a guess, when the source genuinely cannot know: a stream still open that has evicted
nothing has no idea how many rows are coming. A memory parent reads
{ covered: n, total: n, windowed: false }, so the check costs nothing to leave
in and stays quiet when there is nothing to disclose.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'amount', type: 'number' }],
rows: [{ id: 1, amount: 10 }, { id: 2, amount: 20 }],
});
const { covered, total, windowed } = grid.rows.coverage();
// A memory grid is exact: the figure covers everything there is.
return covered === total && windowed === false;
Which columns move together
source: {
mode: 'derived',
from: trades,
statistics: { fn: 'correlation', columns: ['price', 'volume', 'spread'] },
// -> one row per PAIR: { a, b, coefficient, n }
}
Three statistics in this release. Each has one row shape, and the shape is the contract:
{ fn: 'correlation', columns, orient? }- Pearson's r across N columns.orient: 'pairs'(the default) gives one row per unordered pair,{ a, b, coefficient, n }. Long form by default because that is what a grid sorts, filters and charts well - "the three most correlated pairs" is then a sort and alimiton the derived grid. Only the upper triangle is emitted: r is symmetric, so(a,b)and(b,a)are one finding, and a column against itself is 1 by definition.orient: 'matrix'gives the classic square instead, one row per column with a field per other column, for a heat map.{ fn: 'series', of, by, periodsPerYear? }- one row per metric,{ metric, value, n }. Note per metric, not per point:grid.statistics.seriesreturns a summary -n,first,last,change,changePercent,volatility,annualisedVolatility,growth,maxDrawdown,maxDrawdownFrom,maxDrawdownTo,autocorrelation,upDays,downDays- and not a value per row. The shape is the oneprofile'sorient: 'metrics'already emits, deliberately, rather than a third convention for the same idea.byis required and never guessed, because kernels see rows in arrival order and that is not the grid's sort.{ fn: 'datasetVsDataset', with, columns? }- one row per compared column, largest difference first:{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }. Both sides are read over their filtered rows, and the peer is watched - an edit or a filter on it re-derives the comparison, because a comparison whose other side has moved is wrong rather than merely late. A column present on only one side cannot be compared and is still reported, with a nullmagnitudeandunmatchedset to'A'or'B', so you see that it was skipped and why.
Every row says how much it saw. n is the rows the figure
covered, and it is on the row because a derived statistic travels: a coefficient exported to
CSV or bound to a chart has left every bit of its context behind, and “r = 0.98 over
eleven rows” is a different claim from the same number over eleven thousand.
What the row does not tell you: whether the source was windowed. A
statistic over a source holding fewer rows than match its filters is computed on the loaded
window rather than the whole set. The grid detects that from the source's own
counters and says so in a console warning
([lattice] correlation on … computed over N of M matching rows) - and
that remains the signal to watch. A derived source cannot reach those counters: a grid's public
rows.matchCount() reports the loaded matches, so on a bounded stream
evicted to 200 of 2,000 rows it returns 200 and agrees exactly with rows.count().
Rather than ship a flag that could never be true, no such flag is emitted; n says
what the figure actually saw and nothing more is claimed.
A terminal producer, not a pipeline stage. A correlation is one row per pair,
a series summary one row per metric, a comparison one row per column - none of which is
one row per group, so there is no position in
unnest → where → bucket → groupBy → select → sort →
limit for statistics to occupy. It replaces the pipeline, exactly as
profile does. Those keys are now ignored with a warning that names
them rather than discarded in silence, for both producers. Sort, filter or limit the
derived grid itself, or chain a second derived grid whose from is this one.
profile and statistics are mutually exclusive and declaring both is
refused, by name, when the source is built.
What a terminal producer costs
No terminal producer patches incrementally - know this before you point one at a
live feed. The grouped pipeline maintains its grouping across changes: an edit that
names the rows it touched re-reduces only the groups those rows belong to. A producer has no
grouping to maintain, so every change on the parent re-derives its whole output. This
has always been true of profile and was not previously written down; it is written
down here now, and it applies to statistics in the same way.
Measured on a synthetic 200,000-row grid, five changes after a warm-up, one derived grid
attached (machine-dependent - run node bench/derived-producers.mjs against
your own shape):
| Derived shape | Per change at 200k rows |
|---|---|
grouped, no where - the patching pipeline | ~1 ms |
profile, one column | ~20 ms |
statistics correlation, 2 columns (1 pair) | ~5 ms |
statistics correlation, 6 columns (15 pairs) | ~32 ms |
statistics series | ~23 ms |
statistics datasetVsDataset, two grids | ~36 ms |
where, no groupBy - a pipeline shape that also never patches | ~700 ms |
Correlation is quadratic in its column count. It scans the rows once per pair, so N columns cost N·(N−1)/2 passes: six columns is fifteen passes, twenty columns is a hundred and ninety. Correlate the columns you mean rather than every numeric column you have.
The costs of several panels add up. Every derived grid attached to a parent re-derives on the same change, so three analysis panels over one grid cost the sum of the three, not the largest. This is's known gap - a hidden derived grid still does full read and compute work - with a larger constant behind it; a hidden analysis tab recomputing a correlation matrix on every tick is exactly that cost.
The escape hatch is refresh, and it already exists.
'idle' is the default and coalesces a burst of changes into one derivation on the
next frame; a number is a debounce in milliseconds; 'live'
derives on every change and is the one to avoid for an expensive analysis over a ticking feed;
'manual' stops automatic derivation entirely, leaving the host to drive the
source: call rows.load() on the derived grid, with no argument, whenever the
analysis should be brought up to date - from a Refresh button, when its tab is shown, or
on a timer of your own. Each call re-derives once, at the cost in the table above;
a frozen panel refreshed on a button press is executed
above. Below a few tens of thousands of rows none of this matters.
Joining two grids
Two grids holding their own data, and a third showing where they meet. Both sides stay live.
Full guide: Joining two grids ›
Cross-filtering
A derived panel can filter the grid it summarises. Click a region in the summary and the detail grid narrows to it.
Diagnostics and devtools
Data grids fail in ways that are hard to diagnose from outside. This is the grid saying what it is actually doing.