Skip to main content

titan-database

Official@omnitron-dev/titan-database

Maintained by the Omnitron team. Independent npm package.

Kysely-based typed query builder with decorator-driven repository configuration, declarative migrations, row-level security, plugin system (soft-delete / timestamps / audit), multi-dialect (Postgres / MySQL / SQLite), and AsyncLocalStorage-based transaction context so nested calls participate in the active transaction without parameter threading.

pnpm add @omnitron-dev/titan-database

Quickstart

Single connection

import { TitanDatabaseModule } from '@omnitron-dev/titan-database';

@Module({
imports: [
TitanDatabaseModule.forRoot({
connection: {
dialect: 'postgres',
connection: env.DATABASE_URL,
pool: { min: 2, max: 20 },
migrationsPath: './migrations',
coerceBigint: true,
},
}),
],
})
class AppModule {}

Multiple named connections

TitanDatabaseModule.forRoot({
connections: {
primary: { dialect: 'postgres', connection: env.PRIMARY_URL },
analytics: { dialect: 'postgres', connection: env.ANALYTICS_URL },
},
})

forFeature — repository registration

@Module({
imports: [
TitanDatabaseModule.forRoot({ connection: { dialect: 'postgres', connection: env.DATABASE_URL } }),
TitanDatabaseModule.forFeature([UsersRepository, OrdersRepository]),
],
providers: [UsersService],
})
class UsersModule {}

forFeature registers repository classes, wires them into the container under per-repo tokens, and ensures decorator-driven plugins (soft-delete, timestamps, audit) bind correctly at boot.

Async configuration

TitanDatabaseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
connection: {
dialect: 'postgres',
connection: config.get('database.url'),
pool: config.get('database.pool'),
},
}),
inject: [ConfigService],
})

DatabaseModuleOptions

OptionType
connectionDatabaseConnection — single connection
connectionsRecord<string, DatabaseConnection> — multiple named
kysera{ core?, repository?, plugins? } — Kysera integration config
pluginsPluginsConfiguration — global soft-delete / timestamps / audit

DatabaseConnection

FieldType
name?string
dialect'postgres' | 'mysql' | 'sqlite'
connectionstring | ConnectionConfig — URL or config object
pool?{ min, max, acquireTimeoutMillis, idleTimeoutMillis }
debug?boolean
plugins?string[] — plugin names
migrationsPath?string
seedsPath?string
coerceBigint?boolean — parse PG BIGINT as JS number if safe (default true for PG)

Repository pattern — TransactionAwareRepository<DB, Table>

import { Repository, SoftDelete, Timestamps, Audit } from '@omnitron-dev/titan-database';
import { TransactionAwareRepository } from '@omnitron-dev/titan-database';

interface Database {
users: UsersTable;
}

@Repository('users')
@SoftDelete({ column: 'deleted_at' })
@Timestamps({ createdAt: 'created_at', updatedAt: 'updated_at' })
@Audit({ table: 'audit_logs', captureOldValues: true })
export class UsersRepository extends TransactionAwareRepository<Database, 'users'> {
async findByEmail(email: string) {
return this.executor.selectFrom('users')
.where('email', '=', email)
.selectAll()
.executeTakeFirst();
}
}

Inherited methods

MethodPurpose
findById(id)Fetch by primary key (null if missing)
findByIds(ids[])Fetch many by primary key
create(data) / createMany(data[])Insert one / many (returns inserted rows)
update(id, data)Update by id (returns row or null)
delete(id)Hard delete by id (returns boolean)
softDelete(id) / restore(id)Soft-delete / restore (requires @SoftDelete)
list(options?)Offset-paginated list → OffsetPaginatedResult
findWhere(where, options?)Dynamic WHERE (via @kysera/repository operators)
findOneWhere(where) / countWhere(where)Single match / count by WHERE
updateWhere(where, data) / deleteWhere(where)Bulk update / delete by WHERE (returns count)
exists(id) / count()Existence check / row count
upsert(data, options) / upsertMany(data[], options)Insert-or-update

Protected accessors

MemberPurpose
executorCurrent executor — transaction-aware
inTransactiontrue if running inside a transaction context
transactionCurrent Transaction<DB> if any
hasSoftDeletePlugin flag (set by @SoftDelete)
softDeleteColumnColumn name (default 'deletedAt')

DatabaseManager

import { DATABASE_MANAGER, type IDatabaseManager, runInTransaction } from '@omnitron-dev/titan-database';

interface AppDB {
users: { id: number; email: string };
}

