Lattice Grid Buy a licence

api reference

Data Router API Reference

createDataRouter: one stream or one dataset split across many viewers, keyed-diff snapshots, join and rollup routes, and backpressure.

API reference › The data router

All 26 pages Everything on one page → Developer guide →

The data router

modules/data-router is a host-layer demultiplexer: it takes one arriving stream or dataset, splits it by what each record is, and routes each partition to its own grid - or to a headless grid driving a chart. One round-trip, or one live feed, hydrates a whole screen of grids that each see only their slice. It is optional, imports nothing from the grid, and adds no core hook: every grid is driven through the public incremental path, grid.rows.apply({ add, update, remove }). The router never opens a connection itself - the host owns the connection (a WebSocket, SSE, CDC, a message bus, a plain fetch), and the router owns everything once a message has arrived; see a live WebSocket feed for the worked, runnable integration.

import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';

const router = createDataRouter({
  key: 'entityType',          // partition: a property, or fn(row) => value
  rowKey: 'id',               // identity within a grid: a property, or fn(row)
  overlap: false,             // default: first matching route wins
  onUnrouted: (item) => {},   // optional sink for records that match no route
});

router.attach(ordersGrid, 'order');            // a property value...
router.attach(bigGrid, (row) => row.amt > 1e6); // ...or a composite predicate
router.attach(headlessGrid, 'metric', { rowKey: 'ts' }); // per-attach rowKey override; feeds a chart
router.attachDefault(restGrid);                // the "rest" sink: nothing is dropped

const counts = router.load(snapshot);          // keyed diff per grid: [{added,updated,removed}, ...]
router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); // in-place deltas by rowKey

A snapshot is a keyed diff, not a replace. load re-partitions the whole dataset and, per grid, adds the new rows, updates only the changed ones and removes the gone ones - so an unchanged row never repaints and selection, scroll and edit state survive. Deltas are applied in place by rowKey, last-writer-wins within a batch; an upsert whose partition property has changed moves the row (it leaves the route it no longer matches and joins the one it now does, never duplicated). A record matching no route is counted in router.unrouted, handed to onUnrouted, and - if an attachDefault grid exists - routed there, so nothing is ever silently lost. By default a record goes to the first route it matches; overlap: true fans it to every matching route.

Fanning one partition value to several viewers? Set overlap: true. The headline "route one feed to many viewers" - a grid and a KPI panel and a chart, all off one feed - needs overlap: true. With the default overlap: false a record stops at the first route it matches, so a second viewer attached to the same value silently receives nothing. Distinct values (one viewer per value) do not need it. When two routes claim the same value while overlap is false, the router emits a one-time dev warning naming the value.

// One value, several viewers: a grid, a KPI panel and a chart all see 'deal'.
const router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(dealsGrid, 'deal');            // the grid
router.subscribe('deal', kpiPanel);          // a KPI tile off the same value
router.attach(dealsChart, 'deal');           // a chart off the same value
router.load(snapshot);                        // every viewer fills; without overlap:true only the grid would

Read a record, then follow it. router.get(route, rowKey) reads the current record off a route by its row key - the same way a routed grid's own key index would - and returns undefined when the route or key is unknown, so a consumer that needs the record now (a detail form mounted after the ingest, say) can fill before the next diff arrives. Pair it with subscribe, which now hands back an unsubscribe: call it and the route leaves the routing table and its handler is never called again (callers that ignored the return value are unaffected). A form binds { router, route, key } by reading get once and then subscribing to the diffs, so the order of ingest and form creation does not matter.

MemberDescription
createDataRouter({ key?, rowKey?, overlap?, onUnrouted?, selectionDebounce?, metricsInterval?, seq?, dedupe?, batch?, coalesce?, time?, now?, onWrite?, onConflict?, config? })Create a router. key is the partition (property or fn(row)) - optional, since a router whose routes all use fn(row) predicates never reads it; rowKey the default identity within a grid; overlap fans a record to all matching routes; onUnrouted is a sink for unmatched records (it receives the row on load and query, and the whole delta on apply). selectionDebounce is the debounce in ms for cross-grid selection refilters (default 16; 0 refilters synchronously). v10: metricsInterval is the ms between periodic on('metrics') emits (default 1000; 0 disables the timer). v3: seq (a version field or fn(row)) turns on ordered de-duplication, dedupe: false opts out; batch (interval ms or { intervalMs }) / coalesce: true buffer a high-frequency push. v4: time (a timestamp field or fn(row)) and an injectable now clock drive time-domain scrubbing. v8: onWrite/onConflict are the router-global write-back callbacks. v5: config is a declarative routing spec, desugared through configure.
attach(grid, predicate, { rowKey? })Route to grid when predicate matches: a value compared to key, or a fn(row) => boolean. rowKey overrides the router default for this grid.
attachDefault(grid, { rowKey? })The "rest" sink: the grid that receives every record no explicit route matched.
load(snapshot)Apply a full snapshot as a keyed diff per grid. Returns per-route { added, updated, removed } counts in attach order.
apply(deltas)Apply { op: 'upsert' | 'delete', row } deltas in place by rowKey.
unroutedHow many records matched no route. Reset to zero by load and by query, then running for deltas - so read it straight after the call you care about, not at the end of a session.
link(source, target, relation)v2: make a selection in source filter what target receives. relation is a key map { from, to } (target rows whose to value is among the selected source rows' from values - multi-select is an IN set, ANY match) or a function fn(selectedSourceRows) => (row) => boolean. No selection shows the full partition; changes are debounced.
flush()v2: apply any debounced selection refilter now, for a deterministic point (and for tests).
attach(grid, predicate, { rollup })v3: feed the grid a grouped/summarised view - rollup: { groupBy, aggregate } gives one summary row per group (op of sum/avg/min/max/count over a field, or a fn(rows)). Applied by keyed diff, so only a moved group repaints.
relate(edges)v3: declare a relationship graph. Each edge { from, to, on, mutual? }; the router resolves multi-hop chains, several sources into one target (AND), and mutual edges on any selection change. Composes with link().
push(delta)v3: feed a live delta. With a batch interval or coalesce: true it buffers and coalesces rapid updates to one key; otherwise it applies at once.
flushStream()v3: apply the buffered deltas now, coalesced into a single apply (a deterministic point, and for tests).
droppedv3: how many stale/duplicate deltas the seq dedupe gate has dropped.
lastSeq() / checkpoint() / seenThrough(mark)v3: the resume point - the highest applied seq, a per-record checkpoint to persist, and a way to prime it after a reconnect so an early replay is dropped.
subscribe(predicate, handler, opts?)v5: route a slice to any non-grid view. handler(change) receives the same keyed diff { add, update, remove } a grid does - drive a KPI tile, detail pane, map or form. A peer to attach: same partitioning and the same transform/filter/sort/rollup options; grids and charts are unchanged. To drive a KPI/pane and a grid off the same partition value, create the router with overlap: true - with the default overlap: false only the first route on that value receives rows (the router warns once when it detects the clash). : returns an unsubscribe that removes the route (and cancels any pending backpressure flush); a caller that ignored the return value is unaffected.
get(route, rowKey): a point-in-time read of one record off a route - route is the route's target (a grid or a subscribe handler), its partition value, its label, or a fn(row) predicate, and the answer is the route's current partition entry for rowKey, or undefined when the route or key is unknown. Pair it with subscribe to follow the record: read once to fill, then subscribe for the diffs.
alert(predicate, condition, handler, { filter?, debounce?, rowKey? })v5: watch a slice and emit rather than render. condition(rows) is evaluated over the slice on every load and delta; when it first becomes truthy, handler(signal, rows) fires. Edge-triggered (once per crossing, re-arms on release), debounce coalesces a burst, and it never competes for a partition or touches a grid.
configure(spec)v5: the whole routing graph as one data spec - routes (grid/default/subscribe/alert entries), links, relate, buffer - desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as createDataRouter({ config }).
attach(grid, predicate, { writable, onWrite?, onConflict? })v8: make a route writable - the router captures the grid's committed edits off its public edit surface (grid.on('cell:changed') → grid.edit.setCells) and routes them to onWrite(change, { route, source }) (per-route here, or the router-global onWrite), reverting the cell on reject and re-entering an accepted write as a normal delta. onConflict(change, { serverRow }) surfaces a last-write-wins conflict. A derived (rollup/transform) route cannot be writable - its edits are reverted and warned.
attach(grid, predicate, { where })v7: a route-level where - a filter-wire condition { col, op, value } or an and/or/not group - used only by query-slice routing (query()): the router pushes it down to the engine where the adapter allows and finishes the residual client-side. Distinct from filter (a fn(row) that only ever runs in the browser).
attach(grid, predicate, { label, backpressure })v10/v13: a human label for the route (shown in metrics() and the devtools panel), and a per-route backpressure policy that throttles / coalesces / samples how that route's viewer is refreshed under load - without touching the keyed store or any other route. backpressure: { maxHz, minInterval?, sample?, maxLag? }: maxLag (a backlog depth) sets when it engages (below it, changes pass straight through); maxHz/minInterval cap the refresh rate; sample (an integer > 1) thins intermediate refreshes. A trailing flush always lands the latest state (deletes included), so the viewer converges and is never left stale.
flushBackpressure()v13: refresh every backpressured route to the latest state now. A route with a backpressure policy holds its viewer refresh until its rate limit or sample count allows one, so a test - or a teardown - can observe a route that has not caught up yet; this forces the deferred flush for every route at once and gives you a deterministic point. A no-op for routes without a policy or with nothing pending, and it never touches the keyed store: the rows were always current, only the refresh was held.
query(adapter, request?)v7: source the router from a DFQL/DuckDB (or any pushdown) adapter. Runs adapter.execute, partitions the result across the routes and drives the grids by the same keyed diff load() uses; a route's where is planned against the adapter's capabilities (pushed down where allowed, residual finished client-side). Composes with per-route transform/filter/sort/rollup and links/graph. Async - resolves once every slice is fetched and applied.
lastQueryPlan()v7: the pushed/residual split of the last query(), per fetch - whether a filter reached the engine and what work was left client-side. null before any query. Each entry is { route, pushedFilter, residual } for a where route - route the grid, pushedFilter whether its filter reached the engine, residual the work finished client-side - or { base: true, pushedFilter, residual } for the single base fetch that fed every route without a where. Provenance, so a slow slice is diagnosed rather than guessed.
buffer({ window?, max? })v4: turn on time-travel buffering - record the ordered, de-duplicated stream into a bounded ring (a time window in ms and/or a max delta count; eviction folds the oldest into a moving base, so memory never grows unbounded; a default cap applies if you name neither). Seeded from the current world, so it can be turned on at any time. Opt-in and off by default.
scrubTo(target, { by? })v4: scrub the grids to a past point - the base snapshot plus the buffered deltas up to target (a seq when the router has one, else a timestamp; { by: 'seq' | 'time' } chooses). Pushed by keyed diff, so each view keeps scroll and selection and only changed rows repaint. Live deltas keep arriving into the buffer but do not disturb the view.
replay(from, to, { speed?, by? })v4: walk an incident - scrub to from, then apply each buffered delta in (from, to] in order, one per speed ms (default 0). Returns a promise resolving when the range finishes (or is superseded); the router stays parked at to until live().
pause() / resume()v4: pause an in-flight replay at the current step and resume it from where it stopped. No-ops when nothing is replaying / not paused.
live()v4: return to the head - rebuild the base plus every buffered delta (including those that arrived while scrubbed) and push it by keyed diff, then resume normal live application. A single diff animates the view from the past straight to the present, keeping scroll and selection.
traveling / bufferedv4: whether the grids currently show a reconstructed past, and how many deltas are held in the bounded buffer.
broadcast({ channel })v6: mirror the router's ordered, de-duplicated deltas to other browser tabs/windows over a BroadcastChannel, so a grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same channel name; an inbound mirror is applied without re-broadcasting (no echo loop), and broadcast announces the tab so a peer holding the feed resyncs it mid-stream (snapshot + replay). Off by default; needs a seq/dedupe router to drop replayed deltas cleanly.
broadcastingv6: whether the router is currently mirroring to a BroadcastChannel.
addSource(feed, { map?, key?, join?, unnest?, fields? })v9: register a source feed - fan-in. Returns a handle (load/apply/push/remove, plus id/size) whose rows are normalized by map and namespaced by key (a prefix string, true to prefix with the source id, or a keyFn(row)) so ids from different feeds cannot collide, then merged through the router's ordinary path - partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. feed is an optional source id or an options object. A source may also carry a join spec (v11) to enrich its rows with fields looked up from another source, an unnest spec (v17, to expand a nested array into its own row type, and a fields map to coerce raw values to typed values before joins.
removeSource(ref) / sources()v9: drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.
metrics()v10: a cheap point-in-time observability snapshot - per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global unrouted / dropped / buffered / lag figures. Throughput is sampled over the interval since the last metrics() call or emit. Each entry in routes[] also carries its label and, when the route declares a backpressure policy, a backpressure: { pending, coalesced } object - pending is the held backlog since the last flush (the route's lag) and coalesced the cumulative change-events it has absorbed into deferred refreshes (null when the route has no policy).
on('metrics', handler)v10: subscribe to the periodic metrics emit (the metricsInterval ms, default 1000; 0 disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.
mountDevtools(el)v10: mount an opt-in, DOM-touching live panel (in the module's own devtools.js, so the core stays DOM-free) that renders metrics() into el and re-renders on each on('metrics') emit - so the panel's cadence is the router's metricsInterval, not a setting of its own. Returns a controller with refresh(), which forces an immediate re-render, and destroy(), which unsubscribes and removes the panel from the DOM. The same panel is available without a router: mountRouterDevtools(router, el) is a named export of modules/data-router/devtools.js, and mountDevtools is a one-line wrapper over it. Off unless called.
explain({ source?, route? })v18: where metrics() reports throughput, explain() reports why - what the most recent settle (one load/apply/push and every cascade it triggered) did, per source and per route: rows loaded/applied/removed, per-field coercion (coerced/invalid counts and up to 5 sample offending values), unnest parent-rows-in/child-rows-out, every join/collect/rollup-onto-parent/relateRows's matched count and unmatched keys on each side (with samples) and duplicate matches (with samples), a spread join's rows touched/re-emitted, a dependent source's cascade touched/re-emitted, and - per route - its when/filter/transform/sort/rollup in/out counts, the grid writes produced, its label, and wall-clock ms per step. Pass { source: id } and/or { route: labelOrGridOrHandler } to narrow to just that part; omit both for the whole report. The counters and 5-slot sample buffers it reads run unconditionally (so the report is always current); building the report itself is the only cost paid by calling this. The result is plain JSON-serialisable data - see ExplainReport in the type appendix.
persist({ key?, debounce?, storage?, indexedDB?, dbName?, storeName? })v12: turn on durable persistence - snapshot the keyed store and the time-travel ring to a durable async key/value store so an offline reload or a browser refresh resumes exactly where it left off. The default backend is IndexedDB (native, no dependency), opened lazily and guarded so private-mode or blocked storage degrades to in-memory with a one-time warning rather than throwing. Writes a coalesced snapshot after each load/apply (debounced by debounce ms, default 250; 0 is eager). Pass storage - any object with async get(key)/set(key, value) - to use another backend (a server, a test double). Opt-in and off by default.
restore()v12: resume from the durable snapshot. Read the last persisted state and apply it - load the live head through the ordinary keyed diff (so grids attached before this call repaint only what differs), restore the resume checkpoint and, when the snapshot carried a time-travel ring, restore buffering and the ring so scrubTo/replay/live work straight after a reload. Call it once, after attaching the grids. async; resolves true when a snapshot was found and applied, false when persistence is off/degraded or nothing was stored.
flushPersist() / persistingv12: flush any pending durable write now (async; cancels the debounce and resolves once the write settles - for a beforeunload handler, a deterministic checkpoint, or a test), and whether durable persistence is on and not degraded to in-memory.
detach(grid)Stop routing to a grid and forget its slice; drop any link/edge it is part of (restoring a filtered sibling). The host still owns and destroys the grid.
destroy()Detach every grid, drop every link, edge and subscription. Detaches only - the host owns and destroys its grids.

Four things worth knowing before you build on this. attachDefault keeps one sink: calling it twice replaces the first, silently, along with whatever slice it held - attach the sink once, at setup. An alert has no removal: detach drops routes, links and graph edges but leaves alerts running against their own partition, so an alert added at setup keeps firing until destroy(), even after every grid is gone. detach does take a subscribe handler as well as a grid - pass the same function you handed subscribe and the subscription goes with it. And a join accepts three spellings the examples below do not use: on for localKey, fromKey for foreignKey, and select for fields; they are equivalent, and a spec the router cannot honour degrades to plain fan-in with a one-time warning rather than throwing.

The router keeps a small Map<rowKey, row> per route to compute the snapshot diff. That is deliberate for v1; a future optimisation could diff against the grid's own key index rather than a shadow copy. Ordering and dedupe across a live feed are the host's to guarantee - a caller that must drop stale out-of-order deltas can carry its own version or sequence field and filter before apply; v1 imposes no version scheme.

Cross-grid selection filtering (v2,. link keeps each target's full partition separate from what it currently shows: when the source's selection changes, the router recomputes the shown subset from the relation and re-pushes it through the same keyed-diff path, so the target grid stays dumb - it only ever receives rows, never a query or a reference to the source. Selection in the target survives an unrelated refilter, because the keyed path preserves it. No selection (or one the router cannot resolve to routed rows) shows the full partition, and deselecting restores it. The source grid must have selection enabled; still no grid-core change. Debounce is controlled by selectionDebounce (default 16 ms; 0 is synchronous), and flush() forces it.

Cross-grid selection filtering, executed

A customers grid and an orders grid off one feed; selecting customers filters the orders grid to their regions through the keyed-diff path, and deselecting restores the full set. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }];
const customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });

// debounce 0 so a selection refilters synchronously in this example.
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.attach(customers, 'customer');
router.attach(orders, 'order');
router.load([
  { id: 'c1', type: 'customer', region: 'emea' },
  { id: 'c2', type: 'customer', region: 'amer' },
  { id: 'o1', type: 'order', region: 'emea' },
  { id: 'o2', type: 'order', region: 'amer' },
  { id: 'o3', type: 'order', region: 'emea' },
]);
// A selection in customers filters the orders grid by region.
router.link(customers, orders, { from: 'region', to: 'region' });

const full = orders.rows.count();          // 3: no selection, full partition
customers.selection.set(['c1']);            // emea
const oneRegion = orders.rows.count();     // 2: o1, o3
customers.selection.set(['c1', 'c2']);      // emea + amer (IN set)
const both = orders.rows.count();          // 3
customers.selection.set([]);                // deselect restores
const restored = orders.rows.count();      // 3

customers.destroy(); orders.destroy(); router.destroy();
return [full, oneRegion, both, restored].join(' | ');

Per-route transforms and route-level filter/sort (v3,. A route may reshape and narrow its slice before it reaches the grid, and the grid still stays dumb. attach(grid, predicate, opts) takes transform(row) => row' (map/rename/derive), filter(row) => boolean (the grid receives only the subset), and sort (a comparator or { key, dir }, ordering what the grid receives). Filtering and the cross-grid link predicates run on the original row; the transform then produces the display row, and identity stays the row's rowKey, so the keyed diff is unaffected - an unchanged transformed row never repaints, and a delta that pushes a row across the filter threshold makes it enter or leave the view.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// This route shows only amt >= 20, sorted high-to-low, with a derived label.
router.attach(g, 'order', {
  filter: (row) => row.amt >= 20,
  sort: { key: 'amt', dir: 'desc' },
  transform: (row) => ({ ...row, label: `#${row.id}:${row.amt}` }),
});
router.load([
  { id: 'o1', type: 'order', amt: 10 },   // filtered out
  { id: 'o2', type: 'order', amt: 30 },
  { id: 'o3', type: 'order', amt: 20 },
]);
const keys = [];
for (let i = 0; i < g.rows.count(); i++) keys.push(g.rows.get(i).key);

// A delta below the threshold is held; raising it brings it into the view.
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 5 } }]);
const held = g.rows.count() === 2 ? 'held' : 'leaked';
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 40 } }]);
const shown = g.rows.count() === 3 ? 'shown' : 'missing';

