---
id: "packages/active-grid/server-api"
title: "Server API"
description: "The server-mode types, the column query contract and the resource-mode adapter."
url: "/packages/active-grid/server-api"
locale: "en"
revision: "2d1190202513ce24"
---

# Server API

> [!NOTE]
> **The package's own reference**
>
> The text below is the package's own API reference, included unchanged. The
> complete list of exports, generated from the source, is on [All
> exports](/packages/active-grid/api).

`ServerFetchFunction`, `ServerFetchParams`, `ServerOptions`, `PagedResponse`,
`useGridMutation`, `useGridImperative`. These types are mode-agnostic: they
describe the fetch contract and the mutation API regardless of where the rows
come from. For server-mode data access, the row source is a resource view via
`resource-mode.md` (`createResourceServerFetchFn`). For
tutorial-style how-to see
`../guides/server-mode.md`.

## ServerFetchFunction

```typescript
import {type ServerFetchFunction} from '@workspace/active-grid';
```

```typescript
type ServerFetchFunction<TData> = (
  params: ServerFetchParams,
) => Promise<PagedResponse<TData>>;
```

### ServerFetchParams

| Property | Type | Description |
| --- | --- | --- |
| `pageIndex` | `number` | Zero-based fetch-block index. |
| `fetchSize` | `number` | Engine's fetch-block size (rows requested in this server call). Independent of UI page size — pass through to your Prisma `take`. |
| `sorting` | `SortingState` | Array of `{id: string, desc: boolean}`. |
| `filters` | `ColumnFiltersState` | Array of `{id: string, value: FilterValue}`. |
| `globalFilter` | `string \| undefined` | Global search term. |
| `signal` | `AbortSignal` | AbortSignal for cancelling in-flight requests on rapid filter/sort changes. |

### ServerOptions

```typescript
import {type ServerOptions} from '@workspace/active-grid';
```

Options for server-mode data fetching, passed via the `serverOptions` prop on
`ActiveGrid`.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `cacheSize` | `number` | `10` | Maximum number of blocks to keep in memory. |
| `prefetch` | `boolean` | `true` | Prefetch the next block when approaching a block boundary. |
| `blockSize` | `number` | `200` | Rows per block. Grid fetches one block and distributes it across multiple visible pages locally. Navigation within a block is instant; only block boundaries trigger a server fetch. The block size lands on `ServerFetchParams.fetchSize`. |

```tsx
<ActiveGrid
  mode="server"
  fetchFn={fetchOrders}
  serverOptions={{cacheSize: 10, prefetch: true, blockSize: 200}}
/>
```

#### Valid ranges

`blockSize` and `cacheSize` are validated at render in server mode. Both must be
a **finite integer of at least 1**; anything else (`0`, a negative number,
`NaN`, `Infinity`, a non-integer, a non-number) throws a **GridConfigurationError** with
code **GRID\_SERVER\_OPTIONS\_INVALID** naming the offending field.

That is deliberate: before this validation existed, every wrong value failed
silently and differently. `0` and negatives collapsed `pagesPerBlock` to `1`
(the block optimisation quietly off, one server call per visible page), and
`NaN` propagated into `ServerFetchParams.fetchSize`, so the server was asked for
`NaN` rows. Neither produced a message.

`prefetch` is **not** runtime-validated. Its contract (`prefetch !== false`) is
total, and no boolean prop is runtime-checked anywhere in this package; it is
left to the type system rather than growing a new convention for one field.

#### `blockSize` versus the UI page size

They are independent and must stay that way.

| Concept | Owner | Default | Meaning |
| --- | --- | --- | --- |
| UI page size | `config/defaults` | `10` | Rows the user sees at once; options `[10, 20, 50, 100]`. |
| `blockSize` | `serverOptions` | `200` | Rows fetched per server call. |
| `pagesPerBlock` | derived | `floor(block/page)` | How many visible pages one block serves. |
| `fetchSize` | derived | `pagesPerBlock × pageSize` | What `ServerFetchParams.fetchSize` carries. |

