@cleverbrush/knex-schema

Type-safe, schema-driven query builder for Knex — automatic column mapping, eager loading without N+1, and full CRUD with complete TypeScript inference.

# @cleverbrush/knex-schema
$npm install @cleverbrush/knex-schema
# peer dependency
$npm install knex

💡 Why @cleverbrush/knex-schema?

The Problem

Raw Knex queries are untyped strings. You repeat column names in queries, migrations, and application code. When a column is renamed, nothing tells you which queries broke. Eager loading related rows requires N+1 queries or hand-written CTEs.

The Solution

Describe your table once with an @cleverbrush/schema object builder. Add .hasColumnName() and .hasTableName() to map TypeScript property names to SQL column names. From that point, every query is type-safe — column references are property accessor functions, write operations accept typed objects, and the builder resolves names automatically.

Key Features

  • Schema-as-table — one schema definition is the source of truth for both TypeScript types and SQL column mapping.
  • Type-safe column references — use t => t.firstName instead of 'first_name'. Rename a property and TypeScript tells you every broken query.
  • Eager loading without N+1 — .joinOne() and .joinMany() load related rows in a single PostgreSQL CTE query.
  • Full CRUD — typed insert, insertMany, update, delete with bidirectional column mapping.
  • Thenable — await query(db, Schema) directly without calling .execute().

Quick Start

Import schema builders from @cleverbrush/knex-schema (not from @cleverbrush/schema) to get the .hasColumnName() / .hasTableName() extension methods:

import knex from 'knex';
import { query, object, string, number, date } from '@cleverbrush/knex-schema';

// Describe your table — one definition for types and SQL
const UserSchema = object({
    id:        number(),
    firstName: string().hasColumnName('first_name'),
    lastName:  string().hasColumnName('last_name'),
    age:       number().optional(),
    createdAt: date().hasColumnName('created_at'),
}).hasTableName('users');

const db = knex({ client: 'pg', connection: process.env.DB_URL });

// Query — property accessors resolve to SQL column names automatically
const adults = await query(db, UserSchema)
    .where(t => t.age, '>', 18)
    .orderBy(t => t.lastName);
//→ Array<{ id: number; firstName: string; lastName: string; age?: number; createdAt: Date }>

CRUD Operations

// INSERT — returns the full inserted row (database-generated fields included)
const user = await query(db, UserSchema).insert({
    firstName: 'Alice',
    lastName:  'Smith',
    age:       30,
    createdAt: new Date(),
});

// UPDATE — returns updated rows
const updated = await query(db, UserSchema)
    .where(t => t.id, userId)
    .update({ firstName: 'Alicia' });

// DELETE — returns row count
const count = await query(db, UserSchema)
    .where(t => t.id, userId)
    .delete();

// SELECT first match
const user = await query(db, UserSchema)
    .where(t => t.id, userId)
    .first();  // → UserType | undefined

Guarded Upserts

Use onConflict().merge() when an import or sync job should insert missing rows, but update existing rows only when the incoming data is newer.

await query(db, ProductSchema)
    .onConflict(t => t.sku)
    .merge(
        {
            sku: 'SKU-123',
            price: 1999,
            sourceUpdatedAt: new Date('2026-01-15T10:00:00Z'),
        },
        {
            price: ({ excluded }) => excluded(t => t.price),
            sourceUpdatedAt: ({ excluded }) =>
                excluded(t => t.sourceUpdatedAt),
            syncedAt: ({ knex }) => knex.fn.now(),
        },
        {
            where: (qb, { column }) => {
                qb.whereRaw('?? < excluded.??', [
                    column(t => t.sourceUpdatedAt),
                    column(t => t.sourceUpdatedAt),
                ]);
            },
        }
    );

import type { InferDatabaseRow } from '@cleverbrush/knex-schema';
type ProductRow = InferDatabaseRow<typeof ProductSchema>;

excluded() references the incoming row from the failed insert, column() resolves schema property names to SQL column names, and the where callback adds a PostgreSQL guarded update.

insert into "products" ("sku", "price", "source_updated_at")
values (?, ?, ?)
on conflict ("sku") do update set
    "price" = excluded."price",
    "source_updated_at" = excluded."source_updated_at",
    "synced_at" = CURRENT_TIMESTAMP
where "source_updated_at" < excluded."source_updated_at"
returning *

Eager Loading (No N+1)

