Статья о том, как превратить TypeScript-схему в единый источник истины для работы с базой данных: из одного определения получить типы строк, имена таблиц и колонок, типизированные запросы, связи, миграции и unit-of-work поверх Knex.

Все примеры ниже взяты из xpenser — open-source приложения для учёта личных доходов и расходов. Это одновременно полезное приложение, которым я сам пользуюсь, и референсная реализация Cleverbrush Framework: контракты, сервер, клиент, формы, auth, observability, PostgreSQL, Telegram bot и MCP endpoint живут в одном репозитории. Код открыт: github.com/cleverbrush/xpenser.

Дисклеймер: все описываемые библиотеки носят экспериментальный характер. Несмотря на это, покрытие тестами у них достаточно хорошее. Также в данный момент в этих библиотеках поддерживается лишь Postgres в качестве сервера баз данных.

Предыстория

В предыдущих статьях я рассказывал о @cleverbrush/schema, о типизированном HTTP API через @cleverbrush/server и @cleverbrush/client, а затем о @cleverbrush/log и @cleverbrush/otel.

Общая идея у всех этих пакетов одна: схема должна быть не просто валидатором, а источником структурной информации. Если схема уже знает, что у объекта есть поле userId, что это число, что оно обязательно, и что для него можно получить PropertyDescriptor через t => t.userId, то возникает естественный вопрос: почему бы не использовать это же знание для SQL?

Небольшое отступление для тех, кто не работал с Knex. Knex — это query builder для Node.js: он позволяет писать SQL-запросы цепочками методов вместо ручной сборки строк, умеет работать с PostgreSQL, MySQL, SQLite и другими SQL-базами, поддерживает транзакции, connection pool, migrations и schema builder.

Люди часто выбирают Knex, когда хотят оставаться близко к SQL, но не хотят каждый раз руками собирать SELECT ... WHERE ... и следить за плейсхолдерами. Это хороший промежуточный слой между raw SQL и тяжёлой ORM: меньше магии, проще предсказать итоговый запрос, легко упасть на обычный SQL там, где query builder мешает.

Но у Knex есть естественный предел. TypeScript может помочь с типом строки через knex<User>('users'), но имена таблиц и колонок всё равно часто остаются строками: 'users', 'user_id', 'created_at'. Если в коде доменная модель живёт в camelCase, база в snake_case, а рядом ещё есть API-контракты и валидация, появляется риск рассинхронизации. Именно этот зазор я и хотел закрыть schema-driven слоем поверх Knex.

Так появились два пакета:

  • @cleverbrush/knex-schema — schema-aware слой поверх Knex: имена таблиц и колонок, DDL-метаданные, типизированный query builder, projections, scopes, eager loading.

  • @cleverbrush/orm — более высокий уровень: defineEntity(), DbContext, DbSet, relations, транзакции, save graph и tracking context.

Knex при этом никуда не исчезает. Это не попытка заменить SQL полностью. Скорее наоборот: Knex остаётся нижним уровнем и escape hatch, а схема добавляет к нему типы и договорённости, которых обычно не хватает в большом приложении.

С чего начинается таблица

В xpenser таблицы описаны в apps/api/src/db/schemas.ts. Импорт идёт из @cleverbrush/orm, потому что ORM реэкспортирует все schema builders из @cleverbrush/knex-schema и добавляет к ним некоторые методы для описания связей, DDL-метаданных, проекций, навигационных свойств и т.д.:

import {
    boolean,
    type DbContext,
    date,
    defineEntity,
    number,
    object,
    string
} from '@cleverbrush/orm';

Начнём с простой, но реальной схемы пользователя:

export const UserDbSchema = object({
    id: number().primaryKey(),
    email: string(),
    passwordHash: string().optional().hasColumnName('password_hash'),
    emailVerified: boolean().hasColumnName('email_verified').defaultTo(false),
    role: string(),
    authProvider: string().hasColumnName('auth_provider'),
    defaultCurrency: string().hasColumnName('default_currency'),
    countryCode: string().hasColumnName('country_code').defaultTo('US'),
    timezone: string().defaultTo('UTC'),
    createdAt: date().hasColumnName('created_at').defaultTo('now'),
    updatedAt: date().hasColumnName('updated_at').defaultTo('now')
})
    .hasTableName('users')
    .projection(
        'public',
        'id',
        'email',
        'emailVerified',
        'role',
        'authProvider',
        'defaultCurrency',
        'countryCode',
        'timezone',
        'createdAt',
        'updatedAt'
    )
    .projection(
        'auth',
        'id',
        'email',
        'passwordHash',
        'emailVerified',
        'role',
        'authProvider',
        'defaultCurrency',
        'countryCode',
        'timezone'
    );

