titan-database
@omnitron-dev/titan-databaseMaintained 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
| Option | Type |
|---|---|
connection | DatabaseConnection — single connection |
connections | Record<string, DatabaseConnection> — multiple named |
kysera | { core?, repository?, plugins? } — Kysera integration config |
plugins | PluginsConfiguration — global soft-delete / timestamps / audit |
DatabaseConnection
| Field | Type |
|---|---|
name? | string |
dialect | 'postgres' | 'mysql' | 'sqlite' |
connection | string | 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
| Method | Purpose |
|---|---|
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
| Member | Purpose |
|---|---|
executor | Current executor — transaction-aware |
inTransaction | true if running inside a transaction context |
transaction | Current Transaction<DB> if any |
hasSoftDelete | Plugin flag (set by @SoftDelete) |
softDeleteColumn | Column 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;
});
}
}
| Method | Purpose |
|---|---|
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
| Function | Purpose |
|---|---|
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';
| Decorator | Effect |
|---|---|
@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';
| Decorator | Effect |
|---|---|
@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
| Decorator | Effect |
|---|---|
@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.
| From | What |
|---|---|
@kysera/core | error mapping (parseDatabaseError, ErrorCodes, UniqueConstraintError, ForeignKeyError, NotNullError), pagination (paginate, paginateCursor, applyOffset, applyDateRange, executeCount) |
@kysera/repository | upsert, upsertMany, atomicStatusTransition, ContextAwareRepository (also exported as BaseRepository), and the WHERE-operator helpers |
@kysera/infra | withRetry, withTransactionRetry, CircuitBreaker, isTransientError, isSerializationError, HealthMonitor, checkDatabaseHealth |
@kysera/rls | defineRLSSchema, allow, deny, filter, rlsPlugin |
@kysera/dialects | getAdapter, createDialectAdapter, and the per-dialect adapters |
| plugins | softDeletePlugin, timestampsPlugin, auditPlugin |
kysely | Kysely, 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
| Token | Purpose |
|---|---|
DATABASE_MANAGER | DatabaseManager |
DATABASE_MODULE_OPTIONS | Resolved options bundle |
DATABASE_CONNECTION | Default Kysely<unknown> |
DATABASE_HEALTH_INDICATOR | Health 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
txas a parameter through every layer. UserunInTransaction; 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 settingdeleted_at. - Forgetting
migrationsPath. Without it, the migration runner has nothing to discover. - Using
@BypassRLScasually. 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
titan-health—DatabaseHealthIndicatorexported by this module- Best Practices / Performance — N+1, projection, indexing