@toclocoinc/lattice-grid 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/API.html CHANGED
@@ -754,7 +754,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
754
754
  <tr><td class="name">rows</td><td class="type">unknown[]</td><td class="dflt">, </td><td class="desc">Row objects. Held by reference; not copied.</td></tr>
755
755
  <tr><td class="name">rowKey</td><td class="type">string | (row) =&gt; string</td><td class="dflt">, </td><td class="desc">Stable row identity. Without it the grid assigns a key per row object and warns: enough for sorting, filtering, selection and copying within a session, but change tracking, streaming dedupe, selection persistence and remote reload all switch off, because new objects are new rows.</td></tr>
756
756
  <tr><td class="name">source</td><td class="type">SourceConfig</td><td class="dflt">memory</td><td class="desc">Where rows come from: <code>memory</code>, <code>paged</code>, <code>remote</code> or <code>stream</code>. See <a href="#sources">Sources</a>.</td></tr>
757
- <tr><td class="name">ingest</td><td class="type">IngestConfig</td><td class="dflt">, </td><td class="desc"><code>{ retainSource }</code>. How rows enter the column store. <code>retainSource</code> defaults to <code>true</code>: the caller's row objects are held by reference so <code>rows.data()</code> returns them unchanged and <code>row === sourceObject</code> holds. Set it <code>false</code> to keep only the packed columns and reconstruct a row on demand — a smaller footprint, but <code>rows.data()</code> then returns freshly reconstructed objects, so identity checks and <code>row.sourceObject</code> no longer hold and equality becomes value-based. Cell values are unchanged.</td></tr>
757
+ <tr><td class="name">ingest</td><td class="type">IngestConfig</td><td class="dflt">, </td><td class="desc"><code>{ retainSource, dropSourceRows }</code>. How rows enter the column store. <code>retainSource</code> defaults to <code>true</code>: the caller's row objects are held by reference so <code>rows.data()</code> returns them unchanged and <code>row === sourceObject</code> holds. Set it <code>false</code> to stop the store retaining them and reconstruct a row on demand — but the source layer and grid config still hold the array, so the resident footprint does not actually fall. <code>dropSourceRows: true</code> closes that gap: it releases the objects from the source layer too, so the packed columns become the only copy and the footprint drops by roughly an order of magnitude at scale. Either way <code>rows.data()</code> then returns freshly reconstructed objects, so identity checks and <code>row.sourceObject</code> no longer hold and equality becomes value-based. Cell values are unchanged.</td></tr>
758
758
  <tr><td class="name">tree</td><td class="type">TreeConfig</td><td class="dflt">, </td><td class="desc"><code>{ path }</code> or <code>{ parentKey }</code>, plus <code>label</code>, <code>orphans</code>. Rows form a hierarchy. See <a href="api-detail.html#tree-data">Tree data</a>.</td></tr>
759
759
  <tr><td class="name">detail</td><td class="type">DetailConfig</td><td class="dflt">, </td><td class="desc"><code>{ rows, config, render, isMaster, height, cacheLimit, target }</code>. A master row expands into a nested grid, inline or into an element you supply. See <a href="api-detail.html#master-detail">Master-detail</a>.</td></tr>
760
760
  <tr><td class="name">context</td><td class="type">unknown</td><td class="dflt">, </td><td class="desc">Arbitrary value passed to every callback, so formatters and renderers need no closures over app state.</td></tr>
@@ -865,9 +865,10 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
865
865
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column }</td><td class="dflt">, </td><td class="desc">Let a user reorder rows by dragging a handle or with <kbd>Alt</kbd>+<kbd>Shift</kbd>+arrows. The handle goes in the first visible column unless <code>column</code> names another. Refused, with a reason announced, while a sort, filter or grouping is active. See <a href="api-detail.html#row-reorder">Row reorder</a>.</td></tr>
866
866
  <tr><td class="name">rowTransfer</td><td class="type">boolean | { send, receive, mode, group }</td><td class="dflt">, </td><td class="desc">Let rows be dragged between grids. Off by default. <code>send</code> and <code>receive</code> are both on when present, so one-way is <code>{ receive: false }</code> or <code>{ send: false }</code>. <code>mode: 'copy'</code> leaves the row behind; <code>group</code> restricts which grids may exchange. See <a href="api-detail.html#row-transfer">Moving rows between grids</a>.</td></tr>
867
867
  <tr><td class="name">alignedGrids</td><td class="type">Grid[]</td><td class="dflt">, </td><td class="desc">Other grids to stay column-aligned with. Widths, order, visibility, pinning and horizontal scroll are shared; sort, filters, selection and rows stay independent. Declare it on the grid created last. See <a href="api-detail.html#aligned-grids">Aligned grids</a>.</td></tr>
868
- <tr><td class="name">stickyGroupHeaders</td><td class="type">boolean | number | { depth }</td><td class="dflt">true</td><td class="desc">Keep the enclosing group headings pinned above the viewport while scrolling inside a group. Stacks at most two by default; each costs a row of viewport. See <a href="api-detail.html#sticky-group-headings">Sticky group headings</a>.</td></tr>
868
+ <tr><td class="name">stickyGroupHeaders</td><td class="type">boolean | number | { depth }</td><td class="dflt">false</td><td class="desc">Keep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default; <code>true</code> turns it on and stacks at most two, a number sets the cap, and each costs a row of viewport. See <a href="api-detail.html#sticky-group-headings">Sticky group headings</a>.</td></tr>
869
869
  <tr><td class="name">gridLines</td><td class="type">boolean | 'both' | 'horizontal' | 'vertical' | 'none'</td><td class="dflt">'horizontal'</td><td class="desc">Which rules are drawn between cells. Horizontal is what the grid has always drawn; vertical rules are additive. <code>'rows'</code> and <code>'columns'</code> are accepted aliases. Only the rules between data are affected, the header underline and pinned seams are structure.</td></tr>
870
870
  <tr><td class="name">cornerRadius</td><td class="type">boolean | number | string</td><td class="dflt">, </td><td class="desc">Round the grid's outer corners. <code>true</code> adopts the theme's radius, a number is pixels, a string is used as written.</td></tr>
871
+ <tr><td class="name">stripedRows</td><td class="type">boolean</td><td class="dflt">false</td><td class="desc">Shade alternate data rows (zebra striping). Strictly opt-in, so an existing grid is unchanged on upgrade. Parity follows each row's logical index, so a stripe survives a scroll; group headings, footers and the grand total are never striped; selection and hover still win. Uses the theme's <code>--lattice-surface-alt</code>, so dark, high-contrast and terminal come for free.</td></tr>
871
872
  <tr><td class="name">columnTagFilter</td><td class="type">boolean | { multiple, label }</td><td class="dflt">, </td><td class="desc">A bar above the headings for showing only the columns carrying a chosen tag. Draws nothing unless some column has <code>tags</code>. See <a href="api-detail.html#column-tags">Column tags</a>.</td></tr>
872
873
  <tr><td class="name">rowTemplate</td><td class="type">string | { template, cardsPerRow, maxCardWidth, gap, className, role, itemRole }</td><td class="dflt">, </td><td class="desc">Draw each row with a template instead of dividing it into columns, a card list, a feed, a search-result list. Compiles once; binds with <code>{{data.field}}</code>. <code>cardsPerRow</code> or <code>maxCardWidth</code> puts several on a line. The pipeline underneath is unchanged. See <a href="api-detail.html#cards">Cards, lists and feeds</a>.</td></tr>
873
874
  <tr><td class="name">responsive</td><td class="type">{ maxWidth, template, rowHeight }</td><td class="dflt">, </td><td class="desc">Collapse to cards when the <em>container</em> is at or below <code>maxWidth</code> (640 by default), and return to a table above it. Sorting, filtering and export keep working. Emits <code>presentation:changed</code>. See <a href="api-detail.html#cards">Cards, lists and feeds</a>.</td></tr>
@@ -912,7 +913,9 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
912
913
  <tr><td class="name">filter</td><td class="type">ColumnFilterSpec | boolean | FilterName</td><td class="desc"></td></tr>
913
914
  <tr><td class="name">group</td><td class="type">object | boolean</td><td class="desc"><code>{ enabled, index, explode }</code>.</td></tr>
914
915
  <tr><td class="name">pivot</td><td class="type">object | boolean</td><td class="desc"><code>{ enabled, index }</code>.</td></tr>