const out = [g.rows.count() >= 2 ? 2 : 0, keys.join(','), g.rows.value('o2', 'label'), held, shown];
g.destroy(); router.destroy();
return out.join(' | ');

Aggregate/rollup routes (v3,. A route can be fed a grouped, summarised view of its partition instead of the raw rows - a per-category total for a chart route, say. attach(grid, predicate, { rollup: { groupBy, aggregate } }) gives the grid one summary row per group: groupBy is a property, a fn(row) or an array of either, and each aggregate entry is a { op, field } (sum, avg, min, max, count) or a fn(rows) => value. A route filter runs on the raw rows before grouping; sort and transform run on the summaries. The summary is applied by the same keyed diff, so only a group that actually moved repaints - the router owns the roll-up, the grid stays dumb.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'region', field: 'region' }, { id: 'total', field: 'total', type: 'number' }];
const chart = createHeadlessGrid({ rowKey: 'region', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// One summary row per region, the per-region total, biggest first.
router.attach(chart, 'order', {
  rollup: { groupBy: 'region', aggregate: { total: { op: 'sum', field: 'amt' } } },
  sort: { key: 'total', dir: 'desc' },
});
router.load([
  { id: 'o1', type: 'order', region: 'emea', amt: 10 },
  { id: 'o2', type: 'order', region: 'amer', amt: 20 },
  { id: 'o3', type: 'order', region: 'emea', amt: 5 },
]);
const groups = chart.rows.count();     // 2: emea (15), amer (20)
const top = chart.rows.get(0).key;     // amer - highest total

// A new emea order repaints only the emea summary (keyed diff).
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', region: 'emea', amt: 100 } }]);
const emeaTotal = chart.rows.value('emea', 'total'); // 115

chart.destroy(); router.destroy();
return [groups, top, emeaTotal].join(' | ');

The relationship graph (v3,. relate([...]) is the scalable form of v2's pairwise link(). Each edge is { from, to, on }, where on is a key map { from, to } or a function fn(sourceRows) => (row) => boolean. The router resolves the whole graph on any selection change, so it handles multi-hop chains (A→B→C: a selection in A narrows B and, through B's resulting rows, C - with no selection in B), several sources into one target (their filters AND together), and mutual edges (mutual: true - selecting in either linked view narrows the other; requires a key-map on). A node's effective set is its own selection when it has one, otherwise the rows its incoming edges leave - that is what carries a selection transitively down a chain. Additive to link(); the two compose.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }, { id: 'orderId', field: 'orderId', type: 'text' }];
const mk = () => createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const customers = mk(), orders = mk(), lines = mk();
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.attach(customers, 'customer'); router.attach(orders, 'order'); router.attach(lines, 'line');
router.load([
  { id: 'c1', type: 'customer', region: 'emea' }, { id: 'c2', type: 'customer', region: 'amer' },
  { id: 'o1', type: 'order', region: 'emea' }, { id: 'o2', type: 'order', region: 'amer' }, { id: 'o3', type: 'order', region: 'emea' },
  { id: 'l1', type: 'line', orderId: 'o1' }, { id: 'l2', type: 'line', orderId: 'o2' }, { id: 'l3', type: 'line', orderId: 'o3' }, { id: 'l4', type: 'line', orderId: 'o1' },
]);
// customers -> orders (by region) -> lines (by order id): a two-hop chain.
router.relate([
  { from: customers, to: orders, on: { from: 'region', to: 'region' } },
  { from: orders, to: lines, on: { from: 'id', to: 'orderId' } },
]);
customers.selection.set(['c1']); // emea; no selection in orders
const keysOf = (g) => { const a = []; for (let i = 0; i < g.rows.count(); i++) a.push(g.rows.get(i).key); return a.sort().join(','); };
const out = [keysOf(orders), keysOf(lines)]; // o1,o3 | l1,l3,l4
customers.destroy(); orders.destroy(); lines.destroy(); router.destroy();
return out.join(' | ');

Stream hygiene (v3,. A production feed arrives out of order, gets replayed, and comes faster than a grid should repaint. Configure a seq (a version field or fn(row)) and the router orders each batch by it and drops any delta not newer than the one it already applied for that record (counted in router.dropped) - an out-of-order or replayed feed converges to the newest state. push(delta) with a batch interval or coalesce: true buffers a high-frequency feed and coalesces rapid updates to one key into a single apply (flush a deterministic point with flushStream()). When the host's own connection drops and it reconnects, resume precisely: load a fresh snapshot (a keyed diff that preserves grid state) and replay from lastSeq()/checkpoint() - the deltas the router already saw are dropped by the same gate. The router does not detect or recover from the drop itself; see a live WebSocket feed for the worked reconnect example. seenThrough(mark) primes the checkpoint from a persisted one.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router.attach(g, () => true);
router.load([]);

router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]); // stale - dropped
const n = g.rows.value('a', 'n'); // 30: the stale delta did not clobber it
const dropped = router.dropped;   // 1
router.apply([{ op: 'upsert', row: { id: 'a', n: 99, v: 4 } }]); // fresh
const last = router.lastSeq();    // 4

g.destroy(); router.destroy();
return [n, dropped, last].join(' | ');

A live WebSocket feed. The router never opens a connection itself - there is no new WebSocket anywhere in modules/data-router. The host owns the connection; the router owns everything once a message has arrived. Wire a socket's onmessage to the two entry points above: a snapshot message's rows go to load(), a delta message's changes go to apply() (or push(), batched, for a fast feed). Nothing else changes for a real WebSocket, an EventSource, a CDC feed or a message bus - the router takes rows, never a URL or a socket, so the transport is always the host's choice. On a drop, the reconnect pattern is the same snapshot-plus-replay shown above: capture lastSeq()/checkpoint() before the drop, load() a fresh snapshot on the new connection, and let the feed replay from around the last point - anything already applied is dropped by the same seq gate, not re-applied.

A live WebSocket feed, with reconnect, executed

A socket-shaped feed (modules/mock-socket, which frames messages exactly as a real WebSocket does - the same onmessage, the same JSON-framed event.data) drives the router; the connection then drops and reconnects, and a replayed delta already applied is dropped by the seq checkpoint while a genuinely new one lands. Swapping in a real WebSocket is a one-line constructor change - see the mock socket. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { MockWebSocket } = await import('../packages/modules/mock-socket/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'label', field: 'label' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router.attach(g, () => true);

// THE INTEGRATION: a snapshot message loads, a delta message applies. This is
// exactly what a page writes against a real `new WebSocket(url)`.
function wireRouter(r, socket) {
  socket.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.kind === 'snapshot') r.load(msg.rows);
    else if (msg.kind === 'delta') r.apply(msg.changes);
  };
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// First connection: live through v:3, then the socket drops.
function* feedA() {
  yield { kind: 'snapshot', rows: [] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@1', v: 1 } }] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@3', v: 3 } }] };
}
const socketA = new MockWebSocket({ feed: feedA(), rate: 5, jitter: 0, snapshotDelay: 5 });
wireRouter(router, socketA);
await wait(80);
const beforeDrop = g.rows.value('a', 'label');  // A@3
const resumeFrom = router.lastSeq();             // 3 - the resume cursor
socketA.close();