Здесь обычная schema-валидация дополняется DB-метаданными:

  • .hasTableName('users') задаёт имя таблицы.

  • .hasColumnName('password_hash') связывает camelCase-поле в TypeScript со snake_case-колонкой в SQL.

  • .primaryKey() помечает первичный ключ.

  • .defaultTo(false) и .defaultTo('now') описывают значения по умолчанию на уровне DDL.

  • .projection('auth', ...) задаёт именованный набор колонок для выборки.

В обычном Knex можно передать generic-типы в knex<User>('users'), но имена колонок всё равно остаются строками. Здесь TypeScript-свойство и SQL-колонка связаны один раз в схеме, а дальше query builder сам переводит passwordHash в password_hash.

Projections: типизированный SELECT

В xpenser projections используются там, где нельзя случайно достать лишнее поле. Например, при логине нужен passwordHash, но публичные ответы API не должны его видеть:

const user = await db.users
    .projected('auth')
    .where(candidate => candidate.email, email)
    .first();

projected('auth') делает две вещи одновременно:

  • в SQL уходит только набор колонок из .projection('auth', ...);

  • TypeScript-тип результата сужается до этих полей.

Если выбрать projected('public'), то обратиться к user.passwordHash уже не получится на этапе компиляции. Это именно та мелочь, ради которой и хочется держать схему как источник правды, а не как отдельный файл с типами рядом с SQL.

Foreign keys и индексы

Теперь посмотрим на доменную часть xpenser. Пользователь создаёт категории, продавцов и транзакции. Транзакция принадлежит пользователю, имеет категорию, опционального продавца, валюту, сумму, дату операции и рассчитанную сумму в валюте пользователя:

export const TransactionDbSchema = object({
    id: number().primaryKey(),
    userId: number()
        .hasColumnName('user_id')
        .references('users', 'id')
        .onDelete('CASCADE')
        .index('idx_transactions_user_id'),
    categoryId: number()
        .hasColumnName('category_id')
        .references('categories', 'id')
        .onDelete('RESTRICT')
        .index('idx_transactions_category_id'),
    vendorId: number()
        .hasColumnName('vendor_id')
        .references('vendors', 'id')
        .onDelete('SET NULL')
        .index('idx_transactions_vendor_id')
        .optional(),
    type: string(),
    amount: number(),
    currency: string(),
    defaultCurrencyAmount: number().hasColumnName('default_currency_amount'),
    defaultCurrency: string().hasColumnName('default_currency'),
    exchangeRate: number().hasColumnName('exchange_rate'),
    exchangeRateDate: string().hasColumnName('exchange_rate_date'),
    occurredAt: date().hasColumnName('occurred_at'),
    note: string().optional(),
    createdAt: date().hasColumnName('created_at').defaultTo('now'),
    updatedAt: date().hasColumnName('updated_at').defaultTo('now'),
    category: CategoryDbSchema.optional()
}).hasTableName('transactions');

Методы .references(), .onDelete() и .index() нужны не только для читабельности. Они сохраняются в introspection metadata схемы, а значит их можно использовать для генерации DDL, сравнения схем и построения миграций.

Обратите внимание на category: CategoryDbSchema.optional(). Это навигационное свойство: оно нужно ORM, чтобы результат .include(...) был типизированным. Сама связь объявляется отдельно, на уровне entity.

Entity и relations

Схема описывает форму строки и DB-метаданные. Entity добавляет связи:

export const CategoryEntity = defineEntity(CategoryDbSchema);
export const VendorEntity = defineEntity(VendorDbSchema);

export const TransactionEntity = defineEntity(TransactionDbSchema).belongsTo(
    t => t.category,
    l => l.categoryId,
    r => r.id
);

В этом примере:

  • t => t.category — навигационное свойство в результате;

  • l => l.categoryId — FK на таблице transactions;

  • r => r.id — PK на таблице categories.

Все три выражения типизированы. Если переименовать categoryId или попытаться связать число со строкой, TypeScript покажет ошибку до запуска приложения.

