@svgrid/mcp 3.0.12 → 3.0.13
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/dist/data.js +14 -14
- package/dist/preview.d.ts +1 -1
- package/dist/preview.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/data.js
CHANGED
|
@@ -61,7 +61,7 @@ export const examples = [
|
|
|
61
61
|
"title": "Grouping Aggregation",
|
|
62
62
|
"category": "Sorting & Grouping",
|
|
63
63
|
"blurb": "07. Grouping + aggregation The grid's built-in grouping pipeline buckets rows by one or more columns and renders a group row in their place. Aggregation here is",
|
|
64
|
-
"source": "<script lang=\"ts\">\n /**\n * 07. Grouping + aggregation\n * --------------------------\n * The grid's built-in grouping pipeline buckets rows by one or more\n * columns and renders a group row in their place. Aggregation here is\n * computed in the demo (the engine resolves shared values per group; this\n * component layers sum/avg on top for the visible \"Salary\" and\n * \"Performance\" columns via the row-summary footer).\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const rows = makePeople(500)\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n { field: 'performance', header: 'Performance', editorType: 'number' },\n ]\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['department'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.length === 0 ? 'is-on' : ''}\"\n >None</button>\n <button\n onclick={() => applyGroup(['department'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'department' ? 'is-on' : ''}\"\n >Department</button>\n <button\n onclick={() => applyGroup(['country'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'country' ? 'is-on' : ''}\"\n >Country</button>\n <button\n onclick={() => applyGroup(['department', 'country'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'department,country' ? 'is-on' : ''}\"\n >Department → Country</button>\n <span class=\"gb-hint ml-3\">\n Click a group row to expand. The row-summaries footer aggregates totals.\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n // Apply the initial group-by once the imperative API is available,\n // then auto-expand the first group so the data isn't hidden behind a\n // single closed row on first paint.\n next.setGroupBy(groupBy)\n queueMicrotask(() => next.setRowExpanded(`
|
|
64
|
+
"source": "<script lang=\"ts\">\n /**\n * 07. Grouping + aggregation\n * --------------------------\n * The grid's built-in grouping pipeline buckets rows by one or more\n * columns and renders a group row in their place. Aggregation here is\n * computed in the demo (the engine resolves shared values per group; this\n * component layers sum/avg on top for the visible \"Salary\" and\n * \"Performance\" columns via the row-summary footer).\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const rows = makePeople(500)\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n { field: 'performance', header: 'Performance', editorType: 'number' },\n ]\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['department'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.length === 0 ? 'is-on' : ''}\"\n >None</button>\n <button\n onclick={() => applyGroup(['department'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'department' ? 'is-on' : ''}\"\n >Department</button>\n <button\n onclick={() => applyGroup(['country'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'country' ? 'is-on' : ''}\"\n >Country</button>\n <button\n onclick={() => applyGroup(['department', 'country'])}\n class=\"gb-btn rounded border px-3 py-1 {groupBy.join() === 'department,country' ? 'is-on' : ''}\"\n >Department → Country</button>\n <span class=\"gb-hint ml-3\">\n Click a group row to expand. The row-summaries footer aggregates totals.\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n // Apply the initial group-by once the imperative API is available,\n // then auto-expand the first group so the data isn't hidden behind a\n // single closed row on first paint.\n next.setGroupBy(groupBy)\n queueMicrotask(() => next.setRowExpanded(`group_department_Engineering`, true))\n }}\n />\n </div>\n</section>\n\n<style>\n .gb-btn {\n border-color: var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n }\n .gb-btn:hover { background: var(--sg-row-hover-bg, rgba(148, 163, 184, 0.12)); }\n .gb-btn.is-on {\n background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0));\n border-color: var(--sg-border, #cbd5e1);\n }\n .gb-hint { color: var(--sg-muted, #64748b); }\n</style>\n"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"id": "08-tree-and-master-detail",
|
|
@@ -261,7 +261,7 @@ export const examples = [
|
|
|
261
261
|
"title": "Hr Team",
|
|
262
262
|
"category": "Industry Templates",
|
|
263
263
|
"blurb": "12. HR team directory A directory of ~80 employees with the columns an HR app actually needs: person (avatar + name), title, level, team, manager, location, start",
|
|
264
|
-
"source": "<script lang=\"ts\">\n /**\n * 12. HR team directory\n * ---------------------\n * A directory of ~80 employees with the columns an HR app actually needs:\n * person (avatar + name), title, level, team, manager, location, start\n * date, tenure, status, comp.\n *\n * Showcases:\n * - `columnGroupingFeature` with a default group-by (\"team\")\n * - `renderSnippet` for the avatar cell and status badge\n * - A derived `tenure` column (no `field` - `fieldFn`)\n * - Sort by any column; group rows fold to a summary line\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type Status = 'Active' | 'On leave' | 'Contractor'\n type Person = {\n id: string\n firstName: string\n lastName: string\n title: string\n level: string\n team: string\n manager: string\n location: string\n startDate: string // ISO date\n status: Status\n salary: number\n bonusPct: number\n }\n\n // ------------------------------------------------------------------ data\n const TEAMS = [\n 'Engineering', 'Design', 'Product', 'Data', 'Marketing',\n 'Sales', 'Support', 'Operations', 'Finance', 'People',\n ] as const\n type Team = typeof TEAMS[number]\n\n const TITLES: Record<Team, string[]> = {\n Engineering: ['Software Engineer', 'Senior Engineer', 'Staff Engineer', 'Engineering Manager', 'Director of Engineering'],\n Design: ['Product Designer', 'Senior Designer', 'Design Lead', 'Director of Design'],\n Product: ['Associate PM', 'Product Manager', 'Senior PM', 'Group PM', 'Director of Product'],\n Data: ['Data Analyst', 'Data Scientist', 'ML Engineer', 'Analytics Manager'],\n Marketing: ['Marketing Specialist', 'Growth Manager', 'Brand Lead', 'CMO'],\n Sales: ['SDR', 'AE', 'Senior AE', 'Sales Manager', 'VP Sales'],\n Support: ['Support Engineer', 'Senior Support', 'Support Lead'],\n Operations: ['Ops Analyst', 'Operations Manager', 'COO'],\n Finance: ['Accountant', 'Finance Analyst', 'Controller', 'CFO'],\n People: ['HR Generalist', 'Recruiter', 'People Partner', 'CHRO'],\n }\n\n const LEVELS = ['IC2', 'IC3', 'IC4', 'IC5', 'IC6', 'M2', 'M3', 'M4', 'M5']\n const LOCATIONS = [\n 'San Francisco, US', 'New York, US', 'Austin, US', 'London, UK', 'Berlin, DE',\n 'Amsterdam, NL', 'Toronto, CA', 'Sydney, AU', 'Tokyo, JP', 'Singapore, SG',\n 'Remote - EMEA', 'Remote - Americas',\n ]\n const FIRST_NAMES = [\n 'Aiden', 'Bea', 'Cara', 'Dev', 'Elena', 'Felix', 'Grace', 'Hannah', 'Ivan',\n 'Jules', 'Kai', 'Lena', 'Marco', 'Nina', 'Omar', 'Priya', 'Quinn', 'Rosa',\n 'Sven', 'Tess', 'Uma', 'Victor', 'Wren', 'Xio', 'Yael', 'Zane', 'Anika',\n 'Beck', 'Cyrus', 'Dara', 'Eitan', 'Fiona', 'Gus', 'Henri', 'Ines', 'Jonas',\n 'Kira', 'Luca', 'Mara', 'Nico', 'Olin', 'Pia', 'Quentin', 'Reyna', 'Sami',\n ]\n const LAST_NAMES = [\n 'Ortega', 'Nakamura', 'Schmidt', 'Hassan', 'Petrov', 'Yamada', 'Hughes',\n 'Marchetti', 'Anand', 'Lefevre', 'Olsen', 'Carmona', 'Tan', 'Beck',\n 'Mustafa', 'Andersson', 'Vogel', 'Romero', 'Kowalski', 'Mendoza',\n 'Sokolova', 'Voss', 'Holm', 'Park', 'Khoury', 'Bauer', 'Russo',\n ]\n\n // Seeded PRNG so the demo is reproducible across reloads.\n let prngState = 0x9E3779B1\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n function range(lo: number, hi: number): number { return lo + Math.floor(rand() * (hi - lo + 1)) }\n\n function isoDate(daysAgo: number): string {\n const ms = Date.now() - daysAgo * 86_400_000\n return new Date(ms).toISOString().slice(0, 10)\n }\n\n function makePeople(): Person[] {\n const out: Person[] = []\n // 1. Pick one manager per team first so other rows can reference them.\n const managerByTeam: Record<string, string> = {}\n for (const team of TEAMS) {\n const first = pick(FIRST_NAMES)\n const last = pick(LAST_NAMES)\n const titles = TITLES[team]\n const title = titles[titles.length - 1]! // most senior title for the team's head\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: first,\n lastName: last,\n title,\n level: pick(['M3', 'M4', 'M5']),\n team,\n manager: 'Sasha Lin (CEO)',\n location: pick(LOCATIONS),\n startDate: isoDate(range(1500, 4200)),\n status: 'Active',\n salary: range(180, 360) * 1000,\n bonusPct: range(15, 30),\n })\n managerByTeam[team] = `${first} ${last}`\n }\n // 2. Fill out 6–10 ICs per team reporting to that team's head.\n for (const team of TEAMS) {\n const headcount = range(6, 10)\n const icTitles = TITLES[team].slice(0, -1) // drop the manager title\n for (let i = 0; i < headcount; i += 1) {\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: pick(FIRST_NAMES),\n lastName: pick(LAST_NAMES),\n title: pick(icTitles),\n level: pick(LEVELS.slice(0, 6)), // IC-only levels\n team,\n manager: managerByTeam[team]!,\n location: pick(LOCATIONS),\n startDate: isoDate(range(60, 2400)),\n status: pick<Status>(['Active', 'Active', 'Active', 'Active', 'On leave', 'Contractor']),\n salary: range(85, 220) * 1000,\n bonusPct: range(5, 18),\n })\n }\n }\n return out\n }\n\n const rows = makePeople()\n\n // Tenure in years, derived from startDate; used by an `fieldFn` column.\n function tenureYears(p: Person): number {\n const start = new Date(p.startDate).getTime()\n const now = Date.now()\n return Math.round(((now - start) / (365.25 * 86_400_000)) * 10) / 10\n }\n\n function initials(p: Person): string {\n return `${p.firstName[0] ?? ''}${p.lastName[0] ?? ''}`.toUpperCase()\n }\n\n // Stable avatar tint per team - keeps the directory feeling like one company\n // not a rainbow soup.\n const TEAM_COLORS: Record<string, string> = {\n Engineering: '#2563eb', Design: '#db2777', Product: '#7c3aed',\n Data: '#0891b2', Marketing: '#db2516', Sales: '#16a34a',\n Support: '#ca8a04', Operations: '#9333ea', Finance: '#0d9488',\n People: '#e11d48',\n }\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['team'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n{#snippet PersonCell(props: { row: Person })}\n <span class=\"hr-person\">\n <span class=\"hr-avatar\" style=\"background:{TEAM_COLORS[props.row.team] ?? '#475569'}\">\n {initials(props.row)}\n </span>\n <span class=\"hr-person-text\">\n <span class=\"hr-name\">{props.row.firstName} {props.row.lastName}</span>\n <span class=\"hr-id\">{props.row.id}</span>\n </span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"hr-badge hr-status-{props.value.replace(/\\s+/g, '').toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.length === 0 ? 'hr-gbtn-on' : ''}\"\n >None</button>\n <button\n onclick={() => applyGroup(['team'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'team' ? 'hr-gbtn-on' : ''}\"\n >Team</button>\n <button\n onclick={() => applyGroup(['location'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'location' ? 'hr-gbtn-on' : ''}\"\n >Location</button>\n <button\n onclick={() => applyGroup(['status'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'status' ? 'hr-gbtn-on' : ''}\"\n >Status</button>\n <span class=\"ml-auto hr-meta\">{rows.length} employees</span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n width: 240,\n },\n { field: 'title', header: 'Title', editorType: 'text', width: 200 },\n { field: 'level', header: 'Level', editorType: 'text', width: 80 },\n { field: 'team', header: 'Team', editorType: 'text', width: 130 },\n { field: 'manager', header: 'Manager', editorType: 'text', width: 180 },\n { field: 'location', header: 'Location', editorType: 'text', width: 180 },\n {\n field: 'startDate', header: 'Start date', editorType: 'date', width: 120,\n format: { type: 'date', pattern: 'y-m-d' },\n },\n {\n id: 'tenure', header: 'Tenure', editorType: 'number',\n fieldFn: (row) => tenureYears(row),\n width: 100,\n cell: (ctx) => `${(ctx.getValue() as number).toFixed(1)}y`,\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 120,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n {\n field: 'salary', header: 'Salary', editorType: 'number', width: 120,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'bonusPct', header: 'Bonus %', editorType: 'number', width: 100,\n cell: (ctx) => `${ctx.row.original.bonusPct}%`,\n },\n ] satisfies ColumnDef<typeof features, Person>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showRowNumbers={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={44}\n containerHeight=\"100%\"\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n // Expand the first group on mount so the demo lands on data,\n // not on a row of collapsed group headers.\n queueMicrotask(() => next.setRowExpanded(`team:${TEAMS[0]}`, true))\n }}\n />\n </div>\n</section>\n\n<style>\n /* Toolbar chrome follows the active grid theme via --sg-* tokens. */\n .hr-gbtn {\n border-color: var(--sg-border, #cbd5e1);\n background: var(--sg-bg, transparent);\n color: var(--sg-fg, inherit);\n }\n .hr-gbtn:not(.hr-gbtn-on):hover { background: var(--sg-row-hover-bg, transparent); }\n .hr-gbtn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }\n .hr-meta { color: var(--sg-muted, #64748b); }\n\n .hr-person {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n }\n .hr-avatar {\n flex: 0 0 auto;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n color: #fff;\n font-size: 11px;\n font-weight: 700;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n letter-spacing: 0.5px;\n }\n .hr-person-text {\n display: inline-flex;\n flex-direction: column;\n line-height: 1.15;\n min-width: 0;\n }\n .hr-name { font-weight: 600; }\n .hr-id { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .hr-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.4;\n }\n .hr-status-active { background: #dcfce7; color: #166534; }\n .hr-status-onleave { background: #fef3c7; color: #92400e; }\n .hr-status-contractor { background: #e0e7ff; color: #4338ca; }\n :global([data-theme='dark']) .hr-status-active { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .hr-status-onleave { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .hr-status-contractor { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }\n</style>\n"
|
|
264
|
+
"source": "<script lang=\"ts\">\n /**\n * 12. HR team directory\n * ---------------------\n * A directory of ~80 employees with the columns an HR app actually needs:\n * person (avatar + name), title, level, team, manager, location, start\n * date, tenure, status, comp.\n *\n * Showcases:\n * - `columnGroupingFeature` with a default group-by (\"team\")\n * - `renderSnippet` for the avatar cell and status badge\n * - A derived `tenure` column (no `field` - `fieldFn`)\n * - Sort by any column; group rows fold to a summary line\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type Status = 'Active' | 'On leave' | 'Contractor'\n type Person = {\n id: string\n firstName: string\n lastName: string\n title: string\n level: string\n team: string\n manager: string\n location: string\n startDate: string // ISO date\n status: Status\n salary: number\n bonusPct: number\n }\n\n // ------------------------------------------------------------------ data\n const TEAMS = [\n 'Engineering', 'Design', 'Product', 'Data', 'Marketing',\n 'Sales', 'Support', 'Operations', 'Finance', 'People',\n ] as const\n type Team = typeof TEAMS[number]\n\n const TITLES: Record<Team, string[]> = {\n Engineering: ['Software Engineer', 'Senior Engineer', 'Staff Engineer', 'Engineering Manager', 'Director of Engineering'],\n Design: ['Product Designer', 'Senior Designer', 'Design Lead', 'Director of Design'],\n Product: ['Associate PM', 'Product Manager', 'Senior PM', 'Group PM', 'Director of Product'],\n Data: ['Data Analyst', 'Data Scientist', 'ML Engineer', 'Analytics Manager'],\n Marketing: ['Marketing Specialist', 'Growth Manager', 'Brand Lead', 'CMO'],\n Sales: ['SDR', 'AE', 'Senior AE', 'Sales Manager', 'VP Sales'],\n Support: ['Support Engineer', 'Senior Support', 'Support Lead'],\n Operations: ['Ops Analyst', 'Operations Manager', 'COO'],\n Finance: ['Accountant', 'Finance Analyst', 'Controller', 'CFO'],\n People: ['HR Generalist', 'Recruiter', 'People Partner', 'CHRO'],\n }\n\n const LEVELS = ['IC2', 'IC3', 'IC4', 'IC5', 'IC6', 'M2', 'M3', 'M4', 'M5']\n const LOCATIONS = [\n 'San Francisco, US', 'New York, US', 'Austin, US', 'London, UK', 'Berlin, DE',\n 'Amsterdam, NL', 'Toronto, CA', 'Sydney, AU', 'Tokyo, JP', 'Singapore, SG',\n 'Remote - EMEA', 'Remote - Americas',\n ]\n const FIRST_NAMES = [\n 'Aiden', 'Bea', 'Cara', 'Dev', 'Elena', 'Felix', 'Grace', 'Hannah', 'Ivan',\n 'Jules', 'Kai', 'Lena', 'Marco', 'Nina', 'Omar', 'Priya', 'Quinn', 'Rosa',\n 'Sven', 'Tess', 'Uma', 'Victor', 'Wren', 'Xio', 'Yael', 'Zane', 'Anika',\n 'Beck', 'Cyrus', 'Dara', 'Eitan', 'Fiona', 'Gus', 'Henri', 'Ines', 'Jonas',\n 'Kira', 'Luca', 'Mara', 'Nico', 'Olin', 'Pia', 'Quentin', 'Reyna', 'Sami',\n ]\n const LAST_NAMES = [\n 'Ortega', 'Nakamura', 'Schmidt', 'Hassan', 'Petrov', 'Yamada', 'Hughes',\n 'Marchetti', 'Anand', 'Lefevre', 'Olsen', 'Carmona', 'Tan', 'Beck',\n 'Mustafa', 'Andersson', 'Vogel', 'Romero', 'Kowalski', 'Mendoza',\n 'Sokolova', 'Voss', 'Holm', 'Park', 'Khoury', 'Bauer', 'Russo',\n ]\n\n // Seeded PRNG so the demo is reproducible across reloads.\n let prngState = 0x9E3779B1\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n function range(lo: number, hi: number): number { return lo + Math.floor(rand() * (hi - lo + 1)) }\n\n function isoDate(daysAgo: number): string {\n const ms = Date.now() - daysAgo * 86_400_000\n return new Date(ms).toISOString().slice(0, 10)\n }\n\n function makePeople(): Person[] {\n const out: Person[] = []\n // 1. Pick one manager per team first so other rows can reference them.\n const managerByTeam: Record<string, string> = {}\n for (const team of TEAMS) {\n const first = pick(FIRST_NAMES)\n const last = pick(LAST_NAMES)\n const titles = TITLES[team]\n const title = titles[titles.length - 1]! // most senior title for the team's head\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: first,\n lastName: last,\n title,\n level: pick(['M3', 'M4', 'M5']),\n team,\n manager: 'Sasha Lin (CEO)',\n location: pick(LOCATIONS),\n startDate: isoDate(range(1500, 4200)),\n status: 'Active',\n salary: range(180, 360) * 1000,\n bonusPct: range(15, 30),\n })\n managerByTeam[team] = `${first} ${last}`\n }\n // 2. Fill out 6–10 ICs per team reporting to that team's head.\n for (const team of TEAMS) {\n const headcount = range(6, 10)\n const icTitles = TITLES[team].slice(0, -1) // drop the manager title\n for (let i = 0; i < headcount; i += 1) {\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: pick(FIRST_NAMES),\n lastName: pick(LAST_NAMES),\n title: pick(icTitles),\n level: pick(LEVELS.slice(0, 6)), // IC-only levels\n team,\n manager: managerByTeam[team]!,\n location: pick(LOCATIONS),\n startDate: isoDate(range(60, 2400)),\n status: pick<Status>(['Active', 'Active', 'Active', 'Active', 'On leave', 'Contractor']),\n salary: range(85, 220) * 1000,\n bonusPct: range(5, 18),\n })\n }\n }\n return out\n }\n\n const rows = makePeople()\n\n // Tenure in years, derived from startDate; used by an `fieldFn` column.\n function tenureYears(p: Person): number {\n const start = new Date(p.startDate).getTime()\n const now = Date.now()\n return Math.round(((now - start) / (365.25 * 86_400_000)) * 10) / 10\n }\n\n function initials(p: Person): string {\n return `${p.firstName[0] ?? ''}${p.lastName[0] ?? ''}`.toUpperCase()\n }\n\n // Stable avatar tint per team - keeps the directory feeling like one company\n // not a rainbow soup.\n const TEAM_COLORS: Record<string, string> = {\n Engineering: '#2563eb', Design: '#db2777', Product: '#7c3aed',\n Data: '#0891b2', Marketing: '#db2516', Sales: '#16a34a',\n Support: '#ca8a04', Operations: '#9333ea', Finance: '#0d9488',\n People: '#e11d48',\n }\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['team'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n{#snippet PersonCell(props: { row: Person })}\n <span class=\"hr-person\">\n <span class=\"hr-avatar\" style=\"background:{TEAM_COLORS[props.row.team] ?? '#475569'}\">\n {initials(props.row)}\n </span>\n <span class=\"hr-person-text\">\n <span class=\"hr-name\">{props.row.firstName} {props.row.lastName}</span>\n <span class=\"hr-id\">{props.row.id}</span>\n </span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"hr-badge hr-status-{props.value.replace(/\\s+/g, '').toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.length === 0 ? 'hr-gbtn-on' : ''}\"\n >None</button>\n <button\n onclick={() => applyGroup(['team'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'team' ? 'hr-gbtn-on' : ''}\"\n >Team</button>\n <button\n onclick={() => applyGroup(['location'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'location' ? 'hr-gbtn-on' : ''}\"\n >Location</button>\n <button\n onclick={() => applyGroup(['status'])}\n class=\"rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'status' ? 'hr-gbtn-on' : ''}\"\n >Status</button>\n <span class=\"ml-auto hr-meta\">{rows.length} employees</span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n width: 240,\n },\n { field: 'title', header: 'Title', editorType: 'text', width: 200 },\n { field: 'level', header: 'Level', editorType: 'text', width: 80 },\n { field: 'team', header: 'Team', editorType: 'text', width: 130 },\n { field: 'manager', header: 'Manager', editorType: 'text', width: 180 },\n { field: 'location', header: 'Location', editorType: 'text', width: 180 },\n {\n field: 'startDate', header: 'Start date', editorType: 'date', width: 120,\n format: { type: 'date', pattern: 'y-m-d' },\n },\n {\n id: 'tenure', header: 'Tenure', editorType: 'number',\n fieldFn: (row) => tenureYears(row),\n width: 100,\n cell: (ctx) => `${(ctx.getValue() as number).toFixed(1)}y`,\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 120,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n {\n field: 'salary', header: 'Salary', editorType: 'number', width: 120,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'bonusPct', header: 'Bonus %', editorType: 'number', width: 100,\n cell: (ctx) => `${ctx.row.original.bonusPct}%`,\n },\n ] satisfies ColumnDef<typeof features, Person>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showRowNumbers={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={44}\n containerHeight=\"100%\"\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n // Expand the first group on mount so the demo lands on data,\n // not on a row of collapsed group headers.\n queueMicrotask(() => next.setRowExpanded(`group_team_${TEAMS[0]}`, true))\n }}\n />\n </div>\n</section>\n\n<style>\n /* Toolbar chrome follows the active grid theme via --sg-* tokens. */\n .hr-gbtn {\n border-color: var(--sg-border, #cbd5e1);\n background: var(--sg-bg, transparent);\n color: var(--sg-fg, inherit);\n }\n .hr-gbtn:not(.hr-gbtn-on):hover { background: var(--sg-row-hover-bg, transparent); }\n .hr-gbtn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }\n .hr-meta { color: var(--sg-muted, #64748b); }\n\n .hr-person {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n }\n .hr-avatar {\n flex: 0 0 auto;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n color: #fff;\n font-size: 11px;\n font-weight: 700;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n letter-spacing: 0.5px;\n }\n .hr-person-text {\n display: inline-flex;\n flex-direction: column;\n line-height: 1.15;\n min-width: 0;\n }\n .hr-name { font-weight: 600; }\n .hr-id { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .hr-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.4;\n }\n .hr-status-active { background: #dcfce7; color: #166534; }\n .hr-status-onleave { background: #fef3c7; color: #92400e; }\n .hr-status-contractor { background: #e0e7ff; color: #4338ca; }\n :global([data-theme='dark']) .hr-status-active { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .hr-status-onleave { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .hr-status-contractor { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }\n</style>\n"
|
|
265
265
|
},
|
|
266
266
|
{
|
|
267
267
|
"id": "120-test-systems-monitor",
|
|
@@ -357,7 +357,7 @@ export const examples = [
|
|
|
357
357
|
"title": "Industrial",
|
|
358
358
|
"category": "Real-time & Streaming",
|
|
359
359
|
"blurb": "14. Industrial - IoT sensor floor A factory-floor sensor dashboard: ~120 readings across four production lines, ticking every 700 ms. Each row carries threshold bands",
|
|
360
|
-
"source": "<script lang=\"ts\">\n /**\n * 14. Industrial - IoT sensor floor\n * ---------------------------------\n * A factory-floor sensor dashboard: ~120 readings across four production\n * lines, ticking every 700 ms. Each row carries threshold bands\n * (critical-low / warn-low / warn-high / critical-high) and the status\n * column is computed from the live reading against those bands. The Trend\n * column is an inline SVG sparkline of the last 24 readings.\n *\n * Showcases:\n * - Live updates with `$state.raw` swap-the-array pattern\n * - Threshold-driven status badges\n * - SVG sparklines as a custom cell\n * - Group by line / sensor type / status\n * - Row summaries (sensor count per group)\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type SensorType = 'Temperature' | 'Pressure' | 'Vibration' | 'Flow' | 'RPM' | 'Current' | 'Humidity'\n type Status = 'Normal' | 'Warning' | 'Critical'\n\n type Sensor = {\n id: string\n type: SensorType\n line: string\n location: string\n reading: number\n unit: string\n setpoint: number\n criticalLow: number\n warnLow: number\n warnHigh: number\n criticalHigh: number\n status: Status\n lastUpdate: string\n history: number[]\n }\n\n const LINES = ['Line A', 'Line B', 'Line C', 'Line D']\n const LOCATIONS_PER_LINE = ['Boiler', 'Press', 'Conveyor', 'Mixer', 'Furnace', 'Pump', 'Compressor', 'Reactor']\n\n type SensorSpec = {\n type: SensorType\n unit: string\n setpoint: number\n band: number // ±warn band around setpoint\n critBand: number // ±critical band (must be ≥ band)\n noise: number // random-walk step size\n }\n const SENSOR_SPECS: SensorSpec[] = [\n { type: 'Temperature', unit: '°C', setpoint: 180, band: 18, critBand: 32, noise: 1.4 },\n { type: 'Pressure', unit: 'bar', setpoint: 12, band: 1.5, critBand: 3.0, noise: 0.18 },\n { type: 'Vibration', unit: 'mm/s',setpoint: 3, band: 1.2, critBand: 2.8, noise: 0.12 },\n { type: 'Flow', unit: 'L/s', setpoint: 45, band: 6, critBand: 12, noise: 0.6 },\n { type: 'RPM', unit: 'rpm', setpoint: 1450, band: 80, critBand: 180, noise: 8 },\n { type: 'Current', unit: 'A', setpoint: 22, band: 3, critBand: 6, noise: 0.25 },\n { type: 'Humidity', unit: '%RH', setpoint: 48, band: 8, critBand: 14, noise: 0.8 },\n ]\n\n // --- seeded PRNG so the demo's initial state is reproducible.\n let prngState = 0xC0FFEE99\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n\n function statusFor(reading: number, sensor: Pick<Sensor, 'criticalLow' | 'warnLow' | 'warnHigh' | 'criticalHigh'>): Status {\n if (reading <= sensor.criticalLow || reading >= sensor.criticalHigh) return 'Critical'\n if (reading <= sensor.warnLow || reading >= sensor.warnHigh) return 'Warning'\n return 'Normal'\n }\n\n function round(n: number, places: number): number {\n const p = 10 ** places\n return Math.round(n * p) / p\n }\n\n function makeSensors(): Sensor[] {\n const out: Sensor[] = []\n const now = new Date()\n for (const line of LINES) {\n for (let i = 0; i < 30; i += 1) {\n const spec = pick(SENSOR_SPECS)\n const loc = `${pick(LOCATIONS_PER_LINE)} ${1 + Math.floor(rand() * 9)}`\n // Bias initial reading to be near setpoint, sometimes nudged into warn.\n const offset = (rand() - 0.5) * spec.band * 1.4\n const reading = round(spec.setpoint + offset, 2)\n const sensor: Sensor = {\n id: `${line.replace(/[^A-Z]/g, '')}-${(i + 1).toString().padStart(3, '0')}`,\n type: spec.type,\n line,\n location: loc,\n reading,\n unit: spec.unit,\n setpoint: spec.setpoint,\n criticalLow: round(spec.setpoint - spec.critBand, 2),\n warnLow: round(spec.setpoint - spec.band, 2),\n warnHigh: round(spec.setpoint + spec.band, 2),\n criticalHigh: round(spec.setpoint + spec.critBand, 2),\n status: 'Normal',\n lastUpdate: now.toISOString().slice(11, 19),\n // Seed history with 24 nearby values.\n history: Array.from({ length: 24 }, () =>\n round(spec.setpoint + (rand() - 0.5) * spec.band * 0.9, 2)\n ),\n }\n sensor.status = statusFor(sensor.reading, sensor)\n out.push(sensor)\n }\n }\n return out\n }\n\n function specFor(sensor: Sensor): SensorSpec {\n return SENSOR_SPECS.find((s) => s.type === sensor.type)!\n }\n\n let sensors = $state.raw<Sensor[]>(makeSensors())\n let paused = $state(false)\n let tickIntervalMs = $state(700)\n\n function tick() {\n const now = new Date().toISOString().slice(11, 19)\n sensors = sensors.map((s) => {\n // Update only ~60% of sensors per tick - keeps the dashboard alive\n // without every row flashing in unison.\n if (rand() < 0.4) return s\n const spec = specFor(s)\n // Mean-reverting random walk toward setpoint. ~5% of ticks inject a\n // bigger spike so the status badges actually transition.\n const drift = (s.setpoint - s.reading) * 0.04\n const spike = rand() < 0.05 ? (rand() < 0.5 ? -1 : 1) * spec.band * 0.9 : 0\n const reading = round(s.reading + drift + spike + (rand() - 0.5) * spec.noise, 2)\n const history = s.history.length >= 24 ? [...s.history.slice(1), reading] : [...s.history, reading]\n return {\n ...s,\n reading,\n status: statusFor(reading, s),\n lastUpdate: now,\n history,\n }\n })\n }\n\n $effect(() => {\n if (paused) return\n const id = setInterval(tick, tickIntervalMs)\n return () => clearInterval(id)\n })\n\n // ----- summary counts for the toolbar\n const counts = $derived.by(() => {\n let normal = 0, warning = 0, critical = 0\n for (const s of sensors) {\n if (s.status === 'Normal') normal += 1\n else if (s.status === 'Warning') warning += 1\n else critical += 1\n }\n return { normal, warning, critical }\n })\n\n // ----- grouping controls\n let api = $state<SvGridApi<typeof features, Sensor> | null>(null)\n let groupBy = $state<string[]>(['line'])\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n // ----- sparkline path builder\n function sparkPath(values: number[], width: number, height: number): string {\n if (!values.length) return ''\n let min = Infinity, max = -Infinity\n for (const v of values) { if (v < min) min = v; if (v > max) max = v }\n const range = max - min || 1\n const step = width / Math.max(values.length - 1, 1)\n let d = ''\n for (let i = 0; i < values.length; i += 1) {\n const x = i * step\n const y = height - ((values[i]! - min) / range) * height\n d += i === 0 ? `M${x.toFixed(1)},${y.toFixed(1)}` : ` L${x.toFixed(1)},${y.toFixed(1)}`\n }\n return d\n }\n</script>\n\n{#snippet ReadingCell(props: { sensor: Sensor })}\n <span class=\"iot-reading iot-reading-{props.sensor.status.toLowerCase()}\">\n {props.sensor.reading.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 })}\n <span class=\"iot-unit\">{props.sensor.unit}</span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"iot-badge iot-badge-{props.value.toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n{#snippet SetpointCell(props: { sensor: Sensor })}\n <span class=\"iot-setpoint\">\n {props.sensor.setpoint}{props.sensor.unit}\n <span class=\"iot-band\">\n ±{(props.sensor.warnHigh - props.sensor.setpoint).toFixed(1)}\n </span>\n </span>\n{/snippet}\n\n{#snippet SparklineCell(props: { sensor: Sensor })}\n {@const stroke = props.sensor.status === 'Critical' ? '#dc2626' : props.sensor.status === 'Warning' ? '#ca8a04' : '#2563eb'}\n <svg viewBox=\"0 0 100 24\" preserveAspectRatio=\"none\" class=\"iot-spark\" aria-hidden=\"true\">\n <path d={sparkPath(props.sensor.history, 100, 24)} fill=\"none\" stroke={stroke} stroke-width=\"1.4\" />\n </svg>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (paused = !paused)}\n class=\"iot-btn rounded px-3 py-1\"\n >\n {paused ? '▶ Resume' : '⏸ Pause'}\n </button>\n <label class=\"iot-label flex items-center gap-2\">\n Tick:\n <select\n bind:value={tickIntervalMs}\n class=\"iot-select rounded px-2 py-1\"\n >\n <option value={250}>250 ms</option>\n <option value={500}>500 ms</option>\n <option value={700}>700 ms</option>\n <option value={1500}>1.5 s</option>\n </select>\n </label>\n\n <span class=\"ml-3 font-medium\">Group:</span>\n <button onclick={() => applyGroup([])} class=\"iot-btn rounded px-3 py-1 {groupBy.length === 0 ? 'iot-btn-on' : ''}\">None</button>\n <button onclick={() => applyGroup(['line'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'line' ? 'iot-btn-on' : ''}\">Line</button>\n <button onclick={() => applyGroup(['type'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'type' ? 'iot-btn-on' : ''}\">Type</button>\n <button onclick={() => applyGroup(['status'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'status' ? 'iot-btn-on' : ''}\">Status</button>\n\n <span class=\"ml-auto inline-flex items-center gap-3 tabular-nums\">\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-normal\"></span>{counts.normal}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-warning\"></span>{counts.warning}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-critical\"></span>{counts.critical}</span>\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={sensors}\n columns={[\n { field: 'id', header: 'ID', editorType: 'text', width: 110 },\n { field: 'line', header: 'Line', editorType: 'text', width: 110 },\n { field: 'type', header: 'Type', editorType: 'text', width: 130 },\n { field: 'location', header: 'Location', editorType: 'text', width: 140 },\n {\n field: 'reading', header: 'Reading', editorType: 'number', width: 130,\n cell: (ctx) => renderSnippet(ReadingCell, { sensor: ctx.row.original }),\n },\n {\n id: 'setpoint', header: 'Setpoint', editorType: 'number', width: 130,\n fieldFn: (row) => row.setpoint,\n cell: (ctx) => renderSnippet(SetpointCell, { sensor: ctx.row.original }),\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 110,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n { field: 'lastUpdate', header: 'Updated', editorType: 'text', width: 110 },\n {\n id: 'trend', header: 'Trend (24)', fieldFn: () => '', width: 140,\n cell: (ctx) => renderSnippet(SparklineCell, { sensor: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Sensor>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n // Expand the first line so the demo opens with rows visible.\n queueMicrotask(() => next.setRowExpanded(`line:${LINES[0]}`, true))\n }}\n />\n </div>\n</section>\n\n<style>\n /* Toolbar chrome follows the active grid theme; the status colors below\n stay fixed because they carry meaning, not styling. */\n .iot-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, transparent);\n color: var(--sg-fg, inherit);\n }\n .iot-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .iot-btn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }\n .iot-label { color: var(--sg-muted, #475569); }\n .iot-select {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-input-bg, var(--sg-bg, transparent));\n color: var(--sg-fg, inherit);\n }\n\n .iot-reading {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n .iot-unit {\n margin-left: 4px;\n font-weight: 400;\n color: var(--sg-muted, #94a3b8);\n font-size: 11px;\n }\n .iot-reading-normal { color: var(--sg-fg, inherit); }\n .iot-reading-warning { color: #b45309; }\n .iot-reading-critical { color: #b91c1c; }\n :global([data-theme='dark']) .iot-reading-warning { color: #fbbf24; }\n :global([data-theme='dark']) .iot-reading-critical { color: #f87171; }\n\n .iot-setpoint { font-variant-numeric: tabular-nums; }\n .iot-band { margin-left: 6px; color: var(--sg-muted, #94a3b8); font-size: 11px; }\n\n .iot-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n }\n .iot-badge-normal { background: #dcfce7; color: #166534; }\n .iot-badge-warning { background: #fef3c7; color: #92400e; }\n .iot-badge-critical { background: #fee2e2; color: #b91c1c; }\n :global([data-theme='dark']) .iot-badge-normal { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .iot-badge-warning { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .iot-badge-critical { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n\n .iot-dot {\n width: 8px; height: 8px; border-radius: 50%; display: inline-block;\n }\n .iot-dot-normal { background: #16a34a; }\n .iot-dot-warning { background: #ca8a04; }\n .iot-dot-critical { background: #dc2626; }\n\n .iot-spark {\n width: 100%;\n height: 22px;\n display: block;\n }\n</style>\n"
|
|
360
|
+
"source": "<script lang=\"ts\">\n /**\n * 14. Industrial - IoT sensor floor\n * ---------------------------------\n * A factory-floor sensor dashboard: ~120 readings across four production\n * lines, ticking every 700 ms. Each row carries threshold bands\n * (critical-low / warn-low / warn-high / critical-high) and the status\n * column is computed from the live reading against those bands. The Trend\n * column is an inline SVG sparkline of the last 24 readings.\n *\n * Showcases:\n * - Live updates with `$state.raw` swap-the-array pattern\n * - Threshold-driven status badges\n * - SVG sparklines as a custom cell\n * - Group by line / sensor type / status\n * - Row summaries (sensor count per group)\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type SensorType = 'Temperature' | 'Pressure' | 'Vibration' | 'Flow' | 'RPM' | 'Current' | 'Humidity'\n type Status = 'Normal' | 'Warning' | 'Critical'\n\n type Sensor = {\n id: string\n type: SensorType\n line: string\n location: string\n reading: number\n unit: string\n setpoint: number\n criticalLow: number\n warnLow: number\n warnHigh: number\n criticalHigh: number\n status: Status\n lastUpdate: string\n history: number[]\n }\n\n const LINES = ['Line A', 'Line B', 'Line C', 'Line D']\n const LOCATIONS_PER_LINE = ['Boiler', 'Press', 'Conveyor', 'Mixer', 'Furnace', 'Pump', 'Compressor', 'Reactor']\n\n type SensorSpec = {\n type: SensorType\n unit: string\n setpoint: number\n band: number // ±warn band around setpoint\n critBand: number // ±critical band (must be ≥ band)\n noise: number // random-walk step size\n }\n const SENSOR_SPECS: SensorSpec[] = [\n { type: 'Temperature', unit: '°C', setpoint: 180, band: 18, critBand: 32, noise: 1.4 },\n { type: 'Pressure', unit: 'bar', setpoint: 12, band: 1.5, critBand: 3.0, noise: 0.18 },\n { type: 'Vibration', unit: 'mm/s',setpoint: 3, band: 1.2, critBand: 2.8, noise: 0.12 },\n { type: 'Flow', unit: 'L/s', setpoint: 45, band: 6, critBand: 12, noise: 0.6 },\n { type: 'RPM', unit: 'rpm', setpoint: 1450, band: 80, critBand: 180, noise: 8 },\n { type: 'Current', unit: 'A', setpoint: 22, band: 3, critBand: 6, noise: 0.25 },\n { type: 'Humidity', unit: '%RH', setpoint: 48, band: 8, critBand: 14, noise: 0.8 },\n ]\n\n // --- seeded PRNG so the demo's initial state is reproducible.\n let prngState = 0xC0FFEE99\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n\n function statusFor(reading: number, sensor: Pick<Sensor, 'criticalLow' | 'warnLow' | 'warnHigh' | 'criticalHigh'>): Status {\n if (reading <= sensor.criticalLow || reading >= sensor.criticalHigh) return 'Critical'\n if (reading <= sensor.warnLow || reading >= sensor.warnHigh) return 'Warning'\n return 'Normal'\n }\n\n function round(n: number, places: number): number {\n const p = 10 ** places\n return Math.round(n * p) / p\n }\n\n function makeSensors(): Sensor[] {\n const out: Sensor[] = []\n const now = new Date()\n for (const line of LINES) {\n for (let i = 0; i < 30; i += 1) {\n const spec = pick(SENSOR_SPECS)\n const loc = `${pick(LOCATIONS_PER_LINE)} ${1 + Math.floor(rand() * 9)}`\n // Bias initial reading to be near setpoint, sometimes nudged into warn.\n const offset = (rand() - 0.5) * spec.band * 1.4\n const reading = round(spec.setpoint + offset, 2)\n const sensor: Sensor = {\n id: `${line.replace(/[^A-Z]/g, '')}-${(i + 1).toString().padStart(3, '0')}`,\n type: spec.type,\n line,\n location: loc,\n reading,\n unit: spec.unit,\n setpoint: spec.setpoint,\n criticalLow: round(spec.setpoint - spec.critBand, 2),\n warnLow: round(spec.setpoint - spec.band, 2),\n warnHigh: round(spec.setpoint + spec.band, 2),\n criticalHigh: round(spec.setpoint + spec.critBand, 2),\n status: 'Normal',\n lastUpdate: now.toISOString().slice(11, 19),\n // Seed history with 24 nearby values.\n history: Array.from({ length: 24 }, () =>\n round(spec.setpoint + (rand() - 0.5) * spec.band * 0.9, 2)\n ),\n }\n sensor.status = statusFor(sensor.reading, sensor)\n out.push(sensor)\n }\n }\n return out\n }\n\n function specFor(sensor: Sensor): SensorSpec {\n return SENSOR_SPECS.find((s) => s.type === sensor.type)!\n }\n\n let sensors = $state.raw<Sensor[]>(makeSensors())\n let paused = $state(false)\n let tickIntervalMs = $state(700)\n\n function tick() {\n const now = new Date().toISOString().slice(11, 19)\n sensors = sensors.map((s) => {\n // Update only ~60% of sensors per tick - keeps the dashboard alive\n // without every row flashing in unison.\n if (rand() < 0.4) return s\n const spec = specFor(s)\n // Mean-reverting random walk toward setpoint. ~5% of ticks inject a\n // bigger spike so the status badges actually transition.\n const drift = (s.setpoint - s.reading) * 0.04\n const spike = rand() < 0.05 ? (rand() < 0.5 ? -1 : 1) * spec.band * 0.9 : 0\n const reading = round(s.reading + drift + spike + (rand() - 0.5) * spec.noise, 2)\n const history = s.history.length >= 24 ? [...s.history.slice(1), reading] : [...s.history, reading]\n return {\n ...s,\n reading,\n status: statusFor(reading, s),\n lastUpdate: now,\n history,\n }\n })\n }\n\n $effect(() => {\n if (paused) return\n const id = setInterval(tick, tickIntervalMs)\n return () => clearInterval(id)\n })\n\n // ----- summary counts for the toolbar\n const counts = $derived.by(() => {\n let normal = 0, warning = 0, critical = 0\n for (const s of sensors) {\n if (s.status === 'Normal') normal += 1\n else if (s.status === 'Warning') warning += 1\n else critical += 1\n }\n return { normal, warning, critical }\n })\n\n // ----- grouping controls\n let api = $state<SvGridApi<typeof features, Sensor> | null>(null)\n let groupBy = $state<string[]>(['line'])\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n // ----- sparkline path builder\n function sparkPath(values: number[], width: number, height: number): string {\n if (!values.length) return ''\n let min = Infinity, max = -Infinity\n for (const v of values) { if (v < min) min = v; if (v > max) max = v }\n const range = max - min || 1\n const step = width / Math.max(values.length - 1, 1)\n let d = ''\n for (let i = 0; i < values.length; i += 1) {\n const x = i * step\n const y = height - ((values[i]! - min) / range) * height\n d += i === 0 ? `M${x.toFixed(1)},${y.toFixed(1)}` : ` L${x.toFixed(1)},${y.toFixed(1)}`\n }\n return d\n }\n</script>\n\n{#snippet ReadingCell(props: { sensor: Sensor })}\n <span class=\"iot-reading iot-reading-{props.sensor.status.toLowerCase()}\">\n {props.sensor.reading.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 })}\n <span class=\"iot-unit\">{props.sensor.unit}</span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"iot-badge iot-badge-{props.value.toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n{#snippet SetpointCell(props: { sensor: Sensor })}\n <span class=\"iot-setpoint\">\n {props.sensor.setpoint}{props.sensor.unit}\n <span class=\"iot-band\">\n ±{(props.sensor.warnHigh - props.sensor.setpoint).toFixed(1)}\n </span>\n </span>\n{/snippet}\n\n{#snippet SparklineCell(props: { sensor: Sensor })}\n {@const stroke = props.sensor.status === 'Critical' ? '#dc2626' : props.sensor.status === 'Warning' ? '#ca8a04' : '#2563eb'}\n <svg viewBox=\"0 0 100 24\" preserveAspectRatio=\"none\" class=\"iot-spark\" aria-hidden=\"true\">\n <path d={sparkPath(props.sensor.history, 100, 24)} fill=\"none\" stroke={stroke} stroke-width=\"1.4\" />\n </svg>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (paused = !paused)}\n class=\"iot-btn rounded px-3 py-1\"\n >\n {paused ? '▶ Resume' : '⏸ Pause'}\n </button>\n <label class=\"iot-label flex items-center gap-2\">\n Tick:\n <select\n bind:value={tickIntervalMs}\n class=\"iot-select rounded px-2 py-1\"\n >\n <option value={250}>250 ms</option>\n <option value={500}>500 ms</option>\n <option value={700}>700 ms</option>\n <option value={1500}>1.5 s</option>\n </select>\n </label>\n\n <span class=\"ml-3 font-medium\">Group:</span>\n <button onclick={() => applyGroup([])} class=\"iot-btn rounded px-3 py-1 {groupBy.length === 0 ? 'iot-btn-on' : ''}\">None</button>\n <button onclick={() => applyGroup(['line'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'line' ? 'iot-btn-on' : ''}\">Line</button>\n <button onclick={() => applyGroup(['type'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'type' ? 'iot-btn-on' : ''}\">Type</button>\n <button onclick={() => applyGroup(['status'])} class=\"iot-btn rounded px-3 py-1 {groupBy.join() === 'status' ? 'iot-btn-on' : ''}\">Status</button>\n\n <span class=\"ml-auto inline-flex items-center gap-3 tabular-nums\">\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-normal\"></span>{counts.normal}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-warning\"></span>{counts.warning}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-critical\"></span>{counts.critical}</span>\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={sensors}\n columns={[\n { field: 'id', header: 'ID', editorType: 'text', width: 110 },\n { field: 'line', header: 'Line', editorType: 'text', width: 110 },\n { field: 'type', header: 'Type', editorType: 'text', width: 130 },\n { field: 'location', header: 'Location', editorType: 'text', width: 140 },\n {\n field: 'reading', header: 'Reading', editorType: 'number', width: 130,\n cell: (ctx) => renderSnippet(ReadingCell, { sensor: ctx.row.original }),\n },\n {\n id: 'setpoint', header: 'Setpoint', editorType: 'number', width: 130,\n fieldFn: (row) => row.setpoint,\n cell: (ctx) => renderSnippet(SetpointCell, { sensor: ctx.row.original }),\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 110,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n { field: 'lastUpdate', header: 'Updated', editorType: 'text', width: 110 },\n {\n id: 'trend', header: 'Trend (24)', fieldFn: () => '', width: 140,\n cell: (ctx) => renderSnippet(SparklineCell, { sensor: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Sensor>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n // Expand the first line so the demo opens with rows visible.\n queueMicrotask(() => next.setRowExpanded(`group_line_${LINES[0]}`, true))\n }}\n />\n </div>\n</section>\n\n<style>\n /* Toolbar chrome follows the active grid theme; the status colors below\n stay fixed because they carry meaning, not styling. */\n .iot-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, transparent);\n color: var(--sg-fg, inherit);\n }\n .iot-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .iot-btn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }\n .iot-label { color: var(--sg-muted, #475569); }\n .iot-select {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-input-bg, var(--sg-bg, transparent));\n color: var(--sg-fg, inherit);\n }\n\n .iot-reading {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n .iot-unit {\n margin-left: 4px;\n font-weight: 400;\n color: var(--sg-muted, #94a3b8);\n font-size: 11px;\n }\n .iot-reading-normal { color: var(--sg-fg, inherit); }\n .iot-reading-warning { color: #b45309; }\n .iot-reading-critical { color: #b91c1c; }\n :global([data-theme='dark']) .iot-reading-warning { color: #fbbf24; }\n :global([data-theme='dark']) .iot-reading-critical { color: #f87171; }\n\n .iot-setpoint { font-variant-numeric: tabular-nums; }\n .iot-band { margin-left: 6px; color: var(--sg-muted, #94a3b8); font-size: 11px; }\n\n .iot-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n }\n .iot-badge-normal { background: #dcfce7; color: #166534; }\n .iot-badge-warning { background: #fef3c7; color: #92400e; }\n .iot-badge-critical { background: #fee2e2; color: #b91c1c; }\n :global([data-theme='dark']) .iot-badge-normal { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .iot-badge-warning { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .iot-badge-critical { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n\n .iot-dot {\n width: 8px; height: 8px; border-radius: 50%; display: inline-block;\n }\n .iot-dot-normal { background: #16a34a; }\n .iot-dot-warning { background: #ca8a04; }\n .iot-dot-critical { background: #dc2626; }\n\n .iot-spark {\n width: 100%;\n height: 22px;\n display: block;\n }\n</style>\n"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"id": "140-sparkline-cells",
|
|
@@ -3888,7 +3888,7 @@ export const docs = [
|
|
|
3888
3888
|
"path": "docs/enterprise/studio/supabase.md",
|
|
3889
3889
|
"title": "Supabase",
|
|
3890
3890
|
"section": "Studio",
|
|
3891
|
-
"markdown": "# Supabase\n\n[Supabase](https://supabase.com) gives you a hosted PostgreSQL database with a\ngenerous free tier and a browser-safe client. This guide takes you from an empty\nSupabase project to a live CRUD screen, step by step. No backend to run.\n\n\n\n> **Try it first:** the [Supabase demo](https://svgrid.com/demos/194-studio-supabase/)\n> is exactly this - paste your project URL + anon key and it runs against your\n> real database in the browser.\n\n> **Want it all in one page?** The\n> **[Supabase CRUD grid tutorial](./supabase-grid.md)** is the complete,\n> copy-paste walkthrough - the table, the schema, the data source, and the full\n> grid page with create / edit / delete, start to finish. This page is the deeper\n> reference behind it.\n\n**All the Supabase pages, one map.** This guide is the canonical entry point -\nconnect, keys, and Row-Level Security. From here:\n\n- [Supabase CRUD grid](./supabase-grid.md) - the copy-paste one-page tutorial\n- [Supabase Northwind sample](./supabase-sample.md) - a relational practice dataset to load and explore\n- [Real-time](./realtime.md) - live grid over Supabase change streams\n- [Auth & secured screens](./auth.md) - Supabase Auth + RLS per signed-in user\n- [Databases](./databases.md#supabase) - the server-side connection-string route instead\n\nThere are two ways to connect, and you can use either:\n\n| Path | When | Runs in |\n| --- | --- | --- |\n| **A. `supabase-js` client** | SPAs, and when you want Row-Level Security + auth enforced per user | The browser |\n| **B. SvelteKit server route** | You already use SvelteKit `+server.ts` and want SQL on the server | Node / edge server |\n\nPath A is what the demo uses and what most people want. Start there.\n\n---\n\n## Step 1 - Create a project\n\n1. Sign in at [supabase.com](https://supabase.com) and click **New project**.\n2. Give it a name and a database password, pick a region, and wait ~2 minutes\n for it to provision.\n\n## Step 2 - Create and seed the table\n\nOpen **SQL Editor** in the Supabase dashboard, paste this, and click **Run**. It\ncreates a `customers` table and adds a few rows.\n\n```sql\ncreate table if not exists customers (\n id bigint generated always as identity primary key,\n name text not null,\n email text not null,\n tier text,\n mrr integer,\n active boolean default true\n);\n\ninsert into customers (name, email, tier, mrr, active) values\n ('Ada Lovelace', 'ada@analytic.io', 'enterprise', 1200, true),\n ('Alan Turing', 'alan@bletchley.uk', 'pro', 240, true),\n ('Grace Hopper', 'grace@navy.mil', 'enterprise', 980, true),\n ('Edsger Dijkstra', 'edsger@shortest.path', 'free', 0, false);\n```\n\n## Step 3 - Open access with Row-Level Security\n\nSupabase blocks all access by default until you add a **policy**. Because the\nanon key is public (it ships in the browser), RLS is what actually protects your\ndata - so you always keep it on and grant exactly what you intend.\n\nFor a public demo, allow the anon role to read and write:\n\n```sql\nalter table customers enable row level security;\n\ncreate policy \"svgrid demo access\" on customers\n for all to anon using (true) with check (true);\n```\n\n> **For a real app**, do not grant blanket access to `anon`. Grant to\n> `authenticated` and scope rows to the signed-in user, e.g.\n> `using (auth.uid() = user_id)`. See the\n> [Supabase RLS docs](https://supabase.com/docs/guides/database/postgres/row-level-security).\n\n## Step 4 - Get your keys\n\nIn the dashboard: **Project Settings -> API**. Copy two values:\n\n- **Project URL** - `https://xxxxxxxxxxxx.supabase.co`\n- **anon public** key - a long token starting with `eyJ...`\n\nThe anon key is designed to be shipped in the browser. Never ship the\n**service_role** key to a client - it bypasses RLS.\n\n---\n\n## In the designer (no code)\n\nThe visual designer wraps all of the below. Bind an entity to **Supabase**, open\nits **Configure** builder, and:\n\n- Paste the **Project URL** and **anon public** key once - they are stored as a\n **shared project connection**, so every Supabase entity reuses them (you only\n set them one time).\n- Click **List tables** to read the tables from your project, pick one, and\n **Import schema** to pull its real columns, primary key, foreign-key relations,\n and enums into the entity.\n- **Preview** loads live rows onto the canvas. This works in the **online**\n designer too, because Supabase is reached over HTTP.\n\n**Generate app** emits `createClient` in `connections.ts` reading\n`PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\ninlined into source), plus a `.env.example`. Tick **Live updates (Realtime)** to\nalso emit a live subscription ([Real-time](./realtime.md)), and use the\n[Supabase Auth provider](./auth.md) to gate the app.\n\n---\n\n## Path A - Connect from the browser (`supabase-js`)\n\nThis is a SvelteKit page (or any Svelte SPA). Install the client:\n\n```bash\nnpm i @supabase/supabase-js\n```\n\nPut your keys in `.env` (Vite exposes `PUBLIC_`-prefixed vars to the browser):\n\n```bash\nPUBLIC_SUPABASE_URL=\"https://xxxxxxxxxxxx.supabase.co\"\nPUBLIC_SUPABASE_ANON_KEY=\"eyJhbGciOiJI...\"\n```\n\nBuild the data source with **`createSupabaseDataSource`** - it maps the grid's\nsort, filter, paging, and CRUD onto the Supabase query builder for you. You bring\nthe client (so RLS + auth apply per request); it's the whole data layer:\n\n```ts\n// src/lib/customers.source.ts\nimport { createClient } from '@supabase/supabase-js'\nimport { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'\nimport { createSupabaseDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type Customer } from '$lib/customers'\n\nconst client = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)\n\nexport const customersSource = createSupabaseDataSource<Customer>({\n client,\n table: 'customers',\n schema: customersSchema,\n // searchColumns: ['name', 'email'], // optional; defaults to text + enum fields\n})\n```\n\nThen wire it exactly like the in-memory source from\n[Getting started](./getting-started.md) - the page code is identical:\n\n```ts\nconst controller = createServerDataSource(customersSource, {\n pageSize: 10, optimistic: true, getRowId: (r) => String(r.id),\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\nThat is the entire integration. Sorting, filtering, paging, and CRUD now run\nagainst hosted Postgres, with RLS enforced on every request.\n\n> **Don't know the columns ahead of time?** `introspectSupabaseTable({ url, key,\n> table })` reads them from PostgREST and returns an `EntitySchema` you can feed\n> to `schemaToColumns` and `createSupabaseDataSource` - that's how the\n> [live demo](https://svgrid.com/demos/194-studio-supabase/) adapts to any table.\n> It also reads **foreign keys**, so a FK column comes back as a `relation` field\n> and the form gets a searchable picker automatically - see [Relations](./relations.md).\n\n> **Want it live?** Add [`createSupabaseRealtime`](./realtime.md) to refetch and\n> flash rows whenever the database changes - no polling.\n\n---\n\n## Path B - SvelteKit server route (connection string)\n\nPrefer to keep SQL on the server? Supabase is plain Postgres, so use the `pg`\ndriver with the **connection pooler** string (Dashboard -> Project Settings ->\nDatabase -> Connection string, port `6543`). The Studio CLI writes the whole\nroute for you:\n\n```bash\nnpm i pg\nexport DATABASE_URL=\"postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres\"\nnpx @svgrid/studio add customers --db supabase --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\nThis generates the schema, a `+server.ts` API route backed by\n[`createSqlDataSource`](./databases.md), and the page. Keys stay on the server;\nthe browser only talks to your own route. See [The Studio CLI](./cli.md) and\n[Databases](./databases.md) for the full picture.\n\n---\n\n## Troubleshooting\n\n| Symptom | Cause and fix |\n| --- | --- |\n| **On connect it shows no data and only offers to \"add columns\"** - even though the table has columns and rows | The demo infers your columns *from the rows it fetches*, so an RLS-blocked read means it sees neither rows nor columns and falls back to manual entry. Add the `select` policy from Step 3, then reconnect. Confirm it's RLS by running `select * from your_table;` in the SQL Editor (that bypasses RLS): if it returns rows but the demo shows none, RLS is the cause. |\n| **Grid is empty, no error** | RLS is on but no policy grants `select`. Add the policy in Step 3, or scope one to your users. |\n| **`new row violates row-level security`** on create | Your policy allows `select` but not `insert`. Use `for all` (as above) or add a `with check` policy for writes. |\n| **\"Could not reach Supabase\"** | Wrong Project URL, or a network / CORS issue. Confirm the URL is the `https://xxxx.supabase.co` form, not the database host. |\n| **401 / invalid key** | You pasted the wrong key. Use the **anon public** key from Settings -> API, not the service_role or the JWT secret. |\n| **Numbers arrive as strings** | Postgres `numeric` comes back as text over PostgREST. Use `integer` / `bigint` for numeric columns, or cast in a view. |\n\nMore answers in [Troubleshooting & FAQ](./troubleshooting.md).\n\n---\n\n## See also\n\n- [Supabase demo](https://svgrid.com/demos/194-studio-supabase/) - this guide, live\n- [Databases](./databases.md) - the SQL sources and dialects\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract\n- [Getting started](./getting-started.md) - the page code, end to end\n"
|
|
3891
|
+
"markdown": "# Supabase\n\n[Supabase](https://supabase.com) gives you a hosted PostgreSQL database with a\ngenerous free tier and a browser-safe client. This guide takes you from an empty\nSupabase project to a live CRUD screen, step by step. No backend to run.\n\n\n\n> **Try it first:** the [Supabase demo](https://svgrid.com/demos/194-studio-supabase/)\n> is exactly this - paste your project URL + anon key and it runs against your\n> real database in the browser.\n\n> **Want it all in one page?** The\n> **[Supabase CRUD grid tutorial](./supabase-grid.md)** is the complete,\n> copy-paste walkthrough - the table, the schema, the data source, and the full\n> grid page with create / edit / delete, start to finish. This page is the deeper\n> reference behind it.\n\n**Or let Studio write the whole app.** One command reads the tables you name\nand writes a SvelteKit 3 app with list and edit screens for each one (see the\n[Studio CLI](./cli.md)):\n\n```bash\nnpx @svgrid/studio init --kit 3 --supabase-url <url> --supabase-key <anon key>\n```\n\nThe short below runs it against the [Northwind sample](./supabase-sample.md).\n\n<!-- tutorial:mk-studio-supabase-friday -->\n<figure class=\"docs-tutorial docs-tutorial-player docs-tutorial-portrait\" id=\"tutorial-mk-studio-supabase-friday\" data-docs-tutorial=\"mk-studio-supabase-friday\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-studio-supabase-friday.mp4\" poster=\"/tutorials/mk-studio-supabase-friday.poster.webp\" width=\"540\" height=\"960\" controls playsinline preload=\"none\" aria-label=\"Friday, 5:47 PM: a Supabase admin panel by Monday, 47 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-studio-supabase-friday.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-studio-supabase-friday.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Friday, 5:47 PM: a Supabase admin panel by Monday</strong> (47 s, with narration). <a href=\"https://www.youtube.com/watch?v=OsR_dvtlbbM\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-studio-supabase-friday -->\n\n**All the Supabase pages, one map.** This guide is the canonical entry point -\nconnect, keys, and Row-Level Security. From here:\n\n- [Supabase CRUD grid](./supabase-grid.md) - the copy-paste one-page tutorial\n- [Supabase Northwind sample](./supabase-sample.md) - a relational practice dataset to load and explore\n- [Real-time](./realtime.md) - live grid over Supabase change streams\n- [Auth & secured screens](./auth.md) - Supabase Auth + RLS per signed-in user\n- [Databases](./databases.md#supabase) - the server-side connection-string route instead\n\nThere are two ways to connect, and you can use either:\n\n| Path | When | Runs in |\n| --- | --- | --- |\n| **A. `supabase-js` client** | SPAs, and when you want Row-Level Security + auth enforced per user | The browser |\n| **B. SvelteKit server route** | You already use SvelteKit `+server.ts` and want SQL on the server | Node / edge server |\n\nPath A is what the demo uses and what most people want. Start there.\n\n---\n\n## Step 1 - Create a project\n\n1. Sign in at [supabase.com](https://supabase.com) and click **New project**.\n2. Give it a name and a database password, pick a region, and wait ~2 minutes\n for it to provision.\n\n## Step 2 - Create and seed the table\n\nOpen **SQL Editor** in the Supabase dashboard, paste this, and click **Run**. It\ncreates a `customers` table and adds a few rows.\n\n```sql\ncreate table if not exists customers (\n id bigint generated always as identity primary key,\n name text not null,\n email text not null,\n tier text,\n mrr integer,\n active boolean default true\n);\n\ninsert into customers (name, email, tier, mrr, active) values\n ('Ada Lovelace', 'ada@analytic.io', 'enterprise', 1200, true),\n ('Alan Turing', 'alan@bletchley.uk', 'pro', 240, true),\n ('Grace Hopper', 'grace@navy.mil', 'enterprise', 980, true),\n ('Edsger Dijkstra', 'edsger@shortest.path', 'free', 0, false);\n```\n\n## Step 3 - Open access with Row-Level Security\n\nSupabase blocks all access by default until you add a **policy**. Because the\nanon key is public (it ships in the browser), RLS is what actually protects your\ndata - so you always keep it on and grant exactly what you intend.\n\nFor a public demo, allow the anon role to read and write:\n\n```sql\nalter table customers enable row level security;\n\ncreate policy \"svgrid demo access\" on customers\n for all to anon using (true) with check (true);\n```\n\n> **For a real app**, do not grant blanket access to `anon`. Grant to\n> `authenticated` and scope rows to the signed-in user, e.g.\n> `using (auth.uid() = user_id)`. See the\n> [Supabase RLS docs](https://supabase.com/docs/guides/database/postgres/row-level-security).\n\n## Step 4 - Get your keys\n\nIn the dashboard: **Project Settings -> API**. Copy two values:\n\n- **Project URL** - `https://xxxxxxxxxxxx.supabase.co`\n- **anon public** key - a long token starting with `eyJ...`\n\nThe anon key is designed to be shipped in the browser. Never ship the\n**service_role** key to a client - it bypasses RLS.\n\n---\n\n## In the designer (no code)\n\nThe visual designer wraps all of the below. Bind an entity to **Supabase**, open\nits **Configure** builder, and:\n\n- Paste the **Project URL** and **anon public** key once - they are stored as a\n **shared project connection**, so every Supabase entity reuses them (you only\n set them one time).\n- Click **List tables** to read the tables from your project, pick one, and\n **Import schema** to pull its real columns, primary key, foreign-key relations,\n and enums into the entity.\n- **Preview** loads live rows onto the canvas. This works in the **online**\n designer too, because Supabase is reached over HTTP.\n\n**Generate app** emits `createClient` in `connections.ts` reading\n`PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\ninlined into source), plus a `.env.example`. Tick **Live updates (Realtime)** to\nalso emit a live subscription ([Real-time](./realtime.md)), and use the\n[Supabase Auth provider](./auth.md) to gate the app.\n\n---\n\n## Path A - Connect from the browser (`supabase-js`)\n\nThis is a SvelteKit page (or any Svelte SPA). Install the client:\n\n```bash\nnpm i @supabase/supabase-js\n```\n\nPut your keys in `.env` (Vite exposes `PUBLIC_`-prefixed vars to the browser):\n\n```bash\nPUBLIC_SUPABASE_URL=\"https://xxxxxxxxxxxx.supabase.co\"\nPUBLIC_SUPABASE_ANON_KEY=\"eyJhbGciOiJI...\"\n```\n\nBuild the data source with **`createSupabaseDataSource`** - it maps the grid's\nsort, filter, paging, and CRUD onto the Supabase query builder for you. You bring\nthe client (so RLS + auth apply per request); it's the whole data layer:\n\n```ts\n// src/lib/customers.source.ts\nimport { createClient } from '@supabase/supabase-js'\nimport { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'\nimport { createSupabaseDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type Customer } from '$lib/customers'\n\nconst client = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)\n\nexport const customersSource = createSupabaseDataSource<Customer>({\n client,\n table: 'customers',\n schema: customersSchema,\n // searchColumns: ['name', 'email'], // optional; defaults to text + enum fields\n})\n```\n\nThen wire it exactly like the in-memory source from\n[Getting started](./getting-started.md) - the page code is identical:\n\n```ts\nconst controller = createServerDataSource(customersSource, {\n pageSize: 10, optimistic: true, getRowId: (r) => String(r.id),\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\nThat is the entire integration. Sorting, filtering, paging, and CRUD now run\nagainst hosted Postgres, with RLS enforced on every request.\n\n> **Don't know the columns ahead of time?** `introspectSupabaseTable({ url, key,\n> table })` reads them from PostgREST and returns an `EntitySchema` you can feed\n> to `schemaToColumns` and `createSupabaseDataSource` - that's how the\n> [live demo](https://svgrid.com/demos/194-studio-supabase/) adapts to any table.\n> It also reads **foreign keys**, so a FK column comes back as a `relation` field\n> and the form gets a searchable picker automatically - see [Relations](./relations.md).\n\n> **Want it live?** Add [`createSupabaseRealtime`](./realtime.md) to refetch and\n> flash rows whenever the database changes - no polling.\n\n---\n\n## Path B - SvelteKit server route (connection string)\n\nPrefer to keep SQL on the server? Supabase is plain Postgres, so use the `pg`\ndriver with the **connection pooler** string (Dashboard -> Project Settings ->\nDatabase -> Connection string, port `6543`). The Studio CLI writes the whole\nroute for you:\n\n```bash\nnpm i pg\nexport DATABASE_URL=\"postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres\"\nnpx @svgrid/studio add customers --db supabase --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\nThis generates the schema, a `+server.ts` API route backed by\n[`createSqlDataSource`](./databases.md), and the page. Keys stay on the server;\nthe browser only talks to your own route. See [The Studio CLI](./cli.md) and\n[Databases](./databases.md) for the full picture.\n\n---\n\n## Troubleshooting\n\n| Symptom | Cause and fix |\n| --- | --- |\n| **On connect it shows no data and only offers to \"add columns\"** - even though the table has columns and rows | The demo infers your columns *from the rows it fetches*, so an RLS-blocked read means it sees neither rows nor columns and falls back to manual entry. Add the `select` policy from Step 3, then reconnect. Confirm it's RLS by running `select * from your_table;` in the SQL Editor (that bypasses RLS): if it returns rows but the demo shows none, RLS is the cause. |\n| **Grid is empty, no error** | RLS is on but no policy grants `select`. Add the policy in Step 3, or scope one to your users. |\n| **`new row violates row-level security`** on create | Your policy allows `select` but not `insert`. Use `for all` (as above) or add a `with check` policy for writes. |\n| **\"Could not reach Supabase\"** | Wrong Project URL, or a network / CORS issue. Confirm the URL is the `https://xxxx.supabase.co` form, not the database host. |\n| **401 / invalid key** | You pasted the wrong key. Use the **anon public** key from Settings -> API, not the service_role or the JWT secret. |\n| **Numbers arrive as strings** | Postgres `numeric` comes back as text over PostgREST. Use `integer` / `bigint` for numeric columns, or cast in a view. |\n\nMore answers in [Troubleshooting & FAQ](./troubleshooting.md).\n\n---\n\n## See also\n\n- [Supabase demo](https://svgrid.com/demos/194-studio-supabase/) - this guide, live\n- [Databases](./databases.md) - the SQL sources and dialects\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract\n- [Getting started](./getting-started.md) - the page code, end to end\n"
|
|
3892
3892
|
},
|
|
3893
3893
|
{
|
|
3894
3894
|
"slug": "enterprise/studio/testing",
|
|
@@ -6408,7 +6408,7 @@ export const docs = [
|
|
|
6408
6408
|
"path": "docs/reference/auto/index.md",
|
|
6409
6409
|
"title": "Auto-generated reference",
|
|
6410
6410
|
"section": "API reference",
|
|
6411
|
-
"markdown": "# Auto-generated reference\n\n_Generated from the published TypeScript sources. Do not edit by hand - rerun `node tools/build-reference.mjs`._\n\nFor the hand-curated reference (with defaults + behaviour tables),\nsee [the main reference index](../index.md).\n\n## @svgrid/grid\n\n- [`@svgrid/grid/core.ts`](./svgrid-grid-core.md) -
|
|
6411
|
+
"markdown": "# Auto-generated reference\n\n_Generated from the published TypeScript sources. Do not edit by hand - rerun `node tools/build-reference.mjs`._\n\nFor the hand-curated reference (with defaults + behaviour tables),\nsee [the main reference index](../index.md).\n\n## @svgrid/grid\n\n- [`@svgrid/grid/core.ts`](./svgrid-grid-core.md) - 68 exports\n- [`@svgrid/grid/svgrid-wrapper.types.ts`](./svgrid-grid-svgrid-wrapper.types.md) - 10 exports\n- [`@svgrid/grid/ai.ts`](./svgrid-grid-ai.md) - 33 exports\n- [`@svgrid/grid/scheduler-model.ts`](./svgrid-grid-scheduler-model.md) - 30 exports\n- [`@svgrid/grid/dock-model.ts`](./svgrid-grid-dock-model.md) - 25 exports\n- [`@svgrid/grid/dock-manager-model.ts`](./svgrid-grid-dock-manager-model.md) - 32 exports\n- [`@svgrid/grid/chart.ts`](./svgrid-grid-chart.md) - 19 exports\n- [`@svgrid/grid/chart-types.ts`](./svgrid-grid-chart-types.md) - 61 exports\n- [`@svgrid/grid/chart-scale.ts`](./svgrid-grid-chart-scale.md) - 7 exports\n- [`@svgrid/grid/chart-format.ts`](./svgrid-grid-chart-format.md) - 1 exports\n- [`@svgrid/grid/chart-stats.ts`](./svgrid-grid-chart-stats.md) - 17 exports\n- [`@svgrid/grid/chart-trend.ts`](./svgrid-grid-chart-trend.md) - 2 exports\n- [`@svgrid/grid/chart-axes.ts`](./svgrid-grid-chart-axes.md) - 1 exports\n- [`@svgrid/grid/chart-cartesian.ts`](./svgrid-grid-chart-cartesian.md) - 5 exports\n- [`@svgrid/grid/chart-polar.ts`](./svgrid-grid-chart-polar.md) - 1 exports\n- [`@svgrid/grid/chart-hierarchy.ts`](./svgrid-grid-chart-hierarchy.md) - 2 exports\n- [`@svgrid/grid/chart-decimate.ts`](./svgrid-grid-chart-decimate.md) - 6 exports\n- [`@svgrid/grid/chart-financial.ts`](./svgrid-grid-chart-financial.md) - 8 exports\n- [`@svgrid/grid/chart-zoom.ts`](./svgrid-grid-chart-zoom.md) - 6 exports\n- [`@svgrid/grid/chart-motion.ts`](./svgrid-grid-chart-motion.md) - 1 exports\n- [`@svgrid/grid/chart-sync.svelte.ts`](./svgrid-grid-chart-sync.svelte.md) - 4 exports\n- [`@svgrid/grid/chart-indicators.ts`](./svgrid-grid-chart-indicators.md) - 14 exports\n- [`@svgrid/grid/chart-export-pdf.ts`](./svgrid-grid-chart-export-pdf.md) - 3 exports\n- [`@svgrid/grid/chart-samples.ts`](./svgrid-grid-chart-samples.md) - 3 exports\n- [`@svgrid/grid/SvGridChart.types.ts`](./svgrid-grid-SvGridChart.types.md) - 10 exports\n- [`@svgrid/grid/createTree.svelte.ts`](./svgrid-grid-createTree.svelte.md) - 12 exports\n- [`@svgrid/grid/editor-contract.ts`](./svgrid-grid-editor-contract.md) - 11 exports\n- [`@svgrid/grid/editor-registry.ts`](./svgrid-grid-editor-registry.md) - 9 exports\n- [`@svgrid/grid/list-option.ts`](./svgrid-grid-list-option.md) - 14 exports\n- [`@svgrid/grid/builtin-editors.ts`](./svgrid-grid-builtin-editors.md) - 1 exports\n- [`@svgrid/grid/advanced-filter.svelte.ts`](./svgrid-grid-advanced-filter.svelte.md) - 6 exports\n- [`@svgrid/grid/filtering/filter-operator-catalogue.ts`](./svgrid-grid-filter-operator-catalogue.md) - 8 exports\n- [`@svgrid/grid/filtering/row-predicate.ts`](./svgrid-grid-row-predicate.md) - 1 exports\n- [`@svgrid/grid/grid-messages.ts`](./svgrid-grid-grid-messages.md) - 4 exports\n- [`@svgrid/grid/chart-messages.ts`](./svgrid-grid-chart-messages.md) - 4 exports\n- [`@svgrid/grid/chart-panel-messages.ts`](./svgrid-grid-chart-panel-messages.md) - 4 exports\n- [`@svgrid/grid/chart-validate.ts`](./svgrid-grid-chart-validate.md) - 7 exports\n- [`@svgrid/grid/chart-summary.ts`](./svgrid-grid-chart-summary.md) - 2 exports\n- [`@svgrid/grid/chart-stream.ts`](./svgrid-grid-chart-stream.md) - 3 exports\n- [`@svgrid/grid/chart-pivot.ts`](./svgrid-grid-chart-pivot.md) - 6 exports\n- [`@svgrid/grid/chart-table.ts`](./svgrid-grid-chart-table.md) - 3 exports\n- [`@svgrid/grid/grid-icons.ts`](./svgrid-grid-grid-icons.md) - 4 exports\n- [`@svgrid/grid/cell-formatting.ts`](./svgrid-grid-cell-formatting.md) - 5 exports\n- [`@svgrid/grid/positioning.ts`](./svgrid-grid-positioning.md) - 10 exports\n- [`@svgrid/grid/toast-store.svelte.ts`](./svgrid-grid-toast-store.svelte.md) - 14 exports\n- [`@svgrid/grid/spreadsheet.ts`](./svgrid-grid-spreadsheet.md) - 7 exports\n- [`@svgrid/grid/chart-export.ts`](./svgrid-grid-chart-export.md) - 7 exports\n- [`@svgrid/grid/scheduler-ical.ts`](./svgrid-grid-scheduler-ical.md) - 5 exports\n- [`@svgrid/grid/duration.ts`](./svgrid-grid-duration.md) - 2 exports\n- [`@svgrid/grid/datetime/date-core.ts`](./svgrid-grid-date-core.md) - 22 exports\n- [`@svgrid/grid/datetime/timezone.ts`](./svgrid-grid-timezone.md) - 8 exports\n- [`@svgrid/grid/datetime/mask.ts`](./svgrid-grid-mask.md) - 3 exports\n- [`@svgrid/grid/datetime/date-format.ts`](./svgrid-grid-date-format.md) - 3 exports\n- [`@svgrid/grid/datetime/date-restrict.ts`](./svgrid-grid-date-restrict.md) - 5 exports\n- [`@svgrid/grid/server-data-source.ts`](./svgrid-grid-server-data-source.md) - 21 exports\n- [`@svgrid/grid/server-block-cache.ts`](./svgrid-grid-server-block-cache.md) - 8 exports\n- [`@svgrid/grid/row-model.ts`](./svgrid-grid-row-model.md) - 4 exports\n\n## @svgrid/enterprise\n\n- [`@svgrid/enterprise/export.ts`](./svgrid-enterprise-export.md) - 17 exports\n- [`@svgrid/enterprise/pivot.ts`](./svgrid-enterprise-pivot.md) - 10 exports\n- [`@svgrid/enterprise/pivot-chart.ts`](./svgrid-enterprise-pivot-chart.md) - 2 exports\n- [`@svgrid/enterprise/import.ts`](./svgrid-enterprise-import.md) - 13 exports\n- [`@svgrid/enterprise/install.ts`](./svgrid-enterprise-install.md) - 4 exports\n- [`@svgrid/enterprise/server/server-row-model.ts`](./svgrid-enterprise-server-row-model.md) - 19 exports\n- [`@svgrid/enterprise/server/server-selection.ts`](./svgrid-enterprise-server-selection.md) - 6 exports\n- [`@svgrid/enterprise/server/server-pivot.ts`](./svgrid-enterprise-server-pivot.md) - 2 exports\n- [`@svgrid/enterprise/server/svgrid-adapter.ts`](./svgrid-enterprise-svgrid-adapter.md) - 16 exports\n"
|
|
6412
6412
|
},
|
|
6413
6413
|
{
|
|
6414
6414
|
"slug": "reference/auto/svgrid-enterprise-export",
|
|
@@ -6429,7 +6429,7 @@ export const docs = [
|
|
|
6429
6429
|
"path": "docs/reference/auto/svgrid-enterprise-install.md",
|
|
6430
6430
|
"title": "@svgrid/enterprise · install.ts",
|
|
6431
6431
|
"section": "API reference",
|
|
6432
|
-
"markdown": "# `@svgrid/enterprise` · `install.ts`\n\nAuto-generated. Source: `packages\\enterprise\\src\\install.ts`.\n\n### `type EnterpriseAIApi`\n\nThe `api.ai.*` namespace added by the enterprise install.\n\n```ts\nexport type EnterpriseAIApi<TData extends RowData> = {\n /** Natural-language -> filter + sort plan (and optionally apply it). */\n filter(query: string, opts?: AIFilterOptions): Promise<AIFilterResult>\n /** Propose values for empty cells based on a few worked examples. */\n smartFill<TValue = unknown>(opts: AISmartFillOptions): Promise<AISmartFillResult<TValue>>\n /** One-paragraph + bullets summary of a row, selection, group, or the whole view. */\n summarize(opts: AISummarizeOptions): Promise<AISummary>\n /** Classify free-text cells into one of a known set of labels. */\n classify(opts: AIClassifyOptions): Promise<AIClassifyResult>\n /** Natural-language export: \"export EU orders from Q2 as a grouped PDF\". */\n export(query: string, opts?: AIExportOptions): Promise<AIExportPlan>\n /** Scan a slice for anomalies / outliers, returning a structured list. */\n findAnomalies(opts?: AIAnomalyOptions): Promise<AIAnomalyResult>\n /** Natural-language chart: \"revenue by region, stacked by product\". */\n chart(query: string, opts?: AIChartOptions): Promise<AIChartPlan>\n // TData is referenced so the type stays bound to the row shape even\n // though the helpers all read through `api.getData()`. Lets callers\n // get correct inference downstream without explicit generics.\n readonly _rowType?: TData\n}\n```\n\n### `type EnterprisePivotApi`\n\nThe `api.pivot.*` namespace added by the enterprise install.\n\n```ts\nexport type EnterprisePivotApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n /**\n * Build a pivot row/column model from the grid's current data. Returns\n * `{ rows, columns }` you can feed to a separate `<SvGrid>` instance\n * (the typical pattern - the source grid stays unmodified). Pure: no\n * mutation, no DOM access.\n */\n build(config: PivotConfig<TData>): PivotResult<TFeatures>\n /**\n * Build a pivot from an arbitrary array (not necessarily the grid's\n * data). Useful for previewing a designer's config against a sample\n * before committing.\n */\n buildFrom<TIn extends RowData>(\n data: ReadonlyArray<TIn>,\n config: PivotConfig<TIn>,\n ): PivotResult<TFeatures>\n}\n```\n\n### `type EnterpriseGridApi`\n\nThe grid API plus everything enterprise adds - what `installEnterprise` hands back.\n\n```ts\nexport type EnterpriseGridApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = SvGridApi<TFeatures, TData> & {\n /** Export the current visible rows to the chosen format. Returns the built\n * file ({@link ExportResult}) for the dependency-free paths (undefined for\n * the vendored-writer xlsx paths, which download directly). */\n exportData(opts: ExportOptions<TData>): Promise<ExportResult | undefined>\n /** Copy the grid (view / selection / all) to the clipboard in a native text\n * format (default `tsv`, pastes into Excel / Sheets as columns). */\n copyExport(opts?: ClipboardExportOptions<TData>): Promise<void>\n /** Open a printable view of the current visible rows in a new window. */\n print(opts?: PrintOptions<TData>): Promise<void>\n /** Read an Excel / CSV / TSV / JSON file (or inline text) into typed\n * rows. Caller decides whether to commit into the grid or preview\n * the parsed result first via the returned `ImportResult`. */\n importData(opts: ImportOptions<TData>): Promise<ImportResult<TData>>\n /** AI helpers (Pro). All calls route through a consumer-registered\n * AIProvider via setAIProvider(); no model client is bundled. */\n ai: EnterpriseAIApi<TData>\n /** Pivot table builder (Pro). Pure - returns `{ rows, columns }` you\n * hand to a separate `<SvGrid>` instance. */\n pivot: EnterprisePivotApi<TFeatures, TData>\n}\n```\n\n### `function installEnterprise`\n\nAugment a SvGridApi instance with Pro methods (`exportData`, `print`,\n`ai.*`). Mutates and returns the same object so existing references\nkeep working.\n\nIf no license key has been set, the Pro methods still function but the\ngrid shows a small \"unlicensed\" watermark linking to jqwidgets.com and\nthe console emits a one-time nudge. Revoked or malformed keys throw on\nfirst call.\n\n```ts\nexport function installEnterprise<\n TFeatures extends TableFeatures,\n TData extends RowData,\n>(api: SvGridApi<TFeatures, TData>): EnterpriseGridApi<TFeatures, TData> {\n const pro = api as EnterpriseGridApi<TFeatures, TData>\n pro.exportData = (opts) => exportGrid(pro, opts)\n pro.copyExport = (opts) => copyExportToClipboard(pro, opts)\n pro.print = (opts) => printGrid(pro, opts)\n pro.importData = (opts) => importData(pro, opts)\n pro.ai = {\n filter: (query, opts) => aiFilter(pro, query, opts),\n smartFill: (opts) => aiSmartFill(pro, opts),\n summarize: (opts) => aiSummarize(pro, opts),\n classify: (opts) => aiClassify(pro, opts),\n export: (query, opts) => aiExport(pro, query, opts),\n findAnomalies: (opts) => aiFindAnomalies(pro, opts),\n chart: (query, opts) => aiChart(pro, query, opts),\n }\n // Wire the built-in chart panel's AI button (no-op without the `charting` prop).\n enableAiCharting(pro)\n // Register the scheduler / calendar view (no-op without the `scheduler` prop).\n enableSchedulerView()\n // Register the Kanban board view (no-op without the `board` prop).\n enableBoardView()\n // Register the Gantt view (no-op without the `gantt` prop).\n enableGanttView()\n enableSelectionBar()\n // Register the export engine so built-in AI exports (aiExport) can write the\n // enterprise formats (xlsx / pdf / ...); free grids without it get the plan only.\n registerExportProvider(exportGrid as never)\n enablePivot()\n enableAdvancedFilter()\n // Register the Excel keymap (Ctrl+Arrow, Ctrl+D, Ctrl+A and the rest). The\n // grid interprets a key itself only when nothing claims it, so a free grid\n // is unaffected.\n enableSheet()\n // Raise the unlicensed nudge for the server-side row model. No registry to fill -\n // the model hands the grid ordinary rows - so this only nudges when unlicensed.\n enableServerRowModel()\n pro.pivot = {\n build: (config) =>\n createPivotModel<TFeatures, TData>(pro.getData(), config),\n buildFrom: <TIn extends RowData>(data: ReadonlyArray<TIn>, config: PivotConfig<TIn>) =>\n // PivotConfig is variance-strict in TIn; the runtime call only needs\n // the row shape to match config.rows / config.cols field names, which\n // the caller-side generic enforces.\n createPivotModel<TFeatures, TIn>(data, config),\n }\n // Surface the unlicensed state at install time so the watermark appears\n // alongside the grid, not just after the user clicks Export.\n if (!
|
|
6432
|
+
"markdown": "# `@svgrid/enterprise` · `install.ts`\n\nAuto-generated. Source: `packages\\enterprise\\src\\install.ts`.\n\n### `type EnterpriseAIApi`\n\nThe `api.ai.*` namespace added by the enterprise install.\n\n```ts\nexport type EnterpriseAIApi<TData extends RowData> = {\n /** Natural-language -> filter + sort plan (and optionally apply it). */\n filter(query: string, opts?: AIFilterOptions): Promise<AIFilterResult>\n /** Propose values for empty cells based on a few worked examples. */\n smartFill<TValue = unknown>(opts: AISmartFillOptions): Promise<AISmartFillResult<TValue>>\n /** One-paragraph + bullets summary of a row, selection, group, or the whole view. */\n summarize(opts: AISummarizeOptions): Promise<AISummary>\n /** Classify free-text cells into one of a known set of labels. */\n classify(opts: AIClassifyOptions): Promise<AIClassifyResult>\n /** Natural-language export: \"export EU orders from Q2 as a grouped PDF\". */\n export(query: string, opts?: AIExportOptions): Promise<AIExportPlan>\n /** Scan a slice for anomalies / outliers, returning a structured list. */\n findAnomalies(opts?: AIAnomalyOptions): Promise<AIAnomalyResult>\n /** Natural-language chart: \"revenue by region, stacked by product\". */\n chart(query: string, opts?: AIChartOptions): Promise<AIChartPlan>\n // TData is referenced so the type stays bound to the row shape even\n // though the helpers all read through `api.getData()`. Lets callers\n // get correct inference downstream without explicit generics.\n readonly _rowType?: TData\n}\n```\n\n### `type EnterprisePivotApi`\n\nThe `api.pivot.*` namespace added by the enterprise install.\n\n```ts\nexport type EnterprisePivotApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n /**\n * Build a pivot row/column model from the grid's current data. Returns\n * `{ rows, columns }` you can feed to a separate `<SvGrid>` instance\n * (the typical pattern - the source grid stays unmodified). Pure: no\n * mutation, no DOM access.\n */\n build(config: PivotConfig<TData>): PivotResult<TFeatures>\n /**\n * Build a pivot from an arbitrary array (not necessarily the grid's\n * data). Useful for previewing a designer's config against a sample\n * before committing.\n */\n buildFrom<TIn extends RowData>(\n data: ReadonlyArray<TIn>,\n config: PivotConfig<TIn>,\n ): PivotResult<TFeatures>\n}\n```\n\n### `type EnterpriseGridApi`\n\nThe grid API plus everything enterprise adds - what `installEnterprise` hands back.\n\n```ts\nexport type EnterpriseGridApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = SvGridApi<TFeatures, TData> & {\n /** Export the current visible rows to the chosen format. Returns the built\n * file ({@link ExportResult}) for the dependency-free paths (undefined for\n * the vendored-writer xlsx paths, which download directly). */\n exportData(opts: ExportOptions<TData>): Promise<ExportResult | undefined>\n /** Copy the grid (view / selection / all) to the clipboard in a native text\n * format (default `tsv`, pastes into Excel / Sheets as columns). */\n copyExport(opts?: ClipboardExportOptions<TData>): Promise<void>\n /** Open a printable view of the current visible rows in a new window. */\n print(opts?: PrintOptions<TData>): Promise<void>\n /** Read an Excel / CSV / TSV / JSON file (or inline text) into typed\n * rows. Caller decides whether to commit into the grid or preview\n * the parsed result first via the returned `ImportResult`. */\n importData(opts: ImportOptions<TData>): Promise<ImportResult<TData>>\n /** AI helpers (Pro). All calls route through a consumer-registered\n * AIProvider via setAIProvider(); no model client is bundled. */\n ai: EnterpriseAIApi<TData>\n /** Pivot table builder (Pro). Pure - returns `{ rows, columns }` you\n * hand to a separate `<SvGrid>` instance. */\n pivot: EnterprisePivotApi<TFeatures, TData>\n}\n```\n\n### `function installEnterprise`\n\nAugment a SvGridApi instance with Pro methods (`exportData`, `print`,\n`ai.*`). Mutates and returns the same object so existing references\nkeep working.\n\nIf no license key has been set, the Pro methods still function but the\ngrid shows a small \"unlicensed\" watermark linking to jqwidgets.com and\nthe console emits a one-time nudge. Revoked or malformed keys throw on\nfirst call.\n\n```ts\nexport function installEnterprise<\n TFeatures extends TableFeatures,\n TData extends RowData,\n>(api: SvGridApi<TFeatures, TData>): EnterpriseGridApi<TFeatures, TData> {\n const pro = api as EnterpriseGridApi<TFeatures, TData>\n pro.exportData = (opts) => exportGrid(pro, opts)\n pro.copyExport = (opts) => copyExportToClipboard(pro, opts)\n pro.print = (opts) => printGrid(pro, opts)\n pro.importData = (opts) => importData(pro, opts)\n pro.ai = {\n filter: (query, opts) => aiFilter(pro, query, opts),\n smartFill: (opts) => aiSmartFill(pro, opts),\n summarize: (opts) => aiSummarize(pro, opts),\n classify: (opts) => aiClassify(pro, opts),\n export: (query, opts) => aiExport(pro, query, opts),\n findAnomalies: (opts) => aiFindAnomalies(pro, opts),\n chart: (query, opts) => aiChart(pro, query, opts),\n }\n // Wire the built-in chart panel's AI button (no-op without the `charting` prop).\n enableAiCharting(pro)\n // Register the scheduler / calendar view (no-op without the `scheduler` prop).\n enableSchedulerView()\n // Register the Kanban board view (no-op without the `board` prop).\n enableBoardView()\n // Register the Gantt view (no-op without the `gantt` prop).\n enableGanttView()\n enableSelectionBar()\n // Register the export engine so built-in AI exports (aiExport) can write the\n // enterprise formats (xlsx / pdf / ...); free grids without it get the plan only.\n registerExportProvider(exportGrid as never)\n enablePivot()\n enableAdvancedFilter()\n // Register the Excel keymap (Ctrl+Arrow, Ctrl+D, Ctrl+A and the rest). The\n // grid interprets a key itself only when nothing claims it, so a free grid\n // is unaffected.\n enableSheet()\n // Raise the unlicensed nudge for the server-side row model. No registry to fill -\n // the model hands the grid ordinary rows - so this only nudges when unlicensed.\n enableServerRowModel()\n pro.pivot = {\n build: (config) =>\n createPivotModel<TFeatures, TData>(pro.getData(), config),\n buildFrom: <TIn extends RowData>(data: ReadonlyArray<TIn>, config: PivotConfig<TIn>) =>\n // PivotConfig is variance-strict in TIn; the runtime call only needs\n // the row shape to match config.rows / config.cols field names, which\n // the caller-side generic enforces.\n createPivotModel<TFeatures, TIn>(data, config),\n }\n // Surface the unlicensed state at install time so the watermark appears\n // alongside the grid, not just after the user clicks Export.\n if (!hasValidLicense()) emitUnlicensedNudge()\n return pro\n}\n```\n"
|
|
6433
6433
|
},
|
|
6434
6434
|
{
|
|
6435
6435
|
"slug": "reference/auto/svgrid-enterprise-pivot-chart",
|
|
@@ -6457,7 +6457,7 @@ export const docs = [
|
|
|
6457
6457
|
"path": "docs/reference/auto/svgrid-enterprise-server-row-model.md",
|
|
6458
6458
|
"title": "@svgrid/enterprise · server/server-row-model.ts",
|
|
6459
6459
|
"section": "API reference",
|
|
6460
|
-
"markdown": "# `@svgrid/enterprise` · `server/server-row-model.ts`\n\nAuto-generated. Source: `packages\\enterprise\\src\\server\\server-row-model.ts`.\n\n### `type ServerLevelParams`\n\nPer-level cache settings, from `levelParams`.\n\n```ts\nexport type ServerLevelParams = {\n /** Rows per request at this level. Default: the model's `blockSize`. */\n blockSize?: number\n /** Loaded blocks to keep at this level. Default: the model's `maxBlocksInCache`. */\n maxBlocksInCache?: number\n /**\n * `false` loads the whole level as soon as it opens (block by block, still\n * under the concurrency cap) instead of waiting for the viewport. A level\n * that is fully loaded can also be sorted in the browser - see\n * `clientSideSort`.\n */\n infinite?: boolean\n /**\n * Children arrive one block at a time behind a \"Load N more\" row instead\n * of by scroll: the level shows what it has loaded, then a `more` row\n * while the server holds more. `loadMoreChildren(route)` - or the row's\n * own toggle - fetches the next block. Wins over `infinite`.\n */\n loadMore?: boolean\n /**\n * Rows this level claims before its first block lands, as placeholders.\n * Default: the model's `skeletonRows`. Set it to the count you expect at\n * the root of flat data and `api.scrollToRow(900000)` works before\n * anything has loaded: the scrollbar is already the right length, the\n * rows there are placeholders, and the block under them is what loads.\n */\n initialRowCount?: number\n}\n```\n\n### `type ServerRowModelPaginationOptions`\n\nPaging the top level instead of scrolling it. See `ServerRowModelOptions.pagination`.\n\n```ts\nexport type ServerRowModelPaginationOptions = {\n /** Rows per page. Default 100. */\n pageSize?: number\n /** Choices for the footer's page-size selector. */\n pageSizes?: number[]\n /**\n * Fit the page to the grid: as many rows as its body shows without\n * scrolling, re-measured when the grid resizes. `pageSize` is then the\n * size until the first measurement.\n */\n autoPageSize?: boolean\n /**\n * Count every row of the flattened tree towards the page, children\n * included. Off, a page is `pageSize` top-level rows, each shown with\n * whatever is open beneath it.\n */\n paginateChildRows?: boolean\n}\n```\n\n### `type ServerRowModelPagination`\n\nWhere the pager stands.\n\n```ts\nexport type ServerRowModelPagination = {\n pageIndex: number\n pageSize: number\n /** Top-level rows, or flattened rows with `paginateChildRows`. */\n rowCount: number\n pageCount: number\n}\n```\n\n### `type ServerLevelState`\n\nWhere a route's rows stand, for diagnostics.\n\n```ts\nexport type ServerLevelState = {\n route: string[]\n level: number\n rowCount: number | null\n lastRowKnown: boolean\n loading: boolean\n failedBlocks: number[]\n blocks: BlockState[]\n}\n```\n\n### `type ServerRowModelOptions`\n\nEverything `createServerRowModel` is built from: grouping, tree, pivot, blocks per level, totals, paging, selection, transactions and events.\n\n```ts\nexport type ServerRowModelOptions<TData> = {\n /** Columns to group on, outer to inner. Leave empty for a flat list or a tree. */\n groupBy?: string[]\n /** Value columns to roll up per group. */\n aggregations?: ServerAggregation[]\n /**\n * Server-side pivot: the columns whose distinct values become columns.\n * Takes effect with `pivotMode`; see `setPivot`.\n */\n pivotBy?: string[]\n pivotMode?: boolean\n /**\n * In pivot mode, a `Total` header group after the pivot keys with the\n * row's plain aggregates (`amount` beside `2024_amount`, `2025_amount`).\n * The backend puts those fields on every group row and on the grand\n * total in pivot mode; the reference sources do. A string is the label.\n */\n pivotRowTotals?: boolean | string\n /** How a backend joins pivot keys and the value column into a field name. Default `_`. */\n pivotFieldSeparator?: string\n /** Post-process each generated pivot value column. */\n pivotResultColumn?: PivotResultColumnOptions['pivotResultColumn']\n /**\n * Columns to put BEFORE the generated pivot columns in pivot mode -\n * the group column with its expander, typically. The pivot columns\n * replace the grid's `columns` wholesale, so this is where the\n * app's own leading columns come back in.\n */\n pivotLeadingColumns?: ReadonlyArray<unknown>\n /**\n * Self-referential tree: each row is a node whose children load on expand.\n * Needs `getRowId` (the node id, which builds the `groupKeys` path) and\n * `hasChildren`.\n */\n treeData?: boolean\n /** Stable row id. Needed for tree data, selection and transactions. */\n getRowId?: (row: TData) => string\n /** Tree data: whether a node can be expanded. */\n hasChildren?: (row: TData) => boolean\n /**\n * How many rows a group holds, read off its response row. Shown beside the\n * key, and used to size the group's scrollbar before its first block lands.\n */\n childCount?: (row: TData) => number | undefined\n /** Open a group as soon as it arrives. Receives the route and the row. */\n isGroupOpenByDefault?: (route: string[], row: TData) => boolean\n /** Per-level cache settings. Called once per route when its store is created. */\n levelParams?: (level: number, route: string[]) => ServerLevelParams\n /** Rows per request, unless `levelParams` says otherwise. Default 100. */\n blockSize?: number\n /** Loaded blocks to keep per level, unless `levelParams` says otherwise. Unlimited by default. */\n maxBlocksInCache?: number\n /** Requests open at once, across every level. Default 2. */\n maxConcurrentRequests?: number\n /** Wait for the scroll to settle this long before fetching. Default 0. */\n blockLoadDebounceMs?: number\n /**\n * Placeholder rows shown for a level before its first block lands - so an\n * expand never opens onto nothing. Default 3.\n */\n skeletonRows?: number\n /**\n * Page the top level instead of scrolling it. Blocks stay independent of\n * pages: a page of 20 over blocks of 100 costs one request per five\n * pages. Omit for infinite scrolling.\n */\n pagination?: ServerRowModelPaginationOptions\n /** Emit a subtotal \"footer\" row after each expanded group's children. */\n groupFooters?: boolean\n /**\n * A grand-total row across the whole result. The root request then carries\n * `needsGrandTotal` and the source answers with `grandTotal`. `top` /\n * `bottom` put it in the scrolling list; the pinned variants hand it to the\n * grid's pinned rows so it stays in view.\n */\n grandTotalRow?: 'top' | 'bottom' | 'pinnedTop' | 'pinnedBottom'\n /** Drop a group's cached children when it is collapsed. Default false. */\n purgeClosedGroups?: boolean\n /**\n * Hide a group row once it is open, so its children stand at its level.\n * The classic \"multi-column group\" look.\n */\n hideOpenParents?: boolean\n /**\n * Treat a group whose key is empty as unbalanced: always open, its group\n * row hidden, its rows shown among the siblings. Costs one extra request\n * per such group.\n */\n allowUnbalancedGroups?: boolean\n /**\n * Which levels a sort re-fetches. The default is selective: a column that\n * is neither grouped nor aggregated only re-orders leaves; a grouped column\n * re-fetches its own level; an aggregated column re-fetches everything.\n * `true` always re-fetches everything.\n */\n sortAllLevels?: boolean\n /**\n * Sort a fully loaded level in the browser instead of asking the server.\n * Plain type-aware comparison; a backend with collation rules of its own\n * should leave this off.\n */\n clientSideSort?: boolean\n /**\n * Re-fetch only the levels a filter touches instead of everything. Cheaper,\n * but the levels above keep their old counts and aggregates, so a group can\n * end up showing zero children. A filter on an aggregated column always\n * re-fetches everything.\n */\n onlyRefreshFilteredGroups?: boolean\n /**\n * Row selection as a RULE rather than a list of ids, so \"select all\" can\n * mean a million rows the grid never loaded. `self` treats a group row as\n * one row; `descendants` makes ticking a group select everything beneath\n * it. Off by default; the grid then keeps selection itself, over loaded\n * rows only. See `ctl.selection`, `getSelectionState`, `bulkUpdate`.\n */\n selection?: { groupSelects?: ServerSelectionGroupMode }\n /**\n * Apply `updateRow` and `deleteRow` to the loaded rows before the server\n * answers, and put them back if it rejects: the same contract as the\n * free controller's `optimistic`. A create waits for the server either\n * way, since the row has no id until then. Off by default, so a cell\n * shows a new value only once the server has it.\n */\n optimistic?: boolean\n /** Forwarded on every request as `ServerRequest.context`. Keep it serialisable. */\n context?: unknown\n /** Distinct values for a column's set filter, for `<SvGrid rowModel>` to use. */\n filterValues?: (columnId: string) => Promise<Array<string>>\n /** Log every block requested, loaded, failed and evicted, with its route. */\n debug?: boolean\n /**\n * Veto a transaction. Return false and it is reported `cancelled` and\n * nothing changes - the hook for \"a refresh is in flight, drop this\".\n */\n isApplyTransaction?: (transaction: ServerTransaction<TData>) => boolean\n /** How long `applyTransactionAsync` batches before flushing. Default 50 ms. */\n asyncTransactionWaitMs?: number\n onChange?: (state: ServerRowModelState<TData>) => void\n /** A batch of async transactions was applied, in order. */\n onAsyncTransactionsFlushed?: (results: ServerTransactionResult[]) => void\n /** A level finished re-fetching after `refresh()` (not after a purge). */\n onStoreRefreshed?: (route: string[]) => void\n /** A group was opened, by the user or by `isGroupOpenByDefault`. */\n onGroupOpened?: (route: string[]) => void\n /** A block failed to load. The row shows a Retry; this is for logging. */\n onLoadError?: (route: string[], error: unknown) => void\n}\n```\n\n### `type ServerRowModelDisplayRow`\n\nWhat this model puts on screen: every shared display row, plus placeholders.\n\n```ts\nexport type ServerRowModelDisplayRow<TData> = ServerDisplayRow<TData> | ServerPlaceholderRow\n```\n\n### `type ServerRowModelState`\n\nWhat the model emits on every change: the flattened display list, the grid rows, the counts and the current layout.\n\n```ts\nexport type ServerRowModelState<TData> = {\n /**\n * The flattened tree, one entry per row the grid shows: groups with their\n * expanded children spliced in, footers, the grand total, and a shared\n * placeholder object for every row not yet loaded.\n */\n displayRows: ReadonlyArray<ServerRowModelDisplayRow<TData>>\n /** `displayRows` mapped to grid rows - each row's data spread, plus `__group`. */\n gridRows: ReadonlyArray<ServerRowModelGridRow<TData>>\n groupBy: string[]\n aggregations: ServerAggregation[]\n /** Rows at the top level, or null until the root has answered. */\n rowCount: number | null\n /** True while the root has never loaded. */\n loading: boolean\n /** True while a `createRow`, `updateRow` or `deleteRow` is in flight. */\n saving: boolean\n /** The rejection from the last failed top-level fetch, else null. */\n error: unknown\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /** Ids (JSON of the route) of the expanded groups. */\n expandedGroups: string[]\n /** Ids (by `getRowId`) of the leaves whose detail panel is open. */\n openDetails: string[]\n /** The grand-total row, when one is configured and has arrived. */\n grandTotal: TData | null\n pivotBy: string[]\n pivotMode: boolean\n /** The pivoted value fields the backend has reported so far, in pivot mode. */\n pivotResultFields: string[]\n /** The pager, or null when the top level scrolls. */\n pagination: ServerRowModelPagination | null\n}\n```\n\n### `type ServerRowModelGridRow`\n\nWhat the grid renders: the row's own fields at the top level (so a plain\n`field` column shows a subtotal on a group row and the value on a leaf)\nplus the display row under `__group`. Placeholders carry only the marker.\n\n```ts\nexport type ServerRowModelGridRow<TData> = (TData | Record<string, never>) & {\n __group: ServerRowModelDisplayRow<TData>\n}\n```\n\n### `type ServerTransaction`\n\nAdd, update and remove rows in one level without a request.\n\nRows are matched by `getRowId` at a leaf level, and by the group column's\nvalue at a group level (a group row IS its key). The grand total answers\nto the fixed id `sv-grand-total`.\n\n```ts\nexport type ServerTransaction<TData> = {\n /** The level to apply to. Omit for the top level. */\n route?: string[]\n add?: ReadonlyArray<TData>\n /** Where `add` goes. Default: the end of the level. */\n addIndex?: number\n update?: ReadonlyArray<TData>\n /** Rows to remove, or their ids. */\n remove?: ReadonlyArray<TData | string>\n /**\n * The level's row count afterwards, when you know it better than the\n * arithmetic does - a delete that also removed rows outside the cache,\n * say. Applied last.\n */\n rowCount?: number\n}\n```\n\n### `type ServerTransactionStatus`\n\nHow a transaction went. `applied`: every part that could be applied was.\n`storeNotFound`: no level exists for `route` (nothing has been expanded\nthere). `cancelled`: `isApplyTransaction` said no. `storeLoading`: the\nlevel's first block is still in flight. `storeWaitingToLoad`: the level\nexists but nothing has been requested from it yet. `storeLoadingFailed`:\nthe level's load failed; retry it before applying.\n\n```ts\nexport type ServerTransactionStatus =\n | 'applied'\n | 'storeNotFound'\n | 'cancelled'\n | 'storeLoading'\n | 'storeWaitingToLoad'\n | 'storeLoadingFailed'\n```\n\n### `type ServerTransactionResult`\n\nWhat `applyTransaction` returns: the status and the ids it placed, patched and removed.\n\n```ts\nexport type ServerTransactionResult = {\n status: ServerTransactionStatus\n /** Ids of the rows placed in the cache. */\n add: string[]\n /** Ids of the rows found and updated. */\n update: string[]\n /** Ids of the rows found and removed. */\n remove: string[]\n}\n```\n\n### `type RefreshOptions`\n\nWhat `refresh` takes: the level to re-read (`[]` is the root) and whether to drop its cache first.\n\n```ts\nexport type RefreshOptions = {\n /** The group whose children to reload. Omit for the top level. */\n route?: string[]\n /**\n * Drop the cache for that level and everything beneath it and start over.\n * Without it the loaded blocks re-fetch in place: the count, the scroll\n * position and every expanded descendant survive.\n */\n purge?: boolean\n}\n```\n\n### `type ServerRowModel`\n\nThe Server-Side Row Model: the controller surface plus the `GridRowModel` shape, so one object drives the grid through `rowModel`.\n\n```ts\nexport type ServerRowModel<TData> = GridRowModel<ServerRowModelGridRow<TData>> & {\n /** The group accessors the grid's treegrid keyboard and `SvGroupCell` use. Always present on this model. */\n group: NonNullable<GridRowModel<ServerRowModelGridRow<TData>>['group']>\n /** Load the top level, or reload a level. See {@link RefreshOptions}. */\n refresh(options?: RefreshOptions): void\n /** Re-fetch every block that failed, at every level. */\n retryLoads(): void\n /** Replace the sort and re-fetch the levels it affects. Takes either filter shape. */\n setSort(sortModel: ServerSortModel): void\n setFilter(filterModel: ServerFilterModel | GridFilterState): void\n /** Change the grouping. Collapses everything and reloads the top level. */\n setGroupBy(groupBy: string[]): void\n /** Change the aggregations. Reloads every level. */\n setAggregations(aggregations: ServerAggregation[]): void\n /**\n * Change the pivot columns and / or switch pivot mode. Reloads every\n * level. In pivot mode the innermost group level cannot be opened: its\n * rows are the pivoted aggregates, and there are no leaves under them.\n */\n setPivot(pivot: { pivotBy?: string[]; pivotMode?: boolean }): void\n /**\n * Change any of group-by, aggregations and pivot in one go, with ONE\n * reload - what a designer sends on Apply. A field left out keeps its\n * value; a layout equal to the current one does nothing at all.\n */\n setLayout(layout: {\n groupBy?: string[]\n aggregations?: ServerAggregation[]\n pivotBy?: string[]\n pivotMode?: boolean\n pivotRowTotals?: boolean | string\n }): void\n /** Go to a page (clamped to what exists). No-op without `pagination`. */\n setPage(pageIndex: number): void\n /** Change the page size; the current first row stays on screen. */\n setPageSize(pageSize: number): void\n /** Fetch the next block of a `loadMore` level (the \"Load N more\" row). */\n loadMoreChildren(route: string[]): void\n toggleGroup(row: ServerGroupRow<TData>): void\n expandGroup(route: string[]): void\n collapseGroup(route: string[]): void\n /**\n * Open every group. With `includeUnloaded`, groups that arrive later open\n * on arrival too, until the next `collapseAll()`.\n */\n expandAll(options?: { includeUnloaded?: boolean }): void\n collapseAll(): void\n isExpanded(route: string[]): boolean\n /**\n * Master-detail: open or close the detail panel under a leaf, by the id\n * `getRowId` gives it. The panel is a display row of kind `detail` right\n * under its leaf, carrying the leaf as `master`; mark it for the grid\n * with `isDetailRow: (row) => row.__group?.kind === 'detail'` and draw\n * it with `renderDetailRow` (give `detailRowHeight` under\n * virtualization). Closes with the group that holds the leaf, and goes\n * when the leaf is removed.\n */\n toggleDetail(id: string, open?: boolean): void\n isDetailOpen(id: string): boolean\n /** Close every open detail panel. */\n closeAllDetails(): void\n /**\n * Write rows straight into a level, bypassing the datasource: children\n * that came with their parent, a socket that pushed a whole level. Creates\n * the level's store if it does not exist. `startRow` must sit on a block\n * boundary for that level.\n */\n applyRowData(input: {\n route?: string[]\n rows: ReadonlyArray<TData>\n rowCount?: number\n startRow?: number\n }): void\n /** Apply a transaction now. See {@link ServerTransaction}. */\n applyTransaction(transaction: ServerTransaction<TData>): ServerTransactionResult\n /**\n * Queue a transaction and apply it with whatever else arrives within\n * `asyncTransactionWaitMs`, so a burst of updates re-renders once. A\n * transaction whose level is still loading waits for it. The callback\n * gets that transaction's result when the batch flushes.\n */\n applyTransactionAsync(\n transaction: ServerTransaction<TData>,\n callback?: (result: ServerTransactionResult) => void,\n ): void\n /** Apply every queued async transaction now. */\n flushAsyncTransactions(): void\n /**\n * Patch one loaded row in place, on whatever level holds it, without a\n * request and without changing its id. The path for a socket-driven\n * tick; `applyTransaction` is for adding and removing. `replace` swaps\n * the whole row instead of merging the patch. Returns whether it was found.\n */\n updateRowData(id: string, patch: Partial<TData>, options?: { replace?: boolean }): boolean\n /**\n * Write through the source, then update the cache with what it returned\n * (no refetch). Throws when the source lacks the method. Aggregates are\n * NOT recomputed - `refresh({ route })` the parent when a subtotal must\n * follow. A created row lands at `addIndex` within its level, else at the\n * end, which in a level of thousands is out of sight: pass `0` for the\n * top, or the index a deleted row had to put it back where it was.\n */\n createRow(input: Partial<TData>, route?: string[], addIndex?: number): Promise<TData>\n updateRow(id: string, patch: Partial<TData>): Promise<TData>\n deleteRow(id: string): Promise<void>\n /**\n * Move a loaded row to another level: a file dragged into a folder, an\n * order into another region. With `patch` the move is written through\n * `source.updateRow` first (the parent field, typically) and the saved\n * row is what lands; without it the cache alone moves. The row leaves\n * its level as a transaction and joins `toRoute` at `addIndex` (the end\n * by default). A group takes its open children with it in the sense\n * that their cached level is dropped: they are read again under the\n * new route. A target level nothing has opened reports `storeNotFound`\n * and holds the row once it opens; the parent counts follow either way.\n */\n moveRow(\n id: string,\n toRoute: string[],\n options?: { patch?: Partial<TData>; addIndex?: number },\n ): Promise<{ row: TData; status: ServerTransactionStatus }>\n /** Tell the model which display rows are on screen. `<SvGrid rowModel>` does this. */\n setViewport(startIndex: number, endIndex: number): void\n /** The selection rule, when `selection` was configured. */\n readonly selectionModel: ServerSelectionModel | null\n /** The rule as plain data, for saving or for sending to a bulk endpoint. */\n getSelectionState(): ServerSelectionState | ServerGroupSelectionNode | null\n /**\n * Restore a saved rule. The callback-style shape (`toggledNodes`) is\n * accepted as it is, with group ids read as group keys; pass it through\n * `fromCallbackSelectionState(state, mapping)` first when they differ.\n */\n setSelectionState(\n state: ServerSelectionState | ServerGroupSelectionNode | CallbackSelectionState | CallbackGroupSelectionState,\n ): void\n /**\n * Apply one patch to every selected row, loaded or not, through the\n * datasource's `updateWhere`, then reload every level so the rows and\n * their aggregates reflect it. Resolves with how many rows changed.\n * Throws when the source has no `updateWhere` - a bulk edit is refused\n * rather than silently applied to the loaded rows only.\n */\n bulkUpdate(patch: Partial<TData>): Promise<number>\n getLevelState(route?: string[]): ServerLevelState | null\n levelStates(): ServerLevelState[]\n /** Every block at every level, for logging and tests. */\n getCacheState(): Array<BlockState & { route: string[] }>\n getState(): ServerRowModelState<TData>\n /** Abort what is in flight, close the source, stop emitting. Call on unmount. */\n dispose(): void\n /** The grand-total row for `pinnedTop` / `pinnedBottom`, or nothing. */\n readonly pinnedTopRows: ReadonlyArray<ServerRowModelGridRow<TData>> | undefined\n readonly pinnedBottomRows: ReadonlyArray<ServerRowModelGridRow<TData>> | undefined\n}\n```\n\n### `const GRAND_TOTAL_ROW_ID`\n\nThe fixed id of the grand-total display row, so a transaction can address it.\n\n```ts\nexport const GRAND_TOTAL_ROW_ID = 'sv-grand-total' as const\n```\n\n### `const GROUP_TOTAL_ROW_ID_PREFIX`\n\nThe prefix of a group footer's id; the rest is the group route.\n\n```ts\nexport const GROUP_TOTAL_ROW_ID_PREFIX = 'sv-group-total:' as const\n```\n\n### `function createServerRowModel`\n\nBuild the Server-Side Row Model over a `ServerDataSource`: lazy grouping\nand tree data one level at a time, a block cache per level, aggregates,\nchild counts, grand totals, pivot, paging, transactions and a selection\nthat reaches rows the grid never loaded. Hand the result to\n`<SvGrid rowModel>`. Enterprise: nudges when no licence key is set.\n\n```ts\nexport function createServerRowModel<TData>(\n source: ServerDataSource<TData>,\n options: ServerRowModelOptions<TData>,\n): ServerRowModel<TData> {\n // Runs without a key; shows the watermark + one console notice.\n nudgeServerRowModel()\n\n let groupBy = [...(options.groupBy ?? [])]\n let aggregations = [...(options.aggregations ?? [])]\n let pivotBy = [...(options.pivotBy ?? [])]\n let pivotMode = !!options.pivotMode\n let pivotRowTotals: boolean | string = options.pivotRowTotals ?? false\n const pivotSeparator = options.pivotFieldSeparator ?? '_'\n /** The union of every `pivotResultFields` seen since the last reset, in order. */\n let pivotFields: string[] = []\n /** Verbatim column defs from the backend, when it sent them instead of fields. */\n let pivotColumnsFromSource: ReadonlyArray<unknown> | null = null\n const treeData = !!options.treeData\n if (treeData && (!options.getRowId || !options.hasChildren)) {\n throw new Error('createServerRowModel: treeData requires getRowId and hasChildren')\n }\n const getRowId = options.getRowId\n const hasChildren = options.hasChildren\n const childCountOf = options.childCount\n const skeletonRows = Math.max(1, options.skeletonRows ?? 3)\n const paging = options.pagination ?? null\n let pageIndex = 0\n let pageSize = Math.max(1, paging?.pageSize ?? 100)\n /** Rows the pager counts, as of the last flatten. */\n let pagerRowCount = 0\n const groupFooters = !!options.groupFooters\n const grandTotalRow = options.grandTotalRow\n const debug = !!options.debug\n\n let sortModel: ServerSortModel = []\n let filterModel: ServerFilterModel = {}\n\n const stores = new Map<string, Store<TData>>()\n const expanded = new Set<string>()\n /** Group routes that have been through `isGroupOpenByDefault` once. */\n const seenGroups = new Set<string>()\n let autoExpand = false\n\n let grandTotal: TData | null | undefined = undefined\n /**\n * The grand total on hand is from before an in-place refresh of the top\n * level or a bulk edit, so the next root block asks for a fresh one. The\n * old one stays on screen until it lands rather than blinking out.\n */\n let grandTotalStale = false\n /** Leaves whose detail panel is open, by the id `getRowId` gives them. */\n const openDetails = new Set<string>()\n let rootError: unknown = null\n let disposed = false\n /** Writes the server has not answered yet; `saving` is \"any\". */\n let writesInFlight = 0\n\n // Generation guard: bumped on any reset so responses for the previous shape\n // are discarded when they land.\n let generation = 0\n\n // A single in-flight budget across every level. The block cache caps\n // concurrency per instance; with one instance per route that would let ten\n // open groups fire twenty requests, so the gate lives here.\n const maxConcurrent = Math.max(1, options.maxConcurrentRequests ?? 2)\n let inFlight = 0\n const waiting: Array<() => void> = []\n const acquire = (): Promise<void> => {\n if (inFlight < maxConcurrent) {\n inFlight += 1\n return Promise.resolve()\n }\n return new Promise((resolve) => waiting.push(() => { inFlight += 1; resolve() }))\n }\n const release = (): void => {\n inFlight -= 1\n waiting.shift()?.()\n }\n\n const subscribers = new Set<() => void>()\n let lastViewport: [number, number] = [0, 0]\n let segments: Segment<TData>[] = []\n let emitScheduled = false\n\n // ----------------------------------------------------------- stores\n\n function levelOf(route: ReadonlyArray<string>): number {\n return route.length\n }\n\n function isLeafLevel(level: number): boolean {\n return !treeData && level >= groupBy.length\n }\n\n /** The group row `route` names, if its parent level has it loaded. */\n function parentRowOf(route: string[]): TData | undefined {\n if (route.length === 0) return undefined\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return undefined\n const key = route[route.length - 1]\n const rows = parent.cache.rows()\n for (const child of rows) {\n if (rowPlaceholderState(child)) continue\n if (child.kind === 'group' && child.key === key) return child.data\n }\n return undefined\n }\n\n function toChildren(route: string[], startRow: number, rows: ReadonlyArray<TData>): Child<TData>[] {\n const level = levelOf(route)\n const groupField = groupBy[level]\n const storeKey = routeKey(route)\n return rows.map((data, i): Child<TData> => {\n const isGroup = treeData ? hasChildren!(data) : level < groupBy.length\n if (isGroup) {\n const key = treeData\n ? getRowId!(data)\n : ((v) => (v == null ? '' : String(v)))((data as Record<string, unknown>)[groupField!])\n const agg: Record<string, unknown> = {}\n for (const a of aggregations) agg[a.col] = (data as Record<string, unknown>)[a.col]\n return {\n kind: 'group',\n key,\n field: treeData ? '' : groupField!,\n aggregates: agg,\n childCount: childCountOf?.(data),\n data,\n }\n }\n const id = getRowId ? `${storeKey}:${getRowId(data)}` : `${storeKey}:${startRow + i}`\n return { kind: 'leaf', id, data }\n })\n }\n\n function createStore(route: string[]): Store<TData> {\n const level = levelOf(route)\n const params = options.levelParams?.(level, route) ?? {}\n const blockSize = Math.max(1, params.blockSize ?? options.blockSize ?? 100)\n const loadMore = !!params.loadMore\n const infinite = !loadMore && params.infinite !== false\n const key = routeKey(route)\n const gen = generation\n\n const store: Store<TData> = {\n route,\n key,\n level,\n infinite,\n loadMore,\n blockSize,\n refreshPending: false,\n seenBlocks: new Map(),\n cache: null as unknown as BlockCache<Child<TData>>,\n }\n\n store.cache = createBlockCache<Child<TData>>({\n blockSize,\n maxBlocksInCache: params.maxBlocksInCache ?? options.maxBlocksInCache,\n // The model gates concurrency itself, across levels.\n maxConcurrentRequests: Number.MAX_SAFE_INTEGER,\n blockLoadDebounceMs: options.blockLoadDebounceMs,\n initialRowCount: Math.max(1, params.initialRowCount ?? skeletonRows),\n fetch: async (startRow, endRow, signal) => {\n await acquire()\n try {\n if (disposed || gen !== generation || signal.aborted) {\n throw new Error('stale')\n }\n const needsGrandTotal =\n route.length === 0 && grandTotalRow != null && (grandTotal === undefined || grandTotalStale)\n const request: ServerRequest = {\n startRow,\n endRow,\n pageIndex: Math.floor(startRow / blockSize),\n pageSize: blockSize,\n sortModel,\n filterModel,\n groupBy,\n groupKeys: route,\n aggregations,\n ...(pivotMode ? { pivotBy, pivotMode: true } : {}),\n ...(needsGrandTotal ? { needsGrandTotal: true } : {}),\n ...(route.length ? { parentRow: parentRowOf(route) } : {}),\n ...(options.context !== undefined ? { context: options.context } : {}),\n signal,\n }\n let result\n try {\n result = await source.getRows(request)\n } catch (err) {\n if (!disposed && gen === generation) {\n if (route.length === 0) rootError = err\n options.onLoadError?.(route, err)\n }\n throw err\n }\n if (disposed || gen !== generation) throw new Error('stale')\n if (route.length === 0) {\n rootError = null\n if ('grandTotal' in result) {\n grandTotal = result.grandTotal ?? null\n grandTotalStale = false\n }\n }\n if (pivotMode) {\n if (result.pivotResultColumns) pivotColumnsFromSource = result.pivotResultColumns\n for (const f of result.pivotResultFields ?? []) {\n if (!pivotFields.includes(f)) pivotFields.push(f)\n }\n }\n return {\n rows: toChildren(route, startRow, result.rows),\n rowCount: result.rowCount >= 0 ? result.rowCount : undefined,\n }\n } finally {\n release()\n }\n },\n onChange: (s) => {\n if (disposed || gen !== generation) return\n // Runs once per block that just landed: open-by-default, and the\n // debug log. Scanning only those blocks keeps this O(blockSize)\n // rather than O(rows) on every change.\n for (const index of diffBlocks(store)) openByDefaultIn(store, index)\n if (store.refreshPending && !s.loading) {\n store.refreshPending = false\n options.onStoreRefreshed?.(route)\n }\n // A level that is not infinite loads itself completely once it knows\n // how big it is.\n if (!infinite && !loadMore && s.lastRowKnown && s.rowCount != null) {\n store.cache.setViewport(0, Math.max(0, s.rowCount - 1))\n }\n scheduleEmit()\n },\n })\n stores.set(key, store)\n return store\n }\n\n function ensureStore(route: string[]): Store<TData> {\n return stores.get(routeKey(route)) ?? createStore(route)\n }\n\n function dropSubtree(route: string[]): void {\n const prefix = routeKey(route)\n for (const [key, store] of [...stores]) {\n if (key === prefix || isUnder(key, route)) {\n store.cache.dispose()\n stores.delete(key)\n }\n }\n }\n\n function isUnder(key: string, route: string[]): boolean {\n // Routes are JSON arrays; a descendant's key starts with the parent's\n // elements. Compare structurally rather than by string prefix so that\n // [\"a\"] is not mistaken for a parent of [\"ab\"].\n const parsed = JSON.parse(key) as string[]\n if (parsed.length <= route.length) return false\n return route.every((seg, i) => parsed[i] === seg)\n }\n\n /** Block indices that became `loaded` since the last call. Logs when `debug`. */\n function diffBlocks(store: Store<TData>): number[] {\n const now = new Map(store.cache.getCacheState().map((b) => [b.blockIndex, b.status]))\n const landed: number[] = []\n for (const [index, status] of now) {\n const before = store.seenBlocks.get(index)\n if (before === status) continue\n if (status === 'loaded') landed.push(index)\n // eslint-disable-next-line no-console -- opt-in diagnostics, see the debug option\n if (debug) console.debug(`[svgrid ssrm] ${routeKey(store.route)} block ${index}: ${status}`)\n }\n if (debug) {\n for (const index of store.seenBlocks.keys()) {\n // eslint-disable-next-line no-console -- opt-in diagnostics, see the debug option\n if (!now.has(index)) console.debug(`[svgrid ssrm] ${routeKey(store.route)} block ${index}: evicted`)\n }\n }\n store.seenBlocks = now\n return landed\n }\n\n /**\n * Open the groups in a freshly landed block that should start open: by\n * `isGroupOpenByDefault`, by an `expandAll({ includeUnloaded })` in force,\n * or because they are unbalanced. Once per group, so a block that is\n * re-fetched does not re-open something the user closed.\n */\n function openByDefaultIn(store: Store<TData>, blockIndex: number): void {\n const start = blockIndex * store.blockSize\n for (let i = start; i < start + store.blockSize; i += 1) {\n const raw = store.cache.getRow(i)\n if (rowPlaceholderState(raw)) continue\n const child = raw as Child<TData>\n if (child.kind !== 'group') continue\n const childRoute = [...store.route, child.key]\n const ck = routeKey(childRoute)\n if (seenGroups.has(ck)) continue\n seenGroups.add(ck)\n const unbalanced = !!options.allowUnbalancedGroups && child.key === ''\n if (unbalanced || autoExpand || options.isGroupOpenByDefault?.(childRoute, child.data)) {\n expandGroup(childRoute)\n }\n }\n }\n\n // ---------------------------------------------------------- flatten\n\n function flatten(): {\n display: ServerRowModelDisplayRow<TData>[]\n grid: ServerRowModelGridRow<TData>[]\n } {\n const display: ServerRowModelDisplayRow<TData>[] = []\n const grid: ServerRowModelGridRow<TData>[] = []\n segments = []\n\n const pushRow = (d: ServerRowModelDisplayRow<TData>, data?: TData): void => {\n display.push(d)\n grid.push(toGridRow(d, data))\n }\n\n if (grandTotalRow === 'top' && grandTotal) pushRow(grandTotalDisplay(grandTotal), grandTotal)\n const bodyStart = display.length\n\n /**\n * A `loadMore` level shows its loaded prefix and a \"more\" row; rows\n * past the first placeholder are not the grid's to scroll into.\n */\n const loadedPrefix = (rows: ReadonlyArray<Child<TData>>): number => {\n let n = 0\n while (n < rows.length && !rowPlaceholderState(rows[n])) n += 1\n return n\n }\n\n const walk = (store: Store<TData>, from = 0, to = Number.POSITIVE_INFINITY): void => {\n const rows = store.cache.rows()\n const end = Math.min(rows.length, to, store.loadMore ? loadedPrefix(rows) : Number.POSITIVE_INFINITY)\n let segStart = from\n let dispStart = display.length\n const flushSegment = (upTo: number): void => {\n if (upTo > segStart) {\n segments.push({ store, displayStart: dispStart, storeStart: segStart, length: upTo - segStart })\n }\n }\n for (let i = from; i < end; i += 1) {\n const child = rows[i]!\n const ph = rowPlaceholderState(child)\n if (ph) {\n pushRow(ph === 'failed' ? FAILED_ROW : LOADING_ROW)\n continue\n }\n if (child.kind === 'leaf') {\n pushRow(\n { kind: 'leaf', id: child.id, level: store.level, route: store.route, data: child.data },\n child.data,\n )\n if (openDetails.size) {\n const masterId = childId(child)\n if (openDetails.has(masterId)) {\n pushRow(\n { kind: 'detail', id: `${child.id}:detail`, level: store.level, route: store.route, masterId, master: child.data },\n child.data,\n )\n }\n }\n continue\n }\n const childRoute = [...store.route, child.key]\n const ck = routeKey(childRoute)\n const childStore = stores.get(ck)\n const isExp = expanded.has(ck)\n const unbalanced = !!options.allowUnbalancedGroups && child.key === ''\n const hidden = isExp && (unbalanced || !!options.hideOpenParents)\n if (!hidden) {\n pushRow(\n {\n kind: 'group',\n id: ck,\n path: childRoute,\n field: child.field,\n key: child.key,\n level: store.level,\n expanded: isExp,\n ...(pivotMode && !treeData && childRoute.length >= groupBy.length ? { expandable: false } : {}),\n // The expander shows a spinner for the level opening, not for\n // a block far down an open level that a deep scroll asked for.\n loading: !!childStore && childStore.cache.getCacheState().some((b) => b.blockIndex === 0 && b.status === 'loading'),\n aggregates: child.aggregates,\n ...(child.childCount != null ? { childCount: child.childCount } : {}),\n data: child.data,\n },\n child.data,\n )\n }\n if (isExp && childStore) {\n // This store's run ends here; the child's rows follow, then a new\n // run of this store begins.\n flushSegment(i + 1)\n walk(childStore)\n if (groupFooters && !hidden) {\n pushRow(\n {\n kind: 'footer',\n id: GROUP_TOTAL_ROW_ID_PREFIX + ck,\n level: store.level + 1,\n path: childRoute,\n key: child.key,\n aggregates: child.aggregates,\n data: child.data,\n } as ServerFooterRow<TData>,\n child.data,\n )\n }\n segStart = i + 1\n dispStart = display.length\n }\n }\n flushSegment(end)\n if (store.loadMore) {\n const count = store.cache.rowCount()\n const loaded = end\n if (count == null || loaded < count) {\n pushRow({\n kind: 'more',\n id: `sv-more:${store.key}`,\n level: store.level,\n path: store.route,\n remaining: count == null ? store.blockSize : count - loaded,\n loading: store.cache.getCacheState().some((b) => b.status === 'loading'),\n })\n }\n }\n }\n\n const root = stores.get(routeKey([]))\n if (root) {\n if (paging && !paging.paginateChildRows) {\n // A page of top-level rows. The root rows array is already sized to\n // the count (placeholders for what has not loaded), so the page\n // exists before its block does.\n const count = root.cache.rowCount() ?? root.cache.rows().length\n pagerRowCount = count\n const start = Math.min(pageIndex * pageSize, Math.max(0, count - 1))\n walk(root, start, start + pageSize)\n } else {\n walk(root)\n }\n }\n\n if (paging?.paginateChildRows) {\n // A page of the flattened tree. Cut the body and re-base the segments\n // that cross into the page; the grand total stays on every page.\n const bodyLength = display.length - bodyStart\n pagerRowCount = bodyLength\n const start = Math.min(pageIndex * pageSize, Math.max(0, bodyLength - 1))\n const stop = Math.min(bodyLength, start + pageSize)\n display.splice(bodyStart + stop)\n display.splice(bodyStart, start)\n grid.splice(bodyStart + stop)\n grid.splice(bodyStart, start)\n const cut: Segment<TData>[] = []\n for (const seg of segments) {\n const a = Math.max(seg.displayStart, bodyStart + start)\n const b = Math.min(seg.displayStart + seg.length, bodyStart + stop)\n if (b <= a) continue\n cut.push({\n store: seg.store,\n displayStart: a - start,\n storeStart: seg.storeStart + (a - seg.displayStart),\n length: b - a,\n })\n }\n segments = cut\n }\n\n if (grandTotalRow === 'bottom' && grandTotal) pushRow(grandTotalDisplay(grandTotal), grandTotal)\n\n return { display, grid }\n }\n\n function pagination(): ServerRowModelPagination | null {\n if (!paging) return null\n return {\n pageIndex,\n pageSize,\n rowCount: pagerRowCount,\n pageCount: Math.max(1, Math.ceil(pagerRowCount / pageSize)),\n }\n }\n\n /**\n * A display row as the grid sees it: the row's own fields at the top\n * level plus the `__group` marker. Footers and the grand total also carry\n * the flags the grid keys its `sv-grid-group-footer-row` /\n * `sv-grid-grand-total-row` styling on, so they look like the client\n * model's totals rather than like ordinary rows.\n */\n function toGridRow(d: ServerRowModelDisplayRow<TData>, data?: TData): ServerRowModelGridRow<TData> {\n if (d.kind === 'placeholder') return d as unknown as ServerRowModelGridRow<TData>\n const row = { ...(data as object), __group: d } as ServerRowModelGridRow<TData> & {\n __groupFooter?: boolean\n __grandTotal?: boolean\n }\n if (d.kind === 'footer') row.__groupFooter = true\n if (d.kind === 'grandTotal') {\n row.__groupFooter = true\n row.__grandTotal = true\n }\n return row\n }\n\n function grandTotalDisplay(data: TData): ServerGrandTotalRow<TData> {\n const agg: Record<string, unknown> = {}\n for (const a of aggregations) agg[a.col] = (data as Record<string, unknown>)[a.col]\n return { kind: 'grandTotal', id: GRAND_TOTAL_ROW_ID, level: 0, aggregates: agg, data }\n }\n\n function pinnedGrandTotal(): ReadonlyArray<ServerRowModelGridRow<TData>> | undefined {\n if (!grandTotal) return undefined\n return [toGridRow(grandTotalDisplay(grandTotal), grandTotal)]\n }\n\n // ------------------------------------------------------------- state\n\n let current: ServerRowModelState<TData> = {\n displayRows: [],\n gridRows: [],\n groupBy: [...groupBy],\n aggregations: [...aggregations],\n rowCount: null,\n loading: true,\n saving: false,\n error: null,\n sortModel,\n filterModel,\n expandedGroups: [],\n openDetails: [],\n grandTotal: null,\n pivotBy: [...pivotBy],\n pivotMode,\n pivotResultFields: [],\n pagination: pagination(),\n }\n\n function buildState(): ServerRowModelState<TData> {\n const root = stores.get(routeKey([]))\n const { display, grid } = flatten()\n const rootLoaded = !!root && root.cache.getCacheState().some((b) => b.status === 'loaded')\n return {\n displayRows: display,\n gridRows: grid,\n groupBy: [...groupBy],\n aggregations: [...aggregations],\n rowCount: root?.cache.rowCount() ?? null,\n loading: !!root && !rootLoaded && root.cache.getCacheState().some((b) => b.status === 'loading'),\n saving: writesInFlight > 0,\n error: rootError,\n sortModel,\n filterModel,\n expandedGroups: [...expanded],\n openDetails: [...openDetails],\n grandTotal: grandTotal ?? null,\n pivotBy: [...pivotBy],\n pivotMode,\n pivotResultFields: [...pivotFields],\n pagination: pagination(),\n }\n }\n\n function scheduleEmit(): void {\n if (disposed || emitScheduled) return\n emitScheduled = true\n queueMicrotask(() => {\n emitScheduled = false\n if (disposed) return\n current = buildState()\n options.onChange?.(current)\n for (const notify of subscribers) notify()\n })\n }\n\n // ------------------------------------------------------- operations\n\n function applyViewport(): void {\n const [start, end] = lastViewport\n for (const seg of segments) {\n if (seg.store.loadMore) continue\n const segEnd = seg.displayStart + seg.length - 1\n if (segEnd < start || seg.displayStart > end) continue\n const from = Math.max(start, seg.displayStart) - seg.displayStart + seg.storeStart\n const to = Math.min(end, segEnd) - seg.displayStart + seg.storeStart\n seg.store.cache.setViewport(from, to)\n }\n }\n\n /** Ask a level for its first block, so it shows something before any scroll. */\n function primeStore(store: Store<TData>): void {\n if (store.loadMore) {\n store.cache.setViewport(0, store.blockSize - 1)\n return\n }\n const from = store.level === 0 && paging && !paging.paginateChildRows ? pageIndex * pageSize : 0\n store.cache.setViewport(from, from + Math.min(store.blockSize, skeletonRows) - 1)\n }\n\n function expandGroup(route: string[]): void {\n if (disposed) return\n // Under pivot the innermost group rows ARE the result; there is nothing\n // beneath them to fetch.\n if (pivotMode && !treeData && route.length >= groupBy.length) return\n const key = routeKey(route)\n if (expanded.has(key)) return\n expanded.add(key)\n const store = ensureStore(route)\n // A known child count sizes the scrollbar (and the skeletons) right away.\n const parent = parentRowOf(route)\n const known = parent !== undefined ? childCountOf?.(parent) : undefined\n if (known != null && store.cache.rowCount() == null) store.cache.setRowCount(known, true)\n primeStore(store)\n options.onGroupOpened?.(route)\n scheduleEmit()\n }\n\n function collapseGroup(route: string[]): void {\n const key = routeKey(route)\n if (!expanded.delete(key)) return\n if (options.purgeClosedGroups) dropSubtree(route)\n scheduleEmit()\n }\n\n /** Start over below `route` (or everywhere), keeping the expansion set. */\n function rebuild(route: string[] = []): void {\n if (route.length === 0) {\n // A full reset. Disposing every cache aborts every request, and the\n // generation bump discards any that slipped past the abort.\n generation += 1\n for (const s of stores.values()) s.cache.dispose()\n stores.clear()\n grandTotal = undefined\n grandTotalStale = false\n pivotFields = []\n pivotColumnsFromSource = null\n } else {\n dropSubtree(route)\n }\n const target = ensureStore(route)\n primeStore(target)\n // Expanded descendants come back too, each priming its own first block.\n for (const key of expanded) {\n const r = JSON.parse(key) as string[]\n if (r.length > route.length && route.every((seg, i) => r[i] === seg)) primeStore(ensureStore(r))\n }\n scheduleEmit()\n }\n\n /** Which stores a sort touches, under the selective rules. */\n function storesAffectedBySort(next: ServerSortModel): Store<TData>[] {\n const all = [...stores.values()]\n if (options.sortAllLevels || treeData) return all\n const aggCols = new Set(aggregations.map((a) => a.col))\n const levels = new Set<number>()\n let everything = false\n let leaves = false\n for (const s of next) {\n const gi = groupBy.indexOf(s.id)\n if (aggCols.has(s.id)) everything = true\n else if (gi >= 0) levels.add(gi)\n else leaves = true\n }\n // Clearing the sort has to put every level back to server order.\n if (next.length === 0) everything = true\n if (everything) return all\n return all.filter((s) => levels.has(s.level) || (leaves && isLeafLevel(s.level)))\n }\n\n function sortLocally(store: Store<TData>): boolean {\n const st = store.cache.getCacheState()\n const count = store.cache.rowCount()\n if (!store.cache.lastRowKnown() || count == null) return false\n const loaded = st.filter((b) => b.status === 'loaded').length\n if (loaded * store.blockSize < count) return false\n const rows = store.cache.rows().filter((r) => !rowPlaceholderState(r)) as Child<TData>[]\n if (rows.length !== count) return false\n const valueOf = (c: Child<TData>, col: string): unknown => (c.data as Record<string, unknown>)[col]\n rows.sort((a, b) => {\n for (const s of sortModel) {\n const cmp = compareValues(valueOf(a, s.id), valueOf(b, s.id))\n if (cmp !== 0) return s.desc ? -cmp : cmp\n }\n return 0\n })\n store.cache.applyRows(0, rows, count)\n return true\n }\n\n function setSort(next: ServerSortModel): void {\n sortModel = next\n for (const store of storesAffectedBySort(next)) {\n if (options.clientSideSort && sortLocally(store)) continue\n store.refreshPending = true\n store.cache.refresh()\n }\n scheduleEmit()\n }\n\n function setFilter(next: ServerFilterModel | GridFilterState): void {\n const previous = filterModel\n filterModel = Array.isArray(next.columns)\n ? { global: next.global, columns: toServerFilterColumns(next as GridFilterState) }\n : (next as ServerFilterModel)\n if (!options.onlyRefreshFilteredGroups) return rebuild()\n\n // Selective: the levels whose column changed. Everything else keeps its\n // stale counts, which is the documented trade.\n const changed = new Set<string>()\n const prevCols = previous.columns ?? {}\n const nextCols = filterModel.columns ?? {}\n for (const c of new Set([...Object.keys(prevCols), ...Object.keys(nextCols)])) {\n if (JSON.stringify(prevCols[c]) !== JSON.stringify(nextCols[c])) changed.add(c)\n }\n const globalChanged = (previous.global ?? '') !== (filterModel.global ?? '')\n const exprChanged = JSON.stringify(previous.expression) !== JSON.stringify(filterModel.expression)\n const aggCols = new Set(aggregations.map((a) => a.col))\n if (globalChanged || exprChanged || [...changed].some((c) => aggCols.has(c))) return rebuild()\n\n const levels = new Set<number>()\n let leaves = false\n for (const c of changed) {\n const gi = groupBy.indexOf(c)\n if (gi >= 0) levels.add(gi)\n else leaves = true\n }\n for (const store of stores.values()) {\n if (levels.has(store.level) || (leaves && isLeafLevel(store.level))) {\n store.refreshPending = true\n store.cache.refresh()\n }\n }\n scheduleEmit()\n }\n\n function levelState(store: Store<TData>): ServerLevelState {\n const blocks = store.cache.getCacheState()\n return {\n route: store.route,\n level: store.level,\n rowCount: store.cache.rowCount(),\n lastRowKnown: store.cache.lastRowKnown(),\n loading: blocks.some((b) => b.status === 'loading'),\n failedBlocks: blocks.filter((b) => b.status === 'failed').map((b) => b.blockIndex),\n blocks,\n }\n }\n\n // ------------------------------------------------------ transactions\n\n /** The id a transaction row goes by at a level: key at a group level, row id at a leaf level. */\n function transactionId(store: Store<TData>, row: TData | string): string {\n if (typeof row === 'string') return row\n if (getRowId) {\n const id = getRowId(row)\n if (id === GRAND_TOTAL_ROW_ID) return id\n }\n if (isLeafLevel(store.level) || treeData) {\n if (!getRowId) throw new Error('createServerRowModel: transactions need getRowId')\n return getRowId(row)\n }\n const field = groupBy[store.level]!\n const v = (row as Record<string, unknown>)[field]\n return v == null ? '' : String(v)\n }\n\n /** The id a CACHED child goes by, in the same terms. */\n function childId(child: Child<TData>): string {\n if (child.kind === 'group') return child.key\n // Leaf ids are `${storeKey}:${rowId}`; the row id is the tail.\n return getRowId ? getRowId(child.data) : child.id.slice(child.id.lastIndexOf(':') + 1)\n }\n\n function storeStatus(store: Store<TData>): ServerTransactionStatus {\n const blocks = store.cache.getCacheState()\n if (blocks.length === 0) return 'storeWaitingToLoad'\n if (blocks.some((b) => b.status === 'loaded')) return 'applied'\n if (blocks.some((b) => b.status === 'loading')) return 'storeLoading'\n return 'storeLoadingFailed'\n }\n\n function applyTransaction(tx: ServerTransaction<TData>): ServerTransactionResult {\n const result: ServerTransactionResult = { status: 'applied', add: [], update: [], remove: [] }\n if (disposed) return { ...result, status: 'storeNotFound' }\n if (options.isApplyTransaction && !options.isApplyTransaction(tx)) {\n return { ...result, status: 'cancelled' }\n }\n const route = tx.route ?? []\n const store = stores.get(routeKey(route))\n if (!store) return { ...result, status: 'storeNotFound' }\n const status = storeStatus(store)\n if (status !== 'applied') return { ...result, status }\n\n // The grand total answers to its fixed id, on the root route only.\n const isTotal = (row: TData | string): boolean =>\n route.length === 0 && typeof row !== 'string' && !!getRowId && getRowId(row) === GRAND_TOTAL_ROW_ID\n\n for (const row of tx.update ?? []) {\n if (isTotal(row)) {\n grandTotal = row\n result.update.push(GRAND_TOTAL_ROW_ID)\n continue\n }\n const id = transactionId(store, row)\n const index = store.cache.findIndex((c) => childId(c) === id)\n if (index < 0) continue\n const [child] = toChildren(route, index, [row])\n store.cache.patch(index, child!)\n result.update.push(id)\n }\n\n for (const row of tx.remove ?? []) {\n if (isTotal(row)) {\n grandTotal = null\n result.remove.push(GRAND_TOTAL_ROW_ID)\n continue\n }\n const id = transactionId(store, row)\n const index = store.cache.findIndex((c) => childId(c) === id)\n // Outside the cache means outside what we can show; when the count is\n // known the row is simply not here, and the count stands until the\n // caller says otherwise with `rowCount`.\n if (index < 0) continue\n const wasGroup = (store.cache.getRow(index) as Child<TData>).kind === 'group'\n if (store.cache.remove(index, 1) > 0) {\n result.remove.push(id)\n // A removed group takes its expansion and its cached children with it.\n if (wasGroup) {\n const childRoute = [...route, id]\n expanded.delete(routeKey(childRoute))\n dropSubtree(childRoute)\n }\n }\n }\n\n if (tx.add && tx.add.length) {\n const total = store.cache.rowCount()\n const at = tx.addIndex ?? (total ?? store.cache.rows().length)\n const children = toChildren(route, at, tx.add)\n const placed = store.cache.insert(at, children)\n if (placed) for (const c of children) result.add.push(childId(c))\n // Adding the grand total means setting it.\n for (const row of tx.add) if (isTotal(row)) grandTotal = row\n }\n\n if (typeof tx.rowCount === 'number') store.cache.setRowCount(tx.rowCount, true)\n // The parent group row shows this level's count beside its key; adds\n // and removes move it by the net change. Aggregates stay as they were:\n // a refresh of the parent recomputes them, as documented.\n const delta = result.add.length - result.remove.length\n if (delta !== 0 && route.length > 0) adjustChildCount(route, delta)\n scheduleEmit()\n return result\n }\n\n function adjustChildCount(route: string[], delta: number): void {\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return\n const key = route[route.length - 1]\n const index = parent.cache.findIndex((c) => c.kind === 'group' && c.key === key)\n if (index < 0) return\n const child = parent.cache.getRow(index) as Child<TData>\n if (child.kind !== 'group' || child.childCount == null) return\n parent.cache.patch(index, { ...child, childCount: Math.max(0, child.childCount + delta) })\n }\n\n const asyncQueue: Array<{\n tx: ServerTransaction<TData>\n callback?: (result: ServerTransactionResult) => void\n }> = []\n let asyncTimer: ReturnType<typeof setTimeout> | null = null\n\n function flushAsyncTransactions(): void {\n if (asyncTimer) clearTimeout(asyncTimer)\n asyncTimer = null\n if (asyncQueue.length === 0) return\n const batch = asyncQueue.splice(0)\n const results: ServerTransactionResult[] = []\n for (const item of batch) {\n const result = applyTransaction(item.tx)\n // A level still loading gets its transaction back in the queue for\n // the next flush; nothing else is retried.\n if (result.status === 'storeLoading' || result.status === 'storeWaitingToLoad') {\n asyncQueue.push(item)\n continue\n }\n results.push(result)\n item.callback?.(result)\n }\n if (results.length) options.onAsyncTransactionsFlushed?.(results)\n if (asyncQueue.length) scheduleAsyncFlush()\n }\n\n function scheduleAsyncFlush(): void {\n if (asyncTimer || disposed) return\n asyncTimer = setTimeout(() => {\n asyncTimer = null\n flushAsyncTransactions()\n }, options.asyncTransactionWaitMs ?? 50)\n }\n\n /** Run one write with `saving` raised around it; the flag is \"any write in flight\". */\n async function saving<T>(write: () => Promise<T>): Promise<T> {\n writesInFlight += 1\n scheduleEmit()\n try {\n return await write()\n } finally {\n writesInFlight -= 1\n scheduleEmit()\n }\n }\n\n /** The level and index holding a leaf row with this id, searching loaded rows only. */\n function locate(id: string): { store: Store<TData>; index: number } | null {\n for (const store of stores.values()) {\n const index = store.cache.findIndex((c) => childId(c) === id)\n if (index >= 0) return { store, index }\n }\n return null\n }\n\n function updateRowData(id: string, patch: Partial<TData>, opts?: { replace?: boolean }): boolean {\n const hit = locate(id)\n if (!hit) return false\n const current = hit.store.cache.getRow(hit.index) as Child<TData>\n const next = (opts?.replace ? patch : { ...current.data, ...patch }) as TData\n const [child] = toChildren(hit.store.route, hit.index, [next])\n hit.store.cache.patch(hit.index, child!)\n scheduleEmit()\n return true\n }\n\n // --------------------------------------------------------- selection\n\n const selectionModel: ServerSelectionModel | null = options.selection\n ? createServerSelectionModel({\n groupSelects: options.selection.groupSelects,\n // The grid re-derives the header checkbox and the row ticks from a\n // row-model notification, so a selection change is one.\n onChange: scheduleEmit,\n leafCount: groupLeafCount,\n })\n : null\n\n /**\n * Leaves under a loaded group row, from its `count` aggregate (the same\n * one `leafTotal` reads on the grand total); null without one, or when\n * the group row is not loaded.\n */\n function groupLeafCount(route: ReadonlyArray<string>): number | null {\n const counter = aggregations.find((a) => a.fn === 'count')\n if (!counter || route.length === 0) return null\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return null\n const key = route[route.length - 1]\n const index = parent.cache.findIndex((c) => c.kind === 'group' && c.key === key)\n if (index < 0) return null\n const child = parent.cache.getRow(index) as Child<TData>\n if (child.kind !== 'group') return null\n const n = Number(child.aggregates[counter.col])\n return Number.isFinite(n) ? n : null\n }\n\n /** Where a grid row lives in selection terms: its parent route and its own id. */\n function locateForSelection(\n row: ServerRowModelGridRow<TData>,\n ): { route: ReadonlyArray<string>; id: string; isGroup: boolean } | null {\n const d = row.__group\n if (!d) return null\n if (d.kind === 'group') return { route: d.path.slice(0, -1), id: d.key, isGroup: true }\n if (d.kind === 'leaf') {\n return { route: d.route ?? [], id: getRowId ? getRowId(d.data) : d.id, isGroup: false }\n }\n return null\n }\n\n /**\n * How many leaves the whole result holds, for an honest select-all count:\n * the root count when nothing is grouped, else the `count` aggregation\n * on the grand total (the only place the server says it), else unknown.\n */\n function leafTotal(): number | null {\n if (treeData || groupBy.length === 0) return current.rowCount\n const counter = aggregations.find((a) => a.fn === 'count')\n if (!counter || !grandTotal) return null\n const n = Number((grandTotal as Record<string, unknown>)[counter.col])\n return Number.isFinite(n) ? n : null\n }\n\n const selectionAdapter = selectionModel\n ? {\n isSelected: (_rowId: string, row: ServerRowModelGridRow<TData>) => {\n const at = locateForSelection(row)\n return at ? selectionModel.isSelected(at.route, at.id) : false\n },\n headerState: () => selectionModel.headerState(),\n toggle: (_rowId: string, row: ServerRowModelGridRow<TData>, next: boolean) => {\n const at = locateForSelection(row)\n if (at) selectionModel.toggle(at.route, at.id, next, at.isGroup)\n },\n toggleAll: (next: boolean) => selectionModel.setAll(next),\n selectedCount: () => selectionModel.selectedCount(leafTotal()),\n bulkUpdate: (patch: Record<string, unknown>) => bulkUpdate(patch as Partial<TData>),\n }\n : undefined\n\n async function bulkUpdate(patch: Partial<TData>): Promise<number> {\n if (!selectionModel) throw new Error('createServerRowModel: bulkUpdate needs the `selection` option')\n if (!source.updateWhere) {\n throw new Error(\n 'createServerRowModel: the datasource does not implement updateWhere(), so a bulk edit ' +\n 'cannot reach rows the grid has not loaded',\n )\n }\n const rule = selectionModel.getState()\n // A nested rule is keyed level by level by the group columns; say which.\n const changed = await source.updateWhere(\n filterModel,\n patch,\n 'selectAllChildren' in rule ? { ...rule, groupBy: [...groupBy] } : rule,\n )\n if (disposed) return changed\n // Every level may have changed rows AND aggregates; reload them in place.\n for (const store of stores.values()) {\n store.refreshPending = true\n store.cache.refresh()\n }\n grandTotalStale = true\n scheduleEmit()\n return changed\n }\n\n // ------------------------------------------------------------ pivot\n\n /**\n * The columns that replace the grid's own while pivoting: the leading\n * columns the app named, then one header group per pivot key with a value\n * column per aggregation under it. Null outside pivot mode, or before the\n * first pivoted response has said what fields exist.\n */\n function pivotResultColumns(): ReadonlyArray<unknown> | null {\n if (!pivotMode) return null\n const leading = options.pivotLeadingColumns ?? []\n if (pivotColumnsFromSource) return [...leading, ...pivotColumnsFromSource]\n if (pivotFields.length === 0) return null\n return [\n ...leading,\n ...buildPivotResultColumns(pivotFields, aggregations, {\n separator: pivotSeparator,\n pivotResultColumn: options.pivotResultColumn,\n rowTotals: pivotRowTotals,\n }),\n ]\n }\n\n // ---------------------------------------------------- the controller\n\n // The id a leaf's detail is kept under: the app's row id, else the leaf's own.\n const leafDetailId = (g: { id: string; data: TData }) => (getRowId ? getRowId(g.data) : g.id.slice(g.id.lastIndexOf(':') + 1))\n const nav = {\n isGroup: (row: ServerRowModelGridRow<TData>) => row.__group?.kind === 'group',\n level: (row: ServerRowModelGridRow<TData>) =>\n row.__group && 'level' in row.__group ? row.__group.level : 0,\n expanded: (row: ServerRowModelGridRow<TData>) =>\n row.__group?.kind === 'group' ? row.__group.expanded : false,\n onToggle: (row: ServerRowModelGridRow<TData>) => {\n if (row.__group?.kind === 'group') model.toggleGroup(row.__group)\n else if (row.__group?.kind === 'more') model.loadMoreChildren(row.__group.path)\n },\n // A leaf's detail, by the id the app gave the row; a detail row toggles its master.\n toggleDetail: (row: ServerRowModelGridRow<TData>) => {\n const g = row.__group\n if (g?.kind === 'leaf') model.toggleDetail(leafDetailId(g))\n else if (g?.kind === 'detail') model.toggleDetail(g.masterId)\n },\n // Only a leaf has a detail to open; a group's expander is in the group column.\n hasDetail: (row: ServerRowModelGridRow<TData>) => row.__group?.kind === 'leaf',\n detailOpen: (row: ServerRowModelGridRow<TData>) => {\n const g = row.__group\n return g?.kind === 'leaf' ? model.isDetailOpen(leafDetailId(g)) : false\n },\n }\n\n const model: ServerRowModel<TData> = {\n refresh(opts) {\n if (disposed) return\n const route = opts?.route ?? []\n const store = stores.get(routeKey(route))\n if (opts?.purge || !store) return rebuild(route)\n store.refreshPending = true\n // The top level re-read in place brings its aggregates back fresh;\n // the grand total is one of them.\n if (route.length === 0) grandTotalStale = true\n store.cache.refresh()\n scheduleEmit()\n },\n retryLoads() {\n for (const store of stores.values()) store.cache.retryFailed()\n },\n setSort,\n setFilter,\n setGroupBy(next) {\n model.setLayout({ groupBy: next })\n },\n setAggregations(next) {\n model.setLayout({ aggregations: next })\n },\n setPivot(next) {\n model.setLayout(next)\n },\n setLayout(next) {\n if (disposed) return\n const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)\n const groupChanged = next.groupBy !== undefined && !same(next.groupBy, groupBy)\n const aggChanged = next.aggregations !== undefined && !same(next.aggregations, aggregations)\n const pivotChanged =\n (next.pivotBy !== undefined && !same(next.pivotBy, pivotBy)) ||\n (next.pivotMode !== undefined && next.pivotMode !== pivotMode)\n // The totals group is built from what is already loaded: no request.\n if (next.pivotRowTotals !== undefined && next.pivotRowTotals !== pivotRowTotals) {\n pivotRowTotals = next.pivotRowTotals\n if (!groupChanged && !aggChanged && !pivotChanged) {\n scheduleEmit()\n return\n }\n }\n if (!groupChanged && !aggChanged && !pivotChanged) return\n if (next.groupBy) groupBy = [...next.groupBy]\n if (next.aggregations) aggregations = [...next.aggregations]\n if (next.pivotBy) pivotBy = [...next.pivotBy]\n if (next.pivotMode !== undefined) pivotMode = next.pivotMode\n // New group columns make new rows, and pivoted group rows and plain\n // group rows are different rows: neither keeps its open state. A\n // change of aggregations alone keeps the tree open and refetches it.\n if (groupChanged || pivotChanged) {\n expanded.clear()\n seenGroups.clear()\n }\n rebuild()\n },\n toggleGroup(row) {\n if (expanded.has(row.id)) collapseGroup(row.path)\n else expandGroup(row.path)\n },\n expandGroup,\n collapseGroup,\n toggleDetail(id, open) {\n const next = open ?? !openDetails.has(id)\n if (next === openDetails.has(id)) return\n if (next) openDetails.add(id)\n else openDetails.delete(id)\n scheduleEmit()\n },\n isDetailOpen(id) {\n return openDetails.has(id)\n },\n closeAllDetails() {\n if (!openDetails.size) return\n openDetails.clear()\n scheduleEmit()\n },\n expandAll(opts) {\n autoExpand = !!opts?.includeUnloaded\n for (const store of [...stores.values()]) {\n for (const child of store.cache.rows()) {\n if (rowPlaceholderState(child) || child.kind !== 'group') continue\n expandGroup([...store.route, child.key])\n }\n }\n },\n collapseAll() {\n autoExpand = false\n const open = [...expanded].map((k) => JSON.parse(k) as string[])\n expanded.clear()\n if (options.purgeClosedGroups) for (const r of open) dropSubtree(r)\n scheduleEmit()\n },\n isExpanded: (route) => expanded.has(routeKey(route)),\n applyRowData({ route = [], rows, rowCount, startRow = 0 }) {\n if (disposed) return\n const store = ensureStore(route)\n store.cache.applyRows(startRow, toChildren(route, startRow, rows), rowCount)\n scheduleEmit()\n },\n applyTransaction,\n applyTransactionAsync(tx, callback) {\n asyncQueue.push({ tx, callback })\n scheduleAsyncFlush()\n },\n flushAsyncTransactions,\n updateRowData,\n async createRow(input, route = [], addIndex) {\n if (!source.createRow) throw new Error('createServerRowModel: the datasource does not implement createRow()')\n const saved = await saving(() => source.createRow!(input))\n if (disposed) return saved\n applyTransaction({ route, add: [saved], ...(addIndex !== undefined ? { addIndex } : {}) })\n return saved\n },\n async updateRow(id, patch) {\n if (!source.updateRow) throw new Error('createServerRowModel: the datasource does not implement updateRow()')\n const before = options.optimistic ? locate(id) : null\n const previous = before ? ((before.store.cache.getRow(before.index) as Child<TData>).data as TData) : null\n // Show the patch now; the server answer replaces it, a rejection restores it.\n if (previous) updateRowData(id, patch)\n let saved: TData\n try {\n saved = await saving(() => source.updateRow!(id, patch))\n } catch (err) {\n if (previous && !disposed) updateRowData(id, previous, { replace: true })\n throw err\n }\n if (disposed) return saved\n const hit = locate(id)\n if (hit) applyTransaction({ route: hit.store.route, update: [saved] })\n return saved\n },\n async moveRow(id, toRoute, opts = {}) {\n const from = locate(id)\n if (!from) throw new Error(`createServerRowModel: moveRow: row ${id} is not loaded`)\n const child = from.store.cache.getRow(from.index) as Child<TData>\n let row = child.data\n if (opts.patch) {\n if (!source.updateRow) throw new Error('createServerRowModel: the datasource does not implement updateRow()')\n row = await saving(() => source.updateRow!(id, opts.patch!))\n if (disposed) return { row, status: 'cancelled' }\n }\n const fromRoute = [...from.store.route]\n // A moved group's children were cached under its old route.\n if (child.kind === 'group') dropSubtree([...fromRoute, child.key])\n applyTransaction({ route: fromRoute, remove: [id] })\n const res = applyTransaction({\n route: [...toRoute],\n add: [row],\n ...(opts.addIndex !== undefined ? { addIndex: opts.addIndex } : {}),\n })\n // A target level nothing has opened cannot take the row, but its group\n // row is on screen and the row did go there: the badge follows now\n // rather than at the next refresh.\n if (res.status === 'storeNotFound' && toRoute.length) adjustChildCount([...toRoute], 1)\n return { row, status: res.status }\n },\n async deleteRow(id) {\n if (!source.deleteRow) throw new Error('createServerRowModel: the datasource does not implement deleteRow()')\n const before = options.optimistic ? locate(id) : null\n const removed = before ? ((before.store.cache.getRow(before.index) as Child<TData>).data as TData) : null\n const route = before ? [...before.store.route] : []\n const index = before?.index ?? 0\n // Take the row out now; a rejection puts it back where it was.\n if (removed) applyTransaction({ route, remove: [id] })\n try {\n await saving(() => source.deleteRow!(id))\n } catch (err) {\n if (removed && !disposed) applyTransaction({ route, add: [removed], addIndex: index })\n throw err\n }\n if (disposed || removed) return\n const hit = locate(id)\n if (hit) applyTransaction({ route: hit.store.route, remove: [id] })\n },\n setViewport(startIndex, endIndex) {\n lastViewport = [Math.max(0, startIndex), Math.max(startIndex, endIndex)]\n applyViewport()\n },\n setPage(next) {\n if (!paging || disposed) return\n const last = Math.max(0, Math.ceil(pagerRowCount / pageSize) - 1)\n const clamped = Math.max(0, Math.min(Math.floor(next), last))\n if (clamped === pageIndex) return\n pageIndex = clamped\n // The grid keeps reporting the same on-screen indices from page to\n // page, so the new page asks for its own blocks here.\n current = buildState()\n applyViewport()\n const root = stores.get(routeKey([]))\n if (root) primeStore(root)\n scheduleEmit()\n },\n setPageSize(next) {\n if (!paging || disposed) return\n const size = Math.max(1, Math.floor(next))\n if (size === pageSize) return\n const firstRow = pageIndex * pageSize\n pageSize = size\n pageIndex = Math.floor(firstRow / size)\n current = buildState()\n applyViewport()\n const root = stores.get(routeKey([]))\n if (root) primeStore(root)\n scheduleEmit()\n },\n loadMoreChildren(route) {\n const store = stores.get(routeKey(route))\n if (!store || disposed) return\n const rows = store.cache.rows()\n let loaded = 0\n while (loaded < rows.length && !rowPlaceholderState(rows[loaded])) loaded += 1\n store.cache.setViewport(loaded, loaded + store.blockSize - 1)\n scheduleEmit()\n },\n selectionModel,\n getSelectionState: () => selectionModel?.getState() ?? null,\n setSelectionState(state) {\n selectionModel?.setState(isCallbackSelectionState(state) ? fromCallbackSelectionState(state) : state)\n },\n bulkUpdate,\n getLevelState(route = []) {\n const store = stores.get(routeKey(route))\n return store ? levelState(store) : null\n },\n levelStates: () => [...stores.values()].map(levelState),\n getCacheState() {\n const out: Array<BlockState & { route: string[] }> = []\n for (const store of stores.values()) {\n for (const b of store.cache.getCacheState()) out.push({ ...b, route: store.route })\n }\n return out\n },\n // Fresh, not the last emitted snapshot: a caller reading state straight\n // after `collapseAll()` expects to see it collapsed, and the emit is a\n // microtask away. `getRows()` stays on the snapshot - it is the hot path.\n getState: () => (current = buildState()),\n dispose() {\n disposed = true\n generation += 1\n if (asyncTimer) clearTimeout(asyncTimer)\n asyncTimer = null\n asyncQueue.length = 0\n for (const s of stores.values()) s.cache.dispose()\n stores.clear()\n subscribers.clear()\n source.destroy?.()\n },\n get pinnedTopRows() {\n return grandTotalRow === 'pinnedTop' ? pinnedGrandTotal() : undefined\n },\n get pinnedBottomRows() {\n return grandTotalRow === 'pinnedBottom' ? pinnedGrandTotal() : undefined\n },\n get pivotResultColumns() {\n return pivotResultColumns()\n },\n // A getter: the grid re-reads it after every change notification.\n get pagination() {\n if (!paging) return undefined\n return {\n pageIndex,\n pageSize,\n rowCount: pagerRowCount,\n setPage: (i: number) => model.setPage(i),\n setPageSize: (n: number) => model.setPageSize(n),\n ...(paging.pageSizes ? { pageSizes: paging.pageSizes } : {}),\n ...(paging.autoPageSize ? { autoPageSize: true } : {}),\n }\n },\n\n // --- GridRowModel ---------------------------------------------------\n subscribe(onChange) {\n subscribers.add(onChange)\n return () => {\n subscribers.delete(onChange)\n }\n },\n getRows: () => current.gridRows,\n isLoading: () => current.loading,\n getRowId: (row, index) =>\n row.__group && 'id' in row.__group ? row.__group.id : `sv-ph:${index}`,\n rowPlaceholder: (row) => rowPlaceholderState(row),\n retryRow(_row, rowIndex) {\n // The block under the clicked row; every failed block only when the\n // index maps to no level (a stale index after a layout change).\n for (const seg of segments) {\n if (rowIndex < seg.displayStart || rowIndex >= seg.displayStart + seg.length) continue\n seg.store.cache.retryFailedAt(rowIndex - seg.displayStart + seg.storeStart)\n return\n }\n for (const store of stores.values()) store.cache.retryFailed()\n },\n group: nav,\n selection: selectionAdapter,\n filterValues: options.filterValues,\n }\n\n return model\n}\n```\n\n### `function serverRowModelRows`\n\nMap a state's display rows to grid rows. `state.gridRows` already holds\nthis; the function exists for parity with `serverGroupRows` and for code\nthat keeps its own display list.\n\n```ts\nexport function serverRowModelRows<TData>(\n state: ServerRowModelState<TData> | null | undefined,\n): ReadonlyArray<ServerRowModelGridRow<TData>> {\n return state?.gridRows ?? []\n}\n```\n\n### `function serverRowModelNav`\n\nThe `serverGroup` prop for a grid driven by hand rather than by `rowModel`.\n\n```ts\nexport function serverRowModelNav<TData>(ctl: ServerRowModel<TData>) {\n return ctl.group!\n}\n```\n\n### `function serverGroupText`\n\nThe group column's text for one grid row: a group's key (or its\n`leafField` under `treeData`, where a node's key is its id), `Total` for a\nfooter, `Grand total` for the grand total, the `leafField` value of a leaf,\nand nothing for the rows that carry no data (placeholders, skeletons, the\n\"load more\" row). `SvGroupCell` draws the expander, the count and the\nspinner; this is the value behind it, so give the group column\n`fieldFn: (row) => serverGroupText(row, leafField)` and copy, export, the\nclipboard and a `pinnedTop` / `pinnedBottom` grand total (which the grid\nformats from the accessor, not from the cell renderer) all read the same\ntext. `messages` is the map `SvGroupCell` takes, so a localized footer\nand grand total read the same in the cell and in a copy.\n\n```ts\nexport function serverGroupText<TData>(\n row: ServerRowModelGridRow<TData>,\n leafField?: keyof TData & string,\n messages?: Partial<ServerGroupMessages>,\n): string {\n const meta = row.__group\n if (!meta) return ''\n const m = resolveServerGroupMessages(messages)\n switch (meta.kind) {\n case 'group': {\n if (meta.field !== '' || !leafField) return meta.key\n const v = (row as Record<string, unknown>)[leafField]\n return v == null ? meta.key : String(v)\n }\n case 'footer':\n return fillMessage(m.total, { label: meta.key })\n case 'grandTotal':\n return m.grandTotal\n case 'leaf': {\n if (!leafField) return ''\n const v = (row as Record<string, unknown>)[leafField]\n return v == null ? '' : String(v)\n }\n default:\n return ''\n }\n}\n```\n"
|
|
6460
|
+
"markdown": "# `@svgrid/enterprise` · `server/server-row-model.ts`\n\nAuto-generated. Source: `packages\\enterprise\\src\\server\\server-row-model.ts`.\n\n### `type ServerLevelParams`\n\nPer-level cache settings, from `levelParams`.\n\n```ts\nexport type ServerLevelParams = {\n /** Rows per request at this level. Default: the model's `blockSize`. */\n blockSize?: number\n /** Loaded blocks to keep at this level. Default: the model's `maxBlocksInCache`. */\n maxBlocksInCache?: number\n /**\n * `false` loads the whole level as soon as it opens (block by block, still\n * under the concurrency cap) instead of waiting for the viewport. A level\n * that is fully loaded can also be sorted in the browser - see\n * `clientSideSort`.\n */\n infinite?: boolean\n /**\n * Children arrive one block at a time behind a \"Load N more\" row instead\n * of by scroll: the level shows what it has loaded, then a `more` row\n * while the server holds more. `loadMoreChildren(route)` - or the row's\n * own toggle - fetches the next block. Wins over `infinite`.\n */\n loadMore?: boolean\n /**\n * Rows this level claims before its first block lands, as placeholders.\n * Default: the model's `skeletonRows`. Set it to the count you expect at\n * the root of flat data and `api.scrollToRow(900000)` works before\n * anything has loaded: the scrollbar is already the right length, the\n * rows there are placeholders, and the block under them is what loads.\n */\n initialRowCount?: number\n}\n```\n\n### `type ServerRowModelPaginationOptions`\n\nPaging the top level instead of scrolling it. See `ServerRowModelOptions.pagination`.\n\n```ts\nexport type ServerRowModelPaginationOptions = {\n /** Rows per page. Default 100. */\n pageSize?: number\n /** Choices for the footer's page-size selector. */\n pageSizes?: number[]\n /**\n * Fit the page to the grid: as many rows as its body shows without\n * scrolling, re-measured when the grid resizes. `pageSize` is then the\n * size until the first measurement.\n */\n autoPageSize?: boolean\n /**\n * Count every row of the flattened tree towards the page, children\n * included. Off, a page is `pageSize` top-level rows, each shown with\n * whatever is open beneath it.\n */\n paginateChildRows?: boolean\n}\n```\n\n### `type ServerRowModelPagination`\n\nWhere the pager stands.\n\n```ts\nexport type ServerRowModelPagination = {\n pageIndex: number\n pageSize: number\n /** Top-level rows, or flattened rows with `paginateChildRows`. */\n rowCount: number\n pageCount: number\n}\n```\n\n### `type ServerLevelState`\n\nWhere a route's rows stand, for diagnostics.\n\n```ts\nexport type ServerLevelState = {\n route: string[]\n level: number\n rowCount: number | null\n lastRowKnown: boolean\n loading: boolean\n failedBlocks: number[]\n blocks: BlockState[]\n}\n```\n\n### `type ServerRowModelOptions`\n\nEverything `createServerRowModel` is built from: grouping, tree, pivot, blocks per level, totals, paging, selection, transactions and events.\n\n```ts\nexport type ServerRowModelOptions<TData> = {\n /** Columns to group on, outer to inner. Leave empty for a flat list or a tree. */\n groupBy?: string[]\n /** Value columns to roll up per group. */\n aggregations?: ServerAggregation[]\n /**\n * Server-side pivot: the columns whose distinct values become columns.\n * Takes effect with `pivotMode`; see `setPivot`.\n */\n pivotBy?: string[]\n pivotMode?: boolean\n /**\n * In pivot mode, a `Total` header group after the pivot keys with the\n * row's plain aggregates (`amount` beside `2024_amount`, `2025_amount`).\n * The backend puts those fields on every group row and on the grand\n * total in pivot mode; the reference sources do. A string is the label.\n */\n pivotRowTotals?: boolean | string\n /** How a backend joins pivot keys and the value column into a field name. Default `_`. */\n pivotFieldSeparator?: string\n /** Post-process each generated pivot value column. */\n pivotResultColumn?: PivotResultColumnOptions['pivotResultColumn']\n /**\n * Columns to put BEFORE the generated pivot columns in pivot mode -\n * the group column with its expander, typically. The pivot columns\n * replace the grid's `columns` wholesale, so this is where the\n * app's own leading columns come back in.\n */\n pivotLeadingColumns?: ReadonlyArray<unknown>\n /**\n * Self-referential tree: each row is a node whose children load on expand.\n * Needs `getRowId` (the node id, which builds the `groupKeys` path) and\n * `hasChildren`.\n */\n treeData?: boolean\n /** Stable row id. Needed for tree data, selection and transactions. */\n getRowId?: (row: TData) => string\n /** Tree data: whether a node can be expanded. */\n hasChildren?: (row: TData) => boolean\n /**\n * How many rows a group holds, read off its response row. Shown beside the\n * key, and used to size the group's scrollbar before its first block lands.\n */\n childCount?: (row: TData) => number | undefined\n /** Open a group as soon as it arrives. Receives the route and the row. */\n isGroupOpenByDefault?: (route: string[], row: TData) => boolean\n /** Per-level cache settings. Called once per route when its store is created. */\n levelParams?: (level: number, route: string[]) => ServerLevelParams\n /** Rows per request, unless `levelParams` says otherwise. Default 100. */\n blockSize?: number\n /** Loaded blocks to keep per level, unless `levelParams` says otherwise. Unlimited by default. */\n maxBlocksInCache?: number\n /** Requests open at once, across every level. Default 2. */\n maxConcurrentRequests?: number\n /** Wait for the scroll to settle this long before fetching. Default 0. */\n blockLoadDebounceMs?: number\n /**\n * Placeholder rows shown for a level before its first block lands - so an\n * expand never opens onto nothing. Default 3.\n */\n skeletonRows?: number\n /**\n * Page the top level instead of scrolling it. Blocks stay independent of\n * pages: a page of 20 over blocks of 100 costs one request per five\n * pages. Omit for infinite scrolling.\n */\n pagination?: ServerRowModelPaginationOptions\n /** Emit a subtotal \"footer\" row after each expanded group's children. */\n groupFooters?: boolean\n /**\n * A grand-total row across the whole result. The root request then carries\n * `needsGrandTotal` and the source answers with `grandTotal`. `top` /\n * `bottom` put it in the scrolling list; the pinned variants hand it to the\n * grid's pinned rows so it stays in view.\n */\n grandTotalRow?: 'top' | 'bottom' | 'pinnedTop' | 'pinnedBottom'\n /**\n * Milliseconds to keep the rows on screen after a sort, a filter or a\n * regroup while the new answer loads. If its first block lands in that\n * time, the grid goes straight from the old rows to the new ones: no\n * frame of skeletons between them, and one update instead of two. A fast\n * source (a worker, a local database) never shows a skeleton; a slow one\n * shows them once the time is up. Default 0: skeletons at once.\n */\n keepRowsWhileLoading?: number\n /** Drop a group's cached children when it is collapsed. Default false. */\n purgeClosedGroups?: boolean\n /**\n * Hide a group row once it is open, so its children stand at its level.\n * The classic \"multi-column group\" look.\n */\n hideOpenParents?: boolean\n /**\n * Treat a group whose key is empty as unbalanced: always open, its group\n * row hidden, its rows shown among the siblings. Costs one extra request\n * per such group.\n */\n allowUnbalancedGroups?: boolean\n /**\n * Which levels a sort re-fetches. The default is selective: a column that\n * is neither grouped nor aggregated only re-orders leaves; a grouped column\n * re-fetches its own level; an aggregated column re-fetches everything.\n * `true` always re-fetches everything.\n */\n sortAllLevels?: boolean\n /**\n * Sort a fully loaded level in the browser instead of asking the server.\n * Plain type-aware comparison; a backend with collation rules of its own\n * should leave this off.\n */\n clientSideSort?: boolean\n /**\n * Re-fetch only the levels a filter touches instead of everything. Cheaper,\n * but the levels above keep their old counts and aggregates, so a group can\n * end up showing zero children. A filter on an aggregated column always\n * re-fetches everything.\n */\n onlyRefreshFilteredGroups?: boolean\n /**\n * Row selection as a RULE rather than a list of ids, so \"select all\" can\n * mean a million rows the grid never loaded. `self` treats a group row as\n * one row; `descendants` makes ticking a group select everything beneath\n * it. Off by default; the grid then keeps selection itself, over loaded\n * rows only. See `ctl.selection`, `getSelectionState`, `bulkUpdate`.\n */\n selection?: { groupSelects?: ServerSelectionGroupMode }\n /**\n * Apply `updateRow` and `deleteRow` to the loaded rows before the server\n * answers, and put them back if it rejects: the same contract as the\n * free controller's `optimistic`. A create waits for the server either\n * way, since the row has no id until then. Off by default, so a cell\n * shows a new value only once the server has it.\n */\n optimistic?: boolean\n /** Forwarded on every request as `ServerRequest.context`. Keep it serialisable. */\n context?: unknown\n /** Distinct values for a column's set filter, for `<SvGrid rowModel>` to use. */\n filterValues?: (columnId: string) => Promise<Array<string>>\n /** Log every block requested, loaded, failed and evicted, with its route. */\n debug?: boolean\n /**\n * Veto a transaction. Return false and it is reported `cancelled` and\n * nothing changes - the hook for \"a refresh is in flight, drop this\".\n */\n isApplyTransaction?: (transaction: ServerTransaction<TData>) => boolean\n /** How long `applyTransactionAsync` batches before flushing. Default 50 ms. */\n asyncTransactionWaitMs?: number\n onChange?: (state: ServerRowModelState<TData>) => void\n /** A batch of async transactions was applied, in order. */\n onAsyncTransactionsFlushed?: (results: ServerTransactionResult[]) => void\n /** A level finished re-fetching after `refresh()` (not after a purge). */\n onStoreRefreshed?: (route: string[]) => void\n /** A group was opened, by the user or by `isGroupOpenByDefault`. */\n onGroupOpened?: (route: string[]) => void\n /** A block failed to load. The row shows a Retry; this is for logging. */\n onLoadError?: (route: string[], error: unknown) => void\n}\n```\n\n### `type ServerRowModelDisplayRow`\n\nWhat this model puts on screen: every shared display row, plus placeholders.\n\n```ts\nexport type ServerRowModelDisplayRow<TData> = ServerDisplayRow<TData> | ServerPlaceholderRow\n```\n\n### `type ServerRowModelState`\n\nWhat the model emits on every change: the flattened display list, the grid rows, the counts and the current layout.\n\n```ts\nexport type ServerRowModelState<TData> = {\n /**\n * The flattened tree, one entry per row the grid shows: groups with their\n * expanded children spliced in, footers, the grand total, and a shared\n * placeholder object for every row not yet loaded.\n */\n displayRows: ReadonlyArray<ServerRowModelDisplayRow<TData>>\n /** `displayRows` mapped to grid rows - each row's data spread, plus `__group`. */\n gridRows: ReadonlyArray<ServerRowModelGridRow<TData>>\n groupBy: string[]\n aggregations: ServerAggregation[]\n /** Rows at the top level, or null until the root has answered. */\n rowCount: number | null\n /** True while the root has never loaded. */\n loading: boolean\n /** True while a `createRow`, `updateRow` or `deleteRow` is in flight. */\n saving: boolean\n /** The rejection from the last failed top-level fetch, else null. */\n error: unknown\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /** Ids (JSON of the route) of the expanded groups. */\n expandedGroups: string[]\n /** Ids (by `getRowId`) of the leaves whose detail panel is open. */\n openDetails: string[]\n /** The grand-total row, when one is configured and has arrived. */\n grandTotal: TData | null\n pivotBy: string[]\n pivotMode: boolean\n /** The pivoted value fields the backend has reported so far, in pivot mode. */\n pivotResultFields: string[]\n /** The pager, or null when the top level scrolls. */\n pagination: ServerRowModelPagination | null\n}\n```\n\n### `type ServerRowModelGridRow`\n\nWhat the grid renders: the row's own fields at the top level (so a plain\n`field` column shows a subtotal on a group row and the value on a leaf)\nplus the display row under `__group`. Placeholders carry only the marker.\n\n```ts\nexport type ServerRowModelGridRow<TData> = (TData | Record<string, never>) & {\n __group: ServerRowModelDisplayRow<TData>\n}\n```\n\n### `type ServerTransaction`\n\nAdd, update and remove rows in one level without a request.\n\nRows are matched by `getRowId` at a leaf level, and by the group column's\nvalue at a group level (a group row IS its key). The grand total answers\nto the fixed id `sv-grand-total`.\n\n```ts\nexport type ServerTransaction<TData> = {\n /** The level to apply to. Omit for the top level. */\n route?: string[]\n add?: ReadonlyArray<TData>\n /** Where `add` goes. Default: the end of the level. */\n addIndex?: number\n update?: ReadonlyArray<TData>\n /** Rows to remove, or their ids. */\n remove?: ReadonlyArray<TData | string>\n /**\n * The level's row count afterwards, when you know it better than the\n * arithmetic does - a delete that also removed rows outside the cache,\n * say. Applied last.\n */\n rowCount?: number\n}\n```\n\n### `type ServerTransactionStatus`\n\nHow a transaction went. `applied`: every part that could be applied was.\n`storeNotFound`: no level exists for `route` (nothing has been expanded\nthere). `cancelled`: `isApplyTransaction` said no. `storeLoading`: the\nlevel's first block is still in flight. `storeWaitingToLoad`: the level\nexists but nothing has been requested from it yet. `storeLoadingFailed`:\nthe level's load failed; retry it before applying.\n\n```ts\nexport type ServerTransactionStatus =\n | 'applied'\n | 'storeNotFound'\n | 'cancelled'\n | 'storeLoading'\n | 'storeWaitingToLoad'\n | 'storeLoadingFailed'\n```\n\n### `type ServerTransactionResult`\n\nWhat `applyTransaction` returns: the status and the ids it placed, patched and removed.\n\n```ts\nexport type ServerTransactionResult = {\n status: ServerTransactionStatus\n /** Ids of the rows placed in the cache. */\n add: string[]\n /** Ids of the rows found and updated. */\n update: string[]\n /** Ids of the rows found and removed. */\n remove: string[]\n}\n```\n\n### `type RefreshOptions`\n\nWhat `refresh` takes: the level to re-read (`[]` is the root) and whether to drop its cache first.\n\n```ts\nexport type RefreshOptions = {\n /** The group whose children to reload. Omit for the top level. */\n route?: string[]\n /**\n * Drop the cache for that level and everything beneath it and start over.\n * Without it the loaded blocks re-fetch in place: the count, the scroll\n * position and every expanded descendant survive.\n */\n purge?: boolean\n}\n```\n\n### `type ServerRowModel`\n\nThe Server-Side Row Model: the controller surface plus the `GridRowModel` shape, so one object drives the grid through `rowModel`.\n\n```ts\nexport type ServerRowModel<TData> = GridRowModel<ServerRowModelGridRow<TData>> & {\n /** The group accessors the grid's treegrid keyboard and `SvGroupCell` use. Always present on this model. */\n group: NonNullable<GridRowModel<ServerRowModelGridRow<TData>>['group']>\n /** Load the top level, or reload a level. See {@link RefreshOptions}. */\n refresh(options?: RefreshOptions): void\n /** Re-fetch every block that failed, at every level. */\n retryLoads(): void\n /** Replace the sort and re-fetch the levels it affects. Takes either filter shape. */\n setSort(sortModel: ServerSortModel): void\n setFilter(filterModel: ServerFilterModel | GridFilterState): void\n /** Change the grouping. Collapses everything and reloads the top level. */\n setGroupBy(groupBy: string[]): void\n /** Change the aggregations. Reloads every level. */\n setAggregations(aggregations: ServerAggregation[]): void\n /**\n * Change the pivot columns and / or switch pivot mode. Reloads every\n * level. In pivot mode the innermost group level cannot be opened: its\n * rows are the pivoted aggregates, and there are no leaves under them.\n */\n setPivot(pivot: { pivotBy?: string[]; pivotMode?: boolean }): void\n /**\n * Change any of group-by, aggregations and pivot in one go, with ONE\n * reload - what a designer sends on Apply. A field left out keeps its\n * value; a layout equal to the current one does nothing at all.\n */\n setLayout(layout: {\n groupBy?: string[]\n aggregations?: ServerAggregation[]\n pivotBy?: string[]\n pivotMode?: boolean\n pivotRowTotals?: boolean | string\n }): void\n /** Go to a page (clamped to what exists). No-op without `pagination`. */\n setPage(pageIndex: number): void\n /** Change the page size; the current first row stays on screen. */\n setPageSize(pageSize: number): void\n /** Fetch the next block of a `loadMore` level (the \"Load N more\" row). */\n loadMoreChildren(route: string[]): void\n toggleGroup(row: ServerGroupRow<TData>): void\n expandGroup(route: string[]): void\n collapseGroup(route: string[]): void\n /**\n * Open every group. With `includeUnloaded`, groups that arrive later open\n * on arrival too, until the next `collapseAll()`.\n */\n expandAll(options?: { includeUnloaded?: boolean }): void\n collapseAll(): void\n isExpanded(route: string[]): boolean\n /**\n * Master-detail: open or close the detail panel under a leaf, by the id\n * `getRowId` gives it. The panel is a display row of kind `detail` right\n * under its leaf, carrying the leaf as `master`; mark it for the grid\n * with `isDetailRow: (row) => row.__group?.kind === 'detail'` and draw\n * it with `renderDetailRow` (give `detailRowHeight` under\n * virtualization). Closes with the group that holds the leaf, and goes\n * when the leaf is removed.\n */\n toggleDetail(id: string, open?: boolean): void\n isDetailOpen(id: string): boolean\n /** Close every open detail panel. */\n closeAllDetails(): void\n /**\n * Write rows straight into a level, bypassing the datasource: children\n * that came with their parent, a socket that pushed a whole level. Creates\n * the level's store if it does not exist. `startRow` must sit on a block\n * boundary for that level.\n */\n applyRowData(input: {\n route?: string[]\n rows: ReadonlyArray<TData>\n rowCount?: number\n startRow?: number\n }): void\n /** Apply a transaction now. See {@link ServerTransaction}. */\n applyTransaction(transaction: ServerTransaction<TData>): ServerTransactionResult\n /**\n * Queue a transaction and apply it with whatever else arrives within\n * `asyncTransactionWaitMs`, so a burst of updates re-renders once. A\n * transaction whose level is still loading waits for it. The callback\n * gets that transaction's result when the batch flushes.\n */\n applyTransactionAsync(\n transaction: ServerTransaction<TData>,\n callback?: (result: ServerTransactionResult) => void,\n ): void\n /** Apply every queued async transaction now. */\n flushAsyncTransactions(): void\n /**\n * Patch one loaded row in place, on whatever level holds it, without a\n * request and without changing its id. The path for a socket-driven\n * tick; `applyTransaction` is for adding and removing. `replace` swaps\n * the whole row instead of merging the patch. Returns whether it was found.\n */\n updateRowData(id: string, patch: Partial<TData>, options?: { replace?: boolean }): boolean\n /**\n * Write through the source, then update the cache with what it returned\n * (no refetch). Throws when the source lacks the method. Aggregates are\n * NOT recomputed - `refresh({ route })` the parent when a subtotal must\n * follow. A created row lands at `addIndex` within its level, else at the\n * end, which in a level of thousands is out of sight: pass `0` for the\n * top, or the index a deleted row had to put it back where it was.\n */\n createRow(input: Partial<TData>, route?: string[], addIndex?: number): Promise<TData>\n updateRow(id: string, patch: Partial<TData>): Promise<TData>\n deleteRow(id: string): Promise<void>\n /**\n * Move a loaded row to another level: a file dragged into a folder, an\n * order into another region. With `patch` the move is written through\n * `source.updateRow` first (the parent field, typically) and the saved\n * row is what lands; without it the cache alone moves. The row leaves\n * its level as a transaction and joins `toRoute` at `addIndex` (the end\n * by default). A group takes its open children with it in the sense\n * that their cached level is dropped: they are read again under the\n * new route. A target level nothing has opened reports `storeNotFound`\n * and holds the row once it opens; the parent counts follow either way.\n */\n moveRow(\n id: string,\n toRoute: string[],\n options?: { patch?: Partial<TData>; addIndex?: number },\n ): Promise<{ row: TData; status: ServerTransactionStatus }>\n /** Tell the model which display rows are on screen. `<SvGrid rowModel>` does this. */\n setViewport(startIndex: number, endIndex: number): void\n /** The selection rule, when `selection` was configured. */\n readonly selectionModel: ServerSelectionModel | null\n /** The rule as plain data, for saving or for sending to a bulk endpoint. */\n getSelectionState(): ServerSelectionState | ServerGroupSelectionNode | null\n /**\n * Restore a saved rule. The callback-style shape (`toggledNodes`) is\n * accepted as it is, with group ids read as group keys; pass it through\n * `fromCallbackSelectionState(state, mapping)` first when they differ.\n */\n setSelectionState(\n state: ServerSelectionState | ServerGroupSelectionNode | CallbackSelectionState | CallbackGroupSelectionState,\n ): void\n /**\n * Apply one patch to every selected row, loaded or not, through the\n * datasource's `updateWhere`, then reload every level so the rows and\n * their aggregates reflect it. Resolves with how many rows changed.\n * Throws when the source has no `updateWhere` - a bulk edit is refused\n * rather than silently applied to the loaded rows only.\n */\n bulkUpdate(patch: Partial<TData>): Promise<number>\n getLevelState(route?: string[]): ServerLevelState | null\n levelStates(): ServerLevelState[]\n /** Every block at every level, for logging and tests. */\n getCacheState(): Array<BlockState & { route: string[] }>\n getState(): ServerRowModelState<TData>\n /** Abort what is in flight, close the source, stop emitting. Call on unmount. */\n dispose(): void\n /** The grand-total row for `pinnedTop` / `pinnedBottom`, or nothing. */\n readonly pinnedTopRows: ReadonlyArray<ServerRowModelGridRow<TData>> | undefined\n readonly pinnedBottomRows: ReadonlyArray<ServerRowModelGridRow<TData>> | undefined\n}\n```\n\n### `const GRAND_TOTAL_ROW_ID`\n\nThe fixed id of the grand-total display row, so a transaction can address it.\n\n```ts\nexport const GRAND_TOTAL_ROW_ID = 'sv-grand-total' as const\n```\n\n### `const GROUP_TOTAL_ROW_ID_PREFIX`\n\nThe prefix of a group footer's id; the rest is the group route.\n\n```ts\nexport const GROUP_TOTAL_ROW_ID_PREFIX = 'sv-group-total:' as const\n```\n\n### `function createServerRowModel`\n\nBuild the Server-Side Row Model over a `ServerDataSource`: lazy grouping\nand tree data one level at a time, a block cache per level, aggregates,\nchild counts, grand totals, pivot, paging, transactions and a selection\nthat reaches rows the grid never loaded. Hand the result to\n`<SvGrid rowModel>`. Enterprise: nudges when no licence key is set.\n\n```ts\nexport function createServerRowModel<TData>(\n source: ServerDataSource<TData>,\n options: ServerRowModelOptions<TData>,\n): ServerRowModel<TData> {\n // Runs without a key; shows the watermark + one console notice.\n nudgeServerRowModel()\n\n let groupBy = [...(options.groupBy ?? [])]\n let aggregations = [...(options.aggregations ?? [])]\n let pivotBy = [...(options.pivotBy ?? [])]\n let pivotMode = !!options.pivotMode\n let pivotRowTotals: boolean | string = options.pivotRowTotals ?? false\n const pivotSeparator = options.pivotFieldSeparator ?? '_'\n /** The union of every `pivotResultFields` seen since the last reset, in order. */\n let pivotFields: string[] = []\n /** Verbatim column defs from the backend, when it sent them instead of fields. */\n let pivotColumnsFromSource: ReadonlyArray<unknown> | null = null\n const treeData = !!options.treeData\n if (treeData && (!options.getRowId || !options.hasChildren)) {\n throw new Error('createServerRowModel: treeData requires getRowId and hasChildren')\n }\n const getRowId = options.getRowId\n const hasChildren = options.hasChildren\n const childCountOf = options.childCount\n const skeletonRows = Math.max(1, options.skeletonRows ?? 3)\n const paging = options.pagination ?? null\n let pageIndex = 0\n let pageSize = Math.max(1, paging?.pageSize ?? 100)\n /** Rows the pager counts, as of the last flatten. */\n let pagerRowCount = 0\n const groupFooters = !!options.groupFooters\n const grandTotalRow = options.grandTotalRow\n const debug = !!options.debug\n\n let sortModel: ServerSortModel = []\n let filterModel: ServerFilterModel = {}\n\n const stores = new Map<string, Store<TData>>()\n const expanded = new Set<string>()\n /** Group routes that have been through `isGroupOpenByDefault` once. */\n const seenGroups = new Set<string>()\n let autoExpand = false\n\n let grandTotal: TData | null | undefined = undefined\n /**\n * The grand total on hand is from before an in-place refresh of the top\n * level or a bulk edit, so the next root block asks for a fresh one. The\n * old one stays on screen until it lands rather than blinking out.\n */\n let grandTotalStale = false\n /** Leaves whose detail panel is open, by the id `getRowId` gives them. */\n const openDetails = new Set<string>()\n let rootError: unknown = null\n let disposed = false\n /** Writes the server has not answered yet; `saving` is \"any\". */\n let writesInFlight = 0\n\n // Generation guard: bumped on any reset so responses for the previous shape\n // are discarded when they land.\n let generation = 0\n\n // A single in-flight budget across every level. The block cache caps\n // concurrency per instance; with one instance per route that would let ten\n // open groups fire twenty requests, so the gate lives here.\n const maxConcurrent = Math.max(1, options.maxConcurrentRequests ?? 2)\n let inFlight = 0\n const waiting: Array<() => void> = []\n const acquire = (): Promise<void> => {\n if (inFlight < maxConcurrent) {\n inFlight += 1\n return Promise.resolve()\n }\n return new Promise((resolve) => waiting.push(() => { inFlight += 1; resolve() }))\n }\n const release = (): void => {\n inFlight -= 1\n waiting.shift()?.()\n }\n\n const subscribers = new Set<() => void>()\n let lastViewport: [number, number] = [0, 0]\n let segments: Segment<TData>[] = []\n let emitScheduled = false\n\n // ----------------------------------------------------------- stores\n\n function levelOf(route: ReadonlyArray<string>): number {\n return route.length\n }\n\n function isLeafLevel(level: number): boolean {\n return !treeData && level >= groupBy.length\n }\n\n /** The group row `route` names, if its parent level has it loaded. */\n function parentRowOf(route: string[]): TData | undefined {\n if (route.length === 0) return undefined\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return undefined\n const key = route[route.length - 1]\n const rows = parent.cache.rows()\n for (const child of rows) {\n if (rowPlaceholderState(child)) continue\n if (child.kind === 'group' && child.key === key) return child.data\n }\n return undefined\n }\n\n function toChildren(route: string[], startRow: number, rows: ReadonlyArray<TData>): Child<TData>[] {\n const level = levelOf(route)\n const groupField = groupBy[level]\n const storeKey = routeKey(route)\n return rows.map((data, i): Child<TData> => {\n const isGroup = treeData ? hasChildren!(data) : level < groupBy.length\n if (isGroup) {\n const key = treeData\n ? getRowId!(data)\n : ((v) => (v == null ? '' : String(v)))((data as Record<string, unknown>)[groupField!])\n const agg: Record<string, unknown> = {}\n for (const a of aggregations) agg[a.col] = (data as Record<string, unknown>)[a.col]\n return {\n kind: 'group',\n key,\n field: treeData ? '' : groupField!,\n aggregates: agg,\n childCount: childCountOf?.(data),\n data,\n }\n }\n const id = getRowId ? `${storeKey}:${getRowId(data)}` : `${storeKey}:${startRow + i}`\n return { kind: 'leaf', id, data }\n })\n }\n\n function createStore(route: string[]): Store<TData> {\n const level = levelOf(route)\n const params = options.levelParams?.(level, route) ?? {}\n const blockSize = Math.max(1, params.blockSize ?? options.blockSize ?? 100)\n const loadMore = !!params.loadMore\n const infinite = !loadMore && params.infinite !== false\n const key = routeKey(route)\n const gen = generation\n\n const store: Store<TData> = {\n route,\n key,\n level,\n infinite,\n loadMore,\n blockSize,\n refreshPending: false,\n seenBlocks: new Map(),\n cache: null as unknown as BlockCache<Child<TData>>,\n }\n\n store.cache = createBlockCache<Child<TData>>({\n blockSize,\n maxBlocksInCache: params.maxBlocksInCache ?? options.maxBlocksInCache,\n // The model gates concurrency itself, across levels.\n maxConcurrentRequests: Number.MAX_SAFE_INTEGER,\n blockLoadDebounceMs: options.blockLoadDebounceMs,\n initialRowCount: Math.max(1, params.initialRowCount ?? skeletonRows),\n fetch: async (startRow, endRow, signal) => {\n await acquire()\n try {\n if (disposed || gen !== generation || signal.aborted) {\n throw new Error('stale')\n }\n const needsGrandTotal =\n route.length === 0 && grandTotalRow != null && (grandTotal === undefined || grandTotalStale)\n const request: ServerRequest = {\n startRow,\n endRow,\n pageIndex: Math.floor(startRow / blockSize),\n pageSize: blockSize,\n sortModel,\n filterModel,\n groupBy,\n groupKeys: route,\n aggregations,\n ...(pivotMode ? { pivotBy, pivotMode: true } : {}),\n ...(needsGrandTotal ? { needsGrandTotal: true } : {}),\n ...(route.length ? { parentRow: parentRowOf(route) } : {}),\n ...(options.context !== undefined ? { context: options.context } : {}),\n signal,\n }\n let result\n try {\n result = await source.getRows(request)\n } catch (err) {\n if (!disposed && gen === generation) {\n if (route.length === 0) rootError = err\n options.onLoadError?.(route, err)\n }\n throw err\n }\n if (disposed || gen !== generation) throw new Error('stale')\n if (route.length === 0) {\n rootError = null\n if ('grandTotal' in result) {\n grandTotal = result.grandTotal ?? null\n grandTotalStale = false\n }\n }\n if (pivotMode) {\n if (result.pivotResultColumns) pivotColumnsFromSource = result.pivotResultColumns\n for (const f of result.pivotResultFields ?? []) {\n if (!pivotFields.includes(f)) pivotFields.push(f)\n }\n }\n return {\n rows: toChildren(route, startRow, result.rows),\n rowCount: result.rowCount >= 0 ? result.rowCount : undefined,\n }\n } finally {\n release()\n }\n },\n onChange: (s) => {\n if (disposed || gen !== generation) return\n // Runs once per block that just landed: open-by-default, and the\n // debug log. Scanning only those blocks keeps this O(blockSize)\n // rather than O(rows) on every change.\n for (const index of diffBlocks(store)) openByDefaultIn(store, index)\n if (store.refreshPending && !s.loading) {\n store.refreshPending = false\n options.onStoreRefreshed?.(route)\n }\n // A level that is not infinite loads itself completely once it knows\n // how big it is.\n if (!infinite && !loadMore && s.lastRowKnown && s.rowCount != null) {\n store.cache.setViewport(0, Math.max(0, s.rowCount - 1))\n }\n scheduleEmit()\n },\n })\n stores.set(key, store)\n return store\n }\n\n function ensureStore(route: string[]): Store<TData> {\n return stores.get(routeKey(route)) ?? createStore(route)\n }\n\n function dropSubtree(route: string[]): void {\n const prefix = routeKey(route)\n for (const [key, store] of [...stores]) {\n if (key === prefix || isUnder(key, route)) {\n store.cache.dispose()\n stores.delete(key)\n }\n }\n }\n\n function isUnder(key: string, route: string[]): boolean {\n // Routes are JSON arrays; a descendant's key starts with the parent's\n // elements. Compare structurally rather than by string prefix so that\n // [\"a\"] is not mistaken for a parent of [\"ab\"].\n const parsed = JSON.parse(key) as string[]\n if (parsed.length <= route.length) return false\n return route.every((seg, i) => parsed[i] === seg)\n }\n\n /** Block indices that became `loaded` since the last call. Logs when `debug`. */\n function diffBlocks(store: Store<TData>): number[] {\n const now = new Map(store.cache.getCacheState().map((b) => [b.blockIndex, b.status]))\n const landed: number[] = []\n for (const [index, status] of now) {\n const before = store.seenBlocks.get(index)\n if (before === status) continue\n if (status === 'loaded') landed.push(index)\n // eslint-disable-next-line no-console -- opt-in diagnostics, see the debug option\n if (debug) console.debug(`[svgrid ssrm] ${routeKey(store.route)} block ${index}: ${status}`)\n }\n if (debug) {\n for (const index of store.seenBlocks.keys()) {\n // eslint-disable-next-line no-console -- opt-in diagnostics, see the debug option\n if (!now.has(index)) console.debug(`[svgrid ssrm] ${routeKey(store.route)} block ${index}: evicted`)\n }\n }\n store.seenBlocks = now\n return landed\n }\n\n /**\n * Open the groups in a freshly landed block that should start open: by\n * `isGroupOpenByDefault`, by an `expandAll({ includeUnloaded })` in force,\n * or because they are unbalanced. Once per group, so a block that is\n * re-fetched does not re-open something the user closed.\n */\n function openByDefaultIn(store: Store<TData>, blockIndex: number): void {\n const start = blockIndex * store.blockSize\n for (let i = start; i < start + store.blockSize; i += 1) {\n const raw = store.cache.getRow(i)\n if (rowPlaceholderState(raw)) continue\n const child = raw as Child<TData>\n if (child.kind !== 'group') continue\n const childRoute = [...store.route, child.key]\n const ck = routeKey(childRoute)\n if (seenGroups.has(ck)) continue\n seenGroups.add(ck)\n const unbalanced = !!options.allowUnbalancedGroups && child.key === ''\n if (unbalanced || autoExpand || options.isGroupOpenByDefault?.(childRoute, child.data)) {\n expandGroup(childRoute)\n }\n }\n }\n\n // ---------------------------------------------------------- flatten\n\n /**\n * A level's rows as display and grid rows, kept from one emit to the next.\n *\n * A level of leaves maps each cached row to one display row that depends on\n * nothing but that row, so the mapping only has to be redone where the\n * cache changed - and every cache write (a landed block, a patch, a purge)\n * puts a new object at the index it touches. Comparing identities is a\n * tight loop; mapping a million rows per landed block of a hundred was most\n * of what a block cost on the main thread. Keeping the grid row objects also\n * lets the grid keep its own row objects for them.\n *\n * Null for a level holding groups (a group row's display depends on what is\n * expanded beneath it) and while any detail is open (a detail adds a row).\n */\n type LeafRun = {\n rows: ReadonlyArray<Child<TData>>\n display: Array<ServerRowModelDisplayRow<TData>>\n grid: Array<ServerRowModelGridRow<TData>>\n }\n const leafRuns = new WeakMap<Store<TData>, LeafRun>()\n\n function leafRun(store: Store<TData>, rows: ReadonlyArray<Child<TData>>): LeafRun | null {\n if (openDetails.size) return null\n const prev = leafRuns.get(store)\n if (prev && prev.rows === rows) return prev\n const n = rows.length\n // Same length (a block landed, a row was patched): update the kept\n // arrays in place. The flatten only ever copies out of them.\n const inPlace = !!prev && prev.rows.length === n\n const display = inPlace ? prev!.display : new Array<ServerRowModelDisplayRow<TData>>(n)\n const grid = inPlace ? prev!.grid : new Array<ServerRowModelGridRow<TData>>(n)\n const old = prev?.rows\n const oldLength = old ? old.length : 0\n for (let i = 0; i < n; i += 1) {\n const child = rows[i]!\n if (i < oldLength && old![i] === child) {\n if (!inPlace) {\n display[i] = prev!.display[i]!\n grid[i] = prev!.grid[i]!\n }\n continue\n }\n const ph = rowPlaceholderState(child)\n if (ph) {\n const d = ph === 'failed' ? FAILED_ROW : LOADING_ROW\n display[i] = d\n grid[i] = toGridRow(d)\n continue\n }\n if (child.kind !== 'leaf') {\n // Written in place up to here, so the kept run is no longer whole.\n leafRuns.delete(store)\n return null\n }\n const d: ServerLeafRow<TData> = { kind: 'leaf', id: child.id, level: store.level, route: store.route, data: child.data }\n display[i] = d\n grid[i] = toGridRow(d, child.data)\n }\n const run = { rows, display, grid }\n leafRuns.set(store, run)\n return run\n }\n\n /**\n * A level made only of data rows maps one to one: its i-th display row is\n * its i-th child. Such a level is not walked or copied - the flatten records\n * it as one lazy piece and `lazyLeafAt` builds a row when the grid reads it,\n * so a 100M-row level costs the rows on screen, not 100M. Groups, tree\n * data, pivot, load-more, detail rows and child-row paging still walk.\n */\n function isLazyLeafLevel(store: Store<TData>): boolean {\n return (\n !treeData &&\n !pivotMode &&\n store.level >= groupBy.length &&\n !store.loadMore &&\n openDetails.size === 0 &&\n !paging?.paginateChildRows\n )\n }\n type LazyEntry = {\n child: unknown\n d: ServerRowModelDisplayRow<TData>\n g: ServerRowModelGridRow<TData>\n }\n // Replaced when a write settles (see `saving`): the grid writes a cell edit\n // into the row object it was handed, so a row the server refused would keep\n // showing the refused value while its child, which nothing changed, still\n // matched the memo.\n let lazyMemo = new WeakMap<Store<TData>, Map<number, LazyEntry>>()\n const LAZY_MEMO_CAP = 20_000\n /** Row `index` of a lazy leaf level, built once per child object it holds. */\n function lazyLeafAt(store: Store<TData>, index: number): LazyEntry {\n const child = store.cache.getRow(index) as Child<TData>\n const ph = rowPlaceholderState(child)\n if (ph) {\n const d = ph === 'failed' ? FAILED_ROW : LOADING_ROW\n return { child, d, g: toGridRow(d) }\n }\n let memo = lazyMemo.get(store)\n if (!memo) {\n memo = new Map()\n lazyMemo.set(store, memo)\n }\n const hit = memo.get(index)\n if (hit && hit.child === child) return hit\n const leaf = child as Extract<Child<TData>, { kind: 'leaf' }>\n const d: ServerLeafRow<TData> = { kind: 'leaf', id: leaf.id, level: store.level, route: store.route, data: leaf.data }\n const entry: LazyEntry = { child, d, g: toGridRow(d, leaf.data) }\n memo.set(index, entry)\n if (memo.size > LAZY_MEMO_CAP) {\n let drop = LAZY_MEMO_CAP / 4\n for (const key of memo.keys()) {\n if (drop-- <= 0) break\n memo.delete(key)\n }\n }\n return entry\n }\n\n function flatten(): {\n display: ServerRowModelDisplayRow<TData>[]\n grid: ServerRowModelGridRow<TData>[]\n } {\n // The output is a list of pieces: explicit rows (group rows, footers, a\n // walked level) and lazy leaf levels. Small or walked lists come back as\n // plain arrays; a list with a lazy piece comes back windowed.\n type Piece = {\n start: number\n length: number\n display?: ServerRowModelDisplayRow<TData>[]\n grid?: ServerRowModelGridRow<TData>[]\n store?: Store<TData>\n from?: number\n }\n const pieces: Piece[] = []\n let total = 0\n let open: Piece | null = null\n segments = []\n\n const pushRow = (d: ServerRowModelDisplayRow<TData>, data?: TData): void => {\n if (!open) {\n open = { start: total, length: 0, display: [], grid: [] }\n pieces.push(open)\n }\n open.display!.push(d)\n open.grid!.push(toGridRow(d, data))\n open.length += 1\n total += 1\n }\n const pushPiece = (piece: Piece): void => {\n open = null\n pieces.push(piece)\n total += piece.length\n }\n\n if (grandTotalRow === 'top' && grandTotal) pushRow(grandTotalDisplay(grandTotal), grandTotal)\n const bodyStart = total\n\n /**\n * A `loadMore` level shows its loaded prefix and a \"more\" row; rows\n * past the first placeholder are not the grid's to scroll into.\n */\n const loadedPrefix = (rows: ReadonlyArray<Child<TData>>): number => {\n let n = 0\n while (n < rows.length && !rowPlaceholderState(rows[n])) n += 1\n return n\n }\n\n const walk = (store: Store<TData>, from = 0, to = Number.POSITIVE_INFINITY): void => {\n if (isLazyLeafLevel(store)) {\n // A pure leaf level: one lazy piece, nothing copied.\n const end = Math.min(store.cache.windowedRows().length, to)\n if (end > from) {\n segments.push({ store, displayStart: total, storeStart: from, length: end - from })\n pushPiece({ start: total, length: end - from, store, from })\n }\n return\n }\n const rows = store.cache.rows()\n const run = store.loadMore ? null : leafRun(store, rows)\n if (run) {\n // A level of leaves maps one to one: copy the range out of the run.\n const end = Math.min(rows.length, to)\n if (end > from) {\n segments.push({ store, displayStart: total, storeStart: from, length: end - from })\n pushPiece({\n start: total,\n length: end - from,\n display: run.display.slice(from, end),\n grid: run.grid.slice(from, end),\n })\n }\n return\n }\n const end = Math.min(rows.length, to, store.loadMore ? loadedPrefix(rows) : Number.POSITIVE_INFINITY)\n let segStart = from\n let dispStart = total\n const flushSegment = (upTo: number): void => {\n if (upTo > segStart) {\n segments.push({ store, displayStart: dispStart, storeStart: segStart, length: upTo - segStart })\n }\n }\n for (let i = from; i < end; i += 1) {\n const child = rows[i]!\n const ph = rowPlaceholderState(child)\n if (ph) {\n pushRow(ph === 'failed' ? FAILED_ROW : LOADING_ROW)\n continue\n }\n if (child.kind === 'leaf') {\n pushRow(\n { kind: 'leaf', id: child.id, level: store.level, route: store.route, data: child.data },\n child.data,\n )\n if (openDetails.size) {\n const masterId = childId(child)\n if (openDetails.has(masterId)) {\n pushRow(\n { kind: 'detail', id: `${child.id}:detail`, level: store.level, route: store.route, masterId, master: child.data },\n child.data,\n )\n }\n }\n continue\n }\n const childRoute = [...store.route, child.key]\n const ck = routeKey(childRoute)\n const childStore = stores.get(ck)\n const isExp = expanded.has(ck)\n const unbalanced = !!options.allowUnbalancedGroups && child.key === ''\n const hidden = isExp && (unbalanced || !!options.hideOpenParents)\n if (!hidden) {\n pushRow(\n {\n kind: 'group',\n id: ck,\n path: childRoute,\n field: child.field,\n key: child.key,\n level: store.level,\n expanded: isExp,\n ...(pivotMode && !treeData && childRoute.length >= groupBy.length ? { expandable: false } : {}),\n // The expander shows a spinner for the level opening, not for\n // a block far down an open level that a deep scroll asked for.\n loading: !!childStore && childStore.cache.getCacheState().some((b) => b.blockIndex === 0 && b.status === 'loading'),\n aggregates: child.aggregates,\n ...(child.childCount != null ? { childCount: child.childCount } : {}),\n data: child.data,\n },\n child.data,\n )\n }\n if (isExp && childStore) {\n // This store's run ends here; the child's rows follow, then a new\n // run of this store begins.\n flushSegment(i + 1)\n walk(childStore)\n if (groupFooters && !hidden) {\n pushRow(\n {\n kind: 'footer',\n id: GROUP_TOTAL_ROW_ID_PREFIX + ck,\n level: store.level + 1,\n path: childRoute,\n key: child.key,\n aggregates: child.aggregates,\n data: child.data,\n } as ServerFooterRow<TData>,\n child.data,\n )\n }\n segStart = i + 1\n dispStart = total\n }\n }\n flushSegment(end)\n if (store.loadMore) {\n const count = store.cache.rowCount()\n const loaded = end\n if (count == null || loaded < count) {\n pushRow({\n kind: 'more',\n id: `sv-more:${store.key}`,\n level: store.level,\n path: store.route,\n remaining: count == null ? store.blockSize : count - loaded,\n loading: store.cache.getCacheState().some((b) => b.status === 'loading'),\n })\n }\n }\n }\n\n const root = stores.get(routeKey([]))\n if (root) {\n if (paging && !paging.paginateChildRows) {\n // A page of top-level rows. The root rows array is already sized to\n // the count (placeholders for what has not loaded), so the page\n // exists before its block does.\n const count = root.cache.rowCount() ?? root.cache.windowedRows().length\n pagerRowCount = count\n const start = Math.min(pageIndex * pageSize, Math.max(0, count - 1))\n walk(root, start, start + pageSize)\n } else {\n walk(root)\n }\n }\n\n /** Every piece as plain arrays. Lazy pieces read through `lazyLeafAt`. */\n const materialize = (): { display: ServerRowModelDisplayRow<TData>[]; grid: ServerRowModelGridRow<TData>[] } => {\n if (pieces.length === 1 && pieces[0]!.display) return { display: pieces[0]!.display, grid: pieces[0]!.grid! }\n const display: ServerRowModelDisplayRow<TData>[] = []\n const grid: ServerRowModelGridRow<TData>[] = []\n for (const p of pieces) {\n if (p.display) {\n for (let i = 0; i < p.length; i += 1) {\n display.push(p.display[i]!)\n grid.push(p.grid![i]!)\n }\n continue\n }\n for (let i = 0; i < p.length; i += 1) {\n const e = lazyLeafAt(p.store!, p.from! + i)\n display.push(e.d)\n grid.push(e.g)\n }\n }\n return { display, grid }\n }\n\n if (paging?.paginateChildRows) {\n // Child-row paging cuts the flattened body, so it works on plain\n // arrays; no level is lazy in this mode (see isLazyLeafLevel).\n const { display, grid } = materialize()\n // A page of the flattened tree. Cut the body and re-base the segments\n // that cross into the page; the grand total stays on every page.\n const bodyLength = display.length - bodyStart\n pagerRowCount = bodyLength\n const start = Math.min(pageIndex * pageSize, Math.max(0, bodyLength - 1))\n const stop = Math.min(bodyLength, start + pageSize)\n display.splice(bodyStart + stop)\n display.splice(bodyStart, start)\n grid.splice(bodyStart + stop)\n grid.splice(bodyStart, start)\n const cut: Segment<TData>[] = []\n for (const seg of segments) {\n const a = Math.max(seg.displayStart, bodyStart + start)\n const b = Math.min(seg.displayStart + seg.length, bodyStart + stop)\n if (b <= a) continue\n cut.push({\n store: seg.store,\n displayStart: a - start,\n storeStart: seg.storeStart + (a - seg.displayStart),\n length: b - a,\n })\n }\n segments = cut\n if (grandTotalRow === 'bottom' && grandTotal) {\n const d = grandTotalDisplay(grandTotal)\n display.push(d)\n grid.push(toGridRow(d, grandTotal))\n }\n return { display, grid }\n }\n\n if (grandTotalRow === 'bottom' && grandTotal) pushRow(grandTotalDisplay(grandTotal), grandTotal)\n\n if (!pieces.some((p) => p.store)) return materialize()\n // At least one lazy piece: hand out windowed arrays that find the piece\n // holding an index by binary search on the piece starts.\n const pieceAt = (index: number): Piece | undefined => {\n let lo = 0\n let hi = pieces.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n const p = pieces[mid]!\n if (index < p.start) hi = mid - 1\n else if (index >= p.start + p.length) lo = mid + 1\n else return p\n }\n return undefined\n }\n const entryAt = (index: number): { d: ServerRowModelDisplayRow<TData>; g: ServerRowModelGridRow<TData> } | undefined => {\n const p = pieceAt(index)\n if (!p) return undefined\n const offset = index - p.start\n if (p.display) return { d: p.display[offset]!, g: p.grid![offset]! }\n return lazyLeafAt(p.store!, p.from! + offset)\n }\n return {\n display: createWindowedData(total, (i) => entryAt(i)?.d),\n grid: createWindowedData(total, (i) => entryAt(i)?.g),\n }\n }\n\n function pagination(): ServerRowModelPagination | null {\n if (!paging) return null\n return {\n pageIndex,\n pageSize,\n rowCount: pagerRowCount,\n pageCount: Math.max(1, Math.ceil(pagerRowCount / pageSize)),\n }\n }\n\n /**\n * A display row as the grid sees it: the row's own fields at the top\n * level plus the `__group` marker. Footers and the grand total also carry\n * the flags the grid keys its `sv-grid-group-footer-row` /\n * `sv-grid-grand-total-row` styling on, so they look like the client\n * model's totals rather than like ordinary rows.\n */\n function toGridRow(d: ServerRowModelDisplayRow<TData>, data?: TData): ServerRowModelGridRow<TData> {\n if (d.kind === 'placeholder') return d as unknown as ServerRowModelGridRow<TData>\n const row = { ...(data as object), __group: d } as ServerRowModelGridRow<TData> & {\n __groupFooter?: boolean\n __grandTotal?: boolean\n }\n if (d.kind === 'footer') row.__groupFooter = true\n if (d.kind === 'grandTotal') {\n row.__groupFooter = true\n row.__grandTotal = true\n }\n return row\n }\n\n function grandTotalDisplay(data: TData): ServerGrandTotalRow<TData> {\n const agg: Record<string, unknown> = {}\n for (const a of aggregations) agg[a.col] = (data as Record<string, unknown>)[a.col]\n return { kind: 'grandTotal', id: GRAND_TOTAL_ROW_ID, level: 0, aggregates: agg, data }\n }\n\n function pinnedGrandTotal(): ReadonlyArray<ServerRowModelGridRow<TData>> | undefined {\n if (!grandTotal) return undefined\n return [toGridRow(grandTotalDisplay(grandTotal), grandTotal)]\n }\n\n // ------------------------------------------------------------- state\n\n let current: ServerRowModelState<TData> = {\n displayRows: [],\n gridRows: [],\n groupBy: [...groupBy],\n aggregations: [...aggregations],\n rowCount: null,\n loading: true,\n saving: false,\n error: null,\n sortModel,\n filterModel,\n expandedGroups: [],\n openDetails: [],\n grandTotal: null,\n pivotBy: [...pivotBy],\n pivotMode,\n pivotResultFields: [],\n pagination: pagination(),\n }\n\n function buildState(): ServerRowModelState<TData> {\n const root = stores.get(routeKey([]))\n const { display, grid } = flatten()\n const rootLoaded = !!root && root.cache.getCacheState().some((b) => b.status === 'loaded')\n return {\n displayRows: display,\n gridRows: grid,\n groupBy: [...groupBy],\n aggregations: [...aggregations],\n rowCount: root?.cache.rowCount() ?? null,\n loading: !!root && !rootLoaded && root.cache.getCacheState().some((b) => b.status === 'loading'),\n saving: writesInFlight > 0,\n error: rootError,\n sortModel,\n filterModel,\n expandedGroups: [...expanded],\n openDetails: [...openDetails],\n grandTotal: grandTotal ?? null,\n pivotBy: [...pivotBy],\n pivotMode,\n pivotResultFields: [...pivotFields],\n pagination: pagination(),\n }\n }\n\n // `keepRowsWhileLoading`: after a sort, a filter or a regroup, the rows on\n // screen stay until the new answer's first block lands or the time runs\n // out. `holdUntil` is that deadline, 0 when nothing is held.\n let holdUntil = 0\n let holdTimer: ReturnType<typeof setTimeout> | null = null\n let held = false\n\n function holdRows(): void {\n const ms = options.keepRowsWhileLoading ?? 0\n if (ms > 0 && current.displayRows.length > 0) {\n holdUntil = Date.now() + ms\n freezeCurrent()\n }\n }\n\n /**\n * Held rows must not change under the user, but a lazy list reads the live\n * caches, which the sort or filter that started the hold is about to purge.\n * Copy the rows around the last viewport into a snapshot and serve the held\n * lists from it. Only the grid's subscription is told (so it re-reads);\n * `onChange` still fires once, when the hold ends.\n */\n function freezeCurrent(): void {\n if (!isWindowedData(current.gridRows)) return // plain arrays are a snapshot already\n const n = current.gridRows.length\n const MARGIN = 200\n const from = Math.max(0, lastViewport[0] - MARGIN)\n const to = Math.min(n - 1, lastViewport[1] + MARGIN)\n const display = new Map<number, ServerRowModelDisplayRow<TData>>()\n const grid = new Map<number, ServerRowModelGridRow<TData>>()\n for (let i = from; i <= to; i += 1) {\n display.set(i, current.displayRows[i]!)\n grid.set(i, current.gridRows[i]!)\n }\n current = {\n ...current,\n displayRows: createWindowedData(n, (i) => display.get(i) ?? LOADING_ROW),\n gridRows: createWindowedData(n, (i) => grid.get(i) ?? toGridRow(LOADING_ROW)),\n }\n for (const notify of subscribers) notify()\n }\n\n /** Whether the rows on screen should stay a little longer. */\n function holding(): boolean {\n if (!holdUntil) return false\n const root = stores.get(routeKey([]))\n // A failed block ends the hold too: its Retry is the answer to show.\n const rootAnswered = !!root && root.cache.getCacheState().some((b) => b.status !== 'loading')\n let pending = false\n for (const s of stores.values()) if (s.refreshPending) pending = true\n if (Date.now() >= holdUntil || rootError || (rootAnswered && !pending)) {\n holdUntil = 0\n return false\n }\n return true\n }\n\n function scheduleEmit(): void {\n if (disposed || emitScheduled) return\n emitScheduled = true\n queueMicrotask(() => {\n emitScheduled = false\n if (disposed) return\n if (holding()) {\n held = true\n holdTimer ??= setTimeout(() => {\n holdTimer = null\n scheduleEmit()\n }, Math.max(0, holdUntil - Date.now()))\n return\n }\n if (holdTimer) {\n clearTimeout(holdTimer)\n holdTimer = null\n }\n current = buildState()\n // While rows were held the grid kept reporting positions in the old\n // list, mapped through the old segments; map them through the new.\n if (held) {\n held = false\n applyViewport()\n }\n options.onChange?.(current)\n for (const notify of subscribers) notify()\n })\n }\n\n // ------------------------------------------------------- operations\n\n function applyViewport(): void {\n const [start, end] = lastViewport\n for (const seg of segments) {\n if (seg.store.loadMore) continue\n const segEnd = seg.displayStart + seg.length - 1\n if (segEnd < start || seg.displayStart > end) continue\n const from = Math.max(start, seg.displayStart) - seg.displayStart + seg.storeStart\n const to = Math.min(end, segEnd) - seg.displayStart + seg.storeStart\n seg.store.cache.setViewport(from, to)\n }\n }\n\n /** Ask a level for its first block, so it shows something before any scroll. */\n function primeStore(store: Store<TData>): void {\n if (store.loadMore) {\n store.cache.setViewport(0, store.blockSize - 1)\n return\n }\n const from = store.level === 0 && paging && !paging.paginateChildRows ? pageIndex * pageSize : 0\n store.cache.setViewport(from, from + Math.min(store.blockSize, skeletonRows) - 1)\n }\n\n function expandGroup(route: string[]): void {\n if (disposed) return\n // Under pivot the innermost group rows ARE the result; there is nothing\n // beneath them to fetch.\n if (pivotMode && !treeData && route.length >= groupBy.length) return\n const key = routeKey(route)\n if (expanded.has(key)) return\n expanded.add(key)\n const store = ensureStore(route)\n // A known child count sizes the scrollbar (and the skeletons) right away.\n const parent = parentRowOf(route)\n const known = parent !== undefined ? childCountOf?.(parent) : undefined\n if (known != null && store.cache.rowCount() == null) store.cache.setRowCount(known, true)\n primeStore(store)\n options.onGroupOpened?.(route)\n scheduleEmit()\n }\n\n function collapseGroup(route: string[]): void {\n const key = routeKey(route)\n if (!expanded.delete(key)) return\n if (options.purgeClosedGroups) dropSubtree(route)\n scheduleEmit()\n }\n\n /** Start over below `route` (or everywhere), keeping the expansion set. */\n function rebuild(route: string[] = []): void {\n if (route.length === 0) {\n // A full reset. Disposing every cache aborts every request, and the\n // generation bump discards any that slipped past the abort.\n generation += 1\n for (const s of stores.values()) s.cache.dispose()\n stores.clear()\n grandTotal = undefined\n grandTotalStale = false\n pivotFields = []\n pivotColumnsFromSource = null\n } else {\n dropSubtree(route)\n }\n const target = ensureStore(route)\n primeStore(target)\n // Expanded descendants come back too, each priming its own first block.\n for (const key of expanded) {\n const r = JSON.parse(key) as string[]\n if (r.length > route.length && route.every((seg, i) => r[i] === seg)) primeStore(ensureStore(r))\n }\n scheduleEmit()\n }\n\n /** Which stores a sort touches, under the selective rules. */\n function storesAffectedBySort(next: ServerSortModel): Store<TData>[] {\n const all = [...stores.values()]\n if (options.sortAllLevels || treeData) return all\n const aggCols = new Set(aggregations.map((a) => a.col))\n const levels = new Set<number>()\n let everything = false\n let leaves = false\n for (const s of next) {\n const gi = groupBy.indexOf(s.id)\n if (aggCols.has(s.id)) everything = true\n else if (gi >= 0) levels.add(gi)\n else leaves = true\n }\n // Clearing the sort has to put every level back to server order.\n if (next.length === 0) everything = true\n if (everything) return all\n return all.filter((s) => levels.has(s.level) || (leaves && isLeafLevel(s.level)))\n }\n\n function sortLocally(store: Store<TData>): boolean {\n const st = store.cache.getCacheState()\n const count = store.cache.rowCount()\n if (!store.cache.lastRowKnown() || count == null) return false\n const loaded = st.filter((b) => b.status === 'loaded').length\n if (loaded * store.blockSize < count) return false\n const rows = store.cache.rows().filter((r) => !rowPlaceholderState(r)) as Child<TData>[]\n if (rows.length !== count) return false\n const valueOf = (c: Child<TData>, col: string): unknown => (c.data as Record<string, unknown>)[col]\n rows.sort((a, b) => {\n for (const s of sortModel) {\n const cmp = compareValues(valueOf(a, s.id), valueOf(b, s.id))\n if (cmp !== 0) return s.desc ? -cmp : cmp\n }\n return 0\n })\n store.cache.applyRows(0, rows, count)\n return true\n }\n\n function setSort(next: ServerSortModel): void {\n sortModel = next\n holdRows()\n for (const store of storesAffectedBySort(next)) {\n if (options.clientSideSort && sortLocally(store)) continue\n store.refreshPending = true\n store.cache.refresh()\n }\n scheduleEmit()\n }\n\n function setFilter(next: ServerFilterModel | GridFilterState): void {\n const previous = filterModel\n holdRows()\n filterModel = Array.isArray(next.columns)\n ? { global: next.global, columns: toServerFilterColumns(next as GridFilterState) }\n : (next as ServerFilterModel)\n if (!options.onlyRefreshFilteredGroups) return rebuild()\n\n // Selective: the levels whose column changed. Everything else keeps its\n // stale counts, which is the documented trade.\n const changed = new Set<string>()\n const prevCols = previous.columns ?? {}\n const nextCols = filterModel.columns ?? {}\n for (const c of new Set([...Object.keys(prevCols), ...Object.keys(nextCols)])) {\n if (JSON.stringify(prevCols[c]) !== JSON.stringify(nextCols[c])) changed.add(c)\n }\n const globalChanged = (previous.global ?? '') !== (filterModel.global ?? '')\n const exprChanged = JSON.stringify(previous.expression) !== JSON.stringify(filterModel.expression)\n const aggCols = new Set(aggregations.map((a) => a.col))\n if (globalChanged || exprChanged || [...changed].some((c) => aggCols.has(c))) return rebuild()\n\n const levels = new Set<number>()\n let leaves = false\n for (const c of changed) {\n const gi = groupBy.indexOf(c)\n if (gi >= 0) levels.add(gi)\n else leaves = true\n }\n for (const store of stores.values()) {\n if (levels.has(store.level) || (leaves && isLeafLevel(store.level))) {\n store.refreshPending = true\n store.cache.refresh()\n }\n }\n scheduleEmit()\n }\n\n function levelState(store: Store<TData>): ServerLevelState {\n const blocks = store.cache.getCacheState()\n return {\n route: store.route,\n level: store.level,\n rowCount: store.cache.rowCount(),\n lastRowKnown: store.cache.lastRowKnown(),\n loading: blocks.some((b) => b.status === 'loading'),\n failedBlocks: blocks.filter((b) => b.status === 'failed').map((b) => b.blockIndex),\n blocks,\n }\n }\n\n // ------------------------------------------------------ transactions\n\n /** The id a transaction row goes by at a level: key at a group level, row id at a leaf level. */\n function transactionId(store: Store<TData>, row: TData | string): string {\n if (typeof row === 'string') return row\n if (getRowId) {\n const id = getRowId(row)\n if (id === GRAND_TOTAL_ROW_ID) return id\n }\n if (isLeafLevel(store.level) || treeData) {\n if (!getRowId) throw new Error('createServerRowModel: transactions need getRowId')\n return getRowId(row)\n }\n const field = groupBy[store.level]!\n const v = (row as Record<string, unknown>)[field]\n return v == null ? '' : String(v)\n }\n\n /** The id a CACHED child goes by, in the same terms. */\n function childId(child: Child<TData>): string {\n if (child.kind === 'group') return child.key\n // Leaf ids are `${storeKey}:${rowId}`; the row id is the tail.\n return getRowId ? getRowId(child.data) : child.id.slice(child.id.lastIndexOf(':') + 1)\n }\n\n function storeStatus(store: Store<TData>): ServerTransactionStatus {\n const blocks = store.cache.getCacheState()\n if (blocks.length === 0) return 'storeWaitingToLoad'\n if (blocks.some((b) => b.status === 'loaded')) return 'applied'\n if (blocks.some((b) => b.status === 'loading')) return 'storeLoading'\n return 'storeLoadingFailed'\n }\n\n function applyTransaction(tx: ServerTransaction<TData>): ServerTransactionResult {\n const result: ServerTransactionResult = { status: 'applied', add: [], update: [], remove: [] }\n if (disposed) return { ...result, status: 'storeNotFound' }\n if (options.isApplyTransaction && !options.isApplyTransaction(tx)) {\n return { ...result, status: 'cancelled' }\n }\n const route = tx.route ?? []\n const store = stores.get(routeKey(route))\n if (!store) return { ...result, status: 'storeNotFound' }\n const status = storeStatus(store)\n if (status !== 'applied') return { ...result, status }\n\n // The grand total answers to its fixed id, on the root route only.\n const isTotal = (row: TData | string): boolean =>\n route.length === 0 && typeof row !== 'string' && !!getRowId && getRowId(row) === GRAND_TOTAL_ROW_ID\n\n for (const row of tx.update ?? []) {\n if (isTotal(row)) {\n grandTotal = row\n result.update.push(GRAND_TOTAL_ROW_ID)\n continue\n }\n const id = transactionId(store, row)\n const index = store.cache.findIndex((c) => childId(c) === id)\n if (index < 0) continue\n const [child] = toChildren(route, index, [row])\n store.cache.patch(index, child!)\n result.update.push(id)\n }\n\n for (const row of tx.remove ?? []) {\n if (isTotal(row)) {\n grandTotal = null\n result.remove.push(GRAND_TOTAL_ROW_ID)\n continue\n }\n const id = transactionId(store, row)\n const index = store.cache.findIndex((c) => childId(c) === id)\n // Outside the cache means outside what we can show; when the count is\n // known the row is simply not here, and the count stands until the\n // caller says otherwise with `rowCount`.\n if (index < 0) continue\n const wasGroup = (store.cache.getRow(index) as Child<TData>).kind === 'group'\n if (store.cache.remove(index, 1) > 0) {\n result.remove.push(id)\n // A removed group takes its expansion and its cached children with it.\n if (wasGroup) {\n const childRoute = [...route, id]\n expanded.delete(routeKey(childRoute))\n dropSubtree(childRoute)\n }\n }\n }\n\n if (tx.add && tx.add.length) {\n const total = store.cache.rowCount()\n const at = tx.addIndex ?? (total ?? store.cache.windowedRows().length)\n const children = toChildren(route, at, tx.add)\n const placed = store.cache.insert(at, children)\n if (placed) for (const c of children) result.add.push(childId(c))\n // Adding the grand total means setting it.\n for (const row of tx.add) if (isTotal(row)) grandTotal = row\n }\n\n if (typeof tx.rowCount === 'number') store.cache.setRowCount(tx.rowCount, true)\n // The parent group row shows this level's count beside its key; adds\n // and removes move it by the net change. Aggregates stay as they were:\n // a refresh of the parent recomputes them, as documented.\n const delta = result.add.length - result.remove.length\n if (delta !== 0 && route.length > 0) adjustChildCount(route, delta)\n scheduleEmit()\n return result\n }\n\n function adjustChildCount(route: string[], delta: number): void {\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return\n const key = route[route.length - 1]\n const index = parent.cache.findIndex((c) => c.kind === 'group' && c.key === key)\n if (index < 0) return\n const child = parent.cache.getRow(index) as Child<TData>\n if (child.kind !== 'group' || child.childCount == null) return\n parent.cache.patch(index, { ...child, childCount: Math.max(0, child.childCount + delta) })\n }\n\n const asyncQueue: Array<{\n tx: ServerTransaction<TData>\n callback?: (result: ServerTransactionResult) => void\n }> = []\n let asyncTimer: ReturnType<typeof setTimeout> | null = null\n\n function flushAsyncTransactions(): void {\n if (asyncTimer) clearTimeout(asyncTimer)\n asyncTimer = null\n if (asyncQueue.length === 0) return\n const batch = asyncQueue.splice(0)\n const results: ServerTransactionResult[] = []\n for (const item of batch) {\n const result = applyTransaction(item.tx)\n // A level still loading gets its transaction back in the queue for\n // the next flush; nothing else is retried.\n if (result.status === 'storeLoading' || result.status === 'storeWaitingToLoad') {\n asyncQueue.push(item)\n continue\n }\n results.push(result)\n item.callback?.(result)\n }\n if (results.length) options.onAsyncTransactionsFlushed?.(results)\n if (asyncQueue.length) scheduleAsyncFlush()\n }\n\n function scheduleAsyncFlush(): void {\n if (asyncTimer || disposed) return\n asyncTimer = setTimeout(() => {\n asyncTimer = null\n flushAsyncTransactions()\n }, options.asyncTransactionWaitMs ?? 50)\n }\n\n /** Run one write with `saving` raised around it; the flag is \"any write in flight\". */\n async function saving<T>(write: () => Promise<T>): Promise<T> {\n writesInFlight += 1\n scheduleEmit()\n try {\n return await write()\n } finally {\n writesInFlight -= 1\n // Rows are rebuilt from the cache on the next read, so an edit the\n // server refused (and the model never applied) is gone from the grid.\n lazyMemo = new WeakMap()\n scheduleEmit()\n }\n }\n\n /** The level and index holding a leaf row with this id, searching loaded rows only. */\n function locate(id: string): { store: Store<TData>; index: number } | null {\n for (const store of stores.values()) {\n const index = store.cache.findIndex((c) => childId(c) === id)\n if (index >= 0) return { store, index }\n }\n return null\n }\n\n function updateRowData(id: string, patch: Partial<TData>, opts?: { replace?: boolean }): boolean {\n const hit = locate(id)\n if (!hit) return false\n const current = hit.store.cache.getRow(hit.index) as Child<TData>\n const next = (opts?.replace ? patch : { ...current.data, ...patch }) as TData\n const [child] = toChildren(hit.store.route, hit.index, [next])\n hit.store.cache.patch(hit.index, child!)\n scheduleEmit()\n return true\n }\n\n // --------------------------------------------------------- selection\n\n const selectionModel: ServerSelectionModel | null = options.selection\n ? createServerSelectionModel({\n groupSelects: options.selection.groupSelects,\n // The grid re-derives the header checkbox and the row ticks from a\n // row-model notification, so a selection change is one.\n onChange: scheduleEmit,\n leafCount: groupLeafCount,\n })\n : null\n\n /**\n * Leaves under a loaded group row, from its `count` aggregate (the same\n * one `leafTotal` reads on the grand total); null without one, or when\n * the group row is not loaded.\n */\n function groupLeafCount(route: ReadonlyArray<string>): number | null {\n const counter = aggregations.find((a) => a.fn === 'count')\n if (!counter || route.length === 0) return null\n const parent = stores.get(routeKey(route.slice(0, -1)))\n if (!parent) return null\n const key = route[route.length - 1]\n const index = parent.cache.findIndex((c) => c.kind === 'group' && c.key === key)\n if (index < 0) return null\n const child = parent.cache.getRow(index) as Child<TData>\n if (child.kind !== 'group') return null\n const n = Number(child.aggregates[counter.col])\n return Number.isFinite(n) ? n : null\n }\n\n /** Where a grid row lives in selection terms: its parent route and its own id. */\n function locateForSelection(\n row: ServerRowModelGridRow<TData>,\n ): { route: ReadonlyArray<string>; id: string; isGroup: boolean } | null {\n const d = row.__group\n if (!d) return null\n if (d.kind === 'group') return { route: d.path.slice(0, -1), id: d.key, isGroup: true }\n if (d.kind === 'leaf') {\n return { route: d.route ?? [], id: getRowId ? getRowId(d.data) : d.id, isGroup: false }\n }\n return null\n }\n\n /**\n * How many leaves the whole result holds, for an honest select-all count:\n * the root count when nothing is grouped, else the `count` aggregation\n * on the grand total (the only place the server says it), else unknown.\n */\n function leafTotal(): number | null {\n if (treeData || groupBy.length === 0) return current.rowCount\n const counter = aggregations.find((a) => a.fn === 'count')\n if (!counter || !grandTotal) return null\n const n = Number((grandTotal as Record<string, unknown>)[counter.col])\n return Number.isFinite(n) ? n : null\n }\n\n const selectionAdapter = selectionModel\n ? {\n isSelected: (_rowId: string, row: ServerRowModelGridRow<TData>) => {\n const at = locateForSelection(row)\n return at ? selectionModel.isSelected(at.route, at.id) : false\n },\n headerState: () => selectionModel.headerState(),\n toggle: (_rowId: string, row: ServerRowModelGridRow<TData>, next: boolean) => {\n const at = locateForSelection(row)\n if (at) selectionModel.toggle(at.route, at.id, next, at.isGroup)\n },\n toggleAll: (next: boolean) => selectionModel.setAll(next),\n selectedCount: () => selectionModel.selectedCount(leafTotal()),\n bulkUpdate: (patch: Record<string, unknown>) => bulkUpdate(patch as Partial<TData>),\n }\n : undefined\n\n async function bulkUpdate(patch: Partial<TData>): Promise<number> {\n if (!selectionModel) throw new Error('createServerRowModel: bulkUpdate needs the `selection` option')\n if (!source.updateWhere) {\n throw new Error(\n 'createServerRowModel: the datasource does not implement updateWhere(), so a bulk edit ' +\n 'cannot reach rows the grid has not loaded',\n )\n }\n const rule = selectionModel.getState()\n // A nested rule is keyed level by level by the group columns; say which.\n const changed = await source.updateWhere(\n filterModel,\n patch,\n 'selectAllChildren' in rule ? { ...rule, groupBy: [...groupBy] } : rule,\n )\n if (disposed) return changed\n // Every level may have changed rows AND aggregates; reload them in place.\n for (const store of stores.values()) {\n store.refreshPending = true\n store.cache.refresh()\n }\n grandTotalStale = true\n scheduleEmit()\n return changed\n }\n\n // ------------------------------------------------------------ pivot\n\n /**\n * The columns that replace the grid's own while pivoting: the leading\n * columns the app named, then one header group per pivot key with a value\n * column per aggregation under it. Null outside pivot mode, or before the\n * first pivoted response has said what fields exist.\n */\n function pivotResultColumns(): ReadonlyArray<unknown> | null {\n if (!pivotMode) return null\n const leading = options.pivotLeadingColumns ?? []\n if (pivotColumnsFromSource) return [...leading, ...pivotColumnsFromSource]\n if (pivotFields.length === 0) return null\n return [\n ...leading,\n ...buildPivotResultColumns(pivotFields, aggregations, {\n separator: pivotSeparator,\n pivotResultColumn: options.pivotResultColumn,\n rowTotals: pivotRowTotals,\n }),\n ]\n }\n\n // ---------------------------------------------------- the controller\n\n // The id a leaf's detail is kept under: the app's row id, else the leaf's own.\n const leafDetailId = (g: { id: string; data: TData }) => (getRowId ? getRowId(g.data) : g.id.slice(g.id.lastIndexOf(':') + 1))\n const nav = {\n isGroup: (row: ServerRowModelGridRow<TData>) => row.__group?.kind === 'group',\n level: (row: ServerRowModelGridRow<TData>) =>\n row.__group && 'level' in row.__group ? row.__group.level : 0,\n expanded: (row: ServerRowModelGridRow<TData>) =>\n row.__group?.kind === 'group' ? row.__group.expanded : false,\n onToggle: (row: ServerRowModelGridRow<TData>) => {\n if (row.__group?.kind === 'group') model.toggleGroup(row.__group)\n else if (row.__group?.kind === 'more') model.loadMoreChildren(row.__group.path)\n },\n // A leaf's detail, by the id the app gave the row; a detail row toggles its master.\n toggleDetail: (row: ServerRowModelGridRow<TData>) => {\n const g = row.__group\n if (g?.kind === 'leaf') model.toggleDetail(leafDetailId(g))\n else if (g?.kind === 'detail') model.toggleDetail(g.masterId)\n },\n // Only a leaf has a detail to open; a group's expander is in the group column.\n hasDetail: (row: ServerRowModelGridRow<TData>) => row.__group?.kind === 'leaf',\n detailOpen: (row: ServerRowModelGridRow<TData>) => {\n const g = row.__group\n return g?.kind === 'leaf' ? model.isDetailOpen(leafDetailId(g)) : false\n },\n }\n\n const model: ServerRowModel<TData> = {\n refresh(opts) {\n if (disposed) return\n const route = opts?.route ?? []\n const store = stores.get(routeKey(route))\n if (opts?.purge || !store) return rebuild(route)\n store.refreshPending = true\n // The top level re-read in place brings its aggregates back fresh;\n // the grand total is one of them.\n if (route.length === 0) grandTotalStale = true\n store.cache.refresh()\n scheduleEmit()\n },\n retryLoads() {\n for (const store of stores.values()) store.cache.retryFailed()\n },\n setSort,\n setFilter,\n setGroupBy(next) {\n model.setLayout({ groupBy: next })\n },\n setAggregations(next) {\n model.setLayout({ aggregations: next })\n },\n setPivot(next) {\n model.setLayout(next)\n },\n setLayout(next) {\n if (disposed) return\n const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)\n const groupChanged = next.groupBy !== undefined && !same(next.groupBy, groupBy)\n const aggChanged = next.aggregations !== undefined && !same(next.aggregations, aggregations)\n const pivotChanged =\n (next.pivotBy !== undefined && !same(next.pivotBy, pivotBy)) ||\n (next.pivotMode !== undefined && next.pivotMode !== pivotMode)\n // The totals group is built from what is already loaded: no request.\n if (next.pivotRowTotals !== undefined && next.pivotRowTotals !== pivotRowTotals) {\n pivotRowTotals = next.pivotRowTotals\n if (!groupChanged && !aggChanged && !pivotChanged) {\n scheduleEmit()\n return\n }\n }\n if (!groupChanged && !aggChanged && !pivotChanged) return\n if (next.groupBy) groupBy = [...next.groupBy]\n if (next.aggregations) aggregations = [...next.aggregations]\n if (next.pivotBy) pivotBy = [...next.pivotBy]\n if (next.pivotMode !== undefined) pivotMode = next.pivotMode\n // New group columns make new rows, and pivoted group rows and plain\n // group rows are different rows: neither keeps its open state. A\n // change of aggregations alone keeps the tree open and refetches it.\n if (groupChanged || pivotChanged) {\n expanded.clear()\n seenGroups.clear()\n }\n holdRows()\n rebuild()\n },\n toggleGroup(row) {\n if (expanded.has(row.id)) collapseGroup(row.path)\n else expandGroup(row.path)\n },\n expandGroup,\n collapseGroup,\n toggleDetail(id, open) {\n const next = open ?? !openDetails.has(id)\n if (next === openDetails.has(id)) return\n if (next) openDetails.add(id)\n else openDetails.delete(id)\n scheduleEmit()\n },\n isDetailOpen(id) {\n return openDetails.has(id)\n },\n closeAllDetails() {\n if (!openDetails.size) return\n openDetails.clear()\n scheduleEmit()\n },\n expandAll(opts) {\n autoExpand = !!opts?.includeUnloaded\n for (const store of [...stores.values()]) {\n // A pure leaf level holds no groups to open; reading it would walk\n // every row it has.\n if (isLazyLeafLevel(store)) continue\n for (const child of store.cache.rows()) {\n if (rowPlaceholderState(child) || child.kind !== 'group') continue\n expandGroup([...store.route, child.key])\n }\n }\n },\n collapseAll() {\n autoExpand = false\n const open = [...expanded].map((k) => JSON.parse(k) as string[])\n expanded.clear()\n if (options.purgeClosedGroups) for (const r of open) dropSubtree(r)\n scheduleEmit()\n },\n isExpanded: (route) => expanded.has(routeKey(route)),\n applyRowData({ route = [], rows, rowCount, startRow = 0 }) {\n if (disposed) return\n const store = ensureStore(route)\n store.cache.applyRows(startRow, toChildren(route, startRow, rows), rowCount)\n scheduleEmit()\n },\n applyTransaction,\n applyTransactionAsync(tx, callback) {\n asyncQueue.push({ tx, callback })\n scheduleAsyncFlush()\n },\n flushAsyncTransactions,\n updateRowData,\n async createRow(input, route = [], addIndex) {\n if (!source.createRow) throw new Error('createServerRowModel: the datasource does not implement createRow()')\n const saved = await saving(() => source.createRow!(input))\n if (disposed) return saved\n applyTransaction({ route, add: [saved], ...(addIndex !== undefined ? { addIndex } : {}) })\n return saved\n },\n async updateRow(id, patch) {\n if (!source.updateRow) throw new Error('createServerRowModel: the datasource does not implement updateRow()')\n const before = options.optimistic ? locate(id) : null\n const previous = before ? ((before.store.cache.getRow(before.index) as Child<TData>).data as TData) : null\n // Show the patch now; the server answer replaces it, a rejection restores it.\n if (previous) updateRowData(id, patch)\n let saved: TData\n try {\n saved = await saving(() => source.updateRow!(id, patch))\n } catch (err) {\n if (previous && !disposed) updateRowData(id, previous, { replace: true })\n throw err\n }\n if (disposed) return saved\n const hit = locate(id)\n if (hit) applyTransaction({ route: hit.store.route, update: [saved] })\n return saved\n },\n async moveRow(id, toRoute, opts = {}) {\n const from = locate(id)\n if (!from) throw new Error(`createServerRowModel: moveRow: row ${id} is not loaded`)\n const child = from.store.cache.getRow(from.index) as Child<TData>\n let row = child.data\n if (opts.patch) {\n if (!source.updateRow) throw new Error('createServerRowModel: the datasource does not implement updateRow()')\n row = await saving(() => source.updateRow!(id, opts.patch!))\n if (disposed) return { row, status: 'cancelled' }\n }\n const fromRoute = [...from.store.route]\n // A moved group's children were cached under its old route.\n if (child.kind === 'group') dropSubtree([...fromRoute, child.key])\n applyTransaction({ route: fromRoute, remove: [id] })\n const res = applyTransaction({\n route: [...toRoute],\n add: [row],\n ...(opts.addIndex !== undefined ? { addIndex: opts.addIndex } : {}),\n })\n // A target level nothing has opened cannot take the row, but its group\n // row is on screen and the row did go there: the badge follows now\n // rather than at the next refresh.\n if (res.status === 'storeNotFound' && toRoute.length) adjustChildCount([...toRoute], 1)\n return { row, status: res.status }\n },\n async deleteRow(id) {\n if (!source.deleteRow) throw new Error('createServerRowModel: the datasource does not implement deleteRow()')\n const before = options.optimistic ? locate(id) : null\n const removed = before ? ((before.store.cache.getRow(before.index) as Child<TData>).data as TData) : null\n const route = before ? [...before.store.route] : []\n const index = before?.index ?? 0\n // Take the row out now; a rejection puts it back where it was.\n if (removed) applyTransaction({ route, remove: [id] })\n try {\n await saving(() => source.deleteRow!(id))\n } catch (err) {\n if (removed && !disposed) applyTransaction({ route, add: [removed], addIndex: index })\n throw err\n }\n if (disposed || removed) return\n const hit = locate(id)\n if (hit) applyTransaction({ route: hit.store.route, remove: [id] })\n },\n setViewport(startIndex, endIndex) {\n lastViewport = [Math.max(0, startIndex), Math.max(startIndex, endIndex)]\n // The first viewport starts the model. Its first load used to come from\n // the grid reporting its starting sort on mount (setSort -> rebuild);\n // the grid no longer reports a mount as a sort change, and a model that\n // nothing else touched (no sort, no filter, a layout equal to the one\n // it was built with) then never asked for a single block.\n if (!disposed && !stores.has(routeKey([]))) {\n rebuild()\n return\n }\n applyViewport()\n },\n setPage(next) {\n if (!paging || disposed) return\n const last = Math.max(0, Math.ceil(pagerRowCount / pageSize) - 1)\n const clamped = Math.max(0, Math.min(Math.floor(next), last))\n if (clamped === pageIndex) return\n pageIndex = clamped\n // The grid keeps reporting the same on-screen indices from page to\n // page, so the new page asks for its own blocks here.\n current = buildState()\n applyViewport()\n const root = stores.get(routeKey([]))\n if (root) primeStore(root)\n scheduleEmit()\n },\n setPageSize(next) {\n if (!paging || disposed) return\n const size = Math.max(1, Math.floor(next))\n if (size === pageSize) return\n const firstRow = pageIndex * pageSize\n pageSize = size\n pageIndex = Math.floor(firstRow / size)\n current = buildState()\n applyViewport()\n const root = stores.get(routeKey([]))\n if (root) primeStore(root)\n scheduleEmit()\n },\n loadMoreChildren(route) {\n const store = stores.get(routeKey(route))\n if (!store || disposed) return\n const rows = store.cache.rows()\n let loaded = 0\n while (loaded < rows.length && !rowPlaceholderState(rows[loaded])) loaded += 1\n store.cache.setViewport(loaded, loaded + store.blockSize - 1)\n scheduleEmit()\n },\n selectionModel,\n getSelectionState: () => selectionModel?.getState() ?? null,\n setSelectionState(state) {\n selectionModel?.setState(isCallbackSelectionState(state) ? fromCallbackSelectionState(state) : state)\n },\n bulkUpdate,\n getLevelState(route = []) {\n const store = stores.get(routeKey(route))\n return store ? levelState(store) : null\n },\n levelStates: () => [...stores.values()].map(levelState),\n getCacheState() {\n const out: Array<BlockState & { route: string[] }> = []\n for (const store of stores.values()) {\n for (const b of store.cache.getCacheState()) out.push({ ...b, route: store.route })\n }\n return out\n },\n // Fresh, not the last emitted snapshot: a caller reading state straight\n // after `collapseAll()` expects to see it collapsed, and the emit is a\n // microtask away. `getRows()` stays on the snapshot - it is the hot path.\n getState: () => (current = buildState()),\n dispose() {\n disposed = true\n generation += 1\n if (asyncTimer) clearTimeout(asyncTimer)\n asyncTimer = null\n if (holdTimer) clearTimeout(holdTimer)\n holdTimer = null\n asyncQueue.length = 0\n for (const s of stores.values()) s.cache.dispose()\n stores.clear()\n subscribers.clear()\n source.destroy?.()\n },\n get pinnedTopRows() {\n return grandTotalRow === 'pinnedTop' ? pinnedGrandTotal() : undefined\n },\n get pinnedBottomRows() {\n return grandTotalRow === 'pinnedBottom' ? pinnedGrandTotal() : undefined\n },\n get pivotResultColumns() {\n return pivotResultColumns()\n },\n // A getter: the grid re-reads it after every change notification.\n get pagination() {\n if (!paging) return undefined\n return {\n pageIndex,\n pageSize,\n rowCount: pagerRowCount,\n setPage: (i: number) => model.setPage(i),\n setPageSize: (n: number) => model.setPageSize(n),\n ...(paging.pageSizes ? { pageSizes: paging.pageSizes } : {}),\n ...(paging.autoPageSize ? { autoPageSize: true } : {}),\n }\n },\n\n // --- GridRowModel ---------------------------------------------------\n subscribe(onChange) {\n subscribers.add(onChange)\n return () => {\n subscribers.delete(onChange)\n }\n },\n getRows: () => current.gridRows,\n isLoading: () => current.loading,\n getRowId: (row, index) =>\n row.__group && 'id' in row.__group ? row.__group.id : `sv-ph:${index}`,\n rowPlaceholder: (row) => rowPlaceholderState(row),\n retryRow(_row, rowIndex) {\n // The block under the clicked row; every failed block only when the\n // index maps to no level (a stale index after a layout change).\n for (const seg of segments) {\n if (rowIndex < seg.displayStart || rowIndex >= seg.displayStart + seg.length) continue\n seg.store.cache.retryFailedAt(rowIndex - seg.displayStart + seg.storeStart)\n return\n }\n for (const store of stores.values()) store.cache.retryFailed()\n },\n group: nav,\n selection: selectionAdapter,\n filterValues: options.filterValues,\n }\n\n return model\n}\n```\n\n### `function serverRowModelRows`\n\nMap a state's display rows to grid rows. `state.gridRows` already holds\nthis; the function exists for parity with `serverGroupRows` and for code\nthat keeps its own display list.\n\n```ts\nexport function serverRowModelRows<TData>(\n state: ServerRowModelState<TData> | null | undefined,\n): ReadonlyArray<ServerRowModelGridRow<TData>> {\n return state?.gridRows ?? []\n}\n```\n\n### `function serverRowModelNav`\n\nThe `serverGroup` prop for a grid driven by hand rather than by `rowModel`.\n\n```ts\nexport function serverRowModelNav<TData>(ctl: ServerRowModel<TData>) {\n return ctl.group!\n}\n```\n\n### `function serverGroupText`\n\nThe group column's text for one grid row: a group's key (or its\n`leafField` under `treeData`, where a node's key is its id), `Total` for a\nfooter, `Grand total` for the grand total, the `leafField` value of a leaf,\nand nothing for the rows that carry no data (placeholders, skeletons, the\n\"load more\" row). `SvGroupCell` draws the expander, the count and the\nspinner; this is the value behind it, so give the group column\n`fieldFn: (row) => serverGroupText(row, leafField)` and copy, export, the\nclipboard and a `pinnedTop` / `pinnedBottom` grand total (which the grid\nformats from the accessor, not from the cell renderer) all read the same\ntext. `messages` is the map `SvGroupCell` takes, so a localized footer\nand grand total read the same in the cell and in a copy.\n\n```ts\nexport function serverGroupText<TData>(\n row: ServerRowModelGridRow<TData>,\n leafField?: keyof TData & string,\n messages?: Partial<ServerGroupMessages>,\n): string {\n const meta = row.__group\n if (!meta) return ''\n const m = resolveServerGroupMessages(messages)\n switch (meta.kind) {\n case 'group': {\n if (meta.field !== '' || !leafField) return meta.key\n const v = (row as Record<string, unknown>)[leafField]\n return v == null ? meta.key : String(v)\n }\n case 'footer':\n return fillMessage(m.total, { label: meta.key })\n case 'grandTotal':\n return m.grandTotal\n case 'leaf': {\n if (!leafField) return ''\n const v = (row as Record<string, unknown>)[leafField]\n return v == null ? '' : String(v)\n }\n default:\n return ''\n }\n}\n```\n"
|
|
6461
6461
|
},
|
|
6462
6462
|
{
|
|
6463
6463
|
"slug": "reference/auto/svgrid-enterprise-server-selection",
|
|
@@ -6506,7 +6506,7 @@ export const docs = [
|
|
|
6506
6506
|
"path": "docs/reference/auto/svgrid-grid-cell-formatting.md",
|
|
6507
6507
|
"title": "@svgrid/grid · cell-formatting.ts",
|
|
6508
6508
|
"section": "API reference",
|
|
6509
|
-
"markdown": "# `@svgrid/grid` · `cell-formatting.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\cell-formatting.ts`.\n\n### `function resolveDatePattern`\n\nMap shortcut date patterns to Intl options (merged with caller `options`).\n\n```ts\nexport function resolveDatePattern(\n pattern: string | undefined,\n kind: 'date' | 'datetime',\n): Intl.DateTimeFormatOptions | undefined {\n if (!pattern) return undefined\n\n const p = pattern.trim()\n\n switch (p) {\n /** Short numeric local date (no weekday). */\n case 'd':\n return kind === 'datetime'\n ? {\n year: 'numeric',\n month: 'numeric',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n }\n : { year: 'numeric', month: 'numeric', day: 'numeric' }\n /** Long spelled-out date (+ time if datetime). */\n case 'D':\n return kind === 'datetime'\n ? {\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n }\n : { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }\n /** ISO-friendly yyyy-mm-dd (with time when datetime). */\n case 'y-m-d':\n return kind === 'datetime'\n ? {\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n }\n : { year: 'numeric', month: '2-digit', day: '2-digit' }\n case 'medium':\n return { dateStyle: 'medium', timeStyle: kind === 'datetime' ? 'short' : undefined }\n case 'short':\n return { dateStyle: 'short', timeStyle: kind === 'datetime' ? 'short' : undefined }\n case 'long':\n return { dateStyle: 'long', timeStyle: kind === 'datetime' ? 'short' : undefined }\n default:\n return undefined\n }\n}\n```\n\n### `function getNumberFormatter`\n\nA cached `Intl.NumberFormat` for the locale and options given. Cached because\nconstructing one is among the most expensive calls in the platform, and both\ncallers do it per rendered value: once per cell while scrolling a number\ncolumn, and once per axis tick, data label and tooltip while drawing a chart.\n\n```ts\nexport function getNumberFormatter(\n locales: string | readonly string[] | undefined,\n options: Intl.NumberFormatOptions,\n): Intl.NumberFormat {\n // Stable key - for normal column configs the options object is reused\n // (declared in module scope), so this stringifies a small static shape.\n const key =\n (Array.isArray(locales) ? locales.join(',') : locales ?? '') +\n '|' +\n (options.style ?? '') +\n '|' +\n (options.currency ?? '') +\n '|' +\n (options.minimumFractionDigits ?? '') +\n '|' +\n (options.maximumFractionDigits ?? '') +\n '|' +\n (options.useGrouping ?? '') +\n '|' +\n (options.currencyDisplay ?? '') +\n '|' +\n (options.notation ?? '') +\n // Every option that changes the output belongs in the key. `signDisplay`\n // was missing, so a percent column asking for \"+3.27\" got the formatter\n // a currency column with the same digits had already cached, and no sign.\n '|' +\n (options.signDisplay ?? '') +\n '|' +\n (options.minimumIntegerDigits ?? '') +\n '|' +\n (options.compactDisplay ?? '') +\n '|' +\n (options.unit ?? '') +\n '|' +\n (options.unitDisplay ?? '') +\n '|' +\n (options.minimumSignificantDigits ?? '') +\n '|' +\n (options.maximumSignificantDigits ?? '')\n let fmt = numberFormatterCache.get(key)\n if (!fmt) {\n fmt = new Intl.NumberFormat(locales as string | string[] | undefined, options)\n numberFormatterCache.set(key, fmt)\n }\n return fmt\n}\n```\n\n### `function getDateFormatter`\n\nA cached `Intl.DateTimeFormat` for the locale and options given. Cached because\n constructing one per cell is the dominant cost when formatting a date column.\n\n```ts\nexport function getDateFormatter(\n locales: string | readonly string[] | undefined,\n options: Intl.DateTimeFormatOptions,\n): Intl.DateTimeFormat {\n const key =\n (Array.isArray(locales) ? locales.join(',') : locales ?? '') +\n '|' +\n (options.dateStyle ?? '') +\n '|' +\n (options.timeStyle ?? '') +\n '|' +\n (options.year ?? '') +\n '|' +\n (options.month ?? '') +\n '|' +\n (options.day ?? '') +\n '|' +\n (options.hour ?? '') +\n '|' +\n (options.minute ?? '') +\n '|' +\n (options.second ?? '') +\n '|' +\n (options.weekday ?? '') +\n '|' +\n (options.hour12 ?? '')\n let fmt = dateFormatterCache.get(key)\n if (!fmt) {\n fmt = new Intl.DateTimeFormat(locales as string | string[] | undefined, options)\n dateFormatterCache.set(key, fmt)\n }\n return fmt\n}\n```\n\n### `function formatNumericWithConfig`\n\nFormat a numeric value for number / currency / percent column formats.\n\n```ts\nexport function formatNumericWithConfig(value: unknown, config: NumericFormatInput): string {\n const n = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(n)) return String(value ?? '')\n\n const locales = config.locales\n\n if (config.type === 'currency') {\n return getNumberFormatter(locales, {\n style: 'currency',\n currency: config.currency ?? 'USD',\n ...config.options,\n }).format(n)\n }\n\n if (config.type === 'percent') {\n const frac = config.valueIsPercentPoints === true ? n / 100 : n\n return getNumberFormatter(locales, {\n style: 'percent',\n minimumFractionDigits: 0,\n maximumFractionDigits: 2,\n ...config.options,\n }).format(frac)\n }\n\n return getNumberFormatter(locales, config.options ?? {}).format(n)\n}\n```\n"
|
|
6509
|
+
"markdown": "# `@svgrid/grid` · `cell-formatting.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\cell-formatting.ts`.\n\n### `function resolveDatePattern`\n\nMap shortcut date patterns to Intl options (merged with caller `options`).\n\n```ts\nexport function resolveDatePattern(\n pattern: string | undefined,\n kind: 'date' | 'datetime',\n): Intl.DateTimeFormatOptions | undefined {\n if (!pattern) return undefined\n\n const p = pattern.trim()\n\n switch (p) {\n /** Short numeric local date (no weekday). */\n case 'd':\n return kind === 'datetime'\n ? {\n year: 'numeric',\n month: 'numeric',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n }\n : { year: 'numeric', month: 'numeric', day: 'numeric' }\n /** Long spelled-out date (+ time if datetime). */\n case 'D':\n return kind === 'datetime'\n ? {\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n }\n : { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }\n /** ISO-friendly yyyy-mm-dd (with time when datetime). */\n case 'y-m-d':\n return kind === 'datetime'\n ? {\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n }\n : { year: 'numeric', month: '2-digit', day: '2-digit' }\n case 'medium':\n return { dateStyle: 'medium', timeStyle: kind === 'datetime' ? 'short' : undefined }\n case 'short':\n return { dateStyle: 'short', timeStyle: kind === 'datetime' ? 'short' : undefined }\n case 'long':\n return { dateStyle: 'long', timeStyle: kind === 'datetime' ? 'short' : undefined }\n default:\n return undefined\n }\n}\n```\n\n### `function getNumberFormatter`\n\nA cached `Intl.NumberFormat` for the locale and options given. Cached because\nconstructing one is among the most expensive calls in the platform, and both\ncallers do it per rendered value: once per cell while scrolling a number\ncolumn, and once per axis tick, data label and tooltip while drawing a chart.\n\n```ts\nexport function getNumberFormatter(\n locales: string | readonly string[] | undefined,\n options: Intl.NumberFormatOptions,\n): Intl.NumberFormat {\n // Stable key - for normal column configs the options object is reused\n // (declared in module scope), so this stringifies a small static shape.\n const key =\n (Array.isArray(locales) ? locales.join(',') : locales ?? '') +\n '|' +\n (options.style ?? '') +\n '|' +\n (options.currency ?? '') +\n '|' +\n (options.minimumFractionDigits ?? '') +\n '|' +\n (options.maximumFractionDigits ?? '') +\n '|' +\n (options.useGrouping ?? '') +\n '|' +\n (options.currencyDisplay ?? '') +\n '|' +\n (options.notation ?? '') +\n // Every option that changes the output belongs in the key. `signDisplay`\n // was missing, so a percent column asking for \"+3.27\" got the formatter\n // a currency column with the same digits had already cached, and no sign.\n '|' +\n (options.signDisplay ?? '') +\n '|' +\n (options.minimumIntegerDigits ?? '') +\n '|' +\n (options.compactDisplay ?? '') +\n '|' +\n (options.unit ?? '') +\n '|' +\n (options.unitDisplay ?? '') +\n '|' +\n (options.minimumSignificantDigits ?? '') +\n '|' +\n (options.maximumSignificantDigits ?? '')\n let fmt = numberFormatterCache.get(key)\n if (!fmt) {\n fmt = new Intl.NumberFormat(locales as string | string[] | undefined, options)\n numberFormatterCache.set(key, fmt)\n }\n return fmt\n}\n```\n\n### `function parseDateValue`\n\nA cell value as a Date, or null when it is not one. Accepts a Date, epoch ms,\nor a parseable string.\n\nA bare calendar date ('2026-06-27') is read as LOCAL midnight of that day.\n`new Date('2026-06-27')` reads it as UTC midnight, which every zone west of\nUTC formats as the 26th. The day in the string is the day the user meant.\n\n```ts\nexport function parseDateValue(value: unknown): Date | null {\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value\n if (typeof value === 'number') {\n if (!Number.isFinite(value)) return null\n const d = new Date(value)\n return Number.isNaN(d.getTime()) ? null : d\n }\n if (typeof value !== 'string') return null\n const s = value.trim()\n if (s === '') return null\n const ymd = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(s)\n if (ymd) {\n const [y, m, day] = [Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3])]\n const d = new Date(0)\n d.setFullYear(y, m, day)\n d.setHours(0, 0, 0, 0)\n // Reject a rollover ('2026-02-30' is not March 2nd), as `new Date` does.\n return d.getMonth() === m && d.getDate() === day ? d : null\n }\n const d = new Date(s)\n return Number.isNaN(d.getTime()) ? null : d\n}\n```\n\n### `function getDateFormatter`\n\nA cached `Intl.DateTimeFormat` for the locale and options given. Cached because\n constructing one per cell is the dominant cost when formatting a date column.\n\n```ts\nexport function getDateFormatter(\n locales: string | readonly string[] | undefined,\n options: Intl.DateTimeFormatOptions,\n): Intl.DateTimeFormat {\n const key =\n (Array.isArray(locales) ? locales.join(',') : locales ?? '') +\n '|' +\n (options.dateStyle ?? '') +\n '|' +\n (options.timeStyle ?? '') +\n '|' +\n (options.year ?? '') +\n '|' +\n (options.month ?? '') +\n '|' +\n (options.day ?? '') +\n '|' +\n (options.hour ?? '') +\n '|' +\n (options.minute ?? '') +\n '|' +\n (options.second ?? '') +\n '|' +\n (options.weekday ?? '') +\n '|' +\n (options.hour12 ?? '')\n let fmt = dateFormatterCache.get(key)\n if (!fmt) {\n fmt = new Intl.DateTimeFormat(locales as string | string[] | undefined, options)\n dateFormatterCache.set(key, fmt)\n }\n return fmt\n}\n```\n\n### `function formatNumericWithConfig`\n\nFormat a numeric value for number / currency / percent column formats.\n\n```ts\nexport function formatNumericWithConfig(value: unknown, config: NumericFormatInput): string {\n const n = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(n)) return String(value ?? '')\n\n const locales = config.locales\n\n if (config.type === 'currency') {\n return getNumberFormatter(locales, {\n style: 'currency',\n currency: config.currency ?? 'USD',\n ...config.options,\n }).format(n)\n }\n\n if (config.type === 'percent') {\n const frac = config.valueIsPercentPoints === true ? n / 100 : n\n return getNumberFormatter(locales, {\n style: 'percent',\n minimumFractionDigits: 0,\n maximumFractionDigits: 2,\n ...config.options,\n }).format(frac)\n }\n\n return getNumberFormatter(locales, config.options ?? {}).format(n)\n}\n```\n"
|
|
6510
6510
|
},
|
|
6511
6511
|
{
|
|
6512
6512
|
"slug": "reference/auto/svgrid-grid-chart-axes",
|
|
@@ -6695,7 +6695,7 @@ export const docs = [
|
|
|
6695
6695
|
"path": "docs/reference/auto/svgrid-grid-core.md",
|
|
6696
6696
|
"title": "@svgrid/grid · core.ts",
|
|
6697
6697
|
"section": "API reference",
|
|
6698
|
-
"markdown": "# `@svgrid/grid` · `core.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\core.ts`.\n\n### `type RowData`\n\nThe constraint every row type satisfies: an object keyed by string. Your own\nrow type (`type Person = { name: string }`) is what flows through the generics\nbelow; this is only the lower bound they are declared against.\n\n```ts\nexport type RowData = Record<string, unknown>\n```\n\n### `type Updater`\n\nA new value, or a function that derives it from the previous one - the shape\nevery `set*` on the grid accepts, so callers can update state without first\nreading it.\n\n api.setSorting([{ id: 'name', desc: false }])\n api.setSorting((prev) => [...prev, { id: 'age', desc: true }])\n\n```ts\nexport type Updater<T> = T | ((prev: T) => T)\n```\n\n### `type SortingState`\n\nActive sort clauses, outermost first. `desc: false` is ascending.\n\n```ts\nexport type SortingState = Array<{ id: string; desc: boolean }>\n```\n\n### `type ColumnFilter`\n\nOne column's filter: the column `id`, the `value` being matched, and\noptionally which comparison to use. `fn` defaults to the column's own type -\nsee {@link filterFns} for the available names.\n\n```ts\nexport type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }\n```\n\n### `type ColumnFiltersState`\n\nEvery active column filter. A column with no entry here is unfiltered.\n\n```ts\nexport type ColumnFiltersState = Array<ColumnFilter>\n```\n\n### `type PaginationState`\n\nCurrent page position. `pageIndex` is 0-based, so page 1 is index 0.\n\n```ts\nexport type PaginationState = { pageIndex: number; pageSize: number }\n```\n\n### `type GroupingState`\n\nColumn ids the rows are grouped by, outermost first.\n\n```ts\nexport type GroupingState = Array<string>\n```\n\n### `type ExpandedState`\n\nWhich rows are expanded, keyed by row id. Absent means collapsed.\n\n```ts\nexport type ExpandedState = Record<string, boolean>\n```\n\n### `type RowSelectionState`\n\nWhich rows are selected, keyed by row id. Absent means unselected.\n\n```ts\nexport type RowSelectionState = Record<string, boolean>\n```\n\n### `type ActiveCellState`\n\nWhere keyboard focus sits. The indices address the *displayed* grid (after\nsorting, filtering and paging), not the source data.\n\n```ts\nexport type ActiveCellState = {\n rowIndex: number\n colIndex: number\n cellId: string | null\n}\n```\n\n### `type TableFeatures`\n\nThe set of features a grid has registered, as built by {@link tableFeatures}.\nDeliberately open: a feature is identified by its key, so the type carries\nwhich ones are on without enumerating them.\n\n```ts\nexport type TableFeatures = Record<string, unknown>\n```\n\n### `type CellData`\n\nA cell's value. Unconstrained - a column can hold anything.\n\n```ts\nexport type CellData = unknown\n```\n\n### `type HeaderContext`\n\nWhat a column's `header` render function receives.\n\n```ts\nexport type HeaderContext<TData extends RowData> = {\n header: Header<TData>\n column: Column<TData>\n table: SvGrid<TData>\n}\n```\n\n### `type CellContext`\n\nWhat a column's `cell` render function receives. `getValue()` applies the\ncolumn's accessor (`field` or `fieldFn`); `row.original` is the raw object.\n\n```ts\nexport type CellContext<TData extends RowData> = {\n cell: Cell<TData>\n row: Row<TData>\n column: Column<TData>\n table: SvGrid<TData>\n getValue: () => unknown\n}\n```\n\n### `type CellSpanParams`\n\nParams passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks.\n\n```ts\nexport type CellSpanParams<TData extends RowData = RowData> = {\n /** The row's underlying data object. */\n data: TData\n /** Display-row index in the current (filtered/sorted) row set. */\n rowIndex: number\n /** The column's id. */\n columnId: string\n /** The cell's base value for this column. */\n value: unknown\n}\n```\n\n### `type EditorOptionSource`\n\nThe raw option list a column's `editorOptions` can supply.\n\n```ts\nexport type EditorOptionSource = ReadonlyArray<\n string | number | { value: string | number; label?: string; color?: string }\n```\n\n### `type ValueParserParams`\n\nParams passed to a column's `valueParser(...)` on edit commit.\n\n```ts\nexport type ValueParserParams<TData extends RowData = RowData> = {\n /** The value after built-in per-`editorType` coercion. */\n newValue: unknown\n /** The cell's previous value. */\n oldValue: unknown\n /** The raw string the editor produced (pre-coercion). */\n rawInput: string\n /** The row's underlying data object. */\n data: TData\n /** The column's id. */\n columnId: string\n}\n```\n\n### `type EditorContext`\n\nContext passed to a custom `cellEditor` snippet/component. Three write\nhelpers cover the lifecycle:\n\n - `update(next)` - stage `next` as the draft, keep the editor open.\n Use this for live-preview controls (sliders,\n color pickers) so the user can keep adjusting.\n - `commit(next?)` - write the value AND close the editor. The\n argument is optional; when omitted, the most\n recently `update()`d value is saved. Use this\n for \"done\" gestures (Enter, picking an option).\n - `cancel()` - discard the draft and close the editor.\n\n```ts\nexport type EditorContext<TData extends RowData> = CellContext<TData> & {\n value: unknown\n update: (next: unknown) => void\n commit: (next?: unknown) => void\n cancel: () => void\n}\n```\n\n### `type CellFormatConfig`\n\nDeclarative cell formatting, applied through `Intl` - number, currency,\npercent, date and datetime. Prefer this over a `formatter` function: it is\nlocale-aware, and export and the clipboard reuse the same configuration.\n\n```ts\nexport type CellFormatConfig =\n | {\n type: 'number'\n locales?: string | Array<string>\n options?: Intl.NumberFormatOptions\n }\n | {\n type: 'currency'\n /** ISO 4217 (default USD) */\n currency?: string\n locales?: string | Array<string>\n options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>\n }\n | {\n type: 'percent'\n locales?: string | Array<string>\n options?: Omit<Intl.NumberFormatOptions, 'style'>\n /**\n * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).\n * Default false.\n */\n valueIsPercentPoints?: boolean\n }\n | {\n type: 'date' | 'datetime'\n locales?: string | Array<string>\n /**\n * Shortcut patterns merged with `options`:\n * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,\n * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.\n */\n pattern?: string\n options?: Intl.DateTimeFormatOptions\n }\n```\n\n### `type CellFormatter`\n\nA column's custom display function, for anything {@link CellFormatConfig}\ncannot express. Returns a string - to render markup, use `cell` instead.\n\n```ts\nexport type CellFormatter<TData extends RowData> = (context: {\n value: unknown\n row: Row<TData>\n column: Column<TData>\n table: SvGrid<TData>\n}) => string\n```\n\n### `type ColumnDefTemplate`\n\nA header or cell slot: a literal string, or a function returning renderable content.\n\n```ts\nexport type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)\n```\n\n### `type GroupAggregator`\n\nHow a column's value is aggregated for a group row when `columnGrouping`\nis active. Built-in reducers cover the common cases; pass a function for\nanything custom (weighted average, median, percentile, distinct count).\nThe function receives the finite numeric values AND the raw leaf rows.\n\n```ts\nexport type GroupAggregator<TData = any> =\n | 'sum'\n | 'avg'\n | 'min'\n | 'max'\n | 'count'\n | 'countDistinct'\n | 'extent'\n | 'first'\n | ((values: number[], rows: Array<TData>) => unknown)\n```\n\n### `function applyGroupAggregate`\n\nApply a group aggregator over a bucket's leaf rows for one column.\n\n```ts\nexport function applyGroupAggregate<TData extends RowData>(\n agg: GroupAggregator<TData>,\n columnId: string,\n rows: ReadonlyArray<Row<TData>>,\n): unknown {\n // One pass, no intermediate arrays.\n //\n // This used to build a `raw` array, then a coerced one, then a filtered one -\n // three allocations per aggregated column PER GROUP - before reducing. On a\n // 100k-row grid grouped two levels deep, aggregation was about two thirds of\n // the total grouping cost (213ms with three aggregators against 81ms with\n // none), and each additional aggregated column added roughly 80ms.\n //\n // `count` first: it never needs to look at a value at all.\n if (agg === 'count') return rows.length\n\n if (agg === 'first') {\n return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined\n }\n\n if (agg === 'countDistinct') {\n const seen = new Set<string>()\n for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))\n return seen.size\n }\n\n if (typeof agg === 'function') {\n // Custom aggregators keep their contract: the finite numbers, then the\n // original row objects. Note `Number(null)` is 0 and therefore finite, so\n // nulls DO reach the callback as zeros - long-standing behaviour.\n const nums: number[] = []\n for (const row of rows) {\n const n = Number(row.getCellValueByColumnId(columnId))\n if (Number.isFinite(n)) nums.push(n)\n }\n return agg(nums, rows.map((r) => r.original))\n }\n\n // sum / avg / min / max / extent share one accumulation pass.\n let count = 0\n let sum = 0\n let min = Infinity\n let max = -Infinity\n for (const row of rows) {\n const n = Number(row.getCellValueByColumnId(columnId))\n if (!Number.isFinite(n)) continue\n count++\n // Left-to-right, matching the previous `reduce`, so float rounding is\n // bit-identical rather than merely close.\n sum += n\n // Math.min/max on scalars rather than `<`, which differs on -0, and rather\n // than the old `Math.min(...nums)` - spreading a whole group throws\n // RangeError once the bucket is big enough to exhaust the argument stack.\n min = Math.min(min, n)\n max = Math.max(max, n)\n }\n if (!count) return undefined\n switch (agg) {\n case 'sum':\n return sum\n case 'avg':\n return sum / count\n case 'min':\n return min\n case 'max':\n return max\n case 'extent':\n return `${min} – ${max}`\n default:\n return undefined\n }\n}\n```\n\n### `type ColumnDef`\n\nA column definition.\n\n`TFeatures` is a phantom parameter - it is threaded through nested\n`columns` groups but no member depends on it, so `{}`, `TableFeatures` and\n`typeof features` are all interchangeable here. It is deliberately left\nWITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot\nand silently type your data as `RowData`, losing every field-name check.\nPrefer {@link GridColumns} / {@link GridColumnDef} for the common case.\n\n```ts\nexport type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {\n id?: string\n field?: keyof TData & string\n fieldFn?: (row: TData) => unknown\n header?: ColumnDefTemplate<HeaderContext<TData>>\n footer?: ColumnDefTemplate<HeaderContext<TData>>\n cell?: ColumnDefTemplate<CellContext<TData>>\n columns?: Array<ColumnDef<TFeatures, TData>>\n /**\n * Declarative cell spanning (merged cells). Return how many COLUMNS this\n * cell spans to the right (1 = no span). Value-driven. Feed\n * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses\n * the same real `colspan`/`rowspan` merge engine (no separate code path).\n */\n colSpan?: (params: CellSpanParams<TData>) => number\n /**\n * Declarative cell spanning (merged cells). Return how many ROWS this cell\n * spans downward (1 = no span). See `colSpan` for how to apply.\n */\n rowSpan?: (params: CellSpanParams<TData>) => number\n /**\n * High-level data type for the column. A convenience that resolves to the\n * right `editorType`, alignment, date `format`, and filter operators without\n * setting each by hand:\n * 'text' → text editor, left-aligned\n * 'number' → number editor, right-aligned, numeric filter operators\n * 'boolean' → checkbox editor, centered\n * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format\n * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')\n * Anything you set explicitly (`editorType`, `align`, `format`) still wins -\n * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers\n * this from the first data row for columns that declare neither.\n */\n cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'\n /**\n * Hide this column when the grid's `responsive` mode is on and the grid is\n * narrower than this many pixels - drop low-priority columns on small\n * screens. No effect unless the grid has `responsive` set.\n */\n hideBelow?: number\n /**\n * For a column INSIDE a collapsible column group: `'open'` shows this column\n * only while the group is expanded, `'closed'` only while collapsed. Omit to\n * always show it. Setting it on any direct child gives the parent group a\n * collapse toggle. Pair with `openByDefault` on the group.\n */\n columnGroupShow?: 'open' | 'closed'\n /**\n * For a GROUP column (one with `columns: [...]`): start the group expanded.\n * Defaults to `false` (collapsed), the conventional default - so only the always-on\n * and `columnGroupShow: 'closed'` children show until the user expands it.\n */\n openByDefault?: boolean\n editorType?:\n | 'text'\n | 'number'\n | 'date' // rich SvCalendar popover (opt out with 'date-native')\n | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')\n | 'time' // rich SvTimePicker dial (opt out with 'time-native')\n | 'date-native' // plain <input type=\"date\">\n | 'datetime-native' // plain <input type=\"datetime-local\">\n | 'time-native' // plain <input type=\"time\"> - HH:MM or HH:MM:SS\n | 'password' // native <input type=\"password\"> with masked rendering\n | 'checkbox'\n | 'list'\n | 'chips'\n | 'select' // custom dropdown - single value, no typeahead\n | 'rich-select' // custom dropdown with a typeahead search input\n | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)\n | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline\n | 'color' // native <input type=\"color\"> swatch\n | 'rating' // 5-star rating control\n // Any other string names a CUSTOM editor registered via `registerCellEditor`\n // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above\n // autocompleting while allowing arbitrary custom type names.\n | (string & {})\n /**\n * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`\n * and `cancel()` helper. Use when none of the built-in `editorType`s fit;\n * the snippet's outer element is mounted inside the editing cell and\n * inherits keyboard handling (Esc cancels, Enter commits unless your\n * snippet preventDefaults it).\n *\n * Coexists with `editorType`: when both are set, `cellEditor` wins and\n * `editorType` is treated as a hint for parsing the saved value.\n */\n cellEditor?: ColumnDefTemplate<EditorContext<TData>>\n /**\n * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`\n * runs per cell so the tooltip can reflect the value. Returning an empty\n * string skips the tooltip.\n */\n tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)\n /**\n * Declarative per-cell validation. Runs for EVERY\n * rendered cell - including values already present in `data` on load, not\n * just on edit - so bad data is flagged immediately. Invalid cells get the\n * `sv-grid-cell-invalid` class (red highlight) and the returned message as\n * their tooltip.\n *\n * Return value:\n * - `null` / `undefined` / `true` → valid (no highlight)\n * - `false` → invalid, no message\n * - a non-empty `string` → invalid, string is the tooltip\n *\n * On its own `validate` flags: the value keeps rendering as-is and an\n * edit that fails it still lands. Set `rejectInvalid` to refuse such an\n * edit instead.\n */\n validate?: (params: {\n value: unknown\n row: TData\n rowIndex: number\n column: Column<TData>\n }) => string | boolean | null | undefined\n /**\n * Refuse an edit that `validate` flags. When true, a committed value that\n * makes `validate` return `false` or a message is not written: the cell\n * keeps its old value, no history step is recorded and\n * `onCellValueChange` does not fire. The value is checked after\n * `valueParser` has run, so the rule sees what would be stored. Applies to\n * the inline editor, a commit to a selection and a full-row edit; paste\n * does not go through the editor and is not checked.\n */\n rejectInvalid?: boolean\n /**\n * Gate editing per column or per cell.\n *\n * - `true` (or omitted): the column is fully editable.\n * - `false`: the column is read-only - double-click, type-to-edit,\n * fill-handle drag, Delete, and clipboard paste all skip it.\n * - `(ctx) => boolean`: evaluated for each cell, so you can lock\n * individual rows (e.g. by role, status, ownership). Returning\n * `false` opts the cell out of every editing path, identical to\n * setting `editable: false` on the whole column for that row.\n *\n * The grid-wide `enableInlineEditing` prop still wins when set to\n * `false`.\n */\n editable?: boolean | ((context: CellContext<TData>) => boolean)\n /**\n * Transform the committed edit value before it is written to the row.\n * Runs after the built-in per-`editorType` coercion, so `newValue` is\n * already type-parsed; return the final value to store (e.g. round a\n * number, uppercase a code, look up an id). A `valueParser` hook.\n */\n valueParser?: (params: ValueParserParams<TData>) => unknown\n /**\n * Briefly flash / highlight this column's cell when its value changes\n * (streaming feeds, edits, server pushes). `true` uses the default flash;\n * pass `{ className }` to apply your own animation class instead.\n */\n cellFlash?: boolean | { className?: string }\n /**\n * When `false`, this column never shows a sort indicator and clicking\n * its header is a no-op - `api.setSort(thisColumn, ...)` is also\n * ignored. Defaults to `true` (the column participates in sorting as\n * long as `rowSortingFeature` is registered).\n */\n sortable?: boolean\n /**\n * When `false`, this column never shows a filter funnel / menu and\n * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the\n * column is filterable as long as `columnFilteringFeature` is\n * registered).\n */\n filterable?: boolean\n /**\n * Options for `editorType: 'list' | 'chips'`. Either bare values (the\n * string is both value and label) or `{ value, label }` objects.\n * For `chips` this is optional - when omitted, the chips editor becomes\n * free-form (user types and presses Enter to commit a chip).\n *\n * Pass a function `(row) => options` for row-dependent (cascading)\n * options - e.g. City options that depend on Country in the same row.\n *\n * Either form may return a **Promise**, for options that come from the\n * server. While it resolves, the editor shows a loading state and the cell\n * renders its raw value.\n *\n * Results are cached so reopening an editor does not refetch: a static source\n * per column, a per-row source per row AND per that row's data - so a cascade\n * reloads by itself when the cell it depends on is edited. Call\n * `api.refreshEditorOptions(columnId?)` when the list changes server-side.\n */\n editorOptions?:\n | EditorOptionSource\n | Promise<EditorOptionSource>\n | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)\n /** When true, list/chips allow multiple selections. Cell value becomes an array. */\n editorMultiple?: boolean\n /**\n * The text editor accepts line breaks: Alt+Enter inserts one and the\n * editor grows a line per break, while Enter still commits. What a\n * spreadsheet cell with wrapped text wants; `editorType: 'textarea'` is\n * the other shape, where Enter itself is the line break.\n */\n editorMultiline?: boolean\n /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */\n editorSeparator?: string\n format?: CellFormatConfig\n formatter?: CellFormatter<TData>\n /**\n * Aggregate this column's values into the group row when grouping is\n * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |\n * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result\n * is formatted with this column's `format` and shown in the group header.\n */\n aggregate?: GroupAggregator<TData>\n /**\n * What this column contributes to the grid's footer summary row (the one\n * turned on with `summary` / `enableRowSummaries`). Takes the same\n * aggregators as {@link aggregate}, and the result is formatted with this\n * column's `format`.\n *\n * Without it the footer falls back to its default: the sum of a numeric\n * column, `Count: N` otherwise. Set `false` to leave the cell blank, which is\n * usually what an actions or checkbox column wants.\n *\n * { field: 'amount', summary: 'avg' }\n * { id: 'actions', summary: false }\n */\n summary?: GroupAggregator<TData> | false\n /**\n * Render the cell as an in-cell sparkline chart. The cell value should be\n * an array of numbers (or a comma/space separated string). Mutually\n * exclusive with a custom `cell` renderer (a `cell` wins if both are set).\n *\n * { sparkline: { type: 'line' } } // default line\n * { sparkline: { type: 'bar', color: '#16a34a' } }\n * { sparkline: { type: 'winloss' } } // sign-only up/down\n *\n * See `SparklineConfig` for the full option set (type, color,\n * negativeColor, width, height, fixed min/max).\n */\n sparkline?: SparklineConfig\n /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */\n width?: number\n /**\n * Whether the user may resize this column. Only consulted when the grid has\n * `columnResize` on - it narrows that, it does not enable anything.\n *\n * `false` removes the column's drag handle entirely, so pointer drag, the\n * keyboard arrows and double-click-to-autosize are all gone with it, and the\n * column menu drops its Autosize item. Use it for the columns whose width is\n * part of the layout rather than a preference: a row-number gutter, a\n * checkbox column, a fixed icon column.\n *\n * Programmatic sizing is unaffected - `api.autosizeColumn()`,\n * `api.setColumnWidth()` and `fitColumns` all still apply, the same way they\n * do when `columnResize` is off. This governs the user affordance only.\n */\n resizable?: boolean\n /**\n * Initial visibility. Set `false` to start the column hidden while still\n * listing it in the Choose Columns UI for the user to re-enable. Applied\n * once at mount; after that `api.setColumnVisible` / user toggles win.\n * On a group column, `false` hides the whole group's leaf columns.\n */\n visible?: boolean\n /**\n * Horizontal alignment for header and body cells. When omitted, the\n * default is inferred from `editorType`:\n * - `'number' | 'date' | 'datetime'` → `'right'`\n * - `'checkbox'` → `'center'`\n * - everything else → `'left'`\n */\n align?: 'left' | 'center' | 'right'\n /**\n * Per-cell conditional CSS. Two shapes:\n *\n * - **String** (or array of strings): class name(s) added to the\n * cell's `<td>` for every row in this column.\n * - **Function**: invoked per cell with the same `CellContext` shape\n * the `cell` renderer receives. Return a string, an array of\n * strings, or an object mapping class names to booleans.\n *\n * Use it for status tinting, conditional bold, \"negative number\"\n * coloring - anything that's a function of the row's value. Cells\n * still receive their format / cell renderer; the class just\n * augments the rendered `<td>`.\n */\n cellClass?:\n | string\n | ReadonlyArray<string>\n | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)\n}\n```\n\n### `type GridColumnDef`\n\nA column definition keyed only by your row type - the ergonomic form of\n{@link ColumnDef}, whose first parameter is a phantom feature bag that is\nalmost always `{}`.\n\n```ts\nconst columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]\n```\n\nInterchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,\nTData>` in both directions, so it mixes freely with existing code.\n\n```ts\nexport type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>\n```\n\n### `type GridColumns`\n\nAn array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`.\n\n```ts\nexport type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>\n```\n\n### `type Column`\n\nA resolved column: your {@link ColumnDef} plus everything the grid computed\nfrom it - its id, its depth under any group header, and the sort handlers a\nheader needs. This is what you receive in render contexts; the `ColumnDef`\nis what you wrote.\n\n```ts\nexport type Column<TData extends RowData> = {\n id: string\n columnDef: ColumnDef<any, TData>\n depth: number\n parentId?: string\n getCanSort: () => boolean\n getCanFilter: () => boolean\n getIsSorted: () => false | 'asc' | 'desc'\n getToggleSortingHandler: () => () => void\n}\n```\n\n### `type Header`\n\nOne header cell. `colSpan` is how many leaf columns it covers, and\n`isPlaceholder` marks the empty cells that pad a group-header row so the\nlevels line up.\n\n```ts\nexport type Header<TData extends RowData> = {\n id: string\n isPlaceholder: boolean\n colSpan: number\n column: Column<TData>\n getContext: () => HeaderContext<TData>\n}\n```\n\n### `type HeaderGroup`\n\nOne row of header cells. A grid with grouped columns has several, outermost first.\n\n```ts\nexport type HeaderGroup<TData extends RowData> = {\n id: string\n headers: Array<Header<TData>>\n}\n```\n\n### `type Cell`\n\nOne cell: the intersection of a {@link Row} and a {@link Column}.\n\n```ts\nexport type Cell<TData extends RowData> = {\n id: string\n row: Row<TData>\n column: Column<TData>\n getValue: () => unknown\n getContext: () => CellContext<TData>\n}\n```\n\n### `type Row`\n\nA row in the display model. `original` is your untouched data object;\neverything else is grid-computed. `index` is the position in the displayed\nset, so it shifts as sorting and filtering change - key on `id`, not index.\n\nGroup rows and tree parents carry `subRows`; a plain data row does not.\n\n```ts\nexport type Row<TData extends RowData> = {\n id: string\n index: number\n original: TData\n depth: number\n subRows?: Array<Row<TData>>\n /** Total leaf (data) rows under this group row. Undefined for data rows. */\n leafCount?: number\n getCanExpand: () => boolean\n getIsExpanded: () => boolean\n toggleExpanded: () => void\n getIsSelected: () => boolean\n toggleSelected: () => void\n getAllCells: () => Array<Cell<TData>>\n getCellValueByColumnId: (columnId: string) => unknown\n}\n```\n\n### `type RowModel`\n\nThe output of the row pipeline: the rows to display, in order.\n\n```ts\nexport type RowModel<TData extends RowData> = {\n rows: Array<Row<TData>>\n}\n```\n\n### `type Store`\n\nThe minimal reactive store behind the headless core - read `state`, write\nthrough `setState`, and `subscribe` for changes. Deliberately framework\nfree, which is what lets the core run under plain Node.\n\nIn Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained\nselectors so a component only re-runs for the slice it read.\n\n```ts\nexport type Store<T> = {\n readonly state: T\n setState: (updater: (prev: T) => T) => void\n subscribe: (listener: () => void) => () => void\n}\n```\n\n### `const rowSortingFeature`\n\nClick-to-sort. Injected by the `sortable` shortcut.\n\nThis and the five features below are opaque markers: pass the ones you want\nto {@link tableFeatures} and the grid wires up the matching row model. With\n`<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,\n`filterable`, `pageable`, `groupable`) inject them for you. Reach for them\ndirectly when driving the headless core, or when you want a feature on\nwithout its UI.\n\nThe names match TanStack Table v9, so a features object written for it works\nhere unchanged.\n\n```ts\nexport const rowSortingFeature = { key: 'rowSortingFeature' }\n```\n\n### `const columnFilteringFeature`\n\nPer-column filtering. Injected by the `filterable` shortcut.\n\n```ts\nexport const columnFilteringFeature = { key: 'columnFilteringFeature' }\n```\n\n### `const rowPaginationFeature`\n\nPaging of the row model. Injected by the `pageable` shortcut.\n\n```ts\nexport const rowPaginationFeature = { key: 'rowPaginationFeature' }\n```\n\n### `const columnGroupingFeature`\n\nRow grouping with aggregation. Injected by the `groupable` shortcut.\n\n```ts\nexport const columnGroupingFeature = { key: 'columnGroupingFeature' }\n```\n\n### `const rowSelectionFeature`\n\nRow selection state (the checkbox column reads it).\n\n```ts\nexport const rowSelectionFeature = { key: 'rowSelectionFeature' }\n```\n\n### `const rowExpandingFeature`\n\nExpand / collapse, for tree rows and master-detail.\n\n```ts\nexport const rowExpandingFeature = { key: 'rowExpandingFeature' }\n```\n\n### `function tableFeatures`\n\nDeclare which features a grid uses. Identity at runtime - its whole job is to\ncapture the exact set in the type, so `ColumnDef<typeof features, Row>` knows\nwhat is registered and anything you did not register is tree-shaken out.\n\n```ts\nconst features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n```\n\nSame call signature as TanStack Table v9, so a features object written for it\ntransfers unchanged.\n\n```ts\nexport function tableFeatures<T extends TableFeatures>(features: T): T {\n return features\n}\n```\n\n### `const sortFns`\n\nBuilt-in comparators, chosen per column by its data type. `auto` compares as\ntext; set a column's type or supply your own comparator to override.\n\n```ts\nexport const sortFns = {\n auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),\n number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),\n date: (a: unknown, b: unknown) => {\n const aa = new Date(a as any).getTime()\n const bb = new Date(b as any).getTime()\n return aa - bb\n },\n}\n```\n\n### `const filterFns`\n\nBuilt-in match functions, named by {@link ColumnFilter}'s `fn`.\n`includesString` is case-insensitive substring; `equals` is strict identity.\n\n```ts\nexport const filterFns = {\n includesString: (value: unknown, query: string) =>\n String(value).toLowerCase().includes(query.toLowerCase()),\n equals: (value: unknown, query: unknown) => value === query,\n}\n```\n\n### `type RowModelFactory`\n\nOne stage of the row pipeline: takes the rows produced so far and returns the\nnext set. Stages compose in the order given to `_rowModels`, so filtering\nbefore sorting sorts only what survived the filter.\n\n```ts\nexport type RowModelFactory<TData extends RowData> = (args: {\n table: SvGrid<TData>\n rows: Array<Row<TData>>\n}) => Array<Row<TData>>\n```\n\n### `function createCoreRowModel`\n\nThe identity stage that starts every pipeline. Always required, even when no\nother stage is: it is what turns your data into rows.\n\n```ts\nexport function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ rows }) => rows\n}\n```\n\n### `function createFilteredRowModel`\n\nDrops rows that fail the active {@link ColumnFiltersState}. Pairs with\n`columnFilteringFeature`; without it there are no filters to apply.\n\n```ts\nexport function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {\n /**\n * The last full filter, so a tick can repair it. When `getRowModel`\n * reports which rows were replaced (`replacedRowsOf`) and the filter state\n * and columns are the same objects as last time, each replacement is tested\n * on its own: a row that stays in keeps its slot, a row that stays out\n * stays out, and a row whose membership flips sends the whole thing back\n * through the full pass, because an insertion has to land in data order.\n * Positions come from stamps on the rows (ROW_FILTER_GEN / ROW_FILTER_POS)\n * written by the full pass; no map is built.\n */\n let cache: { input: Array<Row<TData>>; output: Array<Row<TData>>; filters: ColumnFiltersState; columns: Array<Column<TData>>; gen: number } | null = null\n let generation = 0\n\n return ({ table, rows }) => {\n const filters: ColumnFiltersState = table.getState().columnFilters ?? []\n if (!filters.length) {\n cache = null\n return rows\n }\n const columns = table.getAllColumns()\n\n // Resolve each filter's match function once, outside the row loop.\n const compiled = filters.map((filter) => ({\n id: filter.id,\n value: filter.value,\n fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,\n }))\n const passes = (row: Row<TData>) => {\n for (let i = 0; i < compiled.length; i++) {\n const filter = compiled[i]!\n // `getCellValueByColumnId` rather than `getAllCells().find(...)`.\n // Both read the same lazily-built `cachedValues` array, but the latter\n // also builds and caches the row's whole `Cell[]` - one object per\n // column - purely to reach one field. On a 100k-row grid that is\n // 100,000 cell arrays the filter never looks at again, and it defeats\n // the laziness the row factory exists to provide.\n if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false\n }\n return true\n }\n\n if (cache && cache.filters === filters && cache.columns === columns) {\n if (cache.input === rows) return cache.output\n const replaced = replacedRowsOf(table)\n if (replaced && rows.length === cache.input.length) {\n let out: Array<Row<TData>> | null = null\n let ok = true\n for (let i = 0; i < rows.length && ok; i++) {\n const prev = cache.input[i]! as BaseRowState<TData>\n const next = rows[i]! as BaseRowState<TData>\n if (next === prev) continue\n if (replaced.get(prev) !== next) { ok = false; break }\n const wasIn = prev[ROW_FILTER_GEN] === cache.gen\n const isIn = passes(next)\n if (wasIn !== isIn) { ok = false; break }\n if (!isIn) continue\n out ??= cache.output.slice()\n const pos = prev[ROW_FILTER_POS]\n out[pos] = next\n next[ROW_FILTER_GEN] = cache.gen\n next[ROW_FILTER_POS] = pos\n }\n if (ok) {\n const output = out ?? cache.output\n cache = { ...cache, input: rows, output }\n return output\n }\n }\n }\n\n const gen = ++generation\n const output: Array<Row<TData>> = []\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i]! as BaseRowState<TData>\n if (!passes(row)) continue\n row[ROW_FILTER_GEN] = gen\n row[ROW_FILTER_POS] = output.length\n output.push(row)\n }\n cache = { input: rows, output, filters, columns, gen }\n return output\n }\n}\n```\n\n### `function createPaginatedRowModel`\n\nNarrows the rows to the current page. Put it LAST: anything after it would\nonly ever see one page of data.\n\n```ts\nexport function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }\n const start = pagination.pageIndex * pagination.pageSize\n return rows.slice(start, start + pagination.pageSize)\n }\n}\n```\n\n### `function createGroupedRowModel`\n\nBuckets rows by the active {@link GroupingState} and inserts a group row\nahead of each bucket, carrying that bucket's aggregates.\n\n```ts\nexport function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const grouping: GroupingState = table.getState().grouping ?? []\n if (!grouping.length) return rows\n const columns = table.getAllColumns()\n\n // Recursively bucket rows by each grouping column in turn. At every level a\n // group row is built that stands in for its children - a non-group column\n // resolves to the value shared by every leaf row, or to undefined when the\n // leaves disagree.\n function buildGroups(\n input: Array<Row<TData>>,\n levelIndex: number,\n depth: number,\n idPrefix: string,\n /** Grouping columns already fixed by an ancestor bucket, and their raw\n * values - `undefined` where that bucket mixed several. */\n fixedValues: ReadonlyMap<string, unknown>,\n ): Array<Row<TData>> {\n if (levelIndex >= grouping.length) {\n // Leaves: actual data rows, with their nesting depth recorded.\n return input.map((row) => ({ ...row, depth }))\n }\n const groupKey = grouping[levelIndex]\n if (!groupKey) return input\n\n // Buckets carry the RAW grouping value alongside the rows, plus whether\n // the bucket saw more than one distinct raw value. Both are needed to let\n // deeper levels skip re-scanning this column: buckets are keyed by\n // `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one\n // bucket, and a scan of such a bucket would report disagreement. Tracking\n // it here costs one comparison per row and keeps the shortcut honest.\n type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }\n const buckets = new Map<string, Bucket>()\n for (const row of input) {\n const value = row.getCellValueByColumnId(groupKey)\n const key = String(value ?? '')\n const bucket = buckets.get(key)\n if (bucket) {\n bucket.rows.push(row)\n if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true\n } else {\n buckets.set(key, { rows: [row], raw: value, mixed: false })\n }\n }\n\n const groupRows: Array<Row<TData>> = []\n let index = 0\n buckets.forEach((bucket, key) => {\n const children = bucket.rows\n const id = `${idPrefix}_${groupKey}_${key}`\n // Record this column as fixed for deeper levels ONLY when the bucket is\n // homogeneous. If all rows here share a raw value, so does every subset\n // of them, which is what makes the shortcut sound.\n //\n // A MIXED bucket must not be recorded at all - not even as \"undefined\".\n // `null`, `undefined` and `''` share a bucket key, so a mixed bucket can\n // still split into homogeneous children one level down, and those\n // children have a real shared value that a scan would find. Marking the\n // column resolved here would hand them the parent's disagreement.\n const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)\n const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)\n const isDeepest = levelIndex + 1 >= grouping.length\n const leafCount = isDeepest\n ? subRows.length\n : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)\n\n // Every grouping column ABOVE this level is already resolved: bucketing\n // by it is what made it constant, so scanning the children to rediscover\n // it is pure waste. Only the current level's key short-circuited before,\n // so a second-level group walked all of its children to re-derive the\n // first level's value - about 100,000 reads on the 100k x 9 two-level\n // case, for an answer already in hand.\n //\n // The current level still returns the stringified bucket key rather than\n // the raw value, because that is what it has always returned and the\n // group row's display depends on it.\n const resolveColumnValue = (columnId: string): unknown => {\n if (columnId === groupKey) return key\n if (fixedValues.has(columnId)) return fixedValues.get(columnId)\n let resolved: unknown\n let hasResolved = false\n for (const child of children) {\n const childValue = child.getCellValueByColumnId(columnId)\n if (!hasResolved) {\n resolved = childValue\n hasResolved = true\n } else if (childValue !== resolved) {\n return undefined\n }\n }\n return resolved\n }\n\n const groupOriginal: Record<string, unknown> = {}\n columns.forEach((column) => {\n const field = column.columnDef.field\n if (!field) return\n const agg = column.columnDef.aggregate\n groupOriginal[field] = agg\n ? applyGroupAggregate(agg, column.id, children)\n : resolveColumnValue(column.id)\n })\n\n const groupRow: Row<TData> = {\n id,\n index: index++,\n original: groupOriginal as TData,\n depth,\n subRows,\n leafCount,\n getCanExpand: () => true,\n getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),\n toggleExpanded: () => {\n table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),\n toggleSelected: () => {\n table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getAllCells: () => [],\n // Prefer the precomputed group value (which carries aggregates)\n // and fall back to the shared-value resolver for columns without\n // a field.\n getCellValueByColumnId: (columnId: string) => {\n const col = columns.find((c) => c.id === columnId)\n const field = col?.columnDef.field\n if (field && field in groupOriginal) return groupOriginal[field]\n return resolveColumnValue(columnId)\n },\n }\n groupRows.push(groupRow)\n })\n return groupRows\n }\n\n return buildGroups(rows, 0, 0, 'group', new Map())\n }\n}\n```\n\n### `type TreeRowModelOptions`\n\nHow to read a hierarchy out of FLAT rows: each row names its parent, and the\ngrid reconstructs the tree. Rows whose parent id matches nothing become roots\nrather than disappearing.\n\nFor nested source data (`children: [...]`), flatten it first with\n{@link flattenTreeData}.\n\n```ts\nexport type TreeRowModelOptions = {\n /** Field holding each row's parent id. Rows with no parent are roots. */\n parentField: string\n /** Field holding the row's own id. Defaults to `'id'`. */\n idField?: string\n}\n```\n\n### `function createTreeRowModel`\n\nClient-side tree data: nest the grid's own flat rows into a parent/child\nhierarchy that `createExpandedRowModel` then walks.\n\nThis works on the rows the grid already built rather than on raw data, so\ntree rows keep their cells, editing, selection and formatting - they are real\ndata rows that happen to have children, not synthetic banners like grouping's.\nThat is also why the model is parent-id based: nested source arrays never\nbecome rows (the grid only builds rows for `data`), so nested input is\nflattened first with {@link flattenTreeData}. One code path, no duplicated\nrow construction.\n\nRows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable\ndata row for a full-width group banner.\n\n```ts\nexport function createTreeRowModel<TData extends RowData>(\n options: TreeRowModelOptions,\n): RowModelFactory<TData> {\n const { parentField, idField = 'id' } = options\n return ({ table, rows }) => {\n if (!rows.length) return rows\n const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]\n const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]\n\n const present = new Set<unknown>()\n for (const row of rows) present.add(keyOf(row))\n\n const childrenByParent = new Map<unknown, Array<Row<TData>>>()\n const roots: Array<Row<TData>> = []\n for (const row of rows) {\n const parent = parentOf(row)\n // A row whose parent is absent (filtered out, or never existed) becomes a\n // root rather than disappearing - silently dropping rows is worse than a\n // shallower tree. Self-parenting is treated the same way.\n if (parent == null || parent === keyOf(row) || !present.has(parent)) {\n roots.push(row)\n continue\n }\n const list = childrenByParent.get(parent) ?? []\n list.push(row)\n childrenByParent.set(parent, list)\n }\n\n // Guards a cycle in the parent chain from recursing forever.\n const seen = new Set<unknown>()\n const build = (row: Row<TData>, depth: number): Row<TData> => {\n const key = keyOf(row)\n const id = row.id\n if (seen.has(key)) {\n return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>\n }\n seen.add(key)\n const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))\n return {\n ...row,\n depth,\n subRows,\n leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),\n __treeRow: true,\n getCanExpand: () => subRows.length > 0,\n getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),\n toggleExpanded: () => {\n table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n } as Row<TData>\n }\n\n return roots.map((root) => build(root, 0))\n }\n}\n```\n\n### `type FlattenTreeOptions`\n\nHow to flatten NESTED source data into the parent-id shape tree rows need.\n`parentField` is written onto each row, so point `treeData.parentField` at\nthe same name afterwards.\n\n```ts\nexport type FlattenTreeOptions = {\n /** Field holding an array of child objects. */\n childrenField: string\n /** Field holding each object's id. Defaults to `'id'`. */\n idField?: string\n /** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */\n parentField?: string\n}\n```\n\n### `function flattenTreeData`\n\nFlatten nested tree data into the flat parent-id shape `createTreeRowModel`\nconsumes, stamping each child with its parent's id.\n\nChildren are emitted directly after their parent so the natural order already\nmatches the rendered tree. The `childrenField` array is left on the objects\n(harmless, and callers often still want it); only the parent link is added.\n\n```ts\nexport function flattenTreeData<T extends RowData>(\n data: ReadonlyArray<T>,\n options: FlattenTreeOptions,\n): T[] {\n const { childrenField, idField = 'id', parentField = '__parentId' } = options\n const out: T[] = []\n const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {\n for (const node of nodes) {\n const flat = { ...node, [parentField]: parentId } as T\n out.push(flat)\n const kids = (node as any)[childrenField]\n if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])\n }\n }\n walk(data, null)\n return out\n}\n```\n\n### `function createExpandedRowModel`\n\nHides the descendants of collapsed rows. Needed for grouping, tree data and\nmaster-detail alike - all three are the same expand/collapse mechanism.\n\n```ts\nexport function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const expanded: ExpandedState = table.getState().expanded ?? {}\n // Nothing expanded means every collapsed row hides its children and the\n // output is the input, element for element. Returning it as is keeps the\n // array identity the stages downstream and the render window key on, and\n // skips a 100k-element copy per data change on a flat grid.\n let anyExpanded = false\n for (const id in expanded) {\n if (expanded[id]) { anyExpanded = true; break }\n }\n if (!anyExpanded) return rows\n const flattened: Array<Row<TData>> = []\n const visit = (row: Row<TData>) => {\n flattened.push(row)\n if (row.subRows?.length && expanded[row.id]) {\n for (const sub of row.subRows) visit(sub)\n }\n }\n for (const row of rows) visit(row)\n return flattened\n }\n}\n```\n\n### `function replacedRowsOf`\n\nThe rows `getRowModel` replaced in the rebuild this pipeline run follows, or null.\n\n```ts\nexport function replacedRowsOf(table: SvGrid<any>): ReadonlyMap<Row<any>, Row<any>> | null {\n return replacedRowsByGrid.get(table) ?? null\n}\n```\n\n### `function createSortedRowModel`\n\nThe sorting stage of the row pipeline: orders the rows by the grid's\n`sorting` state with the built-in comparators (or the `localSortFns` map\nhanded in), stable, equal keys keeping their data order. A tick that\nreplaces a few rows out of many is repaired incrementally rather than\nre-sorted (see the note inside). Registered by `rowSortingFeature`; an app\nonly calls this to install its own comparator set.\n\n```ts\nexport function createSortedRowModel<TData extends RowData>(\n localSortFns: typeof sortFns = sortFns,\n): RowModelFactory<TData> {\n /**\n * What the last full sort produced, kept so a tick can repair it.\n *\n * A live feed replaces a few hundred row objects out of a hundred thousand\n * and hands the grid a new array. Re-sorting all of it costs the same as\n * the first sort did (115-149 ms in the browser for 100k rows, see\n * docs/help/benchmarks.md); the order it would produce differs from the\n * previous one only where the replaced rows land. So the previous output\n * is kept, with its sort keys aligned to it, and when `getRowModel` says\n * which rows were replaced (see `replacedRowsOf`) the stage drops those,\n * sorts the replacements among themselves and merges them back in one\n * linear pass: O(n + k log k) instead of O(n log n) key builds and\n * comparisons.\n *\n * The repair is only attempted when everything else is the same object as\n * last time - the sorting state, the columns, and every input row except\n * the replaced ones - and it produces exactly what the full sort would:\n * equal keys fall back to the data index, which is the order a stable sort\n * over data-ordered input keeps.\n */\n let cache: {\n input: Array<Row<TData>>\n output: Array<Row<TData>>\n sorting: SortingState\n columns: Array<Column<TData>>\n clauses: Array<SortClause>\n } | null = null\n\n type SortClause = {\n desc: boolean\n compare: (a: any, b: any) => number\n /** The key for one row, or `MISSING_KEY` when it cannot be produced incrementally. */\n keyOf: (row: Row<TData>) => any\n }\n const MISSING_KEY = Symbol('svgrid.sort.missing-key')\n\n function fullSort(rows: Array<Row<TData>>, sorting: SortingState, allColumns: Array<Column<TData>>) {\n // Resolve every clause ONCE, before sorting.\n //\n // This used to live inside the comparator, so `getAllColumns().find(...)`\n // ran per comparison per clause: a single-clause sort of 100k rows made\n // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;\n // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)\n // times, so anything inside it that is not O(1) sets the cost of the sort.\n const clauses: Array<{\n keys: Array<any>\n desc: boolean\n compare: (a: any, b: any) => number\n keyOf: (row: Row<TData>) => any\n }> = []\n\n for (const clause of sorting) {\n const column = allColumns.find((col) => col.id === clause.id)\n if (!column) continue\n const editorType = column.columnDef.editorType\n const comparator =\n editorType === 'number'\n ? localSortFns.number\n : editorType === 'date' || editorType === 'datetime'\n ? localSortFns.date\n : localSortFns.auto\n\n // Precompute one sort key per row, so the comparator reads an array slot\n // instead of walking the row's column index on every comparison. For the\n // three built-in comparators the key is also cheaper to compare than the\n // raw value: a timestamp rather than two `new Date()` allocations, a\n // number rather than two `Number()` coercions, a collator rather than a\n // fresh one per `localeCompare` call.\n //\n // The identity checks against `sortFns` matter: `localSortFns` is a\n // public parameter, so a caller can substitute their own comparators.\n // When they have, we fall through to calling their function with the raw\n // values - still hoisted, just not specialised.\n const columnId = column.id\n const n = rows.length\n let keys: Array<any> = new Array(n)\n let compare: (a: any, b: any) => number\n let keyOf: (row: Row<TData>) => any\n\n if (comparator === sortFns.number) {\n keyOf = (row) => Number(row.getCellValueByColumnId(columnId) ?? 0)\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareNumericKeys\n } else if (comparator === sortFns.date) {\n keyOf = (row) => new Date(row.getCellValueByColumnId(columnId) as any).getTime()\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareNumericKeys\n } else if (comparator === sortFns.auto) {\n const strings: string[] = new Array(n)\n // Decide whether ranking is worth attempting BEFORE paying for it.\n //\n // Building the distinct set and then discarding it costs about 9 ms on\n // a 100k-row column where nearly every value is unique, and ranking\n // saves about 19 ms where they repeat - so guessing wrong in either\n // direction is measurable. A small stride sample answers it for well\n // under a millisecond.\n //\n // Strided rather than the first N rows: data arrives sorted or\n // clustered often enough that a prefix is a bad estimator of the whole\n // column. Reading every k-th row is no more expensive and does not care\n // how the rows are arranged.\n const rankLimit = n >> 1\n let distinct: Set<string> | null = null\n if (n > 0) {\n const sampleTarget = Math.min(n, 256)\n const stride = Math.max(1, Math.floor(n / sampleTarget))\n const sample = new Set<string>()\n let sampled = 0\n for (let i = 0; i < n; i += stride) {\n sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))\n sampled++\n }\n // Only attempt ranking when the sample suggests real repetition.\n if (sample.size * 2 <= sampled) distinct = new Set()\n }\n\n for (let i = 0; i < n; i++) {\n const s = String(rows[i]!.getCellValueByColumnId(columnId))\n strings[i] = s\n if (distinct) {\n distinct.add(s)\n if (distinct.size > rankLimit) distinct = null\n }\n }\n\n // Collation is by far the most expensive comparison we do - a CPU\n // profile of a 100k text sort put 65% of the whole operation inside the\n // collator. But a column's DISTINCT values are usually far fewer than\n // its rows (statuses, regions, categories, owners), so rank the\n // distinct values once and sort by rank afterwards. That turns\n // O(n log n) collator calls into O(u log u), where u is the number of\n // distinct values, and the resulting order is identical because rank is\n // a monotone relabelling of the collated order - equal strings share a\n // rank, so ties still fall through to the stable sort exactly as before.\n //\n // Guarded on the uniqueness ratio: when nearly every value is distinct\n // the ranking pass cannot save any collator calls and would just add an\n // O(n) Map build, so that case keeps comparing directly.\n if (distinct) {\n const ordered = Array.from(distinct).sort(compareCollatedKeys)\n const rankOf = new Map<string, number>()\n for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)\n for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!\n compare = compareNumericKeys\n // A replacement whose text is not among the ranked values has no\n // rank; the repair path treats that as \"sort everything again\".\n keyOf = (row) => rankOf.get(String(row.getCellValueByColumnId(columnId))) ?? MISSING_KEY\n } else {\n keys = strings\n compare = compareCollatedKeys\n keyOf = (row) => String(row.getCellValueByColumnId(columnId))\n }\n } else {\n keyOf = (row) => row.getCellValueByColumnId(columnId)\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = comparator\n }\n\n clauses.push({ keys, desc: clause.desc, compare, keyOf })\n }\n\n if (!clauses.length) return null\n\n // Sort an index array, then materialise. `Array.prototype.sort` is stable,\n // so equal keys keep their original relative order exactly as the previous\n // `[...rows].sort(...)` did.\n const order = new Array<number>(rows.length)\n for (let i = 0; i < order.length; i++) order[i] = i\n\n // Single-clause sorts get a specialised comparator.\n //\n // Most sorts are one column, and that path runs O(n log n) times - 1.66\n // million comparisons for 100k rows. The general loop pays a clause-array\n // index, three property loads and an indirect call on every one of them,\n // none of which vary once the clause list is fixed. Hoisting them into a\n // closure and, for the numeric comparator, inlining the subtraction removes\n // the call entirely.\n //\n // `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`\n // as far as sorting is concerned: both are NaN for unorderable values,\n // which the spec coerces to 0, and they differ only in producing 0 versus\n // -0 for equal keys, which sorts identically.\n if (clauses.length === 1) {\n const { keys, compare, desc } = clauses[0]!\n if (compare === compareNumericKeys) {\n order.sort(\n desc\n ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }\n : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },\n )\n } else {\n order.sort(\n desc\n ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }\n : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },\n )\n }\n } else {\n order.sort(function compareRowsByClauses(ia, ib) {\n for (let k = 0; k < clauses.length; k++) {\n const clause = clauses[k]!\n const result = clause.compare(clause.keys[ia], clause.keys[ib])\n if (result !== 0) return clause.desc ? -result : result\n }\n return 0\n })\n }\n\n const sorted = new Array<Row<TData>>(rows.length)\n for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!\n // Each row keeps its key(s), so a later tick can place a replacement\n // among the kept rows without reading any of them again. One property\n // write per row; an array per row only when there are several clauses.\n if (clauses.length === 1) {\n const keys = clauses[0]!.keys\n for (let i = 0; i < rows.length; i++) (rows[i] as any)[ROW_SORT_KEY] = keys[i]\n } else {\n for (let i = 0; i < rows.length; i++) {\n const per = new Array(clauses.length)\n for (let k = 0; k < clauses.length; k++) per[k] = clauses[k]!.keys[i]\n ;(rows[i] as any)[ROW_SORT_KEY] = per\n }\n }\n return { sorted, clauses: clauses.map(({ desc, compare, keyOf }) => ({ desc, compare, keyOf })) }\n }\n\n /**\n * The previous output with the replaced rows swapped for their\n * replacements, in sorted position. Null when the input is not the cached\n * input plus replacements, or a replacement has no incremental key.\n *\n * Every row carries the key(s) the last full sort computed for it\n * (ROW_SORT_KEY: the key itself for one clause, an array for several), so\n * the merge moves row references and nothing else. At 100k rows the loop\n * that also copied an aligned key array cost 3.7 ms; this one is the\n * reference copy alone.\n */\n function repair(rows: Array<Row<TData>>, replaced: ReadonlyMap<Row<any>, Row<any>>) {\n const c = cache!\n if (rows.length !== c.input.length) return null\n // Which rows of THIS stage's input changed. The map may name rows a\n // stage upstream filtered out; only the ones that reach here matter.\n const dropGen = ++sortDropGeneration\n const added: Array<Row<TData>> = []\n for (let i = 0; i < rows.length; i++) {\n const prev = c.input[i]!\n const next = rows[i]!\n if (next === prev) continue\n if (replaced.get(prev) !== next) return null\n ;(prev as any)[ROW_SORT_DROPPED] = dropGen\n added.push(next)\n }\n if (!added.length) return c.output\n\n const clauses = c.clauses\n const single = clauses.length === 1\n for (let j = 0; j < added.length; j++) {\n const row = added[j]! as any\n if (single) {\n const key = clauses[0]!.keyOf(row)\n if (key === MISSING_KEY) return null\n row[ROW_SORT_KEY] = key\n } else {\n const keys = new Array(clauses.length)\n for (let k = 0; k < clauses.length; k++) {\n const key = clauses[k]!.keyOf(row)\n if (key === MISSING_KEY) return null\n keys[k] = key\n }\n row[ROW_SORT_KEY] = keys\n }\n }\n\n // Keys first, then the data index: the tie-break a stable sort over\n // data-ordered input applies, so the merge lands where a full sort would.\n const prevOut = c.output\n const compareRows = single\n ? (() => {\n const { compare, desc } = clauses[0]!\n return (a: Row<TData>, b: Row<TData>) => {\n const r = compare((a as any)[ROW_SORT_KEY], (b as any)[ROW_SORT_KEY])\n if (r !== 0) return desc ? -r : r\n return a.index - b.index\n }\n })()\n : (a: Row<TData>, b: Row<TData>) => {\n const ka = (a as any)[ROW_SORT_KEY]\n const kb = (b as any)[ROW_SORT_KEY]\n for (let k = 0; k < clauses.length; k++) {\n const clause = clauses[k]!\n const r = clause.compare(ka[k], kb[k])\n if (r !== 0) return clause.desc ? -r : r\n }\n return a.index - b.index\n }\n\n // Sort the replacements among themselves (k log k)...\n added.sort(compareRows)\n\n // ...find where each one goes in the previous output by binary search\n // (k log n comparisons, against the dropped rows too - they are skipped\n // below, which does not move an insertion point)...\n const n = prevOut.length\n const insertAt = new Array<number>(added.length)\n let lo = 0\n for (let j = 0; j < added.length; j++) {\n // Monotone: the replacements are sorted, so each search starts where\n // the previous one landed.\n const row = added[j]!\n let hi = n\n while (lo < hi) {\n const mid = (lo + hi) >>> 1\n if (compareRows(row, prevOut[mid]!) < 0) hi = mid\n else lo = mid + 1\n }\n insertAt[j] = lo\n }\n\n // ...then one linear pass that only moves references.\n const out = new Array<Row<TData>>(rows.length)\n let w = 0\n let j = 0\n let nextInsert = insertAt[0] ?? -1\n for (let i = 0; i < n; i++) {\n while (nextInsert === i) {\n out[w++] = added[j++]!\n nextInsert = j < added.length ? insertAt[j]! : -1\n }\n const prev = prevOut[i]!\n if ((prev as any)[ROW_SORT_DROPPED] === dropGen) continue\n out[w++] = prev\n }\n while (j < added.length) out[w++] = added[j++]!\n return out\n }\n\n return function sortedRowModelStage({ table, rows }) {\n const sorting = table.getState().sorting ?? []\n if (!sorting.length) {\n cache = null\n return rows\n }\n const columns = table.getAllColumns()\n\n if (cache && cache.sorting === sorting && cache.columns === columns) {\n if (cache.input === rows) return cache.output\n const replaced = replacedRowsOf(table)\n if (replaced) {\n const repaired = repair(rows, replaced)\n if (repaired) {\n cache = { ...cache, input: rows, output: repaired }\n return repaired\n }\n }\n }\n\n const full = fullSort(rows, sorting, columns)\n if (!full) {\n cache = null\n return rows\n }\n cache = { input: rows, output: full.sorted, sorting, columns, clauses: full.clauses }\n return full.sorted\n }\n}\n```\n\n### `type SvGridOptions`\n\nEverything {@link createSvGridCore} accepts: the data and columns, the\nfeatures and row models that make up the pipeline, and an `on*Change`\ncallback per piece of state for controlled use.\n\n`<SvGrid>` builds this for you from its props - you only construct it\ndirectly when driving the headless core.\n\n```ts\nexport type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {\n _features: TFeatures\n _rowModels?: {\n coreRowModel?: RowModelFactory<TData>\n filteredRowModel?: RowModelFactory<TData>\n sortedRowModel?: RowModelFactory<TData>\n paginatedRowModel?: RowModelFactory<TData>\n groupedRowModel?: RowModelFactory<TData>\n expandedRowModel?: RowModelFactory<TData>\n }\n columns: Array<ColumnDef<TFeatures, TData>>\n data: ReadonlyArray<TData>\n /**\n * Optional row-id resolver. When set, the value it returns becomes\n * `row.id` (and therefore the selection / expansion / edit key). When\n * omitted, ids fall back to the row's array index as a string. Use a\n * stable id (database PK, UUID, etc.) so selection survives reorders.\n */\n getRowId?: (row: TData, index: number) => string\n state?: Partial<Record<string, any>>\n onSortingChange?: (updater: Updater<SortingState>) => void\n onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void\n onPaginationChange?: (updater: Updater<PaginationState>) => void\n onGroupingChange?: (updater: Updater<GroupingState>) => void\n onExpandedChange?: (updater: Updater<ExpandedState>) => void\n onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void\n onActiveCellChange?: (updater: Updater<ActiveCellState>) => void\n}\n```\n\n### `type SvGrid`\n\nThe headless grid instance: the state stores plus the read methods a renderer\nneeds (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).\n\nFramework free by design - `<SvGrid>` is one renderer over this, and you can\nwrite another. See the \"Why headless?\" guide.\n\n```ts\nexport type SvGrid<TData extends RowData> = {\n store: Store<Record<string, any>>\n optionsStore: Store<Record<string, any>>\n state: Record<string, any>\n getState: () => Record<string, any>\n setOptions: (updater: Updater<Record<string, any>>) => void\n setColumnFilters: (updater: Updater<ColumnFiltersState>) => void\n setPagination: (updater: Updater<PaginationState>) => void\n setGrouping: (updater: Updater<GroupingState>) => void\n setExpanded: (updater: Updater<ExpandedState>) => void\n setRowSelection: (updater: Updater<RowSelectionState>) => void\n setActiveCell: (updater: Updater<ActiveCellState>) => void\n moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void\n getAllColumns: () => Array<Column<TData>>\n getHeaderGroups: () => Array<HeaderGroup<TData>>\n getFooterGroups: () => Array<HeaderGroup<TData>>\n getRowModel: () => RowModel<TData>\n}\n```\n\n### `function createSvGridCore`\n\nBuild a headless grid: state, the row pipeline, and the read methods, with no\nDOM and no Svelte. This is the engine `<SvGrid>` renders.\n\nMost callers want `createSvGrid` (the runes-aware wrapper) or the component\nitself; reach for this when you are writing your own renderer or running the\npipeline outside a browser.\n\n```ts\nexport function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(\n options: SvGridOptions<TFeatures, TData>,\n): SvGrid<TData> {\n const internalState: Record<string, any> = {\n sorting: [],\n columnFilters: [],\n pagination: { pageIndex: 0, pageSize: options.data.length || 10 },\n grouping: [],\n expanded: {},\n rowSelection: {},\n activeCell: { rowIndex: 0, colIndex: 0, cellId: null },\n ...(options.state ?? {}),\n }\n const store = createStore(internalState)\n const optionsStore = createStore(options as Record<string, any>)\n let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null\n let cachedColumns: Array<Column<TData>> = []\n let cachedHeaderGroups: Array<HeaderGroup<TData>> = []\n let cachedBaseRowsInput: ReadonlyArray<TData> | null = null\n let cachedBaseRowsColumns: Array<Column<TData>> | null = null\n let cachedBaseRows: Array<Row<TData>> = []\n /** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */\n let replacedRows: Map<Row<TData>, Row<TData>> | null = null\n let cachedRowCtx: BaseRowCtx<TData> | null = null\n let cachedRowModel: RowModel<TData> | null = null\n let cachedRowModelBaseRows: Array<Row<TData>> | null = null\n let cachedPipeline = options._rowModels\n let cachedSlices: {\n sorting: SortingState | undefined\n columnFilters: ColumnFiltersState | undefined\n pagination: PaginationState | undefined\n grouping: GroupingState | undefined\n expanded: ExpandedState | undefined\n } | null = null\n\n const grid = {\n store,\n optionsStore,\n get state() {\n return store.state\n },\n getState() {\n return store.state\n },\n setOptions(updater: Updater<Record<string, any>>) {\n optionsStore.setState((prev) =>\n typeof updater === 'function' ? (updater as any)(prev) : updater,\n )\n },\n setColumnFilters(updater: Updater<ColumnFiltersState>) {\n store.setState((prev) => ({\n ...prev,\n columnFilters:\n typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,\n }))\n options.onColumnFiltersChange?.(updater)\n },\n setPagination(updater: Updater<PaginationState>) {\n store.setState((prev) => ({\n ...prev,\n pagination:\n typeof updater === 'function'\n ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })\n : updater,\n }))\n options.onPaginationChange?.(updater)\n },\n setGrouping(updater: Updater<GroupingState>) {\n store.setState((prev) => ({\n ...prev,\n grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,\n }))\n options.onGroupingChange?.(updater)\n },\n setExpanded(updater: Updater<ExpandedState>) {\n store.setState((prev) => ({\n ...prev,\n expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,\n }))\n options.onExpandedChange?.(updater)\n },\n setRowSelection(updater: Updater<RowSelectionState>) {\n store.setState((prev) => ({\n ...prev,\n rowSelection:\n typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,\n }))\n options.onRowSelectionChange?.(updater)\n },\n setActiveCell(updater: Updater<ActiveCellState>) {\n store.setState((prev) => {\n const previous: ActiveCellState = prev.activeCell ?? {\n rowIndex: 0,\n colIndex: 0,\n cellId: null,\n }\n const nextActive =\n typeof updater === 'function' ? updater(previous) : updater\n return {\n ...prev,\n activeCell: nextActive,\n }\n })\n options.onActiveCellChange?.(updater)\n },\n moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {\n const rows = grid.getRowModel().rows\n const columns = grid.getAllColumns()\n const maxRow = Math.max(rows.length - 1, 0)\n const maxCol = Math.max(columns.length - 1, 0)\n const current: ActiveCellState = grid.getState().activeCell ?? {\n rowIndex: 0,\n colIndex: 0,\n cellId: null,\n }\n\n const rowIndex = Math.min(\n Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),\n maxRow,\n )\n const colIndex = Math.min(\n Math.max(current.colIndex + (next.colDelta ?? 0), 0),\n maxCol,\n )\n const columnId = columns[colIndex]?.id ?? 'col_0'\n grid.setActiveCell({\n rowIndex,\n colIndex,\n cellId: `${rowIndex}_${columnId}`,\n })\n },\n getAllColumns() {\n // Cache hit: referentially identical columns array.\n if (cachedColumnsInput === options.columns && cachedColumns.length) {\n return cachedColumns\n }\n // Soft cache hit: consumers commonly recreate the columns array\n // inline on every render (e.g. `columns={[...]}`). If the new\n // array has the same length AND each entry has the same `field` /\n // `id` / `header` (the visibility-affecting structure of a\n // column), trust the previous build. Mutable inner fields like\n // `cell` and `editorOptions` are still picked up on the next real\n // render that bumps an actual data dep - they're read at cell-\n // render time, not at this top-level cache.\n if (\n cachedColumnsInput &&\n options.columns.length === cachedColumnsInput.length &&\n cachedColumns.length === options.columns.length &&\n options.columns.every((c, i) => {\n const prev = cachedColumnsInput![i]!\n return (\n c.field === prev.field &&\n c.id === prev.id &&\n c.header === prev.header &&\n c.editorType === prev.editorType\n )\n })\n ) {\n // Update the stored input reference so the strict check hits\n // next time, but reuse the built column model.\n cachedColumnsInput = options.columns\n return cachedColumns\n }\n\n cachedColumnsInput = options.columns\n cachedHeaderGroups = []\n const build = (\n defs: Array<ColumnDef<TFeatures, TData>>,\n depth: number,\n parentId?: string,\n ): Array<Column<TData>> => {\n const leaves: Array<Column<TData>> = []\n defs.forEach((columnDef, index) => {\n const id = resolveColumnId(columnDef, parentId, depth, index)\n if (columnDef.columns?.length) {\n leaves.push(...build(columnDef.columns, depth + 1, id))\n return\n }\n leaves.push({\n id,\n depth,\n parentId,\n columnDef,\n getCanSort: () =>\n Boolean((options._features as any).rowSortingFeature) &&\n columnDef.sortable !== false,\n getCanFilter: () =>\n Boolean((options._features as any).columnFilteringFeature) &&\n columnDef.filterable !== false,\n getIsSorted: () => {\n const entry = store.state.sorting?.find((s: any) => s.id === id)\n if (!entry) return false\n return entry.desc ? 'desc' : 'asc'\n },\n getToggleSortingHandler: () => () => {\n const clauses: SortingState = store.state.sorting ?? []\n const current = clauses.find((s: any) => s.id === id)\n const nextClause: SortingState = !current\n ? [...clauses, { id, desc: false }]\n : current.desc\n ? clauses.filter((s) => s.id !== id)\n : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))\n store.setState((prev) => ({ ...prev, sorting: nextClause }))\n options.onSortingChange?.(nextClause)\n },\n })\n })\n return leaves\n }\n cachedColumns = build(options.columns, 0)\n return cachedColumns\n },\n getHeaderGroups() {\n if (cachedHeaderGroups.length) return cachedHeaderGroups\n const headers = grid.getAllColumns().map((column) => {\n const header: Header<TData> = {\n id: column.id,\n isPlaceholder: false,\n colSpan: 1,\n column,\n getContext: () => ({ header, column, table: grid }),\n }\n return header\n })\n cachedHeaderGroups = [{ id: 'header_group_0', headers }]\n return cachedHeaderGroups\n },\n getFooterGroups() {\n return grid.getHeaderGroups()\n },\n getRowModel() {\n const columns = grid.getAllColumns()\n // Read once. `options.data` is a getter when <SvGrid> drives the engine\n // (a $state.raw signal), and the rebuild below touched it twice per row:\n // 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.\n const data = options.data\n if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {\n // Same columns as last time: the shared context still describes them,\n // and a row whose data object sits at the same index can keep its\n // row object. A row model that streams blocks hands the grid a new\n // array of 60k entries per block where 100 changed; building 60k\n // fresh row objects each time was most of the cost of a block\n // landing. The reused row drops its memoised values and cells, since\n // an app may have changed the object in place before passing a new\n // array - that is the case the old rebuild covered by accident.\n const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? cachedBaseRows : null\n // Same length as last time: a replacement, not an add or remove, so\n // each new row stands in for the old row at its index and the stages\n // can repair rather than recompute. Anything else is structural.\n replacedRows = previous && previous.length === data.length ? new Map() : null\n cachedBaseRowsInput = data\n cachedBaseRowsColumns = columns\n // O(1) column-id → index lookup so getCellValueByColumnId doesn't do\n // a linear `findIndex` on every cell read (was O(rows × cells × cols)).\n let rowCtx: BaseRowCtx<TData>\n if (previous && cachedRowCtx) {\n rowCtx = cachedRowCtx\n } else {\n const columnIndexById = new Map<string, number>()\n for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)\n // One shared context for every row in this table, so a row carries a\n // pointer rather than a closure scope. See BASE_ROW_METHODS.\n rowCtx = {\n grid: grid as SvGrid<TData>,\n store,\n columns,\n columnCount: columns.length,\n columnIndexById,\n }\n cachedRowCtx = rowCtx\n }\n\n cachedBaseRows = new Array(data.length)\n const getRowId = options.getRowId\n const m = BASE_ROW_METHODS as unknown as {\n getCanExpand: Row<TData>['getCanExpand']\n getIsExpanded: Row<TData>['getIsExpanded']\n toggleExpanded: Row<TData>['toggleExpanded']\n getIsSelected: Row<TData>['getIsSelected']\n toggleSelected: Row<TData>['toggleSelected']\n getAllCells: Row<TData>['getAllCells']\n getCellValueByColumnId: Row<TData>['getCellValueByColumnId']\n }\n for (let index = 0; index < data.length; index++) {\n const original = data[index]!\n const kept = previous?.[index] as BaseRowState<TData> | undefined\n if (kept && kept.original === original) {\n kept[ROW_VALUES] = null\n kept[ROW_CELLS] = null\n cachedBaseRows[index] = kept\n continue\n }\n // Past a quarter of the rows the merge stops being cheaper than a\n // sort with the ranking pass; let the stages do the full work.\n if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null\n // `_values` and `_cells` stay null until something reads them - a\n // 100k-row grid showing twenty rows must not materialise every row's\n // values or cell objects to paint.\n const row: BaseRowState<TData> = {\n id: getRowId ? getRowId(original, index) : String(index),\n index,\n original,\n depth: 0,\n [ROW_CTX]: rowCtx,\n [ROW_VALUES]: null,\n [ROW_CELLS]: null,\n [ROW_FILTER_GEN]: 0,\n [ROW_FILTER_POS]: -1,\n [ROW_SORT_DROPPED]: 0,\n [ROW_SORT_KEY]: undefined,\n getCanExpand: m.getCanExpand,\n getIsExpanded: m.getIsExpanded,\n toggleExpanded: m.toggleExpanded,\n getIsSelected: m.getIsSelected,\n toggleSelected: m.toggleSelected,\n getAllCells: m.getAllCells,\n getCellValueByColumnId: m.getCellValueByColumnId,\n }\n cachedBaseRows[index] = row\n if (replacedRows && kept) replacedRows.set(kept, row)\n }\n // A new array with every object the same is the documented way to\n // say \"I changed rows in place, re-read them\" (docs/help/cells/\n // view-refresh.md). Nothing was replaced, so there is nothing to\n // repair around: the stages recompute from the current values. A\n // repair assumes the rows it keeps are unchanged in value as well as\n // identity, which is the immutable-update contract a tick follows.\n if (replacedRows && replacedRows.size === 0) replacedRows = null\n }\n\n // Only the slices a pipeline stage actually READS belong in this key.\n //\n // `rowSelection` used to be here, which meant ticking one checkbox on a\n // 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a\n // row array that was identical by construction. Nothing reads it: the two\n // consumers are `getIsSelected` closures (on data rows and on group rows)\n // that read `store.state` when called, so they observe a selection change\n // without the model being rebuilt.\n //\n // `_rowModels` is a closed set of six named slots, so no consumer stage\n // can be inserted that might read selection. A caller CAN supply a custom\n // function for one of those slots; if one ever needs a slice that is not\n // listed here, add it here rather than reinstating all of them.\n const currentSlices = {\n sorting: store.state.sorting,\n columnFilters: store.state.columnFilters,\n pagination: store.state.pagination,\n grouping: store.state.grouping,\n expanded: store.state.expanded,\n }\n if (\n cachedRowModel &&\n cachedRowModelBaseRows === cachedBaseRows &&\n cachedPipeline === options._rowModels &&\n cachedSlices?.sorting === currentSlices.sorting &&\n cachedSlices?.columnFilters === currentSlices.columnFilters &&\n cachedSlices?.pagination === currentSlices.pagination &&\n cachedSlices?.grouping === currentSlices.grouping &&\n cachedSlices?.expanded === currentSlices.expanded\n ) {\n return cachedRowModel\n }\n\n let rows: Array<Row<TData>> = cachedBaseRows\n\n const pipeline = options._rowModels ?? {}\n const ordered: Array<RowModelFactory<TData> | undefined> = [\n pipeline.coreRowModel,\n pipeline.filteredRowModel,\n pipeline.sortedRowModel,\n pipeline.groupedRowModel,\n pipeline.expandedRowModel,\n pipeline.paginatedRowModel,\n ]\n // The stages read the replacements through `replacedRowsOf` while the\n // run lasts; a stage that runs outside one sees null.\n replacedRowsByGrid.set(grid, replacedRows)\n try {\n ordered.forEach((fn) => {\n if (fn) rows = fn({ table: grid, rows })\n })\n } finally {\n replacedRowsByGrid.set(grid, null)\n replacedRows = null\n }\n cachedPipeline = options._rowModels\n cachedSlices = currentSlices\n cachedRowModelBaseRows = cachedBaseRows\n cachedRowModel = { rows }\n return cachedRowModel\n },\n } as InternalGrid<TData>\n\n return grid\n}\n```\n\n### `function isFunction`\n\nNarrowing helper for the many options that accept a value or a function.\n\n```ts\nexport function isFunction(value: unknown): value is (...args: Array<any>) => any {\n return typeof value === 'function'\n}\n```\n"
|
|
6698
|
+
"markdown": "# `@svgrid/grid` · `core.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\core.ts`.\n\n### `type RowData`\n\nThe constraint every row type satisfies: an object keyed by string. Your own\nrow type (`type Person = { name: string }`) is what flows through the generics\nbelow; this is only the lower bound they are declared against.\n\n```ts\nexport type RowData = Record<string, unknown>\n```\n\n### `type Updater`\n\nA new value, or a function that derives it from the previous one - the shape\nevery `set*` on the grid accepts, so callers can update state without first\nreading it.\n\n api.setSorting([{ id: 'name', desc: false }])\n api.setSorting((prev) => [...prev, { id: 'age', desc: true }])\n\n```ts\nexport type Updater<T> = T | ((prev: T) => T)\n```\n\n### `type SortingState`\n\nActive sort clauses, outermost first. `desc: false` is ascending.\n\n```ts\nexport type SortingState = Array<{ id: string; desc: boolean }>\n```\n\n### `type ColumnFilter`\n\nOne column's filter: the column `id`, the `value` being matched, and\noptionally which comparison to use. `fn` defaults to the column's own type -\nsee {@link filterFns} for the available names.\n\n```ts\nexport type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }\n```\n\n### `type ColumnFiltersState`\n\nEvery active column filter. A column with no entry here is unfiltered.\n\n```ts\nexport type ColumnFiltersState = Array<ColumnFilter>\n```\n\n### `type PaginationState`\n\nCurrent page position. `pageIndex` is 0-based, so page 1 is index 0.\n\n```ts\nexport type PaginationState = { pageIndex: number; pageSize: number }\n```\n\n### `type GroupingState`\n\nColumn ids the rows are grouped by, outermost first.\n\n```ts\nexport type GroupingState = Array<string>\n```\n\n### `type ExpandedState`\n\nWhich rows are expanded, keyed by row id. Absent means collapsed.\n\n```ts\nexport type ExpandedState = Record<string, boolean>\n```\n\n### `type RowSelectionState`\n\nWhich rows are selected, keyed by row id. Absent means unselected.\n\n```ts\nexport type RowSelectionState = Record<string, boolean>\n```\n\n### `type ActiveCellState`\n\nWhere keyboard focus sits. The indices address the *displayed* grid (after\nsorting, filtering and paging), not the source data.\n\n```ts\nexport type ActiveCellState = {\n rowIndex: number\n colIndex: number\n cellId: string | null\n}\n```\n\n### `type TableFeatures`\n\nThe set of features a grid has registered, as built by {@link tableFeatures}.\nDeliberately open: a feature is identified by its key, so the type carries\nwhich ones are on without enumerating them.\n\n```ts\nexport type TableFeatures = Record<string, unknown>\n```\n\n### `type CellData`\n\nA cell's value. Unconstrained - a column can hold anything.\n\n```ts\nexport type CellData = unknown\n```\n\n### `type HeaderContext`\n\nWhat a column's `header` render function receives.\n\n```ts\nexport type HeaderContext<TData extends RowData> = {\n header: Header<TData>\n column: Column<TData>\n table: SvGrid<TData>\n}\n```\n\n### `type CellContext`\n\nWhat a column's `cell` render function receives. `getValue()` applies the\ncolumn's accessor (`field` or `fieldFn`); `row.original` is the raw object.\n\n```ts\nexport type CellContext<TData extends RowData> = {\n cell: Cell<TData>\n row: Row<TData>\n column: Column<TData>\n table: SvGrid<TData>\n getValue: () => unknown\n}\n```\n\n### `type CellSpanParams`\n\nParams passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks.\n\n```ts\nexport type CellSpanParams<TData extends RowData = RowData> = {\n /** The row's underlying data object. */\n data: TData\n /** Display-row index in the current (filtered/sorted) row set. */\n rowIndex: number\n /** The column's id. */\n columnId: string\n /** The cell's base value for this column. */\n value: unknown\n}\n```\n\n### `type EditorOptionSource`\n\nThe raw option list a column's `editorOptions` can supply.\n\n```ts\nexport type EditorOptionSource = ReadonlyArray<\n string | number | { value: string | number; label?: string; color?: string }\n```\n\n### `type ValueParserParams`\n\nParams passed to a column's `valueParser(...)` on edit commit.\n\n```ts\nexport type ValueParserParams<TData extends RowData = RowData> = {\n /** The value after built-in per-`editorType` coercion. */\n newValue: unknown\n /** The cell's previous value. */\n oldValue: unknown\n /** The raw string the editor produced (pre-coercion). */\n rawInput: string\n /** The row's underlying data object. */\n data: TData\n /** The column's id. */\n columnId: string\n}\n```\n\n### `type EditorContext`\n\nContext passed to a custom `cellEditor` snippet/component. Three write\nhelpers cover the lifecycle:\n\n - `update(next)` - stage `next` as the draft, keep the editor open.\n Use this for live-preview controls (sliders,\n color pickers) so the user can keep adjusting.\n - `commit(next?)` - write the value AND close the editor. The\n argument is optional; when omitted, the most\n recently `update()`d value is saved. Use this\n for \"done\" gestures (Enter, picking an option).\n - `cancel()` - discard the draft and close the editor.\n\n```ts\nexport type EditorContext<TData extends RowData> = CellContext<TData> & {\n value: unknown\n update: (next: unknown) => void\n commit: (next?: unknown) => void\n cancel: () => void\n}\n```\n\n### `type CellFormatConfig`\n\nDeclarative cell formatting, applied through `Intl` - number, currency,\npercent, date and datetime. Prefer this over a `formatter` function: it is\nlocale-aware, and export and the clipboard reuse the same configuration.\n\n```ts\nexport type CellFormatConfig =\n | {\n type: 'number'\n locales?: string | Array<string>\n options?: Intl.NumberFormatOptions\n }\n | {\n type: 'currency'\n /** ISO 4217 (default USD) */\n currency?: string\n locales?: string | Array<string>\n options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>\n }\n | {\n type: 'percent'\n locales?: string | Array<string>\n options?: Omit<Intl.NumberFormatOptions, 'style'>\n /**\n * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).\n * Default false.\n */\n valueIsPercentPoints?: boolean\n }\n | {\n type: 'date' | 'datetime'\n locales?: string | Array<string>\n /**\n * Shortcut patterns merged with `options`:\n * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,\n * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.\n */\n pattern?: string\n options?: Intl.DateTimeFormatOptions\n }\n```\n\n### `type CellFormatter`\n\nA column's custom display function, for anything {@link CellFormatConfig}\ncannot express. Returns a string - to render markup, use `cell` instead.\n\n```ts\nexport type CellFormatter<TData extends RowData> = (context: {\n value: unknown\n row: Row<TData>\n column: Column<TData>\n table: SvGrid<TData>\n}) => string\n```\n\n### `type ColumnDefTemplate`\n\nA header or cell slot: a literal string, or a function returning renderable content.\n\n```ts\nexport type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)\n```\n\n### `type GroupAggregator`\n\nHow a column's value is aggregated for a group row when `columnGrouping`\nis active. Built-in reducers cover the common cases; pass a function for\nanything custom (weighted average, median, percentile, distinct count).\nThe function receives the finite numeric values AND the raw leaf rows.\n\n```ts\nexport type GroupAggregator<TData = any> =\n | 'sum'\n | 'avg'\n | 'min'\n | 'max'\n | 'count'\n | 'countDistinct'\n | 'extent'\n | 'first'\n | ((values: number[], rows: Array<TData>) => unknown)\n```\n\n### `function applyGroupAggregate`\n\nApply a group aggregator over a bucket's leaf rows for one column.\n\n```ts\nexport function applyGroupAggregate<TData extends RowData>(\n agg: GroupAggregator<TData>,\n columnId: string,\n rows: ReadonlyArray<Row<TData>>,\n): unknown {\n // One pass, no intermediate arrays.\n //\n // This used to build a `raw` array, then a coerced one, then a filtered one -\n // three allocations per aggregated column PER GROUP - before reducing. On a\n // 100k-row grid grouped two levels deep, aggregation was about two thirds of\n // the total grouping cost (213ms with three aggregators against 81ms with\n // none), and each additional aggregated column added roughly 80ms.\n //\n // `count` first: it never needs to look at a value at all.\n if (agg === 'count') return rows.length\n\n if (agg === 'first') {\n return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined\n }\n\n if (agg === 'countDistinct') {\n const seen = new Set<string>()\n for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))\n return seen.size\n }\n\n if (typeof agg === 'function') {\n // Custom aggregators keep their contract: the finite numbers, then the\n // original row objects. Note `Number(null)` is 0 and therefore finite, so\n // nulls DO reach the callback as zeros - long-standing behaviour.\n const nums: number[] = []\n for (const row of rows) {\n const n = Number(row.getCellValueByColumnId(columnId))\n if (Number.isFinite(n)) nums.push(n)\n }\n return agg(nums, rows.map((r) => r.original))\n }\n\n // sum / avg / min / max / extent share one accumulation pass.\n let count = 0\n let sum = 0\n let min = Infinity\n let max = -Infinity\n for (const row of rows) {\n const n = Number(row.getCellValueByColumnId(columnId))\n if (!Number.isFinite(n)) continue\n count++\n // Left-to-right, matching the previous `reduce`, so float rounding is\n // bit-identical rather than merely close.\n sum += n\n // Math.min/max on scalars rather than `<`, which differs on -0, and rather\n // than the old `Math.min(...nums)` - spreading a whole group throws\n // RangeError once the bucket is big enough to exhaust the argument stack.\n min = Math.min(min, n)\n max = Math.max(max, n)\n }\n if (!count) return undefined\n switch (agg) {\n case 'sum':\n return sum\n case 'avg':\n return sum / count\n case 'min':\n return min\n case 'max':\n return max\n case 'extent':\n return `${min} – ${max}`\n default:\n return undefined\n }\n}\n```\n\n### `type ColumnDef`\n\nA column definition.\n\n`TFeatures` is a phantom parameter - it is threaded through nested\n`columns` groups but no member depends on it, so `{}`, `TableFeatures` and\n`typeof features` are all interchangeable here. It is deliberately left\nWITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot\nand silently type your data as `RowData`, losing every field-name check.\nPrefer {@link GridColumns} / {@link GridColumnDef} for the common case.\n\n```ts\nexport type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {\n id?: string\n field?: keyof TData & string\n fieldFn?: (row: TData) => unknown\n header?: ColumnDefTemplate<HeaderContext<TData>>\n footer?: ColumnDefTemplate<HeaderContext<TData>>\n cell?: ColumnDefTemplate<CellContext<TData>>\n columns?: Array<ColumnDef<TFeatures, TData>>\n /**\n * Declarative cell spanning (merged cells). Return how many COLUMNS this\n * cell spans to the right (1 = no span). Value-driven. Feed\n * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses\n * the same real `colspan`/`rowspan` merge engine (no separate code path).\n */\n colSpan?: (params: CellSpanParams<TData>) => number\n /**\n * Declarative cell spanning (merged cells). Return how many ROWS this cell\n * spans downward (1 = no span). See `colSpan` for how to apply.\n */\n rowSpan?: (params: CellSpanParams<TData>) => number\n /**\n * High-level data type for the column. A convenience that resolves to the\n * right `editorType`, alignment, date `format`, and filter operators without\n * setting each by hand:\n * 'text' → text editor, left-aligned\n * 'number' → number editor, right-aligned, numeric filter operators\n * 'boolean' → checkbox editor, centered\n * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format\n * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')\n * Anything you set explicitly (`editorType`, `align`, `format`) still wins -\n * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers\n * this from the first data row for columns that declare neither.\n */\n cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'\n /**\n * Hide this column when the grid's `responsive` mode is on and the grid is\n * narrower than this many pixels - drop low-priority columns on small\n * screens. No effect unless the grid has `responsive` set.\n */\n hideBelow?: number\n /**\n * For a column INSIDE a collapsible column group: `'open'` shows this column\n * only while the group is expanded, `'closed'` only while collapsed. Omit to\n * always show it. Setting it on any direct child gives the parent group a\n * collapse toggle. Pair with `openByDefault` on the group.\n */\n columnGroupShow?: 'open' | 'closed'\n /**\n * For a GROUP column (one with `columns: [...]`): start the group expanded.\n * Defaults to `false` (collapsed), the conventional default - so only the always-on\n * and `columnGroupShow: 'closed'` children show until the user expands it.\n */\n openByDefault?: boolean\n editorType?:\n | 'text'\n | 'number'\n | 'date' // rich SvCalendar popover (opt out with 'date-native')\n | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')\n | 'time' // rich SvTimePicker dial (opt out with 'time-native')\n | 'date-native' // plain <input type=\"date\">\n | 'datetime-native' // plain <input type=\"datetime-local\">\n | 'time-native' // plain <input type=\"time\"> - HH:MM or HH:MM:SS\n | 'password' // native <input type=\"password\"> with masked rendering\n | 'checkbox'\n | 'list'\n | 'chips'\n | 'select' // custom dropdown - single value, no typeahead\n | 'rich-select' // custom dropdown with a typeahead search input\n | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)\n | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline\n | 'color' // native <input type=\"color\"> swatch\n | 'rating' // 5-star rating control\n // Any other string names a CUSTOM editor registered via `registerCellEditor`\n // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above\n // autocompleting while allowing arbitrary custom type names.\n | (string & {})\n /**\n * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`\n * and `cancel()` helper. Use when none of the built-in `editorType`s fit;\n * the snippet's outer element is mounted inside the editing cell and\n * inherits keyboard handling (Esc cancels, Enter commits unless your\n * snippet preventDefaults it).\n *\n * Coexists with `editorType`: when both are set, `cellEditor` wins and\n * `editorType` is treated as a hint for parsing the saved value.\n */\n cellEditor?: ColumnDefTemplate<EditorContext<TData>>\n /**\n * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`\n * runs per cell so the tooltip can reflect the value. Returning an empty\n * string skips the tooltip.\n */\n tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)\n /**\n * Declarative per-cell validation. Runs for EVERY\n * rendered cell - including values already present in `data` on load, not\n * just on edit - so bad data is flagged immediately. Invalid cells get the\n * `sv-grid-cell-invalid` class (red highlight) and the returned message as\n * their tooltip.\n *\n * Return value:\n * - `null` / `undefined` / `true` → valid (no highlight)\n * - `false` → invalid, no message\n * - a non-empty `string` → invalid, string is the tooltip\n *\n * On its own `validate` flags: the value keeps rendering as-is and an\n * edit that fails it still lands. Set `rejectInvalid` to refuse such an\n * edit instead.\n */\n validate?: (params: {\n value: unknown\n row: TData\n rowIndex: number\n column: Column<TData>\n }) => string | boolean | null | undefined\n /**\n * Refuse an edit that `validate` flags. When true, a committed value that\n * makes `validate` return `false` or a message is not written: the cell\n * keeps its old value, no history step is recorded and\n * `onCellValueChange` does not fire. The value is checked after\n * `valueParser` has run, so the rule sees what would be stored. Applies to\n * the inline editor, a commit to a selection and a full-row edit; paste\n * does not go through the editor and is not checked.\n */\n rejectInvalid?: boolean\n /**\n * Gate editing per column or per cell.\n *\n * - `true` (or omitted): the column is fully editable.\n * - `false`: the column is read-only - double-click, type-to-edit,\n * fill-handle drag, Delete, and clipboard paste all skip it.\n * - `(ctx) => boolean`: evaluated for each cell, so you can lock\n * individual rows (e.g. by role, status, ownership). Returning\n * `false` opts the cell out of every editing path, identical to\n * setting `editable: false` on the whole column for that row.\n *\n * The grid-wide `enableInlineEditing` prop still wins when set to\n * `false`.\n */\n editable?: boolean | ((context: CellContext<TData>) => boolean)\n /**\n * Transform the committed edit value before it is written to the row.\n * Runs after the built-in per-`editorType` coercion, so `newValue` is\n * already type-parsed; return the final value to store (e.g. round a\n * number, uppercase a code, look up an id). A `valueParser` hook.\n */\n valueParser?: (params: ValueParserParams<TData>) => unknown\n /**\n * Briefly flash / highlight this column's cell when its value changes\n * (streaming feeds, edits, server pushes). `true` uses the default flash;\n * pass `{ className }` to apply your own animation class instead.\n */\n cellFlash?: boolean | { className?: string }\n /**\n * When `false`, this column never shows a sort indicator and clicking\n * its header is a no-op - `api.setSort(thisColumn, ...)` is also\n * ignored. Defaults to `true` (the column participates in sorting as\n * long as `rowSortingFeature` is registered).\n */\n sortable?: boolean\n /**\n * When `false`, this column never shows a filter funnel / menu and\n * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the\n * column is filterable as long as `columnFilteringFeature` is\n * registered).\n */\n filterable?: boolean\n /**\n * Options for `editorType: 'list' | 'chips'`. Either bare values (the\n * string is both value and label) or `{ value, label }` objects.\n * For `chips` this is optional - when omitted, the chips editor becomes\n * free-form (user types and presses Enter to commit a chip).\n *\n * Pass a function `(row) => options` for row-dependent (cascading)\n * options - e.g. City options that depend on Country in the same row.\n *\n * Either form may return a **Promise**, for options that come from the\n * server. While it resolves, the editor shows a loading state and the cell\n * renders its raw value.\n *\n * Results are cached so reopening an editor does not refetch: a static source\n * per column, a per-row source per row AND per that row's data - so a cascade\n * reloads by itself when the cell it depends on is edited. Call\n * `api.refreshEditorOptions(columnId?)` when the list changes server-side.\n */\n editorOptions?:\n | EditorOptionSource\n | Promise<EditorOptionSource>\n | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)\n /** When true, list/chips allow multiple selections. Cell value becomes an array. */\n editorMultiple?: boolean\n /**\n * The text editor accepts line breaks: Alt+Enter inserts one and the\n * editor grows a line per break, while Enter still commits. What a\n * spreadsheet cell with wrapped text wants; `editorType: 'textarea'` is\n * the other shape, where Enter itself is the line break.\n */\n editorMultiline?: boolean\n /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */\n editorSeparator?: string\n format?: CellFormatConfig\n formatter?: CellFormatter<TData>\n /**\n * Aggregate this column's values into the group row when grouping is\n * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |\n * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result\n * is formatted with this column's `format` and shown in the group header.\n */\n aggregate?: GroupAggregator<TData>\n /**\n * What this column contributes to the grid's footer summary row (the one\n * turned on with `summary` / `enableRowSummaries`). Takes the same\n * aggregators as {@link aggregate}, and the result is formatted with this\n * column's `format`.\n *\n * Without it the footer falls back to its default: the sum of a numeric\n * column, `Count: N` otherwise. Set `false` to leave the cell blank, which is\n * usually what an actions or checkbox column wants.\n *\n * { field: 'amount', summary: 'avg' }\n * { id: 'actions', summary: false }\n */\n summary?: GroupAggregator<TData> | false\n /**\n * Render the cell as an in-cell sparkline chart. The cell value should be\n * an array of numbers (or a comma/space separated string). Mutually\n * exclusive with a custom `cell` renderer (a `cell` wins if both are set).\n *\n * { sparkline: { type: 'line' } } // default line\n * { sparkline: { type: 'bar', color: '#16a34a' } }\n * { sparkline: { type: 'winloss' } } // sign-only up/down\n *\n * See `SparklineConfig` for the full option set (type, color,\n * negativeColor, width, height, fixed min/max).\n */\n sparkline?: SparklineConfig\n /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */\n width?: number\n /**\n * Whether the user may resize this column. Only consulted when the grid has\n * `columnResize` on - it narrows that, it does not enable anything.\n *\n * `false` removes the column's drag handle entirely, so pointer drag, the\n * keyboard arrows and double-click-to-autosize are all gone with it, and the\n * column menu drops its Autosize item. Use it for the columns whose width is\n * part of the layout rather than a preference: a row-number gutter, a\n * checkbox column, a fixed icon column.\n *\n * Programmatic sizing is unaffected - `api.autosizeColumn()`,\n * `api.setColumnWidth()` and `fitColumns` all still apply, the same way they\n * do when `columnResize` is off. This governs the user affordance only.\n */\n resizable?: boolean\n /**\n * Initial visibility. Set `false` to start the column hidden while still\n * listing it in the Choose Columns UI for the user to re-enable. Applied\n * once at mount; after that `api.setColumnVisible` / user toggles win.\n * On a group column, `false` hides the whole group's leaf columns.\n */\n visible?: boolean\n /**\n * Horizontal alignment for header and body cells. When omitted, the\n * default is inferred from `editorType`:\n * - `'number' | 'date' | 'datetime'` → `'right'`\n * - `'checkbox'` → `'center'`\n * - everything else → `'left'`\n */\n align?: 'left' | 'center' | 'right'\n /**\n * Per-cell conditional CSS. Two shapes:\n *\n * - **String** (or array of strings): class name(s) added to the\n * cell's `<td>` for every row in this column.\n * - **Function**: invoked per cell with the same `CellContext` shape\n * the `cell` renderer receives. Return a string, an array of\n * strings, or an object mapping class names to booleans.\n *\n * Use it for status tinting, conditional bold, \"negative number\"\n * coloring - anything that's a function of the row's value. Cells\n * still receive their format / cell renderer; the class just\n * augments the rendered `<td>`.\n */\n cellClass?:\n | string\n | ReadonlyArray<string>\n | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)\n}\n```\n\n### `type GridColumnDef`\n\nA column definition keyed only by your row type - the ergonomic form of\n{@link ColumnDef}, whose first parameter is a phantom feature bag that is\nalmost always `{}`.\n\n```ts\nconst columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]\n```\n\nInterchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,\nTData>` in both directions, so it mixes freely with existing code.\n\n```ts\nexport type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>\n```\n\n### `type GridColumns`\n\nAn array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`.\n\n```ts\nexport type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>\n```\n\n### `type Column`\n\nA resolved column: your {@link ColumnDef} plus everything the grid computed\nfrom it - its id, its depth under any group header, and the sort handlers a\nheader needs. This is what you receive in render contexts; the `ColumnDef`\nis what you wrote.\n\n```ts\nexport type Column<TData extends RowData> = {\n id: string\n columnDef: ColumnDef<any, TData>\n depth: number\n parentId?: string\n getCanSort: () => boolean\n getCanFilter: () => boolean\n getIsSorted: () => false | 'asc' | 'desc'\n getToggleSortingHandler: () => () => void\n}\n```\n\n### `type Header`\n\nOne header cell. `colSpan` is how many leaf columns it covers, and\n`isPlaceholder` marks the empty cells that pad a group-header row so the\nlevels line up.\n\n```ts\nexport type Header<TData extends RowData> = {\n id: string\n isPlaceholder: boolean\n colSpan: number\n column: Column<TData>\n getContext: () => HeaderContext<TData>\n}\n```\n\n### `type HeaderGroup`\n\nOne row of header cells. A grid with grouped columns has several, outermost first.\n\n```ts\nexport type HeaderGroup<TData extends RowData> = {\n id: string\n headers: Array<Header<TData>>\n}\n```\n\n### `type Cell`\n\nOne cell: the intersection of a {@link Row} and a {@link Column}.\n\n```ts\nexport type Cell<TData extends RowData> = {\n id: string\n row: Row<TData>\n column: Column<TData>\n getValue: () => unknown\n getContext: () => CellContext<TData>\n}\n```\n\n### `type Row`\n\nA row in the display model. `original` is your untouched data object;\neverything else is grid-computed. `index` is the position in the displayed\nset, so it shifts as sorting and filtering change - key on `id`, not index.\n\nGroup rows and tree parents carry `subRows`; a plain data row does not.\n\n```ts\nexport type Row<TData extends RowData> = {\n id: string\n index: number\n original: TData\n depth: number\n subRows?: Array<Row<TData>>\n /** Total leaf (data) rows under this group row. Undefined for data rows. */\n leafCount?: number\n getCanExpand: () => boolean\n getIsExpanded: () => boolean\n toggleExpanded: () => void\n getIsSelected: () => boolean\n toggleSelected: () => void\n getAllCells: () => Array<Cell<TData>>\n getCellValueByColumnId: (columnId: string) => unknown\n}\n```\n\n### `type RowModel`\n\nThe output of the row pipeline: the rows to display, in order.\n\n```ts\nexport type RowModel<TData extends RowData> = {\n rows: Array<Row<TData>>\n}\n```\n\n### `type Store`\n\nThe minimal reactive store behind the headless core - read `state`, write\nthrough `setState`, and `subscribe` for changes. Deliberately framework\nfree, which is what lets the core run under plain Node.\n\nIn Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained\nselectors so a component only re-runs for the slice it read.\n\n```ts\nexport type Store<T> = {\n readonly state: T\n setState: (updater: (prev: T) => T) => void\n subscribe: (listener: () => void) => () => void\n}\n```\n\n### `const rowSortingFeature`\n\nClick-to-sort. Injected by the `sortable` shortcut.\n\nThis and the five features below are opaque markers: pass the ones you want\nto {@link tableFeatures} and the grid wires up the matching row model. With\n`<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,\n`filterable`, `pageable`, `groupable`) inject them for you. Reach for them\ndirectly when driving the headless core, or when you want a feature on\nwithout its UI.\n\nThe names match TanStack Table v9, so a features object written for it works\nhere unchanged.\n\n```ts\nexport const rowSortingFeature = { key: 'rowSortingFeature' }\n```\n\n### `const columnFilteringFeature`\n\nPer-column filtering. Injected by the `filterable` shortcut.\n\n```ts\nexport const columnFilteringFeature = { key: 'columnFilteringFeature' }\n```\n\n### `const rowPaginationFeature`\n\nPaging of the row model. Injected by the `pageable` shortcut.\n\n```ts\nexport const rowPaginationFeature = { key: 'rowPaginationFeature' }\n```\n\n### `const columnGroupingFeature`\n\nRow grouping with aggregation. Injected by the `groupable` shortcut.\n\n```ts\nexport const columnGroupingFeature = { key: 'columnGroupingFeature' }\n```\n\n### `const rowSelectionFeature`\n\nRow selection state (the checkbox column reads it).\n\n```ts\nexport const rowSelectionFeature = { key: 'rowSelectionFeature' }\n```\n\n### `const rowExpandingFeature`\n\nExpand / collapse, for tree rows and master-detail.\n\n```ts\nexport const rowExpandingFeature = { key: 'rowExpandingFeature' }\n```\n\n### `function tableFeatures`\n\nDeclare which features a grid uses. Identity at runtime - its whole job is to\ncapture the exact set in the type, so `ColumnDef<typeof features, Row>` knows\nwhat is registered and anything you did not register is tree-shaken out.\n\n```ts\nconst features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n```\n\nSame call signature as TanStack Table v9, so a features object written for it\ntransfers unchanged.\n\n```ts\nexport function tableFeatures<T extends TableFeatures>(features: T): T {\n return features\n}\n```\n\n### `const sortFns`\n\nBuilt-in comparators, chosen per column by its data type. `auto` compares as\ntext; set a column's type or supply your own comparator to override.\n\n```ts\nexport const sortFns = {\n auto: (a: unknown, b: unknown) => compareAutoValues(a, b),\n number: (a: unknown, b: unknown) => compareNumericKeys(numberSortKey(a), numberSortKey(b)),\n date: (a: unknown, b: unknown) => compareNumericKeys(dateSortKey(a), dateSortKey(b)),\n}\n```\n\n### `const filterFns`\n\nBuilt-in match functions, named by {@link ColumnFilter}'s `fn`.\n`includesString` is case-insensitive substring; `equals` is strict identity.\n\n```ts\nexport const filterFns = {\n includesString: (value: unknown, query: string) =>\n String(value).toLowerCase().includes(query.toLowerCase()),\n equals: (value: unknown, query: unknown) => value === query,\n}\n```\n\n### `type BaseRowCtx`\n\nEverything a base row needs that is the same for every row in the table.\n\nOne object per table, referenced by every row, instead of one closure scope\nper row. See {@link BASE_ROW_METHODS}.\n\n```ts\nexport type BaseRowCtx<TData extends RowData> = {\n grid: SvGrid<TData>\n store: { state: Record<string, any> }\n columns: Array<Column<TData>>\n columnCount: number\n columnIndexById: Map<string, number>\n}\n```\n\n### `const ROW_VALUES`\n\nA base row's cached cell values. @internal\n\n```ts\nexport const ROW_VALUES = Symbol('svgrid.row.values')\n```\n\n### `const ROW_CELLS`\n\nA base row's cached cells. @internal\n\n```ts\nexport const ROW_CELLS = Symbol('svgrid.row.cells')\n```\n\n### `const ROW_FILTER_POS`\n\nThe row's index in the last full filter's output (see ROW_FILTER_GEN). @internal\n\n```ts\nexport const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')\n```\n\n### `function denseRows`\n\nThe rows as a plain dense array. For a lazyRows() list, makes the rows not\nmade yet and returns its backing array - the same row objects, without the\nProxy in front. Anything that reads every row (a sort, a filter) should go\nthrough this first: a trap per read, over several passes of a million rows,\ncost more than building the rows did.\n\n```ts\nexport function denseRows<T>(rows: T[]): T[] {\n const fill = (rows as unknown as Record<symbol, (() => T[]) | undefined>)[LAZY_FILL]\n return fill ? fill() : rows\n}\n```\n\n### `type WindowedRowModel`\n\nThe row model for windowed data (windowed-data.ts), or null when `data` is\nan ordinary array. It lives in windowed-row-model.ts and is installed by the\nfirst createWindowedData() call, so a grid that is never handed windowed\ndata does not bundle it: this file imports nothing from either module.\n\n```ts\nexport type WindowedRowModel<TData extends RowData> = (data: unknown, columns: Array<Column<TData>>) => RowModel<TData> | null\n```\n\n### `type WindowedRowModelInstall`\n\nHow windowed-row-model.ts builds its row model from the base row helpers. @internal\n\n```ts\nexport type WindowedRowModelInstall =<TData extends RowData>(ctx: {\n rowCtxFor: (columns: Array<Column<TData>>) => BaseRowCtx<TData>\n getRowId: () => ((row: TData, index: number) => string) | undefined\n}) => WindowedRowModel<TData>\n```\n\n### `function registerWindowedRowModel`\n\nInternal: windowed-row-model.ts installs itself here.\n\n```ts\nexport function registerWindowedRowModel(install: WindowedRowModelInstall): void {\n installWindowedRowModel = install\n}\n```\n\n### `type BaseRowState`\n\nA base row's private fields, on top of the public {@link Row} surface.\n\n```ts\nexport type BaseRowState<TData extends RowData> = Row<TData> & {\n [ROW_CTX]: BaseRowCtx<TData>\n [ROW_VALUES]: Array<unknown> | null\n [ROW_CELLS]: Array<Cell<TData>> | null\n [ROW_FILTER_GEN]: number\n [ROW_FILTER_POS]: number\n [ROW_SORT_DROPPED]: number\n [ROW_SORT_KEY]: unknown\n}\n```\n\n### `const BASE_ROW_METHODS`\n\nThe methods every base row carries, defined ONCE and assigned by reference.\n\nRows used to be built as object literals whose methods were closures, which\nmeant a 100k-row grid allocated 700k closures and a closure scope per row\nbefore painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,\nagainst 2.0 ms and 14.5 MB for this shape - the single largest cost in\nmounting a large grid.\n\nThey read their row through `this` rather than a captured variable, which is\nwhy they can be shared. Note they are assigned as OWN properties rather than\nput on a prototype: `Row` is public, several row models legitimately do\n`{ ...row, depth }`, and a spread copies own properties but not a prototype.\nA class here would silently strip every method off a cloned row.\n\n```ts\nexport const BASE_ROW_METHODS = {\n getCanExpand(this: BaseRowState<RowData>) {\n return false\n },\n getIsExpanded(this: BaseRowState<RowData>) {\n return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id])\n },\n toggleExpanded(this: BaseRowState<RowData>) {\n const id = this.id\n this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getIsSelected(this: BaseRowState<RowData>) {\n return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id])\n },\n toggleSelected(this: BaseRowState<RowData>) {\n const id = this.id\n this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getAllCells(this: BaseRowState<RowData>) {\n return this[ROW_CELLS] ?? buildBaseRowCells(this)\n },\n getCellValueByColumnId(this: BaseRowState<RowData>, columnId: string) {\n const idx = this[ROW_CTX].columnIndexById.get(columnId)\n if (idx === undefined) return undefined\n if (!this[ROW_VALUES]) this[ROW_VALUES] = baseRowValues(this)\n return this[ROW_VALUES][idx]\n },\n}\n```\n\n### `type RowModelFactory`\n\nOne stage of the row pipeline: takes the rows produced so far and returns the\nnext set. Stages compose in the order given to `_rowModels`, so filtering\nbefore sorting sorts only what survived the filter.\n\n```ts\nexport type RowModelFactory<TData extends RowData> = (args: {\n table: SvGrid<TData>\n rows: Array<Row<TData>>\n}) => Array<Row<TData>>\n```\n\n### `function createCoreRowModel`\n\nThe identity stage that starts every pipeline. Always required, even when no\nother stage is: it is what turns your data into rows.\n\n```ts\nexport function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ rows }) => rows\n}\n```\n\n### `function createFilteredRowModel`\n\nDrops rows that fail the active {@link ColumnFiltersState}. Pairs with\n`columnFilteringFeature`; without it there are no filters to apply.\n\n```ts\nexport function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {\n /**\n * The last full filter, so a tick can repair it. When `getRowModel`\n * reports which rows were replaced (`replacedRowsOf`) and the filter state\n * and columns are the same objects as last time, each replacement is tested\n * on its own: a row that stays in keeps its slot, a row that stays out\n * stays out, and a row whose membership flips sends the whole thing back\n * through the full pass, because an insertion has to land in data order.\n * Positions come from stamps on the rows (ROW_FILTER_GEN / ROW_FILTER_POS)\n * written by the full pass; no map is built.\n */\n let cache: { input: Array<Row<TData>>; output: Array<Row<TData>>; filters: ColumnFiltersState; columns: Array<Column<TData>>; gen: number } | null = null\n let generation = 0\n\n return ({ table, rows }) => {\n const filters: ColumnFiltersState = table.getState().columnFilters ?? []\n if (!filters.length) {\n cache = null\n return rows\n }\n const columns = table.getAllColumns()\n\n // Resolve each filter's match function once, outside the row loop.\n const compiled = filters.map((filter) => ({\n id: filter.id,\n value: filter.value,\n fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,\n }))\n const passes = (row: Row<TData>) => {\n for (let i = 0; i < compiled.length; i++) {\n const filter = compiled[i]!\n // `getCellValueByColumnId` rather than `getAllCells().find(...)`.\n // Both read the same lazily-built `cachedValues` array, but the latter\n // also builds and caches the row's whole `Cell[]` - one object per\n // column - purely to reach one field. On a 100k-row grid that is\n // 100,000 cell arrays the filter never looks at again, and it defeats\n // the laziness the row factory exists to provide.\n if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false\n }\n return true\n }\n\n if (cache && cache.filters === filters && cache.columns === columns) {\n if (cache.input === rows) return cache.output\n const replaced = replacedRowsOf(table)\n if (replaced && rows.length === cache.input.length) {\n let out: Array<Row<TData>> | null = null\n let ok = true\n for (let i = 0; i < rows.length && ok; i++) {\n const prev = cache.input[i]! as BaseRowState<TData>\n const next = rows[i]! as BaseRowState<TData>\n if (next === prev) continue\n if (replaced.get(prev) !== next) { ok = false; break }\n const wasIn = prev[ROW_FILTER_GEN] === cache.gen\n const isIn = passes(next)\n if (wasIn !== isIn) { ok = false; break }\n if (!isIn) continue\n out ??= cache.output.slice()\n const pos = prev[ROW_FILTER_POS]\n out[pos] = next\n next[ROW_FILTER_GEN] = cache.gen\n next[ROW_FILTER_POS] = pos\n }\n if (ok) {\n const output = out ?? cache.output\n cache = { ...cache, input: rows, output }\n return output\n }\n }\n }\n\n const gen = ++generation\n const output: Array<Row<TData>> = []\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i]! as BaseRowState<TData>\n if (!passes(row)) continue\n row[ROW_FILTER_GEN] = gen\n row[ROW_FILTER_POS] = output.length\n output.push(row)\n }\n cache = { input: rows, output, filters, columns, gen }\n return output\n }\n}\n```\n\n### `function createPaginatedRowModel`\n\nNarrows the rows to the current page. Put it LAST: anything after it would\nonly ever see one page of data.\n\n```ts\nexport function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }\n const start = pagination.pageIndex * pagination.pageSize\n return rows.slice(start, start + pagination.pageSize)\n }\n}\n```\n\n### `function createGroupedRowModel`\n\nBuckets rows by the active {@link GroupingState} and inserts a group row\nahead of each bucket, carrying that bucket's aggregates.\n\n```ts\nexport function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const grouping: GroupingState = table.getState().grouping ?? []\n if (!grouping.length) return rows\n const columns = table.getAllColumns()\n\n // Recursively bucket rows by each grouping column in turn. At every level a\n // group row is built that stands in for its children - a non-group column\n // resolves to the value shared by every leaf row, or to undefined when the\n // leaves disagree.\n function buildGroups(\n input: Array<Row<TData>>,\n levelIndex: number,\n depth: number,\n idPrefix: string,\n /** Grouping columns already fixed by an ancestor bucket, and their raw\n * values - `undefined` where that bucket mixed several. */\n fixedValues: ReadonlyMap<string, unknown>,\n ): Array<Row<TData>> {\n if (levelIndex >= grouping.length) {\n // Leaves: actual data rows, with their nesting depth recorded.\n return input.map((row) => ({ ...row, depth }))\n }\n const groupKey = grouping[levelIndex]\n if (!groupKey) return input\n\n // Buckets carry the RAW grouping value alongside the rows, plus whether\n // the bucket saw more than one distinct raw value. Both are needed to let\n // deeper levels skip re-scanning this column: buckets are keyed by\n // `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one\n // bucket, and a scan of such a bucket would report disagreement. Tracking\n // it here costs one comparison per row and keeps the shortcut honest.\n type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }\n const buckets = new Map<string, Bucket>()\n for (const row of input) {\n const value = row.getCellValueByColumnId(groupKey)\n const key = String(value ?? '')\n const bucket = buckets.get(key)\n if (bucket) {\n bucket.rows.push(row)\n if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true\n } else {\n buckets.set(key, { rows: [row], raw: value, mixed: false })\n }\n }\n\n const groupRows: Array<Row<TData>> = []\n let index = 0\n buckets.forEach((bucket, key) => {\n const children = bucket.rows\n const id = `${idPrefix}_${groupKey}_${key}`\n // Record this column as fixed for deeper levels ONLY when the bucket is\n // homogeneous. If all rows here share a raw value, so does every subset\n // of them, which is what makes the shortcut sound.\n //\n // A MIXED bucket must not be recorded at all - not even as \"undefined\".\n // `null`, `undefined` and `''` share a bucket key, so a mixed bucket can\n // still split into homogeneous children one level down, and those\n // children have a real shared value that a scan would find. Marking the\n // column resolved here would hand them the parent's disagreement.\n const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)\n const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)\n const isDeepest = levelIndex + 1 >= grouping.length\n const leafCount = isDeepest\n ? subRows.length\n : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)\n\n // Every grouping column ABOVE this level is already resolved: bucketing\n // by it is what made it constant, so scanning the children to rediscover\n // it is pure waste. Only the current level's key short-circuited before,\n // so a second-level group walked all of its children to re-derive the\n // first level's value - about 100,000 reads on the 100k x 9 two-level\n // case, for an answer already in hand.\n //\n // The current level still returns the stringified bucket key rather than\n // the raw value, because that is what it has always returned and the\n // group row's display depends on it.\n const resolveColumnValue = (columnId: string): unknown => {\n if (columnId === groupKey) return key\n if (fixedValues.has(columnId)) return fixedValues.get(columnId)\n let resolved: unknown\n let hasResolved = false\n for (const child of children) {\n const childValue = child.getCellValueByColumnId(columnId)\n if (!hasResolved) {\n resolved = childValue\n hasResolved = true\n } else if (childValue !== resolved) {\n return undefined\n }\n }\n return resolved\n }\n\n const groupOriginal: Record<string, unknown> = {}\n columns.forEach((column) => {\n const field = column.columnDef.field\n if (!field) return\n const agg = column.columnDef.aggregate\n groupOriginal[field] = agg\n ? applyGroupAggregate(agg, column.id, children)\n : resolveColumnValue(column.id)\n })\n\n const groupRow: Row<TData> = {\n id,\n index: index++,\n original: groupOriginal as TData,\n depth,\n subRows,\n leafCount,\n getCanExpand: () => true,\n getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),\n toggleExpanded: () => {\n table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),\n toggleSelected: () => {\n table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n getAllCells: () => [],\n // Prefer the precomputed group value (which carries aggregates)\n // and fall back to the shared-value resolver for columns without\n // a field.\n getCellValueByColumnId: (columnId: string) => {\n const col = columns.find((c) => c.id === columnId)\n const field = col?.columnDef.field\n if (field && field in groupOriginal) return groupOriginal[field]\n return resolveColumnValue(columnId)\n },\n }\n groupRows.push(groupRow)\n })\n return groupRows\n }\n\n return buildGroups(rows, 0, 0, 'group', new Map())\n }\n}\n```\n\n### `type TreeRowModelOptions`\n\nHow to read a hierarchy out of FLAT rows: each row names its parent, and the\ngrid reconstructs the tree. Rows whose parent id matches nothing become roots\nrather than disappearing.\n\nFor nested source data (`children: [...]`), flatten it first with\n{@link flattenTreeData}.\n\n```ts\nexport type TreeRowModelOptions = {\n /** Field holding each row's parent id. Rows with no parent are roots. */\n parentField: string\n /** Field holding the row's own id. Defaults to `'id'`. */\n idField?: string\n}\n```\n\n### `function createTreeRowModel`\n\nClient-side tree data: nest the grid's own flat rows into a parent/child\nhierarchy that `createExpandedRowModel` then walks.\n\nThis works on the rows the grid already built rather than on raw data, so\ntree rows keep their cells, editing, selection and formatting - they are real\ndata rows that happen to have children, not synthetic banners like grouping's.\nThat is also why the model is parent-id based: nested source arrays never\nbecome rows (the grid only builds rows for `data`), so nested input is\nflattened first with {@link flattenTreeData}. One code path, no duplicated\nrow construction.\n\nRows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable\ndata row for a full-width group banner.\n\n```ts\nexport function createTreeRowModel<TData extends RowData>(\n options: TreeRowModelOptions,\n): RowModelFactory<TData> {\n const { parentField, idField = 'id' } = options\n return ({ table, rows }) => {\n if (!rows.length) return rows\n const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]\n const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]\n\n const present = new Set<unknown>()\n for (const row of rows) present.add(keyOf(row))\n\n const childrenByParent = new Map<unknown, Array<Row<TData>>>()\n const roots: Array<Row<TData>> = []\n for (const row of rows) {\n const parent = parentOf(row)\n // A row whose parent is absent (filtered out, or never existed) becomes a\n // root rather than disappearing - silently dropping rows is worse than a\n // shallower tree. Self-parenting is treated the same way.\n if (parent == null || parent === keyOf(row) || !present.has(parent)) {\n roots.push(row)\n continue\n }\n const list = childrenByParent.get(parent) ?? []\n list.push(row)\n childrenByParent.set(parent, list)\n }\n\n // Guards a cycle in the parent chain from recursing forever.\n const seen = new Set<unknown>()\n const build = (row: Row<TData>, depth: number): Row<TData> => {\n const key = keyOf(row)\n const id = row.id\n if (seen.has(key)) {\n return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>\n }\n seen.add(key)\n const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))\n return {\n ...row,\n depth,\n subRows,\n leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),\n __treeRow: true,\n getCanExpand: () => subRows.length > 0,\n getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),\n toggleExpanded: () => {\n table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))\n },\n } as Row<TData>\n }\n\n return roots.map((root) => build(root, 0))\n }\n}\n```\n\n### `type FlattenTreeOptions`\n\nHow to flatten NESTED source data into the parent-id shape tree rows need.\n`parentField` is written onto each row, so point `treeData.parentField` at\nthe same name afterwards.\n\n```ts\nexport type FlattenTreeOptions = {\n /** Field holding an array of child objects. */\n childrenField: string\n /** Field holding each object's id. Defaults to `'id'`. */\n idField?: string\n /** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */\n parentField?: string\n}\n```\n\n### `function flattenTreeData`\n\nFlatten nested tree data into the flat parent-id shape `createTreeRowModel`\nconsumes, stamping each child with its parent's id.\n\nChildren are emitted directly after their parent so the natural order already\nmatches the rendered tree. The `childrenField` array is left on the objects\n(harmless, and callers often still want it); only the parent link is added.\n\n```ts\nexport function flattenTreeData<T extends RowData>(\n data: ReadonlyArray<T>,\n options: FlattenTreeOptions,\n): T[] {\n const { childrenField, idField = 'id', parentField = '__parentId' } = options\n const out: T[] = []\n const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {\n for (const node of nodes) {\n const flat = { ...node, [parentField]: parentId } as T\n out.push(flat)\n const kids = (node as any)[childrenField]\n if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])\n }\n }\n walk(data, null)\n return out\n}\n```\n\n### `function createExpandedRowModel`\n\nHides the descendants of collapsed rows. Needed for grouping, tree data and\nmaster-detail alike - all three are the same expand/collapse mechanism.\n\n```ts\nexport function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {\n return ({ table, rows }) => {\n const expanded: ExpandedState = table.getState().expanded ?? {}\n // Nothing expanded means every collapsed row hides its children and the\n // output is the input, element for element. Returning it as is keeps the\n // array identity the stages downstream and the render window key on, and\n // skips a 100k-element copy per data change on a flat grid.\n let anyExpanded = false\n for (const id in expanded) {\n if (expanded[id]) { anyExpanded = true; break }\n }\n if (!anyExpanded) return rows\n const flattened: Array<Row<TData>> = []\n const visit = (row: Row<TData>) => {\n flattened.push(row)\n if (row.subRows?.length && expanded[row.id]) {\n for (const sub of row.subRows) visit(sub)\n }\n }\n for (const row of rows) visit(row)\n return flattened\n }\n}\n```\n\n### `function replacedRowsOf`\n\nThe rows `getRowModel` replaced in the rebuild this pipeline run follows, or null.\n\n```ts\nexport function replacedRowsOf(table: SvGrid<any>): ReadonlyMap<Row<any>, Row<any>> | null {\n return replacedRowsByGrid.get(table) ?? null\n}\n```\n\n### `function createSortedRowModel`\n\nThe sorting stage of the row pipeline: orders the rows by the grid's\n`sorting` state with the built-in comparators (or the `localSortFns` map\nhanded in), stable, equal keys keeping their data order. A tick that\nreplaces a few rows out of many is repaired incrementally rather than\nre-sorted (see the note inside). Registered by `rowSortingFeature`; an app\nonly calls this to install its own comparator set.\n\n```ts\nexport function createSortedRowModel<TData extends RowData>(\n localSortFns: typeof sortFns = sortFns,\n): RowModelFactory<TData> {\n /**\n * What the last full sort produced, kept so a tick can repair it.\n *\n * A live feed replaces a few hundred row objects out of a hundred thousand\n * and hands the grid a new array. Re-sorting all of it costs the same as\n * the first sort did (115-149 ms in the browser for 100k rows, see\n * docs/help/benchmarks.md); the order it would produce differs from the\n * previous one only where the replaced rows land. So the previous output\n * is kept, with its sort keys aligned to it, and when `getRowModel` says\n * which rows were replaced (see `replacedRowsOf`) the stage drops those,\n * sorts the replacements among themselves and merges them back in one\n * linear pass: O(n + k log k) instead of O(n log n) key builds and\n * comparisons.\n *\n * The repair is only attempted when everything else is the same object as\n * last time - the sorting state, the columns, and every input row except\n * the replaced ones - and it produces exactly what the full sort would:\n * equal keys fall back to the data index, which is the order a stable sort\n * over data-ordered input keeps.\n */\n let cache: {\n input: Array<Row<TData>>\n output: Array<Row<TData>>\n sorting: SortingState\n columns: Array<Column<TData>>\n clauses: Array<SortClause>\n } | null = null\n\n type SortClause = {\n desc: boolean\n compare: (a: any, b: any) => number\n /** The key for one row, or `MISSING_KEY` when it cannot be produced incrementally. */\n keyOf: (row: Row<TData>) => any\n }\n const MISSING_KEY = Symbol('svgrid.sort.missing-key')\n\n function fullSort(rows: Array<Row<TData>>, sorting: SortingState, allColumns: Array<Column<TData>>) {\n // Resolve every clause ONCE, before sorting.\n //\n // This used to live inside the comparator, so `getAllColumns().find(...)`\n // ran per comparison per clause: a single-clause sort of 100k rows made\n // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;\n // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)\n // times, so anything inside it that is not O(1) sets the cost of the sort.\n const clauses: Array<{\n keys: Array<any>\n desc: boolean\n compare: (a: any, b: any) => number\n keyOf: (row: Row<TData>) => any\n /** Set when every key is an integer rank in [0, rankCount): the text rank path. */\n rankCount?: number\n }> = []\n\n for (const clause of sorting) {\n const column = allColumns.find((col) => col.id === clause.id)\n if (!column) continue\n const editorType = column.columnDef.editorType\n const comparator =\n editorType === 'number'\n ? localSortFns.number\n : editorType === 'date' || editorType === 'datetime'\n ? localSortFns.date\n : localSortFns.auto\n\n // Precompute one sort key per row, so the comparator reads an array slot\n // instead of walking the row's column index on every comparison. For the\n // three built-in comparators the key is also cheaper to compare than the\n // raw value: a timestamp rather than two `new Date()` allocations, a\n // number rather than two `Number()` coercions, a collator rather than a\n // fresh one per `localeCompare` call.\n //\n // The identity checks against `sortFns` matter: `localSortFns` is a\n // public parameter, so a caller can substitute their own comparators.\n // When they have, we fall through to calling their function with the raw\n // values - still hoisted, just not specialised.\n const columnId = column.id\n const n = rows.length\n let keys: Array<any> = new Array(n)\n let compare: (a: any, b: any) => number\n let keyOf: (row: Row<TData>) => any\n let autoMode: 'number' | 'mixed' | 'text' | undefined\n let rankCount: number | undefined\n\n if (comparator === sortFns.number) {\n keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId))\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareNumericKeys\n } else if (comparator === sortFns.date) {\n keyOf = (row) => dateSortKey(row.getCellValueByColumnId(columnId))\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareNumericKeys\n } else if (comparator === sortFns.auto && (autoMode = autoSortMode(rows, columnId)) === 'number') {\n // A column with no declared type whose values are all numbers. Sorting\n // it as text put 10 before 2 (#104).\n keyOf = (row) => {\n const value = row.getCellValueByColumnId(columnId)\n // A text value arriving in a tick changes the mode; only a full sort\n // can place it.\n return typeof value === 'number' || isBlankSortValue(value)\n ? numberSortKey(value)\n : MISSING_KEY\n }\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareNumericKeys\n } else if (autoMode === 'mixed') {\n // Numbers and text in one column. Rare, so no key tricks: the raw\n // values through the pairwise comparator, which orders by kind first.\n keyOf = (row) => row.getCellValueByColumnId(columnId)\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = compareAutoValues\n } else if (comparator === sortFns.auto) {\n const strings: string[] = new Array(n)\n // Decide whether ranking is worth attempting BEFORE paying for it.\n //\n // Building the distinct set and then discarding it costs about 9 ms on\n // a 100k-row column where nearly every value is unique, and ranking\n // saves about 19 ms where they repeat - so guessing wrong in either\n // direction is measurable. A small stride sample answers it for well\n // under a millisecond.\n //\n // Strided rather than the first N rows: data arrives sorted or\n // clustered often enough that a prefix is a bad estimator of the whole\n // column. Reading every k-th row is no more expensive and does not care\n // how the rows are arranged.\n const rankLimit = n >> 1\n // Each distinct value's id (its insertion order), when ranking.\n let idOf: Map<string, number> | null = null\n if (n > 0) {\n // Ranking pays when the column has at most n/2 distinct values. A\n // small sample cannot see that by asking whether half of it repeats:\n // 1M rows of 80k values, each one 12 times over, gave 256 samples\n // with almost no repeats, so ranking was skipped and the sort made\n // 20M collator calls (1.75 s; ranked, it is a fraction of that).\n // Count the repeats in a larger sample instead. S samples from u\n // equally common values repeat about S^2 / 2u times, so u <= n/2\n // shows as at least S^2 / n repeats. Up to SAMPLE rows the whole\n // column is the sample and the test is exact.\n const SAMPLE = 2048\n const sampleTarget = Math.min(n, SAMPLE)\n const stride = Math.max(1, Math.floor(n / sampleTarget))\n const sample = new Set<string>()\n let sampled = 0\n for (let i = 0; i < n; i += stride) {\n sample.add(autoTextKey(rows[i]!.getCellValueByColumnId(columnId)))\n sampled++\n }\n const repeats = sampled - sample.size\n const worthRanking = stride === 1 ? sample.size <= rankLimit : repeats * n >= sampled * sampled\n if (worthRanking) idOf = new Map()\n }\n\n // One hash per row: the value's id is looked up (or assigned) as the\n // row is read, and the rank lookup further down is an array read by\n // id. It used to add every value to a Set and then look every value up\n // again in a rank Map, two hashes per row.\n const ids: Int32Array | null = idOf ? new Int32Array(n) : null\n for (let i = 0; i < n; i++) {\n const s = autoTextKey(rows[i]!.getCellValueByColumnId(columnId))\n strings[i] = s\n if (idOf) {\n let id = idOf.get(s)\n if (id === undefined) {\n id = idOf.size\n idOf.set(s, id)\n if (idOf.size > rankLimit) {\n idOf = null\n continue\n }\n }\n ids![i] = id\n }\n }\n\n // Collation is by far the most expensive comparison we do - a CPU\n // profile of a 100k text sort put 65% of the whole operation inside the\n // collator. But a column's DISTINCT values are usually far fewer than\n // its rows (statuses, regions, categories, owners), so rank the\n // distinct values once and sort by rank afterwards. That turns\n // O(n log n) collator calls into O(u log u), where u is the number of\n // distinct values, and the resulting order is identical because rank is\n // a monotone relabelling of the collated order - equal strings share a\n // rank, so ties still fall through to the stable sort exactly as before.\n //\n // Guarded on the uniqueness ratio: when nearly every value is distinct\n // the ranking pass cannot save any collator calls and would just add an\n // O(n) Map build, so that case keeps comparing directly.\n if (idOf) {\n // Map keys come back in insertion order, which is id order.\n const values = Array.from(idOf.keys())\n const byRank = values.map((_, id) => id).sort((a, b) => compareCollatedKeys(values[a]!, values[b]!))\n const rankById = new Int32Array(values.length)\n for (let r = 0; r < byRank.length; r++) rankById[byRank[r]!] = r\n for (let i = 0; i < n; i++) keys[i] = rankById[ids![i]!]!\n const rankIds = idOf\n rankCount = values.length\n compare = compareNumericKeys\n // A replacement whose text is not among the ranked values has no\n // rank; the repair path treats that as \"sort everything again\".\n keyOf = (row) => {\n const value = row.getCellValueByColumnId(columnId)\n // A number arriving in a tick makes the column mixed; resort fully.\n if (typeof value === 'number') return MISSING_KEY\n const id = rankIds.get(autoTextKey(value))\n return id === undefined ? MISSING_KEY : rankById[id]!\n }\n } else {\n keys = strings\n compare = compareCollatedKeys\n keyOf = (row) => {\n const value = row.getCellValueByColumnId(columnId)\n return typeof value === 'number' ? MISSING_KEY : autoTextKey(value)\n }\n }\n } else {\n keyOf = (row) => row.getCellValueByColumnId(columnId)\n for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)\n compare = comparator\n }\n\n clauses.push({ keys, desc: clause.desc, compare, keyOf, rankCount })\n }\n\n if (!clauses.length) return null\n\n // Sort an index array, then materialise. `Array.prototype.sort` is stable,\n // so equal keys keep their original relative order exactly as the previous\n // `[...rows].sort(...)` did.\n // An index array the comparator sorts in place, or a typed array from\n // countingOrder / radixOrder for a large one-column numeric sort.\n let order: ArrayLike<number> = new Array<number>(rows.length)\n for (let i = 0; i < order.length; i++) (order as number[])[i] = i\n\n // Single-clause sorts get a specialised comparator.\n //\n // Most sorts are one column, and that path runs O(n log n) times - 1.66\n // million comparisons for 100k rows. The general loop pays a clause-array\n // index, three property loads and an indirect call on every one of them,\n // none of which vary once the clause list is fixed. Hoisting them into a\n // closure and, for the numeric comparator, inlining the subtraction removes\n // the call entirely.\n //\n // `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`\n // as far as sorting is concerned: both are NaN for unorderable values,\n // which the spec coerces to 0, and they differ only in producing 0 versus\n // -0 for equal keys, which sorts identically.\n if (clauses.length === 1) {\n const { keys, compare, desc, rankCount } = clauses[0]!\n if (rankCount !== undefined && rows.length >= STABLE_NUMERIC_SORT_MIN) {\n order = countingOrder(keys as number[], rankCount, desc)\n } else if (compare === compareNumericKeys && rows.length >= STABLE_NUMERIC_SORT_MIN) {\n order = radixOrder(keys as number[], desc)\n } else if (compare === compareNumericKeys) {\n ;(order as number[]).sort(\n desc\n ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }\n : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },\n )\n } else {\n ;(order as number[]).sort(\n desc\n ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }\n : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },\n )\n }\n } else {\n ;(order as number[]).sort(function compareRowsByClauses(ia, ib) {\n for (let k = 0; k < clauses.length; k++) {\n const clause = clauses[k]!\n const result = clause.compare(clause.keys[ia], clause.keys[ib])\n if (result !== 0) return clause.desc ? -result : result\n }\n return 0\n })\n }\n\n const sorted = new Array<Row<TData>>(rows.length)\n for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!\n // Each row keeps its key(s), so a later tick can place a replacement\n // among the kept rows without reading any of them again. One property\n // write per row; an array per row only when there are several clauses.\n if (clauses.length === 1) {\n const keys = clauses[0]!.keys\n for (let i = 0; i < rows.length; i++) (rows[i] as any)[ROW_SORT_KEY] = keys[i]\n } else {\n for (let i = 0; i < rows.length; i++) {\n const per = new Array(clauses.length)\n for (let k = 0; k < clauses.length; k++) per[k] = clauses[k]!.keys[i]\n ;(rows[i] as any)[ROW_SORT_KEY] = per\n }\n }\n return { sorted, clauses: clauses.map(({ desc, compare, keyOf }) => ({ desc, compare, keyOf })) }\n }\n\n /**\n * The previous output with the replaced rows swapped for their\n * replacements, in sorted position. Null when the input is not the cached\n * input plus replacements, or a replacement has no incremental key.\n *\n * Every row carries the key(s) the last full sort computed for it\n * (ROW_SORT_KEY: the key itself for one clause, an array for several), so\n * the merge moves row references and nothing else. At 100k rows the loop\n * that also copied an aligned key array cost 3.7 ms; this one is the\n * reference copy alone.\n */\n function repair(rows: Array<Row<TData>>, replaced: ReadonlyMap<Row<any>, Row<any>>) {\n const c = cache!\n if (rows.length !== c.input.length) return null\n // Which rows of THIS stage's input changed. The map may name rows a\n // stage upstream filtered out; only the ones that reach here matter.\n const dropGen = ++sortDropGeneration\n const added: Array<Row<TData>> = []\n for (let i = 0; i < rows.length; i++) {\n const prev = c.input[i]!\n const next = rows[i]!\n if (next === prev) continue\n if (replaced.get(prev) !== next) return null\n ;(prev as any)[ROW_SORT_DROPPED] = dropGen\n added.push(next)\n }\n if (!added.length) return c.output\n\n const clauses = c.clauses\n const single = clauses.length === 1\n for (let j = 0; j < added.length; j++) {\n const row = added[j]! as any\n if (single) {\n const key = clauses[0]!.keyOf(row)\n if (key === MISSING_KEY) return null\n row[ROW_SORT_KEY] = key\n } else {\n const keys = new Array(clauses.length)\n for (let k = 0; k < clauses.length; k++) {\n const key = clauses[k]!.keyOf(row)\n if (key === MISSING_KEY) return null\n keys[k] = key\n }\n row[ROW_SORT_KEY] = keys\n }\n }\n\n // Keys first, then the data index: the tie-break a stable sort over\n // data-ordered input applies, so the merge lands where a full sort would.\n const prevOut = c.output\n const compareRows = single\n ? (() => {\n const { compare, desc } = clauses[0]!\n return (a: Row<TData>, b: Row<TData>) => {\n const r = compare((a as any)[ROW_SORT_KEY], (b as any)[ROW_SORT_KEY])\n if (r !== 0) return desc ? -r : r\n return a.index - b.index\n }\n })()\n : (a: Row<TData>, b: Row<TData>) => {\n const ka = (a as any)[ROW_SORT_KEY]\n const kb = (b as any)[ROW_SORT_KEY]\n for (let k = 0; k < clauses.length; k++) {\n const clause = clauses[k]!\n const r = clause.compare(ka[k], kb[k])\n if (r !== 0) return clause.desc ? -r : r\n }\n return a.index - b.index\n }\n\n // Sort the replacements among themselves (k log k)...\n added.sort(compareRows)\n\n // ...find where each one goes in the previous output by binary search\n // (k log n comparisons, against the dropped rows too - they are skipped\n // below, which does not move an insertion point)...\n const n = prevOut.length\n const insertAt = new Array<number>(added.length)\n let lo = 0\n for (let j = 0; j < added.length; j++) {\n // Monotone: the replacements are sorted, so each search starts where\n // the previous one landed.\n const row = added[j]!\n let hi = n\n while (lo < hi) {\n const mid = (lo + hi) >>> 1\n if (compareRows(row, prevOut[mid]!) < 0) hi = mid\n else lo = mid + 1\n }\n insertAt[j] = lo\n }\n\n // ...then one linear pass that only moves references.\n const out = new Array<Row<TData>>(rows.length)\n let w = 0\n let j = 0\n let nextInsert = insertAt[0] ?? -1\n for (let i = 0; i < n; i++) {\n while (nextInsert === i) {\n out[w++] = added[j++]!\n nextInsert = j < added.length ? insertAt[j]! : -1\n }\n const prev = prevOut[i]!\n if ((prev as any)[ROW_SORT_DROPPED] === dropGen) continue\n out[w++] = prev\n }\n while (j < added.length) out[w++] = added[j++]!\n return out\n }\n\n return function sortedRowModelStage({ table, rows }) {\n const sorting = table.getState().sorting ?? []\n if (!sorting.length) {\n cache = null\n return rows\n }\n const columns = table.getAllColumns()\n\n if (cache && cache.sorting === sorting && cache.columns === columns) {\n if (cache.input === rows) return cache.output\n const replaced = replacedRowsOf(table)\n if (replaced) {\n const repaired = repair(rows, replaced)\n if (repaired) {\n cache = { ...cache, input: rows, output: repaired }\n return repaired\n }\n }\n }\n\n const full = fullSort(rows, sorting, columns)\n if (!full) {\n cache = null\n return rows\n }\n cache = { input: rows, output: full.sorted, sorting, columns, clauses: full.clauses }\n return full.sorted\n }\n}\n```\n\n### `type SvGridOptions`\n\nEverything {@link createSvGridCore} accepts: the data and columns, the\nfeatures and row models that make up the pipeline, and an `on*Change`\ncallback per piece of state for controlled use.\n\n`<SvGrid>` builds this for you from its props - you only construct it\ndirectly when driving the headless core.\n\n```ts\nexport type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {\n _features: TFeatures\n _rowModels?: {\n coreRowModel?: RowModelFactory<TData>\n filteredRowModel?: RowModelFactory<TData>\n sortedRowModel?: RowModelFactory<TData>\n paginatedRowModel?: RowModelFactory<TData>\n groupedRowModel?: RowModelFactory<TData>\n expandedRowModel?: RowModelFactory<TData>\n }\n columns: Array<ColumnDef<TFeatures, TData>>\n data: ReadonlyArray<TData>\n /**\n * Optional row-id resolver. When set, the value it returns becomes\n * `row.id` (and therefore the selection / expansion / edit key). When\n * omitted, ids fall back to the row's array index as a string. Use a\n * stable id (database PK, UUID, etc.) so selection survives reorders.\n */\n getRowId?: (row: TData, index: number) => string\n state?: Partial<Record<string, any>>\n onSortingChange?: (updater: Updater<SortingState>) => void\n onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void\n onPaginationChange?: (updater: Updater<PaginationState>) => void\n onGroupingChange?: (updater: Updater<GroupingState>) => void\n onExpandedChange?: (updater: Updater<ExpandedState>) => void\n onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void\n onActiveCellChange?: (updater: Updater<ActiveCellState>) => void\n}\n```\n\n### `type SvGrid`\n\nThe headless grid instance: the state stores plus the read methods a renderer\nneeds (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).\n\nFramework free by design - `<SvGrid>` is one renderer over this, and you can\nwrite another. See the \"Why headless?\" guide.\n\n```ts\nexport type SvGrid<TData extends RowData> = {\n store: Store<Record<string, any>>\n optionsStore: Store<Record<string, any>>\n state: Record<string, any>\n getState: () => Record<string, any>\n setOptions: (updater: Updater<Record<string, any>>) => void\n setColumnFilters: (updater: Updater<ColumnFiltersState>) => void\n setPagination: (updater: Updater<PaginationState>) => void\n setGrouping: (updater: Updater<GroupingState>) => void\n setExpanded: (updater: Updater<ExpandedState>) => void\n setRowSelection: (updater: Updater<RowSelectionState>) => void\n setActiveCell: (updater: Updater<ActiveCellState>) => void\n moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void\n getAllColumns: () => Array<Column<TData>>\n getHeaderGroups: () => Array<HeaderGroup<TData>>\n getFooterGroups: () => Array<HeaderGroup<TData>>\n getRowModel: () => RowModel<TData>\n}\n```\n\n### `function createSvGridCore`\n\nBuild a headless grid: state, the row pipeline, and the read methods, with no\nDOM and no Svelte. This is the engine `<SvGrid>` renders.\n\nMost callers want `createSvGrid` (the runes-aware wrapper) or the component\nitself; reach for this when you are writing your own renderer or running the\npipeline outside a browser.\n\n```ts\nexport function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(\n options: SvGridOptions<TFeatures, TData>,\n): SvGrid<TData> {\n const internalState: Record<string, any> = {\n sorting: [],\n columnFilters: [],\n pagination: { pageIndex: 0, pageSize: options.data.length || 10 },\n grouping: [],\n expanded: {},\n rowSelection: {},\n activeCell: { rowIndex: 0, colIndex: 0, cellId: null },\n ...(options.state ?? {}),\n }\n const store = createStore(internalState)\n const optionsStore = createStore(options as Record<string, any>)\n let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null\n let cachedColumns: Array<Column<TData>> = []\n let cachedHeaderGroups: Array<HeaderGroup<TData>> = []\n let cachedBaseRowsInput: ReadonlyArray<TData> | null = null\n let cachedBaseRowsColumns: Array<Column<TData>> | null = null\n let cachedBaseRows: Array<Row<TData>> = []\n /** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */\n let replacedRows: Map<Row<TData>, Row<TData>> | null = null\n let cachedRowCtx: BaseRowCtx<TData> | null = null\n /** This grid's windowed-data row model, made on first use from the installed hook. */\n let windowedRowModel: WindowedRowModel<TData> | null = null\n\n /** The shared row context for `columns`, reused while they are the same. */\n function rowCtxFor(columns: Array<Column<TData>>): BaseRowCtx<TData> {\n if (cachedRowCtx && cachedRowCtx.columns === columns) return cachedRowCtx\n const columnIndexById = new Map<string, number>()\n for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)\n cachedRowCtx = {\n grid: grid as SvGrid<TData>,\n store,\n columns,\n columnCount: columns.length,\n columnIndexById,\n }\n return cachedRowCtx\n }\n\n let cachedRowModel: RowModel<TData> | null = null\n let cachedRowModelBaseRows: Array<Row<TData>> | null = null\n let cachedPipeline = options._rowModels\n let cachedSlices: {\n sorting: SortingState | undefined\n columnFilters: ColumnFiltersState | undefined\n pagination: PaginationState | undefined\n grouping: GroupingState | undefined\n expanded: ExpandedState | undefined\n } | null = null\n\n const grid = {\n store,\n optionsStore,\n get state() {\n return store.state\n },\n getState() {\n return store.state\n },\n setOptions(updater: Updater<Record<string, any>>) {\n optionsStore.setState((prev) =>\n typeof updater === 'function' ? (updater as any)(prev) : updater,\n )\n },\n setColumnFilters(updater: Updater<ColumnFiltersState>) {\n store.setState((prev) => ({\n ...prev,\n columnFilters:\n typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,\n }))\n options.onColumnFiltersChange?.(updater)\n },\n setPagination(updater: Updater<PaginationState>) {\n store.setState((prev) => ({\n ...prev,\n pagination:\n typeof updater === 'function'\n ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })\n : updater,\n }))\n options.onPaginationChange?.(updater)\n },\n setGrouping(updater: Updater<GroupingState>) {\n store.setState((prev) => ({\n ...prev,\n grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,\n }))\n options.onGroupingChange?.(updater)\n },\n setExpanded(updater: Updater<ExpandedState>) {\n store.setState((prev) => ({\n ...prev,\n expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,\n }))\n options.onExpandedChange?.(updater)\n },\n setRowSelection(updater: Updater<RowSelectionState>) {\n store.setState((prev) => ({\n ...prev,\n rowSelection:\n typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,\n }))\n options.onRowSelectionChange?.(updater)\n },\n setActiveCell(updater: Updater<ActiveCellState>) {\n store.setState((prev) => {\n const previous: ActiveCellState = prev.activeCell ?? {\n rowIndex: 0,\n colIndex: 0,\n cellId: null,\n }\n const nextActive =\n typeof updater === 'function' ? updater(previous) : updater\n return {\n ...prev,\n activeCell: nextActive,\n }\n })\n options.onActiveCellChange?.(updater)\n },\n moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {\n const rows = grid.getRowModel().rows\n const columns = grid.getAllColumns()\n const maxRow = Math.max(rows.length - 1, 0)\n const maxCol = Math.max(columns.length - 1, 0)\n const current: ActiveCellState = grid.getState().activeCell ?? {\n rowIndex: 0,\n colIndex: 0,\n cellId: null,\n }\n\n const rowIndex = Math.min(\n Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),\n maxRow,\n )\n const colIndex = Math.min(\n Math.max(current.colIndex + (next.colDelta ?? 0), 0),\n maxCol,\n )\n const columnId = columns[colIndex]?.id ?? 'col_0'\n grid.setActiveCell({\n rowIndex,\n colIndex,\n cellId: `${rowIndex}_${columnId}`,\n })\n },\n getAllColumns() {\n // Cache hit: referentially identical columns array.\n if (cachedColumnsInput === options.columns && cachedColumns.length) {\n return cachedColumns\n }\n // Soft cache hit: consumers commonly recreate the columns array\n // inline on every render (e.g. `columns={[...]}`). If the new\n // array has the same length AND each entry has the same `field` /\n // `id` / `header` (the visibility-affecting structure of a\n // column), trust the previous build. Mutable inner fields like\n // `cell` and `editorOptions` are still picked up on the next real\n // render that bumps an actual data dep - they're read at cell-\n // render time, not at this top-level cache.\n if (\n cachedColumnsInput &&\n options.columns.length === cachedColumnsInput.length &&\n cachedColumns.length === options.columns.length &&\n options.columns.every((c, i) => {\n const prev = cachedColumnsInput![i]!\n return (\n c.field === prev.field &&\n c.id === prev.id &&\n c.header === prev.header &&\n c.editorType === prev.editorType\n )\n })\n ) {\n // Update the stored input reference so the strict check hits\n // next time, but reuse the built column model.\n cachedColumnsInput = options.columns\n return cachedColumns\n }\n\n cachedColumnsInput = options.columns\n cachedHeaderGroups = []\n const build = (\n defs: Array<ColumnDef<TFeatures, TData>>,\n depth: number,\n parentId?: string,\n ): Array<Column<TData>> => {\n const leaves: Array<Column<TData>> = []\n defs.forEach((columnDef, index) => {\n const id = resolveColumnId(columnDef, parentId, depth, index)\n if (columnDef.columns?.length) {\n leaves.push(...build(columnDef.columns, depth + 1, id))\n return\n }\n leaves.push({\n id,\n depth,\n parentId,\n columnDef,\n getCanSort: () =>\n Boolean((options._features as any).rowSortingFeature) &&\n columnDef.sortable !== false,\n getCanFilter: () =>\n Boolean((options._features as any).columnFilteringFeature) &&\n columnDef.filterable !== false,\n getIsSorted: () => {\n const entry = store.state.sorting?.find((s: any) => s.id === id)\n if (!entry) return false\n return entry.desc ? 'desc' : 'asc'\n },\n getToggleSortingHandler: () => () => {\n const clauses: SortingState = store.state.sorting ?? []\n const current = clauses.find((s: any) => s.id === id)\n const nextClause: SortingState = !current\n ? [...clauses, { id, desc: false }]\n : current.desc\n ? clauses.filter((s) => s.id !== id)\n : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))\n store.setState((prev) => ({ ...prev, sorting: nextClause }))\n options.onSortingChange?.(nextClause)\n },\n })\n })\n return leaves\n }\n cachedColumns = build(options.columns, 0)\n return cachedColumns\n },\n getHeaderGroups() {\n if (cachedHeaderGroups.length) return cachedHeaderGroups\n const headers = grid.getAllColumns().map((column) => {\n const header: Header<TData> = {\n id: column.id,\n isPlaceholder: false,\n colSpan: 1,\n column,\n getContext: () => ({ header, column, table: grid }),\n }\n return header\n })\n cachedHeaderGroups = [{ id: 'header_group_0', headers }]\n return cachedHeaderGroups\n },\n getFooterGroups() {\n return grid.getHeaderGroups()\n },\n getRowModel() {\n const columns = grid.getAllColumns()\n // Read once. `options.data` is a getter when <SvGrid> drives the engine\n // (a $state.raw signal), and the rebuild below touched it twice per row:\n // 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.\n const data = options.data\n if (installWindowedRowModel) {\n windowedRowModel ??= installWindowedRowModel<TData>({ rowCtxFor, getRowId: () => options.getRowId })\n const model = windowedRowModel(data, columns)\n if (model) return model\n }\n if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {\n // Same columns as last time: the shared context still describes them,\n // and a row whose data object sits at the same index can keep its\n // row object. A row model that streams blocks hands the grid a new\n // array of 60k entries per block where 100 changed; building 60k\n // fresh row objects each time was most of the cost of a block\n // landing. The reused row drops its memoised values and cells, since\n // an app may have changed the object in place before passing a new\n // array - that is the case the old rebuild covered by accident.\n // A lazy row list (lazyRows below) is read through its backing array,\n // so reusing rows never makes the ones nobody looked at.\n const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? (lazyRowsBacking(cachedBaseRows) ?? cachedBaseRows) : null\n const previousInput = cachedBaseRowsInput\n // Same length as last time: a replacement, not an add or remove, so\n // each new row stands in for the old row at its index and the stages\n // can repair rather than recompute. Anything else is structural.\n replacedRows = previous && previous.length === data.length ? new Map() : null\n cachedBaseRowsInput = data\n cachedBaseRowsColumns = columns\n // O(1) column-id → index lookup so getCellValueByColumnId doesn't do\n // a linear `findIndex` on every cell read (was O(rows × cells × cols)).\n let rowCtx: BaseRowCtx<TData>\n if (previous && cachedRowCtx) {\n rowCtx = cachedRowCtx\n } else {\n const columnIndexById = new Map<string, number>()\n for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)\n // One shared context for every row in this table, so a row carries a\n // pointer rather than a closure scope. See BASE_ROW_METHODS.\n rowCtx = {\n grid: grid as SvGrid<TData>,\n store,\n columns,\n columnCount: columns.length,\n columnIndexById,\n }\n cachedRowCtx = rowCtx\n }\n\n const getRowId = options.getRowId\n const m = BASE_ROW_METHODS as unknown as {\n getCanExpand: Row<TData>['getCanExpand']\n getIsExpanded: Row<TData>['getIsExpanded']\n toggleExpanded: Row<TData>['toggleExpanded']\n getIsSelected: Row<TData>['getIsSelected']\n toggleSelected: Row<TData>['toggleSelected']\n getAllCells: Row<TData>['getAllCells']\n getCellValueByColumnId: Row<TData>['getCellValueByColumnId']\n }\n // `_values` and `_cells` stay null until something reads them - a\n // 100k-row grid showing twenty rows must not materialise every row's\n // values or cell objects to paint.\n const makeRow = (index: number): BaseRowState<TData> => {\n const original = data[index]!\n return {\n id: getRowId ? getRowId(original, index) : String(index),\n index,\n original,\n depth: 0,\n [ROW_CTX]: rowCtx,\n [ROW_VALUES]: null,\n [ROW_CELLS]: null,\n [ROW_FILTER_GEN]: 0,\n [ROW_FILTER_POS]: -1,\n [ROW_SORT_DROPPED]: 0,\n [ROW_SORT_KEY]: undefined,\n getCanExpand: m.getCanExpand,\n getIsExpanded: m.getIsExpanded,\n toggleExpanded: m.toggleExpanded,\n getIsSelected: m.getIsSelected,\n toggleSelected: m.toggleSelected,\n getAllCells: m.getAllCells,\n getCellValueByColumnId: m.getCellValueByColumnId,\n }\n }\n if (!previous) {\n // A first build has nothing to reuse or repair: make each row the\n // first time something reads it. A grid that only shows the first\n // screen makes ~40 row objects, not 100k; a sort or a filter reads\n // every row and makes them then. Building them all up front was\n // most of a 100k-row mount (~24 ms, plus the garbage).\n cachedBaseRows = lazyRows(data.length, makeRow)\n } else cachedBaseRows = new Array(data.length)\n for (let index = 0; previous && index < data.length; index++) {\n const original = data[index]!\n const kept = previous[index] as BaseRowState<TData> | undefined\n if (kept && kept.original === original) {\n // Read before writing: a row nobody rendered (a million off-screen\n // placeholders under a row model) has nothing memoised, and a\n // store per row was a measurable share of each block landing.\n if (kept[ROW_VALUES] !== null) kept[ROW_VALUES] = null\n if (kept[ROW_CELLS] !== null) kept[ROW_CELLS] = null\n cachedBaseRows[index] = kept\n continue\n }\n // Past a quarter of the rows the merge stops being cheaper than a\n // sort with the ranking pass; let the stages do the full work.\n if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null\n const row = makeRow(index)\n // The previous list was lazy and never made this row, but its data\n // object is the same one: hand the old list this row too, so a\n // caller still holding it gets the same object for the same data,\n // as when every row was built up front.\n if (kept === undefined && previousInput?.[index] === original) previous[index] = row\n cachedBaseRows[index] = row\n if (replacedRows && kept) replacedRows.set(kept, row)\n }\n // A new array with every object the same is the documented way to\n // say \"I changed rows in place, re-read them\" (docs/help/cells/\n // view-refresh.md). Nothing was replaced, so there is nothing to\n // repair around: the stages recompute from the current values. A\n // repair assumes the rows it keeps are unchanged in value as well as\n // identity, which is the immutable-update contract a tick follows.\n if (replacedRows && replacedRows.size === 0) replacedRows = null\n }\n\n // Only the slices a pipeline stage actually READS belong in this key.\n //\n // `rowSelection` used to be here, which meant ticking one checkbox on a\n // 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a\n // row array that was identical by construction. Nothing reads it: the two\n // consumers are `getIsSelected` closures (on data rows and on group rows)\n // that read `store.state` when called, so they observe a selection change\n // without the model being rebuilt.\n //\n // `_rowModels` is a closed set of six named slots, so no consumer stage\n // can be inserted that might read selection. A caller CAN supply a custom\n // function for one of those slots; if one ever needs a slice that is not\n // listed here, add it here rather than reinstating all of them.\n const currentSlices = {\n sorting: store.state.sorting,\n columnFilters: store.state.columnFilters,\n pagination: store.state.pagination,\n grouping: store.state.grouping,\n expanded: store.state.expanded,\n }\n if (\n cachedRowModel &&\n cachedRowModelBaseRows === cachedBaseRows &&\n cachedPipeline === options._rowModels &&\n cachedSlices?.sorting === currentSlices.sorting &&\n cachedSlices?.columnFilters === currentSlices.columnFilters &&\n cachedSlices?.pagination === currentSlices.pagination &&\n cachedSlices?.grouping === currentSlices.grouping &&\n cachedSlices?.expanded === currentSlices.expanded\n ) {\n return cachedRowModel\n }\n\n // A stage with work to do reads every row; give it the dense array.\n const anyStageActive =\n (currentSlices.sorting?.length ?? 0) > 0 ||\n (currentSlices.columnFilters?.length ?? 0) > 0 ||\n (currentSlices.grouping?.length ?? 0) > 0 ||\n (currentSlices.expanded as unknown) === true ||\n Object.keys(currentSlices.expanded ?? {}).length > 0\n let rows: Array<Row<TData>> = anyStageActive ? denseRows(cachedBaseRows) : cachedBaseRows\n\n const pipeline = options._rowModels ?? {}\n const ordered: Array<RowModelFactory<TData> | undefined> = [\n pipeline.coreRowModel,\n pipeline.filteredRowModel,\n pipeline.sortedRowModel,\n pipeline.groupedRowModel,\n pipeline.expandedRowModel,\n pipeline.paginatedRowModel,\n ]\n // The stages read the replacements through `replacedRowsOf` while the\n // run lasts; a stage that runs outside one sees null.\n replacedRowsByGrid.set(grid, replacedRows)\n try {\n ordered.forEach((fn) => {\n if (fn) rows = fn({ table: grid, rows })\n })\n } finally {\n replacedRowsByGrid.set(grid, null)\n replacedRows = null\n }\n cachedPipeline = options._rowModels\n cachedSlices = currentSlices\n cachedRowModelBaseRows = cachedBaseRows\n cachedRowModel = { rows }\n return cachedRowModel\n },\n } as InternalGrid<TData>\n\n return grid\n}\n```\n\n### `function isFunction`\n\nNarrowing helper for the many options that accept a value or a function.\n\n```ts\nexport function isFunction(value: unknown): value is (...args: Array<any>) => any {\n return typeof value === 'function'\n}\n```\n"
|
|
6699
6699
|
},
|
|
6700
6700
|
{
|
|
6701
6701
|
"slug": "reference/auto/svgrid-grid-createTree.svelte",
|
|
@@ -6835,14 +6835,14 @@ export const docs = [
|
|
|
6835
6835
|
"path": "docs/reference/auto/svgrid-grid-server-block-cache.md",
|
|
6836
6836
|
"title": "@svgrid/grid · server-block-cache.ts",
|
|
6837
6837
|
"section": "API reference",
|
|
6838
|
-
"markdown": "# `@svgrid/grid` · `server-block-cache.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\server-block-cache.ts`.\n\n### `function rowPlaceholderState`\n\nWhy a row is not real data, or `null` when it is.\n\nDeliberately NOT an identity check. The sentinels are shared objects, so\n`row === LOADING_ROW` looks like the obvious test - but rows routinely\narrive here through something that wrapped them. Svelte is the everyday\ncase: assigning the controller state into `$state` makes the row array\ndeeply reactive, and every row read back out is a proxy of the original.\nIdentity fails, every placeholder reads as real data, and the grid renders\na screen of blank rows instead of skeletons. Reading the mark works through\nany wrapper that forwards property access, which is all of them.\n\n```ts\nexport function rowPlaceholderState(row: unknown): 'loading' | 'failed' | null {\n if (row == null || typeof row !== 'object') return null\n const mark = (row as Record<symbol, unknown>)[PLACEHOLDER]\n return mark === 'loading' || mark === 'failed' ? mark : null\n}\n```\n\n### `function createRowPlaceholder`\n\nMint a frozen placeholder the grid will recognise, carrying whatever\nother fields the caller wants on it. The row model uses this to give its\nplaceholders a `kind`, so code reading display rows and code reading grid\nrows agree on what they are looking at.\n\n```ts\nexport function createRowPlaceholder<T extends object>(\n state: 'loading' | 'failed',\n fields: T,\n): Readonly<T & { readonly [PLACEHOLDER]: 'loading' | 'failed' }> {\n return Object.freeze({ ...fields, [PLACEHOLDER]: state })\n}\n```\n\n### `type BlockFetchResult`\n\nWhat a block fetch has to answer with. `rowCount` is optional: see {@link BlockCacheOptions}.\n\n```ts\nexport type BlockFetchResult<TData> = {\n rows: ReadonlyArray<TData>\n /**\n * Total rows after filtering. Omit it (or send `-1`) when the backend cannot\n * count cheaply - the cache then discovers the end from the first short\n * block, and `lastRowKnown()` stays false until it does.\n */\n rowCount?: number\n}\n```\n\n### `type BlockCacheOptions`\n\nWhat a block cache is built from: block size, caps, debounce and the fetch.\n\n```ts\nexport type BlockCacheOptions<TData> = {\n /**\n * Rows per request. Bigger blocks mean fewer round trips and more wasted\n * rows when the user scrolls past; 100 is the usual compromise.\n */\n blockSize?: number\n /**\n * Keep at most this many loaded blocks. Blocks outside the viewport are\n * evicted least-recently-seen first, and scrolling back re-fetches them.\n * Unlimited by default, which is right until the dataset is big enough that\n * holding every visited block matters.\n */\n maxBlocksInCache?: number\n /** Requests open at once. Default 2. */\n maxConcurrentRequests?: number\n /**\n * Wait this long after the viewport settles before fetching. Non-zero values\n * stop a drag of the scrollbar from requesting every block it flies past.\n */\n blockLoadDebounceMs?: number\n /**\n * Rows to claim before anything is loaded, so the grid has a scrollbar (and\n * `scrollToRow` has somewhere to land) on the first paint. Default 1.\n */\n initialRowCount?: number\n /**\n * While the total is unknown, how many unloaded rows to keep past the last\n * loaded one. Scrolling into them is what asks for the next block. Default 1.\n */\n overflowRows?: number\n /** Fetch one block. Reject, or throw, to mark it failed. */\n fetch: (\n startRow: number,\n endRow: number,\n signal: AbortSignal,\n ) => Promise<BlockFetchResult<TData>>\n /** Called after any change to the rows, the count, or a block's state. */\n onChange: (cache: BlockCacheState) => void\n /**\n * How to coalesce `onChange`. Defaults to a microtask, so a burst of block\n * arrivals rebuilds the row array once. Pass `(fn) => fn()` in tests to make\n * every change synchronous.\n */\n schedule?: (flush: () => void) => void\n}\n```\n\n### `type BlockState`\n\nOne block's place in the world, for diagnostics and for tests.\n\n```ts\nexport type BlockState = {\n blockIndex: number\n startRow: number\n endRow: number\n status: 'loading' | 'loaded' | 'failed'\n /** Monotonic counter of when the viewport last covered this block. */\n lastTouched: number\n}\n```\n\n### `type BlockCacheState`\n\nOne block as `getCacheState()` reports it.\n\n```ts\nexport type BlockCacheState = {\n /** Rows after filtering, or `null` while the backend has not said. */\n rowCount: number | null\n /** False while the end of the data is still being discovered. */\n lastRowKnown: boolean\n /** True while at least one block is in flight. */\n loading: boolean\n /** Blocks whose fetch rejected. Empty unless something went wrong. */\n failedBlocks: number[]\n}\n```\n\n### `type BlockCache`\n\nA block cache: rows arrive in blocks as the viewport reaches them,\nplaceholders stand in until then, and blocks far from the viewport are\nevicted past the cap.\n\n```ts\nexport type BlockCache<TData> = {\n /**\n * Tell the cache which rows are on screen. Fetches what is missing (after\n * `blockLoadDebounceMs`) and marks those blocks as recently seen so eviction\n * spares them. Safe to call on every scroll frame.\n */\n setViewport(startIndex: number, endIndex: number): void\n /** The dense row array to hand the grid. Placeholders fill unloaded slots. */\n rows(): ReadonlyArray<TData>\n /** One row, without building the array. Returns a placeholder when unloaded. */\n getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW\n rowCount(): number | null\n lastRowKnown(): boolean\n /** Re-fetch the blocks that failed. */\n retryFailed(): void\n /**\n * Re-fetch the failed block holding `rowIndex`, and only that one; the\n * Retry on a failed row. A row whose block has not failed is a no-op.\n */\n retryFailedAt(rowIndex: number): void\n /**\n * Re-fetch the loaded blocks in place, keeping the row count and scroll\n * position. What you want after a mutation lands on the server.\n */\n refresh(): void\n /** Drop everything and start over from the current viewport. */\n purge(): void\n /** Override the total, e.g. from a count endpoint that answered separately. */\n setRowCount(count: number | null, known?: boolean): void\n /**\n * Write rows straight into the cache from `startRow`, as if a fetch had\n * returned them, bypassing the datasource, the debounce and the\n * concurrency cap. `startRow` must sit on a block boundary. An explicit\n * `rowCount` settles the total; without one a short final slice does.\n */\n applyRows(startRow: number, rows: ReadonlyArray<TData>, rowCount?: number): void\n /** Replace one loaded row. No-op when its block is not loaded. */\n patch(index: number, row: TData): boolean\n /**\n * The index of the first LOADED row matching `predicate`, or -1. Rows\n * that are not loaded are not visited - a transaction addressed by id\n * can only touch what is in the cache.\n */\n findIndex(predicate: (row: TData, index: number) => boolean): number\n /**\n * Insert rows at an index and grow the count. Works within whichever\n * run of loaded blocks contains `index`; loaded blocks AFTER that run\n * are dropped, because their rows have shifted and will be re-fetched at\n * their new offsets. An index in an unloaded region grows the count\n * only (the rows exist; they arrive when scrolled to). Returns whether\n * the rows were placed in the cache.\n */\n insert(index: number, rows: ReadonlyArray<TData>): boolean\n /**\n * Remove `count` rows at an index and shrink the count, with the same\n * run semantics as `insert`. Returns how many rows were actually\n * removed from the cache.\n */\n remove(index: number, count?: number): number\n /** Every block the cache is holding, for `debug` output and tests. */\n getCacheState(): BlockState[]\n /** Abort what is in flight and stop emitting. Call on unmount. */\n dispose(): void\n}\n```\n\n### `function createBlockCache`\n\nBuild a block cache over a `fetch(startRow, endRow, signal)`. The free\ninfinite row model and the Enterprise server-side row model both run on\nit, one per level.\n\n```ts\nexport function createBlockCache<TData>(options: BlockCacheOptions<TData>): BlockCache<TData> {\n const blockSize = Math.max(1, options.blockSize ?? 100)\n const maxBlocks = options.maxBlocksInCache ?? Infinity\n const maxConcurrent = Math.max(1, options.maxConcurrentRequests ?? 2)\n const debounceMs = Math.max(0, options.blockLoadDebounceMs ?? 0)\n const initialRowCount = Math.max(0, options.initialRowCount ?? 1)\n const overflowRows = Math.max(0, options.overflowRows ?? 1)\n const schedule = options.schedule ?? defaultSchedule\n\n const blocks = new Map<number, Block<TData>>()\n /** Block indices waiting for a slot, in the order the viewport asked for them. */\n const queue: number[] = []\n let inFlight = 0\n let touchClock = 0\n let count: number | null = null\n let countKnown = false\n let disposed = false\n\n let viewStart = 0\n let viewEnd = 0\n let debounceTimer: ReturnType<typeof setTimeout> | null = null\n\n let emitScheduled = false\n let rowsCache: TData[] | null = null\n\n const blockOf = (rowIndex: number): number => Math.floor(rowIndex / blockSize)\n const startOf = (blockIndex: number): number => blockIndex * blockSize\n\n /**\n * How many rows to claim we have.\n *\n * With a known count that is simply the count. Without one it is the last row\n * we have touched plus `overflowRows`, so there is always somewhere to scroll\n * that will ask for the next block - this is what makes the list grow as you\n * go rather than ending at the first unloaded row.\n */\n function currentLength(): number {\n if (countKnown && count != null) return count\n let highestEnd = 0\n let anySettled = false\n for (const b of blocks.values()) if (b.status !== 'loading') anySettled = true\n for (const [index, block] of blocks) {\n // A block in flight extends the list only once some block has settled.\n // Before that the list is `initialRowCount` long: the few skeleton\n // rows a level shows while it waits, not a hundred of them. The first\n // block failing says nothing about how long the list is either, so it\n // keeps those few rows (its Retry sits on them); a failed block further\n // in was scrolled to, so its slots stay and the scrollbar holds still.\n const end = block.status === 'loaded'\n ? startOf(index) + block.rows.length\n : block.status === 'failed'\n ? index === 0 ? 0 : startOf(index) + blockSize\n : anySettled\n ? startOf(index) + blockSize\n : 0\n if (end > highestEnd) highestEnd = end\n }\n if (!anySettled) return initialRowCount\n return Math.max(initialRowCount, highestEnd + overflowRows)\n }\n\n function state(): BlockCacheState {\n const failed: number[] = []\n for (const [index, block] of blocks) if (block.status === 'failed') failed.push(index)\n return {\n rowCount: countKnown ? count : null,\n lastRowKnown: countKnown,\n loading: inFlight > 0,\n failedBlocks: failed.sort((a, b) => a - b),\n }\n }\n\n /**\n * Something changed: forget the row array NOW, so a synchronous read after\n * a mutation sees the new state, and notify on the next tick, so a burst of\n * mutations notifies once. Every mutating path ends here.\n */\n function emit(): void {\n rowsCache = null // rebuilt lazily, on the next read\n if (disposed || emitScheduled) return\n emitScheduled = true\n schedule(() => {\n emitScheduled = false\n if (disposed) return\n options.onChange(state())\n })\n }\n\n function ensureBlock(blockIndex: number): void {\n if (disposed) return\n const existing = blocks.get(blockIndex)\n // Loading or loaded blocks are left alone; a failed one only retries when\n // asked, so a broken backend is not hammered once per scroll frame.\n if (existing) return\n if (queue.includes(blockIndex)) return\n queue.push(blockIndex)\n pump()\n }\n\n function pump(): void {\n while (!disposed && inFlight < maxConcurrent && queue.length > 0) {\n const blockIndex = queue.shift()!\n if (blocks.has(blockIndex)) continue\n void load(blockIndex)\n }\n }\n\n async function load(blockIndex: number): Promise<void> {\n const controller = new AbortController()\n const block: Block<TData> = {\n status: 'loading',\n rows: [],\n lastTouched: (touchClock += 1),\n controller,\n }\n blocks.set(blockIndex, block)\n inFlight += 1\n emit()\n\n const startRow = startOf(blockIndex)\n const endRow = startRow + blockSize\n try {\n const result = await options.fetch(startRow, endRow, controller.signal)\n if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return\n block.rows = [...result.rows]\n block.status = 'loaded'\n block.controller = null\n applyCountFrom(blockIndex, block.rows.length, result.rowCount)\n evictIfNeeded()\n } catch {\n if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return\n block.status = 'failed'\n block.rows = []\n block.controller = null\n } finally {\n if (!disposed && !controller.signal.aborted) {\n inFlight -= 1\n emit()\n pump()\n }\n }\n }\n\n /**\n * Learn the total from a block that just landed.\n *\n * An explicit non-negative `rowCount` settles it. Otherwise a SHORT block is\n * the proof that we hit the end: fewer rows came back than were asked for, so\n * the data stops where they stop.\n */\n function applyCountFrom(blockIndex: number, received: number, reported: number | undefined): void {\n if (typeof reported === 'number' && reported >= 0) {\n count = reported\n countKnown = true\n return\n }\n if (received < blockSize) {\n count = startOf(blockIndex) + received\n countKnown = true\n }\n }\n\n /**\n * Evict loaded blocks over the limit, least-recently-seen first, never one\n * the viewport is currently on - dropping a visible block would swap real\n * rows for skeletons under the user's eyes and immediately re-fetch them.\n */\n function evictIfNeeded(): void {\n if (!Number.isFinite(maxBlocks)) return\n const firstVisible = blockOf(viewStart)\n const lastVisible = blockOf(Math.max(viewStart, viewEnd))\n const evictable = [...blocks.entries()]\n .filter(([index, b]) => b.status === 'loaded' && (index < firstVisible || index > lastVisible))\n .sort((a, b) => a[1].lastTouched - b[1].lastTouched)\n\n let loaded = 0\n for (const b of blocks.values()) if (b.status === 'loaded') loaded += 1\n\n for (const [index] of evictable) {\n if (loaded <= maxBlocks) break\n blocks.delete(index)\n loaded -= 1\n }\n }\n\n function fetchViewport(): void {\n if (disposed) return\n const first = blockOf(viewStart)\n const last = blockOf(Math.max(viewStart, viewEnd))\n for (let i = first; i <= last; i += 1) {\n const block = blocks.get(i)\n if (block) block.lastTouched = touchClock += 1\n else ensureBlock(i)\n }\n }\n\n function abortAll(): void {\n for (const block of blocks.values()) block.controller?.abort()\n inFlight = 0\n queue.length = 0\n }\n\n return {\n setViewport(startIndex, endIndex) {\n if (disposed) return\n viewStart = Math.max(0, startIndex)\n viewEnd = Math.max(viewStart, endIndex)\n if (debounceMs === 0) {\n fetchViewport()\n return\n }\n if (debounceTimer) clearTimeout(debounceTimer)\n debounceTimer = setTimeout(() => {\n debounceTimer = null\n fetchViewport()\n }, debounceMs)\n },\n\n rows() {\n if (rowsCache) return rowsCache\n const length = currentLength()\n const out = new Array<TData>(length)\n // Fill from the blocks we have, then pad the rest with placeholders. One\n // pass per block beats one lookup per row: at a million rows the\n // difference is the frame budget.\n out.fill(LOADING_ROW as unknown as TData)\n for (const [index, block] of blocks) {\n const start = startOf(index)\n if (start >= length) continue\n if (block.status === 'failed') {\n const end = Math.min(start + blockSize, length)\n for (let i = start; i < end; i += 1) out[i] = FAILED_ROW as unknown as TData\n continue\n }\n for (let i = 0; i < block.rows.length && start + i < length; i += 1) {\n out[start + i] = block.rows[i]!\n }\n }\n rowsCache = out\n return out\n },\n\n getRow(index) {\n const block = blocks.get(blockOf(index))\n if (!block) return LOADING_ROW\n if (block.status === 'failed') return FAILED_ROW\n const row = block.rows[index - startOf(blockOf(index))]\n return row ?? LOADING_ROW\n },\n\n rowCount: () => (countKnown ? count : null),\n lastRowKnown: () => countKnown,\n\n retryFailed() {\n let any = false\n for (const [index, block] of [...blocks]) {\n if (block.status !== 'failed') continue\n blocks.delete(index)\n ensureBlock(index)\n any = true\n }\n if (any) emit()\n },\n\n retryFailedAt(rowIndex) {\n const index = blockOf(Math.max(0, rowIndex))\n if (blocks.get(index)?.status !== 'failed') return\n blocks.delete(index)\n ensureBlock(index)\n emit()\n },\n\n refresh() {\n // Keep the count and the scroll position: drop the DATA, re-request the\n // same blocks. The user sees skeletons where they were, not a jump home.\n for (const [index, block] of [...blocks]) {\n block.controller?.abort()\n blocks.delete(index)\n ensureBlock(index)\n }\n emit()\n },\n\n purge() {\n abortAll()\n blocks.clear()\n count = null\n countKnown = false\n rowsCache = null\n fetchViewport()\n emit()\n },\n\n setRowCount(next, known = next != null) {\n count = next\n countKnown = known && next != null\n emit()\n },\n\n applyRows(startRow, rows, rowCount) {\n if (startRow % blockSize !== 0) {\n throw new Error(\n `createBlockCache.applyRows: startRow ${startRow} is not a multiple of blockSize ${blockSize}`,\n )\n }\n const firstBlock = blockOf(startRow)\n const blockCount = Math.ceil(rows.length / blockSize)\n for (let i = 0; i < blockCount; i += 1) {\n const index = firstBlock + i\n blocks.get(index)?.controller?.abort() // ours now, whatever was in flight\n blocks.set(index, {\n status: 'loaded',\n rows: rows.slice(i * blockSize, (i + 1) * blockSize),\n lastTouched: (touchClock += 1),\n controller: null,\n })\n }\n const lastBlock = blocks.get(firstBlock + blockCount - 1)\n if (lastBlock) applyCountFrom(firstBlock + blockCount - 1, lastBlock.rows.length, rowCount)\n else if (typeof rowCount === 'number' && rowCount >= 0) {\n count = rowCount\n countKnown = true\n }\n emit()\n },\n\n patch(index, row) {\n const block = blocks.get(blockOf(index))\n if (!block || block.status !== 'loaded') return false\n const offset = index - startOf(blockOf(index))\n if (offset < 0 || offset >= block.rows.length) return false\n block.rows[offset] = row\n emit()\n return true\n },\n\n findIndex(predicate) {\n for (const [blockIndex, block] of blocks) {\n if (block.status !== 'loaded') continue\n const start = startOf(blockIndex)\n for (let i = 0; i < block.rows.length; i += 1) {\n if (predicate(block.rows[i]!, start + i)) return start + i\n }\n }\n return -1\n },\n\n insert(index, rows) {\n if (rows.length === 0) return false\n const clamped = Math.max(0, Math.min(index, currentLength()))\n const run = loadedRunAt(clamped)\n if (run) {\n // Rows shift across block boundaries, so re-slice the run from its\n // first block; everything loaded past it is dropped (offsets moved).\n run.flat.splice(clamped - startOf(run.startBlock), 0, ...rows)\n reseat(run.startBlock, run.flat)\n } else {\n dropLoadedFrom(blockOf(clamped))\n }\n if (countKnown && count != null) count += rows.length\n emit()\n return run != null\n },\n\n remove(index, removeCount = 1) {\n if (removeCount <= 0) return 0\n const run = loadedRunAt(index)\n if (!run) return 0\n const removed = run.flat.splice(index - startOf(run.startBlock), removeCount).length\n reseat(run.startBlock, run.flat)\n if (countKnown && count != null) count = Math.max(0, count - removed)\n emit()\n return removed\n },\n\n getCacheState() {\n return [...blocks.entries()]\n .map(([blockIndex, block]) => ({\n blockIndex,\n startRow: startOf(blockIndex),\n endRow: startOf(blockIndex) + blockSize,\n status: block.status,\n lastTouched: block.lastTouched,\n }))\n .sort((a, b) => a.blockIndex - b.blockIndex)\n },\n\n dispose() {\n disposed = true\n if (debounceTimer) clearTimeout(debounceTimer)\n debounceTimer = null\n abortAll()\n blocks.clear()\n },\n }\n\n /**\n * The run of consecutive loaded blocks containing `index`, flattened, with\n * the block it starts at. Null when the block at `index` is not loaded -\n * an index-shifting edit there would have nothing to shift.\n */\n function loadedRunAt(index: number): { startBlock: number; flat: TData[] } | null {\n const at = blockOf(index)\n if (blocks.get(at)?.status !== 'loaded') return null\n let startBlock = at\n while (blocks.get(startBlock - 1)?.status === 'loaded') startBlock -= 1\n const flat: TData[] = []\n for (let i = startBlock; ; i += 1) {\n const block = blocks.get(i)\n if (!block || block.status !== 'loaded') break\n flat.push(...block.rows)\n if (block.rows.length < blockSize) break // the last block, by definition\n }\n return { startBlock, flat }\n }\n\n /**\n * Lay a flat run back out into blocks from `startBlock`, and drop every\n * block after the run: their rows have shifted, and re-fetching them at\n * the new offsets is the honest outcome. Blocks before it are untouched.\n */\n function reseat(startBlock: number, flat: TData[]): void {\n const blockCount = Math.ceil(flat.length / blockSize)\n dropLoadedFrom(startBlock)\n for (let i = 0; i < blockCount; i += 1) {\n blocks.set(startBlock + i, {\n status: 'loaded',\n rows: flat.slice(i * blockSize, (i + 1) * blockSize),\n lastTouched: (touchClock += 1),\n controller: null,\n })\n }\n rowsCache = null\n }\n\n /** Forget every block from `fromBlock` on, aborting any in flight. */\n function dropLoadedFrom(fromBlock: number): void {\n for (const [index, block] of [...blocks]) {\n if (index < fromBlock) continue\n block.controller?.abort()\n blocks.delete(index)\n }\n rowsCache = null\n }\n}\n```\n"
|
|
6838
|
+
"markdown": "# `@svgrid/grid` · `server-block-cache.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\server-block-cache.ts`.\n\n### `function rowPlaceholderState`\n\nWhy a row is not real data, or `null` when it is.\n\nDeliberately NOT an identity check. The sentinels are shared objects, so\n`row === LOADING_ROW` looks like the obvious test - but rows routinely\narrive here through something that wrapped them. Svelte is the everyday\ncase: assigning the controller state into `$state` makes the row array\ndeeply reactive, and every row read back out is a proxy of the original.\nIdentity fails, every placeholder reads as real data, and the grid renders\na screen of blank rows instead of skeletons. Reading the mark works through\nany wrapper that forwards property access, which is all of them.\n\n```ts\nexport function rowPlaceholderState(row: unknown): 'loading' | 'failed' | null {\n if (row == null || typeof row !== 'object') return null\n const mark = (row as Record<symbol, unknown>)[PLACEHOLDER]\n return mark === 'loading' || mark === 'failed' ? mark : null\n}\n```\n\n### `function createRowPlaceholder`\n\nMint a frozen placeholder the grid will recognise, carrying whatever\nother fields the caller wants on it. The row model uses this to give its\nplaceholders a `kind`, so code reading display rows and code reading grid\nrows agree on what they are looking at.\n\n```ts\nexport function createRowPlaceholder<T extends object>(\n state: 'loading' | 'failed',\n fields: T,\n): Readonly<T & { readonly [PLACEHOLDER]: 'loading' | 'failed' }> {\n return Object.freeze({ ...fields, [PLACEHOLDER]: state })\n}\n```\n\n### `type BlockFetchResult`\n\nWhat a block fetch has to answer with. `rowCount` is optional: see {@link BlockCacheOptions}.\n\n```ts\nexport type BlockFetchResult<TData> = {\n rows: ReadonlyArray<TData>\n /**\n * Total rows after filtering. Omit it (or send `-1`) when the backend cannot\n * count cheaply - the cache then discovers the end from the first short\n * block, and `lastRowKnown()` stays false until it does.\n */\n rowCount?: number\n}\n```\n\n### `type BlockCacheOptions`\n\nWhat a block cache is built from: block size, caps, debounce and the fetch.\n\n```ts\nexport type BlockCacheOptions<TData> = {\n /**\n * Rows per request. Bigger blocks mean fewer round trips and more wasted\n * rows when the user scrolls past; 100 is the usual compromise.\n */\n blockSize?: number\n /**\n * Keep at most this many loaded blocks. Blocks outside the viewport are\n * evicted least-recently-seen first, and scrolling back re-fetches them.\n * Unlimited by default, which is right until the dataset is big enough that\n * holding every visited block matters.\n */\n maxBlocksInCache?: number\n /** Requests open at once. Default 2. */\n maxConcurrentRequests?: number\n /**\n * Wait this long after the viewport settles before fetching. Non-zero values\n * stop a drag of the scrollbar from requesting every block it flies past.\n */\n blockLoadDebounceMs?: number\n /**\n * Rows to claim before anything is loaded, so the grid has a scrollbar (and\n * `scrollToRow` has somewhere to land) on the first paint. Default 1.\n */\n initialRowCount?: number\n /**\n * While the total is unknown, how many unloaded rows to keep past the last\n * loaded one. Scrolling into them is what asks for the next block. Default 1.\n */\n overflowRows?: number\n /** Fetch one block. Reject, or throw, to mark it failed. */\n fetch: (\n startRow: number,\n endRow: number,\n signal: AbortSignal,\n ) => Promise<BlockFetchResult<TData>>\n /** Called after any change to the rows, the count, or a block's state. */\n onChange: (cache: BlockCacheState) => void\n /**\n * How to coalesce `onChange`. Defaults to a microtask, so a burst of block\n * arrivals rebuilds the row array once. Pass `(fn) => fn()` in tests to make\n * every change synchronous.\n */\n schedule?: (flush: () => void) => void\n}\n```\n\n### `type BlockState`\n\nOne block's place in the world, for diagnostics and for tests.\n\n```ts\nexport type BlockState = {\n blockIndex: number\n startRow: number\n endRow: number\n status: 'loading' | 'loaded' | 'failed'\n /** Monotonic counter of when the viewport last covered this block. */\n lastTouched: number\n}\n```\n\n### `type BlockCacheState`\n\nOne block as `getCacheState()` reports it.\n\n```ts\nexport type BlockCacheState = {\n /** Rows after filtering, or `null` while the backend has not said. */\n rowCount: number | null\n /** False while the end of the data is still being discovered. */\n lastRowKnown: boolean\n /** True while at least one block is in flight. */\n loading: boolean\n /** Blocks whose fetch rejected. Empty unless something went wrong. */\n failedBlocks: number[]\n}\n```\n\n### `type BlockCache`\n\nA block cache: rows arrive in blocks as the viewport reaches them,\nplaceholders stand in until then, and blocks far from the viewport are\nevicted past the cap.\n\n```ts\nexport type BlockCache<TData> = {\n /**\n * Tell the cache which rows are on screen. Fetches what is missing (after\n * `blockLoadDebounceMs`) and marks those blocks as recently seen so eviction\n * spares them. Safe to call on every scroll frame.\n */\n setViewport(startIndex: number, endIndex: number): void\n /** The dense row array to hand the grid. Placeholders fill unloaded slots. */\n rows(): ReadonlyArray<TData>\n /**\n * The same rows as windowed data (see windowed-data.ts): `length` is the\n * row count and each entry is read through `getRow` when the grid asks for\n * it, so handing it over costs O(1) whatever the count. A new array per\n * change, like `rows()`.\n */\n windowedRows(): ReadonlyArray<TData>\n /** One row, without building the array. Returns a placeholder when unloaded. */\n getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW\n rowCount(): number | null\n lastRowKnown(): boolean\n /** Re-fetch the blocks that failed. */\n retryFailed(): void\n /**\n * Re-fetch the failed block holding `rowIndex`, and only that one; the\n * Retry on a failed row. A row whose block has not failed is a no-op.\n */\n retryFailedAt(rowIndex: number): void\n /**\n * Re-fetch the loaded blocks in place, keeping the row count and scroll\n * position. What you want after a mutation lands on the server.\n */\n refresh(): void\n /** Drop everything and start over from the current viewport. */\n purge(): void\n /** Override the total, e.g. from a count endpoint that answered separately. */\n setRowCount(count: number | null, known?: boolean): void\n /**\n * Write rows straight into the cache from `startRow`, as if a fetch had\n * returned them, bypassing the datasource, the debounce and the\n * concurrency cap. `startRow` must sit on a block boundary. An explicit\n * `rowCount` settles the total; without one a short final slice does.\n */\n applyRows(startRow: number, rows: ReadonlyArray<TData>, rowCount?: number): void\n /** Replace one loaded row. No-op when its block is not loaded. */\n patch(index: number, row: TData): boolean\n /**\n * The index of the first LOADED row matching `predicate`, or -1. Rows\n * that are not loaded are not visited - a transaction addressed by id\n * can only touch what is in the cache.\n */\n findIndex(predicate: (row: TData, index: number) => boolean): number\n /**\n * Insert rows at an index and grow the count. Works within whichever\n * run of loaded blocks contains `index`; loaded blocks AFTER that run\n * are dropped, because their rows have shifted and will be re-fetched at\n * their new offsets. An index in an unloaded region grows the count\n * only (the rows exist; they arrive when scrolled to). Returns whether\n * the rows were placed in the cache.\n */\n insert(index: number, rows: ReadonlyArray<TData>): boolean\n /**\n * Remove `count` rows at an index and shrink the count, with the same\n * run semantics as `insert`. Returns how many rows were actually\n * removed from the cache.\n */\n remove(index: number, count?: number): number\n /** Every block the cache is holding, for `debug` output and tests. */\n getCacheState(): BlockState[]\n /** Abort what is in flight and stop emitting. Call on unmount. */\n dispose(): void\n}\n```\n\n### `function createBlockCache`\n\nBuild a block cache over a `fetch(startRow, endRow, signal)`. The free\ninfinite row model and the Enterprise server-side row model both run on\nit, one per level.\n\n```ts\nexport function createBlockCache<TData>(options: BlockCacheOptions<TData>): BlockCache<TData> {\n const blockSize = Math.max(1, options.blockSize ?? 100)\n const maxBlocks = options.maxBlocksInCache ?? Infinity\n const maxConcurrent = Math.max(1, options.maxConcurrentRequests ?? 2)\n const debounceMs = Math.max(0, options.blockLoadDebounceMs ?? 0)\n const initialRowCount = Math.max(0, options.initialRowCount ?? 1)\n const overflowRows = Math.max(0, options.overflowRows ?? 1)\n const schedule = options.schedule ?? defaultSchedule\n\n const blocks = new Map<number, Block<TData>>()\n /** Block indices waiting for a slot, in the order the viewport asked for them. */\n const queue: number[] = []\n let inFlight = 0\n let touchClock = 0\n let count: number | null = null\n let countKnown = false\n let disposed = false\n\n let viewStart = 0\n let viewEnd = 0\n let debounceTimer: ReturnType<typeof setTimeout> | null = null\n\n let emitScheduled = false\n let rowsCache: TData[] | null = null\n let windowedCache: TData[] | null = null\n\n const blockOf = (rowIndex: number): number => Math.floor(rowIndex / blockSize)\n const startOf = (blockIndex: number): number => blockIndex * blockSize\n\n /**\n * How many rows to claim we have.\n *\n * With a known count that is simply the count. Without one it is the last row\n * we have touched plus `overflowRows`, so there is always somewhere to scroll\n * that will ask for the next block - this is what makes the list grow as you\n * go rather than ending at the first unloaded row.\n */\n function currentLength(): number {\n if (countKnown && count != null) return count\n let highestEnd = 0\n let anySettled = false\n for (const b of blocks.values()) if (b.status !== 'loading') anySettled = true\n for (const [index, block] of blocks) {\n // A block in flight extends the list only once some block has settled.\n // Before that the list is `initialRowCount` long: the few skeleton\n // rows a level shows while it waits, not a hundred of them. The first\n // block failing says nothing about how long the list is either, so it\n // keeps those few rows (its Retry sits on them); a failed block further\n // in was scrolled to, so its slots stay and the scrollbar holds still.\n const end = block.status === 'loaded'\n ? startOf(index) + block.rows.length\n : block.status === 'failed'\n ? index === 0 ? 0 : startOf(index) + blockSize\n : anySettled\n ? startOf(index) + blockSize\n : 0\n if (end > highestEnd) highestEnd = end\n }\n if (!anySettled) return initialRowCount\n return Math.max(initialRowCount, highestEnd + overflowRows)\n }\n\n function state(): BlockCacheState {\n const failed: number[] = []\n for (const [index, block] of blocks) if (block.status === 'failed') failed.push(index)\n return {\n rowCount: countKnown ? count : null,\n lastRowKnown: countKnown,\n loading: inFlight > 0,\n failedBlocks: failed.sort((a, b) => a - b),\n }\n }\n\n /**\n * Something changed: forget the row array NOW, so a synchronous read after\n * a mutation sees the new state, and notify on the next tick, so a burst of\n * mutations notifies once. Every mutating path ends here.\n */\n function emit(): void {\n rowsCache = windowedCache = null // rebuilt lazily, on the next read\n if (disposed || emitScheduled) return\n emitScheduled = true\n schedule(() => {\n emitScheduled = false\n if (disposed) return\n options.onChange(state())\n })\n }\n\n function ensureBlock(blockIndex: number): void {\n if (disposed) return\n const existing = blocks.get(blockIndex)\n // Loading or loaded blocks are left alone; a failed one only retries when\n // asked, so a broken backend is not hammered once per scroll frame.\n if (existing) return\n if (queue.includes(blockIndex)) return\n queue.push(blockIndex)\n pump()\n }\n\n function pump(): void {\n while (!disposed && inFlight < maxConcurrent && queue.length > 0) {\n const blockIndex = queue.shift()!\n if (blocks.has(blockIndex)) continue\n void load(blockIndex)\n }\n }\n\n async function load(blockIndex: number): Promise<void> {\n const controller = new AbortController()\n const block: Block<TData> = {\n status: 'loading',\n rows: [],\n lastTouched: (touchClock += 1),\n controller,\n }\n blocks.set(blockIndex, block)\n inFlight += 1\n emit()\n\n const startRow = startOf(blockIndex)\n const endRow = startRow + blockSize\n try {\n const result = await options.fetch(startRow, endRow, controller.signal)\n if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return\n block.rows = [...result.rows]\n block.status = 'loaded'\n block.controller = null\n applyCountFrom(blockIndex, block.rows.length, result.rowCount)\n evictIfNeeded()\n } catch {\n if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return\n block.status = 'failed'\n block.rows = []\n block.controller = null\n } finally {\n if (!disposed && !controller.signal.aborted) {\n inFlight -= 1\n emit()\n pump()\n }\n }\n }\n\n /**\n * Learn the total from a block that just landed.\n *\n * An explicit non-negative `rowCount` settles it. Otherwise a SHORT block is\n * the proof that we hit the end: fewer rows came back than were asked for, so\n * the data stops where they stop.\n */\n function applyCountFrom(blockIndex: number, received: number, reported: number | undefined): void {\n if (typeof reported === 'number' && reported >= 0) {\n count = reported\n countKnown = true\n return\n }\n if (received < blockSize) {\n count = startOf(blockIndex) + received\n countKnown = true\n }\n }\n\n /**\n * Evict loaded blocks over the limit, least-recently-seen first, never one\n * the viewport is currently on - dropping a visible block would swap real\n * rows for skeletons under the user's eyes and immediately re-fetch them.\n */\n function evictIfNeeded(): void {\n if (!Number.isFinite(maxBlocks)) return\n const firstVisible = blockOf(viewStart)\n const lastVisible = blockOf(Math.max(viewStart, viewEnd))\n const evictable = [...blocks.entries()]\n .filter(([index, b]) => b.status === 'loaded' && (index < firstVisible || index > lastVisible))\n .sort((a, b) => a[1].lastTouched - b[1].lastTouched)\n\n let loaded = 0\n for (const b of blocks.values()) if (b.status === 'loaded') loaded += 1\n\n for (const [index] of evictable) {\n if (loaded <= maxBlocks) break\n blocks.delete(index)\n loaded -= 1\n }\n }\n\n function fetchViewport(): void {\n if (disposed) return\n const first = blockOf(viewStart)\n const last = blockOf(Math.max(viewStart, viewEnd))\n for (let i = first; i <= last; i += 1) {\n const block = blocks.get(i)\n if (block) block.lastTouched = touchClock += 1\n else ensureBlock(i)\n }\n }\n\n function rowAt(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW {\n const block = blocks.get(blockOf(index))\n if (!block) return LOADING_ROW\n if (block.status === 'failed') return FAILED_ROW\n const row = block.rows[index - startOf(blockOf(index))]\n return row ?? LOADING_ROW\n }\n\n function abortAll(): void {\n for (const block of blocks.values()) block.controller?.abort()\n inFlight = 0\n queue.length = 0\n }\n\n return {\n setViewport(startIndex, endIndex) {\n if (disposed) return\n viewStart = Math.max(0, startIndex)\n viewEnd = Math.max(viewStart, endIndex)\n if (debounceMs === 0) {\n fetchViewport()\n return\n }\n if (debounceTimer) clearTimeout(debounceTimer)\n debounceTimer = setTimeout(() => {\n debounceTimer = null\n fetchViewport()\n }, debounceMs)\n },\n\n rows() {\n if (rowsCache) return rowsCache\n const length = currentLength()\n const out = new Array<TData>(length)\n // Fill from the blocks we have, then pad the rest with placeholders. One\n // pass per block beats one lookup per row: at a million rows the\n // difference is the frame budget.\n out.fill(LOADING_ROW as unknown as TData)\n for (const [index, block] of blocks) {\n const start = startOf(index)\n if (start >= length) continue\n if (block.status === 'failed') {\n const end = Math.min(start + blockSize, length)\n for (let i = start; i < end; i += 1) out[i] = FAILED_ROW as unknown as TData\n continue\n }\n for (let i = 0; i < block.rows.length && start + i < length; i += 1) {\n out[start + i] = block.rows[i]!\n }\n }\n rowsCache = out\n return out\n },\n\n windowedRows() {\n if (windowedCache) return windowedCache\n windowedCache = createWindowedData<TData>(currentLength(), (i) => rowAt(i) as TData)\n return windowedCache\n },\n\n getRow: rowAt,\n\n rowCount: () => (countKnown ? count : null),\n lastRowKnown: () => countKnown,\n\n retryFailed() {\n let any = false\n for (const [index, block] of [...blocks]) {\n if (block.status !== 'failed') continue\n blocks.delete(index)\n ensureBlock(index)\n any = true\n }\n if (any) emit()\n },\n\n retryFailedAt(rowIndex) {\n const index = blockOf(Math.max(0, rowIndex))\n if (blocks.get(index)?.status !== 'failed') return\n blocks.delete(index)\n ensureBlock(index)\n emit()\n },\n\n refresh() {\n // Keep the count and the scroll position: drop the DATA, re-request the\n // same blocks. The user sees skeletons where they were, not a jump home.\n for (const [index, block] of [...blocks]) {\n block.controller?.abort()\n blocks.delete(index)\n ensureBlock(index)\n }\n emit()\n },\n\n purge() {\n abortAll()\n blocks.clear()\n count = null\n countKnown = false\n rowsCache = windowedCache = null\n fetchViewport()\n emit()\n },\n\n setRowCount(next, known = next != null) {\n count = next\n countKnown = known && next != null\n emit()\n },\n\n applyRows(startRow, rows, rowCount) {\n if (startRow % blockSize !== 0) {\n throw new Error(\n `createBlockCache.applyRows: startRow ${startRow} is not a multiple of blockSize ${blockSize}`,\n )\n }\n const firstBlock = blockOf(startRow)\n const blockCount = Math.ceil(rows.length / blockSize)\n for (let i = 0; i < blockCount; i += 1) {\n const index = firstBlock + i\n blocks.get(index)?.controller?.abort() // ours now, whatever was in flight\n blocks.set(index, {\n status: 'loaded',\n rows: rows.slice(i * blockSize, (i + 1) * blockSize),\n lastTouched: (touchClock += 1),\n controller: null,\n })\n }\n const lastBlock = blocks.get(firstBlock + blockCount - 1)\n if (lastBlock) applyCountFrom(firstBlock + blockCount - 1, lastBlock.rows.length, rowCount)\n else if (typeof rowCount === 'number' && rowCount >= 0) {\n count = rowCount\n countKnown = true\n }\n emit()\n },\n\n patch(index, row) {\n const block = blocks.get(blockOf(index))\n if (!block || block.status !== 'loaded') return false\n const offset = index - startOf(blockOf(index))\n if (offset < 0 || offset >= block.rows.length) return false\n block.rows[offset] = row\n emit()\n return true\n },\n\n findIndex(predicate) {\n for (const [blockIndex, block] of blocks) {\n if (block.status !== 'loaded') continue\n const start = startOf(blockIndex)\n for (let i = 0; i < block.rows.length; i += 1) {\n if (predicate(block.rows[i]!, start + i)) return start + i\n }\n }\n return -1\n },\n\n insert(index, rows) {\n if (rows.length === 0) return false\n const clamped = Math.max(0, Math.min(index, currentLength()))\n const run = loadedRunAt(clamped)\n if (run) {\n // Rows shift across block boundaries, so re-slice the run from its\n // first block; everything loaded past it is dropped (offsets moved).\n run.flat.splice(clamped - startOf(run.startBlock), 0, ...rows)\n reseat(run.startBlock, run.flat)\n } else {\n dropLoadedFrom(blockOf(clamped))\n }\n if (countKnown && count != null) count += rows.length\n emit()\n return run != null\n },\n\n remove(index, removeCount = 1) {\n if (removeCount <= 0) return 0\n const run = loadedRunAt(index)\n if (!run) return 0\n const removed = run.flat.splice(index - startOf(run.startBlock), removeCount).length\n reseat(run.startBlock, run.flat)\n if (countKnown && count != null) count = Math.max(0, count - removed)\n emit()\n return removed\n },\n\n getCacheState() {\n return [...blocks.entries()]\n .map(([blockIndex, block]) => ({\n blockIndex,\n startRow: startOf(blockIndex),\n endRow: startOf(blockIndex) + blockSize,\n status: block.status,\n lastTouched: block.lastTouched,\n }))\n .sort((a, b) => a.blockIndex - b.blockIndex)\n },\n\n dispose() {\n disposed = true\n if (debounceTimer) clearTimeout(debounceTimer)\n debounceTimer = null\n abortAll()\n blocks.clear()\n },\n }\n\n /**\n * The run of consecutive loaded blocks containing `index`, flattened, with\n * the block it starts at. Null when the block at `index` is not loaded -\n * an index-shifting edit there would have nothing to shift.\n */\n function loadedRunAt(index: number): { startBlock: number; flat: TData[] } | null {\n const at = blockOf(index)\n if (blocks.get(at)?.status !== 'loaded') return null\n let startBlock = at\n while (blocks.get(startBlock - 1)?.status === 'loaded') startBlock -= 1\n const flat: TData[] = []\n for (let i = startBlock; ; i += 1) {\n const block = blocks.get(i)\n if (!block || block.status !== 'loaded') break\n flat.push(...block.rows)\n if (block.rows.length < blockSize) break // the last block, by definition\n }\n return { startBlock, flat }\n }\n\n /**\n * Lay a flat run back out into blocks from `startBlock`, and drop every\n * block after the run: their rows have shifted, and re-fetching them at\n * the new offsets is the honest outcome. Blocks before it are untouched.\n */\n function reseat(startBlock: number, flat: TData[]): void {\n const blockCount = Math.ceil(flat.length / blockSize)\n dropLoadedFrom(startBlock)\n for (let i = 0; i < blockCount; i += 1) {\n blocks.set(startBlock + i, {\n status: 'loaded',\n rows: flat.slice(i * blockSize, (i + 1) * blockSize),\n lastTouched: (touchClock += 1),\n controller: null,\n })\n }\n rowsCache = windowedCache = null\n }\n\n /** Forget every block from `fromBlock` on, aborting any in flight. */\n function dropLoadedFrom(fromBlock: number): void {\n for (const [index, block] of [...blocks]) {\n if (index < fromBlock) continue\n block.controller?.abort()\n blocks.delete(index)\n }\n rowsCache = windowedCache = null\n }\n}\n```\n"
|
|
6839
6839
|
},
|
|
6840
6840
|
{
|
|
6841
6841
|
"slug": "reference/auto/svgrid-grid-server-data-source",
|
|
6842
6842
|
"path": "docs/reference/auto/svgrid-grid-server-data-source.md",
|
|
6843
6843
|
"title": "@svgrid/grid · server-data-source.ts",
|
|
6844
6844
|
"section": "API reference",
|
|
6845
|
-
"markdown": "# `@svgrid/grid` · `server-data-source.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\server-data-source.ts`.\n\n### `type ServerSortModel`\n\nSort clauses in priority order; `id` is the column id.\n\n```ts\nexport type ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\n### `type ServerFilterModel`\n\nWhat a request carries for filtering: the global search, the per-column\noperator filters, and the advanced-filter expression.\n\n```ts\nexport type ServerFilterModel = {\n /** Free-text global search. */\n global?: string\n /**\n * Per-column filters, keyed by column id. `value` (+ `valueTo`) carry the\n * operator-style filter; `selectedValues` carries a facet/checklist\n * selection (set-filter). Either or both may be present.\n */\n columns?: Record<\n string,\n { operator: string; value: string; valueTo?: string; selectedValues?: string[] }\n >\n /**\n * Advanced-filter predicate (Pro), as a JSON AST. Expresses what `columns`\n * cannot: OR across columns, nesting, negation, two conditions on one\n * column, cross-column comparison, and aggregates.\n *\n * CONTRACT - all or nothing. A backend that receives this MUST either:\n *\n * (a) translate the WHOLE expression into its query, make `rowCount`\n * reflect it, and set `appliedExpression: true` on the result; or\n * (b) apply none of it and leave `appliedExpression` unset.\n *\n * Partial application is a contract violation, not a degraded mode: it\n * returns a SUPERSET of the requested rows while the UI says the filter is\n * on. That is strictly worse than not filtering, because nothing about the\n * result looks wrong. When the ack is missing the grid says so rather than\n * filtering the loaded page itself - see `ServerState.expressionUnapplied`.\n */\n expression?: GridPredicateExpr\n}\n```\n\n### `type ServerAggFn`\n\nThe aggregate functions every backend helper understands.\n\n```ts\nexport type ServerAggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'\n```\n\n### `type ServerAggregation`\n\nOne value column to roll up per group. `fn` is one of the five built-ins\nor any name your backend knows (`median`, `countDistinct`, a domain\naggregate): the request carries it untouched. The SvelteKit planner emits\nonly the built-ins plus the names it was told to allow, since `fn` ends\nup in SQL.\n\n```ts\nexport type ServerAggregation = { col: string; fn: ServerAggFn | (string & {}) }\n```\n\n### `type ServerRequest`\n\nOne range of rows as the grid asks for it. A flat request carries the\nrange, the sort and the filter; a grouped request adds `groupBy`,\n`groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.\n\n```ts\nexport type ServerRequest = {\n /** Zero-based index of the first row wanted (inclusive). */\n startRow: number\n /** Index just past the last row wanted (exclusive). */\n endRow: number\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /**\n * Server-side grouping / tree: the columns being grouped on, outer to inner.\n * Omitted / empty for a flat request.\n */\n groupBy?: string[]\n /**\n * The path of group keys the grid is expanding, e.g. `['Germany', 'Berlin']`.\n * Empty (`[]`) asks for the top level. When `groupKeys.length < groupBy.length`\n * the server returns **group rows** (one per distinct key at this level,\n * carrying the group key + aggregates); when they are equal it returns the\n * **leaf rows** under that path.\n */\n groupKeys?: string[]\n /** Value columns to aggregate per group. */\n aggregations?: ServerAggregation[]\n /**\n * Server-side pivot: the columns whose distinct values become columns.\n * With `pivotMode` on, a group row carries one value per (pivot key x\n * aggregation) under a field named `<key>_<col>` (the separator is the\n * row model's `pivotFieldSeparator`), and the response lists those\n * fields in `pivotResultFields`. Only the Enterprise row model sets it.\n */\n pivotBy?: string[]\n pivotMode?: boolean\n /**\n * True on a top-level request when the grid wants a grand-total row and\n * does not have one cached. Answer with `ServerResult.grandTotal`. Only\n * the Enterprise server row model sets it.\n */\n needsGrandTotal?: boolean\n /**\n * The group (or tree node) row being expanded, when this request is for\n * its children. Handy for backends that key children off something on\n * the parent rather than off `groupKeys`. Absent at the top level.\n */\n parentRow?: unknown\n /**\n * Whatever the app passed as `context` to its controller, forwarded\n * untouched on every request. Keep it JSON-serialisable: the SvelteKit\n * transport posts the whole request to your endpoint.\n */\n context?: unknown\n /**\n * Aborted when the grid no longer wants the answer: the block was purged\n * or evicted, the sort or filter changed, a group was collapsed, the\n * controller was disposed. Hand it to `fetch` and a query the user has\n * scrolled past is cancelled at the server instead of completing for\n * nothing; a rejection carrying the abort is ignored by the grid. Not\n * serialisable - the SvelteKit transport reads it and leaves it out of\n * the body.\n */\n signal?: AbortSignal\n}\n```\n\n### `type ServerGroupRow`\n\nA group row in the server-side group/tree model - one distinct key at a\nlevel, with its rolled-up aggregates. The grid renders it with an expander;\nexpanding it fetches its children through the same `getRows`.\n\n```ts\nexport type ServerGroupRow<TData> = {\n kind: 'group'\n /** Stable id (the group path). */\n id: string\n /** Group keys from the root to this node, e.g. `['Germany', 'Berlin']`. */\n path: string[]\n /** The column this group is on (the `groupBy` entry for this level). */\n field: string\n /** This group's key value. */\n key: string\n /** Zero-based depth (0 = top level). */\n level: number\n expanded: boolean\n loading: boolean\n /** Aggregate values keyed by column id, read from the group's response row. */\n aggregates: Record<string, unknown>\n /**\n * How many rows this group holds, when the backend said (see the row\n * model's `childCount` option). Drawn next to the key by `SvGroupCell`,\n * and used to size the group's scrollbar before its first block lands.\n */\n childCount?: number\n /**\n * `false` when nothing can open beneath this row: the innermost group\n * level under a server-side pivot, whose rows are the result itself.\n * The group cell then draws no expander.\n */\n expandable?: boolean\n /** The raw response row for this group (key + aggregates), for cell rendering. */\n data: TData\n}\n```\n\n### `type ServerLeafRow`\n\nA data row in the display list.\n\n```ts\nexport type ServerLeafRow<TData> = {\n kind: 'leaf'\n id: string\n level: number\n /** The group path this leaf sits under. Set by the block-cached row model. */\n route?: string[]\n data: TData\n}\n```\n\n### `type ServerMoreRow`\n\nA \"load more\" affordance emitted at the end of a group whose children are\nonly partially loaded (intra-group paging). Trigger `loadMoreChildren(path)`\nto fetch the next block.\n\n```ts\nexport type ServerMoreRow = {\n kind: 'more'\n id: string\n level: number\n /** Path of the parent group whose children to load more of. */\n path: string[]\n /** How many children remain unloaded. */\n remaining: number\n loading: boolean\n}\n```\n\n### `type ServerFooterRow`\n\nA subtotal / footer row emitted after an expanded group's children when\n`groupFooters` is on. Carries the group's aggregates a second time so a\n\"Total\" line sits under the detail.\n\n```ts\nexport type ServerFooterRow<TData> = {\n kind: 'footer'\n id: string\n level: number\n path: string[]\n /** The group this footer totals (its key). */\n key: string\n aggregates: Record<string, unknown>\n /** The group's response row (key + aggregates), so value columns show totals. */\n data: TData\n}\n```\n\n### `type ServerSkeletonRow`\n\nA placeholder row shown while a block of children is being fetched.\n\n```ts\nexport type ServerSkeletonRow = {\n kind: 'skeleton'\n id: string\n level: number\n}\n```\n\n### `type ServerGrandTotalRow`\n\nA grand-total row across the whole result. One per grid, at the top or\nthe bottom, with the fixed id `sv-grand-total` so a transaction can\naddress it.\n\n```ts\nexport type ServerGrandTotalRow<TData> = {\n kind: 'grandTotal'\n id: 'sv-grand-total'\n level: 0\n aggregates: Record<string, unknown>\n data: TData\n}\n```\n\n### `type ServerPlaceholderRow`\n\nA row whose data has not arrived: `loading` while its block is in\nflight, `failed` when the fetch rejected. Shared, frozen objects - one\nper state, not one per row - so a million unloaded rows cost nothing to\nrepresent. They also carry the grid's placeholder mark, so\n`rowPlaceholderState()` recognises them and the grid draws them itself.\n\n```ts\nexport type ServerPlaceholderRow = {\n readonly kind: 'placeholder'\n readonly state: 'loading' | 'failed'\n}\n```\n\n### `type ServerDetailRow`\n\nThe detail panel under a leaf whose detail is open: master-detail on a\nserver-side row model. The grid draws it through `renderDetailRow`\n(mark it with `isDetailRow`); `master` is the leaf it belongs to.\n\n```ts\nexport type ServerDetailRow<TData> = {\n kind: 'detail'\n id: string\n level: number\n route?: string[]\n /** The leaf's id, as `getRowId` names it. */\n masterId: string\n master: TData\n}\n```\n\n### `type ServerDisplayRow`\n\nEvery row a server row model can put on screen. Each carries an `id` and\na `level`. The block-cached model adds {@link ServerPlaceholderRow} for\nrows not yet loaded - see its own `ServerRowModelDisplayRow`.\n\n```ts\nexport type ServerDisplayRow<TData> =\n | ServerGroupRow<TData>\n | ServerLeafRow<TData>\n | ServerDetailRow<TData>\n | ServerMoreRow\n | ServerFooterRow<TData>\n | ServerSkeletonRow\n | ServerGrandTotalRow<TData>\n```\n\n### `type ServerResult`\n\nWhat `getRows` answers with: the rows for the requested range and the\ncount after filtering, plus the grand total and the pivot fields when\nasked for.\n\n```ts\nexport type ServerResult<TData> = {\n rows: ReadonlyArray<TData>\n /**\n * Total row count after filtering, for the pager and the scrollbar.\n *\n * Paging needs it. Infinite scrolling does not: send `-1` (or, from a\n * source typed loosely, omit it) when counting is expensive, and the grid\n * discovers the end from the first short block instead. See\n * `mode: 'infinite'` on {@link ServerControllerOptions}.\n */\n rowCount: number\n /**\n * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it\n * unset if you ignored the expression; the grid then warns rather than\n * pretending the filter ran. See the contract on `ServerFilterModel.expression`.\n */\n appliedExpression?: boolean\n /**\n * The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an\n * object sets it, `null` removes it, and leaving it out keeps whatever the\n * grid already had. Shaped like a group row: the aggregate values live\n * under their column ids.\n */\n grandTotal?: TData | null\n /**\n * Pivot mode: the fields the group rows carry for the pivoted values,\n * e.g. `[\"2024_amount\", \"2025_amount\"]`, from which the grid builds one\n * column per field under a header group per pivot key. Send the full\n * list on every pivoted response; the grid keeps the union. Every group\n * row carries every listed field, `null` where no rows fall in that\n * cell.\n */\n pivotResultFields?: string[]\n /**\n * Pivot mode, the long way: full column definitions instead of field\n * names, for a backend that wants to name and format them itself. Wins\n * over `pivotResultFields` when both are present.\n */\n pivotResultColumns?: ReadonlyArray<unknown>\n}\n```\n\n### `type ServerSelectionRule`\n\nA selection expressed as a rule rather than a list, for `updateWhere`.\nThe flat shape is \"these ids\" or \"everything except these\"; the nested\nshape is the same idea per group, keyed by group key or leaf id, as the\nEnterprise row model keeps it under `groupSelects: 'descendants'`.\n\n```ts\nexport type ServerSelectionRule =\n | { selectAll: boolean; toggled: string[] }\n | {\n selectAllChildren: boolean\n toggled: Record<string, { selectAllChildren: boolean; toggled: Record<string, unknown>; group?: boolean }>\n group?: boolean\n /** The group columns the tree's levels are keyed by, outer to inner. */\n groupBy?: string[]\n }\n```\n\n### `type ServerDataSource`\n\nThe contract a backend implements: `getRows`, and optionally the write\nmethods, a bulk edit by rule and `destroy`.\n\n```ts\nexport type ServerDataSource<TData> = {\n getRows(request: ServerRequest): Promise<ServerResult<TData>>\n /**\n * Optional write side. Implement whichever your backend supports; the\n * controller exposes matching `createRow` / `updateRow` / `deleteRow`\n * methods that call through and then `refresh()` the current page.\n * Calling a controller method whose source counterpart is missing throws.\n */\n createRow?(input: Partial<TData>): Promise<TData>\n updateRow?(id: string, patch: Partial<TData>): Promise<TData>\n deleteRow?(id: string): Promise<void>\n /**\n * Apply one patch to every row a selection RULE names, server-side - the\n * write behind a bulk edit of rows the grid never loaded. `filterModel`\n * scopes the rows exactly as `getRows` does; `selection` is the rule:\n * either \"these ids\" (`selectAll: false`) or \"everything but these\"\n * (`selectAll: true`), or the per-group tree the Enterprise row model\n * keeps under `groupSelects: 'descendants'`. Resolve with how many rows\n * changed. Optional: without it, a bulk edit under select-all is refused\n * rather than silently applied to the loaded rows only.\n */\n updateWhere?(\n filterModel: ServerFilterModel,\n patch: Partial<TData>,\n selection: ServerSelectionRule,\n ): Promise<number>\n /**\n * Called once, from the controller's `dispose()`, for a source with\n * something to close: a socket, a subscription, a worker.\n */\n destroy?(): void\n}\n```\n\n### `type ServerState`\n\nWhat `createServerDataSource` emits on every change: the rows on hand,\nthe counts, the loading and saving flags, the last error, and the\ncurrent sort and filter.\n\n```ts\nexport type ServerState<TData> = {\n rows: ReadonlyArray<TData>\n total: number\n loading: boolean\n /** True while a create / update / delete mutation is in flight. */\n saving: boolean\n error: unknown\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /**\n * True when an advanced-filter expression was sent but the source did not\n * acknowledge applying it - so `rows` is unfiltered and the UI should say so.\n * Surface this rather than hiding it: the rows look perfectly normal.\n *\n * Optional so existing code that builds a `ServerState` literal keeps\n * compiling; the controller always sets it.\n */\n expressionUnapplied?: boolean\n /**\n * Rows after filtering, or `null` when the backend has not said and the\n * end has not been found yet. Only ever null in `infinite` mode; `total`\n * carries the best current guess either way.\n */\n rowCount?: number | null\n /** False while the end of an infinite list is still being discovered. */\n lastRowKnown?: boolean\n /** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */\n failedBlocks?: number[]\n}\n```\n\n### `type ServerController`\n\nEverything a controller can do, plus the {@link GridRowModel} surface, so\none object can be driven by hand OR handed to `<SvGrid rowModel>` and wire\nitself up.\n\n```ts\nexport type ServerController<TData> = GridRowModel<TData> & {\n /** Re-fetch the current page (e.g. after a mutation). */\n refresh(): void\n /**\n * The rows on screen, so `infinite` mode knows which blocks to fetch and\n * which to spare from eviction. Wire it to the grid's\n * `onVisibleRangeChange` - or pass the controller as `rowModel` and the\n * grid wires it for you. No-op in `page` mode.\n */\n setViewport(startIndex: number, endIndex: number): void\n /** Re-fetch the blocks that failed. No-op in `page` mode. */\n retryLoads(): void\n /**\n * Throw away every cached block and reload from the current viewport.\n * `refresh()` is the gentler option: it keeps the row count and the scroll\n * position. No-op in `page` mode, where there is only ever one page held.\n */\n purge(): void\n /** Cached blocks and their state, for logging and tests. Empty in `page` mode. */\n getCacheState(): BlockState[]\n setSort(sortModel: ServerSortModel): void\n /**\n * Replace the filter. Takes the `ServerFilterModel` a request carries, or\n * the payload the grid's own `onFiltersChange` hands you - which is a\n * list of columns rather than a map, and is converted here so that every\n * app does not write the same `Object.fromEntries` by hand.\n */\n setFilter(filterModel: ServerFilterModel | GridFilterState): void\n setPage(pageIndex: number): void\n setPageSize(pageSize: number): void\n /**\n * Create a row through the source, then refresh the current page. Resolves\n * with the created row. Rejects if the source has no `createRow` (or if the\n * create itself fails - the read state is left untouched on failure).\n */\n createRow(input: Partial<TData>): Promise<TData>\n /**\n * Update a row by id through the source. Non-optimistic: refreshes the page.\n * Optimistic (see `optimistic` + `getRowId` options): patches the local row\n * immediately, then reconciles with the server result, rolling back on error.\n */\n updateRow(id: string, patch: Partial<TData>): Promise<TData>\n /**\n * Delete a row by id through the source. Non-optimistic: refreshes the page.\n * Optimistic: removes the local row immediately, restoring it on error.\n */\n deleteRow(id: string): Promise<void>\n getState(): ServerState<TData>\n /** Stop accepting in-flight responses (call on unmount). */\n dispose(): void\n}\n```\n\n### `type ServerControllerOptions`\n\nOptions for `createServerDataSource`: the page size, or the block\nsettings in `infinite` mode, optimistic writes and the change callback.\n\n```ts\nexport type ServerControllerOptions<TData> = {\n pageSize?: number\n /** Called whenever any of `rows` / `total` / `loading` / page changes. */\n onChange?: (state: ServerState<TData>) => void\n /**\n * Apply `updateRow` / `deleteRow` to the local rows immediately (before the\n * server confirms) and roll back on error - so edits feel instant and no\n * refetch is needed. Requires `getRowId` to locate rows; ignored without it.\n * A subsequent `refresh()` reconciles ordering/filtering. Default false.\n */\n optimistic?: boolean\n /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */\n getRowId?: (row: TData) => string\n /**\n * How rows reach the grid.\n *\n * - `page` (default): one page at a time. `state.rows` is that page, and\n * `setPage` moves between them.\n * - `infinite`: one long scrollable list. `state.rows` spans the whole\n * result, with placeholder rows standing in for blocks nobody has\n * scrolled to yet; blocks load as the viewport reaches them.\n */\n mode?: 'page' | 'infinite'\n /** `infinite` mode: rows per request. Default 100. */\n blockSize?: number\n /**\n * `infinite` mode: keep at most this many loaded blocks, evicting the\n * least recently seen. Unlimited by default.\n */\n maxBlocksInCache?: number\n /** `infinite` mode: requests open at once. Default 2. */\n maxConcurrentRequests?: number\n /** `infinite` mode: wait for the scroll to settle this long before fetching. */\n blockLoadDebounceMs?: number\n /**\n * `infinite` mode: rows to claim before anything has loaded, so there is a\n * scrollbar on first paint. Default 1.\n */\n initialRowCount?: number\n}\n```\n\n### `function createServerDataSource`\n\nThe free server row model over a `ServerDataSource`: one page at a time,\nor one block-cached list in `infinite` mode, with sort, filter, race\nsafety and writes. The result is a `GridRowModel`, so\n`<SvGrid rowModel={ctl} />` wires every seam.\n\n```ts\nexport function createServerDataSource<TData>(\n source: ServerDataSource<TData>,\n options: ServerControllerOptions<TData>,\n): ServerController<TData> {\n const state: ServerState<TData> = {\n rows: [],\n total: 0,\n loading: false,\n saving: false,\n error: null,\n pageIndex: 0,\n pageSize: options.pageSize ?? 50,\n pageCount: 1,\n sortModel: [],\n filterModel: {},\n expressionUnapplied: false,\n }\n\n const infinite = options.mode === 'infinite'\n // `onChange` is the one-callback API this controller shipped with;\n // `subscribe` is the many-listener one `GridRowModel` needs. Both fire\n // from `emit`, so a grid driven by `rowModel` and an app reading\n // `onChange` stay in step.\n const subscribers = new Set<() => void>()\n let cache: BlockCache<TData> | null = null\n\n // Monotonic request id: only the latest fetch is allowed to land, so a slow\n // response for an old sort/filter can't clobber a newer one.\n let requestSeq = 0\n /** The page fetch in flight; a newer one, or dispose, aborts it. */\n let pageAbort: AbortController | null = null\n let disposed = false\n // Once per controller: a misconfigured backend would otherwise log on every\n // page, scroll and filter change.\n let warnedExpressionUnapplied = false\n\n const emit = () => {\n state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))\n options.onChange?.({ ...state })\n for (const notify of subscribers) notify()\n }\n\n /**\n * Pull the block cache's view of the world into `state` and emit.\n *\n * `total` stays a plain number because the pager and the footer have\n * always read it as one; while the end is undiscovered it holds the\n * current optimistic length, which is exactly what the scrollbar needs.\n * `rowCount` is the honest answer, null and all.\n */\n function emitFromCache(): void {\n if (!cache || disposed) return\n const rows = cache.rows()\n state.rows = rows\n state.rowCount = cache.rowCount()\n state.lastRowKnown = cache.lastRowKnown()\n state.total = cache.rowCount() ?? rows.length\n emit()\n }\n\n function buildCache(): BlockCache<TData> {\n return createBlockCache<TData>({\n blockSize: options.blockSize ?? 100,\n maxBlocksInCache: options.maxBlocksInCache,\n maxConcurrentRequests: options.maxConcurrentRequests,\n blockLoadDebounceMs: options.blockLoadDebounceMs,\n initialRowCount: options.initialRowCount,\n fetch: async (startRow, endRow, signal) => {\n const result = await source.getRows({\n startRow,\n endRow,\n // A block is a page of its own size, so a backend that only knows\n // how to page still works unchanged.\n pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),\n pageSize: endRow - startRow,\n sortModel: state.sortModel,\n filterModel: state.filterModel,\n groupBy: [],\n groupKeys: [],\n aggregations: [],\n signal,\n })\n noteExpressionApplied(result)\n return { rows: result.rows, rowCount: result.rowCount }\n },\n onChange: (s) => {\n state.loading = s.loading\n state.failedBlocks = s.failedBlocks\n emitFromCache()\n },\n })\n }\n\n /** Reload from scratch: new sort, new filter, or an explicit purge. */\n function resetCache(): void {\n if (disposed) return\n cache?.dispose()\n cache = buildCache()\n cache.setViewport(viewStart, viewEnd)\n emitFromCache()\n }\n\n /**\n * An expression was sent but the backend did not acknowledge applying it,\n * so these rows are a SUPERSET of what was asked for. Say so instead of\n * filtering here: filtering one page would turn \"3 of 1,000,000 match\"\n * into a confident lie and make paging incoherent, since the next page\n * would re-filter a different slice.\n */\n function noteExpressionApplied(result: ServerResult<TData>): void {\n state.expressionUnapplied =\n state.filterModel.expression != null && result.appliedExpression !== true\n if (!state.expressionUnapplied || warnedExpressionUnapplied) return\n warnedExpressionUnapplied = true\n console.warn(\n '[svgrid] The data source was sent filterModel.expression but did not ' +\n 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +\n 'and the rows shown are unfiltered. Apply the whole expression and ' +\n 'acknowledge it, or clear the advanced filter. ' +\n 'See https://svgrid.com/docs/help/server/server-filtering',\n )\n }\n\n async function fetchPage() {\n if (disposed) return\n const id = ++requestSeq\n pageAbort?.abort()\n const controller = new AbortController()\n pageAbort = controller\n state.loading = true\n state.error = null\n emit()\n const startRow = state.pageIndex * state.pageSize\n try {\n const result = await source.getRows({\n startRow,\n endRow: startRow + state.pageSize,\n pageIndex: state.pageIndex,\n pageSize: state.pageSize,\n sortModel: state.sortModel,\n filterModel: state.filterModel,\n // Flat mode: no grouping. Server-side grouping and tree data live in\n // `createServerGroupModel` from @svgrid/enterprise, which fills these in.\n groupBy: [],\n groupKeys: [],\n aggregations: [],\n signal: controller.signal,\n })\n if (disposed || id !== requestSeq) return // stale\n state.rows = result.rows\n state.total = result.rowCount\n state.rowCount = result.rowCount\n state.lastRowKnown = true\n noteExpressionApplied(result)\n state.loading = false\n emit()\n } catch (err) {\n if (disposed || id !== requestSeq) return\n state.rows = []\n state.error = err\n state.loading = false\n emit()\n }\n }\n\n // Shared mutation lifecycle: flip `saving`, run the write, refresh the\n // current page on success, and always clear `saving`. A missing source\n // method (or a disposed controller) rejects before anything is emitted.\n function mutate<T>(name: string, thunk: (() => Promise<T>) | null): Promise<T> {\n if (disposed) {\n return Promise.reject(new Error('createServerDataSource: controller is disposed'))\n }\n if (!thunk) {\n return Promise.reject(\n new Error(`createServerDataSource: the datasource does not implement ${name}()`),\n )\n }\n state.saving = true\n emit()\n return (async () => {\n try {\n const result = await thunk()\n // Page mode re-reads the page. Infinite mode re-reads the blocks it\n // holds, in place: the list keeps its length and its scroll.\n if (infinite) cache?.refresh()\n else await fetchPage()\n return result\n } finally {\n state.saving = false\n emit()\n }\n })()\n }\n\n const optimistic = !!options.optimistic && !!options.getRowId\n const getRowId = options.getRowId\n\n // The last range the grid reported, so a sort / filter / purge can\n // re-request what the user is actually looking at rather than row 0.\n let viewStart = 0\n let viewEnd = 0\n\n // Optimistic update: patch the local row, reconcile with the server result,\n // roll back on error. Falls back to the plain refresh path when the row\n // isn't on the current page (nothing local to update).\n async function optimisticUpdate(\n id: string,\n patch: Partial<TData>,\n fn: (id: string, patch: Partial<TData>) => Promise<TData>,\n ): Promise<TData> {\n if (disposed) throw new Error('createServerDataSource: controller is disposed')\n if (infinite && cache) {\n // The cache owns the rows here; patch it, not a copy the next block\n // to land would overwrite.\n const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)\n if (at < 0) return mutate('updateRow', () => fn(id, patch))\n const prev = cache.getRow(at) as TData\n cache.patch(at, { ...prev, ...patch })\n state.saving = true\n emit()\n try {\n const result = await fn(id, patch)\n cache.patch(at, result)\n return result\n } catch (err) {\n cache.patch(at, prev)\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n const prevRows = state.rows\n const idx = prevRows.findIndex((r) => getRowId!(r) === id)\n if (idx < 0) return mutate('updateRow', () => fn(id, patch))\n\n state.rows = [...prevRows.slice(0, idx), { ...prevRows[idx]!, ...patch }, ...prevRows.slice(idx + 1)]\n state.saving = true\n emit()\n try {\n const result = await fn(id, patch)\n state.rows = state.rows.map((r) => (getRowId!(r) === id ? result : r))\n return result\n } catch (err) {\n state.rows = prevRows\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n\n // Optimistic delete: drop the local row + decrement total, restore on error.\n async function optimisticDelete(\n id: string,\n fn: (id: string) => Promise<void>,\n ): Promise<void> {\n if (disposed) throw new Error('createServerDataSource: controller is disposed')\n if (infinite && cache) {\n const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)\n if (at < 0) return mutate('deleteRow', () => fn(id))\n const prev = cache.getRow(at) as TData\n cache.remove(at, 1)\n state.saving = true\n emit()\n try {\n await fn(id)\n } catch (err) {\n cache.insert(at, [prev])\n throw err\n } finally {\n state.saving = false\n emit()\n }\n return\n }\n const prevRows = state.rows\n const prevTotal = state.total\n const next = prevRows.filter((r) => getRowId!(r) !== id)\n if (next.length === prevRows.length) return mutate('deleteRow', () => fn(id))\n\n state.rows = next\n state.total = Math.max(0, prevTotal - (prevRows.length - next.length))\n state.saving = true\n emit()\n try {\n await fn(id)\n } catch (err) {\n state.rows = prevRows\n state.total = prevTotal\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n\n // In infinite mode the first blocks are requested as soon as the grid\n // reports a viewport; `refresh()` is what arms the cache before that.\n if (infinite) cache = buildCache()\n\n return {\n refresh: () => {\n if (!infinite) return void fetchPage()\n // Keep the count and the scroll position, re-request what is held.\n cache?.refresh()\n },\n setViewport(startIndex, endIndex) {\n viewStart = startIndex\n viewEnd = endIndex\n cache?.setViewport(startIndex, endIndex)\n },\n retryLoads: () => cache?.retryFailed(),\n purge: () => cache?.purge(),\n getCacheState: () => cache?.getCacheState() ?? [],\n\n // --- GridRowModel -----------------------------------------------\n subscribe(onChange) {\n subscribers.add(onChange)\n return () => {\n subscribers.delete(onChange)\n }\n },\n getRows: () => state.rows,\n isLoading: () => state.loading,\n getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,\n // Only infinite mode has unloaded rows to stand in for.\n rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,\n retryRow: infinite ? (_row: TData, rowIndex: number) => cache?.retryFailedAt(rowIndex) : undefined,\n /**\n * A getter, not a snapshot: the grid re-reads this after every change\n * notification, and a frozen object would leave the pager on page 1\n * forever. Absent in infinite mode, where there are no pages.\n */\n get pagination() {\n if (infinite) return undefined\n return {\n pageIndex: state.pageIndex,\n pageSize: state.pageSize,\n rowCount: state.total,\n setPage: (pageIndex: number) => this.setPage(pageIndex),\n setPageSize: (pageSize: number) => this.setPageSize(pageSize),\n }\n },\n createRow: (input) =>\n mutate('createRow', source.createRow ? () => source.createRow!(input) : null),\n updateRow: (id, patch) => {\n if (!source.updateRow) return mutate('updateRow', null)\n const fn = source.updateRow\n return optimistic ? optimisticUpdate(id, patch, fn) : mutate('updateRow', () => fn(id, patch))\n },\n deleteRow: (id) => {\n if (!source.deleteRow) return mutate('deleteRow', null)\n const fn = source.deleteRow\n return optimistic ? optimisticDelete(id, fn) : mutate('deleteRow', () => fn(id))\n },\n setSort(sortModel) {\n state.sortModel = sortModel\n state.pageIndex = 0\n if (infinite) return resetCache()\n void fetchPage()\n },\n setFilter(filterModel) {\n state.filterModel = Array.isArray(filterModel.columns)\n ? {\n global: filterModel.global,\n columns: toServerFilterColumns(filterModel as GridFilterState),\n }\n : (filterModel as ServerFilterModel)\n state.pageIndex = 0\n if (infinite) return resetCache()\n void fetchPage()\n },\n setPage(pageIndex) {\n // There are no pages to move between when the whole result is one\n // scrollable list; the grid scrolls instead.\n if (infinite) return\n const clamped = Math.max(0, pageIndex)\n if (clamped === state.pageIndex) return\n state.pageIndex = clamped\n void fetchPage()\n },\n setPageSize(pageSize) {\n state.pageSize = Math.max(1, pageSize)\n state.pageIndex = 0\n if (infinite) return\n void fetchPage()\n },\n getState: () => ({ ...state }),\n dispose() {\n disposed = true\n pageAbort?.abort()\n pageAbort = null\n cache?.dispose()\n cache = null\n // An in-flight fetch's resolution short-circuits on `disposed`, so it\n // never clears `loading`. Clear it here (and emit) so a disposed\n // controller doesn't report a permanent loading state.\n if (state.loading) {\n state.loading = false\n emit()\n }\n },\n }\n}\n```\n"
|
|
6845
|
+
"markdown": "# `@svgrid/grid` · `server-data-source.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\server-data-source.ts`.\n\n### `type ServerSortModel`\n\nSort clauses in priority order; `id` is the column id.\n\n```ts\nexport type ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\n### `type ServerFilterModel`\n\nWhat a request carries for filtering: the global search, the per-column\noperator filters, and the advanced-filter expression.\n\n```ts\nexport type ServerFilterModel = {\n /** Free-text global search. */\n global?: string\n /**\n * Per-column filters, keyed by column id. `value` (+ `valueTo`) carry the\n * operator-style filter; `selectedValues` carries a facet/checklist\n * selection (set-filter). Either or both may be present.\n */\n columns?: Record<\n string,\n { operator: string; value: string; valueTo?: string; selectedValues?: string[] }\n >\n /**\n * Advanced-filter predicate (Pro), as a JSON AST. Expresses what `columns`\n * cannot: OR across columns, nesting, negation, two conditions on one\n * column, cross-column comparison, and aggregates.\n *\n * CONTRACT - all or nothing. A backend that receives this MUST either:\n *\n * (a) translate the WHOLE expression into its query, make `rowCount`\n * reflect it, and set `appliedExpression: true` on the result; or\n * (b) apply none of it and leave `appliedExpression` unset.\n *\n * Partial application is a contract violation, not a degraded mode: it\n * returns a SUPERSET of the requested rows while the UI says the filter is\n * on. That is strictly worse than not filtering, because nothing about the\n * result looks wrong. When the ack is missing the grid says so rather than\n * filtering the loaded page itself - see `ServerState.expressionUnapplied`.\n */\n expression?: GridPredicateExpr\n}\n```\n\n### `type ServerAggFn`\n\nThe aggregate functions every backend helper understands.\n\n```ts\nexport type ServerAggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'\n```\n\n### `type ServerAggregation`\n\nOne value column to roll up per group. `fn` is one of the five built-ins\nor any name your backend knows (`median`, `countDistinct`, a domain\naggregate): the request carries it untouched. The SvelteKit planner emits\nonly the built-ins plus the names it was told to allow, since `fn` ends\nup in SQL.\n\n```ts\nexport type ServerAggregation = { col: string; fn: ServerAggFn | (string & {}) }\n```\n\n### `type ServerRequest`\n\nOne range of rows as the grid asks for it. A flat request carries the\nrange, the sort and the filter; a grouped request adds `groupBy`,\n`groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.\n\n```ts\nexport type ServerRequest = {\n /** Zero-based index of the first row wanted (inclusive). */\n startRow: number\n /** Index just past the last row wanted (exclusive). */\n endRow: number\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /**\n * Server-side grouping / tree: the columns being grouped on, outer to inner.\n * Omitted / empty for a flat request.\n */\n groupBy?: string[]\n /**\n * The path of group keys the grid is expanding, e.g. `['Germany', 'Berlin']`.\n * Empty (`[]`) asks for the top level. When `groupKeys.length < groupBy.length`\n * the server returns **group rows** (one per distinct key at this level,\n * carrying the group key + aggregates); when they are equal it returns the\n * **leaf rows** under that path.\n */\n groupKeys?: string[]\n /** Value columns to aggregate per group. */\n aggregations?: ServerAggregation[]\n /**\n * Server-side pivot: the columns whose distinct values become columns.\n * With `pivotMode` on, a group row carries one value per (pivot key x\n * aggregation) under a field named `<key>_<col>` (the separator is the\n * row model's `pivotFieldSeparator`), and the response lists those\n * fields in `pivotResultFields`. Only the Enterprise row model sets it.\n */\n pivotBy?: string[]\n pivotMode?: boolean\n /**\n * True on a top-level request when the grid wants a grand-total row and\n * does not have one cached. Answer with `ServerResult.grandTotal`. Only\n * the Enterprise server row model sets it.\n */\n needsGrandTotal?: boolean\n /**\n * The group (or tree node) row being expanded, when this request is for\n * its children. Handy for backends that key children off something on\n * the parent rather than off `groupKeys`. Absent at the top level.\n */\n parentRow?: unknown\n /**\n * Whatever the app passed as `context` to its controller, forwarded\n * untouched on every request. Keep it JSON-serialisable: the SvelteKit\n * transport posts the whole request to your endpoint.\n */\n context?: unknown\n /**\n * Aborted when the grid no longer wants the answer: the block was purged\n * or evicted, the sort or filter changed, a group was collapsed, the\n * controller was disposed. Hand it to `fetch` and a query the user has\n * scrolled past is cancelled at the server instead of completing for\n * nothing; a rejection carrying the abort is ignored by the grid. Not\n * serialisable - the SvelteKit transport reads it and leaves it out of\n * the body.\n */\n signal?: AbortSignal\n}\n```\n\n### `type ServerGroupRow`\n\nA group row in the server-side group/tree model - one distinct key at a\nlevel, with its rolled-up aggregates. The grid renders it with an expander;\nexpanding it fetches its children through the same `getRows`.\n\n```ts\nexport type ServerGroupRow<TData> = {\n kind: 'group'\n /** Stable id (the group path). */\n id: string\n /** Group keys from the root to this node, e.g. `['Germany', 'Berlin']`. */\n path: string[]\n /** The column this group is on (the `groupBy` entry for this level). */\n field: string\n /** This group's key value. */\n key: string\n /** Zero-based depth (0 = top level). */\n level: number\n expanded: boolean\n loading: boolean\n /** Aggregate values keyed by column id, read from the group's response row. */\n aggregates: Record<string, unknown>\n /**\n * How many rows this group holds, when the backend said (see the row\n * model's `childCount` option). Drawn next to the key by `SvGroupCell`,\n * and used to size the group's scrollbar before its first block lands.\n */\n childCount?: number\n /**\n * `false` when nothing can open beneath this row: the innermost group\n * level under a server-side pivot, whose rows are the result itself.\n * The group cell then draws no expander.\n */\n expandable?: boolean\n /** The raw response row for this group (key + aggregates), for cell rendering. */\n data: TData\n}\n```\n\n### `type ServerLeafRow`\n\nA data row in the display list.\n\n```ts\nexport type ServerLeafRow<TData> = {\n kind: 'leaf'\n id: string\n level: number\n /** The group path this leaf sits under. Set by the block-cached row model. */\n route?: string[]\n data: TData\n}\n```\n\n### `type ServerMoreRow`\n\nA \"load more\" affordance emitted at the end of a group whose children are\nonly partially loaded (intra-group paging). Trigger `loadMoreChildren(path)`\nto fetch the next block.\n\n```ts\nexport type ServerMoreRow = {\n kind: 'more'\n id: string\n level: number\n /** Path of the parent group whose children to load more of. */\n path: string[]\n /** How many children remain unloaded. */\n remaining: number\n loading: boolean\n}\n```\n\n### `type ServerFooterRow`\n\nA subtotal / footer row emitted after an expanded group's children when\n`groupFooters` is on. Carries the group's aggregates a second time so a\n\"Total\" line sits under the detail.\n\n```ts\nexport type ServerFooterRow<TData> = {\n kind: 'footer'\n id: string\n level: number\n path: string[]\n /** The group this footer totals (its key). */\n key: string\n aggregates: Record<string, unknown>\n /** The group's response row (key + aggregates), so value columns show totals. */\n data: TData\n}\n```\n\n### `type ServerSkeletonRow`\n\nA placeholder row shown while a block of children is being fetched.\n\n```ts\nexport type ServerSkeletonRow = {\n kind: 'skeleton'\n id: string\n level: number\n}\n```\n\n### `type ServerGrandTotalRow`\n\nA grand-total row across the whole result. One per grid, at the top or\nthe bottom, with the fixed id `sv-grand-total` so a transaction can\naddress it.\n\n```ts\nexport type ServerGrandTotalRow<TData> = {\n kind: 'grandTotal'\n id: 'sv-grand-total'\n level: 0\n aggregates: Record<string, unknown>\n data: TData\n}\n```\n\n### `type ServerPlaceholderRow`\n\nA row whose data has not arrived: `loading` while its block is in\nflight, `failed` when the fetch rejected. Shared, frozen objects - one\nper state, not one per row - so a million unloaded rows cost nothing to\nrepresent. They also carry the grid's placeholder mark, so\n`rowPlaceholderState()` recognises them and the grid draws them itself.\n\n```ts\nexport type ServerPlaceholderRow = {\n readonly kind: 'placeholder'\n readonly state: 'loading' | 'failed'\n}\n```\n\n### `type ServerDetailRow`\n\nThe detail panel under a leaf whose detail is open: master-detail on a\nserver-side row model. The grid draws it through `renderDetailRow`\n(mark it with `isDetailRow`); `master` is the leaf it belongs to.\n\n```ts\nexport type ServerDetailRow<TData> = {\n kind: 'detail'\n id: string\n level: number\n route?: string[]\n /** The leaf's id, as `getRowId` names it. */\n masterId: string\n master: TData\n}\n```\n\n### `type ServerDisplayRow`\n\nEvery row a server row model can put on screen. Each carries an `id` and\na `level`. The block-cached model adds {@link ServerPlaceholderRow} for\nrows not yet loaded - see its own `ServerRowModelDisplayRow`.\n\n```ts\nexport type ServerDisplayRow<TData> =\n | ServerGroupRow<TData>\n | ServerLeafRow<TData>\n | ServerDetailRow<TData>\n | ServerMoreRow\n | ServerFooterRow<TData>\n | ServerSkeletonRow\n | ServerGrandTotalRow<TData>\n```\n\n### `type ServerResult`\n\nWhat `getRows` answers with: the rows for the requested range and the\ncount after filtering, plus the grand total and the pivot fields when\nasked for.\n\n```ts\nexport type ServerResult<TData> = {\n rows: ReadonlyArray<TData>\n /**\n * Total row count after filtering, for the pager and the scrollbar.\n *\n * Paging needs it. Infinite scrolling does not: send `-1` (or, from a\n * source typed loosely, omit it) when counting is expensive, and the grid\n * discovers the end from the first short block instead. See\n * `mode: 'infinite'` on {@link ServerControllerOptions}.\n */\n rowCount: number\n /**\n * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it\n * unset if you ignored the expression; the grid then warns rather than\n * pretending the filter ran. See the contract on `ServerFilterModel.expression`.\n */\n appliedExpression?: boolean\n /**\n * The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an\n * object sets it, `null` removes it, and leaving it out keeps whatever the\n * grid already had. Shaped like a group row: the aggregate values live\n * under their column ids.\n */\n grandTotal?: TData | null\n /**\n * Pivot mode: the fields the group rows carry for the pivoted values,\n * e.g. `[\"2024_amount\", \"2025_amount\"]`, from which the grid builds one\n * column per field under a header group per pivot key. Send the full\n * list on every pivoted response; the grid keeps the union. Every group\n * row carries every listed field, `null` where no rows fall in that\n * cell.\n */\n pivotResultFields?: string[]\n /**\n * Pivot mode, the long way: full column definitions instead of field\n * names, for a backend that wants to name and format them itself. Wins\n * over `pivotResultFields` when both are present.\n */\n pivotResultColumns?: ReadonlyArray<unknown>\n}\n```\n\n### `type ServerSelectionRule`\n\nA selection expressed as a rule rather than a list, for `updateWhere`.\nThe flat shape is \"these ids\" or \"everything except these\"; the nested\nshape is the same idea per group, keyed by group key or leaf id, as the\nEnterprise row model keeps it under `groupSelects: 'descendants'`.\n\n```ts\nexport type ServerSelectionRule =\n | { selectAll: boolean; toggled: string[] }\n | {\n selectAllChildren: boolean\n toggled: Record<string, { selectAllChildren: boolean; toggled: Record<string, unknown>; group?: boolean }>\n group?: boolean\n /** The group columns the tree's levels are keyed by, outer to inner. */\n groupBy?: string[]\n }\n```\n\n### `type ServerDataSource`\n\nThe contract a backend implements: `getRows`, and optionally the write\nmethods, a bulk edit by rule and `destroy`.\n\n```ts\nexport type ServerDataSource<TData> = {\n getRows(request: ServerRequest): Promise<ServerResult<TData>>\n /**\n * Optional write side. Implement whichever your backend supports; the\n * controller exposes matching `createRow` / `updateRow` / `deleteRow`\n * methods that call through and then `refresh()` the current page.\n * Calling a controller method whose source counterpart is missing throws.\n */\n createRow?(input: Partial<TData>): Promise<TData>\n updateRow?(id: string, patch: Partial<TData>): Promise<TData>\n deleteRow?(id: string): Promise<void>\n /**\n * Apply one patch to every row a selection RULE names, server-side - the\n * write behind a bulk edit of rows the grid never loaded. `filterModel`\n * scopes the rows exactly as `getRows` does; `selection` is the rule:\n * either \"these ids\" (`selectAll: false`) or \"everything but these\"\n * (`selectAll: true`), or the per-group tree the Enterprise row model\n * keeps under `groupSelects: 'descendants'`. Resolve with how many rows\n * changed. Optional: without it, a bulk edit under select-all is refused\n * rather than silently applied to the loaded rows only.\n */\n updateWhere?(\n filterModel: ServerFilterModel,\n patch: Partial<TData>,\n selection: ServerSelectionRule,\n ): Promise<number>\n /**\n * Called once, from the controller's `dispose()`, for a source with\n * something to close: a socket, a subscription, a worker.\n */\n destroy?(): void\n}\n```\n\n### `type ServerState`\n\nWhat `createServerDataSource` emits on every change: the rows on hand,\nthe counts, the loading and saving flags, the last error, and the\ncurrent sort and filter.\n\n```ts\nexport type ServerState<TData> = {\n rows: ReadonlyArray<TData>\n total: number\n loading: boolean\n /** True while a create / update / delete mutation is in flight. */\n saving: boolean\n error: unknown\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n /**\n * True when an advanced-filter expression was sent but the source did not\n * acknowledge applying it - so `rows` is unfiltered and the UI should say so.\n * Surface this rather than hiding it: the rows look perfectly normal.\n *\n * Optional so existing code that builds a `ServerState` literal keeps\n * compiling; the controller always sets it.\n */\n expressionUnapplied?: boolean\n /**\n * Rows after filtering, or `null` when the backend has not said and the\n * end has not been found yet. Only ever null in `infinite` mode; `total`\n * carries the best current guess either way.\n */\n rowCount?: number | null\n /** False while the end of an infinite list is still being discovered. */\n lastRowKnown?: boolean\n /** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */\n failedBlocks?: number[]\n}\n```\n\n### `type ServerController`\n\nEverything a controller can do, plus the {@link GridRowModel} surface, so\none object can be driven by hand OR handed to `<SvGrid rowModel>` and wire\nitself up.\n\n```ts\nexport type ServerController<TData> = GridRowModel<TData> & {\n /** Re-fetch the current page (e.g. after a mutation). */\n refresh(): void\n /**\n * The rows on screen, so `infinite` mode knows which blocks to fetch and\n * which to spare from eviction. Wire it to the grid's\n * `onVisibleRangeChange` - or pass the controller as `rowModel` and the\n * grid wires it for you. No-op in `page` mode.\n */\n setViewport(startIndex: number, endIndex: number): void\n /** Re-fetch the blocks that failed. No-op in `page` mode. */\n retryLoads(): void\n /**\n * Throw away every cached block and reload from the current viewport.\n * `refresh()` is the gentler option: it keeps the row count and the scroll\n * position. No-op in `page` mode, where there is only ever one page held.\n */\n purge(): void\n /** Cached blocks and their state, for logging and tests. Empty in `page` mode. */\n getCacheState(): BlockState[]\n setSort(sortModel: ServerSortModel): void\n /**\n * Replace the filter. Takes the `ServerFilterModel` a request carries, or\n * the payload the grid's own `onFiltersChange` hands you - which is a\n * list of columns rather than a map, and is converted here so that every\n * app does not write the same `Object.fromEntries` by hand.\n */\n setFilter(filterModel: ServerFilterModel | GridFilterState): void\n setPage(pageIndex: number): void\n setPageSize(pageSize: number): void\n /**\n * Create a row through the source, then refresh the current page. Resolves\n * with the created row. Rejects if the source has no `createRow` (or if the\n * create itself fails - the read state is left untouched on failure).\n */\n createRow(input: Partial<TData>): Promise<TData>\n /**\n * Update a row by id through the source. Non-optimistic: refreshes the page.\n * Optimistic (see `optimistic` + `getRowId` options): patches the local row\n * immediately, then reconciles with the server result, rolling back on error.\n */\n updateRow(id: string, patch: Partial<TData>): Promise<TData>\n /**\n * Delete a row by id through the source. Non-optimistic: refreshes the page.\n * Optimistic: removes the local row immediately, restoring it on error.\n */\n deleteRow(id: string): Promise<void>\n getState(): ServerState<TData>\n /** Stop accepting in-flight responses (call on unmount). */\n dispose(): void\n}\n```\n\n### `type ServerControllerOptions`\n\nOptions for `createServerDataSource`: the page size, or the block\nsettings in `infinite` mode, optimistic writes and the change callback.\n\n```ts\nexport type ServerControllerOptions<TData> = {\n pageSize?: number\n /** Called whenever any of `rows` / `total` / `loading` / page changes. */\n onChange?: (state: ServerState<TData>) => void\n /**\n * Apply `updateRow` / `deleteRow` to the local rows immediately (before the\n * server confirms) and roll back on error - so edits feel instant and no\n * refetch is needed. Requires `getRowId` to locate rows; ignored without it.\n * A subsequent `refresh()` reconciles ordering/filtering. Default false.\n */\n optimistic?: boolean\n /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */\n getRowId?: (row: TData) => string\n /**\n * How rows reach the grid.\n *\n * - `page` (default): one page at a time. `state.rows` is that page, and\n * `setPage` moves between them.\n * - `infinite`: one long scrollable list. `state.rows` spans the whole\n * result, with placeholder rows standing in for blocks nobody has\n * scrolled to yet; blocks load as the viewport reaches them.\n */\n mode?: 'page' | 'infinite'\n /** `infinite` mode: rows per request. Default 100. */\n blockSize?: number\n /**\n * `infinite` mode: keep at most this many loaded blocks, evicting the\n * least recently seen. Unlimited by default.\n */\n maxBlocksInCache?: number\n /** `infinite` mode: requests open at once. Default 2. */\n maxConcurrentRequests?: number\n /** `infinite` mode: wait for the scroll to settle this long before fetching. */\n blockLoadDebounceMs?: number\n /**\n * `infinite` mode: rows to claim before anything has loaded, so there is a\n * scrollbar on first paint. Default 1.\n */\n initialRowCount?: number\n}\n```\n\n### `function createServerDataSource`\n\nThe free server row model over a `ServerDataSource`: one page at a time,\nor one block-cached list in `infinite` mode, with sort, filter, race\nsafety and writes. The result is a `GridRowModel`, so\n`<SvGrid rowModel={ctl} />` wires every seam.\n\n```ts\nexport function createServerDataSource<TData>(\n source: ServerDataSource<TData>,\n options: ServerControllerOptions<TData>,\n): ServerController<TData> {\n const state: ServerState<TData> = {\n rows: [],\n total: 0,\n loading: false,\n saving: false,\n error: null,\n pageIndex: 0,\n pageSize: options.pageSize ?? 50,\n pageCount: 1,\n sortModel: [],\n filterModel: {},\n expressionUnapplied: false,\n }\n\n const infinite = options.mode === 'infinite'\n // `onChange` is the one-callback API this controller shipped with;\n // `subscribe` is the many-listener one `GridRowModel` needs. Both fire\n // from `emit`, so a grid driven by `rowModel` and an app reading\n // `onChange` stay in step.\n const subscribers = new Set<() => void>()\n let cache: BlockCache<TData> | null = null\n\n // Monotonic request id: only the latest fetch is allowed to land, so a slow\n // response for an old sort/filter can't clobber a newer one.\n let requestSeq = 0\n /** The page fetch in flight; a newer one, or dispose, aborts it. */\n let pageAbort: AbortController | null = null\n let disposed = false\n // Once per controller: a misconfigured backend would otherwise log on every\n // page, scroll and filter change.\n let warnedExpressionUnapplied = false\n\n const emit = () => {\n state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))\n options.onChange?.({ ...state })\n for (const notify of subscribers) notify()\n }\n\n /**\n * Pull the block cache's view of the world into `state` and emit.\n *\n * `total` stays a plain number because the pager and the footer have\n * always read it as one; while the end is undiscovered it holds the\n * current optimistic length, which is exactly what the scrollbar needs.\n * `rowCount` is the honest answer, null and all.\n */\n function emitFromCache(): void {\n if (!cache || disposed) return\n // Windowed: the grid reads only the rows it shows, so a block landing\n // costs the same at 100M rows as at 10K.\n const rows = cache.windowedRows()\n state.rows = rows\n state.rowCount = cache.rowCount()\n state.lastRowKnown = cache.lastRowKnown()\n state.total = cache.rowCount() ?? rows.length\n emit()\n }\n\n function buildCache(): BlockCache<TData> {\n return createBlockCache<TData>({\n blockSize: options.blockSize ?? 100,\n maxBlocksInCache: options.maxBlocksInCache,\n maxConcurrentRequests: options.maxConcurrentRequests,\n blockLoadDebounceMs: options.blockLoadDebounceMs,\n initialRowCount: options.initialRowCount,\n fetch: async (startRow, endRow, signal) => {\n const result = await source.getRows({\n startRow,\n endRow,\n // A block is a page of its own size, so a backend that only knows\n // how to page still works unchanged.\n pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),\n pageSize: endRow - startRow,\n sortModel: state.sortModel,\n filterModel: state.filterModel,\n groupBy: [],\n groupKeys: [],\n aggregations: [],\n signal,\n })\n noteExpressionApplied(result)\n return { rows: result.rows, rowCount: result.rowCount }\n },\n onChange: (s) => {\n state.loading = s.loading\n state.failedBlocks = s.failedBlocks\n emitFromCache()\n },\n })\n }\n\n /** Reload from scratch: new sort, new filter, or an explicit purge. */\n function resetCache(): void {\n if (disposed) return\n cache?.dispose()\n cache = buildCache()\n cache.setViewport(viewStart, viewEnd)\n emitFromCache()\n }\n\n /**\n * An expression was sent but the backend did not acknowledge applying it,\n * so these rows are a SUPERSET of what was asked for. Say so instead of\n * filtering here: filtering one page would turn \"3 of 1,000,000 match\"\n * into a confident lie and make paging incoherent, since the next page\n * would re-filter a different slice.\n */\n function noteExpressionApplied(result: ServerResult<TData>): void {\n state.expressionUnapplied =\n state.filterModel.expression != null && result.appliedExpression !== true\n if (!state.expressionUnapplied || warnedExpressionUnapplied) return\n warnedExpressionUnapplied = true\n console.warn(\n '[svgrid] The data source was sent filterModel.expression but did not ' +\n 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +\n 'and the rows shown are unfiltered. Apply the whole expression and ' +\n 'acknowledge it, or clear the advanced filter. ' +\n 'See https://svgrid.com/docs/help/server/server-filtering',\n )\n }\n\n async function fetchPage() {\n if (disposed) return\n const id = ++requestSeq\n pageAbort?.abort()\n const controller = new AbortController()\n pageAbort = controller\n state.loading = true\n state.error = null\n emit()\n const startRow = state.pageIndex * state.pageSize\n try {\n const result = await source.getRows({\n startRow,\n endRow: startRow + state.pageSize,\n pageIndex: state.pageIndex,\n pageSize: state.pageSize,\n sortModel: state.sortModel,\n filterModel: state.filterModel,\n // Flat mode: no grouping. Server-side grouping and tree data live in\n // `createServerGroupModel` from @svgrid/enterprise, which fills these in.\n groupBy: [],\n groupKeys: [],\n aggregations: [],\n signal: controller.signal,\n })\n if (disposed || id !== requestSeq) return // stale\n state.rows = result.rows\n state.total = result.rowCount\n state.rowCount = result.rowCount\n state.lastRowKnown = true\n noteExpressionApplied(result)\n state.loading = false\n emit()\n } catch (err) {\n if (disposed || id !== requestSeq) return\n state.rows = []\n state.error = err\n state.loading = false\n emit()\n }\n }\n\n // Shared mutation lifecycle: flip `saving`, run the write, refresh the\n // current page on success, and always clear `saving`. A missing source\n // method (or a disposed controller) rejects before anything is emitted.\n function mutate<T>(name: string, thunk: (() => Promise<T>) | null): Promise<T> {\n if (disposed) {\n return Promise.reject(new Error('createServerDataSource: controller is disposed'))\n }\n if (!thunk) {\n return Promise.reject(\n new Error(`createServerDataSource: the datasource does not implement ${name}()`),\n )\n }\n state.saving = true\n emit()\n return (async () => {\n try {\n const result = await thunk()\n // Page mode re-reads the page. Infinite mode re-reads the blocks it\n // holds, in place: the list keeps its length and its scroll.\n if (infinite) cache?.refresh()\n else await fetchPage()\n return result\n } finally {\n state.saving = false\n emit()\n }\n })()\n }\n\n const optimistic = !!options.optimistic && !!options.getRowId\n const getRowId = options.getRowId\n\n // The last range the grid reported, so a sort / filter / purge can\n // re-request what the user is actually looking at rather than row 0.\n let viewStart = 0\n let viewEnd = 0\n\n // Optimistic update: patch the local row, reconcile with the server result,\n // roll back on error. Falls back to the plain refresh path when the row\n // isn't on the current page (nothing local to update).\n async function optimisticUpdate(\n id: string,\n patch: Partial<TData>,\n fn: (id: string, patch: Partial<TData>) => Promise<TData>,\n ): Promise<TData> {\n if (disposed) throw new Error('createServerDataSource: controller is disposed')\n if (infinite && cache) {\n // The cache owns the rows here; patch it, not a copy the next block\n // to land would overwrite.\n const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)\n if (at < 0) return mutate('updateRow', () => fn(id, patch))\n const prev = cache.getRow(at) as TData\n cache.patch(at, { ...prev, ...patch })\n state.saving = true\n emit()\n try {\n const result = await fn(id, patch)\n cache.patch(at, result)\n return result\n } catch (err) {\n cache.patch(at, prev)\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n const prevRows = state.rows\n const idx = prevRows.findIndex((r) => getRowId!(r) === id)\n if (idx < 0) return mutate('updateRow', () => fn(id, patch))\n\n state.rows = [...prevRows.slice(0, idx), { ...prevRows[idx]!, ...patch }, ...prevRows.slice(idx + 1)]\n state.saving = true\n emit()\n try {\n const result = await fn(id, patch)\n state.rows = state.rows.map((r) => (getRowId!(r) === id ? result : r))\n return result\n } catch (err) {\n state.rows = prevRows\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n\n // Optimistic delete: drop the local row + decrement total, restore on error.\n async function optimisticDelete(\n id: string,\n fn: (id: string) => Promise<void>,\n ): Promise<void> {\n if (disposed) throw new Error('createServerDataSource: controller is disposed')\n if (infinite && cache) {\n const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)\n if (at < 0) return mutate('deleteRow', () => fn(id))\n const prev = cache.getRow(at) as TData\n cache.remove(at, 1)\n state.saving = true\n emit()\n try {\n await fn(id)\n } catch (err) {\n cache.insert(at, [prev])\n throw err\n } finally {\n state.saving = false\n emit()\n }\n return\n }\n const prevRows = state.rows\n const prevTotal = state.total\n const next = prevRows.filter((r) => getRowId!(r) !== id)\n if (next.length === prevRows.length) return mutate('deleteRow', () => fn(id))\n\n state.rows = next\n state.total = Math.max(0, prevTotal - (prevRows.length - next.length))\n state.saving = true\n emit()\n try {\n await fn(id)\n } catch (err) {\n state.rows = prevRows\n state.total = prevTotal\n throw err\n } finally {\n state.saving = false\n emit()\n }\n }\n\n // In infinite mode the first blocks are requested as soon as the grid\n // reports a viewport; `refresh()` is what arms the cache before that.\n if (infinite) cache = buildCache()\n\n return {\n refresh: () => {\n if (!infinite) return void fetchPage()\n // Keep the count and the scroll position, re-request what is held.\n cache?.refresh()\n },\n setViewport(startIndex, endIndex) {\n viewStart = startIndex\n viewEnd = endIndex\n cache?.setViewport(startIndex, endIndex)\n },\n retryLoads: () => cache?.retryFailed(),\n purge: () => cache?.purge(),\n getCacheState: () => cache?.getCacheState() ?? [],\n\n // --- GridRowModel -----------------------------------------------\n subscribe(onChange) {\n subscribers.add(onChange)\n return () => {\n subscribers.delete(onChange)\n }\n },\n getRows: () => state.rows,\n isLoading: () => state.loading,\n getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,\n // Only infinite mode has unloaded rows to stand in for.\n rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,\n retryRow: infinite ? (_row: TData, rowIndex: number) => cache?.retryFailedAt(rowIndex) : undefined,\n /**\n * A getter, not a snapshot: the grid re-reads this after every change\n * notification, and a frozen object would leave the pager on page 1\n * forever. Absent in infinite mode, where there are no pages.\n */\n get pagination() {\n if (infinite) return undefined\n return {\n pageIndex: state.pageIndex,\n pageSize: state.pageSize,\n rowCount: state.total,\n setPage: (pageIndex: number) => this.setPage(pageIndex),\n setPageSize: (pageSize: number) => this.setPageSize(pageSize),\n }\n },\n createRow: (input) =>\n mutate('createRow', source.createRow ? () => source.createRow!(input) : null),\n updateRow: (id, patch) => {\n if (!source.updateRow) return mutate('updateRow', null)\n const fn = source.updateRow\n return optimistic ? optimisticUpdate(id, patch, fn) : mutate('updateRow', () => fn(id, patch))\n },\n deleteRow: (id) => {\n if (!source.deleteRow) return mutate('deleteRow', null)\n const fn = source.deleteRow\n return optimistic ? optimisticDelete(id, fn) : mutate('deleteRow', () => fn(id))\n },\n setSort(sortModel) {\n state.sortModel = sortModel\n state.pageIndex = 0\n if (infinite) return resetCache()\n void fetchPage()\n },\n setFilter(filterModel) {\n state.filterModel = Array.isArray(filterModel.columns)\n ? {\n global: filterModel.global,\n columns: toServerFilterColumns(filterModel as GridFilterState),\n }\n : (filterModel as ServerFilterModel)\n state.pageIndex = 0\n if (infinite) return resetCache()\n void fetchPage()\n },\n setPage(pageIndex) {\n // There are no pages to move between when the whole result is one\n // scrollable list; the grid scrolls instead.\n if (infinite) return\n const clamped = Math.max(0, pageIndex)\n if (clamped === state.pageIndex) return\n state.pageIndex = clamped\n void fetchPage()\n },\n setPageSize(pageSize) {\n state.pageSize = Math.max(1, pageSize)\n state.pageIndex = 0\n if (infinite) return\n void fetchPage()\n },\n getState: () => ({ ...state }),\n dispose() {\n disposed = true\n pageAbort?.abort()\n pageAbort = null\n cache?.dispose()\n cache = null\n // An in-flight fetch's resolution short-circuits on `disposed`, so it\n // never clears `loading`. Clear it here (and emit) so a disposed\n // controller doesn't report a permanent loading state.\n if (state.loading) {\n state.loading = false\n emit()\n }\n },\n }\n}\n```\n"
|
|
6846
6846
|
},
|
|
6847
6847
|
{
|
|
6848
6848
|
"slug": "reference/auto/svgrid-grid-spreadsheet",
|
|
@@ -6856,7 +6856,7 @@ export const docs = [
|
|
|
6856
6856
|
"path": "docs/reference/auto/svgrid-grid-svgrid-wrapper.types.md",
|
|
6857
6857
|
"title": "@svgrid/grid · svgrid-wrapper.types.ts",
|
|
6858
6858
|
"section": "API reference",
|
|
6859
|
-
"markdown": "# `@svgrid/grid` · `svgrid-wrapper.types.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\svgrid-wrapper.types.ts`.\n\n### `type SvGridFilterOperator`\n\nThe comparisons a column filter can use, as shown in the filter menu.\n\n```ts\nexport type SvGridFilterOperator = FilterOperator\n```\n\n### `type SvGridViewState`\n\nA serializable snapshot of everything that makes up the current \"view\":\nsort, grouping, pagination, column layout (width / pinning / order /\nvisibility), and all filter surfaces. Round-trippable through\n`api.getState()` / `api.setState()` - persist it to a URL, localStorage, or\na server to implement \"save view\" / \"named views\".\n\n```ts\nexport type SvGridViewState = {\n sorting: Array<{ id: string; desc: boolean }>\n grouping: string[]\n pagination: { pageIndex: number; pageSize: number }\n columnWidths: Record<string, number>\n columnPinning: { left: string[]; right: string[] }\n columnOrder: string[]\n /** Ids of columns currently hidden via setColumnVisible. */\n hiddenColumns: string[]\n globalFilter: string\n columnFilters: Record<\n string,\n { operator: SvGridFilterOperator; value: string; valueTo?: string }\n >\n /** Facet (Excel-style value checklist) selections, keyed by column id. */\n facetFilters: Record<string, string[]>\n /**\n * Advanced-filter expression (Pro). OPTIONAL, and omitted entirely when no\n * advanced filter is set - so views saved before this feature existed, and\n * views from grids that never use it, round-trip byte-identical. An explicit\n * `null` clears the filter on `setState`.\n */\n advancedFilter?: GridPredicateExpr | null\n /** Built-in chart panel state (present only when `charting` is on): the\n * ACTIVE chart, for back-compat + spread-and-tweak. */\n chart?: {\n open: boolean\n type: ChartType\n dimension: string | null\n series: string | null\n measure: string | null\n reduce: 'sum' | 'avg' | 'count'\n stacked: boolean\n }\n /** All charts in the tab strip (multi-chart), plus the active index.\n * Every field the panel can change is here so a saved view restores the\n * chart exactly; `null` inherits the `charting` config. */\n charts?: ChartTabSnapshot[]\n chartActive?: number\n /** The named charts kept with `api.saveChart`; present only when there is one. */\n savedCharts?: SavedChart[]\n}\n```\n\n### `type ChartTabSnapshot`\n\nOne chart tab as the saved view carries it (`GridState.charts[i]`).\n\n```ts\nexport type ChartTabSnapshot = {\n title: string\n type: ChartType\n dimension: string | null\n series: string | null\n measure: string | null\n /** Scatter's y measure, a range's high, a bullet's target. */\n measure2?: string | null\n reduce: ChartReducer\n stacked: boolean | null\n stacked100?: boolean | null\n orientation?: 'vertical' | 'horizontal' | null\n donut?: boolean | null\n palette?: string[] | null\n dataLabels: boolean | null\n logScale: boolean | null\n timeAxis: boolean | null\n valueFormat: 'number' | 'currency' | 'percent' | 'compact' | null\n bucket?: ChartTimeBucket | null\n bins?: number | null\n funnelShape?: 'trapezoid' | 'pyramid' | 'cone' | null\n candleStyle?: 'classic' | 'hollow' | 'heikin-ashi' | null\n /** The zoom window as category indices, or null for the whole chart. */\n zoom?: ChartZoomWindow | null\n ohlc?: { open: string | null; high: string | null; low: string | null; close: string | null; volume: string | null } | null\n indicators?: string[]\n format?: ChartFormatState | null\n /** An unlinked chart carries the spec it was frozen with. */\n frozen?: { spec: ChartSpec; at: string } | null\n}\n```\n\n### `type SavedChart`\n\nA chart configuration kept under a name (`api.saveChart`).\n\n```ts\nexport type SavedChart = {\n name: string\n /** ISO timestamp of the save. */\n savedAt: string\n /** The tab's state at the time; `applySavedChart` puts it on the active tab. */\n tab: ChartTabSnapshot\n}\n```\n\n### `type SvGridCellCoords`\n\nOne cell, in the 0-based display coordinates every selection method uses.\n\n```ts\nexport type SvGridCellCoords = { row: number; col: number }\n```\n\n### `type SvGridSelectedRange`\n\nA selected rectangle with its orientation kept: `from` is where the\nselection was started (the anchor) and `to` where it ended (the focus),\nso a range dragged upwards has `from.row > to.row`. `highlight` is the\nactive cell inside it. The same three fields as Handsontable's CellRange,\nso code written against `getSelectedRange()` there reads the same here.\n\n```ts\nexport type SvGridSelectedRange = {\n from: SvGridCellCoords\n to: SvGridCellCoords\n highlight: SvGridCellCoords\n}\n```\n\n### `type SvGridTransaction`\n\nA batch of row mutations for `api.applyTransaction`. `update` / `remove`\n(by id) match on `getRowId`; `remove` also accepts row object references.\n\n```ts\nexport type SvGridTransaction<TData> = {\n add?: ReadonlyArray<TData>\n update?: ReadonlyArray<TData>\n remove?: ReadonlyArray<TData | string>\n}\n```\n\n### `type SvGridTransactionResult`\n\nWhat a batched `applyTransaction` did: how many rows were added, updated and removed.\n\n```ts\nexport type SvGridTransactionResult = {\n added: number\n updated: number\n removed: number\n}\n```\n\n### `type SvGridApi`\n\nImperative API exposed via the `<SvGrid onApiReady>` callback. Use it for\ndata, column, filter, sort, group, and visibility operations from outside\nthe component.\n\n```ts\nexport type SvGridApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n // ----- DOM -----\n /**\n * The grid's root element (`.sv-grid-root`), or null before mount and\n * after unmount. For measuring, focusing, or reading the theme the grid\n * is drawn with - the PDF export takes its colours from here.\n */\n getElement(): HTMLElement | null\n\n // ----- Cells -----\n /** Read a cell value from the underlying data at `rowIndex`. */\n getCellValue(rowIndex: number, columnId: string): unknown\n /** Write a cell value through the column's field. */\n setCellValue(rowIndex: number, columnId: string, value: unknown): void\n /**\n * Programmatically begin editing a cell (as a double-click would). Returns\n * `true` if editing started (cell exists, editable, editing enabled).\n */\n startEditing(rowIndex: number, columnId: string): boolean\n /**\n * Commit (default) or, with `cancel: true`, discard the active edit.\n * Returns `true` if there was an edit in progress.\n */\n stopEditing(cancel?: boolean): boolean\n\n // ----- Cell selection -----\n /**\n * Programmatically select one or more rectangular cell ranges. Each\n * range is `[rowStart, colStart, rowEnd, colEnd]` in 0-indexed grid\n * coordinates. Pass an empty array to clear the selection.\n *\n * Every range is honoured: the last one becomes the active range and\n * the others stay highlighted beside it, as after a Ctrl+drag. The\n * active cell goes to the last range's start corner.\n */\n selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void\n /**\n * The current cell-selection rectangles in the same shape `selectCells`\n * accepts, each normalised to `[minRow, minCol, maxRow, maxCol]`, in the\n * order they were selected with the active one last. Empty array when no\n * range is active.\n *\n * Handsontable's method of the same name. Two differences, both on\n * purpose: an empty array rather than `undefined`, so `getSelected()[0]`\n * never throws; and normalised corners, so a loop from the first row to\n * the last cannot run zero times because the range was dragged upwards.\n * For the orientation, and the active cell inside a range, use\n * `getSelectedRange()`.\n *\n * With cell selection off (`selectable={false}`) no rectangle is ever\n * recorded and this stays `[]`; the focused cell is `getActiveCell()`.\n */\n getSelected(): Array<[number, number, number, number]>\n /**\n * The most recently selected rectangle as `[startRow, startCol, endRow,\n * endCol]`, start being where the selection began, or `undefined` when\n * nothing is selected. Handsontable's `getSelectedLast()`, orientation\n * included: a range dragged upwards has `startRow > endRow`.\n */\n getSelectedLast(): [number, number, number, number] | undefined\n /**\n * Every selected rectangle with its orientation and active cell, oldest\n * first, or `undefined` when nothing is selected. Handsontable's\n * `getSelectedRange()`.\n */\n getSelectedRange(): SvGridSelectedRange[] | undefined\n /** The most recently selected rectangle, or `undefined`. Handsontable's\n * `getSelectedRangeLast()`. */\n getSelectedRangeLast(): SvGridSelectedRange | undefined\n\n // ----- Integrated charting (requires the `charting` prop) -----\n /** Open the built-in chart panel. */\n openChart(): void\n /** Close the built-in chart panel. */\n closeChart(): void\n /** The live chart spec the panel is rendering, or `null`. */\n getChartSpec(): ChartSpec | null\n /** Select cell rectangles + open the chart panel (scopes to the range). */\n chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void\n /**\n * Configure the built-in chart panel's ACTIVE chart. Column references\n * accept a column id OR field name. Opens the panel unless `open === false`.\n */\n configureChart(config: {\n open?: boolean\n type?: ChartType\n dimension?: string | null\n series?: string | null\n measure?: string | null\n reduce?: ChartReducer\n stacked?: boolean\n dataLabels?: boolean\n logScale?: boolean\n timeAxis?: boolean\n valueFormat?: 'number' | 'currency' | 'percent' | 'compact'\n /** Group a date dimension by calendar unit; `null` returns to exact values. */\n bucket?: ChartTimeBucket | null\n /** Second measure: a scatter's Y, a range's high end, a bullet's target. */\n measure2?: string | null\n /** Histogram bin count; `null` for Sturges' rule. */\n bins?: number | null\n funnelShape?: 'trapezoid' | 'pyramid' | 'cone' | null\n candleStyle?: 'classic' | 'hollow' | 'heikin-ashi' | null\n /** Candlestick / OHLC: the price columns (ids or fields); `null` goes\n * back to guessing them from the column names. */\n ohlc?: { open?: string | null; high?: string | null; low?: string | null; close?: string | null; volume?: string | null } | null\n /** Indicator panes under a price chart (`volume`, `rsi`, `macd`,\n * `stochastic`, `atr`, `obv`) and overlays on it (`sma`, `ema`,\n * `bb`, `vwap`). */\n indicators?: Array<'volume' | 'rsi' | 'macd' | 'stochastic' | 'atr' | 'obv' | 'sma' | 'ema' | 'bb' | 'vwap'>\n /** The builder's format state (titles, axes, legend, per-series style);\n * `null` resets it. */\n format?: ChartFormatState | null\n /** `true` unlinks the chart from the grid (it keeps the spec it has),\n * `false` links it back. */\n frozen?: boolean\n /** Apply a saved chart (by name) to the active tab before the other keys. */\n saved?: string\n }): void\n /**\n * Keep the active chart's whole configuration (type, columns, aggregate,\n * switches, indicators, format, zoom) under a name. A same-named entry is\n * replaced. Saved charts travel with `getState()` / `setState()` and show\n * in the panel's Saved charts popover.\n */\n saveChart(name: string): void\n /** Apply a saved chart to the active tab, which keeps its title. `false` when no chart has that name. */\n applySavedChart(name: string): boolean\n removeSavedChart(name: string): void\n getSavedCharts(): SavedChart[]\n /**\n * Register a natural-language \"chart this\" handler. When set, the chart\n * panel shows an AI button. `@svgrid/enterprise`'s `enableAiCharting(api)`\n * fills this. Pass `null` to remove it.\n */\n setChartAiHandler(\n handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null,\n ): void\n /**\n * Register an \"explain this chart\" handler. When set, the chart panel's AI\n * row shows an Explain button that reads the active chart and shows the\n * summary and insights it returns. `enableAiCharting(api)` fills this with\n * `aiExplainChart`. Pass `null` to remove it.\n */\n setChartExplainHandler(handler: (() => Promise<{ summary: string; insights: string[] } | null>) | null): void\n\n // ----- Rows -----\n /** Add one row. `position` defaults to `'bottom'`. */\n addRow(row: TData, position?: 'top' | 'bottom' | number): void\n addRows(rows: ReadonlyArray<TData>, position?: 'top' | 'bottom' | number): void\n /** Remove a row at the given data-array index. */\n removeRow(rowIndex: number): void\n removeRows(rowIndices: ReadonlyArray<number>): void\n /**\n * Apply a batch of add / update / remove mutations in a SINGLE data update\n * (one re-render, not one per row) - the high-frequency / streaming path.\n * `update` and `remove`-by-id match rows via `getRowId`, so set that prop\n * for those to work; `remove` also accepts row object references. Returns\n * the counts actually applied.\n */\n applyTransaction(tx: SvGridTransaction<TData>): SvGridTransactionResult\n\n // ----- Columns -----\n /** Add one column. `position` defaults to `'right'`. */\n addColumn(\n column: ColumnDef<TFeatures, TData>,\n position?: 'left' | 'right' | number,\n ): void\n addColumns(\n columns: ReadonlyArray<ColumnDef<TFeatures, TData>>,\n position?: 'left' | 'right' | number,\n ): void\n /** Remove a column by id (or field when no id was provided). */\n removeColumn(columnId: string): void\n\n // ----- Visibility -----\n setColumnVisible(columnId: string, visible: boolean): void\n isColumnVisible(columnId: string): boolean\n\n // ----- Sort / group / filter -----\n /** Sort by one column (replaces any existing sort). Pass `null` to clear. */\n setSort(columnId: string, direction: 'asc' | 'desc' | null): void\n clearSort(): void\n setGroupBy(columnIds: ReadonlyArray<string>): void\n\n // ----- Options (runtime prop overrides) -----\n /**\n * Change a grid prop at runtime, e.g. `api.setOption('sortable', true)`. The\n * override is merged over the incoming prop and the grid re-renders reactively -\n * identical to the parent passing a new prop value. Pass `undefined` to clear the\n * override and fall back to the prop.\n *\n * Note: seed-once props are NOT retroactive - `initialSorting`,\n * `initialColumnPinning`, `initialHiddenColumns`, `columnOrder`, the initial\n * `pageSize`, and the one-shot `externalSort` / `externalFilter` are read at mount,\n * so overriding them later has no effect. Prefer the dedicated data path for\n * `data` / `columns` (this works, but the sync effects are the idiomatic route).\n */\n setOption<K extends keyof Props<TFeatures, TData>>(key: K, value: Props<TFeatures, TData>[K] | undefined): void\n /** Read a prop's effective value: the runtime override if set, else the incoming prop. */\n getOption<K extends keyof Props<TFeatures, TData>>(key: K): Props<TFeatures, TData>[K]\n /** Clear every runtime override set via `setOption`, reverting to the incoming props. */\n resetOptions(): void\n\n /** Set the operator filter for a column. Pass `null` to clear. */\n setFilter(\n columnId: string,\n filter:\n | {\n operator: SvGridFilterOperator\n value?: string\n /** Upper bound for the `between` operator. Required when `operator === 'between'`. */\n valueTo?: string\n /**\n * Optional SECOND condition on the same column, joined by `join`\n * (multi-condition filtering, e.g. \"> 100 AND < 500\").\n */\n operator2?: SvGridFilterOperator\n value2?: string\n valueTo2?: string\n /** How to combine the two conditions. Defaults to `'AND'`. */\n join?: 'AND' | 'OR'\n }\n | null,\n ): void\n /**\n * Set the facet (set-list, Excel-style multi-select) filter for a column.\n * Pass an empty array or `null` to clear it. The values restore the\n * checked state of the column-menu's value list - the engine then filters\n * the data to rows whose cell value is in the set. Used to restore\n * snapshots captured via `onFiltersChange`'s `selectedValues`.\n */\n setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void\n clearFilter(columnId: string): void\n /**\n * Drop cached async `editorOptions` so the next render refetches them.\n * Omit `columnId` to clear every column. Only affects columns whose\n * `editorOptions` returns a Promise - static lists are never refetched.\n */\n refreshEditorOptions(columnId?: string): void\n /**\n * Open the `contextMenu` at the pointer, for the cell at (rowIndex,\n * colIndex), from an element of your own: a spreadsheet's column letters\n * and row numbers offer the cell menu for the column or row they select.\n * Does nothing without a `contextMenu` prop.\n */\n openContextMenu(event: MouseEvent, rowIndex: number, colIndex: number): void\n /**\n * Clear every active column filter (menu, filter-row, set-list, global, and\n * the advanced filter). Resets the grid to \"no filtering\" in a single call.\n */\n clearAllFilters(): void\n /**\n * Set the advanced-filter expression (Pro). `null` clears it. Composed with\n * AND after the global, column and facet filters.\n *\n * Rows are only removed once `@svgrid/enterprise`'s `enableAdvancedFilter()`\n * has registered a compiler. Without it the expression is stored but nothing\n * is filtered - use `isAdvancedFilterActive()` to tell the two apart.\n */\n setAdvancedFilter(expr: GridPredicateExpr | null): void\n /** The current advanced-filter expression, or `null`. */\n getAdvancedFilter(): GridPredicateExpr | null\n /** Clear the advanced filter, leaving other filter surfaces untouched. */\n clearAdvancedFilter(): void\n /** Whether an expression is set AND an engine is registered to run it. */\n isAdvancedFilterActive(): boolean\n /**\n * Read the active column-menu filters as a snapshot. Keyed by column id.\n * Returns an empty object when nothing is filtered. `valueTo` is only\n * present when `operator === 'between'`.\n */\n getFilters(): Record<\n string,\n { operator: SvGridFilterOperator; value: string; valueTo?: string }\n >\n\n /**\n * Snapshot of the DATA rows the grid is displaying right now - after\n * filtering, sorting, grouping and pagination. Use this when you need the\n * visible result set (e.g. to export it as CSV).\n *\n * Group banner rows are not included: the return type is `TData`, and a\n * banner is not one of your rows. That matters while grouping is on, because\n * with every group collapsed this returns an EMPTY array even though the\n * grid visibly shows a banner per group. Count banners from `getState()`\n * rather than from the length of this.\n */\n getDisplayedRows(): ReadonlyArray<TData>\n\n /** Snapshot of the current data array (pre-pipeline). */\n getData(): ReadonlyArray<TData>\n\n /**\n * The stable id the grid keys a row by: what the `getRowId` prop\n * computed, or the row's position when no `getRowId` is set. Pass a\n * display index (the space `getActiveCell`, `scrollToRow` and the cell\n * methods use) or a row object; returns null for an unknown index or a\n * row that is not in the data. Pair with `selectRows`, `toggleRowSelected`\n * or an `applyTransaction` remove, all of which take these ids.\n */\n getRowId(row: number | TData): string | null\n\n /**\n * Snapshot of every column the grid currently knows about, in visual\n * order, with the human-readable header label. Use this when exporting\n * or building a column-picker UI - the data is read once, no\n * subscription. Hidden columns are included; check `visible` to filter.\n */\n getColumns(): ReadonlyArray<{\n id: string\n field?: string\n header: string\n visible: boolean\n /** The column's `format` config, when set. Lets an exporter reproduce\n * the on-screen display value (currency, date pattern, etc.). */\n format?: CellFormatConfig\n /** Effective horizontal alignment ('left' | 'center' | 'right'). */\n align?: 'left' | 'center' | 'right'\n /** The column's declared `editorType`, when set. Lets a filter or\n * expression UI offer type-appropriate operators. */\n editorType?: string\n }>\n\n // ----- Free data export (CSV / TSV / JSON + clipboard) -----\n /**\n * Export the grid to a **CSV** file. Free in the community grid; the\n * richer Excel / PDF / styled formats live in @svgrid/enterprise. Values\n * are formatted as shown on screen (pass `rawValues: true` for raw). Rows\n * default to the current view (`rows: 'selected' | 'all'` to change).\n * Resolves with the serialized text; pass `download: false` to skip the\n * browser download and just get the string.\n */\n exportCsv(options?: GridExportOptions): Promise<string>\n /** Export the grid to a **TSV** file (tab-separated). See `exportCsv`. */\n exportTsv(options?: GridExportOptions): Promise<string>\n /** Export the grid to a **JSON** file (array of `{ field: value }`). */\n exportJson(options?: GridExportOptions): Promise<string>\n /**\n * Copy the grid to the system clipboard. `format: 'tsv'` (default) pastes\n * straight into Excel / Sheets; `'csv'` / `'markdown'` also supported.\n * Resolves with the copied text.\n */\n copyToClipboard(options?: GridClipboardOptions): Promise<string>\n\n /** Clear every checked row. Emits `onRowSelectionChange({}, [])`. */\n clearRowSelection(): void\n\n // ----- Column layout (width + pinning) -----\n /**\n * Set the width of one column in pixels. Identical to dragging the\n * column's resize handle. Width is clamped to `MIN_COLUMN_WIDTH`.\n */\n setColumnWidth(columnId: string, width: number): void\n /**\n * Snapshot of every column's current width (in pixels), keyed by\n * column id. Columns the user has never resized AND that have no\n * explicit `width` on their ColumnDef are reported at the grid-wide\n * default. Useful for \"save view\" + URL persistence.\n */\n getColumnWidths(): Record<string, number>\n /**\n * Snap one column's width to its widest visible cell (header text +\n * any rendered body cell). Equivalent to double-clicking the column's\n * resize handle. The grid also exposes this through the column menu's\n * \"Autosize\" item.\n */\n autosizeColumn(columnId: string): void\n /**\n * The height of a row in px: the one it was dragged or set to, else the\n * declared `rowHeight` (30 when none). `rowIndex` is a display index.\n */\n getRowHeight(rowIndex: number): number\n /**\n * Give a row its own height, the way dragging its grip does with\n * `rowResize`; `null` takes it back to the declared height. The height\n * belongs to the row (its id), so it follows the row through a sort and\n * survives the data being replaced. Ignored under `autoRowHeight`, where\n * the content decides.\n */\n setRowHeight(rowIndex: number, height: number | null): void\n /**\n * Fold a column to nothing, the way a spreadsheet hides one: it keeps\n * its index, its cells and its width, and takes no room until it is\n * unfolded. Unlike `setColumnVisible(id, false)`, which takes the\n * column out of the model and shifts every index after it, references\n * by index stay valid, so a sheet's formulas and formats are untouched.\n * Arrow keys, Tab and Enter step over a collapsed column.\n */\n setColumnCollapsed(columnId: string, collapsed: boolean): void\n isColumnCollapsed(columnId: string): boolean\n /**\n * Fold a row to nothing, the row-side twin of `setColumnCollapsed`.\n * `rowIndex` is a display index; the fold belongs to the row (its id)\n * and follows it through a sort.\n */\n setRowCollapsed(rowIndex: number, collapsed: boolean): void\n isRowCollapsed(rowIndex: number): boolean\n /** The `mergedCells` in force, copied: origins with their spans. */\n getMergedCells(): Array<{ rowIndex: number; colIndex: number; rowSpan: number; colSpan: number }>\n /** Run `autosizeColumn` on every column. */\n autosizeAllColumns(): void\n /**\n * Replace the column-pinning state in one call. Each entry is a\n * column id; the order in the array becomes the visible order along\n * the pinned edge.\n */\n setColumnPinning(pinning: {\n left?: ReadonlyArray<string>\n right?: ReadonlyArray<string>\n }): void\n /** Snapshot of the current column-pinning state. */\n getColumnPinning(): { left: string[]; right: string[] }\n\n // ----- Column reorder -----\n /**\n * Replace the column order. Pass an array of column ids in the\n * desired visual order. Unknown ids are skipped; columns not in the\n * array keep their existing relative position after the listed ones.\n * Fires `onColumnOrderChange` once the new order is applied. Pin\n * groups (`columnPinning.left` / `right`) are still applied on top.\n */\n setColumnOrder(order: ReadonlyArray<string>): void\n /**\n * Snapshot of the current visual column order - the same shape the\n * `onColumnOrderChange` callback receives. Useful for saving and\n * restoring a view layout.\n */\n getColumnOrder(): string[]\n\n // ----- Row expansion -----\n /**\n * Set whether a row (group node or expandable leaf) is expanded.\n * The `id` is the engine's row id - for grouped rows that's the\n * synthetic group key (e.g. `\"department:Engineering\"`).\n */\n setRowExpanded(id: string, expanded: boolean): void\n /** Expand every group node in the current grouped row model. */\n expandAllGroups(): void\n /** Collapse every expansion - resets expanded state to {}. */\n collapseAllGroups(): void\n\n // ----- Undo / redo -----\n /** Undo the most recent inline-edit. Returns false when the history is empty. */\n undo(): boolean\n /** Redo the most recently undone edit. Returns false when the redo stack is empty. */\n redo(): boolean\n /** True when there's at least one step on the undo stack. */\n canUndo(): boolean\n /** True when there's at least one step on the redo stack. */\n canRedo(): boolean\n /** Wipe both stacks (e.g. after a server save commits the buffer). */\n clearHistory(): void\n /**\n * Mark every step recorded from now on with `tag`, until the next call;\n * `undefined` stops the marking. The grid never reads the tag. It is for a\n * consumer that shows one of several data sets through the grid (a\n * workbook's sheets, say) and has to know which one the next Ctrl+Z would\n * change before it lets the grid apply it: it tags the steps with the data\n * set they were made on and asks `peekUndo` first.\n */\n setHistoryTag(tag: unknown): void\n /** The step the next `undo()` would apply, or null when there is none. A\n * multi-cell action carries one tag, so it reports as one. */\n peekUndo(): { tag: unknown } | null\n /** The step the next `redo()` would apply, or null when there is none. */\n peekRedo(): { tag: unknown } | null\n\n // ----- Keyboard commands -----\n /**\n * The same `GridCommandContext` a registered shortcut handler receives.\n *\n * Keyboard commands are handed one on every keystroke, but a button in a\n * ribbon or a toolbar has no keystroke to ride in on, and re-implementing\n * \"bold the selection\" against the api would give the button and the key\n * two code paths that drift. Both go through this instead.\n *\n * Live, not a snapshot: the object reads through to the grid, so one built\n * once at mount still reports the current selection.\n */\n getCommandContext(): GridCommandContext\n\n // ----- Find in grid -----\n /** Open the built-in find overlay (Ctrl+F also opens it). */\n openFind(): void\n /** Close the find overlay and clear the query. */\n closeFind(): void\n /** Update the find query programmatically (useful for app-wide command palettes). */\n setFindQuery(q: string): void\n /** Snapshot of the current find hits (rowIndex / colIndex / columnId). */\n getFindHits(): Array<{ rowIndex: number; colIndex: number; columnId: string }>\n\n // ----- Row selection (read + write) -----\n /**\n * The currently selected data rows (group-header rows excluded), in row-model\n * order. Read once - no subscription. The push-based equivalent is\n * `onRowSelectionChange`.\n */\n getSelectedRows(): TData[]\n /** The engine row ids of the selected rows. Keys into the selection record. */\n getSelectedRowIds(): string[]\n /**\n * Select rows by engine row id. By default this REPLACES the selection;\n * pass `additive: true` to add to the existing selection instead.\n */\n selectRows(ids: ReadonlyArray<string>, additive?: boolean): void\n /** Select every selectable (non-group) row in the current row model. */\n selectAllRows(): void\n /** Flip one row's selected state by id. */\n toggleRowSelected(id: string): void\n\n // ----- Pagination -----\n /**\n * Current pagination snapshot. `total` is the post-filter row count;\n * `pageCount` is derived from it and `pageSize` (always >= 1).\n */\n getPageInfo(): {\n pageIndex: number\n pageSize: number\n pageCount: number\n total: number\n }\n /** Jump to a 0-based page. Clamped to [0, pageCount - 1]. */\n setPage(pageIndex: number): void\n /** Advance one page (no-op past the last page). */\n nextPage(): void\n /** Go back one page (no-op before the first page). */\n prevPage(): void\n /** Jump to the first page. */\n firstPage(): void\n /** Jump to the last page. */\n lastPage(): void\n /** Change the page size, keeping the first visible row in view. */\n setPageSize(pageSize: number): void\n\n // ----- Navigation / scrolling -----\n /**\n * Scroll the body so the given row index is at the top of the viewport.\n * Works with virtualization on. Index is clamped to the row count.\n */\n scrollToRow(rowIndex: number): void\n /** The active (focused) cell, or null when nothing is focused. */\n getActiveCell(): { rowIndex: number; colIndex: number; columnId: string } | null\n /** Move the active cell. Both coordinates are clamped to the grid bounds. */\n setActiveCell(rowIndex: number, colIndex: number): void\n\n // ----- View state (save / restore) -----\n /**\n * Serializable snapshot of the whole view - sort, grouping, pagination,\n * column layout, and every filter surface. Pair with `setState` for\n * \"save view\" / URL persistence / named views.\n */\n getState(): SvGridViewState\n /**\n * Restore a view from a (partial) snapshot produced by `getState`. Only the\n * keys present are applied, so you can restore just the columns, just the\n * filters, etc.\n */\n setState(state: Partial<SvGridViewState>): void\n /** Force a recompute of the row pipeline + a re-render. */\n refresh(): void\n}\n```\n\n### `type SvGridWrapperProps`\n\nThe props `<SvGrid>` accepts. See the SvGrid reference for the full list with defaults.\n\n```ts\nexport type SvGridWrapperProps<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n data: ReadonlyArray<TData>\n columns: Array<ColumnDef<TFeatures, TData>>\n /**\n * Feature set from `tableFeatures({ ... })`. Optional - the `sortable` /\n * `filterable` / `groupable` shortcuts inject the matching feature, so a\n * grid can be configured from the boolean shortcuts alone.\n */\n features?: TFeatures\n /**\n * Capability shortcuts. Every capability is OFF by default (a bare grid is\n * a plain read-only table); set a shortcut `true` to opt in.\n *\n * `sortable` - column sorting (injects `rowSortingFeature`)\n * `filterable` - column filtering (injects `columnFilteringFeature`)\n * `editable` - inline editing (alias of `enableInlineEditing`)\n * `groupable` - grouping controls (alias of `showGroupingControls`)\n * `pageable` - pagination footer (alias of `showPagination`)\n */\n sortable?: boolean\n filterable?: boolean\n editable?: boolean\n groupable?: boolean\n pageable?: boolean\n options?: Partial<SvGridOptions<TFeatures, TData>>\n loading?: boolean\n error?: string | null\n emptyMessage?: string\n showGlobalFilter?: boolean\n showColumnFilters?: boolean\n showGroupingControls?: boolean\n showRowSelection?: boolean\n showPagination?: boolean\n virtualization?: boolean\n /** Row height in pixels. Pass a function `(rowIndex) => px` for per-row\n * variable heights (e.g. when wiring up an interactive row-resize). */\n rowHeight?: number | ((rowIndex: number) => number)\n overscan?: number\n containerHeight?: number\n columnVirtualization?: boolean\n columnOverscan?: number\n columnWidth?: number\n showFilterMenu?: boolean\n showFilterRow?: boolean\n enableCellSelection?: boolean\n enableInlineEditing?: boolean\n enableRowSummaries?: boolean\n /** Shortcut alias for `enableRowSummaries`; wins when both are set. */\n summary?: boolean\n /** Receives the imperative grid API when the component is ready. */\n onApiReady?: (api: SvGridApi<TFeatures, TData>) => void\n}\n```\n"
|
|
6859
|
+
"markdown": "# `@svgrid/grid` · `svgrid-wrapper.types.ts`\n\nAuto-generated. Source: `packages\\grid\\src\\svgrid-wrapper.types.ts`.\n\n### `type SvGridFilterOperator`\n\nThe comparisons a column filter can use, as shown in the filter menu.\n\n```ts\nexport type SvGridFilterOperator = FilterOperator\n```\n\n### `type SvGridViewState`\n\nA serializable snapshot of everything that makes up the current \"view\":\nsort, grouping, pagination, column layout (width / pinning / order /\nvisibility), and all filter surfaces. Round-trippable through\n`api.getState()` / `api.setState()` - persist it to a URL, localStorage, or\na server to implement \"save view\" / \"named views\".\n\n```ts\nexport type SvGridViewState = {\n sorting: Array<{ id: string; desc: boolean }>\n grouping: string[]\n pagination: { pageIndex: number; pageSize: number }\n columnWidths: Record<string, number>\n columnPinning: { left: string[]; right: string[] }\n columnOrder: string[]\n /** Ids of columns currently hidden via setColumnVisible. */\n hiddenColumns: string[]\n globalFilter: string\n columnFilters: Record<\n string,\n { operator: SvGridFilterOperator; value: string; valueTo?: string }\n >\n /** Facet (Excel-style value checklist) selections, keyed by column id. */\n facetFilters: Record<string, string[]>\n /**\n * Advanced-filter expression (Pro). OPTIONAL, and omitted entirely when no\n * advanced filter is set - so views saved before this feature existed, and\n * views from grids that never use it, round-trip byte-identical. An explicit\n * `null` clears the filter on `setState`.\n */\n advancedFilter?: GridPredicateExpr | null\n /** Built-in chart panel state (present only when `charting` is on): the\n * ACTIVE chart, for back-compat + spread-and-tweak. */\n chart?: {\n open: boolean\n type: ChartType\n dimension: string | null\n series: string | null\n measure: string | null\n reduce: 'sum' | 'avg' | 'count'\n stacked: boolean\n }\n /** All charts in the tab strip (multi-chart), plus the active index.\n * Every field the panel can change is here so a saved view restores the\n * chart exactly; `null` inherits the `charting` config. */\n charts?: ChartTabSnapshot[]\n chartActive?: number\n /** The named charts kept with `api.saveChart`; present only when there is one. */\n savedCharts?: SavedChart[]\n}\n```\n\n### `type ChartTabSnapshot`\n\nOne chart tab as the saved view carries it (`GridState.charts[i]`).\n\n```ts\nexport type ChartTabSnapshot = {\n title: string\n type: ChartType\n dimension: string | null\n series: string | null\n measure: string | null\n /** Scatter's y measure, a range's high, a bullet's target. */\n measure2?: string | null\n reduce: ChartReducer\n stacked: boolean | null\n stacked100?: boolean | null\n orientation?: 'vertical' | 'horizontal' | null\n donut?: boolean | null\n palette?: string[] | null\n dataLabels: boolean | null\n logScale: boolean | null\n timeAxis: boolean | null\n valueFormat: 'number' | 'currency' | 'percent' | 'compact' | null\n bucket?: ChartTimeBucket | null\n bins?: number | null\n funnelShape?: 'trapezoid' | 'pyramid' | 'cone' | null\n candleStyle?: 'classic' | 'hollow' | 'heikin-ashi' | null\n /** The zoom window as category indices, or null for the whole chart. */\n zoom?: ChartZoomWindow | null\n ohlc?: { open: string | null; high: string | null; low: string | null; close: string | null; volume: string | null } | null\n indicators?: string[]\n format?: ChartFormatState | null\n /** An unlinked chart carries the spec it was frozen with. */\n frozen?: { spec: ChartSpec; at: string } | null\n}\n```\n\n### `type SavedChart`\n\nA chart configuration kept under a name (`api.saveChart`).\n\n```ts\nexport type SavedChart = {\n name: string\n /** ISO timestamp of the save. */\n savedAt: string\n /** The tab's state at the time; `applySavedChart` puts it on the active tab. */\n tab: ChartTabSnapshot\n}\n```\n\n### `type SvGridCellCoords`\n\nOne cell, in the 0-based display coordinates every selection method uses.\n\n```ts\nexport type SvGridCellCoords = { row: number; col: number }\n```\n\n### `type SvGridSelectedRange`\n\nA selected rectangle with its orientation kept: `from` is where the\nselection was started (the anchor) and `to` where it ended (the focus),\nso a range dragged upwards has `from.row > to.row`. `highlight` is the\nactive cell inside it. The same three fields as Handsontable's CellRange,\nso code written against `getSelectedRange()` there reads the same here.\n\n```ts\nexport type SvGridSelectedRange = {\n from: SvGridCellCoords\n to: SvGridCellCoords\n highlight: SvGridCellCoords\n}\n```\n\n### `type SvGridTransaction`\n\nA batch of row mutations for `api.applyTransaction`. `update` / `remove`\n(by id) match on `getRowId`; `remove` also accepts row object references.\n\n```ts\nexport type SvGridTransaction<TData> = {\n add?: ReadonlyArray<TData>\n update?: ReadonlyArray<TData>\n remove?: ReadonlyArray<TData | string>\n}\n```\n\n### `type SvGridTransactionResult`\n\nWhat a batched `applyTransaction` did: how many rows were added, updated and removed.\n\n```ts\nexport type SvGridTransactionResult = {\n added: number\n updated: number\n removed: number\n}\n```\n\n### `type SvGridApi`\n\nImperative API exposed via the `<SvGrid onApiReady>` callback. Use it for\ndata, column, filter, sort, group, and visibility operations from outside\nthe component.\n\n```ts\nexport type SvGridApi<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n // ----- DOM -----\n /**\n * The grid's root element (`.sv-grid-root`), or null before mount and\n * after unmount. For measuring, focusing, or reading the theme the grid\n * is drawn with - the PDF export takes its colours from here.\n */\n getElement(): HTMLElement | null\n\n // ----- Cells -----\n /** Read a cell value from the underlying data at `rowIndex`. */\n getCellValue(rowIndex: number, columnId: string): unknown\n /** Write a cell value through the column's field. */\n setCellValue(rowIndex: number, columnId: string, value: unknown): void\n /**\n * Programmatically begin editing a cell (as a double-click would). Returns\n * `true` if editing started (cell exists, editable, editing enabled).\n */\n startEditing(rowIndex: number, columnId: string): boolean\n /**\n * Commit (default) or, with `cancel: true`, discard the active edit.\n * Returns `true` if there was an edit in progress.\n */\n stopEditing(cancel?: boolean): boolean\n\n // ----- Cell selection -----\n /**\n * Programmatically select one or more rectangular cell ranges. Each\n * range is `[rowStart, colStart, rowEnd, colEnd]` in 0-indexed grid\n * coordinates. Pass an empty array to clear the selection.\n *\n * Every range is honoured: the last one becomes the active range and\n * the others stay highlighted beside it, as after a Ctrl+drag. The\n * active cell goes to the last range's start corner.\n */\n selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void\n /**\n * The current cell-selection rectangles in the same shape `selectCells`\n * accepts, each normalised to `[minRow, minCol, maxRow, maxCol]`, in the\n * order they were selected with the active one last. Empty array when no\n * range is active.\n *\n * Handsontable's method of the same name. Two differences, both on\n * purpose: an empty array rather than `undefined`, so `getSelected()[0]`\n * never throws; and normalised corners, so a loop from the first row to\n * the last cannot run zero times because the range was dragged upwards.\n * For the orientation, and the active cell inside a range, use\n * `getSelectedRange()`.\n *\n * With cell selection off (`selectable={false}`) no rectangle is ever\n * recorded and this stays `[]`; the focused cell is `getActiveCell()`.\n */\n getSelected(): Array<[number, number, number, number]>\n /**\n * The most recently selected rectangle as `[startRow, startCol, endRow,\n * endCol]`, start being where the selection began, or `undefined` when\n * nothing is selected. Handsontable's `getSelectedLast()`, orientation\n * included: a range dragged upwards has `startRow > endRow`.\n */\n getSelectedLast(): [number, number, number, number] | undefined\n /**\n * Every selected rectangle with its orientation and active cell, oldest\n * first, or `undefined` when nothing is selected. Handsontable's\n * `getSelectedRange()`.\n */\n getSelectedRange(): SvGridSelectedRange[] | undefined\n /** The most recently selected rectangle, or `undefined`. Handsontable's\n * `getSelectedRangeLast()`. */\n getSelectedRangeLast(): SvGridSelectedRange | undefined\n\n // ----- Integrated charting (requires the `charting` prop) -----\n /** Open the built-in chart panel. */\n openChart(): void\n /** Close the built-in chart panel. */\n closeChart(): void\n /** The live chart spec the panel is rendering, or `null`. */\n getChartSpec(): ChartSpec | null\n /** Select cell rectangles + open the chart panel (scopes to the range). */\n chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void\n /**\n * Configure the built-in chart panel's ACTIVE chart. Column references\n * accept a column id OR field name. Opens the panel unless `open === false`.\n */\n configureChart(config: {\n open?: boolean\n type?: ChartType\n dimension?: string | null\n series?: string | null\n measure?: string | null\n reduce?: ChartReducer\n stacked?: boolean\n dataLabels?: boolean\n logScale?: boolean\n timeAxis?: boolean\n valueFormat?: 'number' | 'currency' | 'percent' | 'compact'\n /** Group a date dimension by calendar unit; `null` returns to exact values. */\n bucket?: ChartTimeBucket | null\n /** Second measure: a scatter's Y, a range's high end, a bullet's target. */\n measure2?: string | null\n /** Histogram bin count; `null` for Sturges' rule. */\n bins?: number | null\n funnelShape?: 'trapezoid' | 'pyramid' | 'cone' | null\n candleStyle?: 'classic' | 'hollow' | 'heikin-ashi' | null\n /** Candlestick / OHLC: the price columns (ids or fields); `null` goes\n * back to guessing them from the column names. */\n ohlc?: { open?: string | null; high?: string | null; low?: string | null; close?: string | null; volume?: string | null } | null\n /** Indicator panes under a price chart (`volume`, `rsi`, `macd`,\n * `stochastic`, `atr`, `obv`) and overlays on it (`sma`, `ema`,\n * `bb`, `vwap`). */\n indicators?: Array<'volume' | 'rsi' | 'macd' | 'stochastic' | 'atr' | 'obv' | 'sma' | 'ema' | 'bb' | 'vwap'>\n /** The builder's format state (titles, axes, legend, per-series style);\n * `null` resets it. */\n format?: ChartFormatState | null\n /** `true` unlinks the chart from the grid (it keeps the spec it has),\n * `false` links it back. */\n frozen?: boolean\n /** Apply a saved chart (by name) to the active tab before the other keys. */\n saved?: string\n }): void\n /**\n * Keep the active chart's whole configuration (type, columns, aggregate,\n * switches, indicators, format, zoom) under a name. A same-named entry is\n * replaced. Saved charts travel with `getState()` / `setState()` and show\n * in the panel's Saved charts popover.\n */\n saveChart(name: string): void\n /** Apply a saved chart to the active tab, which keeps its title. `false` when no chart has that name. */\n applySavedChart(name: string): boolean\n removeSavedChart(name: string): void\n getSavedCharts(): SavedChart[]\n /**\n * Register a natural-language \"chart this\" handler. When set, the chart\n * panel shows an AI button. `@svgrid/enterprise`'s `enableAiCharting(api)`\n * fills this. Pass `null` to remove it.\n */\n setChartAiHandler(\n handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null,\n ): void\n /**\n * Register an \"explain this chart\" handler. When set, the chart panel's AI\n * row shows an Explain button that reads the active chart and shows the\n * summary and insights it returns. `enableAiCharting(api)` fills this with\n * `aiExplainChart`. Pass `null` to remove it.\n */\n setChartExplainHandler(handler: (() => Promise<{ summary: string; insights: string[] } | null>) | null): void\n\n // ----- Rows -----\n /** Add one row. `position` defaults to `'bottom'`. */\n addRow(row: TData, position?: 'top' | 'bottom' | number): void\n addRows(rows: ReadonlyArray<TData>, position?: 'top' | 'bottom' | number): void\n /** Remove a row at the given data-array index. */\n removeRow(rowIndex: number): void\n removeRows(rowIndices: ReadonlyArray<number>): void\n /**\n * Apply a batch of add / update / remove mutations in a SINGLE data update\n * (one re-render, not one per row) - the high-frequency / streaming path.\n * `update` and `remove`-by-id match rows via `getRowId`, so set that prop\n * for those to work; `remove` also accepts row object references. Returns\n * the counts actually applied.\n */\n applyTransaction(tx: SvGridTransaction<TData>): SvGridTransactionResult\n\n // ----- Columns -----\n /** Add one column. `position` defaults to `'right'`. */\n addColumn(\n column: ColumnDef<TFeatures, TData>,\n position?: 'left' | 'right' | number,\n ): void\n addColumns(\n columns: ReadonlyArray<ColumnDef<TFeatures, TData>>,\n position?: 'left' | 'right' | number,\n ): void\n /** Remove a column by id (or field when no id was provided). */\n removeColumn(columnId: string): void\n\n // ----- Visibility -----\n setColumnVisible(columnId: string, visible: boolean): void\n isColumnVisible(columnId: string): boolean\n\n // ----- Sort / group / filter -----\n /** Sort by one column (replaces any existing sort). Pass `null` to clear. */\n setSort(columnId: string, direction: 'asc' | 'desc' | null): void\n clearSort(): void\n setGroupBy(columnIds: ReadonlyArray<string>): void\n\n // ----- Options (runtime prop overrides) -----\n /**\n * Change a grid prop at runtime, e.g. `api.setOption('sortable', true)`. The\n * override is merged over the incoming prop and the grid re-renders reactively -\n * identical to the parent passing a new prop value. Pass `undefined` to clear the\n * override and fall back to the prop.\n *\n * Note: seed-once props are NOT retroactive - `initialSorting`,\n * `initialColumnPinning`, `initialHiddenColumns`, `columnOrder`, the initial\n * `pageSize`, and the one-shot `externalSort` / `externalFilter` are read at mount,\n * so overriding them later has no effect. Prefer the dedicated data path for\n * `data` / `columns` (this works, but the sync effects are the idiomatic route).\n */\n setOption<K extends keyof Props<TFeatures, TData>>(key: K, value: Props<TFeatures, TData>[K] | undefined): void\n /** Read a prop's effective value: the runtime override if set, else the incoming prop. */\n getOption<K extends keyof Props<TFeatures, TData>>(key: K): Props<TFeatures, TData>[K]\n /** Clear every runtime override set via `setOption`, reverting to the incoming props. */\n resetOptions(): void\n\n /** Set the operator filter for a column. Pass `null` to clear. */\n setFilter(\n columnId: string,\n filter:\n | {\n operator: SvGridFilterOperator\n value?: string\n /** Upper bound for the `between` operator. Required when `operator === 'between'`. */\n valueTo?: string\n /**\n * Optional SECOND condition on the same column, joined by `join`\n * (multi-condition filtering, e.g. \"> 100 AND < 500\").\n */\n operator2?: SvGridFilterOperator\n value2?: string\n valueTo2?: string\n /** How to combine the two conditions. Defaults to `'AND'`. */\n join?: 'AND' | 'OR'\n }\n | null,\n ): void\n /**\n * Set the facet (set-list, Excel-style multi-select) filter for a column.\n * Pass an empty array or `null` to clear it. The values restore the\n * checked state of the column-menu's value list - the engine then filters\n * the data to rows whose cell value is in the set. Used to restore\n * snapshots captured via `onFiltersChange`'s `selectedValues`.\n */\n setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void\n clearFilter(columnId: string): void\n /**\n * Drop cached async `editorOptions` so the next render refetches them.\n * Omit `columnId` to clear every column. Only affects columns whose\n * `editorOptions` returns a Promise - static lists are never refetched.\n */\n refreshEditorOptions(columnId?: string): void\n /**\n * Open the `contextMenu` at the pointer, for the cell at (rowIndex,\n * colIndex), from an element of your own: a spreadsheet's column letters\n * and row numbers offer the cell menu for the column or row they select.\n * Does nothing without a `contextMenu` prop.\n */\n openContextMenu(event: MouseEvent, rowIndex: number, colIndex: number): void\n /**\n * Clear every active column filter (menu, filter-row, set-list, global, and\n * the advanced filter). Resets the grid to \"no filtering\" in a single call.\n */\n clearAllFilters(): void\n /**\n * Set the advanced-filter expression (Pro). `null` clears it. Composed with\n * AND after the global, column and facet filters.\n *\n * Rows are only removed once `@svgrid/enterprise`'s `enableAdvancedFilter()`\n * has registered a compiler. Without it the expression is stored but nothing\n * is filtered - use `isAdvancedFilterActive()` to tell the two apart.\n */\n setAdvancedFilter(expr: GridPredicateExpr | null): void\n /** The current advanced-filter expression, or `null`. */\n getAdvancedFilter(): GridPredicateExpr | null\n /** Clear the advanced filter, leaving other filter surfaces untouched. */\n clearAdvancedFilter(): void\n /** Whether an expression is set AND an engine is registered to run it. */\n isAdvancedFilterActive(): boolean\n /**\n * Read the active column-menu filters as a snapshot. Keyed by column id.\n * Returns an empty object when nothing is filtered. `valueTo` is only\n * present when `operator === 'between'`.\n */\n getFilters(): Record<\n string,\n { operator: SvGridFilterOperator; value: string; valueTo?: string }\n >\n\n /**\n * Snapshot of the DATA rows the grid is displaying right now - after\n * filtering, sorting, grouping and pagination. Use this when you need the\n * visible result set (e.g. to export it as CSV).\n *\n * Group banner rows are not included: the return type is `TData`, and a\n * banner is not one of your rows. That matters while grouping is on, because\n * with every group collapsed this returns an EMPTY array even though the\n * grid visibly shows a banner per group. Count banners from `getState()`\n * rather than from the length of this.\n */\n getDisplayedRows(): ReadonlyArray<TData>\n\n /** Snapshot of the current data array (pre-pipeline). */\n getData(): ReadonlyArray<TData>\n\n /**\n * The stable id the grid keys a row by: what the `getRowId` prop\n * computed, or the row's position when no `getRowId` is set. Pass a\n * display index (the space `getActiveCell`, `scrollToRow` and the cell\n * methods use) or a row object; returns null for an unknown index or a\n * row that is not in the data. Pair with `selectRows`, `toggleRowSelected`\n * or an `applyTransaction` remove, all of which take these ids.\n */\n getRowId(row: number | TData): string | null\n\n /**\n * Snapshot of every column the grid currently knows about, in visual\n * order, with the human-readable header label. Use this when exporting\n * or building a column-picker UI - the data is read once, no\n * subscription. Hidden columns are included; check `visible` to filter.\n */\n getColumns(): ReadonlyArray<{\n id: string\n field?: string\n header: string\n visible: boolean\n /** The column's `format` config, when set. Lets an exporter reproduce\n * the on-screen display value (currency, date pattern, etc.). */\n format?: CellFormatConfig\n /** Effective horizontal alignment ('left' | 'center' | 'right'). */\n align?: 'left' | 'center' | 'right'\n /** The column's declared `editorType`, when set. Lets a filter or\n * expression UI offer type-appropriate operators. */\n editorType?: string\n }>\n\n // ----- Free data export (CSV / TSV / JSON + clipboard) -----\n /**\n * Export the grid to a **CSV** file. Free in the community grid; the\n * richer Excel / PDF / styled formats live in @svgrid/enterprise. Values\n * are formatted as shown on screen (pass `rawValues: true` for raw). Rows\n * default to the current view (`rows: 'selected' | 'all'` to change).\n * Resolves with the serialized text; pass `download: false` to skip the\n * browser download and just get the string.\n */\n exportCsv(options?: GridExportOptions): Promise<string>\n /** Export the grid to a **TSV** file (tab-separated). See `exportCsv`. */\n exportTsv(options?: GridExportOptions): Promise<string>\n /** Export the grid to a **JSON** file (array of `{ field: value }`). */\n exportJson(options?: GridExportOptions): Promise<string>\n /**\n * Copy the grid to the system clipboard. `format: 'tsv'` (default) pastes\n * straight into Excel / Sheets; `'csv'` / `'markdown'` also supported.\n * Resolves with the copied text.\n */\n copyToClipboard(options?: GridClipboardOptions): Promise<string>\n\n /** Clear every checked row. Emits `onRowSelectionChange({}, [])`. */\n clearRowSelection(): void\n\n // ----- Column layout (width + pinning) -----\n /**\n * Set the width of one column in pixels. Identical to dragging the\n * column's resize handle. Width is clamped to `MIN_COLUMN_WIDTH`.\n */\n setColumnWidth(columnId: string, width: number): void\n /**\n * Snapshot of every column's current width (in pixels), keyed by\n * column id. Columns the user has never resized AND that have no\n * explicit `width` on their ColumnDef are reported at the grid-wide\n * default. Useful for \"save view\" + URL persistence.\n */\n getColumnWidths(): Record<string, number>\n /**\n * Snap one column's width to its widest visible cell (header text +\n * any rendered body cell). Equivalent to double-clicking the column's\n * resize handle. The grid also exposes this through the column menu's\n * \"Autosize\" item.\n */\n autosizeColumn(columnId: string): void\n /**\n * The height of a row in px: the one it was dragged or set to, else the\n * declared `rowHeight` (30 when none). `rowIndex` is a display index.\n */\n getRowHeight(rowIndex: number): number\n /**\n * Give a row its own height, the way dragging its grip does with\n * `rowResize`; `null` takes it back to the declared height. The height\n * belongs to the row (its id), so it follows the row through a sort and\n * survives the data being replaced. Ignored under `autoRowHeight`, where\n * the content decides.\n */\n setRowHeight(rowIndex: number, height: number | null): void\n /**\n * Fold a column to nothing, the way a spreadsheet hides one: it keeps\n * its index, its cells and its width, and takes no room until it is\n * unfolded. Unlike `setColumnVisible(id, false)`, which takes the\n * column out of the model and shifts every index after it, references\n * by index stay valid, so a sheet's formulas and formats are untouched.\n * Arrow keys, Tab and Enter step over a collapsed column.\n */\n setColumnCollapsed(columnId: string, collapsed: boolean): void\n isColumnCollapsed(columnId: string): boolean\n /**\n * Fold a row to nothing, the row-side twin of `setColumnCollapsed`.\n * `rowIndex` is a display index; the fold belongs to the row (its id)\n * and follows it through a sort.\n */\n setRowCollapsed(rowIndex: number, collapsed: boolean): void\n isRowCollapsed(rowIndex: number): boolean\n /** The `mergedCells` in force, copied: origins with their spans. */\n getMergedCells(): Array<{ rowIndex: number; colIndex: number; rowSpan: number; colSpan: number }>\n /** Run `autosizeColumn` on every column. */\n autosizeAllColumns(): void\n /**\n * Replace the column-pinning state in one call. Each entry is a\n * column id; the order in the array becomes the visible order along\n * the pinned edge.\n */\n setColumnPinning(pinning: {\n left?: ReadonlyArray<string>\n right?: ReadonlyArray<string>\n }): void\n /** Snapshot of the current column-pinning state. */\n getColumnPinning(): { left: string[]; right: string[] }\n\n // ----- Column reorder -----\n /**\n * Replace the column order. Pass an array of column ids in the\n * desired visual order. Unknown ids are skipped; columns not in the\n * array keep their existing relative position after the listed ones.\n * Fires `onColumnOrderChange` once the new order is applied. Pin\n * groups (`columnPinning.left` / `right`) are still applied on top.\n */\n setColumnOrder(order: ReadonlyArray<string>): void\n /**\n * Snapshot of the current visual column order - the same shape the\n * `onColumnOrderChange` callback receives. Useful for saving and\n * restoring a view layout.\n */\n getColumnOrder(): string[]\n\n // ----- Row expansion -----\n /**\n * Set whether a row (group node or expandable leaf) is expanded.\n * The `id` is the engine's row id - for grouped rows that's the\n * synthetic group key, `group_<field>_<value>` (e.g.\n * `\"group_department_Engineering\"`; a second level appends\n * `_<field>_<value>`). An id that matches no row is ignored silently.\n */\n setRowExpanded(id: string, expanded: boolean): void\n /** Expand every group node in the current grouped row model. */\n expandAllGroups(): void\n /** Collapse every expansion - resets expanded state to {}. */\n collapseAllGroups(): void\n\n // ----- Undo / redo -----\n /** Undo the most recent inline-edit. Returns false when the history is empty. */\n undo(): boolean\n /** Redo the most recently undone edit. Returns false when the redo stack is empty. */\n redo(): boolean\n /** True when there's at least one step on the undo stack. */\n canUndo(): boolean\n /** True when there's at least one step on the redo stack. */\n canRedo(): boolean\n /** Wipe both stacks (e.g. after a server save commits the buffer). */\n clearHistory(): void\n /**\n * Mark every step recorded from now on with `tag`, until the next call;\n * `undefined` stops the marking. The grid never reads the tag. It is for a\n * consumer that shows one of several data sets through the grid (a\n * workbook's sheets, say) and has to know which one the next Ctrl+Z would\n * change before it lets the grid apply it: it tags the steps with the data\n * set they were made on and asks `peekUndo` first.\n */\n setHistoryTag(tag: unknown): void\n /** The step the next `undo()` would apply, or null when there is none. A\n * multi-cell action carries one tag, so it reports as one. */\n peekUndo(): { tag: unknown } | null\n /** The step the next `redo()` would apply, or null when there is none. */\n peekRedo(): { tag: unknown } | null\n\n // ----- Keyboard commands -----\n /**\n * The same `GridCommandContext` a registered shortcut handler receives.\n *\n * Keyboard commands are handed one on every keystroke, but a button in a\n * ribbon or a toolbar has no keystroke to ride in on, and re-implementing\n * \"bold the selection\" against the api would give the button and the key\n * two code paths that drift. Both go through this instead.\n *\n * Live, not a snapshot: the object reads through to the grid, so one built\n * once at mount still reports the current selection.\n */\n getCommandContext(): GridCommandContext\n\n // ----- Find in grid -----\n /** Open the built-in find overlay (Ctrl+F also opens it). */\n openFind(): void\n /** Close the find overlay and clear the query. */\n closeFind(): void\n /** Update the find query programmatically (useful for app-wide command palettes). */\n setFindQuery(q: string): void\n /** Snapshot of the current find hits (rowIndex / colIndex / columnId). */\n getFindHits(): Array<{ rowIndex: number; colIndex: number; columnId: string }>\n\n // ----- Row selection (read + write) -----\n /**\n * The currently selected data rows (group-header rows excluded), in row-model\n * order. Read once - no subscription. The push-based equivalent is\n * `onRowSelectionChange`.\n */\n getSelectedRows(): TData[]\n /** The engine row ids of the selected rows. Keys into the selection record. */\n getSelectedRowIds(): string[]\n /**\n * Select rows by engine row id. By default this REPLACES the selection;\n * pass `additive: true` to add to the existing selection instead.\n */\n selectRows(ids: ReadonlyArray<string>, additive?: boolean): void\n /** Select every selectable (non-group) row in the current row model. */\n selectAllRows(): void\n /** Flip one row's selected state by id. */\n toggleRowSelected(id: string): void\n\n // ----- Pagination -----\n /**\n * Current pagination snapshot. `total` is the post-filter row count;\n * `pageCount` is derived from it and `pageSize` (always >= 1).\n */\n getPageInfo(): {\n pageIndex: number\n pageSize: number\n pageCount: number\n total: number\n }\n /** Jump to a 0-based page. Clamped to [0, pageCount - 1]. */\n setPage(pageIndex: number): void\n /** Advance one page (no-op past the last page). */\n nextPage(): void\n /** Go back one page (no-op before the first page). */\n prevPage(): void\n /** Jump to the first page. */\n firstPage(): void\n /** Jump to the last page. */\n lastPage(): void\n /** Change the page size, keeping the first visible row in view. */\n setPageSize(pageSize: number): void\n\n // ----- Navigation / scrolling -----\n /**\n * Scroll the body so the given row index is at the top of the viewport.\n * Works with virtualization on. Index is clamped to the row count.\n */\n scrollToRow(rowIndex: number): void\n /** The active (focused) cell, or null when nothing is focused. */\n getActiveCell(): { rowIndex: number; colIndex: number; columnId: string } | null\n /** Move the active cell. Both coordinates are clamped to the grid bounds. */\n setActiveCell(rowIndex: number, colIndex: number): void\n\n // ----- View state (save / restore) -----\n /**\n * Serializable snapshot of the whole view - sort, grouping, pagination,\n * column layout, and every filter surface. Pair with `setState` for\n * \"save view\" / URL persistence / named views.\n */\n getState(): SvGridViewState\n /**\n * Restore a view from a (partial) snapshot produced by `getState`. Only the\n * keys present are applied, so you can restore just the columns, just the\n * filters, etc.\n */\n setState(state: Partial<SvGridViewState>): void\n /** Force a recompute of the row pipeline + a re-render. */\n refresh(): void\n}\n```\n\n### `type SvGridWrapperProps`\n\nThe props `<SvGrid>` accepts. See the SvGrid reference for the full list with defaults.\n\n```ts\nexport type SvGridWrapperProps<\n TFeatures extends TableFeatures,\n TData extends RowData,\n> = {\n data: ReadonlyArray<TData>\n columns: Array<ColumnDef<TFeatures, TData>>\n /**\n * Feature set from `tableFeatures({ ... })`. Optional - the `sortable` /\n * `filterable` / `groupable` shortcuts inject the matching feature, so a\n * grid can be configured from the boolean shortcuts alone.\n */\n features?: TFeatures\n /**\n * Capability shortcuts. Every capability is OFF by default (a bare grid is\n * a plain read-only table); set a shortcut `true` to opt in.\n *\n * `sortable` - column sorting (injects `rowSortingFeature`)\n * `filterable` - column filtering (injects `columnFilteringFeature`)\n * `editable` - inline editing (alias of `enableInlineEditing`)\n * `groupable` - grouping controls (alias of `showGroupingControls`)\n * `pageable` - pagination footer (alias of `showPagination`)\n */\n sortable?: boolean\n filterable?: boolean\n editable?: boolean\n groupable?: boolean\n pageable?: boolean\n options?: Partial<SvGridOptions<TFeatures, TData>>\n loading?: boolean\n error?: string | null\n emptyMessage?: string\n showGlobalFilter?: boolean\n showColumnFilters?: boolean\n showGroupingControls?: boolean\n showRowSelection?: boolean\n showPagination?: boolean\n virtualization?: boolean\n /** Row height in pixels. Pass a function `(rowIndex) => px` for per-row\n * variable heights (e.g. when wiring up an interactive row-resize). */\n rowHeight?: number | ((rowIndex: number) => number)\n overscan?: number\n containerHeight?: number\n columnVirtualization?: boolean\n columnOverscan?: number\n columnWidth?: number\n showFilterMenu?: boolean\n showFilterRow?: boolean\n enableCellSelection?: boolean\n enableInlineEditing?: boolean\n enableRowSummaries?: boolean\n /** Shortcut alias for `enableRowSummaries`; wins when both are set. */\n summary?: boolean\n /** Receives the imperative grid API when the component is ready. */\n onApiReady?: (api: SvGridApi<TFeatures, TData>) => void\n}\n```\n"
|
|
6860
6860
|
},
|
|
6861
6861
|
{
|
|
6862
6862
|
"slug": "reference/auto/svgrid-grid-timezone",
|
|
@@ -7108,8 +7108,8 @@ export const apiReference = {
|
|
|
7108
7108
|
]
|
|
7109
7109
|
};
|
|
7110
7110
|
export const apiSurface = {
|
|
7111
|
-
"gridVersion": "3.0.
|
|
7112
|
-
"enterpriseVersion": "3.1.
|
|
7111
|
+
"gridVersion": "3.0.11",
|
|
7112
|
+
"enterpriseVersion": "3.1.8",
|
|
7113
7113
|
"grid": {
|
|
7114
7114
|
"values": [
|
|
7115
7115
|
"ALL_FILTER_OPERATORS",
|