@cleverbrush/orm

EF-Core-like typed ORM on top of @cleverbrush/knex-schema β€” identity map, change tracking, relations, polymorphic inheritance, and schema migrations in one package.

# @cleverbrush/orm
$npm install @cleverbrush/orm
# migration CLI (dev)
$npm install --save-dev @cleverbrush/orm-cli

πŸ’‘ Why @cleverbrush/orm?

The Problem

Even with a type-safe query builder, you still need to track which entities changed, propagate foreign keys through related inserts, handle optimistic concurrency, and keep your schema in sync with the database. All of that usually requires stitching together multiple libraries or writing the same boilerplate repeatedly.

The Solution

@cleverbrush/orm wraps @cleverbrush/knex-schema in an EF-Core– style layer: define entities once, declare relations fluently, and let the context track every mutation automatically. Flush all changes in one saveChanges() call.

Key Features

  • Identity map β€” loading the same PK twice always returns the same object reference.
  • Automatic change detection β€” mutate entity properties normally; the tracker detects diffs on flush.
  • Relation graph saves β€” pass a nested object graph to db.users.save(); the ORM propagates PKs and FKs in the right order inside a transaction.
  • Optimistic concurrency β€” mark any column with .rowVersion(); the ORM auto-increments it and throws ConcurrencyError on conflict.
  • STI & CTI polymorphic inheritance β€” model discriminated unions with single-table or class-table layouts.
  • Schema migrations β€” diff your entity definitions against the live DB and emit typed migration files with @cleverbrush/orm-cli.

Quick Start

import knex from 'knex';
import { number, object, string, defineEntity, createDb } from '@cleverbrush/orm';

// 1. Define schema + entity
const UserSchema = object({
    id:    number().primaryKey(),
    email: string().hasColumnName('email_address'),
    name:  string(),
}).hasTableName('users');

const UserEntity = defineEntity(UserSchema);

// 2. Create the context
const db = createDb(
    knex({ client: 'pg', connection: process.env.DATABASE_URL }),
    { users: UserEntity }
);

// 3. Query
const alice = await db.users.find(1);

// 4. Insert / update (PK absent β†’ INSERT, PK present β†’ UPDATE)
const created = await db.users.save({ email: 'bob@example.com', name: 'Bob' });
const updated = await db.users.save({ id: 1, email: 'alice@example.com', name: 'Alice' });

Relations

Declare relations fluently on the entity. They are fully typed β€” .include() only accepts keys you have declared.

const TodoSchema = object({
    id:     number().primaryKey(),
    title:  string(),
    userId: number().hasColumnName('user_id'),
    author: object({ id: number().primaryKey(), name: string() })
                .hasTableName('users').optional(),
}).hasTableName('todos');

const TodoEntity = defineEntity(TodoSchema)
    .belongsTo(t => t.author, 'userId');

const UserEntity = defineEntity(UserSchema)
    .hasMany(t => t.todos, TodoEntity, 'userId');

const db = createDb(knex, { users: UserEntity, todos: TodoEntity });

// Eager-load the author
const todo = await db.todos
    .where(t => t.id, 42)
    .include(t => t.author)
    .first();
console.log(todo?.author?.name); // fully typed

// Save a whole graph in one transaction
const user = await db.users.save({
    name: 'Alice',
    todos: [{ title: 'Buy milk', completed: false, userId: 0 }],
});

Change Tracking

Enable tracking with { tracking: true }. The context maintains an identity map and flushes all dirty entries inside a single transaction on saveChanges().

const db = createDb(knex, { users: UserEntity }, { tracking: true });

// Load β€” entity enters the identity map as Unchanged.
const user = await db.users.find(1);

// Mutate normally β€” no special setter required.
user.name = 'Updated';

// All dirty entries are flushed in one transaction.
const { inserted, updated, deleted } = await db.saveChanges();

// Works with await using β€” throws PendingChangesError if not flushed.
async function handler() {
    await using db = createDb(knex, { users: UserEntity }, { tracking: true });
    const user = await db.users.find(1);
    user.name = 'Updated';
    await db.saveChanges();
}   // Symbol.asyncDispose fires here