Для других случаев есть .hasOne(), .hasMany() и .belongsToMany(). В xpenser пока хватает belongsTo, но в тестах ORM покрыты и более сложные графы: сохранение дерева объектов, many-to-many через pivot таблицу, composite primary keys и polymorphic variants.

DbContext: карта всех таблиц

После объявления entity собираются в обычный объект:

export const entityMap = {
    users: UserEntity,
    categories: CategoryEntity,
    vendors: VendorEntity,
    transactions: TransactionEntity,
    transactionScans: TransactionScanEntity,
    transactionScanItems: TransactionScanItemEntity,
    transactionScanImages: TransactionScanImageEntity,
    exchangeRates: ExchangeRateEntity
};

export type AppEntityMap = typeof entityMap;
export type AppDb = DbContext<AppEntityMap>;

На старте API создаётся DbContext:

import { createDb } from '@cleverbrush/orm';
import { instrumentKnex } from '@cleverbrush/otel';
import knex from 'knex';
import { entityMap } from '../db/schemas.js';

const connection = instrumentKnex(
    knex({
        client: 'pg',
        connection: config.db.connectionString,
        pool: { min: 2, max: 10 },
        acquireConnectionTimeout: 10_000
    }),
    { sanitizeStatement: () => '<redacted>' }
);

const db = createDb(connection, entityMap);

На выходе db.users, db.categories, db.transactions и остальные поля становятся типизированными DbSet. Это похоже на DbContext из Entity Framework, только без декораторов, reflection metadata и кодогенерации.

Запросы: от простого к реальному

Самый простой запрос выглядит так:

const categories = await db.categories.where(
    category => category.userId,
    userId
);

Колонка выбирается через selector. В TypeScript это поле называется userId, а в SQL уйдёт user_id.

Можно загрузить одну транзакцию вместе с категорией:

const row = await db.transactions
    .include(transaction => transaction.category)
    .where(transaction => transaction.id, transactionId)
    .where(transaction => transaction.userId, userId)
    .first();

include(transaction => transaction.category) доступен только потому, что связь была объявлена в TransactionEntity. Результат знает о поле category, и это поле имеет тип категории, а не unknown.

В реальном списке транзакций запрос постепенно собирается из фильтров:

let builder = db.transactions
    .include(transaction => transaction.category)
    .where(transaction => transaction.userId, userId);

if (query.categoryId) {
    builder = builder.where(
        transaction => transaction.categoryId,
        query.categoryId
    );
}

if (query.from) {
    builder = builder.where(
        transaction => transaction.occurredAt,
        '>=',
        query.from
    );
}

if (query.to) {
    builder = builder.where(
        transaction => transaction.occurredAt,
        '<=',
        query.to
    );
}

const rows = await builder
    .orderBy(transaction => transaction.occurredAt, 'desc')
    .orderBy(transaction => transaction.id, 'desc');

Это всё ещё Knex-подобный fluent API, но без строковых имён колонок в основном пути.

Вставка, обновление, удаление

Создание транзакции в xpenser выглядит так:

const created = await db.transactions.insert({
    userId,
    categoryId: body.categoryId,
    vendorId: body.vendorId ?? undefined,
    type: category.type,
    amount: body.amount,
    currency: body.currency,
    defaultCurrencyAmount: convertAmount(body.amount, exchange.rate),
    defaultCurrency: user.defaultCurrency,
    exchangeRate: exchange.rate,
    exchangeRateDate: exchange.rateDate,
    occurredAt: body.occurredAt,
    note: body.note ?? undefined
});

Тип payload выводится из схемы. Нельзя передать строку в amount, нельзя забыть обязательный categoryId, нельзя случайно написать default_currency_amount вместо defaultCurrencyAmount.

Обновление:

await db.transactions
    .where(transaction => transaction.id, transactionId)
    .where(transaction => transaction.userId, userId)
    .update({
        categoryId: next.categoryId,
        vendorId: (next.vendorId ?? null) as never,
        type: category.type,
        amount: next.amount,
        currency: next.currency,
        defaultCurrencyAmount: convertAmount(next.amount, exchange.rate),
        defaultCurrency: user.defaultCurrency,
        exchangeRate: exchange.rate,
        exchangeRateDate: exchange.rateDate,
        occurredAt: next.occurredAt,
        note: next.note ?? undefined,
        updatedAt: new Date()
    });

