Lattice Grid Buy a licence

api reference

The data router

createDataRouter, splitting one feed across many grids and charts, keyed-diff snapshots and in-place deltas, join and rollup routes, backpressure, and the mock socket for a real-time screen with no backend.

API reference › The data router

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
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).
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? })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.
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.
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(30);
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(' | ');

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(' | ');

The mock socket

modules/mock-socket is a serverless stand-in for a live WebSocket feed, for building and demonstrating a real-time UI with no backend. MockWebSocket presents the same surface as the browser's WebSocket - the same readyState and state constants, the same onopen, onmessage, onclose and onerror, addEventListener, send and close - so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.

import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';

const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  if (message.kind === 'snapshot') router.load(message.rows);
  else router.apply(message.changes);
};

// Going live is the one line that changes; everything above stays as written:
const socket = new WebSocket('wss://example.com/ops');

The swap is literally one line. Both sockets frame their messages the same way, so the reader parses event.data and switches on kind either way. The feed is seedable and deterministic: the shipped generators carry their own seed and the timing jitter is seeded too, so the same inputs replay the same stream - which is what lets a tutorial and its runnable example show the same thing every time, and what lets a test assert on an exact stream rather than a plausible one. Bring your own generator: a feed is any iterator that yields { kind: 'snapshot', rows } first and then { kind: 'delta', changes } forever - a plain generator function is the easiest form - and rng(seed) is exported so a custom feed can be seeded the same way the shipped ones are.

MemberDescription
new MockWebSocket({ feed, rate?, jitter?, seed?, snapshotDelay?, pauseWhenHidden?, url? })Open a mock socket driven by feed (a generator: snapshot first, then deltas). rate is the ms between deltas (default 1000); jitter a random plus-or-minus ms per gap (default 0); seed seeds that jitter (default 1); snapshotDelay the ms before it opens (default 60); pauseWhenHidden stops the feed while the tab is in the background (default true); url a cosmetic address so socket.url reads like the real thing.
onopen / onmessage / onclose / onerrorThe WebSocket handlers. onmessage receives an event whose data is the JSON-framed FeedMessage; a feed that ends closes the socket cleanly; a feed that throws surfaces as an error event, not an uncaught throw.
addEventListener / removeEventListenerThe EventTarget surface, alongside the on* handlers - both receive every event.
send(data?)Accepted and ignored: there is nothing upstream, so a page that calls send runs unchanged.
close()Close the socket and stop the feed, emitting a clean close.
pause() / resume()Hold the feed and continue it while the socket stays open - a demo and test affordance beyond the WebSocket surface.
opsFeed({ seed?, orders?, shipments?, incidents?, batch? })A mixed operations feed - orders, shipments and incidents across three regions plus a throughput rollup - the kind the data router partitions across several grids and a chart from one source. Yields a snapshot, then deltas forever.
priceFeed({ seed?, symbols?, move?, batch?, spread? })A market-data feed: instruments whose prices random-walk each tick, each record carrying type: 'price', symbol, last, chg and a bid/ask straddling the last. Yields a snapshot, then deltas forever.
rng(seed)A small seeded pseudo-random generator (mulberry32), so a custom feed can be seeded the same way the shipped ones are: the same seed yields the same sequence.

Every record on the shipped feeds carries a type, the property the data router partitions on, and an id (or symbol), its row key - so a mock feed drops straight into a routed screen. The module is plain JavaScript and timers: no dependencies, no eval, safe to paste into a page or a sandbox.