915
- <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">One property drives the group row, the tree node, the pivot cell and the grand total.</td></tr>
916
+ <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">One property drives the group row, the tree node, the pivot cell and the grand total. Split it per scope with <code>groupTotal</code> / <code>grandTotal</code> when the subtotals and the grand total should reduce differently.</td></tr>
917
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides <code>total</code> for those scopes only; omitted, <code>total</code> applies.</td></tr>
918
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for the pinned grand-total row, where it should differ from the subtotals. Overrides <code>total</code> for the grand total only; omitted, <code>total</code> applies.</td></tr>
916
919
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec | number</td><td class="desc">A bare number is the width.</td></tr>
917
920
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec | string</td><td class="desc"></td></tr>
918
921
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc"><code>{ lookup: 'label' | 'value' | 'columns', csv, excel }</code>.</td></tr>
@@ -1072,7 +1075,7 @@ grid.overlay.hide();</code></pre>
1072
1075
  <tr><td class="sig">group(ids)</td><td class="type">void</td><td class="desc">Set the row-group columns, in order.</td></tr>
1073
1076
  <tr><td class="sig">pivot(ids)</td><td class="type">void</td><td class="desc"></td></tr>
1074
1077
  <tr><td class="sig">totals(ids)</td><td class="type">void</td><td class="desc">Which columns carry an aggregation.</td></tr>
1075
- <tr><td class="sig">setTotal(id, fn)</td><td class="type">void</td><td class="desc">Change one column's aggregation. <code>null</code> stops totalling it. A named total the column's type says is meaningless is refused (§9.4), the same way it is at configuration.</td></tr>
1078
+ <tr><td class="sig">setTotal(id, fn, opts?)</td><td class="type">void</td><td class="desc">Change one column's aggregation. <code>null</code> stops totalling it. A named total the column's type says is meaningless is refused (§9.4), the same way it is at configuration. With no <code>opts</code>, <code>fn</code> becomes the shared <code>total</code> and clears any group/grand overrides; pass <code>{ scope: 'group' }</code> or <code>{ scope: 'grand' }</code> to set the group subtotals and the grand total independently (a scope with no override follows <code>total</code>).</td></tr>
1076
1079
  <tr><td class="sig">aggregates(id)</td><td class="type">TotalName[]</td><td class="desc">The aggregate names meaningful for a column, honouring its type's declaration — what the aggregate chooser offers.</td></tr>
1077
1080
  <tr><td class="sig">distinct(id)</td><td class="type">unknown[]</td><td class="desc">Distinct values, read from the dictionary rather than by scanning rows.</td></tr>
1078
1081
  <tr><td class="sig">state()</td><td class="type">ColumnState[]</td><td class="desc">Serialisable column state.</td></tr>