Удаление:

const deleted = await db.transactions
    .where(transaction => transaction.id, transactionId)
    .where(transaction => transaction.userId, userId)
    .delete();

if (deleted === 0) {
    throw new TransactionNotFoundError('Transaction was not found.');
}

При этом escape hatch остаётся. В xpenser есть запросы, где проще взять чистый Knex: например, когда нужна ручная выборка из нескольких таблиц, специальные aliases или SQL, который не хочется прятать за ORM API. Это нормально: ORM не должен мешать писать SQL там, где SQL очевиднее.

Транзакции

DbContext поддерживает callback-форму транзакций:

await db.transaction(async trx => {
    await trx.telegramLinkTokens
        .where(candidate => candidate.userId, userId)
        .delete();

    await trx.telegramLinkTokens.insert({
        userId,
        tokenHash: hashTelegramLinkToken(token),
        expiresAt,
        consumedAt: undefined
    });
});

Если callback бросит исключение, Knex откатит транзакцию. Внутри callback вы получаете тот же DbContext, но привязанный к trx, поэтому trx.users, trx.transactions, trx.telegramLinkTokens остаются типизированными DbSet.

Пример посложнее: перед удалением категории xpenser переносит все её транзакции в replacement-категорию и только после этого удаляет старую:

await db.transaction(async trx => {
    await trx.transactions
        .where(transaction => transaction.userId, userId)
        .where(transaction => transaction.categoryId, categoryId)
        .update({
            categoryId: replacement.id,
            type: replacement.type,
            updatedAt: now
        });

    await trx.categories
        .where(candidate => candidate.id, categoryId)
        .where(candidate => candidate.userId, userId)
        .delete();
});

Транзакционная граница видна сразу, а типизация никуда не пропадает.

Миграции

У @cleverbrush/knex-schema есть snapshot-based генератор миграций:

import {
    generateMigrationsForContext,
    loadSnapshot,
    writeSnapshot
} from '@cleverbrush/knex-schema';

const prev = loadSnapshot('./migrations/snapshot.json');
const result = generateMigrationsForContext(
    Object.values(entityMap),
    prev
);

if (!result.isEmpty) {
    await fs.promises.writeFile(
        './migrations/20260611000000_changes.ts',
        result.full
    );
    writeSnapshot('./migrations/snapshot.json', result.nextSnapshot);
}

Функция сравнивает текущие entity-схемы с сериализованным снимком предыдущего состояния:

  • для новых таблиц генерирует CREATE TABLE;

  • для изменённых таблиц генерирует ALTER TABLE;

  • для удалённых entity генерирует DROP TABLE;

  • сортирует таблицы по FK-зависимостям, чтобы родительские таблицы создавались раньше дочерних.

Важная оговорка: xpenser сейчас использует обычные handwritten Knex migrations и запускает их через knex.migrate.latest(...):

export async function runMigrations(knex: Knex): Promise<void> {
    await knex.migrate.latest({
        directory: migrationsDirectory,
        tableName: 'knex_migrations',
        loadExtensions: ['.ts', '.js']
    });
}

То есть генератор миграций — возможность framework-пакета, а не обязательный путь. Мне нравится иметь оба варианта: простые изменения можно получать из diff схем, а сложные data migrations всё равно писать руками на Knex.

Нижний уровень: query(knex, schema)

@cleverbrush/orm — не единственный вход. Если не нужен DbContext, можно работать прямо со схемой:

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

const users = await query(knex, UserDbSchema)
    .where(user => user.emailVerified, true)
    .projected('public');

Этот слой даёт тот же перевод имён колонок, projections, scopes, pagination, insert/update/delete, joinOne, joinMany, raw escape hatches и DDL helpers. ORM просто добавляет поверх него entity map, relations, DbSet, транзакционный контекст и tracking.

Tracking context и unit-of-work

Для сценариев в стиле «загрузить объект, поменять несколько свойств, сохранить изменения» есть tracking mode:

await using db = createDb(knex, entityMap, { tracking: true });

const user = await db.users.findOrFail(userId);
user.defaultCurrency = 'EUR';
user.updatedAt = new Date();

const result = await db.saveChanges();

Tracking context поддерживает identity map: если один и тот же primary key загружен дважды, вы получите один и тот же object reference. При saveChanges() ORM сравнит текущие значения со snapshot и отправит в БД только изменённые поля.