A `blockSize` **smaller than** the page size is valid but **inert**:
`pagesPerBlock` floors to `1`, so a block holds exactly one visible page and
every page change becomes a server call. Nothing in the UI reveals that, so the
grid emits a dev-only warning naming the grid, the block size and the page size,
once per combination.

Never derive one from the other in consumer code. A page-size change is a
dataset change (it is part of the dataset key and invalidates the block cache);
a block-size change is a transport tuning knob.

### PagedResponse

```typescript
import {type PagedResponse} from '@workspace/active-grid';
```

| Property | Type | Description |
| --- | --- | --- |
| `data` | `TData[]` | Row data for the current page. |
| `totalCount` | `number` | Total number of rows across all pages. |
| `facets` | `Record<string, FacetOption[]> \| undefined` | Server-calculated filter options per column. |
| `aggregations` | `Record<string, number \| string> \| undefined` | Server-calculated aggregation values per column. |

### FacetOption

| Property | Type | Description |
| --- | --- | --- |
| `label` | `string` | Display label. |
| `value` | `string` | Filter value. |
| `count` | `number` | Number of rows with this value. |

## Where server-mode rows come from

`active-grid` resolves no server-side query and has no server entry point.
Server-mode rows come from a resource view in `@workspace/active-resources`,
connected with `createResourceServerFetchFn` (`resource-mode.md`).
The ownership split between the grid, the adapter and the server, and the
conditions for a non-resource exception, are in
`../ARCHITECTURE.md`.

## Which props reach the datasource

Two groups, and the boundary between them is a contract: a presentation prop
must never be able to stop, start or alter a fetch. R-32 was diagnosed for
weeks against three props on the wrong side of this line, so it is written down
rather than left to be inferred.

**Datasource props — changing one can start, stop or change a fetch:**

| Prop | Effect |
| --- | --- |
| `mode` | Server mode is what makes a datasource exist at all. |
| `fetchFn` | Its **identity** matters — see below. |
| `serverOptions.blockSize` / `.cacheSize` | Block geometry and therefore `fetchSize`. |
| `serverOptions.retry*` | Retry behaviour of a failed fetch. |
| `pagination.pageSizeOptions` / `initialPageSize` | Page size is part of the dataset key. |
| `serverCapabilities` | Decides which sort/filter/group state may exist, and prunes stale hydrated state before the first fetch. |
| `enablePersistence` / `enableUrlSync` / `statePriority` | Decide the state the first fetch is made WITH. |
| `pagination.mode: 'infinite'` | Switches to accumulate-blocks. |

**Presentation-only props — none of these can reach the datasource:**

`settings` (including `settings.showFilterRow`), `toolbar`, `headerMenu`,
`contextMenu`, `rowMenu`, `loadingDisplay`, `height`, `autoSizeOnMount`,
`columnTypes`, `columnDefaults`, and every `column.meta` field except
`_serverFilterCapability` (which the grid itself stamps from
`serverCapabilities`).

In particular: hiding the filter row — with `settings.showFilterRow: false`, by
omitting `meta.filterType`, or because no column declares a filter capability —
changes **visibility only**. It does not start or stop a fetch, and it does not
change `ServerFetchParams.filters`.

### The initial fetch

An initial fetch for the block containing the hydrated page, once the grid's
initial state has resolved from every
external source (URL, `localStorage`). It carries the hydrated sorting, filters
and global search, so a deep-linked filter produces exactly one already-filtered
query rather than an unfiltered one followed by a filtered one.

Two consequences worth knowing:

- **A superseded request is cancelled by the request that replaces it**, not by
  an effect cleanup. Paginating to another block while the first request is
  still open therefore yields the block you navigated to. (ADR-025)
- **A server-mode grid that never fetches looks exactly like one that is still
  fetching**, because `isLoading` starts `true` and the default first-load
  indicator is `'none'` (ADR-021). In development the grid
  therefore warns when its datasource has been idle for a few seconds without a
  request, naming the grid and the blocking condition. It is a diagnostic: it
  changes no UI and never retries.

