# Server selection: select all, minus these

The header checkbox selects every row the filter matches, loaded or not, and the selection becomes a rule: all except these ids, or per group under grouping. The panel shows getSelectionState() live, Save and Restore round-trip it, and a bulk action sends the rule to the server as one updateWhere that answers with the count it changed. The selection bar shows the server's number, not the ticks on screen.

Requires `@svgrid/enterprise` (and `@svgrid/grid`). Category: Server-Side Row Model. Live demo: https://svgrid.com/demos/471-server-selection/

Install: `npm i @svgrid/grid @svgrid/enterprise` (Svelte 5).

## About this example

Select-all across unloaded rows in a Svelte 5 data grid over server-side data. The header checkbox selects every row the current filter matches, loaded or not, and the selection becomes a rule rather than a list: all except these ids, or per group under grouping with groupSelects descendants. The panel shows the rule as getSelectionState reports it, Save and Restore round-trip it through setSelectionState, and a bulk action sends the rule to the server as one updateWhere call that answers with the number of rows it changed. The selection bar's count is the server's number, not the ticks on screen.

The header checkbox selects every row the filter matches, loaded or not, and the selection becomes a rule rather than a list: "all, except these ids", or per group under grouping. The panel shows that rule live, as `getSelectionState()` reports it; Save and Restore round-trip it; a bulk action sends the rule to the server as one `updateWhere` and the affected count comes back. The count chip on the selection bar is the server's number, not the number of ticks on screen.

The row model and the selection rules are Enterprise; the datasource contract the in-memory reference source implements is free.

## Imports, features and API used

- Imports: `@svgrid/grid`, `@svgrid/enterprise`, `../shared/mock-api`
- Table features registered: `rowSortingFeature`, `columnFilteringFeature`, `rowSelectionFeature`

## Frequently asked questions

### How can the grid select rows it has never loaded?

It does not keep a list. The selection is a rule: selectAll plus the ids toggled the other way. A row is selected when the rule says so, the count comes from the server's row count minus the exceptions, and a bulk edit sends the rule and the filter to updateWhere so the backend applies the change to exactly that set.

### What changes under grouping?

The rule nests per group: ticking a plan selects its subscribers, loaded or not, and unticking one of them records an exception on that plan. getSelectionState returns the nested shape and updateWhere receives it with the group columns, so the server can walk it.

### Does a filter change what select-all means?

Yes. The rule is evaluated against the filtered set: with a status filter open, select-all selects the matching rows only, the count reflects the filtered row count, and the bulk edit carries the filter model along with the rule.

## Related documentation

