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.

fields.ts (shared)
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 },
);
server.ts
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. assertValidQuery rejects bad rules. Avoid sanitizeQuery with removeInvalidRules or removeUnknown on 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 column when the API name differs from the database column.
  • Limit size. settings.maxRules and maxDepth limit 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.sql are your SQL: they receive escaped literals or placeholders, but never interpolate raw values 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.