Skip to content

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.

Terminal window
yarn add @voltage/database @voltage/cli @voltage/event-manager @voltage/logger @voltage/zod zod

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 {}

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'],
}),
})

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.

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.

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.

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) {}
}

The DSL read methods all accept operator arrays:

// Single record — throws NotFoundException if not found
const user = await this.query.for(User).getOneOrFail(where(id(userId)));
// Single record — returns null if not found
const user = await this.query.for(User).getOne(where(equals('email', email)));
// Many records
const 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 check
const taken = await this.query.for(User).getExists(where(equals('email', email)));

The proxy passes through all Repository mutation methods directly:

// Create and persist in one step
const subscription = await this.query
.for(Subscription)
.save(this.query.for(Subscription).create({ user, tier, expiresAt }));
// Update specific fields
await this.query.for(Quota).update(quota.id, { pending: quota.pending + 1 });
// Soft delete by entity instance
await this.query.for(Subscription).softRemove(subscription);
// Soft delete by ID
await this.query.for(Export).softDelete(row.id);

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 key
where(id(userId))
// Match any of a set of IDs
where(ids([id1, id2, id3]))
// Column equality
where(equals('status', 'active'))
// Column IN (...)
where(contains('role', ['admin', 'moderator']))
// Column IS NULL
where(empty('deletedAt'))
// Negate any predicate
where(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))

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 hold
where(and(equals('locale', 'de'), moreThanOrEqual('expiresAt', new Date())))
// Either condition must hold
where(or(equals('role', 'admin'), equals('role', 'moderator')))
// Falsy values are ignored — useful for optional filters
where(
and(
filter.locale && equals('locale', filter.locale),
filter.active && moreThanOrEqual('expiresAt', new Date()),
),
)

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());
}
// Usage
const 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 },
];
}

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']),
);

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 entity
const 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 only
const 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.

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();
}

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 });
});

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 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.

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.

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');
}
}
}

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:

Terminal window
# Interactive fixture installation
yarn cli database install
# Generate a migration from entity changes
yarn cli database migration generate --name CreateUserTable
# Run pending migrations
yarn cli database migration run
# Revert the last migration
yarn cli database migration revert
# Show migration status
yarn cli database migration show
# Sync schema without migrations (development only)
yarn cli database schema sync
# Drop the entire schema
yarn cli database schema drop
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;
};
}