Lattice Grid Buy a licence

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.

SourceFor
memoryThe default. Everything is present; the grid does all the work.
pagedA page at a time from a server that paginates.
remoteBlocks fetched on demand as the user scrolls, with sort and filter pushed to the server.
streamRows 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:

OptionTypeDescription
retainSourcebooleanDefault 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.
dropSourceRowsbooleanDefault 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.

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.

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.

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 },
})
CapabilityValuesMeans
filterfalse | 'term' | 'flat' | 'tree'Nothing, one field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine genuinely applies. Declare only those it does.
sortfalse | 'single' | 'multi'No ordering, one column, or several.
quickbooleanWhether a free-text search across columns can be pushed.
rangebooleanWhether the engine can return a window rather than the whole result.
totalbooleanWhether it can report how many rows matched.
groupbooleanWhether 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:' or ATTACH '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. from is a FROM expression, so lattice_demo.main.products (and even md:lattice_demo.main.products, which DuckDB also resolves) reads as written. Write-back accepts database.schema.table when every part is a plain identifier and was proven with an UPDATE … RETURNING against MotherDuck. A name that needs quoting ("my-db".main.t) or carries md: is not a plain table, so writable: true is 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: true works unchanged: the adapter's INSTALL spatial; LOAD spatial ran on the client, the table's GEOMETRY column was found by DESCRIBE, and a bounding box, a sort by location and GeoJSON output all matched the oracle. The motherduck extension itself is fetched by DuckDB on the first ATTACH, 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

OptionDoes
unnestExpands an array property, one row per element, before anything else runs.
bucketRounds a date column down to a day, week, month, quarter or year and groups on that.
limitPerApplies limit within each value of a column rather than overall: a top three per region.
cumulativeKeeps rows until their running share of the total reaches a fraction: the Pareto head.
profileOne 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.

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 a limit on 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.series returns 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 one profile's orient: 'metrics' already emits, deliberately, rather than a third convention for the same idea. by is 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 null magnitude and unmatched set 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 shapePer 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.

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.