THE DataGrid Protocol (TDGP)#

TDGP (
tdgp/1
) 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.
Implement 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
IServerSideGetRowsRequest
, Infinite Table's lazy
DataSource
, ...), but the server side is left as an exercise: every team rebuilds the same query backend, once per grid.
TDGP standardizes that backend contract: one server speaking TDGP over your store, and any number of grids in front of it — AG Grid via
@thedatagrid/client/ag-grid
, Infinite Table via
@thedatagrid/client/infinite
, or anything else via the plain client.

Quick 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" }]
  }'
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
}

Endpoints & discovery#

Dataset identity always lives in the route, never in the request body.
MethodPathPurpose
GET
/.well-known/tdgp
Discovery document: protocol version, capability summary (
levels
,
extensions
), links to catalog / OpenAPI / docs / tRPC
GET
/datasets
Dataset catalog
POST
/{dataset}/query
The main query endpoint
POST
/{dataset}/filter-values
Distinct filter values for a field (
filter-values
extension)
GET
/{dataset}/stream
Server-sent events with live row updates (
stream
extension)
GET
/openapi.json
,
/docs
OpenAPI 3.1 spec + interactive reference, generated from the live registry
GET
/health
Liveness
ALL
/trpc/*
tRPC mirror façade (optional transport)
Each catalog entry (
DatasetCatalogEntry
) declares:
  • name
    (the route segment),
    title
    ,
    description
  • primaryKey
    — mandatory, see below
  • fields[]
    — name, type (
    string | number | boolean | date
    ), nullability, and per-field filter metadata: supported operators, whether the field is
    enumerable
    , and for object-valued enums the
    valueKey
    (plus optional
    labelKey
    )
  • level
    — the conformance level this dataset supports, and its
    extensions[]

Queries —
POST /{dataset}/query

One request shape covers flat lists, lazy grouping and pivoting (
DataSourceRequest
; the normative Zod schema is
dataSourceRequestSchema
in
@thedatagrid/protocol
):
FieldMeaning
fields
Projection — dataset fields and computed-field ids; omit for all fields
start
/
limit
Offset pagination over the current level's
data
array. Servers cap
limit
(default max 10,000)
filter
Predicate tree (see FilterModel below)
sort
[{ field, dir }]
— targets dataset fields, computed-field ids, or aggregation ids (at group/pivot levels)
groupBy
[{ field }]
— grouping columns
groupKeys
The drill path for lazy loading (see traversal semantics below)
pivotBy
[{ field }]
— pivot columns
aggregations
[{ id, field, fn }]
with
fn
one of
sum
,
avg
,
min
,
max
,
count
process
Who does what:
{ group?, pivot?, aggregation?, pagination? }
, each
'server'
or
'client'

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
}
2. Group
process.group: 'server'
with
groupKeys
above leaf depth. Each node carries its key path, the group field values, and its aggregations;
totalCount
is the number of groups at this level:
{
  "protocol": "tdgp/1",
  "data": [
    {
      "keys": ["France", "backend"],
      "data": { "country": "France", "stack": "backend" },
      "aggregations": { "avgSalary": 132500, "count": 412 }
    }
  ],
  "totalCount": 3
}
3. Pivot
process.pivot: 'server'
: group nodes plus a nested
pivot
cell tree (
totals
per aggregation id, then deeper pivot levels under
values
), and a
mappings
object naming the keys:
{
  "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" }
}

Lazy traversal semantics#

  • groupKeys: []
    returns the top-level group nodes. Expanding a node means re-requesting with that node's key path —
    groupKeys: ["France"]
    returns France's children.
  • When
    groupKeys.length === groupBy.length
    , the server returns leaf rows (a flat response), with the drill path pinned as equality filters.
  • groupKeys.length > groupBy.length
    is a typed 400.
  • 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:
EQ
,
NEQ
,
GT
,
GTE
,
LT
,
LTE
,
BETWEEN
,
IN
,
CONTAINS
,
STARTS_WITH
,
ENDS_WITH
,
IS_BLANK
,
IS_NOT_BLANK
.
{
  "kind": "group",
  "combinator": "AND",
  "children": [
    { "kind": "predicate", "field": "country", "operator": "IN", "args": ["France", "Spain"] },
    { "kind": "predicate", "field": "salary", "operator": "GTE", "args": [100000] }
  ]
}
Object args: clients may send whole objects obtained from filter-value discovery as predicate args; the server reduces them to the declared
valueKey
scalar. Object args on fields without a
valueKey
are a typed 400 (
INVALID_FILTER_VALUE
).
NEQ
is null-safe — it matches NULL rows.

Filter-value discovery —
POST /{dataset}/filter-values

"What can I filter this column by?" (
filter-values
extension):
curl -X POST https://data.thedatagrid.com/orders/filter-values \
  -H 'content-type: application/json' \
  -d '{ "field": "currency", "withCounts": true }'
  • Passing a
    filter
    enables cascading (Excel-style): only values present under the given filter are returned; clients exclude the target field's own predicate.
  • For object-valued enums the response returns whole objects and names the comparable scalar:
    { valueKey, labelKey?, values: [{ value, count? }], totalCount }
    .
    search
    matches the label server-side.
  • Only
    enumerable
    fields are discoverable — anything else is a typed 400.

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": {} }
}
CodeHTTP status
BAD_REQUEST
,
INVALID_FILTER_VALUE
,
UNSUPPORTED_FEATURE
400
UNAUTHORIZED
401
FORBIDDEN
403
DATASET_NOT_FOUND
,
FIELD_NOT_FOUND
404
INTERNAL
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
primaryKey
— a stable scalar, unique per row, present on every leaf row, published in the catalog. Grids use it for row identity (
getRowId
), SSE tick targeting, detail routes and master-detail foreign keys. Servers enforce uniqueness at the store level.

Conformance levels & extensions#

Levels are cumulative:
  1. core — flat data + filter / sort / pagination
  2. group — lazy server-side grouping + native aggregations
  3. pivot — server-side pivoting
Extensions are orthogonal and declared per dataset in the catalog:
  • filter-values
    — distinct-value discovery with cascading
  • stream
    — server-sent events with live row updates
  • counts
    — facet counts in filter-values responses
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
    ,
    discover
    . Same semantics, same schemas, with a typed
    AppRouter
    for TypeScript clients.
  • SSE
    GET /{dataset}/stream
    (
    stream
    extension): a
    text/event-stream
    of named events (e.g.
    tick
    with updated rows). Ticks are also persisted, so queries always see current values. Try it:
    curl -N https://data.thedatagrid.com/quotes/stream
    .

Versioning#

tdgp/1
is additive-stable: new optional request fields, response fields, extensions and error codes may be added; breaking changes require
tdgp/2
. Servers ignore unknown request fields; clients must ignore unknown response fields.

Client packages#

Both packages are MIT-licensed and dependency-light:

@thedatagrid/protocol

The 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

@thedatagrid/client

The 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
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" }],
});
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} />
Infinite Table — a lazy
DataSource
data function:
import { 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>
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:
  1. Validate incoming bodies with the Zod schemas from
    @thedatagrid/protocol
    (
    dataSourceRequestSchema
    ,
    filterValuesRequestSchema
    , …).
  2. 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"
    .
  3. Publish discovery + catalog so clients know which level and extensions you support.
  4. Never silently drop unsupported features — return a typed error instead.
The reference implementation factors the hard part into
@thedatagrid/protocol-runtime
:
defineDataset()
/
f
field builders, a live dataset registry, the
DataAdapter
interface (
queryFlat
,
queryGroups
,
queryPivot
,
filterValues
), 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.
The 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
@thedatagrid/client/ag-grid
, Infinite Table's lazy
DataSource
through
@thedatagrid/client/infinite
, and anything else through the plain client. Implement TDGP once over your own data — validate with
@thedatagrid/protocol
, shape your store behind a
DataAdapter
with
@thedatagrid/protocol-runtime
, prove it with
@thedatagrid/protocol-testkit
— and the grid becomes a swappable frontend decision.
Beyond queries, the protocol also covers filter-value discovery (Excel-style cascading filter dropdowns via
POST /{dataset}/filter-values
), live updates over server-sent events (try
curl -N https://data.thedatagrid.com/quotes/stream
), a dataset catalog with per-field filter capabilities, and an OpenAPI spec generated from the live server.
See 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.