Optimistic Concurrency (Row Versioning)

const OrderSchema = object({
    id:      number().primaryKey(),
    status:  string(),
    version: number().rowVersion(),   // auto-incremented on every UPDATE
}).hasTableName('orders');

const db = createDb(knex, { orders: OrderEntity }, { tracking: true });

const order = await db.orders.find(orderId);
order.status = 'shipped';

try {
    await db.saveChanges();
} catch (err) {
    if (err instanceof ConcurrencyError) {
        // Another process updated the row β€” reload and retry.
        await db.reload(order);
    }
}

Polymorphic Entities (STI / CTI)

Model discriminated unions with Single-Table Inheritance (one table, discriminator column) or Class-Table Inheritance (base table + per-variant extension tables).

// ── STI: all variants in one table ──────────────────────────────
const ActivityBase = object({
    id:     number().primaryKey(),
    type:   string(),
    todoId: number().hasColumnName('todo_id'),
}).hasTableName('activities');

const ActivityEntity = defineEntity(ActivityBase)
    .discriminator('type')
    .stiVariant('assigned', object({
        type:       string('assigned'),
        assigneeId: number().hasColumnName('assignee_id').optional(),
    }))
    .stiVariant('commented', object({
        type: string('commented'),
        body: string().optional(),
    }));

const db = createDb(knex, { activities: ActivityEntity });

// Get a typed view scoped to one variant (analogous to EF Core Set<T>())
const assigned = db.activities.ofVariant('assigned');

// Insert β€” discriminator is set automatically
const activity = await assigned.insert({ todoId: 42, assigneeId: 9 });
// activity.type === 'assigned', activity.assigneeId === 9

// Find by PK, typed to the variant
const found = await assigned.find(activity.id);

// Update rows matching a WHERE clause
await assigned.where(t => t.id, 3).update({ assigneeId: 99 });

// Delete rows matching a WHERE clause
await assigned.where(t => t.id, 3).delete();

Schema Migrations

Use @cleverbrush/orm-cli to diff your entity definitions against the live database and emit TypeScript migration files.

// db.config.ts β€” one config file, picked up automatically by cb-orm
import knex from 'knex';
import { defineConfig } from '@cleverbrush/orm-cli';
import { UserEntity, TodoEntity } from './src/db/schemas.js';

export default defineConfig({
    knex: knex({ client: 'pg', connection: process.env.DATABASE_URL }),
    entities: { users: UserEntity, todos: TodoEntity },
    migrations: { directory: './migrations' }
});
# Diff schema vs DB β†’ emit a TS migration file
npx cb-orm migrate generate add_users_table

# Apply pending migrations
npx cb-orm migrate run

# Check migration status
npx cb-orm migrate status

# Sync in-place (dev only β€” no migration file)
npx cb-orm db push

API Reference

Method / TypeDescription
createDb(knex, map, opts?)Creates a DbContext (or TrackedDbContext with { tracking: true })
defineEntity(schema)Wraps a schema to enable relation and variant declarations
db.<set>.find(pk)Find one row by PK; undefined if not found
db.<set>.findOrFail(pk)Like find but throws EntityNotFoundError
db.<set>.findMany([pk…])Fetch multiple rows by PK in one query
db.<set>.save(graph)Insert or update a nested object graph (transactional)
db.<set>.ofVariant(key)Return a typed VariantDbSet scoped to a polymorphic variant β€” analogous to EF Core's Set<DerivedType>()
db.saveChanges()Flush all pending changes in a single transaction (tracked context only)
db.discardChanges()Roll back all in-memory mutations (tracked context only)
db.entry(entity)Return the EntityEntry view (state, snapshot, isModified, reset)
db.transaction(cb)Run a callback inside a Knex transaction
ConcurrencyErrorThrown when a row-version UPDATE/DELETE hits a conflict
EntityNotFoundErrorThrown by findOrFail when no row matches
InvariantViolationErrorThrown when PK or discriminator is mutated on a tracked entity
PendingChangesErrorThrown by [Symbol.asyncDispose] with unsaved changes

See Also