/** * Server-Side Row Model (SSRM) controller. A single, documented datasource * contract for grids whose data lives on the server - the "I have a million * rows in a database" case. The consumer implements ONE async `getRows` * function; this controller owns the request lifecycle (sort, filter, page), * de-dupes/races, and pushes results back through `onChange`. * * It's headless and framework-agnostic on purpose: wire `setSort` / * `setFilter` / `setPage` to the grid's controlled callbacks, and render the * grid from the `{ rows, total, loading }` it hands you. See the demo. * * The write side (`createRow` / `updateRow` / `deleteRow`) is optional: a * source that only implements `getRows` stays a pure read model, and the * matching controller methods throw a clear error if called. Mutations are * non-optimistic for now - the grid reflects a change only after the * follow-up re-fetch of the current page lands. */ import type { GridPredicateExpr } from './filtering/predicate-expr'; export type ServerSortModel = Array<{ id: string; desc: boolean; }>; export type ServerFilterModel = { /** Free-text global search. */ global?: string; /** * Per-column filters, keyed by column id. `value` (+ `valueTo`) carry the * operator-style filter; `selectedValues` carries a facet/checklist * selection (set-filter). Either or both may be present. */ columns?: Record; /** * Advanced-filter predicate (Pro), as a JSON AST. Expresses what `columns` * cannot: OR across columns, nesting, negation, two conditions on one * column, cross-column comparison, and aggregates. * * CONTRACT - all or nothing. A backend that receives this MUST either: * * (a) translate the WHOLE expression into its query, make `rowCount` * reflect it, and set `appliedExpression: true` on the result; or * (b) apply none of it and leave `appliedExpression` unset. * * Partial application is a contract violation, not a degraded mode: it * returns a SUPERSET of the requested rows while the UI says the filter is * on. That is strictly worse than not filtering, because nothing about the * result looks wrong. When the ack is missing the grid says so rather than * filtering the loaded page itself - see `ServerState.expressionUnapplied`. */ expression?: GridPredicateExpr; }; /** A value column to roll up per group. */ export type ServerAggregation = { col: string; fn: 'sum' | 'avg' | 'min' | 'max' | 'count'; }; export type ServerRequest = { /** Zero-based index of the first row wanted (inclusive). */ startRow: number; /** Index just past the last row wanted (exclusive). */ endRow: number; pageIndex: number; pageSize: number; sortModel: ServerSortModel; filterModel: ServerFilterModel; /** * Server-side grouping / tree: the columns being grouped on, outer to inner. * Omitted / empty for a flat request. */ groupBy?: string[]; /** * The path of group keys the grid is expanding, e.g. `['Germany', 'Berlin']`. * Empty (`[]`) asks for the top level. When `groupKeys.length < groupBy.length` * the server returns **group rows** (one per distinct key at this level, * carrying the group key + aggregates); when they are equal it returns the * **leaf rows** under that path. */ groupKeys?: string[]; /** Value columns to aggregate per group. */ aggregations?: ServerAggregation[]; }; /** * A group row in the server-side group/tree model - one distinct key at a * level, with its rolled-up aggregates. The grid renders it with an expander; * expanding it fetches its children through the same `getRows`. */ export type ServerGroupRow = { kind: 'group'; /** Stable id (the group path). */ id: string; /** Group keys from the root to this node, e.g. `['Germany', 'Berlin']`. */ path: string[]; /** The column this group is on (the `groupBy` entry for this level). */ field: string; /** This group's key value. */ key: string; /** Zero-based depth (0 = top level). */ level: number; expanded: boolean; loading: boolean; /** Aggregate values keyed by column id, read from the group's response row. */ aggregates: Record; /** The raw response row for this group (key + aggregates), for cell rendering. */ data: TData; }; export type ServerLeafRow = { kind: 'leaf'; id: string; level: number; data: TData; }; /** * A "load more" affordance emitted at the end of a group whose children are * only partially loaded (intra-group paging). Trigger `loadMoreChildren(path)` * to fetch the next block. */ export type ServerMoreRow = { kind: 'more'; id: string; level: number; /** Path of the parent group whose children to load more of. */ path: string[]; /** How many children remain unloaded. */ remaining: number; loading: boolean; }; /** * A subtotal / footer row emitted after an expanded group's children when * `groupFooters` is on. Carries the group's aggregates a second time so a * "Total" line sits under the detail. */ export type ServerFooterRow = { kind: 'footer'; id: string; level: number; path: string[]; /** The group this footer totals (its key). */ key: string; aggregates: Record; /** The group's response row (key + aggregates), so value columns show totals. */ data: TData; }; /** A placeholder row shown while a block of children is being fetched. */ export type ServerSkeletonRow = { kind: 'skeleton'; id: string; level: number; }; /** A row in the flattened server-side group/tree display list. */ export type ServerDisplayRow = ServerGroupRow | ServerLeafRow | ServerMoreRow | ServerFooterRow | ServerSkeletonRow; export type ServerResult = { rows: ReadonlyArray; /** Total row count after filtering (for the pager). */ rowCount: number; /** * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it * unset if you ignored the expression; the grid then warns rather than * pretending the filter ran. See the contract on `ServerFilterModel.expression`. */ appliedExpression?: boolean; }; export type ServerDataSource = { getRows(request: ServerRequest): Promise>; /** * Optional write side. Implement whichever your backend supports; the * controller exposes matching `createRow` / `updateRow` / `deleteRow` * methods that call through and then `refresh()` the current page. * Calling a controller method whose source counterpart is missing throws. */ createRow?(input: Partial): Promise; updateRow?(id: string, patch: Partial): Promise; deleteRow?(id: string): Promise; }; export type ServerState = { rows: ReadonlyArray; total: number; loading: boolean; /** True while a create / update / delete mutation is in flight. */ saving: boolean; error: unknown; pageIndex: number; pageSize: number; pageCount: number; sortModel: ServerSortModel; filterModel: ServerFilterModel; /** * True when an advanced-filter expression was sent but the source did not * acknowledge applying it - so `rows` is unfiltered and the UI should say so. * Surface this rather than hiding it: the rows look perfectly normal. * * Optional so existing code that builds a `ServerState` literal keeps * compiling; the controller always sets it. */ expressionUnapplied?: boolean; }; export type ServerController = { /** Re-fetch the current page (e.g. after a mutation). */ refresh(): void; setSort(sortModel: ServerSortModel): void; setFilter(filterModel: ServerFilterModel): void; setPage(pageIndex: number): void; setPageSize(pageSize: number): void; /** * Create a row through the source, then refresh the current page. Resolves * with the created row. Rejects if the source has no `createRow` (or if the * create itself fails - the read state is left untouched on failure). */ createRow(input: Partial): Promise; /** * Update a row by id through the source. Non-optimistic: refreshes the page. * Optimistic (see `optimistic` + `getRowId` options): patches the local row * immediately, then reconciles with the server result, rolling back on error. */ updateRow(id: string, patch: Partial): Promise; /** * Delete a row by id through the source. Non-optimistic: refreshes the page. * Optimistic: removes the local row immediately, restoring it on error. */ deleteRow(id: string): Promise; getState(): ServerState; /** Stop accepting in-flight responses (call on unmount). */ dispose(): void; }; export type ServerControllerOptions = { pageSize?: number; /** Called whenever any of `rows` / `total` / `loading` / page changes. */ onChange: (state: ServerState) => void; /** * Apply `updateRow` / `deleteRow` to the local rows immediately (before the * server confirms) and roll back on error - so edits feel instant and no * refetch is needed. Requires `getRowId` to locate rows; ignored without it. * A subsequent `refresh()` reconciles ordering/filtering. Default false. */ optimistic?: boolean; /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */ getRowId?: (row: TData) => string; }; export declare function createServerDataSource(source: ServerDataSource, options: ServerControllerOptions): ServerController;