A typed query behavior engine for filtering, progressive multi-sorting, pagination and backend-safe query generation.
querypipe manages the contract applied to your data — a single type-safe QuerySpec for filters, progressive (additive) multi-sort, and pagination — never the data itself.
It is not a UI library, not an ORM, and not a state manager. It doesn't compete with nuqs (URL state), TanStack Table (UI state) or api-query-params (Mongo parsing) — it's the bridge between them. One schema, one spec, one canonical wire format shared by client and server (and, soon, by the querypipe-java port — the spec and JSON wire format are language-independent by design).
URL / UI state querypipe Backend
┌────────────────┐ parse ┌────────────┐ generate ┌──────────────┐
│ ?sort=price… │ ─────────▶ │ QuerySpec │ ──────────▶ │ ORDER BY / │
│ TanStack sort │ ◀───────── │ (typed, │ ◀────────── │ Prisma / SQL │
│ nuqs params │ stringify │ frozen) │ validate │ (param-safe) │
└────────────────┘ └────────────┘ └──────────────┘
│ apply
▼
client-side rows (stable,
SQL ORDER BY-equivalent)
- 🧷 Type-safe end to end. Field names are literal types inferred from the schema (
sort("pricee")is a compile error); operator/value pairs are checked at the type level (betweenwants[T, T],isNullaccepts no value);enumFieldnarrows values to a union. - 🔀 Progressive sorting as a first-class semantic.
sortresets,thenSortappends without disturbing prior priorities,insertSortAfter/removeSort/toggleSortDirectionedit the chain in place; priorities renormalize to1..non every step. This is the "shift-click a second column header to refine the order" behavior, modeled properly. - 🛡️ Backend-safe by default. A mandatory field whitelist (there is no schema-less parse), guard limits (sort columns, filter count, value length,
INsize), no user input ever reaching aRegExp/pattern, and a structured multi-errorResult— parse and validate never throw. - 🎯 Deterministic. Canonical, byte-stable
stringify; stable sort with an automatically injected unique tie-breaker; a fixed SQLORDER BYreference model (PostgreSQL NULL semantics) shared by clientapplyand future adapters. - 🪶 Tiny & dependency-free. Zero runtime dependencies, ESM + CJS, fully tree-shakable, ≈ 7.4 kB min+gzip. Optional per-field value validation via the dependency-free Standard Schema interface (Zod / Valibot / ArkType plug in with no coupling).
npm install querypipe
# or: pnpm add querypipe · yarn add querypipe · bun add querypipeRequires Node ≥ 18.17 (or any modern browser/runtime). Ships ESM + CJS with correct types for both.
import { createQuery, numberField, stringField, dateField, enumField, isOk } from "querypipe";
const products = createQuery({
fields: {
id: numberField(),
name: stringField(),
price: numberField(),
category: enumField(["phone", "laptop", "tablet"]),
deletedAt: dateField(),
costPrice: numberField({ filterable: false, sortable: false }), // unreachable from the wire
},
stableBy: "id", // deterministic tie-breaker, injected at execution time
guards: { maxSortColumns: 3 }, // backend-safe by default
});const q = products
.filter("price", "between", [1000, 5000])
.filter("deletedAt", "isNull")
.sort("price", "desc")
.thenSort("name"); // refines, never destroys, the existing order
q.stringify();
// "sort=price:desc,name:asc&filter=price:between:1000,5000;deletedAt:isNull"
q.apply(rows); // non-mutating, stable, SQL ORDER BY-equivalentEvery builder method returns a new frozen query — undo/redo, diff and history (v0.2) come for free on top of it.
const parsed = products.parse(req.query.q ?? "");
if (!isOk(parsed)) {
return res.status(400).json({ errors: parsed.errors }); // structured, machine-readable, all at once
}
// parsed.value is whitelisted, guard-limited, injection-safe by construction
const rows = await db.product.findMany(toPrismaArgs(parsed.value));Progressive (additive) multi-sort
let q = products.sort("category"); // priority 1
q = q.thenSort("price", "desc"); // append → priority 2, category untouched
q = q.insertSortAfter("category", "name");// name slots in at priority 2, price → 3
q = q.toggleSortDirection("price"); // flip in place, keep its priority
q = q.removeSort("name"); // drop it, renormalize to 1..nthenSort is guaranteed never to change the (field, direction, priority) of any pre-existing sort — a property the conformance suite enforces.
Filter operators (MVP set)
eq neq gt gte lt lte in notIn between contains startsWith endsWith isNull notNull, combined with root-level AND. contains/startsWith/endsWith are always literal — user input never becomes a pattern language. isNull/notNull take no value (enforced by types and runtime). OR groups arrive in v0.3 as a pure API unlock — the FilterGroup type and wire format already ship in v0.1, so nothing about the spec shape changes.
Canonical string ⇄ spec, losslessly
products.parse("offset=30&filter=deletedAt:isNull&sort=price"); // lenient in…
// → canonical out, byte-stable:
// "sort=price:asc&filter=deletedAt:isNull&offset=30"Values containing the grammar's own separators round-trip losslessly via a 6-character percent-encode set (% , ; : & =). stringify(parse(x)) is idempotent — perfect for cache keys and ETags.
Structured errors, never thrown on the data path
const r = products.parse("sort=secret:asc,nope:asc&filter=price:like:5");
// r.errors === [
// { code: "NOT_SORTABLE", field: "secret", position: 5 },
// { code: "UNKNOWN_FIELD", field: "nope", position: 17 },
// { code: "INVALID_OPERATOR", field: "price", op: "like", position: 40 },
// ]Closed error-code set: UNKNOWN_FIELD · NOT_SORTABLE · NOT_FILTERABLE · INVALID_OPERATOR · INVALID_VALUE · MAX_SORT_EXCEEDED · MAX_FILTER_EXCEEDED · VALUE_TOO_LONG · CONFLICTING_PAGINATION. Schema/builder misuse throws TypeError (a bug), data errors return Result (input) — the line is mechanical.
Optional value validation (Standard Schema, zero coupling)
import { z } from "zod";
const users = createQuery({
fields: { email: stringField({ schema: z.string().email() }) },
});
// z / valibot / arktype all plug in via the ~standard interface — no runtime dependency added.| Concern | Owned by | querypipe's role |
|---|---|---|
| URL state persistence | nuqs, URLSearchParams |
Produces/consumes the canonical query string they store |
| Table UI state | TanStack Table | Two-way adapter (v0.2): table state ⇄ the same QuerySpec |
| Mongo query parsing | api-query-params | Compat dialect (v0.2): parse their syntax → canonical spec |
| ORM execution | Prisma, Drizzle, raw SQL | Adapters (v0.2): QuerySpec → param-safe where/orderBy |
| The query contract | — | querypipe — one typed spec everyone agrees on |
Runtime: createQuery · numberField stringField dateField booleanField enumField customField · parseQuery stringifyQuery validateQuerySpec canonicalize applyQuery · isOk isErr
Types: QuerySpec SortCondition FilterNode FilterCondition FilterGroup Operator QueryError QueryErrorCode FieldSchema QueryResult
The full frozen surface (implementation-free, CI-typechecked) lives at docs/api/public-api.d.ts. Methods are also available fluently on a query instance (q.parse, q.stringify, q.apply, q.withSpec, q.toSpec).
| Package | Contents |
|---|---|
querypipe |
The primary published package. Field schema DSL, immutable builder, parse/stringify/canonicalize, validate, client-side apply. Zero dependencies. |
@querypipe/testing |
Language-independent JSON conformance vectors (written before the implementation — they are the contract), the vector runner seam, an independent ORDER BY reference model, fast-check invariant properties. Private in v0.1; the scoped name is reserved. |
Examples: examples/react-table (TanStack Table + nuqs, manual wiring) · examples/express-prisma (parse → validate → Prisma args).
Every architectural decision is recorded as an ADR — read them to understand why the spec looks the way it does:
- 📐 Architecture Decision Records — 11 ADRs (wire format & discriminated filter AST, immutability, error model, adapter contract, tie-breaker, guards, escaping/canonicalization, NULL semantics, Standard Schema, dialect plugins, Java portability)
- 📜 Normative specs: query-string grammar · JSON wire format · conformance test-vector format
- 🗺️ Roadmap & quarterly plan — what's reserved, what's next, and the exit criteria for each release
Design priority order on any trade-off: Java portability → type safety → bundle size → API ergonomics.
| Release | Theme | Highlights |
|---|---|---|
| v0.1 ✅ | The contract | Core engine + conformance suite (this release) |
| v0.2 | Generate & interop | Adapters (@querypipe/prisma · @querypipe/sql · @querypipe/mongo · two-way @querypipe/tanstack), syntax dialects (JSON:API, OData, page=), keyset/cursor pagination, spec versioning & migrate, computed & nested fields, diff/merge/history |
| v0.3 | OR groups | orGroup(...) builder — a pure API unlock (type & wire format already ship in v0.1) |
| querypipe-java | Cross-language | Same spec, same JSON vectors, JPA/JDBC adapters |
Full quarter-by-quarter detail with exit criteria: docs/ROADMAP.md. Interface stubs for v0.2 already live in docs/api/adapter-v0.2.d.ts and docs/api/dialect-v0.2.d.ts.
pnpm install
pnpm build # tsup → ESM + CJS + .d.ts
pnpm test # vitest: unit + 20 conformance vector files + fast-check invariants
pnpm typecheck
pnpm check # everything CI runs (build, typecheck, tests, publint, attw, size gate)The conformance suite is the source of truth: vectors are written before the implementation, and the same JSON files will be the acceptance suite for querypipe-java. See CONTRIBUTING.
MIT © querypipe contributors