### The external query

The grid owns its own page, sort, column filters and search, and already treats
a change in any of those as a new result set. A page can carry a SECOND set of
controls outside the grid that narrows the same query server-side: a search box
in a page toolbar, a folder picker, a kind filter carried in the URL. The grid
cannot see those.

Tell it with `serverQueryKey` — an opaque string built from the values
themselves:

```tsx
const serverQueryKey = `${q}|${kind}|${folder ?? ''}`;

const fetchFn = useMemo(
  () =>
    createResourceServerFetchFn<Row>({
      serverAction: getCoreResourceData,
      viewKey: 'core.things',
      scope: {q, kind, folder},
    }),
  [serverQueryKey], // ← follow the key, not the object identity
);

<ActiveGrid
  mode="server"
  fetchFn={fetchFn}
  serverQueryKey={serverQueryKey}
  pagination={{enabled: true}}
/>;
```

A change gives the result set the same treatment the grid's own filters get:
the block cache is invalidated, a fetch goes out, `pageIndex` returns to 0 and
the row selection is cleared. There is no second fetch path — the key simply
joins the dataset key.

**Why the page reset matters on its own.** Without it, narrowing from twelve
pages to one leaves the reader on page 5 of a result set that no longer has
one: an empty body with nothing on screen explaining why.

**Why the selection is cleared.** A selection is a set of row IDs belonging to
the PREVIOUS question. Carried across a scope change, a bulk action would act
on rows the reader can no longer see.

**Build it from the values, never from a counter.** Returning to a previous
filter state should be the previous result set, not a third one.

Leave the prop undefined when the grid owns the whole question. The dataset key
is then byte-identical to what it was before the prop existed, so no existing
grid changes behaviour.

#### `fetchFn` identity is the older, weaker signal

Before `serverQueryKey` the only signal was the **identity** of `fetchFn`: a
new function invalidates the cache and refetches. That still works and is still
what a memoised `fetchFn` should follow, but it does two things `serverQueryKey`
does not — it does not reset the page and it does not clear the selection, and
it is easy to get wrong in both directions. A `fetchFn` memoised on `[]` while
its scope changes leaves the grid showing the previous answer, with no error
and no warning; a `fetchFn` re-created on every render invalidates the cache on
every render. Prefer the explicit key, and memoise `fetchFn` on that same key.

### Route query keys can collide with `enableUrlSync`

`enableUrlSync` claims exactly five query keys: `page`, `pageSize`, `sort`,
`filters`, `search` (ADR-023). It merges rather than
replaces, so other parameters survive — but a page that owns a key of the same
NAME shares it with the grid and both will write to it.

`page` and `sort` are the realistic collisions. If your route already owns
either (its own pager, its own sort control, its own grammar such as
`sort=name-asc`), do not enable `enableUrlSync` on that grid: the grid would
read `page=2` as its own page index and re-serialise `sort` in `id:asc` form.
Either the route owns the URL or the grid does.

## useGridMutation

```typescript
import {
  useGridMutation,
  type GridMutationOptions,
  type GridMutationResult,
  type GridMutationStrategy,
  type UseGridMutationResult,
} from '@workspace/active-grid';
```

**The required consumer API** for mutation-flows on server-mode grids
(ADR-009). Runs an async action and applies a named grid-strategy on
success, tracking `isPending` — all without remounting the grid.

```tsx
const gridRef = useRef<GridImperativeHandle>(null);
const mutation = useGridMutation(gridRef);

const onDelete = async (id: string) => {
  const result = await mutation.mutate(id, {
    action: (rowId) => softDeleteAction(rowId),
    onSuccess: {type: 'removeRow', rowId: id},
  });
  if (result.success) toast.success('Deleted');
  else toast.error(result.error.message);
};
```

### Strategies

