Server API
The server-mode types, the column query contract and the resource-mode adapter.
@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3
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.
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#
import {type ServerFetchFunction} from '@workspace/active-grid';type ServerFetchFunction<TData> = ( params: ServerFetchParams,) => Promise<PagedResponse<TData>>;ServerFetchParams#
| Property | Type | Description |
|---|---|---|
page | number | Zero-based fetch-block index. |
fetch | number | Engine's fetch-block size (rows requested in this server call). Independent of UI page size — pass through to your Prisma take. |
sorting | Sorting | Array of {id: string, desc: boolean}. |
filters | Column | Array of {id: string, value: Filter. |
global | string | undefined | Global search term. |
signal | Abort | AbortSignal for cancelling in-flight requests on rapid filter/sort changes. |
ServerOptions#
import {type ServerOptions} from '@workspace/active-grid';Options for server-mode data fetching, passed via the serverOptions prop on
ActiveGrid.
| Property | Type | Default | Description |
|---|---|---|---|
cache | number | 10 | Maximum number of blocks to keep in memory. |
prefetch | boolean | true | Prefetch the next block when approaching a block boundary. |
block | 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 Server. |
<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/ | 10 | Rows the user sees at once; options [10, 20, 50, 100]. |
block | server | 200 | Rows fetched per server call. |
pages | derived | floor(block/ | How many visible pages one block serves. |
fetch | derived | pages | What Server 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#
import {type PagedResponse} from '@workspace/active-grid';| Property | Type | Description |
|---|---|---|
data | TData[] | Row data for the current page. |
total | number | Total number of rows across all pages. |
facets | Record<string, Facet | 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. |
fetch | Its identity matters — see below. |
server / . | Block geometry and therefore fetch. |
server | Retry behaviour of a failed fetch. |
pagination. / initial | Page size is part of the dataset key. |
server | Decides which sort/filter/group state may exist, and prunes stale hydrated state before the first fetch. |
enable / enable / state | Decide the state the first fetch is made WITH. |
pagination. | 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
isLoadingstartstrueand 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:
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#
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.
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: "remove | Patches the block cache, decrements total. Preserves session-state. |
{type: "add | Inserts a row, increments total. |
{type: "refetch"} | Invalidates the cache and re-fetches the current page. |
{type: "reset | 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:
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#
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:
- Library-internal tests inside
@workspace/active-griditself. - Demo / showcase pages that exercise the primitive directly without a preceding async action.
- 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:
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:
"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:
// 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-schemaconst 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#
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 | Serializable | The column supports server-side sort (survives hydration). |
filter | false | Serializable | The column supports server-side filter (survives hydration). |
global | false | Serializable | The column participates in the toolbar single-search input. |
group | false | Serializable | The column supports server-side grouping. |
aggregate | false | Serializable | 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.
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#
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 |
|---|---|
scalar | Uses the column-id as the field path. Deferred resolution when the column-id is dotted (user.). |
scalar | Explicit nested path; both sort and filter use this path. |
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#
import {byField, byCount} from '@workspace/active-grid';| Helper | Returns | Declares |
|---|---|---|
by | Sort | Sort on <path ?? column. |
by | Sort | Sort on the count of a relation (<relation> _). |
// Sort users by number of assigned roles.ch.accessor('roleCount', { header: t('user.roleCount'), server: {sort: byCount('userRoles')},});Filter helpers#
import { fieldFilter, multiField, someByField, everyByField, noneByField,} from '@workspace/active-grid';| Helper | Returns | Description |
|---|---|---|
field | Filter | Scalar field; if path is omitted, column-id is used. filter disambiguates when operator is ambiguous (equals text/number/date). |
multi | Filter | OR across multiple scalar paths. |
some | Filter | Passes if at least one related record matches (relation: {some: ...}). |
every | Filter | Passes only if every related record matches (relation: {every: ...}). |
none | Filter | Passes only if no related record matches (relation: {none: ...}). |
// 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#
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 |
|---|---|
global | Global |
global | Global |
global | Global |
global | Global |
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#
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 |
|---|---|---|
group | Group | Scalar group-by marker on path ?? column-. Works for any scalar including 1:1 FK columns. |
group | Group | Reserved — type-shape exists for forward compatibility, but the runtime does not activate it. Use group 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.
ch.accessor('category', { header: t('order.category'), server: { ...scalarField(), groupBy: groupByField(), },});Aggregate helpers#
import { aggregateScalar, aggregateCount, type AggregateOp,} from '@workspace/active-grid';Declarative per-column aggregations available in a server group-by.
| Helper | Returns | Behavior |
|---|---|---|
aggregate | Aggregate | Numeric aggregation block. ops is a non-empty subset of Aggregate ("sum" | "avg" | "min" | "max" | "count"). |
aggregate | Aggregate | _ block. relation: undefined / → count all rows; relation: "user → 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).
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.
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.
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 itserror.message), when the action rejects or throws (the message is the error'smessage), and whentransformthrows on a row. With anonError, a rejection that is not anErroris thrown on as anErrorwith the original value as itscauseand the messageFailed 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'sserverCapabilitiesprop; 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.