.joinOne() and .joinMany() load related rows in a single PostgreSQL query using correlated subqueries and jsonb_agg. The inferred TypeScript type is updated automatically for each join you add.

const PostSchema = object({
    id:       number(),
    title:    string(),
    authorId: number().hasColumnName('author_id'),
}).hasTableName('posts');

// One-to-many — load each user's posts (latest 5, newest first)
const users = await query(db, UserSchema)
    .joinMany({
        foreignSchema: PostSchema,
        localColumn:   t => t.id,
        foreignColumn: t => t.authorId,
        as:            'posts',
    }, posts => posts.orderBy(t => t.id, 'desc').limit(5));
// users[0].posts → Array<{ id: number; title: string; authorId: number }>

// Many-to-one — attach the author to each post
const posts = await query(db, PostSchema)
    .joinOne({
        foreignSchema: UserSchema,
        localColumn:   t => t.authorId,
        foreignColumn: t => t.id,
        as:            'author',
        required:      false,  // left join — author may be null
    });
// posts[0].author → { id: number; firstName: string; ... } | null

Filtering

Use property accessors or string keys — both are resolved to SQL column names. Record objects ({ name: "Alice" }) have their keys mapped automatically too.

query(db, UserSchema)
    .where(t => t.firstName, 'like', 'A%')          // property accessor
    .andWhere('age', '>', 18)                        // string key
    .orWhere({ lastName: 'Smith' })                  // record — keys mapped to columns
    .whereIn(t => t.id, [1, 2, 3])
    .whereNotNull(t => t.createdAt)
    .whereBetween(t => t.age, [20, 40])
    .whereILike(t => t.lastName, 'sm%')             // case-insensitive (PostgreSQL)
    .whereRaw('extract(year from created_at) = ?', [2025]);

Escape Hatch

Use .apply(fn, { output }) with a complete output schema for raw SQL. The independently mutable Knex.QueryBuilder is passed to your callback:

const UserName = object({ name: string() });
const rows = await query(db, UserSchema)
    .where(t => t.id, id)
    .apply(qb => qb.clearSelect().select({ name: 'first_name' }), {
        output: UserName,
    });

// Share an immutable Framework base query; retain each configured result.
const base = query(db, UserSchema).whereNull(t => t.deletedAt);
const activeUsers = await base.where(t => t.age, '>', 18);

Composable read queries

Use alias(schema, name) with typed flat joins and object projections for DTOs. Left-joined fields include null; source scopes and soft deletion are retained.

const task = alias(TaskSchema, 'task');
const owner = alias(UserSchema, 'owner');
const rows = await query(knex, task)
    .leftJoin(owner, t => eq(t.task.ownerId, t.owner.id))
    .select(t => ({ id: t.task.id, ownerName: t.owner.name }));

Aggregates and optional output schemas

Count helpers return checked numbers; sum and average preserve database numeric text or null. Min/max follow the column representation. Optional output schemas replace decoding and receive raw driver values, including null. Typed aggregate expressions support grouped results.

const count = await query(knex, TaskSchema).countValue();
const average = await query(knex, TaskSchema).avgValue(t => t.estimate, {
    output: number().isFloat().coerce().nullable()
});

Ordering and composite cursors

Eager loading preserves final parent order and page size. Composite cursors retain timestamp precision and require non-null scalar columns with a declared unique tie-breaker. Reapply access filters; cursors are not authorization.

const page = await query(knex, TaskSchema).paginateAfter({
    limit: 50, cursor,
    orderBy: [
        { column: t => t.createdAt, direction: 'desc' },
        { column: t => t.id, direction: 'desc' }
    ]
});

Queries are immutable. Read the complete query guide for examples, precision policy, and restrictions.

Automatic projection-aware schemas

Every query exposes rowSchema automatically. Immutable PostgreSQL queries describe the actual decoded result schema: SQL null stays null, dates are Date objects at every depth, and decimal/bigint values are exact strings before JSON parsing.

const read = query(knex, TaskSchema)
    .select(t => ({ title: t.title, amount: t.amount }));
const Source = read.rowSchema;
const rows = await read.where(t => t.id, taskId);

Typed aliases, aggregates, named projections and nested graphs retain their selected shape. Polymorphic readers expose a union rowSchema and per-variant object schemas. Raw shapes require an explicit Framework output schema. Retain every returned builder when configuring a query.

Read representation and pagination details

Define now, supply the connection at execution

Keep reusable queries in their own modules. Their row schemas and argument types are available without Knex; handlers supply an injected connection or transaction.