| Strategy | Behavior |
| --- | --- |
| `{type: "removeRow"}` | Patches the block cache, decrements `totalCount`. Preserves session-state. |
| `{type: "addRow"}` | Inserts a row, increments `totalCount`. |
| `{type: "refetch"}` | Invalidates the cache and re-fetches the current page. |
| `{type: "resetSelection"}` | Clears all row selections (typical after a bulk action). |
| `{type: "none"}` | No grid-side action; consumer handles reconciliation elsewhere. |

### Compose strategies

Pass an **array** to compose strategies — canonical after a bulk delete:

```tsx
const result = await mutation.mutate(formData, {
  action: (fd) => deleteRowsAction(fd),
  onSuccess: [{type: 'refetch'}, {type: 'resetSelection'}],
});
```

### `mutation.isPending`

Toggles while the action is in flight — wire it to your dialog's
`isLoading` prop. The return type of `mutate` is inferred from the action's
result-shape, so typed errors survive `result.success === false` narrowing
without casts.

### Anti-pattern: `<ActiveGrid key={...}>`

**Never** use `<ActiveGrid key={mutationCounter}>` to force a refresh — that
remounts the whole grid and discards all session state. A dev-time
detector emits a warn-once log when the same `gridId` mounts twice outside
the React-Strict-Mode noise window (100 ms).

## useGridImperative

```typescript
import {useGridImperative} from '@workspace/active-grid';
```

Typed wrapper that returns stable `{refetch, removeRow, addRow,
resetSelection}` callbacks. The package considers this the
**package-internal primitive** that `useGridMutation` is built on.
Production consumer-code must go through `useGridMutation` — three
exception classes may call the primitive directly:

1. Library-internal tests inside `@workspace/active-grid` itself.
2. Demo / showcase pages that exercise the primitive directly without a
   preceding async action.
3. Pure UI commands tied to a button (e.g. a "Deselect" outside a mutation
   flow).

## Client-mode mutations

In `mode="client"`, `gridRef.removeRow`/`addRow` are no-ops with a dev-time
warning; the host mutates its own `data`. Pattern:
`../guides/server-mode.md` § Client-mode mutations.

## Decimal serialization

Prisma's `Decimal` type (used for `@db.Decimal(p, s)` columns) is a runtime
`Decimal.js`-instance, not a primitive. It does not cross the Next.js
Server-Action boundary cleanly — the React serializer rejects it with
"Only plain objects can be passed".

`@workspace/active-grid` exports `serializeDecimals` as a re-export from
`@workspace/active-utils/serialization`:

```ts
import { serializeDecimals } from "@workspace/active-grid";

// In a server-action:
const rows = await dbCore.invoice.findMany({ where: { ... } });
return { success: true, data: { rows: serializeDecimals(rows), total } };
```

### What it does

`serializeDecimals(value)` walks the entire value graph (objects, arrays,
nested types) and replaces every Prisma `Decimal`-instance with its
`number` equivalent via `.toNumber()`. Primitives, `Date`, `RegExp`, and
plain objects pass through unchanged.

| Input field type | Output type |
| --- | --- |
| `Decimal` (Prisma) | `number` |
| `Decimal \| null` | `number \| null` |
| `Date` | `Date` (unchanged) |
| `string \| number \| bool` | unchanged |
| Nested object / array | recursively walked |

### When to call it

Always at the **server-action output boundary** when the response payload
contains Prisma `Decimal`-fields. Resource views in
`@workspace/active-resources` apply the serializer as part of the data-action
pipeline, so resource-backed grids need no extra call. For custom
server-actions that return rows with Decimal fields, call it explicitly:

```ts
"use server";
export async function getInvoices() {
  const invoices = await dbCore.invoice.findMany({ ... });
  return { success: true, data: serializeDecimals(invoices) };
}
```

### Consumer-side type

After `serializeDecimals` runs, the type from Prisma (`Decimal`) is in
practice `number` on the client. TypeScript does not narrow this — the
Prisma-typed value still has `Decimal` in its type. Two options at the
client-side:

```ts
// Option A: cast at the consumer-boundary (single place)
const rows = result.data as InvoiceWithNumberDecimal[];

// Option B: Zod-parse if you have an explicit response-schema
const rows = invoiceResponseSchema.parse(result.data);
```

