THE DataGrid Protocol (TDGP)#
TDGP () is an open, versioned JSON wire protocol between a datagrid UI and a data backend: how a grid asks for rows — filtered, sorted, grouped, pivoted, aggregated, paginated, lazily — and what the answers look like.
tdgp/1Implement it once on your server, and any TDGP-aware grid binding can consume it: AG Grid (Server-Side Row Model), Infinite Table, or your own custom UI. Every response carries .
"protocol": "tdgp/1"Try it right now
A public TDGP server runs at data.thedatagrid.com — CORS-open, no auth, 11 datasets from 1k to 50k rows, including a live SSE quote stream. The interactive API reference at data.thedatagrid.com/docs is generated from the live server.
For a hands-on walkthrough with AG Grid, read AG Grid Server-Side Row Model without writing a backend.
Why a protocol?#
Every serious datagrid ends up needing server-side data operations — the dataset is too big to ship to the browser, so filtering, sorting, grouping, pivoting and aggregation move to the server. Each grid defines its own client-side contract for this (AG Grid's , Infinite Table's lazy , ...), but the server side is left as an exercise: every team rebuilds the same query backend, once per grid.
IServerSideGetRowsRequestDataSourceTDGP standardizes that backend contract: one server speaking TDGP over your store, and any number of grids in front of it — AG Grid via , Infinite Table via , or anything else via the plain client.
@thedatagrid/client/ag-grid@thedatagrid/client/infiniteQuick start#
Everything below runs against the live server — paste it into a terminal:
# Discovery: what does this server speak?
curl https://data.thedatagrid.com/.well-known/tdgp
# Catalog: what datasets are available, with what fields and capabilities?
curl https://data.thedatagrid.com/datasets
# Query: group 10,000 developers by country, with an average salary per group
curl -X POST https://data.thedatagrid.com/developers-10k/query \
-H 'content-type: application/json' \
-d '{
"process": { "group": "server" },
"groupBy": [{ "field": "country" }],
"groupKeys": [],
"aggregations": [{ "id": "avgSalary", "field": "salary", "fn": "avg" }]
}' COPY
The response is a page of group nodes, each with its aggregations:
{
"protocol": "tdgp/1",
"data": [
{
"keys": ["Argentina"],
"data": { "country": "Argentina" },
"aggregations": { "avgSalary": 121043.5 }
}
],
"totalCount": 24
} COPY
Endpoints & discovery#
Dataset identity always lives in the route, never in the request body.
| Method | Path | Purpose |
|---|---|---|
| GET | | Discovery document: protocol version, capability summary ( |
| GET | | Dataset catalog |
| POST | | The main query endpoint |
| POST | | Distinct filter values for a field ( |
| GET | | Server-sent events with live row updates ( |
| GET | | OpenAPI 3.1 spec + interactive reference, generated from the live registry |
| GET | | Liveness |
| ALL | | tRPC mirror façade (optional transport) |
Each catalog entry () declares:
DatasetCatalogEntry- (the route segment),
name,titledescription - — mandatory, see below
primaryKey - — name, type (
fields[]), nullability, and per-field filter metadata: supported operators, whether the field isstring | number | boolean | date, and for object-valued enums theenumerable(plus optionalvalueKey)labelKey - — the conformance level this dataset supports, and its
levelextensions[]
Queries — POST /{dataset}/query
POST /{dataset}/queryOne request shape covers flat lists, lazy grouping and pivoting (; the normative Zod schema is in ):
DataSourceRequestdataSourceRequestSchema@thedatagrid/protocol| Field | Meaning |
|---|---|
| Projection — dataset fields and computed-field ids; omit for all fields |
| Offset pagination over the current level's |
| Predicate tree (see FilterModel below) |
| |
| |
| The drill path for lazy loading (see traversal semantics below) |
| |
| |
| Who does what: |
Three response shapes#
The response shape is determined by the request:
1. Flat — no server grouping, or drilled down to leaf depth:
{
"protocol": "tdgp/1",
"data": [{ "id": 1, "firstName": "Ada", "country": "UK", "salary": 150000 }],
"totalCount": 10000,
"totalCountUnfiltered": 10000
} COPY
2. Group — with above leaf depth. Each node carries its key path, the group field values, and its aggregations; is the number of groups at this level:
process.group: 'server'groupKeystotalCount{
"protocol": "tdgp/1",
"data": [
{
"keys": ["France", "backend"],
"data": { "country": "France", "stack": "backend" },
"aggregations": { "avgSalary": 132500, "count": 412 }
}
],
"totalCount": 3
} COPY
3. Pivot — : group nodes plus a nested cell tree ( per aggregation id, then deeper pivot levels under ), and a object naming the keys:
process.pivot: 'server'pivottotalsvaluesmappings{
"protocol": "tdgp/1",
"data": [
{
"keys": ["France"],
"data": { "country": "France" },
"aggregations": { "avgSalary": 128000 },
"pivot": {
"totals": { "avgSalary": 128000 },
"values": {
"backend": { "totals": { "avgSalary": 132500 } },
"frontend": { "totals": { "avgSalary": 121000 } }
}
}
}
],
"totalCount": 24,
"mappings": { "values": "values", "totals": "totals" }
} COPY
Lazy traversal semantics#
- returns the top-level group nodes. Expanding a node means re-requesting with that node's key path —
groupKeys: []returns France's children.groupKeys: ["France"] - When , the server returns leaf rows (a flat response), with the drill path pinned as equality filters.
groupKeys.length === groupBy.length - is a typed 400.
groupKeys.length > groupBy.length - Aggregations are recomputed per level — an average of averages is not an average.
- Pivot responses restrict rollups to the page of group nodes returned.
FilterModel#
A predicate tree, published per-field in the catalog:
{ "kind": "group", "combinator": "AND" | "OR", "children": [...] }{ "kind": "not", "child": ... }{ "kind": "predicate", "field": "...", "operator": "...", "args": [...] }
Operators: , , , , , , , , , , , , .
EQNEQGTGTELTLTEBETWEENINCONTAINSSTARTS_WITHENDS_WITHIS_BLANKIS_NOT_BLANK{
"kind": "group",
"combinator": "AND",
"children": [
{ "kind": "predicate", "field": "country", "operator": "IN", "args": ["France", "Spain"] },
{ "kind": "predicate", "field": "salary", "operator": "GTE", "args": [100000] }
]
} COPY
Object args: clients may send whole objects obtained from filter-value discovery as predicate args; the server reduces them to the declared scalar. Object args on fields without a are a typed 400 (). is null-safe — it matches NULL rows.
valueKeyvalueKeyINVALID_FILTER_VALUENEQFilter-value discovery — POST /{dataset}/filter-values
POST /{dataset}/filter-values"What can I filter this column by?" ( extension):
filter-valuescurl -X POST https://data.thedatagrid.com/orders/filter-values \
-H 'content-type: application/json' \
-d '{ "field": "currency", "withCounts": true }' COPY
- Passing a enables cascading (Excel-style): only values present under the given filter are returned; clients exclude the target field's own predicate.
filter - For object-valued enums the response returns whole objects and names the comparable scalar: .
{ valueKey, labelKey?, values: [{ value, count? }], totalCount }matches the label server-side.search - Only fields are discoverable — anything else is a typed 400.
enumerable
Errors#
Every error is a typed envelope with the HTTP status implied by the code:
{
"protocol": "tdgp/1",
"error": { "code": "FIELD_NOT_FOUND", "message": "Unknown field 'salry'", "details": {} }
} COPY
| Code | HTTP status |
|---|---|
| 400 |
| 401 |
| 403 |
| 404 |
| 500 |
Unsupported features are always typed errors, never silently dropped — a grid can tell the difference between "no results" and "this server can't do that".
Primary key#
Every dataset declares — a stable scalar, unique per row, present on every leaf row, published in the catalog. Grids use it for row identity (), SSE tick targeting, detail routes and master-detail foreign keys. Servers enforce uniqueness at the store level.
primaryKeygetRowIdConformance levels & extensions#
Levels are cumulative:
- core — flat data + filter / sort / pagination
- group — lazy server-side grouping + native aggregations
- pivot — server-side pivoting
Extensions are orthogonal and declared per dataset in the catalog:
- — distinct-value discovery with cascading
filter-values - — server-sent events with live row updates
stream - — facet counts in filter-values responses
counts
A client reads the discovery document and the catalog, and only relies on what the server declares.
Transports#
- REST — the canonical binding (all paths on this page).
- tRPC — a mirror façade at :
/trpc,{dataset}.query,{dataset}.filterValues,catalog. Same semantics, same schemas, with a typeddiscoverfor TypeScript clients.AppRouter - SSE — (
GET /{dataset}/streamextension): astreamof named events (e.g.text/event-streamwith updated rows). Ticks are also persisted, so queries always see current values. Try it:tick.curl -N https://data.thedatagrid.com/quotes/stream
Versioning#
tdgp/1tdgp/2Client packages#
Both packages are MIT-licensed and dependency-light:
@thedatagrid/protocol
@thedatagrid/protocolThe wire contract itself — Zod schemas plus inferred TypeScript types for requests, responses, the FilterModel, the catalog and error envelopes. Use it to validate requests on your server or to type your client code.
npm install @thedatagrid/protocol COPY
@thedatagrid/client
@thedatagrid/clientThe canonical isomorphic client (browsers, Node 18+, edge runtimes) plus ready-made grid bindings. The bindings use structural types only — no grid dependency is pulled in.
npm install @thedatagrid/client COPY
import { createTdgpClient } from "@thedatagrid/client";
const client = createTdgpClient({ url: "https://data.thedatagrid.com" });
const response = await client.query("developers-10k", {
process: { group: "server" },
groupBy: [{ field: "country" }],
groupKeys: [],
aggregations: [{ id: "avgSalary", field: "salary", fn: "avg" }],
}); COPY
AG Grid Server-Side Row Model — a complete SSRM datasource in a few lines:
import { createTdgpClient } from "@thedatagrid/client";
import { createAgGridServerSideDatasource } from "@thedatagrid/client/ag-grid";
const client = createTdgpClient({ url: "https://data.thedatagrid.com" });
const serverSideDatasource = createAgGridServerSideDatasource({
client,
dataset: "developers-10k",
});
// <AgGridReact rowModelType="serverSide" serverSideDatasource={serverSideDatasource} /> COPY
Infinite Table — a lazy data function:
DataSourceimport { createTdgpClient } from "@thedatagrid/client";
import { createInfiniteDataFn } from "@thedatagrid/client/infinite";
const client = createTdgpClient({ url: "https://data.thedatagrid.com" });
const data = createInfiniteDataFn({ client, dataset: "developers-10k" });
// <DataSource primaryKey="id" data={data} lazyLoad>...</DataSource> COPY
See the full walkthrough with a live grid: AG Grid Server-Side Row Model without writing a backend.
Implementing a TDGP server#
A conforming server needs to answer the endpoints above for the level it declares. In practice that means:
- Validate incoming bodies with the Zod schemas from (
@thedatagrid/protocol,dataSourceRequestSchema, …).filterValuesRequestSchema - Execute the resolved plan against your store — flat rows, lazy groups, pivots, filter-values — and return one of the three response shapes, always with .
"protocol": "tdgp/1" - Publish discovery + catalog so clients know which level and extensions you support.
- Never silently drop unsupported features — return a typed error instead.
The reference implementation factors the hard part into : / field builders, a live dataset registry, the interface (, , , ), and a store-agnostic query orchestrator that validates fields, normalizes filters and dispatches to the adapter. SQL (and other) adapters implement that interface; the HTTP/tRPC surface sits on top.
@thedatagrid/protocol-runtimedefineDataset()fDataAdapterqueryFlatqueryGroupsqueryPivotfilterValuesThe live reference at data.thedatagrid.com is the behaviour to match. Its OpenAPI spec at data.thedatagrid.com/openapi.json is the machine-readable form of everything on this page.
One protocol, any grid#
The interesting part of a protocol rather than a bespoke backend: a TDGP server has no idea which grid is talking to it. The same endpoint serves AG Grid's Server-Side Row Model through , Infinite Table's lazy through , and anything else through the plain client. Implement TDGP once over your own data — validate with , shape your store behind a with , prove it with — and the grid becomes a swappable frontend decision.
@thedatagrid/client/ag-gridDataSource@thedatagrid/client/infinite@thedatagrid/protocolDataAdapter@thedatagrid/protocol-runtime@thedatagrid/protocol-testkitBeyond queries, the protocol also covers filter-value discovery (Excel-style cascading filter dropdowns via ), live updates over server-sent events (try ), a dataset catalog with per-field filter capabilities, and an OpenAPI spec generated from the live server.
POST /{dataset}/filter-valuescurl -N https://data.thedatagrid.com/quotes/streamSee it end-to-end with a live grid: AG Grid Server-Side Row Model without writing a backend.
Help shape TDGP
Want help adopting TDGP against your own store or grid? Have an idea for an extension, a new binding, or a conformance level? Use the contact form — we're actively looking for partners to support and expand the protocol.