@Service({ name: 'reports' })
class ReportsService {
constructor(@Inject(DATABASE_MANAGER) private readonly db: IDatabaseManager) {}

@Public()
async summary() {
// getConnection() is async, and takes your schema as its type argument.
// Without one it is Kysely<unknown>, which accepts no table name.
const conn = await this.db.getConnection<AppDB>();
return runInTransaction(conn, async () => {
const users = await conn.selectFrom('users').selectAll().execute();
return users;
});
}
}
MethodPurpose
getConnection(name?)async — named or default plugin-aware Kysely<unknown>
getExecutor(name?, plugins?)async — plugin-aware Kysera executor
getPool(name?) / getPoolMetrics(name?)Raw driver pool / pool stats
isConnected(name?) / getConnectionNames()Connection state / registered names
close(name?) / closeAll()Async cleanup of one / every connection (on shutdown)

Transactions are run through the standalone runInTransaction(conn, fn, options?) helper (below), not a method on the manager — pass it a connection from getConnection().

Transaction context

The module uses AsyncLocalStorage to track the active transaction per request. Repository calls inside runInTransaction automatically use the transaction; no plumbing required.

import {
runInTransaction,
getExecutor,
getCurrentTransaction,
isInTransactionContext,
} from '@omnitron-dev/titan-database';

await runInTransaction(db, async () => {
await this.usersRepo.create({ email: 'ada@example.com' }); // uses the transaction
await this.auditRepo.create({ kind: 'user.created' }); // same transaction
}, { name: 'signup' });

Helpers

FunctionPurpose
runInTransaction(db, fn, options?)Open a new transaction and run fn inside it
getExecutor(db)Return either current transaction or the base connection
getCurrentTransaction()Current transaction or undefined
isInTransactionContext()Boolean check
getTransactionContext()Full context: depth, started-at, name, connection name

Decorators

Repository / plugins

import { Repository, SoftDelete, Timestamps, Audit, Migration }
from '@omnitron-dev/titan-database';
DecoratorEffect
@Repository(table | config)Mark a class as a repository; config: { table, connection?, softDelete?, timestamps?, audit?, schema?, validate?, plugins? } (RLS is configured separately via @Policy)
@SoftDelete({ column?, includeDeleted?, tables? })Soft-delete behaviour — column defaults to 'deletedAt'
@Timestamps({ createdAt?, updatedAt? })Auto-managed timestamp columns
@Audit({ table?, captureOldValues?, captureNewValues? })Row-level audit logging
@Migration({ version, description?, dependencies?, connection?, transactional?, timeout? })Mark a class as a migration

Row-level security

import { Policy, Allow, Deny, Filter, BypassRLS } from '@omnitron-dev/titan-database';
DecoratorEffect
@Policy({ table?, skipFor?, defaultPolicy? })Class-level RLS configuration
@Allow({ operations: [...], priority?, name? })Allow rule for the listed operations
@Deny({ operations: [...], priority?, name? })Deny rule (evaluated before allow)
@Filter({ operations?, name? })Method returns a WHERE-clause predicate
@BypassRLS()Skip RLS (requires admin / system context)

Example:

@Repository('orders')
@Policy({ skipFor: ['admin'] })
class OrdersRepository extends TransactionAwareRepository<Database, 'orders'> {
@Filter({ operations: ['select'] })
tenantFilter(ctx: ExecutionContext) {
return { tenant_id: ctx.tenantId };
}

@Allow({ operations: ['insert', 'update'] })
ownerWrite(ctx: ExecutionContext, row: Row) {
return row.user_id === ctx.auth.userId;
}
}

Injection

DecoratorEffect
@InjectConnection(name?)Inject a named connection
@InjectDatabaseManager()Inject the DatabaseManager
@InjectRepository(RepoClass)Inject a repository instance

Migrations

import { Migration } from '@omnitron-dev/titan-database';

@Migration({ version: '20260101_001', description: 'create users table' })
export class CreateUsersTable {
async up(db: Kysely<any>) {
await db.schema.createTable('users')
.addColumn('id', 'uuid', (c) => c.primaryKey())
.addColumn('email', 'text', (c) => c.notNull().unique())
.addColumn('created_at', 'timestamptz', (c) => c.defaultTo('now()'))
.execute();
}

async down(db: Kysely<any>) {
await db.schema.dropTable('users').execute();
}
}

Migrations discovered via the configured migrationsPath; run in version order. dependencies enforces partial ordering. With transactional: true (the default), each migration runs in its own transaction and rolls back on failure.

The hardened runner