Use option A when the server action already owns this validation.

### Note on `decimal.js`

`decimal.js` is a transitive dependency of `@prisma/client`, not a direct
dependency of `@workspace/active-utils`. Do not import from `decimal.js`
in consumer code — runtime resolution is fragile across worker boundaries.
The `serializeDecimals` helper duck-types via `.toNumber()` to avoid the
`instanceof` brittleness.

Per-column declarative markers describing which runtime capabilities
(sort / filter / globalFilter / groupBy / aggregate) a column supports. Lives
on `GridColumnDef.server`, typed `ServerColumnContract`.

## What the contract is for now

The contract is **client-side only**. `active-grid` no longer resolves
server-side queries: there is no `/server` subpath and no in-package query
resolver. Server-mode data access goes through `@workspace/active-resources`
(see `resource-mode.md`), where the model, permission,
`select` and every `where` fragment live server-side and never cross the wire.

`column.server` supplies client-side capability metadata and the grid's hydration path
(`extractWireContract`, called inside `use-grid-setup`) reads it to **prune a
stale persisted / URL sort or filter that the column does not declare**
(TD-3 contract-aware hydration pruning). If a saved view or URL carries a sort
on a column whose contract has no `sort` mapping, that instruction is dropped
before the first fetch rather than sent to a server that would reject it. No
server-side resolver consumes these mappings. Column enhancement also uses the
declared sort/filter mappings to resolve UI capabilities; grouping and toolbar
affordances read their corresponding markers. These declarations do not execute
a server query or independently authorize a server operation.

For the historical architecture of the sort/filter foundation see
ADR-013, the global
target R-22, and groupBy + aggregate
ADR-017.

## Serializable by construction

`column.server` values are plain JSON — a tagged union of **serializable**
mapping shapes. Every helper below returns one of these tagged objects.
Because the contract is a declaration (not a closure), it survives
persistence, URL-sync and any future wire transit unchanged.

## ServerColumnContract

```typescript
import {type ServerColumnContract} from '@workspace/active-grid';
```

The contract-shape attached to `column.server`. Five sub-keys; all values
are serializable.

| Sub-key | Shape | Declares |
| --- | --- | --- |
| `sort` | `false \| SerializableSortMapping` | The column supports server-side sort (survives hydration). |
| `filter` | `false \| SerializableFilterMapping` | The column supports server-side filter (survives hydration). |
| `global` | `false \| SerializableGlobalMapping` | The column participates in the toolbar single-search input. |
| `groupBy` | `false \| SerializableGroupByMapping` | The column supports server-side grouping. |
| `aggregate` | `false \| SerializableAggregateMapping` | The aggregations available for this column in a server groupBy. |

Each sub-key is **independent** — declaring `filter` does not imply `sort`.
Setting a sub-key to `false` explicitly marks the capability as absent, so a
stale persisted/URL instruction for it is pruned at hydration.

```typescript
import {
  scalarField,
  someByField,
  globalField,
  aggregateScalar,
  type ServerColumnContract,
} from '@workspace/active-grid';

const userColumns: GridColumnDef<User>[] = [
  {
    accessorKey: 'email',
    header: 'Email',
    server: {
      ...scalarField(), // sort + filter on column-id "email"
      global: globalField(), // joins the toolbar search
    } satisfies ServerColumnContract,
  },
  {
    id: 'roles',
    accessorFn: (r) => r.roles.map((x) => x.name).join(', '),
    header: 'Roles',
    server: {
      filter: someByField({
        relation: 'userRoles',
        path: 'role.name',
        filterType: 'select',
      }),
    },
  },
  {
    accessorKey: 'salary',
    header: 'Salary',
    server: {
      ...scalarField(),
      aggregate: aggregateScalar({ops: ['sum', 'avg']}),
    },
  },
];
```

## scalarField — combined sort + filter

```typescript
import {scalarField} from '@workspace/active-grid';
```