// Reconnect: the server answers with a fresh snapshot plus a replay that
// includes two deltas already applied (v:1, v:3) and one truly new one (v:4).
function* feedB() {
  yield { kind: 'snapshot', rows: [{ id: 'a', label: 'A@3', v: 3 }] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@1', v: 1 } }] }; // replayed
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@3', v: 3 } }] }; // replayed
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@4', v: 4 } }] }; // new
}
const droppedBefore = router.dropped;
const socketB = new MockWebSocket({ feed: feedB(), rate: 5, jitter: 0, snapshotDelay: 5 });
wireRouter(router, socketB);
await wait(80);
const afterReconnect = g.rows.value('a', 'label');        // A@4 - only the new delta advanced it
const replaysDropped = router.dropped - droppedBefore;    // 2 - both replays dropped
socketB.close();

g.destroy(); router.destroy();
return [beforeDrop, resumeFrom, afterReconnect, replaysDropped].join(' | ');

One feed, three grids, executed

A single snapshot fanned to an orders grid, an invoices grid and a "rest" sink, then a delta that changes a row's partition - proving the fan-out, the sink, and that a moved row leaves its old grid and joins the new one rather than being duplicated. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
const invoices = createHeadlessGrid({ rowKey: 'id', columns: cols });
const rest = createHeadlessGrid({ rowKey: 'id', columns: cols });

// One router keyed on `type`; each grid sees only its slice, the "rest" sink
// catches anything that matches no explicit route.
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(orders, 'order');
router.attach(invoices, 'invoice');
router.attachDefault(rest);

// One snapshot hydrates all three grids at once.
router.load([
  { id: 'o1', type: 'order', amt: 10 },
  { id: 'o2', type: 'order', amt: 20 },
  { id: 'i1', type: 'invoice', amt: 99 },
  { id: 'x1', type: 'ticket', amt: 1 },   // matches no route -> the sink, not dropped
]);
const fanned = [orders.rows.count(), invoices.rows.count(), rest.rows.count()].join(',');

// o2's partition changes: it MOVES from orders to invoices, not duplicated.
router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
const moved = orders.rows.count() + '/' + invoices.rows.count();

orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
return [fanned, moved, router.unrouted].join(' | ');

Time-travel buffering (v4,. buffer({ window?, max? }) records the ordered, de-duplicated stream into a bounded ring on top of a base snapshot, so a consumer can scrubTo a past point, replay a range (pause/resume it), and jump back to live() - every reconstructed state pushed to the grids by the ordinary keyed diff, so views keep scroll and selection and only changed rows repaint. The bound is a time window and/or a max delta count; eviction folds the oldest delta into a moving base, so memory stays bounded. Live deltas keep arriving into the buffer while scrubbed but do not disturb the (time-travelled) view; traveling and buffered report the state. Opt-in and off by default - a router that never calls buffer() behaves exactly as v1/v2/v3.

Scrub and return to live, executed

A versioned feed buffered into a bounded ring; the view scrubs to a past seq, reads the reconstructed value, then returns to the live head. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(g, () => true);
router.buffer({ max: 100 });               // record into a bounded ring
router.apply([{ op: 'upsert', row: { id: 'a', n: 10, v: 1 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
const head = g.rows.value('a', 'n');       // 30: the live head
router.scrubTo(1, { by: 'seq' });          // reconstruct the state at seq 1
const past = g.rows.value('a', 'n');       // 10
const traveling = router.traveling;        // true
router.live();                             // back to the head, by keyed diff
const back = g.rows.value('a', 'n');       // 30

g.destroy(); router.destroy();
return [head, past, traveling, back].join(' | ');

Cross-tab / pop-out window sync (v6,. broadcast({ channel }) mirrors the router's ordered, de-duplicated deltas to other browser tabs/windows over a BroadcastChannel, so a routed grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same channel name and applies the mirrored deltas through the ordinary keyed-diff path, so its grids stay dumb and keep scroll/selection. What is mirrored is exactly what the router applied (post-order, post-dedupe); an inbound mirror is applied without re-broadcasting, so there is no echo loop. Calling broadcast announces the tab, and any peer already holding the feed answers with a snapshot (current world + resume checkpoint) so the new tab resyncs mid-stream via the v3 reconnect path. Off by default; broadcasting reports whether it is on, destroy() closes the channel. Needs a seq/dedupe router to drop replayed deltas cleanly. (Not demonstrated headless: it depends on the browser's BroadcastChannel delivering across tabs asynchronously.)

DFQL/DuckDB query-slice routing (v7,. query(adapter, request?) sources the router from a query rather than a pushed feed: it runs adapter.execute (any pushdown adapter - a DFQL/DuckDB one, or a createPushdownSource-style object with capabilities and execute(query)), partitions the result across the routes, and drives the grids by the same keyed diff load() uses. Where a route declares a where (a filter-wire condition { col, op, value } or an and/or/not group), that filter is pushed down into the engine where the adapter's capability model allows and the residual is finished client-side; routes without a where share one base query and are partitioned client-side. It composes with the per-route transform/filter/sort/rollup (v3) and cross-grid links/graph, and lastQueryPlan() reports the pushed/residual split per fetch. Async.

Query-slice routing, executed

Two routes over one query, each with its own where; a capability-free adapter pushes nothing, so each residual is finished client-side, and the plan records the split. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
const big = createHeadlessGrid({ rowKey: 'id', columns: cols });
const small = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// Each route carries a `where`, pushed down where the adapter allows.
router.attach(big, 'order', { where: { col: 'amt', op: 'gte', value: 20 } });
router.attach(small, 'order', { where: { col: 'amt', op: 'lt', value: 20 } });

// A capability-free adapter: nothing pushes, so every `where` is residual.
const adapter = {
  capabilities: {},
  execute: async (query) => ({ rows: [
    { id: 'o1', type: 'order', amt: 10 },
    { id: 'o2', type: 'order', amt: 30 },
    { id: 'o3', type: 'order', amt: 20 },
  ] }),
};
await router.query(adapter);
const bigN = big.rows.count();            // 2: o2, o3 (amt >= 20)
const smallN = small.rows.count();        // 1: o1 (amt < 20)
const plan = router.lastQueryPlan();       // per-fetch pushed/residual split

big.destroy(); small.destroy(); router.destroy();
return [bigN, smallN, plan.length].join(' | ');

Write-back routing (v8,. A route made writable - attach(grid, predicate, { writable: true }) - has its grid's committed edits routed back to a write target the host persists. The router captures edits off the grid's public edit surface (it subscribes to grid.on('cell:changed') and re-enters accepted writes through grid.edit.setCells, so grid-core is untouched) and hands each change to onWrite(change, { route, source }) - the per-route callback here, or the router-global onWrite passed to createDataRouter. A rejected write reverts the cell; an accepted one re-enters as a normal delta. onConflict(change, { serverRow }) surfaces a last-write-wins conflict. A derived route (one carrying rollup or transform) cannot be writable - its edits are reverted and warned. (Documented here from the shipped surface; the grid-driven write-back commit path is demonstrated by the grid's own write-back example above rather than repeated on the router.)

Fan-in: many feeds, one router (v9,. One router can ingest many feeds. addSource(feed, { map?, key? }) returns a per-feed handle (load/apply/push/remove, plus id and size) whose rows are normalized to the common shape by map and namespaced by key (a prefix string, true to prefix with the source id, or a keyFn(row)) so ids from different feeds cannot collide in the shared keyed store, then merged through the router's ordinary apply/load path - partitioned, routed, linked, deduped, buffered and (v8) written back exactly as the single-source path. removeSource(ref) (or the handle's remove) drops exactly the rows a feed contributed; sources() lists the registered ids.

Fan-in from two feeds, executed

Two feeds with a colliding raw id, namespaced per source so they merge without clobbering; removing one feed drops exactly its rows. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'n', field: 'n', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(g, 'order');

// Two feeds, each namespaced by its source id so raw ids cannot collide.
const crm = router.addSource('crm', { key: true });
const erp = router.addSource('erp', { key: true });
crm.load([{ id: '1', type: 'order', n: 10 }, { id: '2', type: 'order', n: 20 }]);
erp.load([{ id: '1', type: 'order', n: 99 }]);  // same raw id '1' - merged, not clobbered
const merged = g.rows.count();               // 3
const ids = router.sources().join(',');       // crm,erp
const held = crm.size;                        // 2 - what this one feed holds live

erp.remove();                                  // drops exactly erp's row; the handle knows its own feed
const afterRemove = g.rows.count();          // 2

g.destroy(); router.destroy();
return [merged, ids, held, afterRemove].join(' | ');

Fan-in JOIN / enrichment (v11,. Fan-in above merges feeds side by side; a join spec goes further and enriches one feed's rows with fields looked up from another registered source - e.g. an orders feed enriched with name/tier from a customers source keyed by customerId. Declared per source: addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } }). localKey is the field on the enriched (left) row holding the foreign key (a field name or fn(row)); foreignKey is the field matched on the lookup row (defaults to localKey's name); fields is what to pull - an array, a { src: dest } rename map, or select(lookupRow, leftRow) => object. No second store is built: the lookup source is an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key → store-id. missing chooses what happens when the lookup is absent or late: hold withholds the row from viewers until its lookup arrives, passthrough (the default) lets it flow unenriched, and null flows it with the pulled fields set to null. Enriched rows reach viewers through the ordinary keyed-diff path. Late lookups re-enrich: when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted - a held row is released, a null/passthrough row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under hold, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.

Durable resume and backpressure (v12/v13, /. persist({ key }) turns on durability: the router snapshots its keyed store and time-travel ring to a durable async store (IndexedDB by default, or any { get, set } you pass as storage) after each load/apply, and await router.restore() - called once after the grids are attached - rehydrates them through the ordinary keyed diff, so an offline reload or a browser refresh resumes exactly where it left off (blocked/private storage degrades to in-memory with a one-time warning, never a throw). Independently, a route can declare backpressure - attach(grid, type, { label, backpressure: { maxHz } }) - to cap how often its viewer repaints under load without slowing the store or any sibling route: maxHz/minInterval rate-limit the refresh, sample thins intermediate ones, and maxLag sets the backlog depth at which throttling engages; a trailing flush always lands the latest state so the viewer converges. What it cost is observable: router.metrics().routes[].backpressure is { pending, coalesced } - the held backlog and the cumulative change-events folded into deferred refreshes (null for a route with no policy).

A JOIN with a late lookup, executed

An order arrives before its customer, so under hold it is withheld; when the customer feed loads, the order is released and enriched with the looked-up name. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'kind', field: 'kind' }, { id: 'customerId', field: 'customerId' }, { id: 'name', field: 'name' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'order');

// Enrich orders with a customer name looked up by customerId; hold until known.
const orders = router.addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name'], missing: 'hold' } });
const customers = router.addSource('customers');

orders.load([{ id: 'O1', kind: 'order', customerId: 'C1', amount: 100 }]);
const held = g.rows.count();                 // 0 - withheld until the lookup arrives

customers.load([{ id: 'C1', kind: 'customer', customerId: 'C1', name: 'Acme' }]);
const released = g.rows.count();             // 1 - released and enriched
let name;
for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === 'O1') name = r.data.name; }

g.destroy(); router.destroy();
return [held, released, name].join(' | ');

Explain: why does my grid show 0 rows? (v18,. metrics() says how many rows moved; it cannot say why a row never arrived. explain() can: it reports the most recent settle's join matches and misses by the actual key values, not just a count. The classic trap below looks like a type mismatch at a glance - a string '42' next to a number 42 - and it is worth knowing the router already treats those as the same key (both sides of a join key are coerced to a string before they are compared, so a numeric id and its numeral-string twin match fine). What actually breaks a join is a difference in representation: here, the upstream feed hands the order back with a stray leading space on the id. explain({ source })'s unmatched.left.samples names the exact offending value, so the space is visible the moment you look - nothing about "0 rows" has to be guessed. trace.missing() (v19, answers the SAME question for one named row, by a reason CODE rather than a count to read: pass the row's own source and key (not the join key) and it names exactly which lookup failed and why.

Explain reveals an unmatched join key, executed

An order's customerId carries a stray space its feed never stripped. The join's missing: 'hold' withholds the row entirely - the grid shows nothing - explain() names the exact value that failed to match, and trace.missing('orders', 'O1') says the same thing for that one row: join-no-partner, with the key it looked up and the source it looked in. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'customerId', field: 'customerId' }, { id: 'name', field: 'name' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
// traceLimit bounds the per-row lineage store trace() reads from - well above
// this example's two rows, so nothing here is evicted.
const router = createDataRouter({ rowKey: 'id', trace: true, traceLimit: 50 });
router.attach(g, (row) => row.kind === 'order');

const customers = router.addSource('customers');
const orders = router.addSource('orders', {
  join: { from: 'customers', localKey: 'customerId', foreignKey: 'id', fields: ['name'], missing: 'hold' },
});

customers.load([{ id: 42, name: 'Acme Corp', kind: 'customer' }]);
// Looks like a string vs a number - the router already normalises THAT. The
// real break is the leading space the feed left on the id.
orders.load([{ id: 'O1', customerId: ' 42', kind: 'order' }]);

const rowsShown = g.rows.count();                       // 0 - held, nothing to see

const report = router.explain({ source: 'orders' }).source;
const join = report.joins[0];
const offendingKey = `[${join.unmatched.left.samples[0]}]`; // "[ 42]" - the space, made visible

// trace.missing() answers the same question for the one row that was held - 
// 'orders'/'O1' is the row's OWN identity, not the join key it looked up.
const missing = router.trace.missing('orders', 'O1');
const missingKey = `[${missing.key}]`;

// Narrow to one route (here, by the grid it feeds) for just that route's report.
const routeReported = router.explain({ route: g }).route !== null;

g.destroy(); router.destroy();
return [rowsShown, join.matched, join.unmatched.left.count, offendingKey, missing.reason, missingKey, missing.source, routeReported].join(' | ');

COLLECT join and join arrays (v14,. A join may be one spec or an array applied in order, and a spec with many: true is a collect join instead of a lookup: it gathers every row of from whose foreignKey equals the left row's localKey (defaults to the source's rowKey) into an array written under as, mapping each by select (default the whole row): a list of field names collects objects with just those fields, a { from: to } rename map collects objects with the renamed fields, a single field-name string collects the bare values, and a select(fromRow, leftRow) function returns whatever you like; a field the collected rows do not have is collected as undefined and warns once by name (data-router:collect-select-field:*), with optional distinct and sort (true for the default ascending order, or a compareFn). missing works as for a lookup, on the collected array: passthrough (default) yields [] when nothing matches, null yields null, and hold withholds the row until at least one match arrives. Because a join reads from's rows after that source's own joins, the graph chains into a multi-level join - a country rename re-enriches its addresses, which re-collect their companies - live everywhere by keyed diff, touching only the affected rows and recomputed from the base row so it stays idempotent. A declaration that would close a cycle (a source collecting from a source that transitively collects from it) is refused by name (router:join-cycle) rather than recursing.

A multi-level COLLECT join into a list column, executed

An address looks up its country's name; a company collects its addresses' names - distinct and sorted - into a list column. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
await import('../packages/modules/list/index.js');   // registers the `list` type

const cols = [{ id: 'id', field: 'id' }, { id: 'countries', field: 'countries', type: 'list', typeOptions: { of: 'text' } }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'company');

const countries = router.addSource('country');
const address = router.addSource('address', { join: { from: 'country', localKey: 'countryId', fields: { name: 'countryName' } } });
const company = router.addSource('company', { join: { from: 'address', foreignKey: 'companyId', many: true, select: (a) => a.countryName, distinct: true, sort: true, as: 'countries' } });

countries.load([
  { id: 'CO1', kind: 'country', countryId: 'CO1', name: 'France' },
  { id: 'CO2', kind: 'country', countryId: 'CO2', name: 'Germany' },
  { id: 'CO3', kind: 'country', countryId: 'CO3', name: 'UK' },
]);
address.load([
  { id: 'A1', kind: 'address', companyId: 'C1', countryId: 'CO1' },
  { id: 'A2', kind: 'address', companyId: 'C1', countryId: 'CO1' },  // same country twice: distinct collapses it
  { id: 'A3', kind: 'address', companyId: 'C1', countryId: 'CO2' },
  { id: 'A4', kind: 'address', companyId: 'C2', countryId: 'CO2' },
  { id: 'A5', kind: 'address', companyId: 'C2', countryId: 'CO3' },
  { id: 'A6', kind: 'address', companyId: 'C3', countryId: 'CO3' },
]);
company.load([{ id: 'C1', kind: 'company' }, { id: 'C2', kind: 'company' }, { id: 'C3', kind: 'company' }]);

const pick = (k) => { for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === k) return r.data.countries; } return []; };
const out = [pick('C1').join(','), pick('C2').join(','), pick('C3').join(',')].join(' | ');
g.destroy(); router.destroy();
return out;

Rollup onto the parent (v15,. A many: true join may compute instead of collect: give it an aggregate map (in place of as) and each named reducer is written straight onto the parent row. The reducer fns reuse the v3 rollup vocabulary - count, sum, avg, min, max - plus distinctCount, first and last (first/last order the children by an orderBy field and return the field value of the first/last child; orderBy defaults to field). A join-level where: (childRow) => boolean filters the children before any aggregate is computed. An empty child set yields count 0, sum 0 and the rest null. Because a join reads from's rows after that source's own joins, a company can count the distinct countries its addresses sit in - a country rename re-enriches the addresses and the company's counts follow - live by keyed diff, one parent re-emitted per child change.

A rollup-onto-parent join, executed

An address looks up its country's name; a company counts its addresses and the distinct countries they sit in. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [
  { id: 'id', field: 'id' },
  { id: 'addressCount', field: 'addressCount', type: 'number' },
  { id: 'countriesCount', field: 'countriesCount', type: 'number' },
] });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'company');

const country = router.addSource('country');
const address = router.addSource('address', { join: { from: 'country', localKey: 'countryId', fields: { name: 'countryName' } } });
const company = router.addSource('company', { join: {
  from: 'address', foreignKey: 'companyId', many: true,
  aggregate: { addressCount: { fn: 'count' }, countriesCount: { field: 'countryName', fn: 'distinctCount' } },
} });

country.load([
  { id: 'CO1', kind: 'country', countryId: 'CO1', name: 'France' },
  { id: 'CO2', kind: 'country', countryId: 'CO2', name: 'Germany' },
  { id: 'CO3', kind: 'country', countryId: 'CO3', name: 'UK' },
]);
address.load([
  { id: 'A1', kind: 'address', companyId: 'C1', countryId: 'CO1' },
  { id: 'A2', kind: 'address', companyId: 'C1', countryId: 'CO1' },  // same country twice: distinct collapses it
  { id: 'A3', kind: 'address', companyId: 'C1', countryId: 'CO2' },
  { id: 'A4', kind: 'address', companyId: 'C2', countryId: 'CO2' },
  { id: 'A5', kind: 'address', companyId: 'C2', countryId: 'CO3' },
]);
company.load([{ id: 'C1', kind: 'company' }, { id: 'C2', kind: 'company' }]);

const pick = (k) => { for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === k) return `${r.data.addressCount},${r.data.countriesCount}`; } return ''; };
const out = [pick('C1'), pick('C2')].join(' | ');
g.destroy(); router.destroy();
return out;

Shaping: map plus fields coercion. Beyond map - which renames and reshapes a feed’s rows - a source may declare a fields map that coerces raw values to the typed values the grid’s columns expect. Each entry is a type name ('text', 'number', 'integer', 'boolean', 'date', 'json') or an options object: { type: 'number', decimal: ',' } reads a decimal comma and thousands separators; { type: 'boolean', true: ['Y','yes','1'], false: ['N','no','0'] } names its own marker sets; { type: 'date', format: 'iso' | 'epoch-s' | 'epoch-ms' | 'dd/MM/yyyy' } chooses the input format (the result is the grid’s YYYY-MM-DD date storage form, and the router reuses the core’s own parsers so the two never disagree); 'json' parses a string. Any field may also list nulls: ['', 'N/A', '-'] to map those strings to null first. Coercion runs after map and before joins, so join keys, spread and rollups all read typed values; a value that fails to coerce is stored as null and warns once per source+field (router:coerce-failed), never thrown; an undeclared field passes through untouched.

Field coercion feeding a typed grid, executed

A feed sends a decimal-comma amount and a Y/N flag; fields coerces them to the number and boolean the grid’s typed columns sort and filter on, before any join sees them. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [
  { id: 'id', field: 'id' }, { id: 'kind', field: 'kind' },
  { id: 'amount', field: 'amount', type: 'number' },
  { id: 'active', field: 'active', type: 'boolean' },
];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'order');

// The feed sends "1.234,56" and "Y"/"N"; `fields` types them for the grid.
const orders = router.addSource('orders', {
  fields: {
    amount: { type: 'number', decimal: ',' },
    active: { type: 'boolean', true: ['Y', 'yes'], false: ['N', 'no'] },
  },
});
orders.load([
  { id: 'o1', kind: 'order', amount: '1.234,56', active: 'Y' },
  { id: 'o2', kind: 'order', amount: '42', active: 'N' },
]);

const amount = g.rows.value('o1', 'amount');   // 1234.56 - a number, not "1.234,56"
const typed = typeof amount;                   // number
const active = g.rows.value('o1', 'active');   // true - a boolean, not "Y"
// The typed number column compares numerically, so o1 sorts above o2.
const bigger = g.rows.value('o1', 'amount') > g.rows.value('o2', 'amount');

g.destroy(); router.destroy();
return [amount, typed, active, bigger].join(' | ');

Spread onto the parent (v16,. An EAV / custom-field source's rows each become their OWN field on the parent: give the join a spread spec instead of as/aggregate, and every matching child row writes prefix + name(row) → value(row) onto the parent. name and value are field names or fn(row)s; prefix (e.g. 'cf_') namespaces them; include narrows which attributes spread (an array, or a fn(name, row)); and type coerces per-name values (number/date/boolean/text, a bad parse keeping the raw value rather than a silent NaN/Invalid Date). The attribute source's own joins apply first (multi-level), an attribute add/change/delete re-emits its parent live, and a deleted attribute leaves the field cleared rather than stale. Two attributes landing on one name are last-writer-wins and warned once (router:spread-duplicate); a spread field never overwrites a base parent field and warns once (router:spread-collision). The router reports the field names per route through fieldsOf(ref) and the fields:changed event ({ route, added, removed }), so a host can add or drop grid columns as custom attributes appear and disappear.

A SPREAD join of EAV attributes onto a parent, executed

Each companyAttr row becomes one cf_-prefixed field on its company; fieldsOf reports the names a host turns into grid columns. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }] });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'company');

