On the server
Share one schema, validate what the browser sends, and run it as parameterized SQL or in memory.
@qnot/core has no DOM and no framework, so the same schema module runs in the browser and in
Node, Bun, Deno or an edge function. The browser builds a query; the server decides whether to
trust it.
import { builtinFunctions, defineFields, f } from '@qnot/core';
export const fields = defineFields(
{
name: f.text(),
age: f.number({ min: 0, integer: true }),
plan: f.enum(['free', 'pro', 'team']),
signedUp: f.date(),
},
{ functions: builtinFunctions },
);import { assertValidQuery, encode, type QueryOf } from '@qnot/core';
import { sqlFormat } from '@qnot/core/formats/sql';
import { fields } from './fields';
// emptyQuery: 'throw' refuses a query with no rules instead of matching every row.
const postgres = sqlFormat({ dialect: 'postgres', params: 'numbered', emptyQuery: 'throw' });
export async function searchUsers(body: unknown) {
const query = body as QueryOf<typeof fields>;
assertValidQuery(fields, query); // throws with each bad rule's path and message
const { sql, params } = encode(postgres, query, fields);
return db.query(`SELECT * FROM users WHERE ${sql}`, params);
}Given age ≥ 21, plan in (pro, team), name contains "O'Brien; DROP TABLE users" and
signedUp > relativeDate(30 days ago), that produces:
age >= $1 AND plan IN ($2, $3) AND name LIKE $4 ESCAPE '!' AND signedUp > CURRENT_DATE - INTERVAL '30 day'
-- params: [21, 'pro', 'team', "%O'Brien; DROP TABLE users%"]User input only ever reaches the database as bound parameters: rule values, list items, function
arguments, custom operator values and case results. The INTERVAL text comes from relativeDate,
which only accepts a whole amount and a known unit.
What to check
- Validate, don't sanitize.
assertValidQueryrejects bad rules. AvoidsanitizeQuerywithremoveInvalidRulesorremoveUnknownon untrusted input: dropping a rule makes the query match more rows, not fewer. - Decide what empty means. A query with no rules is valid; by default it exports as
1 = 1.emptyQuery: 'none'makes it match nothing,'throw'refuses it. - Keep
unsupported: 'throw'(the default) so a rule the dialect can't express fails loudly instead of being dropped. - Map columns. Give fields a
columnwhen the API name differs from the database column. - Limit size.
settings.maxRulesandmaxDepthlimit the UI; on the server, check the size of the incoming JSON yourself.
Security
The SQL output was audited for injection against Postgres (including
standard_conforming_strings = off), MySQL (including NO_BACKSLASH_ESCAPES), SQL Server and
SQLite, in inline and parameterized mode, and CI keeps fuzzing it against all four. In short:
- Only JSON scalars are written into SQL, escaped so they mean the same thing under every server setting; NUL characters are refused.
- Fields, refs, functions and operators must exist in your schema (own keys only); anything else throws, and malformed groups always throw.
- Invalid rules and rules a dialect can't express throw by default, so a tampered query never silently matches more rows.
- Custom operator and function
formats.sqlare your SQL: they receive escaped literals or placeholders, but never interpolaterawvalues without checking them.
Threat model, guarantees per dialect, and the audit results: SECURITY.md.
In memory
The same query filters rows without a database:
import { compile } from '@qnot/core';
const matches = compile(fields, query);
const result = rows.filter(matches);The repository's examples/server runs this whole flow as a script.
On this page