@@ -2801,6 +2804,209 @@ app.get('/api/orders', async (req, res) =&gt; {
2801
2804
  <code>createPushdownSource</code> switches to fetching everything and paging from what it holds.
2802
2805
  </p>
2803
2806
 
2807
+ <h4 id="pushdown-fulldataset">Whole-dataset statistics: <code>fullDataset</code></h4>
2808
+ <p class="section-note">
2809
+ A windowed source computes a total, statistic or group over the <em>loaded window</em> — the
2810
+ rows on screen — not the whole matching set, unless residual work already forced a whole-result
2811
+ fetch. &ldquo;Median revenue&rdquo; in the footer becomes the median of ~200 rows, wrong and
2812
+ looking right. <code>fullDataset.enabled</code> makes the whole-result fetch <strong>sticky and
2813
+ explicit</strong>: the entire matching set is held client-side once per query and every window,
2814
+ total and statistic is served from it, so the figures are computed over everything. It reuses
2815
+ the same whole-result path residual work already takes — <code>needsAll</code> — rather than a
2816
+ parallel mechanism. It is off by default and strictly opt-in. For a <code>restAdapter</code>,
2817
+ which cannot compute, it is the only way to get a correct whole-dataset statistic at all.
2818
+ </p>
2819
+ <p class="section-note">
2820
+ <strong>Memory-guarded, refused loudly.</strong> A matching set past <code>maxRows</code> or
2821
+ <code>maxBytesEstimate</code> is <strong>refused</strong> — thrown, surfaced as a
2822
+ <code>source:error</code> with no rows shown — never silently truncated. Presenting a fraction
2823
+ as the whole is the exact failure whole-dataset pull exists to prevent, so it is never the
2824
+ failure mode of the fix itself. Narrow the filter or raise the limit.
2825
+ </p>
2826
+ <div class="table-wrap">
2827
+ <table>
2828
+ <thead><tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
2829
+ <tbody>
2830
+ <tr><td class="name">enabled</td><td class="type">boolean</td><td class="desc"><code>false</code></td><td class="desc">Hold the whole matching set client-side and serve every window, total and statistic from it.</td></tr>
2831
+ <tr><td class="name">maxRows</td><td class="type">number</td><td class="desc"><code>1_000_000</code></td><td class="desc">Refuse (visible <code>source:error</code>) when the matching set is larger.</td></tr>
2832
+ <tr><td class="name">maxBytesEstimate</td><td class="type">number</td><td class="desc"><code>512&nbsp;MB</code></td><td class="desc">Refuse past this estimated heap cost, sampled from a representative row.</td></tr>
2833
+ </tbody>
2834
+ </table>
2835
+ </div>
2836
+ <pre data-run="js" data-expect="49.5" data-covers="config:fullDataset config:enabled config:maxBytesEstimate"><code><span class="cmt">// An adapter that can page but reports the whole matching count. Without</span>
2837
+ <span class="cmt">// fullDataset a stat would see only the window; with it, the whole set.</span>
2838
+ <span class="kw">const</span> { createPushdownSource } = <span class="kw">await</span> import('../packages/core/src/source/pushdown.js');
2839
+ <span class="kw">const</span> all = Array.from({ length: 100 }, (_, i) =&gt; ({ id: i, amount: i }));
2840
+ <span class="kw">const</span> adapter = {
2841
+ name: 'demo',
2842
+ capabilities: { range: true, total: true },
2843
+ execute: <span class="kw">async</span> (query) =&gt; {
2844
+ <span class="kw">const</span> start = query.range ? query.range.start : 0;
2845
+ <span class="kw">const</span> end = query.range ? query.range.end : all.length;
2846
+ <span class="kw">return</span> { rows: all.slice(start, end), total: all.length };
2847
+ },
2848
+ };
2849
+ <span class="kw">const</span> source = createPushdownSource({ adapter, fullDataset: { enabled: true, maxRows: 1000, maxBytesEstimate: 5_000_000 } });
2850
+ <span class="cmt">// Ask for a 10-row window; fullDataset holds all 100, so the mean is the true one.</span>
2851
+ <span class="kw">const</span> block = <span class="kw">await</span> source.fetch({ range: { start: 0, end: 10 }, filters: null, sort: [], quick: '' });
2852
+ <span class="kw">const</span> held = block.total; <span class="cmt">// 100: the whole set is held, not the 10-row window</span>
2853
+ <span class="kw">return</span> all.reduce((s, r) =&gt; s + r.amount, 0) / held; <span class="cmt">// 49.5, the true whole-dataset mean</span></code></pre>
2854
+
2855
+ <h4 id="pushdown-allowpartialresults">Refusing a partial result: <code>allowPartialResults</code></h4>
2856
+ <p class="section-note">
2857
+ When a query has residual work — a filter, sort or quick search the engine could not push — the
2858
+ source asks the adapter for the <em>whole</em> matching set, applies the residual here, and pages
2859
+ from what it holds. If the adapter instead returns a <strong>page</strong> of that result (it
2860
+ paged when told not to), the client-side filter or sort runs over the wrong rows: the rows that
2861
+ belong on page one may be in the fraction that was never fetched, so a page is presented as the
2862
+ full filtered set. That is a wrong answer, not a slow one.
2863
+ </p>
2864
+ <p class="section-note">
2865
+ By default the source <strong>refuses</strong> such a shortfall — it throws, and the remote
2866
+ source surfaces a <code>source:error</code> with no rows shown, rather than filter a fraction
2867
+ and lie. The fix is to make the adapter follow the engine's own paging before returning, or hold
2868
+ the data in memory. <code>allowPartialResults: true</code> is the knowing escape hatch: a caller
2869
+ who accepts the permissive behaviour — an adapter that genuinely cannot page and a result small
2870
+ enough not to matter, or a diagnostic run — keeps the old warn-once-and-proceed path. It is
2871
+ off by default, because a silent wrong answer is the one thing the design refuses. It does not
2872
+ affect the <code>fullDataset</code> memory guard, nor the no-residual short-return warning.
2873
+ </p>
2874
+ <div class="table-wrap">
2875
+ <table>
2876
+ <thead><tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
2877
+ <tbody>
2878
+ <tr><td class="name">allowPartialResults</td><td class="type">boolean</td><td class="desc"><code>false</code></td><td class="desc">Accept a partial/paged result to a whole-set request that residual work will filter over, keeping the warn-once-and-proceed behaviour instead of refusing. Off by default: the shortfall is thrown.</td></tr>
2879
+ </tbody>
2880
+ </table>
2881
+ </div>
2882
+ <pre data-run="js" data-expect="refused; opted in: 1 rows" data-covers="config:allowPartialResults"><code><span class="cmt">// An eq-only engine: the `gt` filter is residual and runs in the browser. The</span>
2883
+ <span class="cmt">// adapter reports 40 matching but returns only 1 row — a page shown as the whole.</span>
2884
+ <span class="kw">const</span> { createPushdownSource } = <span class="kw">await</span> import('../packages/core/src/source/pushdown.js');
2885
+ <span class="kw">const</span> adapter = {
2886
+ name: 'shortfall',
2887
+ capabilities: { filter: 'tree', operators: ['eq'] },
2888
+ execute: <span class="kw">async</span> () =&gt; ({ rows: [{ id: 1, a: 5 }], total: 40 }),
2889
+ };
2890
+ <span class="kw">const</span> req = { range: { start: 0, end: 10 }, filters: { col: 'a', op: 'gt', value: 1 }, sort: [], quick: '' };
2891
+
2892
+ <span class="cmt">// Default: refused. Filtering 1 of 40 rows would return the wrong rows.</span>
2893
+ <span class="kw">const</span> strict = createPushdownSource({ adapter });
2894
+ <span class="kw">let</span> refused = false;
2895
+ <span class="kw">try</span> { <span class="kw">await</span> strict.fetch(req); } <span class="kw">catch</span> (e) { refused = /returned 1 of 40|refused/.test(e.message); }
2896
+
2897
+ <span class="cmt">// Knowing opt-in: warns once and proceeds with the fraction.</span>
2898
+ <span class="kw">const</span> lax = createPushdownSource({ adapter, allowPartialResults: true });
2899
+ <span class="kw">const</span> block = <span class="kw">await</span> lax.fetch(req);
2900
+ <span class="kw">return</span> refused ? `refused; opted in: ${block.rows.length} rows` : 'not refused';</code></pre>
2901
+
2902
+ <h4 id="pushdown-aggregates">Pushing statistics down: <code>aggregates</code></h4>
2903
+ <p class="section-note">
2904
+ A DuckDB-class engine can compute a median or a standard deviation over the whole matching set
2905
+ far faster than pulling every row to do it here. The <code>aggregates</code> config decides, at
2906
+ grid setup, which statistics are computed by the engine and which by the grid. It is a
2907
+ <strong>design-time developer choice</strong> — fixed for the life of the grid, never a runtime
2908
+ toggle, never shown to an end user. Absent, every aggregate is computed client-side, so no
2909
+ existing grid changes behaviour.
2910
+ </p>
2911
+ <p class="section-note">
2912
+ Each statistic is classified <strong>IDENTICAL</strong> (the engine's result equals the grid's
2913
+ own kernel, verified against it) or <strong>MAY-DIFFER</strong> (the engine computes it by a
2914
+ method that can differ from the grid's definition). The classification drives this
2915
+ documentation and build-time provenance, <em>not</em> whether a stat is pushed — that is your
2916
+ choice. Only <code>weightedQuantile</code> is a genuine <strong>fallback</strong>: the engine
2917
+ cannot express the grid's midpoint convention, so it is always computed client-side.
2918
+ </p>
2919
+ <div class="table-wrap">
2920
+ <table>
2921
+ <thead><tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
2922
+ <tbody>
2923
+ <tr><td class="name">default</td><td class="type">'engine' | 'client' | 'engine-if-identical'</td><td class="desc"><code>'client'</code></td><td class="desc"><code>engine</code> pushes everything the engine can express (using its method for MAY-DIFFER stats); <code>engine-if-identical</code> pushes only the verified-identical ones and keeps MAY-DIFFER client-side — the recommended setting for a windowed DuckDB source; <code>client</code> computes everything here.</td></tr>
2924
+ <tr><td class="name">overrides</td><td class="type">Record&lt;stat, 'engine' | 'client'&gt;</td><td class="desc">&mdash;</td><td class="desc">Per-stat overrides that win over <code>default</code>. A stat the engine cannot express is always client-side regardless.</td></tr>
2925
+ </tbody>
2926
+ </table>
2927
+ </div>
2928
+ <p class="section-note">
2929
+ <strong>No mixed provenance.</strong> An engine number and a client number never appear in one
2930
+ result set. Aggregates are pushed only when the filter is <em>fully</em> pushed; a residual
2931
+ filter the engine could not apply forces <em>every</em> aggregate client-side, because an
2932
+ engine figure computed over a superset beside a client figure over the real set would be
2933
+ wrong-but-plausible. <code>lastPlan().aggregates</code> reports, per statistic, whether the
2934
+ engine or the client computed it and the class it was assigned — build-time inspection, not a
2935
+ per-figure runtime marker.
2936
+ </p>
2937
+ <p class="section-note">
2938
+ The classification table below is generated from the single pushdown map
2939
+ (<code>STAT_PUSHDOWN</code>), so it cannot drift from what the adapter actually emits:
2940
+ </p>
2941
+ <div class="table-wrap">
2942
+ <table id="stat-pushdown-table">
2943
+ <thead><tr><th>Statistic</th><th>Class</th><th>DuckDB expression</th><th>Note</th></tr></thead>
2944
+ <tbody data-generated="stat-pushdown">
2945
+ <!-- Generated from STAT_PUSHDOWN by tools/apiref.js; do not hand-edit. -->
2946
+ <tr><td class="name">sum</td><td class="type">IDENTICAL</td><td class="desc"><code>sum(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2947
+ <tr><td class="name">avg</td><td class="type">IDENTICAL</td><td class="desc"><code>avg(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2948
+ <tr><td class="name">min</td><td class="type">IDENTICAL</td><td class="desc"><code>min(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2949
+ <tr><td class="name">max</td><td class="type">IDENTICAL</td><td class="desc"><code>max(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2950
+ <tr><td class="name">count</td><td class="type">IDENTICAL</td><td class="desc"><code>count(*)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2951
+ <tr><td class="name">countValues</td><td class="type">IDENTICAL</td><td class="desc"><code>count(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2952
+ <tr><td class="name">range</td><td class="type">IDENTICAL</td><td class="desc"><code>(max(col) - min(col))</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2953
+ <tr><td class="name">variance</td><td class="type">IDENTICAL</td><td class="desc"><code>var_samp(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2954
+ <tr><td class="name">varianceP</td><td class="type">IDENTICAL</td><td class="desc"><code>var_pop(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2955
+ <tr><td class="name">stddev</td><td class="type">IDENTICAL</td><td class="desc"><code>stddev_samp(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2956
+ <tr><td class="name">stddevP</td><td class="type">IDENTICAL</td><td class="desc"><code>stddev_pop(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2957
+ <tr><td class="name">sumSquares</td><td class="type">IDENTICAL</td><td class="desc"><code>sum(col * col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2958
+ <tr><td class="name">median</td><td class="type">IDENTICAL</td><td class="desc"><code>median(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2959
+ <tr><td class="name">p25</td><td class="type">IDENTICAL</td><td class="desc"><code>quantile_cont(col, 0.25)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2960
+ <tr><td class="name">p75</td><td class="type">IDENTICAL</td><td class="desc"><code>quantile_cont(col, 0.75)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2961
+ <tr><td class="name">p90</td><td class="type">IDENTICAL</td><td class="desc"><code>quantile_cont(col, 0.9)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2962
+ <tr><td class="name">p95</td><td class="type">IDENTICAL</td><td class="desc"><code>quantile_cont(col, 0.95)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2963
+ <tr><td class="name">p99</td><td class="type">IDENTICAL</td><td class="desc"><code>quantile_cont(col, 0.99)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2964
+ <tr><td class="name">iqr</td><td class="type">IDENTICAL</td><td class="desc"><code>(quantile_cont(col, 0.75) - quantile_cont(col, 0.25))</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2965
+ <tr><td class="name">mad</td><td class="type">IDENTICAL</td><td class="desc"><code>mad(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2966
+ <tr><td class="name">distinct</td><td class="type">IDENTICAL</td><td class="desc"><code>count(DISTINCT col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2967
+ <tr><td class="name">skewness</td><td class="type">IDENTICAL</td><td class="desc"><code>skewness(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2968
+ <tr><td class="name">kurtosis</td><td class="type">IDENTICAL</td><td class="desc"><code>kurtosis(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2969
+ <tr><td class="name">geomean</td><td class="type">IDENTICAL</td><td class="desc"><code>exp(avg(ln(col)))</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2970
+ <tr><td class="name">harmean</td><td class="type">IDENTICAL</td><td class="desc"><code>(count(col) / sum(1.0 / col))</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2971
+ <tr><td class="name">entropy</td><td class="type">IDENTICAL</td><td class="desc"><code>entropy(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2972
+ <tr><td class="name">correlation</td><td class="type">IDENTICAL</td><td class="desc"><code>corr(weight, col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2973
+ <tr><td class="name">hhi</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_transform(map_values(histogram(col)), lambda v: (v::DOUBLE/count(col))*(v::DOUBLE/count(col)))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2974
+ <tr><td class="name">evenness</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL WHEN count(DISTINCT col)&lt;2 THEN 1.0 ELSE entropy(col)/log2(count(DISTINCT col)) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2975
+ <tr><td class="name">top3Share</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,3))::DOUBLE/count(col) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2976
+ <tr><td class="name">top10Share</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,10))::DOUBLE/count(col) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2977
+ <tr><td class="name">gini</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN list_min(list(col) FILTER (WHERE isfinite(col)))&lt;0 THEN NULL WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL WHEN list_sum(list(col) FILTER (WHERE isfinite(col)))=0 THEN 0 ELSE 2.0*list_sum(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v, i: i*v))/(len(list(col) FILTER (WHERE isfinite(col)))*list_sum(list(col) FILTER (WHERE isfinite(col))))-(len(list(col) FILTER (WHERE isfinite(col)))+1.0)/len(list(col) FILTER (WHERE isfinite(col))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2978
+ <tr><td class="name">trimmedMean</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_slice(list_sort(list(col) FILTER (WHERE isfinite(col))), floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1, len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT)) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2979
+ <tr><td class="name">winsorizedMean</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v: least(list_sort(list(col) FILTER (WHERE isfinite(col)))[len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT], greatest(list_sort(list(col) FILTER (WHERE isfinite(col)))[floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1], v)))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2980
+ <tr><td class="name">robustOutliers</td><td class="type">IDENTICAL</td><td class="desc"><code>(SELECT CASE WHEN len(d.a)=0 THEN NULL WHEN d.mad=0 THEN NULL ELSE len(list_filter(d.a, lambda v: abs(0.6745*(v-d.med)/d.mad)&gt;3.5)) END FROM (SELECT xs AS a, list_median(xs) AS med, list_median(list_transform(xs, lambda w: abs(w-list_median(xs)))) AS mad FROM (SELECT list(col) FILTER (WHERE isfinite(col)) AS xs)) d)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2981
+ <tr><td class="name">jarqueBera</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))&lt;8 THEN NULL WHEN list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2)))=0 THEN NULL ELSE (len(list(col) FILTER (WHERE isfinite(col)))/6.0)*(power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),3)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),1.5),2)+power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),4)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),2)-3,2)/4.0) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2982
+ <tr><td class="name">weightedAvg</td><td class="type">IDENTICAL</td><td class="desc"><code>sum(col*weight) FILTER (WHERE isfinite(col) AND isfinite(weight))/nullif(sum(weight) FILTER (WHERE isfinite(col) AND isfinite(weight)),0)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2983
+ <tr><td class="name">mode</td><td class="type">MAY-DIFFER</td><td class="desc"><code>mode(col)</code></td><td class="desc">DuckDB returns a modal value even for an all-distinct column; the grid returns null. Tie-breaking can also differ, and on a text column an empty string counts as a value. Under pushdown you will see a value.</td></tr>
2984
+ <tr><td class="name">weightedQuantile</td><td class="type">FALLBACK</td><td class="desc">&mdash;</td><td class="desc">No SQL equivalent for the grid's weighted-quantile midpoint convention; always computed client-side (needs a full-dataset pull for a correct figure over a remote source).</td></tr>
2985
+ </tbody>
2986
+ </table>
2987
+ </div>
2988
+ <pre data-run="js" data-expect="engine=identical client=fallback" data-covers="config:aggregates config:default config:overrides export:STAT_PUSHDOWN"><code><span class="cmt">// Push the verified-identical stats to the engine; keep the fallback here.</span>
2989
+ <span class="cmt">// STAT_PUSHDOWN is the published map every stat's class and SQL comes from.</span>
2990
+ <span class="kw">const</span> { createPushdownSource, STAT_PUSHDOWN } = <span class="kw">await</span> import('../packages/core/src/source/index.js');
2991
+ <span class="kw">void</span> STAT_PUSHDOWN; <span class="cmt">// the single source of truth for the classification table above</span>
2992
+ <span class="kw">const</span> adapter = {
2993
+ name: 'demo',
2994
+ capabilities: { filter: 'tree', operators: ['eq'] },
2995
+ execute: <span class="kw">async</span> () =&gt; ({ rows: [], total: 0 }),
2996
+ <span class="cmt">// A real duckdbAdapter runs SQL; here we just echo which stats arrived.</span>
2997
+ executeAggregates: <span class="kw">async</span> (query, aggs) =&gt; Object.fromEntries(aggs.map((a) =&gt; [a.id, 1])),
2998
+ };
2999
+ <span class="kw">const</span> source = createPushdownSource({ adapter, aggregates: {
3000
+ <span class="kw">default</span>: 'engine-if-identical', <span class="cmt">// push only the verified-identical stats</span>
3001
+ overrides: { mode: 'client' }, <span class="cmt">// but always keep mode's exact definition</span>
3002
+ } });
3003
+ <span class="kw">const</span> split = <span class="kw">await</span> source.aggregate(
3004
+ { filters: null, sort: [], range: null },
3005
+ [{ id: 'a', col: 'revenue', fn: 'median' }, { id: 'b', col: 'size', fn: 'weightedQuantile' }],
3006
+ );
3007
+ <span class="cmt">// median is IDENTICAL so it pushes; weightedQuantile is a fallback so it stays here.</span>
3008
+ <span class="kw">return</span> `engine=${split.engine[0].class} client=${split.client[0].class}`;</code></pre>
3009
+
2804
3010
  <h3 id="derived-join">Joining two grids</h3>