const attr = router.addSource('companyAttr');
const company = router.addSource('company', { join: {
  from: 'companyAttr', foreignKey: 'companyId',
  spread: { name: 'attribute', value: 'value', prefix: 'cf_' },
}});

company.load([{ id: 'C1', kind: 'company', name: 'Acme' }]);
attr.load([
  { id: 'A1', kind: 'companyAttr', companyId: 'C1', attribute: 'industry', value: 'Media' },
  { id: 'A2', kind: 'companyAttr', companyId: 'C1', attribute: 'tier', value: 'Gold' },
]);

const pick = (k) => { for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === k) return r.data; } return {}; };
const row = pick('C1');
const out = [row.cf_industry, row.cf_tier].join(',') + ' | ' + router.fieldsOf('company').join(',');
g.destroy(); router.destroy();
return out;

Unnest a nested payload (v17,. A REST-style response embeds its children: a company carries an addresses array. Give the source an unnest spec and each element becomes its OWN row type, routed through the ordinary partition/route/join machinery - an address grid fills from the companies’ embedded addresses, a country lookup join can enrich each address, and a company collect join can collect its children’s looked-up fields straight back. Each spec is { path, as, key?, parentKey?, keep?, join?, unnest? }: path is the dotted array field ('profile.addresses'); as the child type (and its source id, so a lookup/collect join can from it); key the child’s identity - a field name, or fn(child, parent, index), defaulting to `${parentKey}:${index}` (the index fallback, whose caveat is that reordering an array churns its children); parentKey the field written onto each child holding the parent’s key (default ${source}Id, e.g. companyId); keep (default false) keeps the nested array on the parent’s own routed row; and join/unnest are the child source’s own joins and nested unnest. A parent update whose array changed emits a keyed diff of its children (add/change/remove by child key; a child moved between parents is a remove + add), a parent delete removes its children, and a load() snapshot diffs children too. Two children of one parent resolving to the same key warn once (router:unnest-duplicate-key), last in source order winning.

Unnest: a nested REST payload into two grids, executed

A company payload embeds its addresses; unnest routes them to an address grid, each address looks up its country’s name, and the company collects those names back. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const companyGrid = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'name', field: 'name' }] });
const addressGrid = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'companyId', field: 'companyId' }] });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(companyGrid, 'company');
router.attach(addressGrid, 'address');

const country = router.addSource('country');
const company = router.addSource('company', {
  unnest: [{ path: 'addresses', as: 'address', key: 'id', parentKey: 'companyId',
             join: { from: 'country', localKey: 'countryId', fields: { name: 'countryName' } } }],
  join: { from: 'address', foreignKey: 'companyId', many: true, select: (a) => a.countryName, distinct: true, sort: true, as: 'countries' },
});

country.load([
  { id: 'CO1', kind: 'country', countryId: 'CO1', name: 'France' },
  { id: 'CO2', kind: 'country', countryId: 'CO2', name: 'Germany' },
]);
company.load([
  { id: 'C1', kind: 'company', name: 'Acme', addresses: [
    { id: 'A1', countryId: 'CO1' },
    { id: 'A2', countryId: 'CO2' },
    { id: 'A3', countryId: 'CO1' },
  ] },
]);

const pick = (g, k) => { for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === k) return r.data; } return {}; };
const addrCount = addressGrid.rows.count();
const countries = pick(companyGrid, 'C1').countries.join(',');
const a1Company = pick(addressGrid, 'A1').companyId;
companyGrid.destroy(); addressGrid.destroy(); router.destroy();
return [addrCount, countries, a1Company].join(' | ');

Observability (v10,. metrics() is a cheap point-in-time snapshot of the router's runtime - per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global unrouted / dropped (duplicate) / buffered (buffer depth) / lag figures. Throughput is sampled, so it is measured over the interval since the last read or emit. on('metrics', handler) drives it on a periodic timer (the metricsInterval ms, default 1000; 0 disables it) and returns an unsubscribe - the timer runs only while a listener is registered, so collection is off-by-default. mountDevtools(el, { interval? }) mounts an opt-in DOM panel (in the module's own devtools.js, so the core stays DOM-free) that renders metrics() and refreshes on each emit.

A metrics snapshot, executed

A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the unrouted total, and on('metrics') returns an unsubscribe. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id', metricsInterval: 0 });  // 0: no periodic emit; read on demand
router.attach(g, 'order');

const off = router.on('metrics', () => {});   // register; returns unsubscribe (no timer at interval 0)
router.load([
  { id: 'o1', type: 'order' },
  { id: 'o2', type: 'order' },
  { id: 'x1', type: 'ticket' },               // matches no route
]);
const m = router.metrics();
const rows = m.routes[0].rows;               // 2
const unrouted = m.unrouted;                 // 1
off();                                        // last listener gone

g.destroy(); router.destroy();
return [rows, unrouted, typeof off].join(' | ');

The remaining options, each executed

One short example per option family the eight above do not reach: overlap and the unrouted sink, coalesced pushes, write-back, the declarative graph, durable resume, backpressure, and time-domain scrubbing. Each is run headless on every build.

Fan one value to two viewers, and log the strays, executed

With overlap a record goes to every route it matches, so a grid and a second viewer can share one partition; onUnrouted receives what matched none, which is the right sink when a stray record is a bug to log rather than a row to show. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g1 = createHeadlessGrid({ rowKey: 'id', columns: cols });
const g2 = createHeadlessGrid({ rowKey: 'id', columns: cols });
const strays = [];
const router = createDataRouter({ key: 'type', rowKey: 'id', overlap: true, onUnrouted: (row) => strays.push(row.id) });
router.attach(g1, 'order');
router.attach(g2, 'order');            // a second viewer on the same value: allowed because overlap is on
router.load([{ id: 'o1', type: 'order' }, { id: 'x1', type: 'ticket' }]);
const out = [g1.rows.count(), g2.rows.count(), strays.join(','), router.unrouted];
g1.destroy(); g2.destroy(); router.destroy();
return out.join(' | ');

Coalesce a burst into one repaint, executed

A high-frequency feed goes through push; with coalesce (or a batch interval) rapid updates to one key fold into a single apply, and flushStream gives a deterministic point. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

// A hand-rolled viewer, grid-shaped: the router accepts anything with rows.apply.
const rows = new Map(); let applies = 0;
const g = { rows: { apply(change) {
  applies += 1;
  for (const row of [...(change.add || []), ...(change.update || [])]) rows.set(row.id, row);
} } };
const router = createDataRouter({ rowKey: 'id', coalesce: true, batch: { intervalMs: 50 } });
router.attach(g, () => true);
router.push({ op: 'upsert', row: { id: 'a', n: 1 } });
router.push({ op: 'upsert', row: { id: 'a', n: 2 } });
router.push({ op: 'upsert', row: { id: 'a', n: 3 } });
const before = applies;                    // 0: nothing applied inside the batch window
router.flushStream();                      // one apply, carrying the latest value
const after = applies;
const n = rows.get('a').n;
router.destroy();
return [before, after, n].join(' | ');

A writable route, a conflict, executed