Доступны и более явные операции:

db.attach('users', existingUser);

const entry = db.entry(existingUser);
entry.isModified('defaultCurrency');

db.remove(existingUser);
await db.saveChanges();

Перед сохранением можно повесить hook, например для audit-полей:

db.onSavingChanges(entry => {
    if (entry.state === 'Modified' && 'updatedAt' in entry.entity) {
        (entry.entity as { updatedAt: Date }).updatedAt = new Date();
    }
});

await using здесь работает как runtime guard. При выходе из блока вызывается [Symbol.asyncDispose](): если остались несохранённые изменения, будет брошен PendingChangesError. Это не магия компилятора, а практичная защита от тихой потери изменений.

Row versioning и конкурентные обновления

Если у сущности есть версия строки, её можно явно отметить:

const OrderSchema = object({
    id: number().primaryKey(),
    status: string(),
    version: number().rowVersion()
}).hasTableName('orders');

При saveChanges() ORM добавит условие вида AND version = <snapshot>. Если другая транзакция уже обновила строку и версия изменилась, UPDATE не затронет ни одной строки, а ORM бросит ConcurrencyError.

В xpenser эта возможность пока не нужна, но для систем с совместным редактированием она закрывает типичный optimistic concurrency сценарий.

Что получилось

Если собрать всё вместе, цепочка выглядит так:

@cleverbrush/schema
    -> runtime validation
    -> TypeScript inference
    -> PropertyDescriptors
    -> DB metadata via @cleverbrush/knex-schema
    -> typed queries and DDL helpers
    -> DbContext / DbSet via @cleverbrush/orm

Главная ценность здесь не в том, что можно написать меньше SQL. SQL всё равно остаётся важным, а Knex остаётся доступным. Ценность в том, что границы приложения начинают проверяться компилятором:

  • поле переименовано в схеме — selectors в запросах перестают компилироваться;

  • колонка называется default_currency_amount, но в коде используется defaultCurrencyAmount;

  • projection сужает не только SQL SELECT, но и TypeScript-тип;

  • relation объявлена один раз и дальше используется через .include(t => t.category);

  • транзакционный DbContext сохраняет тот же API внутри trx.

Чего здесь нет

Это не замена Prisma, TypeORM или Drizzle. У этих инструментов свои сильные стороны: зрелая экосистема, schema language, studio, generators, адаптеры, документация, привычные паттерны.

@cleverbrush/orm интересен в другом случае: когда у вас уже есть schema-first стек, и вы хотите, чтобы HTTP-контракты, валидация, формы, логирование и database layer говорили на одном языке.

В xpenser это оказалось удобным компромиссом. Большая часть CRUD и domain queries живёт на DbSet, а нестандартные запросы остаются на обычном Knex. При этом OpenTelemetry-инструментация видит и то, и другое, потому что внизу один и тот же Knex connection pool.

Итоги

@cleverbrush/knex-schema и @cleverbrush/orm добавляют к Knex schema-driven слой:

  • схемы описывают TypeScript-тип, DB-имена, defaults, indexes и foreign keys;

  • selectors вида t => t.userId заменяют строковые имена колонок;

  • projections сужают и SQL, и TypeScript-тип результата;

  • defineEntity() добавляет relations и typed eager loading;

  • createDb() превращает entity map в DbContext с DbSet;

  • транзакции сохраняют тот же типизированный API;

  • tracking context даёт identity map, saveChanges() и runtime guard от забытых изменений;

  • Knex остаётся доступным для raw SQL и сложных запросов.

xpenser показывает эту идею на рабочем приложении, а не на искусственном todo-примере: пользователи, категории, продавцы, транзакции, курсы валют, сканы чеков, API keys, Telegram linking и MCP OAuth живут в одной PostgreSQL-модели.

В следующей статье хочу уже подробно разобрать сам xpenser: как устроено приложение целиком, почему я сделал его как open-source личный финансовый трекер, как в нём связаны web app, API, Telegram bot, MCP endpoint, contracts, typed client, observability и database layer.

Ссылки

Cleverbrush Framework: github.com/cleverbrush/framework

xpenser app: xpenser.cleverbrush.com

xpenser GitHub: github.com/cleverbrush/xpenser

Документация и playground: docs.cleverbrush.com

Knex: knexjs.org

npm

npm install @cleverbrush/knex-schema
npm install @cleverbrush/orm knex pg