Every list endpoint in Ion Drive speaks the same query language. It is designed
to be readable in a URL, easy to build by hand, and consistent across
REST, GraphQL, and MCP. The @ion-drive/client SDK builds these queries for
you in a type-safe way.
search (alias q) matches a term against all text-like columns of the
object — text, email, url, slug, and single/multi enum fields — using a
case-insensitive ILIKE '%term%' OR across those columns.
GET /api/v1/data/contacts?search=acme
GET /api/v1/data/contacts?q=acme # shorthand alias
% and _ in the term are matched literally (they are escaped), so a search
for 50% does not become a wildcard. Search combines with filters via AND.
Filter on a specific property with the field[operator]=value syntax. A bare
field=value is shorthand for equality.
GET /api/v1/data/contacts?status[neq]=archived&age[gte]=21
GET /api/v1/data/contacts?status=active # same as status[eq]=active
Testing with curl? Pass -g (--globoff). curl treats [ and ] in a
URL as its own globbing syntax, so a field[op]=value query fails with
curl: (3) bad range in URL (quoting the URL does not help — globbing is
curl’s, not the shell’s). Disable it:
Operators are case-insensitive.name[NEQ]=John and name[neq]=John are
identical — so are the aliases: age[GT]=30, age[>]=30, and age[gt]=30 all
mean the same thing.
Offset-based (Supabase/PostgREST-style) — limit and offset. When
present these take precedence; the response’s page/pageSize are derived
from them so the metadata stays coherent.
pageSize/limit default to 25 and are clamped to a maximum of 100.
totalCount reflects the filters and search, so it is safe to drive a pager
from it.
GET /api/v1/data/contacts?page=2&pageSize=50 # page-based
GET /api/v1/data/contacts?limit=50&offset=50 # offset-based (same window)
The query language has no group-by or window functions — deliberately. The
standard leaderboard reads compose from two primitives you already have
(sort + filtered totalCount) plus one small aggregate endpoint.
pagination.totalCount respects filters and search, and the list query runs a
real COUNT(*) under the same conditions — so a count-where is already a
first-class, cheap read. A player’s rank is the number of players ahead of
them, plus one:
Terminal window
# "My rank" for a player with 42 wins: count players with more wins.
pageSize=1 keeps the payload minimal — you only want the count. Ties: with
wins[gt]= equal scores share the best rank (standard competition ranking,
“1224”); use a tie-breaker filter (e.g. &created_at[lt]=…) if you need total
order.
The aggregate endpoint below returns the same number without fetching any rows
(fn=count → filteredCount), which reads more clearly:
A single count / sum / avg / min / max over the rows matching the
same filter + search parameters as the list endpoint — one condition
pipeline, so an aggregate always agrees with pagination.totalCount for the
same query.
GET /api/v1/data/:object/aggregate?fn=<fn>[&field=<field>][&filters…][&search=…]
Parameter
Meaning
fn
Required. One of count, sum, avg, min, max. One fn per call.
field
The field to aggregate. Required for sum/avg/min/max (numeric fields only — 400 otherwise). Optional for count: with a field it counts that field’s non-null values.
anything else
The list endpoint’s filter operators and search/q, applied identically. Sort/pagination keys are ignored (a scalar has no order or pages).
value is null when no rows match (sum/avg/min/max over an empty
set, SQL semantics). Values are JSON numbers; astronomically large
BIGINT/NUMERIC results (beyond 2⁵³) lose precision.
filteredCount is always the matching-row count — avg callers get their
denominator, rank callers their numerator, in one request.
select — comma-separated list of fields to return (projection).
expand — comma-separated relation keys to include as attached records.
GET /api/v1/data/contacts?select=id,full_name,email
GET /api/v1/data/contacts?expand=company,tags
GET /api/v1/data/companies?expand=contacts_by_company
A relation key is (Phase 13):
Key
Where
Attaches
<relName>
the FK-holding side (many_to_one/one_to_one source, one_to_many target)
the related record or null
<relName>
either side of a many_to_many
the linked records (array)
<fkObject>_by_<relName>
the “one” side (reverse traversal)
the FK-holding records (array; single for a reverse one_to_one)
The OpenAPI spec’s expand parameter (and MCP’s get_object tool) list each
object’s available keys. Unknown keys are ignored. GraphQL exposes the same
keys as nested fields (see graphql.md).
@ion-drive/client ships a fluent, awaitable builder inspired by Supabase’s
postgrest-js — you never assemble these strings by hand. It normalises operator
aliases, encodes values (dates become ISO strings), and joins list values for
you. Start from .from(object).select(...), chain filters/modifiers, and
await the chain — no terminal call needed.
MCP — the query_data tool takes object_name, search, filters,
sort, page/page_size, and limit/offset, so an LLM agent searches and
filters exactly like the REST and GraphQL clients do.