developer guide
Custom Pushdown Adapter Contract
Everything createPushdownSource does for the eight shipped adapters, stated precisely enough to write a ninth: what you declare, what you receive, what you return, and how a refusal is named.
This is the reference version of the same contract every shipped adapter follows: restAdapter,
duckdbAdapter, odataAdapter, graphqlAdapter, splunkAdapter,
clickhouseAdapter, elasticsearchAdapter and dfqlAdapter. For the
narrative version and worked examples, start from the pushdown guide; for a
backend wired against a real endpoint, the backend recipes show five of them end to
end.
A pushdown adapter is the one thing a new back end has to write: an object
with a capabilities declaration and an execute(query, request) function.
Everything else - paging, abort, the residual filter/sort/quick pass, the
group and pivot planner, the write-back bridge - is createPushdownSource
(packages/core/src/source/pushdown.js), shared by every shipped adapter
(restAdapter, duckdbAdapter, odataAdapter, graphqlAdapter,
splunkAdapter, clickhouseAdapter, elasticsearchAdapter, dfqlAdapter)
and available to a hand-written one on the same terms. This page states that
contract precisely, field by field, from the shipped declarations in
packages/core/src/types.d.ts. A test
(test/adapter-contract-doc.test.js) parses those declarations and asserts
every field list below still matches them, so this page cannot silently go
stale the way hand-written API tables do.
1. What you declare: capabilities
Everything defaults off (NO_CAPABILITIES). An adapter that declares nothing
still works - the source does all the work itself - so declaring capabilities
you do not actually implement is the one way to get wrong rows: it is a claim,
not a hint.
filter
operators
sort
quick
range
total
group
pivot
buckets
aggregates
mutate
filter:false | 'term' | 'flat' | 'tree'- a single field and term, a flat conjunction, or a full nested tree.operators: the comparison operators the engine understands ('eq','gt', …); a condition whose operator is not in this set never pushes.sort:false | 'single' | 'multi'- sort is all-or-nothing (a partial push needs every row anyway to finish it, so it buys nothing).quick: whether the free-text quick filter can be pushed.range: whether the engine can return a window rather than the whole result.total: whether it can report the matching row count.group: whether it can answer one grouped level at a time (executeGroupLevelrequired) - all-or-nothing, like sort, because a group row counted over the wrong set is a wrong row, not a slow one.pivot: whether it can answer a pivoted level from grouped aggregates (executePivotLevelrequired) - no client-side fallback exists.buckets: spatial bucket kindssource.aggregate()may push as agroupBykey (e.g.['grid']).aggregates: the statistic names (by the grid’s own names) the engine may be asked to reduce; anything else is computed client-side.mutate:false | { append?, update?, delete?, returning? }- the write-back contract.false(the default) is read-only by declaration.
2. What you receive: the request (RemoteRequest)
execute(query, request) is called with the pushed half of the plan - only the part your declared capabilities said you could answer - as query,
and the original, unplanned request as request (carrying signal and,
in export mode, stream).
protocol
range
groupPath
groupValues
groupBy
totals
totalFns
pivotBy
pivotMode
filters
quick
sort
context
signal
where
Mapped onto the shapes hosts usually ask about:
- filter tree -
filters: aFilterSet(a single condition or a nested{ op: 'and'|'or', conditions: [...] }group), already wired for the wire (relative date tokens resolved to absolute ranges). - sort -
sort:SortEntry[], outermost first. - page / offset -
range:{ start, end }, half-open, row-indexed - there is no separate offset/limit pair,rangeis both. - groupBy / aggregates -
groupBy(ColumnRef[]) plustotals(ColumnRef[], which columns want a subtotal) andtotalFns(Record<string, string>, the named statistic each one reduces with, e.g.{ amount: 'sum' }).groupPathand the typedgroupValuesnarrow a request to one grouped level;pivotBy/pivotModeare the pivot analogue. - count - not a request field: it is answered on the result (
total), gated bycapabilities.total. - export flag - not on
RemoteRequestitself:request.stream === trueon the secondexecuteparameter marks export mode (section 5, below). - abort -
signal: AbortSignal. Aborts when the grid no longer needs this block (superseded by a newer sort/filter/scroll, or the grid was destroyed). Reaching an already-aborted signal throws the signal’s own abort reason before the adapter is ever called (throwIfSuperseded, GEO-7). context: whatever the grid’s owncontextconfig holds (tenant id, auth token, locale), passed through untouched.
3. What you return: the answer
execute(query: RemoteRequest, request?: Partial<RemoteRequest> & { stream?: boolean }):
Promise<{ rows: unknown[]; total?: number; pendingTotal?: Promise<number | null> }>;
rows: the rows for the requested range (or, ungrouped/unpivoted, the window).total: the exact matching-set count. Omit it (do not invent one) whencapabilities.totalisfalse- the source and grid understand “unknown” as a real state (they discover the length as the user scrolls), and a guessed page-length total is a wrong number in the place a right one goes.pendingTotal: for a count that is expensive to compute alongside the page, aPromise<number|null>that resolves with the exact count once it is known (never an estimate). Ignored whentotalis present.
Grouped and pivoted levels are separate methods with their own return shapes:
executeGroupLevel(query, aggregates, request)→{ rows, total?, leaves?, matchCount?, grand? }. Present only whencapabilities.groupis set.executePivotLevel(query, aggregates, request)→{ rows, total?, leaves?, matchCount?, grand?, pivot: { axis?, cells } }. Present only whencapabilities.pivotis set.
4. lastPlan().unpushed honesty
createPushdownSource(...).lastPlan() returns the plan the last request
actually ran, or null before any request. unpushed is the list of request
parts that could not be pushed and were finished client-side - a subset of
['filter', 'sort', 'quick', 'where', 'group', 'pivot']. It is not a static
description of your declared capabilities: an adapter that declares group
without implementing executeGroupLevel is re-planned without grouping and
'group' appears in unpushed for that request, exactly as if group had
never been declared - a capability without the method behind it does not get
to look pushed.
lastPlan() also carries pushed, residual, needsAll, full,
grouped, groupLevel, groupReason, pivoted, pivotReason, and, when an
aggregate request was answered, aggregates: { engine, client } - which
statistics the engine computed and which fell to the client, each client one
with its own reason. When a total was deferred, total: { deferred: true, state, value } replaces the plain number.
5. Refusal by name
Grouping and pivoting are all-or-nothing (section 1, above): there is no client-side half
to fall back to for a pivot, so a pivot the adapter cannot answer is refused
outright rather than silently returning unpivoted rows. The rejected
Promise carries a stable error code:
pushdown:pivot-unsupported
error.code === 'pushdown:pivot-unsupported' and error.reason names which
condition failed (the same string lastPlan().pivotReason reports); the
remote source surfaces it as source:error and the grid shows it in its
error overlay. A refused grouping has no error to throw - a grid can
always fall back to grouping the rows it holds - so it is unpushed plus a
named lastPlan().groupReason and a developer warning instead.
6. Abort and supersede
Every execute/executeGroupLevel/executePivotLevel/mutate call
receives the request’s signal. A request already superseded before the
adapter is ever reached throws the signal’s own abort reason - a
SupersededError (packages/core/src/source/abort.js) when the grid raised
it: .name === 'SupersededError', .superseded === true, .key the scope
key that was replaced. An adapter’s own fetch/driver call should pass the
same signal through so an abandoned query is actually cancelled rather than
run to completion and discarded.
7. Export / stream mode
A predicate selection’s “read every matching row” path
(source.streamMatching(), marks each page it asks for with
{ stream: true } on the request (second) argument. An adapter that counts
alongside its rows may skip the count for a streamed page - re-scanning the
matching set once per page defeats the point of streaming it - and the
source ignores any total a streamed call returns. Nothing else changes:
the same execute method answers both an ordinary window and a streamed
page.
8. Write-back (mutations)
Declared per-kind on capabilities.mutate (section 1, above); false (the default) is
read-only. A source asked to edit against an adapter that has not opted in
refuses loudly rather than silently dropping the write
(source.pushdown.readonly.<name>).
kind
rows
key
patch
keys
origin
requestId
adapter.mutate(op: MutationOp, request?: RemoteRequest): Promise<MutationResult>
is called once per mutation, request carrying the same signal execute
receives. kind ('append' | 'update' | 'delete') decides which of the
other fields carry the payload - patch for an update, rows for an
append, keys for a delete.
ok
rows
keys
reason
conflict
ok: false (only an explicit false, anything else is success) reverts the
optimistic change and surfaces reason on cell:reverted. rows /
keys reconcile a returning: 'row' / 'key' declaration; conflict
surfaces a last-write-wins divergence via cell:conflict.
Reference implementations
packages/core/src/source/adapters/rest.js (restAdapter) is the shortest
worked example - a plain HTTP endpoint, most capabilities declared false by
default, edit: true opting into the mutation bridge. duckdbAdapter
(packages/core/src/source/adapters/duckdb.js) is the fullest one: filter,
sort, range, total, group and pivot all pushed into SQL. Start from whichever
is closer to your engine’s own query shape.