The 80%-case helper: fills both `sort` and `filter` for a plain scalar
column. Returns `Pick<ServerColumnContract, "sort" | "filter">` —
spread it onto `column.server` and add other sub-keys as needed.

| Signature | Behavior |
| --- | --- |
| `scalarField()` | Uses the column-id as the field path. Deferred resolution when the column-id is dotted (`user.email`). |
| `scalarField("user.email")` | Explicit nested path; both `sort` and `filter` use this path. |

```typescript
ch.accessor('email', {
  header: t('user.email'),
  server: scalarField(),
});

ch.accessor('ownerName', {
  header: t('entity.owner'),
  server: scalarField('owner.displayName'), // nested 1:1 relation
});
```

## Sort helpers

```typescript
import {byField, byCount} from '@workspace/active-grid';
```

| Helper | Returns | Declares |
| --- | --- | --- |
| `byField(path?)` | `SortFieldMapping` | Sort on `<path ?? columnId>`. |
| `byCount(relation)` | `SortCountMapping` | Sort on the count of a relation (`<relation> _count`). |

```typescript
// Sort users by number of assigned roles.
ch.accessor('roleCount', {
  header: t('user.roleCount'),
  server: {sort: byCount('userRoles')},
});
```

## Filter helpers

```typescript
import {
  fieldFilter,
  multiField,
  someByField,
  everyByField,
  noneByField,
} from '@workspace/active-grid';
```

| Helper | Returns | Description |
| --- | --- | --- |
| `fieldFilter(path?, {filterType?})` | `FilterFieldMapping` | Scalar field; if `path` is omitted, column-id is used. `filterType` disambiguates when operator is ambiguous (`equals` text/number/date). |
| `multiField(paths, {filterType?})` | `FilterMultiFieldMapping` | OR across multiple scalar paths. |
| `someByField({relation, path, filterType?})` | `FilterRelationMapping` | Passes if at least one related record matches (`relation: {some: ...}`). |
| `everyByField({relation, path, filterType?})` | `FilterRelationMapping` | Passes only if every related record matches (`relation: {every: ...}`). |
| `noneByField({relation, path, filterType?})` | `FilterRelationMapping` | Passes only if no related record matches (`relation: {none: ...}`). |

```typescript
// Multi-field OR — search "name" across firstName + lastName + nickname.
ch.accessor('name', {
  header: t('user.name'),
  server: {
    filter: multiField(['firstName', 'lastName', 'nickname']),
  },
});

// "Users that have any role with name X"
ch.accessor('roles', {
  header: t('user.roles'),
  server: {
    filter: someByField({
      relation: 'userRoles',
      path: 'role.name',
      filterType: 'select',
    }),
  },
});
```

## Global-search helpers

```typescript
import {
  globalField,
  globalSomeByField,
  globalEveryByField,
  globalNoneByField,
} from '@workspace/active-grid';
```

Mark a column as a target of the toolbar single-search input
(`<ActiveGrid toolbar={{search: {enabled: true}}}>`). Columns without a
`global`-mapping silently sit out the search — fail-closed by design.

| Helper | Returns |
| --- | --- |
| `globalField(path?, {operator?})` | `GlobalFieldMapping` |
| `globalSomeByField({relation, path, operator?})` | `GlobalRelationMapping` |
| `globalEveryByField({relation, path, operator?})` | `GlobalRelationMapping` |
| `globalNoneByField({relation, path, operator?})` | `GlobalRelationMapping` |

Default operator is `"contains"` (case-insensitive). Override with
`"startsWith"` for prefix-match or `"equals"` for exact-match. See
`../guides/server-mode.md` § "Global search"
for the UX trade-off discussion around relation-search.

## GroupBy helpers

```typescript
import {groupByField, groupByRelation} from '@workspace/active-grid';
```

Declarative server-side grouping markers. Runtime activation requires
`enableGrouping`, `enableServerGrouping` and ordered `serverGroupBy`
declarations. Aggregate capability comes from the server; the former
`serverGroupAggregations` prop was removed. See Server grouping.

