@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.
π‘ 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 throwsConcurrencyErroron 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, l => l.userId, r => r.id);
const UserEntity = defineEntity(UserSchema)
.hasMany(t => t.todos, l => l.id, r => r.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 hereOptimistic 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 pushAPI Reference
| Method / Type | Description |
|---|---|
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 |
ConcurrencyError | Thrown when a row-version UPDATE/DELETE hits a conflict |
EntityNotFoundError | Thrown by findOrFail when no row matches |
InvariantViolationError | Thrown when PK or discriminator is mutated on a tracked entity |
PendingChangesError | Thrown by [Symbol.asyncDispose] with unsaved changes |
See Also
- @cleverbrush/knex-schema β the schema DSL and query builder that powers this ORM
- @cleverbrush/orm-cli β migration CLI tool
- API Reference β full TypeDoc-generated API documentation