A route attached writable captures the grid’s committed edits and routes them to onWrite; when the server answers with a conflict, onConflict fires with the server row and the optimistic value stands (last-write-wins, no merge engine). Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number', edit: true }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const conflicts = [];
const router = createDataRouter({ key: 'type', rowKey: 'id', onConflict: (change, ctx) => conflicts.push(ctx.serverRow.amt) });
router.attach(g, 'a', { writable: true, onWrite: () => ({ conflict: { id: 'r1', type: 'a', amt: 7 } }) });
router.load([{ id: 'r1', type: 'a', amt: 10 }]);
g.edit.setCells([{ key: 'r1', colId: 'amt', value: 99 }]);   // the user's edit, routed to onWrite
const out = [conflicts.length, conflicts[0], g.rows.byKey('r1').data.amt];
g.destroy(); router.destroy();
return out.join(' | ');

The routing graph as one declarative spec, executed

The same routes and links the imperative calls would make, as data: routes attach a grid when a value matches, links filter one grid by another’s selection. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region' }];
const customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.configure({
  routes: [{ grid: customers, when: 'customer' }, { grid: orders, when: 'order' }],
  links: [{ from: customers, to: orders, on: { from: 'region', to: 'region' } }],
});
router.load([
  { id: 'c1', type: 'customer', region: 'emea' },
  { id: 'o1', type: 'order', region: 'emea' },
  { id: 'o2', type: 'order', region: 'amer' },
]);
const all = orders.rows.count();           // 2: no selection shows the whole partition
customers.selection.set(['c1']);
router.flush();
const linked = orders.rows.count();        // 1: only emea orders
customers.destroy(); orders.destroy(); router.destroy();
return [all, linked].join(' | ');

Durable resume through a store of your own, executed

The router snapshots to IndexedDB by default - dbName and storeName say where, and indexedDB lets you hand it a factory (here a tiny fake, so the example is deterministic) - or to any storage with async get/set. A second router over the same store restores what the first one wrote. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

// A fake IDBFactory: one database, one object store, put/get in a transaction.
const dbs = new Map(); const opened = [];
const req = () => ({ onsuccess: null, onerror: null, onupgradeneeded: null, result: undefined, error: null });
const fakeIndexedDB = { open(name) {
  const r = req(); opened.push(name);
  queueMicrotask(() => {
    const fresh = !dbs.has(name); if (fresh) dbs.set(name, new Map());
    const stores = dbs.get(name);
    r.result = {
      objectStoreNames: { contains: (n) => stores.has(n) },
      ['createObjectStore'](n) { stores.set(n, new Map()); return {}; },
      transaction(n) {
        const store = stores.get(Array.isArray(n) ? n[0] : n); const tx = { oncomplete: null, onerror: null, onabort: null }; const ops = [];
        tx.objectStore = () => ({
          put(v, k) { const q = req(); ops.push(() => { store.set(k, v); if (q.onsuccess) q.onsuccess({ target: q }); }); return q; },
          get(k) { const q = req(); ops.push(() => { q.result = store.get(k); if (q.onsuccess) q.onsuccess({ target: q }); }); return q; },
        });
        queueMicrotask(() => { for (const op of ops) op(); if (tx.oncomplete) tx.oncomplete(); });
        return tx;
      },
      close() {},
    };
    if (fresh && r.onupgradeneeded) r.onupgradeneeded({ target: r });
    if (r.onsuccess) r.onsuccess({ target: r });
  });
  return r;
} };

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const a = createDataRouter({ key: 'type', rowKey: 'id' });
const ga = createHeadlessGrid({ rowKey: 'id', columns: cols });
a.attach(ga, 'order');
a.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router', debounce: 0 });
a.load([{ id: 'o1', type: 'order' }, { id: 'o2', type: 'order' }]);
await a.flushPersist();

const b = createDataRouter({ key: 'type', rowKey: 'id' });      // "after the reload"
const gb = createHeadlessGrid({ rowKey: 'id', columns: cols });
b.attach(gb, 'order');
b.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router' });
const found = await b.restore();
const store = [...dbs.get('demo').keys()][0];

// Or skip IndexedDB entirely: any async get/set pair is a store.
const mem = new Map();
const c = createDataRouter({ key: 'type', rowKey: 'id' });
c.attach(createHeadlessGrid({ rowKey: 'id', columns: cols }), 'order');
c.persist({ storage: { get: async (k) => mem.get(k), set: async (k, v) => { mem.set(k, v); } }, debounce: 0 });
c.load([{ id: 'o9', type: 'order' }]);
await c.flushPersist();
const out = [opened[0], store, found, gb.rows.count(), b.persisting, mem.size];
ga.destroy(); gb.destroy(); a.destroy(); b.destroy(); c.destroy();
return out.join(' | ');

Throttle one viewer under load, executed

A backpressure policy on one route caps how often that viewer is refreshed without slowing the store or any sibling; a burst inside the window is held to one leading refresh, and flushBackpressure lands the coalesced latest state. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

let clock = 0;
const router = createDataRouter({ key: 'type', rowKey: 'id', now: () => clock });
const view = { refreshes: 0, rows: new Map() };
router.subscribe('x', (change) => {
  view.refreshes += 1;
  for (const row of [...change.add, ...change.update]) view.rows.set(row.id, row);
  for (const key of change.remove) view.rows.delete(key);
}, { backpressure: { maxHz: 100 } });      // at most one refresh per 10 ms
const up = (id, n) => ({ op: 'upsert', row: { id, type: 'x', n } });
router.apply([up('a', 1)]);                // leading edge: refreshes at once
router.apply([up('b', 2)]);                // inside the window: held
router.apply([up('c', 3)]);
router.apply([up('a', 9)]);                // still held; a's latest value wins
const held = view.refreshes;               // 1
clock = 10;
router.flushBackpressure();                // window open: one trailing refresh
const out = [held, view.refreshes, view.rows.get('a').n, view.rows.size];
router.destroy();
return out.join(' | ');

Scrub by wall-clock time, executed

When rows carry a timestamp, time names it and the buffer works in the time domain: scrubTo takes a moment rather than a seq, and live returns to the head. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'ts', field: 'ts', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ rowKey: 'id', time: 'ts' });
router.attach(g, () => true);
router.buffer({ window: 10000 });
router.apply([{ op: 'upsert', row: { id: 'a', ts: 100 } }]);
router.apply([{ op: 'upsert', row: { id: 'b', ts: 200 } }]);
router.apply([{ op: 'upsert', row: { id: 'c', ts: 300 } }]);
router.scrubTo(150, { by: 'time' });       // what the grid showed at t=150
const then = g.rows.count();               // 1
router.live();
const now = g.rows.count();                // 3
g.destroy(); router.destroy();
return [then, now].join(' | ');

Fire once on a rising edge, and re-arm, executed

An alert watches a partition slice and EMITS rather than renders: condition(rows) is evaluated on every load and delta, the handler fires once when it first becomes truthy, staying quiet while it holds and re-arming when it clears. Run headless on every build.

const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const fired = [];
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.alert('order',
  (rows) => rows.find((r) => r.amt > 100) || false,   // condition(rows): the signal, or falsy
  () => { fired.push(1); });                            // handler(signal, rows): fires on the rising edge

router.load([{ id: 'o1', type: 'order', amt: 10 }]);
router.apply([{ op: 'upsert', row: { id: 'o1', type: 'order', amt: 150 } }]); // crosses
const crossed = fired.length;                        // 1
router.apply([{ op: 'upsert', row: { id: 'o1', type: 'order', amt: 200 } }]); // stays above: no refire
const stillOne = fired.length;                       // 1
router.apply([{ op: 'upsert', row: { id: 'o1', type: 'order', amt: 10 } }]);  // falls below: re-arms
router.apply([{ op: 'upsert', row: { id: 'o1', type: 'order', amt: 150 } }]); // crosses again
const refired = fired.length;                       // 2

router.destroy();
return [crossed, stillOne, refired].join(' | ');

Watch a slice and report every measurement, executed

monitor (v14, is a levelled sibling of alert: evaluate(rows) runs and reports on every load and delta, not only on a rising edge, which is the feed the alarms module runs its level ladder on. It returns a stop function rather than the router. Run headless on every build.

const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const readings = [];
const router = createDataRouter({ key: 'type', rowKey: 'id' });
const stop = router.monitor('order',
  (rows) => rows.reduce((sum, r) => sum + r.amt, 0),    // evaluate(rows): what to report
  (value) => readings.push(value));                      // handler(value, rows): every evaluation

// Seeded immediately (0, over an empty world), then re-evaluated on load and each delta.
router.load([{ id: 'o1', type: 'order', amt: 10 }]);
router.apply([{ op: 'upsert', row: { id: 'o2', type: 'order', amt: 20 } }]);
router.apply([{ op: 'upsert', row: { id: 'o1', type: 'order', amt: 15 } }]);
stop();                                                   // release the monitor and its partition
router.apply([{ op: 'upsert', row: { id: 'o3', type: 'order', amt: 999 } }]); // no further reading

router.destroy();
return readings.join(' | ');

Detach a grid; the host still owns it, executed

detach(grid) stops routing to a grid and forgets its slice, but never touches the grid itself - the host still owns and destroys it. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(g, 'order');
router.load([{ id: 'o1', type: 'order' }]);
const before = g.rows.count();          // 1
router.detach(g);                       // stop routing to g; the router forgets its slice
router.apply([{ op: 'upsert', row: { id: 'o2', type: 'order' } }]);
const after = g.rows.count();           // still 1: the delta never reached the detached grid
const stillUsable = typeof g.rows.count === 'function'; // detach never destroys the grid

g.destroy(); router.destroy();
return [before, after, stillUsable].join(' | ');

Drop a fan-in source by id directly, executed

removeSource(ref) takes either a source id or the handle addSource returned; this executed example drops one by its id directly, the way a host that only kept the id (not the handle) would. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(g, 'order');
router.addSource('crm', { key: true }).load([{ id: '1', type: 'order' }]);
router.addSource('erp', { key: true }).load([{ id: '1', type: 'order' }]); // colliding raw id, namespaced
const merged = g.rows.count();          // 2: both feeds' rows, kept apart by source id
router.removeSource('erp');             // drop a feed by id, without going through its handle
const after = g.rows.count();           // 1

g.destroy(); router.destroy();
return [merged, after].join(' | ');

Persist and prime the resume checkpoint, executed

checkpoint() is a copy of the per-record resume point a host can persist; seenThrough(mark) primes a fresh router from it, so a delta at or below what was already seen is dropped by the ordinary seq gate rather than reapplied. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router.attach(g, () => true);
router.apply([{ op: 'upsert', row: { id: 'a', n: 1, v: 3 } }]);

const mark = router.checkpoint();       // per-record resume point: Map<rowKey, seq>
const persisted = mark.get('a');        // 3 -- what a host writes to storage before a reload

// "After the reload": a fresh router primed from the persisted checkpoint.
const g2 = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router2 = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router2.attach(g2, () => true);
router2.seenThrough(mark);              // primes the checkpoint, so an early replay is dropped
router2.apply([{ op: 'upsert', row: { id: 'a', n: 1, v: 2 } }]);   // replayed, below the checkpoint
const belowDropped = g2.rows.count() === 0 ? 'dropped' : 'leaked';
router2.apply([{ op: 'upsert', row: { id: 'a', n: 9, v: 4 } }]);   // genuinely new
const above = g2.rows.value('a', 'n');

g.destroy(); router.destroy(); g2.destroy(); router2.destroy();
return [persisted, belowDropped, above].join(' | ');

Walk an incident with pause and resume, executed

pause() freezes an in-flight replay at its current step; resume() continues it from exactly there. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(g, () => true);
router.buffer({ max: 100 });
router.load([{ id: 'a', n: 0, v: 0 }]);
for (let i = 1; i <= 4; i++) router.apply([{ op: 'upsert', row: { id: 'a', n: i, v: i } }]);

const p = router.replay(1, 4, { speed: 5 });   // walk v1..v4, one step every 5ms
router.pause();                                 // synchronously, before the first step timer fires
const paused = g.rows.value('a', 'n');          // 1: parked at the range start
await new Promise((r) => setTimeout(r, 20));
const stillPaused = g.rows.value('a', 'n');     // 1: still frozen while paused

router.resume();                                // continue from where it stopped
await p;
const done = g.rows.value('a', 'n');            // 4: resume ran the rest of the range

g.destroy(); router.destroy();
return [paused, stillPaused, done].join(' | ');

Mount the live devtools panel, executed

mountDevtools(el) is opt-in and DOM-touching (kept out of the DOM-free index.js, in the module's own devtools.js); it renders metrics() into el, and the controller's refresh()/destroy() are deterministic for a test. Reaches for the same in-tree DOM test double the suite runs headlessly against. Run headless on every build.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document } = createTestDom();
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }] });
const router = createDataRouter({ key: 'type', rowKey: 'id', metricsInterval: 0 });
router.attach(g, 'order');
router.load([{ id: 'o1', type: 'order' }]);

const el = document.createElement('div');
const panel = router.mountDevtools(el);        // renders metrics() into el; off unless called
const mountedRows = el.children.length;        // 1: the panel root

router.load([{ id: 'o1', type: 'order' }, { id: 'o2', type: 'order' }]);
panel.refresh();                                // force an immediate re-render, deterministic for tests
const stillMounted = el.children.length;        // still 1: refresh replaces the panel's own content

panel.destroy();                                // unsubscribes and removes the panel from the DOM
const afterDestroy = el.children.length;        // 0

g.destroy(); router.destroy();
return [mountedRows, stillMounted, afterDestroy].join(' | ');