| Helper | Returns | Behavior |
| --- | --- | --- |
| `groupByField(path?)` | `GroupByFieldMapping` | Scalar group-by marker on `path ?? column-id`. Works for any scalar including 1:1 FK columns. |
| `groupByRelation({relation, path})` | `GroupByRelationMapping` | **Reserved** — type-shape exists for forward compatibility, but the runtime does not activate it. Use `groupByField("<fk>")` for 1:1 FK grouping. |

Scalar group-by only accepts scalar field-names. One-to-many relation grouping
is defined server-side in the resource view rather than declared here: for a
1:1 relation, group on the foreign-key scalar with `groupByField('<fkScalar>')`;
for a one-to-many relation, the resource view defines the grouping.

```typescript
ch.accessor('category', {
  header: t('order.category'),
  server: {
    ...scalarField(),
    groupBy: groupByField(),
  },
});
```

## Aggregate helpers

```typescript
import {
  aggregateScalar,
  aggregateCount,
  type AggregateOp,
} from '@workspace/active-grid';
```

Declarative per-column aggregations available in a server group-by.

| Helper | Returns | Behavior |
| --- | --- | --- |
| `aggregateScalar({path?, ops})` | `AggregateScalarMapping` | Numeric aggregation block. `ops` is a non-empty subset of `AggregateOp` (`"sum" \| "avg" \| "min" \| "max" \| "count"`). |
| `aggregateCount({relation?})` | `AggregateCountMapping` | `_count` block. `relation: undefined / null` → count all rows; `relation: "userRoles"` → count of that relation. |

Note on `count`: inside `aggregateScalar` it is the **non-null-count** on
the scalar field. For row-counts or relation-counts use `aggregateCount`.

Aggregations are always declared explicitly per column: auto-aggregate is
semantically dangerous (SUM on a `versionNumber` column is valid but
nonsensical; AVG on a `userId: Int` is meaningless).

```typescript
ch.accessor('salary', {
  header: t('employee.salary'),
  server: {
    ...scalarField(),
    aggregate: aggregateScalar({ops: ['sum', 'avg']}),
  },
});

// Row-count per group, no specific field.
ch.accessor('rowCount', {
  header: t('group.rowCount'),
  server: {aggregate: aggregateCount()},
});
```

## Serializable mapping types

The wire-types behind the helpers. Consumers rarely need to construct
these directly — prefer the helpers — but the types are exported for
typed metadata-tables and library composition.

```typescript
import {
  type SerializableSortMapping,
  type SortFieldMapping,
  type SortCountMapping,
  type SerializableFilterMapping,
  type FilterFieldMapping,
  type FilterMultiFieldMapping,
  type FilterRelationMapping,
  type SerializableGlobalMapping,
  type GlobalFieldMapping,
  type GlobalRelationMapping,
  type SerializableGroupByMapping,
  type GroupByFieldMapping,
  type GroupByRelationMapping,
  type SerializableAggregateMapping,
  type AggregateScalarMapping,
  type AggregateCountMapping,
  type AggregateOp,
} from '@workspace/active-grid';
```

Each mapping carries a `_kind`-discriminator (e.g. `"sort:field"`,
`"filter:relation"`, `"global:field"`, `"groupBy:relation"`,
`"aggregate:scalar"`). The discriminator is how the grid's hydration pruning
identifies each mapping — do not omit or rename it.

The resource adapter is the thin client half of the server-defined
resource-view pipeline (the active-resources package). It is the **only**
server-mode data path in `active-grid`: the browser transmits nothing but the
server-defined view key plus the interaction state (filters, sorting, global
search, paging). Model, permission, `select` and every `where` fragment live
server-side in the resource declaration and never cross the wire.

```ts
import {
  ActiveGrid,
  createResourceServerFetchFn,
  type CreateResourceServerFetchFnConfig,
  type GridServerCapabilities,
  type ResourceServerAction,
} from '@workspace/active-grid';
```

## `createResourceServerFetchFn<TRow>(config)`