// data/user-queries.ts
import { parameter, query } from '@cleverbrush/knex-schema';
import { UserSchema } from './user-schema.js';

export const findUser = query(UserSchema)
    .where(user => user.id, parameter('id'));
export const names = query(UserSchema).select('id', 'name');

// api/handlers/get-user.ts
import type { Knex } from 'knex';
import { findUser } from '../../data/user-queries.js';

export async function getUser(knex: Knex, id: number) {
    return findUser.query(knex, id).first();
}

// Direct SELECT or SQL inspection:
await findUser(knex, 10);
await names(knex); // parameterless definition
findUser.toSQL(knex, 10);

Definitions are immutable and non-thenable. Selectors, scopes and relation/variant customizers run once during configuration. Compiled PostgreSQL SELECTs are cached per definition and actual Knex instance, never across unrelated clients; each call executes with fresh bindings.

Bind with .query(knex, ...values) before pagination, native SQL escape hatches or supported writes. The caller owns transaction lifetime. Existing connection-first queries and ORM DbSets remain supported.

Multi-file examples, transactions and cache boundaries

Parameterized compiled queries

Add a named parameter to make a PostgreSQL SELECT callable. Argument types come from the selected schema properties, in the order their names first appear. Repeated names share one argument.

import { parameter, query } from '@cleverbrush/knex-schema';

const findUser = query(knex, UserSchema)
    .where(t => t.id, parameter('id'));
const users = await findUser(10);

const search = query(knex, UserSchema)
    .where(t => t.firstName, parameter('name'))
    .where(t => t.age, '>=', parameter('minimumAge'));
await search('John', 18);
await search('Jane', 30);

const sql = search.toSQL('John', 18); // inspect without executing
const bound = search.query('John', 18);
await bound.orderBy(t => t.age).limit(10);

The first call or SQL inspection caches SQL, binding slots and decoding. Later calls bind fresh values and execute again. Bound readers compose independently; template derivatives own their compiled statements. Relations, aliases, variants and caller-owned transactions are supported.

Examples, null semantics and supported parameter positions

Filtering and ordering schema-aware reads

Group AND/OR search predicates without changing the selected row schema. IN/EXISTS subqueries, null checks, and bound raw predicates and ordering remain explicit. Every outer operation returns an independent reader.

const base = query(knex, TaskSchema)
    .select(t => ({ id: t.id, title: t.title }));
const read = base.where(t => t.projectId, projectId)
    .andWhere(group => group
        .where(t => t.title, 'ilike', pattern)
        .orWhereExists(labelMatches))
    .orderByRaw('case when ?? = ? then 0 else 1 end', [
        base.ref(t => t.id), priorityTaskId
    ]);
// read.rowSchema === base.rowSchema

Group callbacks run synchronously once and expose only predicates. Subquery SQL and bindings are captured on attachment, with no database execution. Use ref() for quoted columns and generated aliases; keep values in bindings and raw SQL fragments application-authored. Raw apply() requires an explicit output schema. Return the configured group from every predicate callback.

Multi-file examples and compatibility boundaries

API Reference

API reference table
CategoryMethods
Eager loading.joinOne(spec), .joinMany(spec)
Filtering.where(), .andWhere(), .orWhere(), .whereNot(), .whereIn(), .whereNotIn(), .whereNull(), .whereNotNull(), .whereBetween(), .whereNotBetween(), .whereLike(), .whereILike(), .whereRaw(), .whereExists()
Ordering.orderBy(col, dir?), .orderByRaw(sql)
Grouping.groupBy(...cols), .groupByRaw(sql), .having(), .havingRaw(sql)
Pagination.limit(n), .offset(n)
Selection.select(...cols), .distinct(...cols)
Aggregates.count(col?), .countDistinct(col?), .min(col), .max(col), .sum(col), .avg(col)
Writes.insert(data), .insertMany(data[]), .update(data), .delete()
Execution.execute(), .first(), await builder (thenable)
Escape hatch.apply(fn, { output }), .toQuery(), .toString()

JSONB documents

Use object({ ... }).jsonb() for a document column. Add .acceptUnknownProps() on each object that must retain undeclared JSON fields. Reads, projections and write-returning results preserve those fields alongside typed, declared properties.

Invalid JSON extension values are rejected before persistence. Tracked ORM document columns detect nested edits and compare documents structurally; object key order is not a storage guarantee. Optional and nullable object columns accept SQL null.