- [Server-Side Row Model (SSRM)](https://svgrid.com/docs/help/server/server-row-model.md)
- [Server selection - Enterprise](https://svgrid.com/docs/help/server/server-selection.md)

## Related articles

- [Stable Row Identity in SvGrid (and why it matters)](https://svgrid.com/blog/stable-row-identity-getrowid/) - How SvGrid tracks rows across mutations, why object reference stability is the key to correct selection and editing, and the exact patterns to use with live data feeds.
- [A Fill Handle (Drag to Fill) in SvGrid](https://svgrid.com/blog/fill-handle-drag-to-fill/) - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
- [Copy a Cell Range to the Clipboard in SvGrid](https://svgrid.com/blog/copy-cell-range-to-clipboard/) - How SvGrid copies selected cell ranges as tab-separated values that paste cleanly into Excel and Google Sheets, plus headers, programmatic copy, and format control.

## Source code (471-server-selection.svelte)

```svelte
<!-- Documented in: docs/help/server/server-selection.md -->
<script lang="ts">
  /**
   * 471. Server selection: select all, minus these
   * ----------------------------------------------
   * The header checkbox selects every row the filter matches, loaded or not,
   * and the selection becomes a rule rather than a list: "all, except these
   * ids", or per group under grouping. The panel shows that rule live, as
   * `getSelectionState()` reports it; Save and Restore round-trip it; a bulk
   * action sends the rule to the server as one `updateWhere` and the
   * affected count comes back. The count chip on the selection bar is the
   * server's number, not the number of ticks on screen.
   *
   * The row model and the selection rules are Enterprise; the datasource
   * contract the in-memory reference source implements is free.
   */
  import { SvGrid, renderComponent, tableFeatures, rowSortingFeature, columnFilteringFeature, rowSelectionFeature, type GridColumns } from '@svgrid/grid'
  import {
    installEnterprise,
    createInMemoryDataSource,
    createServerRowModel,
    serverGroupText,
    SvGroupCell,
    type EntitySchema,
    type ServerRowModel,
    type ServerRowModelState,
    type ServerRowModelGridRow,
    type ServerSelectionState,
    type ServerGroupSelectionNode,
  } from '@svgrid/enterprise'
  import { createPrng } from '../shared/mock-api'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })

  // ---- The server ----------------------------------------------------------
  type Subscriber = { id: number; email: string; plan: string; status: string; country: string; mrr: number; signedUp: string }
  const PLANS = ['Free', 'Pro', 'Team']
  const STATUS = ['active', 'active', 'active', 'paused', 'churned']
  const COUNTRIES = ['US', 'DE', 'UK', 'FR', 'JP', 'BR', 'IN', 'AU', 'CA', 'NL']
  const NAMES = ['ada', 'grace', 'linus', 'margaret', 'ken', 'barbara', 'dennis', 'donald', 'brian', 'tim']
  const ROWS = 100_000
  const rng = createPrng(0x5e1ec7)
  const DB: Subscriber[] = Array.from({ length: ROWS }, (_, i) => {
    const plan = rng.pick(PLANS)
    return {
      id: i + 1,
      email: `${rng.pick(NAMES)}.${(i + 1).toString(36)}@example.com`,
      plan,
      status: rng.pick(STATUS),
      country: rng.pick(COUNTRIES),
      mrr: plan === 'Free' ? 0 : plan === 'Pro' ? rng.int(9, 29) : rng.int(49, 199),
      signedUp: new Date(Date.now() - rng.int(1, 900) * 86_400_000).toISOString().slice(0, 10),
    }
  })
  const schema: EntitySchema<Subscriber> = {
    name: 'subscribers',
    fields: [
      { field: 'id', type: 'number', primaryKey: true, readonly: true },
      { field: 'email', type: 'text' },
      { field: 'plan', type: 'text' },
      { field: 'status', type: 'text' },
      { field: 'country', type: 'text' },
      { field: 'mrr', type: 'number' },
      { field: 'signedUp', type: 'date' },
    ],
  }
  // The reference backend: sort, filter, group, count and `updateWhere` (the
  // bulk edit by rule) over the array, behind a little latency.
  const memory = createInMemoryDataSource(DB, schema)
  let requests = $state(0)
  let lastBulk = $state('')
  const source: typeof memory = {
    ...memory,
    async getRows(req) {
      requests += 1
      await new Promise((r) => setTimeout(r, 120))
      return memory.getRows(req)
    },
    async updateWhere(filterModel, patch, rule) {
      await new Promise((r) => setTimeout(r, 200))
      const n = await memory.updateWhere(filterModel, patch, rule)
      lastBulk = `updateWhere: ${n.toLocaleString()} rows set to ${JSON.stringify(patch)}`
      return n
    },
  }

  // ---- The model ---------------------------------------------------------
  type Mode = 'flat' | 'grouped'
  type Row = ServerRowModelGridRow<Subscriber>
  let mode = $state<Mode>('flat')
  let view = $state<ServerRowModelState<Subscriber>>()
  let ctl = $state.raw<ServerRowModel<Subscriber>>(makeModel('flat'))
  function makeModel(m: Mode) {
    const model = createServerRowModel<Subscriber>(source, {
      groupBy: m === 'grouped' ? ['plan'] : [],
      // The count on the grand total is what makes "100,000 selected" a
      // number the model can stand behind under grouping.
      aggregations: [
        { col: 'mrr', fn: 'sum' },
        { col: 'id', fn: 'count' },
      ],
      grandTotalRow: 'pinnedBottom',
      getRowId: (r) => String(r.id),
      childCount: (r) => (r as { childCount?: number }).childCount,
      isGroupOpenByDefault: () => true,
      // Flat: one rule, "all except these". Grouped: a ticked plan ticks
      // its subscribers, loaded or not, and a leaf unticked under it is an
      // exception on that plan.
      selection: { groupSelects: m === 'grouped' ? 'descendants' : 'self' },
      blockSize: 100,
      skeletonRows: 5,
      filterValues: async (columnId) => [...new Set(DB.map((r) => String(r[columnId as keyof Subscriber])))].sort(),
      onChange: (s) => (view = s),
    })
    model.refresh()
    return model
  }
  function setMode(next: Mode) {
    if (next === mode) return
    ctl.dispose()
    mode = next
    saved = null
    ctl = makeModel(next)
  }
  $effect(() => () => ctl.dispose())

  // ---- The rule ------------------------------------------------------------
  type Rule = ServerSelectionState | ServerGroupSelectionNode
  const rule = $derived.by((): Rule | null => {
    void view
    return ctl.getSelectionState()
  })
  const selectedCount = $derived.by(() => {
    void view
    return ctl.selection?.selectedCount?.() ?? 0
  })
  // The rule as JSON, with long exception lists folded so it stays readable.
  const ruleText = $derived.by(() => {
    if (!rule) return 'null'
    const fold = (v: unknown): unknown => {
      if (Array.isArray(v)) return v.length > 12 ? [...v.slice(0, 12), `... ${v.length - 12} more`] : v
      if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fold(x)]))
      return v
    }
    return JSON.stringify(fold(rule), null, 2)
  })
  let saved = $state<Rule | null>(null)
  function save() {
    saved = ctl.getSelectionState()
  }
  function restore() {
    if (saved) ctl.setSelectionState(saved)
  }
  let busy = $state(false)
  async function bulk(patch: Partial<Subscriber>) {
    if (!selectedCount || busy) return
    busy = true
    try {
      await ctl.bulkUpdate(patch)
    } finally {
      busy = false
    }
  }
  // A rule worth saving is one that selects something.
  const hasRule = $derived(selectedCount > 0)
  // The leaf total: the count aggregate on the grand total, which is what
  // the selection model divides by too.
  const total = $derived(Number(view?.grandTotal?.id ?? view?.rowCount ?? ROWS))

  // ---- Columns --------------------------------------------------------------
  const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
  const groupColumn: GridColumns<Row>[number] = {
    id: 'group',
    header: 'Plan',
    width: 200,
    sortable: false,
    filterable: false,
    fieldFn: (row) => serverGroupText(row, 'email'),
    cell: (ctx) =>
      renderComponent(SvGroupCell, {
        row: ctx.row.original,
        onToggle: () => ctl.group.onToggle(ctx.row.original),
        leafField: 'email',
      }),
  }
  const idColumn: GridColumns<Row>[number] = {
    field: 'id',
    header: 'Id',
    width: 70,
    align: 'right',
    editable: false,
    formatter: ({ value, row }) => (row?.original.__group?.kind === 'leaf' ? String(value) : ''),
  }
  const rest: GridColumns<Row> = [
    { field: 'status', header: 'Status', width: 90, editorType: 'select', editorOptions: ['active', 'paused', 'churned'] },
    { field: 'country', header: 'Country', width: 80 },
    { field: 'mrr', header: 'MRR', width: 90, align: 'right', format: usd },
    { field: 'signedUp', header: 'Signed up', width: 110 },
  ]
  const flatColumns: GridColumns<Row> = [
    idColumn,
    // The pinned grand total is formatted from the accessor with no row.
    { field: 'email', header: 'Email', width: 200, formatter: ({ value, row }) => (row ? String(value ?? '') : 'Grand total') },
    { field: 'plan', header: 'Plan', width: 80 },
    ...rest,
  ]
  const groupedColumns: GridColumns<Row> = [groupColumn, idColumn, ...rest]
  const columns = $derived(mode === 'grouped' ? groupedColumns : flatColumns)
</script>

<section class="wrap demo-kit">
  <header class="chrome">
    <div class="seg mode-seg" role="group" aria-label="Layout">
      <button type="button" class:is-on={mode === 'flat'} aria-pressed={mode === 'flat'} onclick={() => setMode('flat')}>Flat</button>
      <button type="button" class:is-on={mode === 'grouped'} aria-pressed={mode === 'grouped'} onclick={() => setMode('grouped')}>Grouped by plan</button>
    </div>
    <div class="actions">
      <button type="button" class="btn" disabled={!selectedCount || busy} onclick={() => bulk({ status: 'paused' })} title="One updateWhere with the rule; the server answers with the count it changed">{busy ? 'Applying...' : 'Pause selected'}</button>
      <button type="button" class="btn" disabled={!selectedCount || busy} onclick={() => bulk({ status: 'active' })} title="The same updateWhere, the other way">Activate selected</button>
      <button type="button" class="btn" disabled={!hasRule} onclick={save} title="Keep the rule as data (getSelectionState)">Save rule</button>
      <button type="button" class="btn" disabled={!saved} onclick={restore} title="Put the saved rule back (setSelectionState)">Restore rule</button>
    </div>
    <span class="note">
      Tick the header checkbox: 100,000 rows are selected and the grid has loaded a hundred. Untick a few
      and the rule reads "all except these"; open a column filter first and the rule applies to the
      filtered set. Under grouping a plan ticks its subscribers, loaded or not.
    </span>
  </header>
  <div class="body">
    <div class="gridpane">
      {#key mode}
        <SvGrid
          responsive={true}
          columnResize
          fitColumns
          rowModel={ctl}
          stickyGroupRows
          {columns}
          {features}
          sortable
          filterable
          filterMode="menu"
          showRowSelection
          selectionBar={['selectAll', 'editFields']}
          rowHeight={32}
          containerHeight="100%"
          onApiReady={(next) => installEnterprise(next)}
        />
      {/key}
    </div>
    <aside class="rule" aria-label="Selection rule">
      <div class="rule-head">getSelectionState() <span class="muted">live</span></div>
      <pre class="rule-body">{ruleText}</pre>
      {#if saved}<div class="rule-saved">Saved: {'selectAll' in saved ? `${saved.selectAll ? 'all' : 'none'}, ${saved.toggled.length} exception${saved.toggled.length === 1 ? '' : 's'}` : 'a per-plan rule'}</div>{/if}
    </aside>
  </div>
  <footer class="foot">
    <span class="stat" data-stat="selected"><span class="stat-label">Selected</span><strong>{selectedCount.toLocaleString()}</strong> of {total.toLocaleString()}</span>
    <span class="stat"><span class="stat-label">Loaded</span><strong>{(view?.gridRows.filter((r) => r.__group?.kind === 'leaf').length ?? 0).toLocaleString()}</strong> rows</span>
    <span class="stat"><span class="stat-label">Requests</span><strong>{requests}</strong></span>
    {#if view?.error}<span class="stat err">{String((view.error as Error).message ?? view.error)}</span>{/if}
    {#if lastBulk}<span class="stat last">{lastBulk}</span>{/if}
  </footer>
</section>

<style>
  /* Only what is particular to this demo; the chrome is the shared demo-kit. */
  .rule {
    width: 280px;
    flex: none;
    display: flex;
    flex-direction: column;
    min-height: 0;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px;
    background: var(--sg-bg, #fff);
    font-size: 12px;
  }
  .rule-head {
    padding: 8px 10px;
    font-weight: 600;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    color: var(--sg-fg, #0f172a);
    background: var(--sg-header-bg, #f8fafc);
    border-bottom: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px 10px 0 0;
  }
  .rule-body {
    flex: 1;
    min-height: 0;
    overflow: auto;
    margin: 0;
    padding: 10px;
    font-size: 11.5px;
    line-height: 1.45;
    color: var(--sg-fg, #0f172a);
  }
  .rule-saved { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); color: var(--sg-muted, #64748b); }
  @media (max-width: 900px) {
    .rule { width: auto; max-height: 180px; }
  }
</style>
```

[View this example on GitHub](https://github.com/sv-grid/sv-grid/blob/main/examples/src/demos/471-server-selection.svelte)
