Client Builder
@adonis-agora/filter-client — a zero-dependency, framework-agnostic filter query builder, its typed variant, the reactive store contract, and TanStack Table sync.
@adonis-agora/filter-client is the front-end half of the pair. It builds the exact wire format @adonis-agora/filter parses — filter[field][op]=value, sort=-createdAt, page/size — through a fluent API, with zero runtime dependencies (it runs in the browser and Node). It also doubles as a reactive store for React/Vue/Svelte and has an optional TanStack Table adapter.
npm install @adonis-agora/filter-clientThe builder
filterQuery() returns a fresh FilterQueryBuilder. Chain conditions, then terminate with toQueryString(), build(), or toFlatObject().
import { filterQuery } from '@adonis-agora/filter-client'
const qs = filterQuery()
.equals('status', 'active')
.contains('name', 'fleet')
.gte('age', 18)
.sort('createdAt', 'desc')
.page(1, 25)
.toQueryString()
// → filter%5Bstatus%5D=active&filter%5Bname%5D[contains]=fleet&filter%5Bage%5D[gte]=18&sort=-createdAt&page=1&size=25
await fetch(`/users?${qs}`)where — the core method
where is overloaded to cover every operator shape, and replaces any existing filter for the same field (the natural mode for a UI where a control replaces its previous selection):
filterQuery().where('age', 'gte', 18) // explicit operator
filterQuery().where('status', ['A', 'B']) // array → auto `in`
filterQuery().where('name', 'Al') // bare value → auto `equals`
filterQuery().where('deletedAt', 'isNull') // unary operator, no valueThe value is validated against the operator at call time (between needs a 2-tuple, in needs an array, scalar operators reject arrays, unary operators reject values) — a mismatch throws synchronously.
Convenience methods
Every operator has a named helper that delegates to where:
filterQuery()
.equals('status', 'active')
.notEquals('role', 'banned')
.contains('name', 'fleet')
.in('id', [1, 2, 3])
.notIn('role', ['banned'])
.between('age', 18, 65)
.gt('score', 10).gte('score', 10).lt('score', 90).lte('score', 90)
.startsWith('code', 'AB')
.endsWith('email', '.com')
.isNull('deletedAt').isNotNull('email')
.isEmpty('bio').isNotEmpty('bio')add — accumulating range filters
where replaces; add accumulates. It is restricted to range operators (gt/gte/lt/lte) so you can put two bounds on one field:
filterQuery()
.add('createdAt', 'gte', '2026-01-01')
.add('createdAt', 'lte', '2026-12-31')
// both survive — a between-style range on createdAt
// shorthands:
filterQuery().addGte('createdAt', '2026-01-01').addLte('createdAt', '2026-12-31')Calling add with a non-range operator throws — use where (or between) for those.
remove and clear
filterQuery()
.equals('status', 'active')
.contains('name', 'fleet')
.remove('status') // drops all filters for `status`
.build()
// → { filter: { where: [{ field: 'name', operator: 'contains', value: 'fleet' }] } }
filterQuery().equals('a', 1).clear() // resets everything to emptyAND / OR groups
.or() and .and() take a callback that receives a sub-builder; its conditions become a nested group:
filterQuery()
.equals('status', 'active')
.or((q) => q
.contains('name', 'sync')
.contains('email', 'sync'),
)
.build()
// → status = 'active' AND (name contains 'sync' OR email contains 'sync')Groups are emitted as a ColumnFilter with an empty field and an OR/AND array — the "group node" shape the server's validator recognizes. Groups are not representable as flat objects, so toQueryString() falls back to the indexed where[i][...] notation when any group is present; toFlatObject() ignores them.
Sort, pagination, search, include, distinct
filterQuery()
.sort('createdAt', 'desc') // or .sortDesc('createdAt') / .sortAsc('name')
.search('fleet') // global term
.include('role', 'posts') // relations the caller should preload (deduped)
.distinct('status') // distinct projection (deduped)
.page(0, 25) // 0-based page index, size (default 25)
.set('extraKey', 'value') // arbitrary extra key in the output`include` is a request, not an action
.include(...) only carries relation names across the wire. Nothing in this
library preloads them: parseFilterRequest drops include entirely, and
parseSpatieRequest returns it as input.include but no runner consumes it.
Eager-loading stays your call, because only you know which relations are safe to
expose — read the list, check it against your own allow-list, and call Lucid's
preload yourself:
const PRELOADABLE = ['posts', 'team'] as const
const input = parseSpatieRequest(ctx.request.qs())
const query = User.query()
applyFilterFromRequest(query, userFilter, ctx, { input })
for (const relation of input.include ?? []) {
if (PRELOADABLE.includes(relation as any)) query.preload(relation as any)
}The same goes for select (JSON:API sparse fieldsets) — it is parsed, never applied.
.page(page, size) uses a 0-based page index (TanStack convention). The server's applyFilter resolves a 1-based page for Lucid's paginate(). If you send the client's page straight through, add 1 at the boundary — or use toQueryString(), where the page is passed verbatim and you normalize server-side.
Output formats
| Terminator | Returns | Use for |
|---|---|---|
toQueryString() | a &-joined query string | GET endpoint URLs |
build() | a FilterQueryResult object — { filter: { where }, sort, paginate, include, search, distinct } | POST search bodies |
toFlatObject() | a flat { field: value | { op: value } } map | the filter bag of a flat request; drops groups |
const builder = filterQuery().gte('age', 18).in('status', ['A', 'B'])
builder.toQueryString() // filter%5Bage%5D[gte]=18&filter%5Bstatus%5D[]=A&filter%5Bstatus%5D[]=B
builder.build() // { filter: { where: [ {age gte 18}, {status in [A,B]} ] } }
builder.toFlatObject() // { age: { gte: 18 }, status: ['A', 'B'] }Why the output has %5B in it
toQueryString() percent-encodes the field-name brackets (filter%5Bage%5D)
while leaving the operator brackets literal. Both spellings decode to the same
filter[age][gte] key, so the server sees no difference and you can paste the
string straight into a URL. It matters in exactly one place: a test that asserts
the string. Compare against the encoded form shown here, or decode first with
decodeURIComponent(qs).
The typed builder
filterQueryTyped<Fields, Map>() is the same builder at runtime, but its method signatures restrict field names to a known union — and, when you supply a field-type map, restrict each field's operators and value types too. Zero runtime overhead; the typing is the only difference.
Field names only
import { filterQueryTyped } from '@adonis-agora/filter-client'
type UserFields = 'name' | 'age' | 'status'
const q = filterQueryTyped<UserFields>()
.contains('name', 'Al')
.gte('age', 18)
.sortDesc('age')
.page(0, 25)
.build()
// ❌ Compile error — 'invalid' is not a UserField:
// filterQueryTyped<UserFields>().where('invalid', 'foo')Field names + types
Pass a second type argument mapping each field to its TS value type. The builder then derives the legal operators and value types per field:
type UserMap = {
name: string
age: number
status: 'active' | 'inactive'
createdAt: Date
deletedAt: Date | null
}
const q = filterQueryTyped<keyof UserMap & string, UserMap>()
.contains('name', 'Al') // ✅ string field → string operators
.gte('age', 18) // ✅ number field → ordering operators
.equals('status', 'active') // ✅ only the enum members are accepted
.between('createdAt', d1, d2) // ✅ Date field → ordering + tuple
.isNull('deletedAt') // ✅ isNull/isNotNull valid for every field
// ❌ contains() is gated to string fields — 'age' is a number:
// filterQueryTyped<keyof UserMap & string, UserMap>().contains('age', 'x')
// ❌ gte() is gated to orderable fields — 'status' is a string enum:
// filterQueryTyped<keyof UserMap & string, UserMap>().gte('status', 'x')The operator matrix the types enforce mirrors the runtime validator exactly:
| Field base type | Allowed operators |
|---|---|
string (incl. string enums) | equality, string ops, array ops, isEmpty/isNotEmpty, isNull/isNotNull, exists/notExists |
number | equality, ordering, between/notBetween, array ops, null/exists unary |
boolean | equality, array ops, null/exists unary |
Date | equality, ordering, between/notBetween, array ops, null/exists unary |
json / other / unknown | permissive — the full operator union |
Nullable fields (Date | null) get the base type's operators — NonNullable strips the null so deletedAt: Date | null still gets ordering operators, plus the always-valid isNull/isNotNull.
whereDynamic / sortDynamic — the runtime escape hatch
The typed builder narrows where and sort to the known field union — which is exactly what you want until the field name isn't known at compile time: an AG-Grid column id, a user-built query, a field the codegen'd builder doesn't cover. For those, whereDynamic and sortDynamic take a plain string field and deliberately step outside the type-level allow-list.
// The field comes from runtime state (a grid column, a saved view, user input):
builder.whereDynamic(column.id, filterModel.operator, filterModel.value)
// Unary operators accept the 2-arg form:
builder.whereDynamic('deletedAt', 'isNull')
// Sort counterpart:
builder.sortDynamic(column.id, sortModel.direction) // direction defaults to 'asc'They are not unvalidated: whereDynamic still checks the operator against the known operator set (an unknown operator throws) and validates the value shape against it, and it keeps the same replace semantics as where (one filter per field). sortDynamic is identical to sort at runtime — it exists purely so the typed builder can keep sort narrowed to the generated union while still offering an explicit untyped entry point.
import { filterQueryTyped } from '@adonis-agora/filter-client'
type UserFields = 'name' | 'age'
filterQueryTyped<UserFields>()
.contains('name', 'Al') // ✅ typed, narrowed field
.whereDynamic('customAttr.color', 'equals', 'red') // ✅ field outside the union
.sortDynamic('customAttr.color', 'desc')The server allow-list is still the real boundary
whereDynamic/sortDynamic only bypass the client's compile-time field union — a convenience, never a security control. The server's applyFilter allow-list is the actual boundary: a dynamic field the server doesn't whitelist is dropped (or rejected under throwOnInvalid) regardless of what the client sent.
Reactivity
The builder is also an observable store, so framework adapters can re-render on mutation. Every mutating method bumps an internal version, invalidates a cached snapshot, and notifies subscribers.
| Member | Purpose |
|---|---|
subscribe(listener) | register a change listener; returns an unsubscribe fn (bound — pass it directly) |
getSnapshot() | the current build() result, cached with a stable reference until the next mutation |
getVersion() | monotonic mutation counter (cheap dependency key) |
This is the exact contract React's useSyncExternalStore needs:
import { useSyncExternalStore, useRef } from 'react'
import { filterQuery } from '@adonis-agora/filter-client'
function useFilterQuery() {
const ref = useRef(filterQuery())
const builder = ref.current
const snapshot = useSyncExternalStore(builder.subscribe, builder.getSnapshot)
return [builder, snapshot] as const
}
// usage: builder.contains('name', e.target.value) re-renders with the new snapshotgetSnapshot() returns the same object reference until the next mutation — required by useSyncExternalStore to avoid infinite render loops. Don't mutate the returned snapshot; treat it as immutable.
TanStack Table sync
A types-only peer (@tanstack/table-core) powers an optional adapter at the @adonis-agora/filter-client/tanstack subpath. It maps vanilla TanStack Table state — column filters, sorting, pagination — onto a builder.
import { filterQuery } from '@adonis-agora/filter-client'
import { applyTanstackTableState } from '@adonis-agora/filter-client/tanstack'
const body = applyTanstackTableState(filterQuery(), {
columnFilters: table.getState().columnFilters, // { id, value }[]
sorting: table.getState().sorting, // { id, desc }[]
pagination: table.getState().pagination, // { pageIndex, pageSize }
resolveOperator: (id) => (id === 'createdAt' ? 'gte' : 'iContains'),
fields: ['name', 'status', 'createdAt'], // optional allowlist; others dropped
})
.include('author')
.build()TanStack column filters carry no operator (it lives in the column's filterFn), so resolveOperator(columnId, value) is the seam where you decide one. The default: array → in, string → iContains (matching TanStack's auto substring filtering), everything else → equals.
For a one-shot build straight from state:
import { tanstackTableToFilterQuery } from '@adonis-agora/filter-client/tanstack'
const body = tanstackTableToFilterQuery({
columnFilters: table.getState().columnFilters,
sorting: table.getState().sorting,
pagination: table.getState().pagination,
})TanStack's pagination.pageIndex is 0-based and is passed straight to the builder's .page(). Keep the 0-based vs 1-based note in mind when the server reads it.
The rest of the public surface
Beyond the builder, the package exports the pieces it is built from, so you can reach for one without adopting the whole chain.
Serializers
toQueryString() is a composition of two exported functions. Use them directly
when you already hold the data in one of their shapes:
import { flatObjectToQueryString, columnFiltersToQueryString } from '@adonis-agora/filter-client'
// A flat { key: value | value[] | { op: value } } map → a query string.
flatObjectToQueryString({ 'filter[age]': { gte: 18 }, sort: '-createdAt' })
// A ColumnFilter[] (groups and all) → the indexed `where[i][...]` notation.
columnFiltersToQueryString([{ field: 'age', operator: 'gte', value: 18 }])
// → where[0][field]=age&where[0][operator]=gte&where[0][value]=18columnFiltersToQueryString is what the builder falls back to once any OR/AND
group is present, since groups are not representable as a flat object.
Operator validation
The builder validates every call it receives; the same checks are exported for code that assembles filters before handing them over — a dynamic filter UI, say, where the operator comes from a dropdown:
import { validateOperatorValue, validateAddOperator, RANGE_OPERATORS } from '@adonis-agora/filter-client'
validateOperatorValue('in', 'active') // throws: `in` expects an array
validateOperatorValue('isNull', 'x') // throws: unary operators take no value
validateAddOperator('equals') // throws: add() is for range operators
RANGE_OPERATORS.has('gte') // true — the set `add()` acceptsBoth throw a descriptive Error and return void on success, so they read as
assertions.
Types
| Export | What it is |
|---|---|
FilterQueryResult, SortItem, OffsetPagination | the shapes build() returns |
ColumnFilter, FilterOperator, FILTER_OPERATORS | one condition, the operator union, and the runtime list of every operator |
TypedFilterQuery, TypedFilterQueryBuilder | the generated typed builder's interface — what make:filter-client emits against |
FieldTypeKind, FilterFieldTypes | the field-kind declarations that drive per-field operator narrowing |
ValueAt, Base, OperatorsFor, ValueForOp, StringFieldsOf, OrderableFieldsOf | the type-level helpers behind that narrowing, for building your own typed wrappers |
Round trip
The client and server agree on the wire format by construction — what .toQueryString() emits is what parseFilterRequest reads:
const qs = filterQuery().contains('name', 'Al').gte('age', 18).sortDesc('createdAt').toQueryString()
await fetch(`/users?${qs}`)export default class UsersController {
async index(ctx: HttpContext) {
const query = User.query()
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
return query.paginate(page, size)
}
}See Operators for the full wire-format table and Getting Started for the end-to-end flow.
Provider & Macros
The optional @adonis-agora/filter provider registers chainable Lucid query-builder macros — applyFilterFromRequest and filterPaginate — so a model query can filter and paginate inline without importing a free function.
Testing
Unit-testing a filter spec against the shipped recording mock, asserting the allow-list and the server scope, and end-to-end Japa tests against a real database.