createHardenedRunner(...) wraps @kysera/migrations to close three gaps that each produce a database nobody can safely re-run against. Use it in production; the differences are not stylistic.

One transaction per migration, covering the bookkeeping. The schema change, the migrations row and the checksum are written together. Vanilla kysera defaults useTransactions to false and, separately, writes the bookkeeping row OUTSIDE the migration's transaction — so a connection dropped between the two leaves a migration applied but unrecorded, and the next run re-applies it.

A Postgres advisory lock (pg_try_advisory_lock) gates every up and down, so two concurrent deploys against one database refuse rather than interleave their bookkeeping.

Content checksums in a sidecar table (migration_checksums). Every applied migration is re-hashed at startup and compared. Editing a migration that has already run in production halts the runner and names the file — without this, the directory reads as the source of truth while production runs something else, and nothing anywhere disagrees.

On first run against an existing database the checksums are backfilled trust-on-first-use, so legacy environments do not fail immediately. The backfill is logged, and verify prints what was recorded.

What comes through from @kysera/*

This package re-exports about sixty symbols from the @kysera family, so they are importable from @omnitron-dev/titan-database and a reader has no reason to guess that a second package is involved. They are listed here by origin rather than described — their own documentation is authoritative, and copying it here would rot.

FromWhat
@kysera/coreerror mapping (parseDatabaseError, ErrorCodes, UniqueConstraintError, ForeignKeyError, NotNullError), pagination (paginate, paginateCursor, applyOffset, applyDateRange, executeCount)
@kysera/repositoryupsert, upsertMany, atomicStatusTransition, ContextAwareRepository (also exported as BaseRepository), and the WHERE-operator helpers
@kysera/infrawithRetry, withTransactionRetry, CircuitBreaker, isTransientError, isSerializationError, HealthMonitor, checkDatabaseHealth
@kysera/rlsdefineRLSSchema, allow, deny, filter, rlsPlugin
@kysera/dialectsgetAdapter, createDialectAdapter, and the per-dialect adapters
pluginssoftDeletePlugin, timestampsPlugin, auditPlugin
kyselyKysely, Transaction, Selectable, Insertable, Updateable (types)

Three of kysera's error classes are renamed on the way through: DatabaseError, NotFoundError and BadRequestError are exported as KyseraDatabaseError, KyseraNotFoundError and KyseraBadRequestError. The unprefixed names are NOT exported from this package at all — reach for NotFoundError here and the import fails rather than resolving to something that merely looks right, which is the intended outcome: those names belong to your application's own error hierarchy or to Titan's, and a database driver's version of them is a different type.

Plugin lifecycle ordering

For deletes, soft-delete intercepts first: it issues an UPDATE that sets deleted_at instead of a DELETE; the audit plugin captures the before/after.

Tokens

TokenPurpose
DATABASE_MANAGERDatabaseManager
DATABASE_MODULE_OPTIONSResolved options bundle
DATABASE_CONNECTIONDefault Kysely<unknown>
DATABASE_HEALTH_INDICATORHealth indicator for titan-health
getDatabaseConnectionToken(name?)Token for a named connection
getRepositoryToken(RepoClass)Token for a specific repository instance

Lifecycle

TitanDatabaseModule implements:

  • async onStop(app)manager.closeAll() to release every connection. Crucial during dev with file watchers — without this, rapid restarts exhaust PG connection slots.

Plugin registry

For applications that build their own repositories without inheriting from the base class, register table-level plugins explicitly:

import { registerTablePlugins } from '@omnitron-dev/titan-database';

registerTablePlugins('users', [
/* soft-delete plugin instance */,
/* timestamps plugin instance */,
]);

Anti-patterns

  • Manual transaction threading. Don't pass tx as a parameter through every layer. Use runInTransaction; repository calls pick up the transaction from the async context.
  • Naked DELETE on soft-delete tables. Use the repo's delete() — the plugin transforms it into an UPDATE setting deleted_at.
  • Forgetting migrationsPath. Without it, the migration runner has nothing to discover.
  • Using @BypassRLS casually. It exists for system flows (cron-driven cleanups, admin scripts) — every use case needs a written justification.
  • Sharing one connection across very different workloads. OLTP and analytics traffic on one pool starves each other. Use multiple named connections.

Inter-module dependencies

  • Uses @kysera/* — core / repository / rls / soft-delete / timestamps / audit / executor / infra / migrations.
  • Peer deps on the drivers you use: pg, mysql2, better-sqlite3.
  • Optional health indicator integrates with titan-health.

See also