Database
@voltage/database wraps TypeORM for NestJS with a composable query DSL, soft-delete base classes, fixture bundles for seed data, and a CLI for migration and schema management. The central piece is QueryHelper — a single injectable service that works across all entities and combines TypeORM’s Repository API with the built-in query operators.
Installation
Section titled “Installation”yarn add @voltage/database @voltage/cli @voltage/event-manager @voltage/logger @voltage/zod zodBasic usage
Section titled “Basic usage”forRoot()
Section titled “forRoot()”Call forRoot() once in your root module with your PostgreSQL connection details:
import { DatabaseModule } from '@voltage/database';
@Module({ imports: [ DatabaseModule.forRoot({ host: process.env.DB_HOST, port: process.env.DB_PORT, username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, migrations: ['dist/migrations/*.js'], }), ],})export class AppModule {}forRootAsync()
Section titled “forRootAsync()”Use forRootAsync() when connection details come from another provider:
DatabaseModule.forRootAsync({ inject: [AppConfiguration], useFactory: (config: AppConfiguration) => ({ host: config.database.host, port: config.database.port, username: config.database.username, password: config.database.password, database: config.database.name, migrations: ['dist/migrations/*.js'], }),})forFeature()
Section titled “forFeature()”Register entities in the module that owns them. This is the standard TypeORM pattern and makes entities available for query building:
import { DatabaseModule } from '@voltage/database';import { User } from './user.entity';
@Module({ imports: [DatabaseModule.forFeature([User])],})export class UserModule {}DatabaseModule.forFeature is an alias for TypeOrmModule.forFeature — they are interchangeable.
Entities
Section titled “Entities”Base classes
Section titled “Base classes”Extend Record for any entity that needs a UUID primary key and timestamps. Extend SoftRecord when rows should be soft-deleted instead of removed:
import { Column, Entity, ManyToOne } from '@voltage/database';import { SoftRecord } from '@voltage/database';
@Entity()export class Subscription extends SoftRecord { @ManyToOne(() => User) user: User;
@Column() tierName: string;
@Column({ type: 'timestamptz' }) expiresAt: Date;}Record provides id (UUID), createdAt, and updatedAt. SoftRecord adds deletedAt — TypeORM filters soft-deleted rows from all queries automatically.
Decimal columns
Section titled “Decimal columns”Use @DecimalColumn() for monetary or precision-sensitive values. The column stores the value as decimal in PostgreSQL and maps it to a Decimal instance (from decimal.js) in TypeScript — avoiding floating-point rounding errors:
import { DecimalColumn } from '@voltage/database';import Decimal from 'decimal.js';
@Entity()export class Price extends Record { @DecimalColumn({ precision: 12, scale: 2 }) amount: Decimal;}The default precision is 10, scale 2.
Querying
Section titled “Querying”QueryHelper is the preferred way to query the database. Inject it once per service — it works across all entities, so you never need a per-entity @InjectRepository(). query.for(Entity) returns a proxy that exposes the full TypeORM Repository<Entity> API — find, findOne, count, save, remove, createQueryBuilder, and everything else — alongside the additional DSL methods documented below. If you know TypeORM, everything you already know still works.
import { QueryHelper } from '@voltage/database';
@Injectable()export class UserService { constructor(private readonly query: QueryHelper) {}}Reading data
Section titled “Reading data”The DSL read methods all accept operator arrays:
// Single record — throws NotFoundException if not foundconst user = await this.query.for(User).getOneOrFail(where(id(userId)));
// Single record — returns null if not foundconst user = await this.query.for(User).getOne(where(equals('email', email)));
// Many recordsconst users = await this.query.for(User).getMany(where(equals('locale', 'de')));
// Many records with total count (for pagination)const [subscriptions, total] = await this.query.for(Subscription).getManyAndCount( where(equals('userId', user.id)), paginate({ page: 1, size: 20 }),);
// Existence checkconst taken = await this.query.for(User).getExists(where(equals('email', email)));Writing data
Section titled “Writing data”The proxy passes through all Repository mutation methods directly:
// Create and persist in one stepconst subscription = await this.query .for(Subscription) .save(this.query.for(Subscription).create({ user, tier, expiresAt }));
// Update specific fieldsawait this.query.for(Quota).update(quota.id, { pending: quota.pending + 1 });
// Soft delete by entity instanceawait this.query.for(Subscription).softRemove(subscription);
// Soft delete by IDawait this.query.for(Export).softDelete(row.id);Predicates
Section titled “Predicates”Predicates are functions that build a SQL condition. Pass one or more to where() to filter queries:
import { where, equals, id, ids, contains, empty, not } from '@voltage/database';
// Match by primary keywhere(id(userId))
// Match any of a set of IDswhere(ids([id1, id2, id3]))
// Column equalitywhere(equals('status', 'active'))
// Column IN (...)where(contains('role', ['admin', 'moderator']))
// Column IS NULLwhere(empty('deletedAt'))
// Negate any predicatewhere(not(empty('verifiedAt')))Comparison predicates for numeric and date columns:
import { lessThan, lessThenOrEqual, moreThan, moreThanOrEqual, between } from '@voltage/database';
where(moreThanOrEqual('expiresAt', new Date()))where(lessThan('score', 100))where(between('createdAt', start, end))Composing predicates
Section titled “Composing predicates”and() and or() combine predicates into a single condition. Both accept falsy values — false, null, undefined — which are silently skipped. This makes conditional filters clean without if branches:
import { and, or, where } from '@voltage/database';
// Both conditions must holdwhere(and(equals('locale', 'de'), moreThanOrEqual('expiresAt', new Date())))
// Either condition must holdwhere(or(equals('role', 'admin'), equals('role', 'moderator')))
// Falsy values are ignored — useful for optional filterswhere( and( filter.locale && equals('locale', filter.locale), filter.active && moreThanOrEqual('expiresAt', new Date()), ),)Custom predicates
Section titled “Custom predicates”Extract reusable conditions into typed predicate functions. This keeps queries readable and the logic testable in isolation:
import { and, moreThanOrEqual, lessThenOrEqual, Predicate } from '@voltage/database';
export function isActive(): Predicate<Subscription> { return moreThanOrEqual('expiresAt', new Date());}
export function belongsToUser(user: User): Predicate<Subscription> { return equals('userId', user.id);}
export function isActiveForUser(user: User): Predicate<Subscription> { return and(belongsToUser(user), isActive());}
// Usageconst subscription = await this.query .for(Subscription) .getOneOrFail(where(isActiveForUser(user)));When the built-in predicates are not expressive enough, write the SQL directly. A Predicate<T> is a function that receives the query alias and returns a [sql, params] tuple:
export function hasAvailableQuota(amount: number): Predicate<Quota> { return (alias) => [ `${alias}.available - ${alias}.pending >= :amount`, { amount }, ];}Pagination and ordering
Section titled “Pagination and ordering”paginate() applies skip and take from a { page, size } object. orderBy() wraps TypeORM’s ordering with the entity alias pre-applied:
import { paginate, orderBy, whereQuickFilter } from '@voltage/database';
const [users, total] = await this.query.for(User).getManyAndCount( paginate({ page, size }), orderBy('createdAt', 'DESC'),);whereQuickFilter() adds a case-insensitive ILIKE search across multiple columns — useful for quick search inputs:
const [users, total] = await this.query.for(User).getManyAndCount( paginate({ page, size }), search && whereQuickFilter(search, ['entity.firstName', 'entity.lastName', 'entity.email']),);Relations
Section titled “Relations”Load relations with relations(). Pass the same shape as TypeORM’s FindOptionsRelations:
import { relations, where, id } from '@voltage/database';
const order = await this.query.for(Order).getOneOrFail( where(id(orderId)), relations({ items: true, customer: true }),);For filtered joins or aggregations, use the join operators. After the join, use using() to scope predicates to the joined entity’s alias:
import { innerJoinAndSelect, leftJoin, using, where, equals } from '@voltage/database';
// Join and select, then filter on the joined entityconst results = await this.query.for(Subscription).getMany( innerJoinAndSelect(Tier, 'tier', 'tier'), using(Tier, 'tier', where(equals('name', tierName))), where(equals('userId', userId)),);
// Join without selecting — for filtering onlyconst results = await this.query.for(Order).getMany( leftJoin(Customer, 'customer', 'customer'), using(Customer, 'customer', where(equals('country', 'DE'))),);The first argument to the join operator is the joined entity type (for TypeScript inference), the second is the relation property on the root entity, and the third is the alias. using() takes the same entity type and alias, then applies any operators scoped to that alias — so equals('country', 'DE') resolves to customer.country rather than the root entity’s alias.
Raw query builder
Section titled “Raw query builder”When the DSL operators are not enough, call getQueryBuilder() to get the underlying SelectQueryBuilder and use TypeORM directly. A common case is streaming large result sets:
const stream = await this.query .for(Export) .getQueryBuilder(where(shouldBeDeleted())) .stream();
try { for await (const row of stream) { await this.query.for(Export).softDelete(row.entity_id); }} finally { stream.destroy();}Transactions
Section titled “Transactions”Call query.transaction() with a callback. The callback receives a scoped QueryHelper bound to the transaction’s EntityManager — use it for all queries inside the transaction:
await this.query.transaction(async (query) => { const user = await query.for(User).save(query.for(User).create(data)); await query.for(Profile).save(query.for(Profile).create({ user }));});Pass an isolation level as the first argument when needed:
await this.query.transaction('SERIALIZABLE', async (query) => { const quota = await query.for(Quota).getOneOrFail(where(id(quotaId))); await query.for(Quota).update(quota.id, { pending: quota.pending + 1 });});Loading entities from route params
Section titled “Loading entities from route params”LoadRecordPipe is a pipe factory that loads an entity by the route parameter value. By default it looks up by id:
import { LoadRecordPipe } from '@voltage/database';
@Get(':id')async view(@Param('id', LoadRecordPipe(User)) user: User) { return user;}Pass a callback to customise the lookup — for example, to load by a field other than id or to eager-load relations:
@Get(':slug')async view( @Param('slug', LoadRecordPipe(Post, (slug, repo) => repo.createQueryBuilder('post') .where('post.slug = :slug', { slug }) .leftJoinAndSelect('post.author', 'author') .getOneOrFail() )) post: Post,) { return post;}LoadRecordPipe throws a NotFoundException automatically when no record is found.
Fixtures
Section titled “Fixtures”Fixtures are classes that produce seed data for development and testing environments. Decorate a class with @Bundle() and implement Installable to register it as a named bundle:
import { Bundle, fixture, Installable } from '@voltage/database';
@Bundle('demo')@Injectable()export class DemoBundle implements Installable { constructor(private readonly query: QueryHelper) {}
users = { alice: fixture(User, { firstName: 'Alice', email: 'alice@example.com', locale: 'en', }), bob: fixture(User, { firstName: 'Bob', email: 'bob@example.com', locale: 'de', }), } as const;
collect() { return [this.users]; }}fixture() creates a typed entity stub. The collect() method returns all the data to install — plain objects, arrays, or nested fixture maps. The installer flattens and persists them in dependency order.
Referencing another bundle
Section titled “Referencing another bundle”Inject BundleResolver to access the collected fixtures of another bundle. Call resolver.resolve() inside collect() to build fixtures that reference entities from another bundle, or in AfterInstallBundleEvent to run logic that requires persisted IDs:
import { Bundle, BundleResolver, fixture, Installable } from '@voltage/database';
@Bundle('subscriptions')@Injectable()export class SubscriptionBundle implements Installable { constructor(private readonly resolver: BundleResolver) {}
async collect() { const { users } = await this.resolver.resolve(UserBundle);
return [ fixture(Subscription, { user: users.alice, tierName: 'pro' }), fixture(Subscription, { user: users.bob, tierName: 'basic' }), ]; }}resolver.resolve() returns the typed return value of the target bundle’s collect() method. The resolved bundle is collected at most once — repeated calls return the cached result.
Reacting to installation
Section titled “Reacting to installation”Listen to AfterInstallBundleEvent to run logic after a bundle is installed — for example, creating related records that require persisted IDs:
import { AfterInstallBundleEvent } from '@voltage/database';import { OnEvent } from '@voltage/event-manager';
@Bundle('demo')@Injectable()export class DemoBundle implements Installable { constructor( private readonly query: QueryHelper, private readonly subscriptions: SubscriptionService, ) {}
users = { alice: fixture(User, { email: 'alice@example.com', locale: 'en' }), } as const;
collect() { return [this.users]; }
@OnEvent(AfterInstallBundleEvent) protected async afterInstall(event: AfterInstallBundleEvent) { if (event.bundle === DemoBundle) { await this.subscriptions.create(this.users.alice, 'pro'); } }}Automatic installation at startup
Section titled “Automatic installation at startup”Set the install option in forRoot() to run specific bundles on every startup — useful in development environments to seed a fresh database:
DatabaseModule.forRoot({ // ... connection config install: { bundles: ['demo'], synchronize: 'migrate', // drop and re-run migrations before installing },})synchronize can be 'migrate' (drop + migrate), 'synchronize' (drop + schema sync), or 'none' (skip schema changes).
The database CLI commands are available through @voltage/cli. Run them from the project root:
# Interactive fixture installationyarn cli database install
# Generate a migration from entity changesyarn cli database migration generate --name CreateUserTable
# Run pending migrationsyarn cli database migration run
# Revert the last migrationyarn cli database migration revert
# Show migration statusyarn cli database migration show
# Sync schema without migrations (development only)yarn cli database schema sync
# Drop the entire schemayarn cli database schema dropConfiguration
Section titled “Configuration”interface DatabaseConfiguration { /** PostgreSQL host. */ host: string; /** PostgreSQL port. Defaults to 5432. */ port?: number; /** Database username. */ username: string; /** Database password. */ password: string; /** Database name. */ database: string; /** Glob patterns pointing to compiled migration files. */ migrations: string[]; /** Run pending migrations automatically on startup. Defaults to false. */ migrationsRun?: boolean; /** Connection pool size. */ poolSize?: number; /** Additional driver-specific options passed to the underlying pg client. */ extra?: Record<string, unknown>; /** Query result cache configuration passed to TypeORM. */ cache?: Record<string, unknown>; /** Automatic fixture installation on startup. Omit to disable. */ install?: { /** Bundle names to install. */ bundles: string[]; /** * Schema operation to run before installation. * - 'migrate': drop database and run migrations * - 'synchronize': drop database and sync schema from entities * - 'none': skip schema changes */ synchronize?: 'migrate' | 'synchronize' | 'none'; /** Named data source to use. Defaults to 'default'. */ connection?: string; };}