Parent-delete integrity: cascade, orphan, keep (v18,

When a parent row is deleted, what happens to its children? onParentDelete: 'cascade' | 'orphan' | 'keep' answers that on a COLLECT join (join: { from, foreignKey, many: true, as, onParentDelete }), on an unnest (unnest: { path, as, onParentDelete }), and standalone via relateRows({ child, parent, foreignKey, onParentDelete, writeBack }) - the last is for a parent→child relationship the router does not otherwise read. 'cascade' deletes every child whose foreign key matches the deleted parent, in one keyed-diff batch, recursively through the child’s own cascades (a company whose addresses cascade, whose phones cascade). 'orphan' keeps the children, marks them __orphan: true (a documented field), and warns once (router:orphaned-rows, with the count); a re-added parent re-adopts its orphans (the mark clears). 'keep' keeps them bare - the default for a collect join and for relateRows, and the unchanged behaviour there; an unnest’s unchanged behaviour is already to remove its children, so it defaults to 'cascade'. The join graph is acyclic (a cyclic join is refused), and relateRows cycles are broken by never deleting a row twice. Cascaded deletes do not trigger write-back - the server is assumed to have done the cascade - unless writeBack: true on relateRows, which routes each cascaded delete to the router’s onWrite so the host persists it.

A standalone cascade rule, executed

relateRows declares a parent→child delete rule without a join: deleting a company cascades its addresses. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const companyGrid = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }] });
const addressGrid = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }] });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(companyGrid, 'company');
router.attach(addressGrid, 'address');
const company = router.addSource('company');
const address = router.addSource('address');
router.relateRows({ child: 'address', parent: 'company', foreignKey: 'companyId', onParentDelete: 'cascade' });
address.load([
  { id: 'A1', kind: 'address', companyId: 'C1' },
  { id: 'A2', kind: 'address', companyId: 'C1' },
  { id: 'A3', kind: 'address', companyId: 'C2' },
]);
company.load([{ id: 'C1', kind: 'company' }, { id: 'C2', kind: 'company' }]);
company.apply([{ op: 'delete', row: { id: 'C1', kind: 'company' } }]);   // cascades A1 + A2
const companyCount = companyGrid.rows.count();   // 1: C2 survives
const addressCount = addressGrid.rows.count();   // 1: A3 survives
companyGrid.destroy(); addressGrid.destroy(); router.destroy();
return [companyCount, addressCount].join(' | ');

Type reference

Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.

The data router

RouteRollup

A rollup route's summary spec (v3): one summary row per `groupBy` group, each `aggregate` a named reducer over the group's rows or a `{ op, field }` shorthand (`sum`, `avg`, `min`, `max`, `count`).

PropertyTypeDescription
groupByRouterKey | RouterKey[]What identifies a group: a property name, a `fn(row)`, or a list of either for a composite. Named properties are carried onto the summary row.
aggregateRecord<string, ((rows: RouterRecord[]) => unknown) | { op: string; field?: string }>The summary fields, each a reducer over the group's rows: a function handed the rows, or `{ op, field }` for `count`, `sum`, `avg`, `min` or `max` over the numeric values of `field`. `avg` over no numeric values is 0; `min` and `max` are undefined. (optional)

RouteOptions

Per-route options, shared by `attach`, `attachDefault` and `subscribe`. `rowKey` overrides the router default for this route. `transform` reshapes each row before the viewer sees it; `filter` admits a subset; `sort` orders what the viewer receives; `rollup` summarises the slice (v3). A `transform` or `rollup` route is derived and cannot be `writable`. `where` is read only by `query()` (v7). `writable` routes the grid's committed edits to `onWrite`, reverting on reject, with `onConflict` for a last- write-wins conflict (v8); both default to the router's own. `label` names the route in metrics and the devtools panel; `backpressure` throttles its refresh under load (v13).

PropertyTypeDescription
rowKeyRouterKeyWhat identifies a row on this route - a field name or a `fn(row)` - overriding the router's own `rowKey`. (optional)
sortRouteSortOrders the rows the viewer receives - a comparator, or `{ key, dir }`. A viewer with a sort model of its own still governs the final display; for a rollup route this sorts the summary rows. (optional)
rollupRouteRollupFeeds the viewer one summary row per group instead of the raw rows. A rollup route is derived and its edits are always reverted. (optional)
whereRouteWhereA filter-wire condition for this route's slice, read only by `query()`: the pushable part goes to the engine and the rest is finished in the browser. It has no effect on rows arriving through `load`, `apply` or `push`. (optional)
writablebooleanCaptures the viewer's committed edits and routes them to `onWrite` instead of leaving them local. Defaults to false; on a derived (rollup or transform) route the edit is reverted with a warning whatever this says. (optional)
labelstringNames this route in `metrics()` and the devtools panel. Defaults to null - the default route reports `default`. (optional)
backpressureRouteBackpressureThrottles how often this route's viewer is refreshed under load. Omitted, every change repaints the viewer at once. (optional)
MethodSignatureParametersReturnsDescription
transform(row: RouterRecord) => RouterRecordrow: RouterRecord=> RouterRecordReshapes each row before the viewer sees it. The row's identity is still taken from the original, so the keyed diff is unaffected; a transform route is derived and its edits are always reverted. (optional)
filter(row: RouterRecord) => booleanrow: RouterRecord=> booleanAdmits a subset of the route's partition to the viewer. It runs on the original row, before any transform and before a rollup groups them. (optional)
onWrite(change: RouterWrite, ctx: { route: unknown; source: unknown }) => unknownchange: RouterWrite
ctx: { route: unknown; source: unknown }
=> unknownReceives each captured edit as `{ key, colId, value, before, row }`. Return `false` or `{ ok: false }` (or reject, or throw) to revert the cell, `{ conflict, row }` to fire `onConflict` and take the winning row, anything else to accept; a promise is awaited with the optimistic value standing. Overrides the router-wide handler. The context's `route` is the viewer, and `source` is the `addSource` handle of the feed the edited row arrived on - the one to persist back to on a fan-in router - or null when the rows were loaded into the router directly. (optional)
onConflict(change: RouterWrite, ctx: { serverRow: RouterRecord }) => voidchange: RouterWrite
ctx: { serverRow: RouterRecord }
=> voidCalled when `onWrite` returns a `conflict`, with the server's row, so the host can tell the user. The router itself is last-write-wins: the returned row (or the optimistic one) re-enters regardless. Overrides the router-wide handler. (optional)

RouterConfig