Builds a `ServerFetchFunction<TRow>` on top of a resource data-action
(`ResourceServerAction`, produced by the active-resources data-action
factory). It maps the grid's fetch params to the neutral
wire-request `{viewKey, pageIndex, fetchSize, filters, sorting, globalFilter,
scope?, preset?, groupBy?, groupKeys?}` and unwraps the response.

`CreateResourceServerFetchFnConfig<TRow>` fields:

- `serverAction` — the resource data-action for the target database.
- `viewKey` — the server-defined view key (the only view-selection value sent).
- `scope?` — optional client-supplied, server-verified scope (value or
  `(params) => value`).
- `preset?` — optional server-fixed preset key.
- `transform?` — optional row mapper (defaults to identity).
- `onError?` — optional error callback with the failed read's message. It
  fires once per logical fetch, and only when that fetch fails for good: after
  the grid's retries (`serverOptions.retryCount`) are spent, where the grid
  shows its error state. A read fails when the action answers
  `{success: false}` (the message is its `error.message`), when the action
  rejects or throws (the message is the error's `message`), and when
  `transform` throws on a row. With an `onError`, a rejection that is not an
  `Error` is thrown on as an `Error` with the original value as its `cause`
  and the message `Failed to load data`. A failed attempt that a retry redeems, or that a
  newer request supersedes (an aborted request), never calls it. Each failed
  attempt still throws, so the grid can retry. A callback that itself throws
  is not contained by the grid.
- `capabilities?` — optional dev-only drift check against the grid's
  `serverCapabilities` prop; the prop always wins.

## The external query: `serverQueryKey`

A resource `scope` is computed per fetch and never re-fetches by itself. When
the page owns controls OUTSIDE the grid that feed that scope — a toolbar search
box, a folder picker, a URL-carried kind filter — give the grid
`serverQueryKey`, an opaque string built from those values. A change invalidates
the cache, refetches, resets `pageIndex` to 0 and clears the row selection. Full
rules and the reasoning: server-mode.md.

## `GridServerCapabilities`

Server-declared UI capabilities for a resource-backed grid, passed to
`<ActiveGrid serverCapabilities={...}>`. A FULL structural mirror of
active-resources' ResourceCapabilities type (`{filter, sort, group, aggregate,
search, presets}`) — pass the resource's `deriveCapabilities` map straight
through, unnarrowed. It is the single source of the grid's interaction
surface **down to operator granularity**: the setup synthesizes the internal
per-column contract from it (no per-column `server` marker), stamps the
per-field filter capability on `column.meta`, drives the filter components'
operator lists, filter semantics and nullability, prunes stale persisted/URL
sort/filter state (including a filter whose operator is no longer allowed)
before the first fetch, and gates the toolbar search-box (`search: false`
suppresses it). A contract-parity test in the consumer workspace keeps this
type identical to the server's — a server-side map extension fails typecheck
until the mirror follows.

## `GridServerFilterCapability`

The per-field entry of `GridServerCapabilities.filter`:
`{filterType, nullable?, operators}`. When stamped on a column
(`meta._serverFilterCapability`, done by the setup — never by hand) it is the
operator source for that column's filter. `allowedOperators`/`operatorPreset`
remain as presentation narrowing and intersect with it: they can only shrink
the offered list, never widen it (an explicit entry outside the capability is
ignored with a dev-warning). The column's `type:` stays presentation; if it
diverges from the capability `filterType` the filter is disabled fail-closed
with a dev-warning (exception: a `'select'` presentation works over any type —
the set/array wire-form is validated server-side against the declared type).

## `GridServerFilterType`

The server-side filter type vocabulary
(`'text' | 'number' | 'date' | 'boolean' | 'select'`) — mirror of
active-resources' FieldFilterType union, deliberately narrower than the grid's
own `ColumnFilterType` (no `'interval'`).

## `GridServerAggregateOp`

The server-side aggregate vocabulary
(`'sum' | 'avg' | 'min' | 'max' | 'count'`) — mirror of active-resources'
`AggregateOp`.