2805
3011
  <p class="section-note">
2806
3012
  Two grids each holding their own data, and a third showing where they meet. Orders against
@@ -3171,6 +3377,20 @@ createGrid(el, {
3171
3377
  <p>A unit given <code>{ auto: false }</code> stays off the <code>display: 'auto'</code> ladder while remaining accepted on input and available as an explicit <code>display</code>. That is how imperial units sit beside metric ones without an auto readout jumping between the two.</p>
3172
3378
  <div class="note"><p><strong>The stored value is always a plain number in the column's own unit.</strong> Sorting, filtering, grouping, totals and the pivot all read that number and never the text, which is why <code>250mm</code> sorts below <code>1.5 cm</code> correctly rather than <code>1</code> sorting before <code>9</code>. <code>registerUnitSystem</code> is global and throws on a duplicate name, so register each system once at startup rather than inside a component that may mount twice.</p></div>
3173
3379
 
3380
+ <h3 id="compound-units">Compound display: <code>5 ft 11 in</code>, <code>1 h 23 m</code></h3>
3381
+ <p><code>compound</code> renders one stored number across an ordered subset of the system's units. It is <strong>display and parse only</strong>: the value stays a single base-unit number, so sort, filter, group and total are the same arithmetic they always were. The order is free &mdash; the units are sorted largest to smallest &mdash; and the smallest one carries any remainder. Parsing sums the parts, so a paste of <code>5 ft 11 in</code> round-trips, and a single <code>71 in</code> or a bare <code>6</code> still work.</p>
3382
+ <pre data-run="js" data-expect="5 ft 11 in | 1.8034 | true" data-covers="config:compound"><code><span class="kw">const</span> { formatUnit, parseUnit } = <span class="kw">await</span> import('../packages/core/src/columns/types/unit.js');
3383
+
3384
+ <span class="cmt">// A height column stored in metres, displayed as feet and inches.</span>
3385
+ <span class="kw">const</span> cfg = { system: 'length', unit: 'm', compound: ['ft', 'in'], locale: 'en-GB' };
3386
+
3387
+ <span class="kw">const</span> shown = formatUnit(1.8034, cfg); <span class="cmt">// across the two units</span>
3388
+ <span class="kw">const</span> stored = parseUnit('5 ft 11 in', cfg); <span class="cmt">// summed back to metres</span>
3389
+ <span class="kw">const</span> stable = formatUnit(parseUnit(shown, cfg), cfg) === shown; <span class="cmt">// round-trips</span>
3390
+
3391
+ <span class="kw">return</span> `${shown} | ${stored} | ${stable}`;</code></pre>
3392
+ <div class="note"><p>The compound units need not include the stored <code>unit</code>, and any unit of the system is accepted on input: <code>71 in</code> pasted into a feet-and-inches column is still 71 inches. Excel export and <code>display: 'auto'</code> share a rule here &mdash; a column of mixed-scale text is not summable in a spreadsheet, so the export uses the raw base number on the configured unit. The <strong>compound cell editor</strong> (mid-value keystrokes, roll-over between feet and inches, caret behaviour at a boundary) is a separate, later piece; this is the read-and-paste half.</p></div>
3393
+
3174
3394
  <h2 id="stat">The statistic block</h2>
3175
3395
  <p>
3176
3396
  <code>createStat</code> draws the tile a dashboard opens with: a label, a value, its change
@@ -3689,7 +3909,7 @@ createGrid(el, {
3689
3909
  <h3 id="config-example">A configuration, executed</h3>
3690
3910
  <p class="section-note">This block runs on every build. If a key here stopped being honoured, or was
3691
3911
  renamed, the build would fail rather than the documentation quietly going stale.</p>
3692
- <pre data-run="js" data-expect="2" data-covers="config:rowKey config:columns config:rows config:rowHeight config:headerHeight config:overscan config:autoHeight config:showHeader config:density config:theme config:locale config:timeZone config:title config:gridLines config:cornerRadius config:targetSize config:sampleSize config:quickFilterText config:maximise config:shortcuts config:rowReorder config:stickyGroupHeaders config:groupFooter config:totalFilteredOnly config:showTotalInHeader config:aggregateChooser config:allowUnsafeTemplates config:useWorker config:sharedMemory config:workerThreshold config:columnVirtualisationAbove config:showColumnFunctions export:createHeadlessGrid"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3912
+ <pre data-run="js" data-expect="2" data-covers="config:rowKey config:columns config:rows config:rowHeight config:headerHeight config:overscan config:autoHeight config:showHeader config:density config:theme config:locale config:timeZone config:title config:gridLines config:cornerRadius config:stripedRows config:targetSize config:sampleSize config:quickFilterText config:maximise config:shortcuts config:rowReorder config:stickyGroupHeaders config:groupFooter config:totalFilteredOnly config:showTotalInHeader config:aggregateChooser config:allowUnsafeTemplates config:useWorker config:sharedMemory config:workerThreshold config:columnVirtualisationAbove config:showColumnFunctions export:createHeadlessGrid"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3693
3913
 
3694
3914
  <span class="cmt">// Every one of these is a documented configuration key, set together so the</span>
3695
3915
  <span class="cmt">// example proves they are accepted and honoured rather than merely spelled.</span>
@@ -3700,7 +3920,7 @@ createGrid(el, {
3700
3920
  rowHeight: 32, headerHeight: 40, overscan: 8, autoHeight: <span class="kw">false</span>,
3701
3921
  showHeader: <span class="kw">true</span>, density: 'compact', theme: 'light',
3702
3922
  locale: 'en-GB', timeZone: 'UTC', title: 'Readings',
3703
- gridLines: 'both', cornerRadius: 4, targetSize: 'default',
3923
+ gridLines: 'both', cornerRadius: 4, stripedRows: <span class="kw">false</span>, targetSize: 'default',
3704
3924
  sampleSize: 100, quickFilterText: '', maximise: <span class="kw">false</span>,
3705
3925
  shortcuts: <span class="kw">true</span>, rowReorder: <span class="kw">false</span>,
3706
3926
  stickyGroupHeaders: <span class="kw">true</span>, groupFooter: <span class="kw">false</span>,
@@ -3746,6 +3966,33 @@ grid.destroy();
3746
3966
  grid.destroy();
3747
3967
  <span class="kw">return</span> n;</code></pre>
3748
3968
 
3969
+ <h3 id="dropsourcerows-example">Source-layer memory reduction, executed</h3>
3970
+ <p class="section-note">A memory grid loaded with <code>ingest.dropSourceRows</code> on. Once the column store
3971
+ is built, the caller's row objects are released from the source layer and the grid config, so the
3972
+ packed columns are the only resident copy — an order-of-magnitude drop at scale. Reads are served
3973
+ by reconstructing a row from the columns, so the <em>values</em> are unchanged; what is gone is
3974
+ object identity, which is why <code>rows.data()</code> returns a fresh object each call rather than
3975
+ the one you supplied.</p>
3976
+ <pre data-run="js" data-expect="true" data-covers="config:dropSourceRows config:ingest"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3977
+
3978
+ <span class="kw">const</span> supplied = [{ id: '1', city: 'Oslo', pop: 700000 }, { id: '2', city: 'Bergen', pop: 280000 }];
3979
+
3980
+ <span class="kw">const</span> grid = createHeadlessGrid({
3981
+ rowKey: 'id',
3982
+ columns: [{ id: 'city', field: 'city' }, { id: 'pop', field: 'pop', type: 'number' }],
3983
+ source: { mode: 'memory' },
3984
+ rows: supplied,
3985
+ <span class="cmt">// Release the caller's objects; keep only the packed columns.</span>
3986
+ ingest: { dropSourceRows: <span class="kw">true</span> },
3987
+ });
3988
+
3989
+ <span class="kw">const</span> back = grid.rows.data();
3990
+ <span class="cmt">// Same values, reconstructed from the columns — but not the caller's own object.</span>
3991
+ <span class="kw">const</span> valuesMatch = back[0].city === 'Oslo' &amp;&amp; back[0].pop === 700000;
3992
+ <span class="kw">const</span> identityDropped = back[0] !== supplied[0];
3993
+ grid.destroy();
3994
+ <span class="kw">return</span> valuesMatch &amp;&amp; identityDropped;</code></pre>
3995
+
3749
3996
  <h3 id="events-example">Events, executed</h3>
3750
3997
  <p class="section-note">Fourteen events raised by ordinary calls, asserted on every build. An event that
3751
3998
  stopped firing, or changed name, fails here rather than in a consumer.</p>
@@ -4241,6 +4488,36 @@ grid.destroy();
4241
4488
  <!-- BEGIN GENERATED TYPE REFERENCE -->
4242
4489
  <h2 id="type-reference">Type reference</h2>
4243
4490
  <p class="section-note">Every interface the library declares, with the type of each member. The sections above describe how the grid is used; this one is the complete surface, generated from the type declarations so that it always matches the release.</p>
4491
+ <h3 id="type-AggregateProvenance">AggregateProvenance</h3>
4492
+ <p class="section-note">How one aggregate was routed, for `lastPlan()` provenance.</p>
4493
+ <div class="table-wrap">
4494
+ <table>
4495
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4496
+ <tbody>
4497
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc"></td></tr>
4498
+ <tr><td class="name">col</td><td class="type">string</td><td class="desc"></td></tr>
4499
+ <tr><td class="name">fn</td><td class="type">string</td><td class="desc"></td></tr>
4500
+ <tr><td class="name">class</td><td class="type">'identical' | 'may-differ' | 'fallback'</td><td class="desc">How the engine result relates to the grid kernel.</td></tr>
4501
+ <tr><td class="name">reason</td><td class="type">string</td><td class="desc">Why it is client-side, when it is (config, fallback, or the guard). <small>(optional)</small></td></tr>
4502
+ <tr><td class="name">weight</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
4503
+ <tr><td class="name">params</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">Parameters the statistic takes, carried through so an adapter emits the matching SQL (e.g. a trim share). <small>(optional)</small></td></tr>
4504
+ </tbody>
4505
+ </table>
4506
+ </div>
4507
+ <h3 id="type-AggregateRequest">AggregateRequest</h3>
4508
+ <p class="section-note">One aggregate the grid asks the source to compute over the matching set. `params` carries e.g. `{ share: 0.1 }` so an adapter emits the matching SQL; `weight` names the second column for a two-column stat like `correlation`.</p>
4509
+ <div class="table-wrap">
4510
+ <table>
4511
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4512
+ <tbody>
4513
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc">Keys the result back to the request.</td></tr>
4514
+ <tr><td class="name">col</td><td class="type">string</td><td class="desc">The column to reduce.</td></tr>
4515
+ <tr><td class="name">fn</td><td class="type">string</td><td class="desc">The statistic name, as used in `total: '&lt;name&gt;'`.</td></tr>
4516
+ <tr><td class="name">weight</td><td class="type">string</td><td class="desc">The second column, for a two-column statistic. <small>(optional)</small></td></tr>
4517
+ <tr><td class="name">params</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">Parameters the statistic takes, e.g. a trim share. <small>(optional)</small></td></tr>
4518
+ </tbody>
4519
+ </table>
4520
+ </div>
4244
4521
  <h3 id="type-AiApi">AiApi</h3>
4245
4522
  <div class="table-wrap">
4246
4523
  <table>
@@ -4514,6 +4791,8 @@ grid.destroy();
4514
4791
  <tr><td class="name">group</td><td class="type">{ enabled?: boolean; index?: number; explode?: boolean } | boolean</td><td class="desc">Row grouping by this column. `index` fixes its place among several; `explode` gives a multi-value cell one group per value rather than one group for the combination. <small>(optional)</small></td></tr>
4515
4792
  <tr><td class="name">pivot</td><td class="type">{ enabled?: boolean; index?: number } | boolean</td><td class="desc">Use this column as a pivot dimension, and where it sits among several. <small>(optional)</small></td></tr>
4516
4793
  <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction shown in the totals row and in group footers. <small>(optional)</small></td></tr>
4794
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides `total` for those scopes only; when omitted the column's `total` applies to both. Lets a column average within each group while the grand total sums, for example (BACKLOG-0000726). <small>(optional)</small></td></tr>
4795
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for the pinned grand-total row, where it should differ from the group subtotals. Overrides `total` for the grand total only; when omitted the column's `total` applies (BACKLOG-0000726). <small>(optional)</small></td></tr>
4517
4796
  <tr><td class="name">shadow</td><td class="type">ShadowKind | {</td><td class="desc">A value the grid maintains about this column's own history, rather than a field in the data. `{of: 'price', kind: 'delta'}`, or the bare kind to shadow the column it sits beside. <small>(optional)</small></td></tr>
4518
4797
  <tr><td class="name">running</td><td class="type">'total' | 'percent' | 'delta'</td><td class="desc">A running total down the grid **as it is currently ordered**. The one derived value that depends on the display order: sort differently and every value changes. That is why it is not a shadow kind: every shadow reads the same however the rows are arranged. <small>(optional)</small></td></tr>
4519
4798
  <tr><td class="name">spec</td><td class="type">{ lower?: number; upper?: number; target?: number }</td><td class="desc">The customer's tolerance, for process capability and control charts. Declared here rather than passed to each call so the capability figures, a control chart and any rule marking an out-of-tolerance cell cannot disagree about what the tolerance is. <small>(optional)</small></td></tr>
@@ -4711,7 +4990,7 @@ grid.destroy();
4711
4990
  <table>
4712
4991
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4713
4992
  <tbody>
4714
- <tr><td class="name">setTotal</td><td class="type">(id: string, fn: TotalName | TotalFn | null): void</td><td class="desc">Set or clear a column's totals-row reduction.</td></tr>
4993
+ <tr><td class="name">setTotal</td><td class="type">(</td><td class="desc">Set or clear a column's totals-row reduction. With no `scope`, `fn` becomes the column's single `total`, applied to both group subtotals and the grand total, and any independent group/grand overrides are cleared — the same one-property behaviour as before (BACKLOG-0000726). Pass `scope: 'group'` or `scope: 'grand'` to set just that scope's reduction independently, leaving the other and the base `total` untouched; the scope that has no override falls back to `total`.</td></tr>
4715
4994
  <tr><td class="name">aggregates</td><td class="type">(id: string): TotalName[]</td><td class="desc">The aggregate names meaningful for a column, honouring its type's `totals.supported` declaration (§9.4). What the aggregate chooser offers.</td></tr>
4716
4995
  <tr><td class="name">distinct</td><td class="type">(id: string): unknown[]</td><td class="desc">Every distinct value in a column, from the dictionary where there is one.</td></tr>
4717
4996
  <tr><td class="name">get</td><td class="type">(id: string): ResolvedColumn | undefined</td><td class="desc"></td></tr>
@@ -4763,6 +5042,8 @@ grid.destroy();
4763
5042
  <tr><td class="name">groupIndex</td><td class="type">number | null</td><td class="desc"><small>(optional)</small></td></tr>
4764
5043
  <tr><td class="name">pivotIndex</td><td class="type">number | null</td><td class="desc"><small>(optional)</small></td></tr>
4765
5044
  <tr><td class="name">total</td><td class="type">TotalName | null</td><td class="desc"><small>(optional)</small></td></tr>
5045
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | null</td><td class="desc">The group-subtotal override, when one differs from `total`. <small>(optional)</small></td></tr>
5046
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | null</td><td class="desc">The grand-total override, when one differs from `total`. <small>(optional)</small></td></tr>
4766
5047
  </tbody>
4767
5048
  </table>
4768
5049
  </div>
@@ -5543,6 +5824,7 @@ grid.destroy();
5543
5824
  <tr><td class="name">density</td><td class="type">Density</td><td class="desc">Row height and padding as a named step, rather than pixel by pixel. <small>(optional)</small></td></tr>
5544
5825
  <tr><td class="name">gridLines</td><td class="type">boolean | 'both' | 'horizontal' | 'vertical' | 'none' | 'rows' | 'columns'</td><td class="desc">Which rules are drawn between cells. `'both'` by default. The two axes are separate decisions: horizontal rules help the eye track along a row, vertical ones stop adjacent values running together. `false` or `'none'` draws neither. Only the rules *between data* are affected, the header's underline, the pinned seams and the totals separator are structure, not grid lines. <small>(optional)</small></td></tr>
5545
5826
  <tr><td class="name">cornerRadius</td><td class="type">boolean | number | string</td><td class="desc">Round the grid's outer corners. Square by default. `true` adopts the theme's own radius; a number is pixels; a string is used as written, so a host can pass its own token or a relative unit. <small>(optional)</small></td></tr>
5827
+ <tr><td class="name">stripedRows</td><td class="type">boolean</td><td class="desc">Shade alternate data rows (zebra striping). Off by default, and strictly opt-in: an existing grid must look exactly the same on upgrade. When `true`, every other data row takes the theme's `--lattice-surface-alt` background, which every palette already defines, so dark, high-contrast and terminal stripe correctly without extra work. Parity follows the row's *logical* index, not its position in the DOM, so a row keeps its stripe across a scroll even though the rows are recycled. Structural rows — group headings, group footers and the grand total — are never striped, and both selection and hover still win over the stripe. <small>(optional)</small></td></tr>
5546
5828
  <tr><td class="name">columnTagFilter</td><td class="type">boolean | { multiple?: boolean; label?: string }</td><td class="desc">Show a bar above the column headings for filtering columns by tag. Off by default, and it draws nothing unless some column carries a `tags` entry. `multiple: true` lets more than one tag be chosen at once. Only tagged columns are ever hidden, so an untagged account or total column stays visible whatever is selected. <small>(optional)</small></td></tr>
5547
5829
  <tr><td class="name">typeOptions</td><td class="type">Record&lt;string, {</td><td class="desc">Per-column options a data type reads. `ratio` and `percentRate` use `{ weight }` to name the column their average is weighted by. A unit type reads `{ significantFigures }` to render to a fixed precision rather than a fixed number of decimals. <small>(optional)</small></td></tr>
5548
5830
  <tr><td class="name">rowTemplate</td><td class="type">string | {</td><td class="desc"><small>(optional)</small></td></tr>
@@ -5552,7 +5834,7 @@ grid.destroy();
5552
5834
  <tr><td class="name">rowHeight</td><td class="type">number | ((row: Row) =&gt; number)</td><td class="desc">Row height in pixels, or a function of the row. A function makes the grid measure rather than assume, which costs a pass over what is on screen: worth it for wrapped text, wasteful for a uniform grid. <small>(optional)</small></td></tr>
5553
5835
  <tr><td class="name">title</td><td class="type">string</td><td class="desc">A caption for the grid, drawn above the column headings. Inside the grid rather than an element the host places above it: a title outside does not scroll with the grid, is not in the region a screen reader announces, and is left behind by image capture and print. <small>(optional)</small></td></tr>
5554
5836
  <tr><td class="name">showHeader</td><td class="type">boolean</td><td class="desc">Draw the column headings at all. `true` by default. `false` removes the row, and removes it from the accessibility tree rather than only from view, a heading a screen reader still announces is invisible, not hidden. What a small dashboard tile wants when its `title` already says what the panel is. Distinct from `showColumnFunctions`, which keeps the headings and drops only the sort, filter and menu controls inside them. <small>(optional)</small></td></tr>
5555
- <tr><td class="name">headerHeight</td><td class="type">number</td><td class="desc">Header height in pixels. <small>(optional)</small></td></tr>
5837
+ <tr><td class="name">headerHeight</td><td class="type">number</td><td class="desc">Header height in pixels. Omitted, the header takes its height from the density-scaled `--lattice-header-height` token, so `density` sizes the header as it sizes the rows. A number names one explicitly and outranks the token. <small>(optional)</small></td></tr>
5556
5838
  <tr><td class="name">overscan</td><td class="type">number</td><td class="desc">How many rows to render beyond the viewport. More costs memory and smooths fast scrolling; fewer is lighter and can show a gap. <small>(optional)</small></td></tr>
5557
5839
  <tr><td class="name">autoHeight</td><td class="type">boolean | 'visible'</td><td class="desc">Size rows to their content rather than to the density token. Only rows that are actually rendered are ever measured, in both settings: the grid does not lay out rows you cannot see. The difference is what happens on a large grid: `true` gives up above ten thousand rows and falls back to fixed heights, because a cumulative offset array being patched as you scroll a million rows is not worth the result. `'visible'` keeps measuring at any size, accepting that the scrollbar shifts as rows are measured on the way past. The name is historical and reads as though it were about which rows are measured; it is about whether the ceiling applies. <small>(optional)</small></td></tr>
5558
5840
  <tr><td class="name">state</td><td class="type">GridState</td><td class="desc">Sort, filters, grouping, widths and the rest, restored at construction. <small>(optional)</small></td></tr>
@@ -5589,7 +5871,7 @@ grid.destroy();
5589
5871
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column?: string }</td><td class="desc">Let a user reorder rows by dragging a handle, or with Alt+Shift+Up/Down. `true` puts the handle in the first visible column; `{ column }` names a different one. The move reorders your data and emits `row:moved`; persisting it is yours, and `rows.data()` afterwards is the new order. Refused, with a reason announced, while a sort, filter or grouping is active, the position a row is dropped at has no single meaning in the underlying order then. <small>(optional)</small></td></tr>
5590
5872
  <tr><td class="name">rowTransfer</td><td class="type">boolean | {</td><td class="desc">Let rows be dragged out of this grid, into it, or both. Off by default: rows leaving a grid is a data change a host has to want, and a mis-drag that silently removed one has no gesture a user would think to undo. `send` and `receive` are both on when the option is present, so one-way is expressed by turning off the direction you do not want, a source grid is `{ receive: false }` and a target is `{ send: false }`. `mode: 'copy'` leaves the row where it was. `group` restricts exchange to grids sharing the same name, so two unrelated grids on a page do not accept each other's rows. The source needs `rowReorder` as well, since that is what draws the handle a drag starts from. <small>(optional)</small></td></tr>
5591
5873
  <tr><td class="name">alignedGrids</td><td class="type">unknown[]</td><td class="desc">Other grids to stay column-aligned with. Column widths, order, visibility and pinning are shared, and horizontal scrolling moves them together. Sort, filters, selection, grouping and the rows themselves stay independent: sharing those would make one grid with extra steps rather than two aligned ones. Declared on the grid created last, since it is the only one that can name the others; the link is peer-based once made. <small>(optional)</small></td></tr>
5592
- <tr><td class="name">stickyGroupHeaders</td><td class="type">boolean | number | { depth?: number }</td><td class="desc">Keep the enclosing group headings pinned above the viewport while scrolling inside a group. On by default, stacking at most two. `false` turns it off; a number, or `{ depth }`, sets how many may stack: each costs a row of viewport, so a deep grouping would otherwise spend the screen describing itself. <small>(optional)</small></td></tr>
5874
+ <tr><td class="name">stickyGroupHeaders</td><td class="type">boolean | number | { depth?: number }</td><td class="desc">Keep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default a deliberate product default; sticky group headers are opt-in. `true` turns it on, stacking at most two; a number, or `{ depth }`, sets how many may stack: each costs a row of viewport, so a deep grouping would otherwise spend the screen describing itself. `false` is off, the same as leaving it unset. <small>(optional)</small></td></tr>
5593
5875
  <tr><td class="name">highlightOnChange</td><td class="type">boolean | string | {</td><td class="desc">Flash a cell when its value changes. `true` takes the defaults; an object names a colour, a duration in milliseconds, or both. <small>(optional)</small></td></tr>
5594
5876
  <tr><td class="name">formatting</td><td class="type">Record&lt;string, FormattingRule[]&gt;</td><td class="desc">Conditional formatting rules the grid holds as runtime state, keyed by column id or `'*'` for every column (spec 8.12). Seeds `grid.formatting`, which an end user can then change; the rules travel in saved views and undo like any other change. Config-time `cell.style` is unaffected. <small>(optional)</small></td></tr>
5595
5877
  <tr><td class="name">rowClass</td><td class="type">string | string[] | ((p: RowStyleParams) =&gt; string | string[])</td><td class="desc">A class, or classes, for every row. Re-evaluated on each repaint. <small>(optional)</small></td></tr>
@@ -5723,6 +6005,7 @@ grid.destroy();
5723
6005
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
5724
6006
  <tbody>
5725
6007
  <tr><td class="name">retainSource</td><td class="type">boolean</td><td class="desc">Retain the caller's row objects by reference so identity round-trips. Default `true`, the historical behaviour: `rows.data()` returns the exact objects you supplied, `row === sourceObject` holds, and a custom renderer reading `row.sourceObject` works. Set `false` to keep only the packed columns and reconstruct a plain row object from them on demand. This drops roughly half the resident footprint, but changes three behaviours: `rows.data()` returns freshly reconstructed objects (new object each call, so `row === sourceObject` no longer holds), a custom renderer that reaches for `row.sourceObject` gets a reconstruction rather than the original, and equality against a row becomes value-based. The stored values are unchanged, so `get()`, `byKey()`, `value()` and `values()` are unaffected. <small>(optional)</small></td></tr>
6008
+ <tr><td class="name">dropSourceRows</td><td class="type">boolean</td><td class="desc">Release the caller's row objects from the *source layer* once the column store has been built, so the columns become the sole resident copy of the data. Default `false`, which keeps today's behaviour. `retainSource:false` stops the {@link https://en.wikipedia.org/wiki/Column-oriented_DBMS column store} from holding the caller's objects, but the memory source and the grid config still retain the supplied array by reference — so the objects stay alive and the resident footprint does not actually fall. This flag closes that gap: it clears `MemorySource`'s retained array and drops the array from the grid config, leaving nothing on the heap but the packed columns. That is where the large reduction comes from (roughly an order of magnitude at a million rows), not from `retainSource` on its own. Implies `retainSource:false`: dropping the caller's objects while the store still expects to read through them would leave the source with no data at all, so setting this on forces the store to reconstruct rows from columns. Every read is therefore served from the columns — `at()`, `byKey()`, `get()`, `value()`, `values()`, filtering, sorting, grouping, totals and export are all unaffected in their values. What changes is the same three identity behaviours `retainSource:false` documents: `rows.data()` returns freshly reconstructed objects (so `row === sourceObject` no longer holds), a custom renderer reaching for `row.sourceObject` gets a reconstruction, and equality against a row becomes value-based. One consumer cannot be served from the columns: an *impure computed column* (a shadow, or a rank/positional column) is deliberately never materialised into the store, so its handle is built by reading the source objects. Under `dropSourceRows` those objects are gone, so such a column reduces over nothing and warns once rather than returning a silently wrong figure. Do not enable `dropSourceRows` on a grid that sorts, filters, groups or totals on a shadow or a positional column. <small>(optional)</small></td></tr>
5726
6009
  <tr><td class="name">useWorker</td><td class="type">boolean</td><td class="desc">Columnize `stream`-source ingest on a Worker so a large load does not block the main thread. Default `false`. When on, an arriving chunk that clears {@link IngestConfig.workerThreshold} is packed into typed column buffers on the Worker; the main thread merges the finished buffers into the store and renders, without running the per-field extraction pass that otherwise dominates ingest. This makes **stream** ingest non-blocking (remote sources already are). Memory and paged sources cannot be made non-blocking this way — the main thread must read the caller's own row objects — and are unaffected. The effect composes with `retainSource: false`: with it off the source keeps no caller-object array on the main thread at all, so the load is both non-blocking and lighter on memory. A column that reads through a closure — a `date` column's storage conversion, or a computed column — cannot cross the Worker boundary, so a grid with any such column columnizes on the main thread and says so once. Falls back silently to the main thread wherever a Worker cannot be created. <small>(optional)</small></td></tr>
5727
6010
  <tr><td class="name">workerThreshold</td><td class="type">number</td><td class="desc">Row count in a single stream chunk at or above which columnization is offloaded to the Worker when {@link IngestConfig.useWorker} is on. Default `10000`. A smaller first chunk is packed on the main thread, where the cost is trivial and the postMessage round trip would only add latency to time-to-first-row. <small>(optional)</small></td></tr>
5728
6011
  </tbody>
@@ -6103,6 +6386,17 @@ grid.destroy();
6103
6386
  </tbody>
6104
6387
  </table>
6105
6388
  </div>
6389
+ <h3 id="type-PushdownAggregatesConfig">PushdownAggregatesConfig</h3>
6390
+ <p class="section-note">Design-time aggregate-pushdown policy for a pushdown source (BACKLOG-0000730 Part B). The developer chooses, at grid setup before render, whether each statistic is computed by the engine (fast, over the matching set) or client-side (the grid's exact definition, needs a full-dataset pull). It is fixed for the life of the grid, never a runtime toggle, and never surfaced to an end user. Absent, every aggregate is computed client-side — today's behaviour, so no existing caller regresses. `engine-if-identical` is the recommended setting for a windowed DuckDB source: it pushes only the statistics whose engine result is verified identical to the grid kernel, keeping the documented MAY-DIFFER stats (e.g. `mode`) client-side. The engine is used only when the filter is fully pushed; a residual filter forces every aggregate client-side, so an engine figure and a client figure never mix in one result set.</p>
6391
+ <div class="table-wrap">
6392
+ <table>
6393
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6394
+ <tbody>
6395
+ <tr><td class="name">default</td><td class="type">AggregateMode</td><td class="desc">The default policy for stats the engine can express. `'engine'` pushes everything expressible (using the engine's method for MAY-DIFFER stats); `'engine-if-identical'` pushes only the verified-identical ones; `'client'` computes everything client-side. Default `'client'`. <small>(optional)</small></td></tr>
6396
+ <tr><td class="name">overrides</td><td class="type">Record&lt;string, 'engine' | 'client'&gt;</td><td class="desc">Per-stat overrides, winning over `default`. A stat the engine cannot express (`weightedQuantile`) is always client-side regardless. <small>(optional)</small></td></tr>
6397
+ </tbody>
6398
+ </table>
6399
+ </div>
6106
6400
  <h3 id="type-PushdownCapabilities">PushdownCapabilities</h3>
6107
6401
  <p class="section-note">What a pushdown adapter can answer. Everything is off unless declared.</p>
6108
6402
  <div class="table-wrap">
@@ -6119,6 +6413,18 @@ grid.destroy();
6119
6413
  </tbody>
6120
6414
  </table>
6121
6415
  </div>
6416
+ <h3 id="type-PushdownFullDatasetConfig">PushdownFullDatasetConfig</h3>
6417
+ <p class="section-note">Opt-in, sticky full-dataset pull for a pushdown/remote source (BACKLOG-0000730). Off by default. When enabled, the source materialises the entire matching set client-side once per query signature and serves every window, total and statistic from it, so those figures are computed over the whole set rather than the loaded window. A set past either limit is refused with a visible `source:error` — never silently truncated.</p>
6418
+ <div class="table-wrap">
6419
+ <table>
6420
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6421
+ <tbody>
6422
+ <tr><td class="name">enabled</td><td class="type">boolean</td><td class="desc">Sticky: hold the whole matching set client-side. Default `false`. <small>(optional)</small></td></tr>
6423
+ <tr><td class="name">maxRows</td><td class="type">number</td><td class="desc">Refuse (visible error) past this many rows. Default `1_000_000`. <small>(optional)</small></td></tr>
6424
+ <tr><td class="name">maxBytesEstimate</td><td class="type">number</td><td class="desc">Refuse past this estimated heap cost, in bytes. Default `512 * 1024 * 1024`. <small>(optional)</small></td></tr>
6425
+ </tbody>
6426
+ </table>
6427
+ </div>
6122
6428
  <h3 id="type-PushdownPlan">PushdownPlan</h3>
6123
6429
  <p class="section-note">How one request was divided between the engine and the grid.</p>
6124
6430
  <div class="table-wrap">
@@ -6129,6 +6435,8 @@ grid.destroy();
6129
6435
  <tr><td class="name">residual</td><td class="type">{ filters: object | null; sort: SortEntry[] | null; quick: string }</td><td class="desc">What the grid applied afterwards.</td></tr>
6130
6436
  <tr><td class="name">needsAll</td><td class="type">boolean</td><td class="desc">Whether the whole result had to be fetched rather than a window.</td></tr>
6131
6437
  <tr><td class="name">unpushed</td><td class="type">string[]</td><td class="desc">Which parts could not be pushed: `filter`, `sort`, `quick`.</td></tr>
6438
+ <tr><td class="name">full</td><td class="type">boolean</td><td class="desc">Whether the whole result was fetched because `fullDataset` is on, rather than only because residual work forced it. When true, totals and statistics reduce over the whole matching set and the windowed-stat warning is silent.</td></tr>
6439
+ <tr><td class="name">aggregates</td><td class="type">{</td><td class="desc">Per-aggregate provenance, present only when the last request computed aggregates (BACKLOG-0000730 Part B): which statistics the engine computed and which the client did, with the class the pushdown map assigned each. Under grouping it also carries the `groupBy` the subtotals were computed over. Build-time inspection, not a runtime per-figure marker. <small>(optional)</small></td></tr>
6132
6440
  </tbody>
6133
6441
  </table>
6134
6442
  </div>
@@ -6140,6 +6448,9 @@ grid.destroy();
6140
6448
  <tr><td class="name">adapter</td><td class="type">PushdownAdapter</td><td class="desc"></td></tr>
6141
6449
  <tr><td class="name">compute</td><td class="type">object</td><td class="desc">The compute barrel, for applying whatever the engine could not. <small>(optional)</small></td></tr>
6142
6450
  <tr><td class="name">pageSize</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
6451
+ <tr><td class="name">fullDataset</td><td class="type">PushdownFullDatasetConfig</td><td class="desc">Opt-in full-dataset pull. Off unless `fullDataset.enabled` is set. See {@link PushdownFullDatasetConfig}. <small>(optional)</small></td></tr>
6452
+ <tr><td class="name">aggregates</td><td class="type">PushdownAggregatesConfig</td><td class="desc">Design-time aggregate-pushdown policy. Absent = client-side (today's behaviour). See {@link PushdownAggregatesConfig}. <small>(optional)</small></td></tr>
6453
+ <tr><td class="name">allowPartialResults</td><td class="type">boolean</td><td class="desc">Accept a partial/paged result to a whole-set request when residual work (a filter, sort or quick search) will run over it client-side. Off by default: such a shortfall is refused with a thrown error, because filtering or sorting a fraction of the result presents the wrong rows as the whole filtered set — a wrong answer, not a slow one. Set `true` only when you knowingly accept that risk (e.g. an adapter that cannot page and a result small enough not to matter); the old warn-once-and-proceed behaviour is then kept. It never changes the fullDataset memory-guard or the no-residual short-return warning. <small>(optional)</small></td></tr>
6143
6454
  </tbody>
6144
6455
  </table>
6145
6456
  </div>
@@ -6304,6 +6615,8 @@ grid.destroy();
6304
6615
  <tr><td class="name">group</td><td class="type">{ enabled: boolean; index: number; explode: boolean }</td><td class="desc"></td></tr>
6305
6616
  <tr><td class="name">pivot</td><td class="type">{ enabled: boolean; index: number }</td><td class="desc"></td></tr>
6306
6617
  <tr><td class="name">total</td><td class="type">TotalName | TotalFn | null</td><td class="desc"></td></tr>
6618
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn | null</td><td class="desc">The group-subtotal override, or null when group subtotals follow `total` (BACKLOG-0000726).</td></tr>
6619
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn | null</td><td class="desc">The grand-total override, or null when the grand total follows `total` (BACKLOG-0000726).</td></tr>
6307
6620
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec</td><td class="desc"></td></tr>
6308
6621
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec</td><td class="desc"></td></tr>
6309
6622
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc"></td></tr>
@@ -6723,6 +7036,7 @@ grid.destroy();
6723
7036
  <tr><td class="name">group</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
6724
7037
  <tr><td class="name">space</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
6725
7038
  <tr><td class="name">placement</td><td class="type">'suffix' | 'prefix'</td><td class="desc"><small>(optional)</small></td></tr>
7039
+ <tr><td class="name">compound</td><td class="type">string[]</td><td class="desc">Render one stored number across an ordered subset of the system's units, e.g. `['ft', 'in']` for `5 ft 11 in`. Display and parse only: the stored value stays a single base-unit number, so sort, filter and total are unchanged. Parsing sums the parts. <small>(optional)</small></td></tr>
6726
7040
  </tbody>
6727
7041
  </table>
6728
7042
  </div>