A declarative routing graph (v5,: the same routes, links, relationship edges and buffer the imperative calls would make, as one data spec. Desugars to those calls and composes with them.

PropertyTypeDescription
routesRecord<string, unknown>[]The routes to open, each `{ grid, when, ...routeOptions }`, or `{ default: grid }`, or `{ subscribe: handler, when }`, or `{ alert: handler, when, condition }`. `when` is the partition value or predicate `attach` takes. (optional)
links{ from: unknown; to: unknown; on?: SelectionRelation; relation?: SelectionRelation }[]Selection links to make, each naming the `from` and `to` grids and the relation as `on` (or `relation`) - the arguments of `link()`. (optional)
relateRouterEdge[]Relationship-graph edges to register, as `relate()` takes them; an edge may be `mutual` to work in both directions. (optional)
buffer{ window?: number; max?: number }Turns on the time-travel buffer, bounded by `window` (ms of feed time) and/or `max` deltas. An empty object applies a default cap of 10,000 deltas and warns. (optional)

RouterJoinAggregate

One named reducer of a ROLLUP-ONTO-PARENT join's `aggregate` map (v15,. `fn` reuses the v3 rollup vocabulary - `count`, `sum`, `avg`, `min`, `max` - and adds `distinctCount`, `first` and `last`. `field` is the child field reduced over (required for every `fn` except `count`); `first`/`last` order the children by `orderBy` (defaulting to `field`) and return the `field` value of the first/last child. An empty child set yields `count` 0, `sum` 0 and the rest null.

PropertyTypeDescription
fnstringThe reducer: count | sum | avg | min | max | distinctCount | first | last.
fieldstringThe child field reduced over (required for every `fn` except `count`). (optional)
orderBystringFor first/last: the field the children are ordered by (defaults to `field`). (optional)

RouterJoin

PropertyTypeDescription
fromstringThe id of the registered source holding the lookup/collected rows.
localKeyRouterKeyReads the joining value off this source's row - a field name or a `fn(row)`. Required for a lookup join (or `on`); a collect join defaults it to the source's rowKey. Without it the join is ignored with a warning. (optional)
onRouterKeyAn alias for `localKey`, read when `localKey` is absent. (optional)
foreignKeyRouterKeyReads the joining value off the other source's rows - a field name or a `fn(row)`. Defaults to a string `localKey`; with a function `localKey` and no `foreignKey`, the join is ignored with a warning. (optional)
fromKeyRouterKeyAn alias for `foreignKey`, read when `foreignKey` is absent. (optional)
manybooleanA COLLECT join (v14): gather every matching row instead of one. (optional)
asstringA COLLECT join's destination field: the collected array is written here. Required for a collect join; without it the join is ignored with a warning. (optional)
aggregateRecord<string, RouterJoinAggregate>A ROLLUP-ONTO-PARENT join (v15,: a `many: true` join with an `aggregate` map instead of `as`. Each named result is computed over the matching children and written onto the parent row. Omit `as` when `aggregate` is set. (optional)
fieldsstring[] | Record<string, string> | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) => unknown)Which lookup fields to carry onto the row: a list of names, a `{ from: to }` rename map, or a `fn(lookupRow, leftRow)` returning the fields to merge. With none, rows pass through unenriched and the router warns. (optional)
selectstring[] | Record<string, string> | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) => unknown)An alias for `fields`, read when `fields` is absent. For a COLLECT join it is also the per-row mapping: a list of names (objects with just those fields), a `{ from: to }` rename map, a single field name (the bare values), or `fn(fromRow, leftRow)` returning the value to collect (defaults to the whole from-row). A field the rows lack warns once. A lookup join reads a list, a map or a function; a single string is not a lookup form. (optional)
distinctbooleanA COLLECT join: de-duplicate the collected values. Defaults to false. (optional)
sortboolean | ((a: unknown, b: unknown) => number)A COLLECT join: order the collected array - `true` for the default ascending order (numbers numerically, everything else by string), or a `compareFn`. Defaults to no sort. (optional)
missingRouterJoinMissing 'hold' | 'passthrough' | 'null'What happens while the match has not arrived: `hold` keeps the row from viewers until it does, `passthrough` sends it unenriched (a collect join sets `as` to `[]`), `null` fills the declared fields with null (a collect join sets `as` to null). Defaults to `passthrough`, which an unrecognised value also falls back to, with a warning. (optional)
onParentDeleteRouterParentDelete 'cascade' | 'orphan' | 'keep'A COLLECT join's parent-delete integrity (v18,: what deleting a parent (this source's row) does to the collected children. `'cascade'` deletes every matching child (recursively), `'orphan'` keeps them marked `__orphan: true` and warns once, and `'keep'` (the default) keeps them bare - the unchanged behaviour. A rollup/spread join is not a collect join and does not read this. (optional)
spreadRouterSpreadA SPREAD join (v16,: an EAV / custom-field source whose rows each become one field on the parent. `name` reads the attribute name off the child row and `value` the value (each a field name or a `fn(row)`), the pair written onto the parent as `prefix + name` - so `company` + `companyAttr` rows with `prefix: 'cf_'` gain `cf_industry`, `cf_tier` columns as attributes arrive. `include` narrows the names (an array, or a `fn(name, row)`), and `type` coerces per-name values (`number`, `date`, `boolean`, `text`). The attribute source's own joins apply first (multi-level), an attribute add/change/delete re-emits its parent live (a deleted field is absent, not stale), and the router reports the field names through `fieldsOf(ref)` and the `fields:changed` event. Set `spread` without `many: true`; it implies a one-to-many match and a `missing` policy has no meaning (a parent with no attribute simply carries no spread fields). Two attributes landing on one name are last-writer-wins and warned once (`router:spread-duplicate`); a spread field never overwrites a base parent field and warns once (`router:spread-collision`). (optional)
MethodSignatureParametersReturnsDescription
where(childRow: RouterRecord) => booleanchildRow: RouterRecord=> booleanFilters the children before any `aggregate` is computed: a predicate over each matching child row (its enriched form, so a child's own joins are visible). Only the rows it admits contribute to the result. (optional)

RouterSpread

A SPREAD join's shape (v16,: turn an EAV / custom-field source's rows into one field per attribute on the parent row.

PropertyTypeDescription
namestring | ((row: RouterRecord) => unknown)Reads the attribute name off the child row - a field name or a `fn(row)`. Required.
valuestring | ((row: RouterRecord) => unknown)Reads the attribute value off the child row - a field name or a `fn(row)`. Required.
prefixstringPrepended to every attribute name on the parent (e.g. `'cf_'`). Defaults to `''`. (optional)
includestring[] | ((name: string, row: RouterRecord) => boolean)Narrows which attribute names spread: an array of names, or a `fn(name, row)` returning true to keep. Omitted, every attribute spreads. (optional)
typeRecord<string, RouterSpreadType>Coerces each attribute's value by name: `number`, `date`, `boolean` or `text`. A value that fails to parse keeps its raw value rather than becoming `NaN`/`Invalid Date`; `null`/`undefined` pass through untouched. (optional)

RouterUnnest

One `unnest` spec of `addSource` (v17,: expand a nested array on each of a source's rows into its OWN row type, routed through the ordinary partition/route/join machinery. `path` is the dotted array field (`'profile.addresses'`); `as` is the child type (and its source id, so a lookup/collect join can `from` it); `key` is the child's identity - a field name, or `fn(child, parent, index)`, defaulting to `` `${parentKey}:${index}` `` (the index fallback, whose caveat is that reordering an array churns its children); `parentKey` is the field written onto each child holding the parent's key (default `${source}Id`); `keep` (default false) keeps the nested array on the parent's own routed row; and `join`/`unnest` are the child source's own joins and nested unnest. A parent update whose nested array changed emits a keyed diff of its children (add/change/remove by child key; a child moved between parents is a remove + add), a parent delete removes its children, and a `load()` snapshot diffs children too. Two children of one parent resolving to the same key warn once (`router:unnest-duplicate-key`), last in source order winning.

PropertyTypeDescription
pathstringThe dotted array field on the parent row to expand.
asstringThe child row type (and its source id, so joins can `from` it).
keystring | ((child: RouterRecord, parent: RouterRecord, index: number) => string | number)The child's identity: a field name read off the child, or `fn(child, parent, index)`. Defaults to `` `${parentKey}:${index}` `` - the index fallback, which churns its children when an array is reordered. (optional)
parentKeystringThe field written onto each child holding the parent's key (default `${source}Id`). (optional)
keepbooleanKeep the nested array on the parent's own routed row. Defaults to false. (optional)
onParentDeleteRouterParentDelete 'cascade' | 'orphan' | 'keep'What a parent's delete does to its unnested children (v18,. `'cascade'` (the default, and the unchanged behaviour) removes them, `'orphan'` keeps them marked `__orphan: true` and warns once, and `'keep'` keeps them bare. A re-added parent re-adopts its orphans (the mark clears). (optional)
joinRouterJoin | RouterJoin[]The child source's own joins (applied after unnest, before it routes). (optional)
unnestRouterUnnest | RouterUnnest[]A nested unnest: the child rows may declare their own nested arrays. (optional)

RouterSourceHandle

The handle `addSource` returns for one feed (v9). Its `load` is a per-source snapshot - a keyed diff over this feed's rows only, other feeds untouched; `apply` and `push` take this feed's deltas through the router's ordinary and batched paths; `remove` deletes exactly the rows it holds and unregisters it, returning the router.

PropertyTypeDescription
idstringThe source id. (read-only)
sizenumberHow many rows this source currently holds live. (read-only)
MethodSignatureParametersReturnsDescription
load(rows: RouterRecord[]): RouterSourceHandlerows: RouterRecord[]RouterSourceHandleApply a per-source snapshot: upsert its current rows, delete the ones it no longer has.
apply(deltas: RouterDelta[]): RouterSourceHandledeltas: RouterDelta[]RouterSourceHandleApply per-source deltas through the router's ordinary apply path.
push(delta: RouterDelta | RouterDelta[]): RouterSourceHandledelta: RouterDelta | RouterDelta[]RouterSourceHandleEnqueue per-source deltas through the router's stream path (batching honoured).
remove(): DataRouter - DataRouterRemove this source: delete exactly the rows it holds from every route, then unregister it.

RouterRouteMetrics

One route's figures in a `metrics()` snapshot (v10).

PropertyTypeDescription
labelstring | nullThe route's `label`, or `default` for the default route, or null when it has neither.
rowsnumberHow many rows the route's partition holds, before its filter, links and rollup.
shownnumberHow many rows the viewer currently holds - the partition after the route's filter, any cross-grid links, and a rollup's grouping.
throughputnumberRows routed to this route per second since the previous metrics read. The first read of a route reports 0, having no interval to measure.
[key: string]unknownAny further counter the route's own stage publishes. The four above are the ones every route reports; a stage may add to them and they arrive here.

RouterMetrics

A `metrics()` snapshot (v10): per-route and per-source counts and throughput (rows/sec since the previous read), and the global unrouted, dropped (duplicate), buffered and lag figures.

PropertyTypeDescription
routesRouterRouteMetrics[]One entry per route, in attach order, the default route last.
sourcesRouterSourceMetrics[]One entry per registered fan-in source.
unroutednumberHow many arriving records matched no route (and went to the default sink, if there is one). Counted since the last `load()` or `query()`, each of which resets it.
droppednumberHow many deltas the dedupe gate discarded as stale or already seen, cumulative for the router's life.
bufferednumberHow many deltas the time-travel ring currently holds. Zero when not buffering.
lagnumberHow many buffered deltas the viewers are behind the live head, in deltas. Nonzero only while scrubbed into the past.
throughputnumberRows routed across every route per second since the previous metrics read; 0 on the first read.

ExplainOptions

Narrow an `explain()` call to one source and/or one route. Omit both for the whole report.

PropertyTypeDescription
sourcestringA registered source id (v9, `addSource`'s id). (optional)
routeunknownA route's target (an `attach` grid, a `subscribe`/`alert`/`monitor` handler), its partition value, or its `label`. (optional)

ExplainNarrowed

`explain()` narrowed to one source and/or one route (whichever `ExplainOptions` named); the unnamed side is omitted.

PropertyTypeDescription
sourceExplainSourceReport | nullThe named source's report, or null when the id is unknown or nothing has settled yet. Present only when `{ source }` was passed. (optional)
routeExplainRouteReport | nullThe named route's report, or null when it cannot be resolved. Present only when `{ route }` was passed. (optional)

ExplainJoinReport

One `join`/`collect`/rollup-onto-parent/`relateRows` figure for a source's most recent settle (v18,. `unmatched.left` is this source's own rows whose key found nothing on the `from` side (the actual offending key values, up to 5); `unmatched.right` is `from`-side rows no left row currently points at - both are how `explain()` answers "why does my grid show 0 rows" when a join silently drops everything. `duplicates` (lookup joins only) is how many left rows matched MORE than one `from` row by key - the last one registered wins silently, so this is the signal that a lookup join's assumed 1:1 key is not.

PropertyTypeDescription
kind'lookup' | 'collect' | 'rollup' | 'relateRows''lookup' (one row), 'collect' (an array), 'rollup' (aggregated onto the parent), or 'relateRows' (a standalone delete rule).
fromstringThe `from`/`parent` source id this join or rule reads.
asstring | nullThe field a collect join wrote its array under, or null for every other kind.
matchednumberHow many of this source's rows found at least one matching `from` row.
unmatched{ left: ExplainSamples; right: ExplainSamples }Keys that found nothing, on each side of the join - the actual offending values, up to 5 samples each.
duplicatesExplainSamplesLookup joins only: left rows that matched more than one `from` row by key (the last one registered silently wins).

ExplainSourceReport

One source's full `explain()` report for its most recent settle (v18,.

PropertyTypeDescription
sourcestringThe source id (`addSource`'s id, or an `unnest` spec's `as`).
rows{ loaded: number; applied: number; removed: number }Rows this settle loaded (a `load()` snapshot), applied (an `apply()`/`push()` upsert) and removed.
fieldsRecord<string, ExplainFieldReport>One entry per declared `fields` coercion, keyed by field name.
unnestRecord<string, ExplainUnnestReport>One entry per `unnest` spec, keyed by its `as`.
joinsExplainJoinReport[]One entry per `join`/collect/rollup-onto-parent, plus any `relateRows` rule naming this source as the child.
spread{ touched: number; reemitted: number }A `spread` join's rows touched (enriched) and re-emitted (actually changed) this settle.
cascade{ touched: number; reemitted: number }Rows of THIS source's dependents (sources that join from it) touched and re-emitted by this settle's cascade.
timingMsnumberWall-clock ms this settle spent ingesting/enriching this source (includes any unnest expansion it triggered).

ExplainRouteReport

One route's (or `alert`/`monitor`'s) full `explain()` report for its most recent settle (v18,.

PropertyTypeDescription
routeunknownThe route's `label` when set, else its partition value, else an opaque stable id (never a live grid/handler reference - this is JSON-safe).
labelstring | nullThe route's `label`, or null.
kind'alert' | 'monitor'`alert`/`monitor` only: which kind this is. Absent for a grid/`subscribe` route. (optional)
when{ routed: number; unrouted: number }Rows this settle's `when` predicate routed here, and how many matched no route at all (router-wide, duplicated on every route's report).
filter{ in: number; out: number }Rows in (the route's partition) and out (after `filter` and any cross-grid link/graph predicate).
transform{ in: number; out: number } | nullRows transform ran over, in and out (a transform never drops a row). Null for an `alert`/`monitor` (it has none).
sort{ count: number } | nullHow many rows this settle's `sort` ordered (0 when the route has no `sort`). Null for an `alert`/`monitor`.
rollup{ in: number; out: number; groups: number } | nullNull for a non-rollup route (or an `alert`/`monitor`); otherwise rows in, summary rows out, and the group count.
write{ add: number; update: number; remove: number } | nullRows written to the grid this settle, by kind. Null for an `alert`/`monitor` (it renders nothing).
timingMsnumberWall-clock ms this settle spent in this route's `materialize()` (0 for an `alert`/`monitor`, which never materializes).

ExplainReport

The whole `explain()` report for the most recent settle (v18, - one `load`/`apply`/`push` and everything it cascaded into. Narrow with `{ source }`/`{ route }` for just one part; this is what comes back when neither is passed. Fully JSON-serialisable (every id is a string, label or number - never a live grid/handler reference) and safe to `JSON.stringify`/parse and compare.

PropertyTypeDescription
sourcesRecord<string, ExplainSourceReport>One entry per registered source, keyed by its id.
routesExplainRouteReport[]One entry per route (grid/`subscribe`, then `alert`/`monitor`), in attach order.
unroutednumberHow many records this settle matched no route (same figure as `metrics()`'s `unrouted` and each route report's `when.unrouted`).
timingMsnumberWall-clock ms the whole settle took, start to finish.

TraceResult

`router.trace(gridOrLabel, rowKey)`'s result for a row the store still holds (v19,.

PropertyTypeDescription
knowntrueAlways true for a row the store still holds - the `known: false` case is `TraceNotTraced`.
source{ id: string; key: unknown }[]The source row(s) the path began from: the source id and the row's identity as that source knows it.
stepsTraceStep[]Every step the row passed, in order.
route{ label: string | null } | nullThe route that delivered it (its `label`), or null if it never reached one.
write{ op: 'add' | 'update'; seq: number | undefined } | nullThe write it produced and the seq it carried, or null if it never reached one.

TraceMissingResult

`trace.missing()`'s result for a row the store still holds (v19,.

PropertyTypeDescription
knowntrueAlways true for a row the store still holds - the `known: false` case is `TraceNotTraced`.
presentbooleanWhether the row is, right now, in the grid asked about (or any grid, when none was named).
messagestringA human-readable explanation.
reasonTraceMissingReasonThe reason code, when `present` is false. (optional)
labelstring | null`predicate-false` only: the route whose predicate rejected it. (optional)
keyunknown`join-no-partner` only: the key looked up and the source it looked in. (optional)
sourcestring`join-no-partner`/`removed-by-cascade` only: the source (or parent key) it looked in/followed. (optional)
parentKeyunknown`removed-by-cascade` only: the parent row's key whose removal cascaded the delete to this row. (optional)
route{ label: string | null }Present when the row IS in the grid: which route. (optional)

RouterTraceApi

`router.trace` (v19,: a callable - `trace(gridOrLabel, rowKey)` - with `enable`/`disable`/`missing` attached, the same shape `on`'s unsubscribe or a timer's controller takes when a capability needs more than one entry point.

MethodSignatureParametersReturnsDescription
enable(): void - voidTurn tracing on.
disable(): void - voidTurn tracing off.
missing(sourceRef: unknown, sourceKey: unknown, gridOrLabel?: unknown): TraceMissingResult | TraceNotTracedsourceRef: unknown
sourceKey: unknown
gridOrLabel?: unknown
TraceMissingResult | TraceNotTracedWhy `sourceKey` on source `sourceRef` is not (or is) in a grid; narrow to one route's predicate with `gridOrLabel`.

RouterQueryAdapter

A pushdown adapter `query()` can source the router from (v7): anything with an `execute(query, request)` returning rows, and optional `capabilities` the planner consults to decide what it may push down.

PropertyTypeDescription
capabilitiesRecord<string, unknown>What the engine can evaluate, as the pushdown capability model reads it; the planner consults it to decide how much of a filter to push down. Omitted, the conservative defaults apply. (optional)
MethodSignatureParametersReturnsDescription
execute(query: Record<string, unknown>, request?: Record<string, unknown>) => Promise<{ rows: RouterRecord[]; total?: number }>query: Record<string, unknown>
request?: Record<string, unknown>
=> Promise<{ rows: RouterRecord[]; total?: number }>Runs one planned query and resolves to its rows (`total` optional and unused by the router). Called once per `where` route, plus once for the base query shared by the routes without one.

RouterPersistOptions

Durable persistence options (v12): `key` names the snapshot, `debounce` (ms) coalesces writes, `storage` is a `{ get, set }` pair of your own, or `indexedDB` / `dbName` / `storeName` select the browser store.

PropertyTypeDescription
keystringThe record the snapshot is written under. Defaults to `lattice-router`. (optional)
debouncenumberHow long to wait after a change before writing, in ms, so a burst costs one write. Defaults to 250; zero or less writes on every change. (optional)
storage{ get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }An async key/value backend of your own. Given one, IndexedDB is never opened. (optional)
indexedDBunknownThe `IDBFactory` to open the database with. Defaults to the global `indexedDB`; where none is reachable the router warns once and keeps running in memory with no durable resume. (optional)
dbNamestringThe IndexedDB database to open. Defaults to `lattice-router`. (optional)
storeNamestringThe object store inside the database. Defaults to `snapshots`. (optional)

RouterEventPayloads

What a handler receives, per router event.

PropertyTypeDescription
metricsRouterMetricsThe same snapshot `metrics()` returns, taken at the emit; the throughput baseline advances with it.
fields:changed{ route: unknown; added: string[]; removed: string[] }The spread field names a route gained and lost since the last emit, keyed by the route's partition value, label or target (v16). `added` are the names that first appeared, `removed` those that disappeared; a host turns `added` into grid columns and `removed` into dropped columns.

DataRouter

A data router: one arriving stream, partitioned by a property (or composite predicate), fanned out to a grid per partition. Each grid sees only its slice, updated by keyed diff through the public `grid.rows.apply` path - no grid-core change, no cross-references between grids. Snapshots apply keyed diffs (unchanged rows never repaint); deltas add, update or remove in place by `rowKey`, preserving selection and scroll.

Properties
PropertyTypeDescription
traceRouterTraceApiTrace one row's path through the router (v19,, and - via `trace.missing()` - why an input row is NOT in a grid. Off by default (`{ trace: true }` on `createDataRouter`, or `trace.enable()`; `trace.disable()` turns it off); while off, each step pays one boolean check. Recording is bounded (`traceLimit`, default 10,000 rows; oldest dropped first) - a row outside the store answers {@link TraceNotTraced} rather than a guess.
unroutednumberHow many records matched no route since the last `load` or `query`, running for deltas. (read-only)
droppednumberHow many stale or duplicate deltas the dedupe gate dropped since creation (v3). (read-only)
persistingbooleanWhether durable persistence is on and not degraded to in-memory (v12). (read-only)
travelingbooleanWhether the grids are currently showing a reconstructed past (v4). (read-only)
bufferednumberHow many deltas the bounded buffer currently holds (v4). (read-only)
broadcastingbooleanWhether the router is mirroring to a BroadcastChannel (v6). (read-only)
Methods
MethodSignatureParametersReturnsDescription
attach(grid: unknown, predicate: RoutePredicate, opts?: RouteOptions): DataRoutergrid: unknown
predicate: RoutePredicate
opts?: RouteOptions
DataRouterAttach a grid behind a predicate; `opts` may reshape, filter, sort, summarise or throttle the route.
attachDefault(grid: unknown, opts?: RouteOptions): DataRoutergrid: unknown
opts?: RouteOptions
DataRouterAttach the "rest" sink for records no explicit route matched. A second call replaces the first.
subscribe(predicate: RoutePredicate, handler: (change: RouterChange) => void, opts?: RouteOptions): () => voidpredicate: RoutePredicate
handler: (change: RouterChange) => void, opts?: RouteOptions
() => voidRoute a partition slice to any non-grid view (v5): the handler receives the same keyed diff a grid would. Returns an unsubscribe that removes the route and its handler.
get(route: RoutePredicate, rowKey: string | number): RouterRecord | undefinedroute: RoutePredicate
rowKey: string | number
RouterRecord | undefinedRead one record off a route by its row key - the current row or `undefined` when the route or key is unknown. `route` is the route's target, partition value, label or predicate.
alert(predicate: RoutePredicate, condition: (rows: RouterRecord[]) => unknown, handler: (signal: unknown, rows: RouterRecord[]) => void, opts?: AlertOptions): DataRouterpredicate: RoutePredicate
condition: (rows: RouterRecord[]) => unknown, handler: (signal: unknown
rows: RouterRecord[]) => void, opts?: AlertOptions
DataRouterWatch a slice and emit on a rising edge of `condition` rather than render (v5). Removed only by `destroy`.
monitor(predicate: RoutePredicate, evaluate: (rows: RouterRecord[]) => unknown, handler: (value: unknown, rows: RouterRecord[]) => void, opts?: { rowKey?: RouterKey; filter?: (row: RouterRecord) => boolean }): () => voidpredicate: RoutePredicate
evaluate: (rows: RouterRecord[]) => unknown, handler: (value: unknown
rows: RouterRecord[]) => void, opts?: { rowKey?: RouterKey; filter?: (row: RouterRecord) => boolean }
() => voidWatch a slice and report what it MEASURES on every evaluation (v14, - a levelled sibling of `alert`, sharing its partition, its `rowKey`/`filter`, its seeding and its independence from a time-travel scrub, and differing only in reporting a value rather than a rising edge. This is the feed `lattice-grid/modules/alarms` runs its level ladder, clear edge and hold timer on. Alone among the routes it returns a **stop function** rather than the router, because an alarm set has to be able to release it on `destroy()`.
configure(spec?: RouterConfig): DataRouterspec?: RouterConfigDataRouterTake the whole routing graph as one declarative spec (v5); desugars to the calls above and composes with them.
link(source: unknown, target: unknown, relation: SelectionRelation): DataRoutersource: unknown
target: unknown
relation: SelectionRelation
DataRouterLink a source grid's selection to what a target grid receives (v2,: the target shows the subset of its partition the `relation` admits, re-pushed through the keyed-diff path. No selection shows the full partition; changes are debounced.
relate(edges: RouterEdge[]): DataRouteredges: RouterEdge[]DataRouterDeclare a relationship graph (v3): multi-hop, several-into-one and mutual edges - the scalable form of `link`.
flush(): DataRouter - DataRouterApply any debounced selection refilter synchronously (for tests/determinism).
detach(grid: unknown): DataRoutergrid: unknownDataRouterDetach a grid - or a `subscribe` handler - and drop any link it is part of; the host still owns and destroys it.
load(snapshot: RouterRecord[]): RouteDiff[]snapshot: RouterRecord[]RouteDiff[]Apply a full snapshot as a keyed diff per grid; returns per-route counts. Resets `unrouted`.
apply(deltas: RouterDelta[]): voiddeltas: RouterDelta[]voidApply incremental deltas, routed and applied in place by `rowKey`; ordered and de-duplicated when `seq` is on.
push(delta: RouterDelta | RouterDelta[]): DataRouterdelta: RouterDelta | RouterDelta[]DataRouterEnqueue deltas for batched or coalesced application (v3); applies at once when no batching mode is on.
flushStream(): DataRouter - DataRouterApply the buffered deltas now as a single `apply` (v3) - a deterministic point, and for tests.
flushBackpressure(): DataRouter - DataRouterRefresh every backpressured route to the latest state now (v13); a no-op with nothing pending.
addSource(feed: string | RouterSourceOptions, opts?: RouterSourceOptions): RouterSourceHandlefeed: string | RouterSourceOptions
opts?: RouterSourceOptions
RouterSourceHandleRegister a source feed for fan-in (v9): its rows are normalised and namespaced into the one keyed store.
removeSource(ref: string | RouterSourceHandle): DataRouterref: string | RouterSourceHandleDataRouterRemove a source feed by id or handle (v9): delete exactly its rows from every route, then unregister it.
sources(): string[] - string[]The registered source ids (v9).
relateRows(o: { child: string | RouterSourceHandle; parent: string | RouterSourceHandle; foreignKey: RouterKey; onParentDelete?: RouterParentDelete; writeBack?: boolean }): DataRoutero: { child: string | RouterSourceHandle; parent: string | RouterSourceHandle; foreignKey: RouterKey; onParentDelete?: RouterParentDelete; writeBack?: boolean }DataRouterDeclare a parent→child delete rule without a join (v18,: deleting a `parent` row cascades (`onParentDelete: 'cascade'`) or orphans (`'orphan'`) the `child` rows whose `foreignKey` matches, or does nothing (`'keep'`, the default). `writeBack` (default false) routes each cascaded delete through the router's `onWrite` so the host persists it.
fieldsOf(ref: unknown): string[]ref: unknownstring[]The spread field names a route currently carries (v16,: with a `spread` join, each EAV attribute row becomes its own field on the parent, and this reports the names a host can turn into grid columns. Pass the route's target (an `attach` grid or a `subscribe` handler), its partition value, or its `label`. Returns the names in first-seen order - an empty array for an unknown route, a route with no spread fields, or a router with no spread join. Pair it with the `fields:changed` event to add or drop columns as attributes appear and disappear.
metrics(): RouterMetrics - RouterMetricsA cheap point-in-time snapshot of the router's runtime (v10); throughput is measured since the previous read.
explain(opts?: ExplainOptions): ExplainReport | ExplainNarrowedopts?: ExplainOptionsExplainReport | ExplainNarrowedExplain the most recent settle (v18,: what the last `load`/`apply`/`push` (and every cascade it triggered) did, per source and per route - rows in/out at each pipeline step, which join/unnest/ spread/cascade keys matched or didn't (up to 5 sample offending values), the grid writes produced, and the wall-clock ms each step took. Where `metrics()` answers "how much, how fast", this answers "what happened, and where did my rows go". Always current (the counters it reads run whether or not this is ever called); pass `{ source }` and/or `{ route }` to narrow to just that part.
on(event: RouterEventName, handler: (snapshot: RouterEventPayloads[RouterEventName]) => void): () => voidevent: RouterEventName
handler: (snapshot: RouterEventPayloads[RouterEventName]) => void
() => voidSubscribe to a router event (v10): the periodic `metrics` emit - the timer runs only while a listener is registered - or a `fields:changed` emit when a spread join's field set for a route changes (v16). What each carries is {@link RouterEventPayloads}. Returns the unsubscribe.
mountDevtools(el: unknown): RouterDevtoolsPanelel: unknownRouterDevtoolsPanelMount the live devtools panel into `el` (v10); it re-renders on each `metrics` emit.
lastSeq(): number | undefined - number | undefinedThe highest seq applied - the resume point to request the feed from after a dropped socket (v3).
checkpoint(): Map<string, number> - Map<string, number>A copy of the per-record resume checkpoint: record identity → last applied seq (v3).
seenThrough(mark: Map<string, number> | Record<string, number>): DataRoutermark: Map<string, number> | Record<string, number>DataRouterPrime the resume checkpoint from a persisted one, so replayed deltas at or below those seqs are dropped (v3).
persist(opts?: RouterPersistOptions): DataRouteropts?: RouterPersistOptionsDataRouterTurn on durable persistence of the router's state (v12).
restore(): Promise<boolean> - Promise<boolean>Resume from the durable snapshot (v12); resolves true when one was found and applied.
flushPersist(): Promise<DataRouter> - Promise<DataRouter>Flush any pending durable write now (v12); resolves once it has settled.
buffer(opts?: { window?: number; max?: number }): DataRouteropts?: { window?: number; max?: number }DataRouterRecord the stream into a bounded ring for time travel (v4): a time `window` in ms and/or a `max` delta count.
scrubTo(target: number, opts?: { by?: 'seq' | 'time' }): DataRoutertarget: number
opts?: { by?: 'seq' | 'time' }
DataRouterScrub the attached grids to a past seq or timestamp (v4).
replay(from: number, to: number, opts?: { speed?: number; by?: 'seq' | 'time' }): Promise<void>from: number
to: number
opts?: { speed?: number; by?: 'seq' | 'time' }
Promise<void>Replay a buffered range step by step (v4); resolves when it completes or is superseded.
pause(): DataRouter - DataRouterPause an in-flight replay at the current step (v4); a no-op when nothing is replaying.
resume(): DataRouter - DataRouterResume a paused replay from where it stopped (v4); a no-op when not paused.
live(): DataRouter - DataRouterReturn to live (v4): rebuild the head from the base plus every buffered delta.
broadcast(opts: { channel: string }): DataRouteropts: { channel: string }DataRouterMirror the ordered, de-duplicated deltas to other tabs over a BroadcastChannel (v6), with no echo loop.
query(adapter: RouterQueryAdapter, request?: Record<string, unknown>): Promise<DataRouter>adapter: RouterQueryAdapter
request?: Record<string, unknown>
Promise<DataRouter>Source the router from a pushdown adapter (v7): each `where` route is planned against the adapter's capabilities.
lastQueryPlan(): RouterQueryPlanEntry[] | null - RouterQueryPlanEntry[] | nullThe pushed/residual split of the last `query()` (v7), per fetch, or null before any.
destroy(): void - voidDetach every grid and drop every link (the host destroys the grids themselves).
Events
EventWhenPayloadCancellable
metricsThe metrics timer fired: a `metrics()` snapshot, every `metricsInterval` ms (default 1000; `0` disables the timer).RouterMetricsno
fields:changedA route's spread field set changed (v16): `{ route, added, removed }`, the names that appeared and disappeared.{ route: unknown; added: string[]; removed: string[] } -

DataRouterOptions

Options for `createDataRouter`. `key` is the partition property or `fn(row)`; optional, since a router whose routes all use `fn(row)` predicates never reads it. `rowKey` is the identity within a grid; `overlap` fans a record to every matching route (default: first match wins); `onUnrouted` receives what matched no route - the row on `load` and `query`, the whole delta on `apply`; `selectionDebounce` is the ms debounce for cross-grid selection refilters (default 16; `0` is synchronous). `seq` names the per-record version that orders and de-duplicates a feed (v3), `dedupe` (default on with `seq`) drops stale and duplicate deltas; `batch` (ms, or `{ intervalMs }`) and `coalesce` buffer a high-frequency feed for `push`; `time` reads a row's timestamp for time-domain scrubbing and `now` overrides the clock (v4); `config` is a declarative routing graph applied at construction (v5); `onWrite` and `onConflict` are the defaults for every writable route (v8); `metricsInterval` is the ms between `metrics` emits (default 1000; `0` disables the timer) (v10).

PropertyTypeDescription
keyRouterKeyHow a record says which partition it belongs to: a property name, or a function of the record. This is the one thing the router needs to route. (optional)
rowKeyRouterKeyA record's identity within a route - a property name or a function. It is what makes an update an update rather than a second row, and what lets a record whose partition changed *move* between routes instead of being duplicated. Defaults to the record's `rowKey` property. (optional)
overlapbooleanSend a record to every route whose predicate it matches, rather than to the first one only. Off by default. (optional)
selectionDebouncenumberHow long, in milliseconds, to wait before re-filtering linked grids after a selection changes. 16 by default; `0` re-filters synchronously. (optional)
seqRouterKeyWhere a record carries its version or sequence number - a property name or a function. Supplying it lets the router apply a feed in order and, unless `dedupe` says otherwise, drop a delta it has already seen. (optional)
dedupebooleanWhether to drop a record whose sequence number is not newer than the last one applied for that identity. On whenever `seq` is given; meaningless without it. (optional)
batchnumber | { intervalMs: number }Buffer incoming records and apply them on an interval rather than one at a time: a number of milliseconds, or `{ intervalMs }`. Batching also turns coalescing on. (optional)
coalescebooleanSettle repeated updates to the same identity inside one batch into a single apply, so a fast feed costs one update per row rather than one per message. (optional)
timeRouterKeyWhere a record carries its timestamp - a property name or a function. It is the axis `scrubTo` and `replay` move along when time-travel is buffering. (optional)
configRouterConfigA whole router described as data - routes, links, buffering - applied through `configure()` as soon as the router is built. (optional)
metricsIntervalnumberHow often, in milliseconds, to emit the `metrics` event. 1000 by default; `0` stops the timer and leaves `metrics()` to be read on demand. (optional)
tracebooleanTurn trace recording on from the start (v19,, equivalent to calling `trace.enable()` immediately. Off by default. (optional)
traceLimitnumberThe trace store's bound, oldest row dropped first (v19). 10,000 by default. (optional)
MethodSignatureParametersReturnsDescription
onUnrouted(item: RouterRecord | RouterDelta) => voiditem: RouterRecord | RouterDelta=> voidCalled with each record that matched no route. They are never silently dropped: they are counted as well, and go to the default route when one is attached. (optional)
now() => number - => numberThe clock the router stamps and expires by. `Date.now` unless you supply one, which is how a test drives time without faking the global. (optional)
onWrite(change: RouterWrite, ctx: { route: unknown; source: unknown }) => unknownchange: RouterWrite
ctx: { route: unknown; source: unknown }
=> unknownPersist an edit committed in any route attached `{ writable: true }` that names no handler of its own. Return, or resolve, falsely to revert the edit; a route with neither this nor its own handler leaves the edit in place unpersisted and warns. The context's `source` is the `addSource` handle of the feed the edited row arrived on, or null. (optional)
onConflict(change: RouterWrite, ctx: { serverRow: RouterRecord }) => voidchange: RouterWrite
ctx: { serverRow: RouterRecord }
=> voidCalled when a write comes back reporting a conflict, with the server's version of the row. The router itself is last-write-wins; this is where a host resolves it differently. (optional)