Skip to content
NewComposite primary keys5 min read

NestJS

uql-orm/nestjs registers your querier pool with Nest’s DI container and ends it on application shutdown. Nest 10+, tested against 12.

The project has to be ESM. Nest’s CJS build rewrites a dynamic import() back into a require, so an ESM-only package fails with ERR_REQUIRE_ESM. Nest 12 scaffolds ESM by default (nest new, or nest upgrade); on 10 and 11, set "type": "module" and an ESM moduleResolution yourself.

Declare entities with defineEntity. Nest injects constructor parameters with a parameter decorator, which exists only in the legacy spec, so a Nest project keeps experimentalDecorators: true - and one tsconfig.json cannot mix specs with UQL’s standard decorators. The imperative API is the way round it: same options, identical metadata, the same checks bar one foreign-key case, and nothing else on this page changes. Not a temporary gap - TC39’s parameter decorators are a separate Stage 1 proposal, untouched by Nest 12.

app.module.ts
import { Module } from '@nestjs/common';
import { UqlModule } from 'uql-orm/nestjs';
import { pool } from './uql.config.js';
@Module({
imports: [UqlModule.forRoot({ pool })], // global by default; pass global: false to scope it
})
export class AppModule {}

forRootAsync builds the pool from other providers, ConfigService from @nestjs/config being the usual one:

import { ConfigModule, ConfigService } from '@nestjs/config';
import { PgQuerierPool } from 'uql-orm/postgres';
UqlModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new PgQuerierPool({ connectionString: config.get('DATABASE_URL') }),
});
import { Inject, Injectable } from '@nestjs/common';
import { UQL_QUERIER_POOL } from 'uql-orm/nestjs';
import type { QuerierPool, Query, UniversalQuerier } from 'uql-orm/type';
import { User } from './shared/models/index.js';
@Injectable()
export class UsersService {
constructor(@Inject(UQL_QUERIER_POOL) private readonly pool: QuerierPool) {}
findMany(q: Query<User>) {
return this.pool.findMany(User, q);
}
create(user: User, db: UniversalQuerier = this.pool) {
return db.insertOne(User, user);
}
}

The pool is the stateless, shareable resource, which is why it is the thing to own via DI. A Querier holds a connection and possibly an open transaction: as a singleton it would pin one connection for the app’s lifetime and share transaction state across requests; request-scoped, it would re-instantiate the whole provider graph per request.

Accepting a UniversalQuerier that defaults to the pool is what lets two services share one commit, with no request-scoped providers and no interceptor owning the release:

await this.pool.transaction(async (querier) => {
await this.users.create(user, querier);
await this.audit.record({ type: 'user.created' }, querier);
});

UQL_QUERIER_POOL is the token to inject, and the one to pass to querierMiddleware({ pool }) or createFetchHandler({ pool }). What you inject is what runs.

Pass getContext and UQL wires a global interceptor that runs every request inside withContext, so security filters apply to every query, relations, cascades and transactions included. forRoot is generic in your request type, so naming the shape getContext reads types req inside it:

type AuthedRequest = { user: { id: string; tenantId: number } };
UqlModule.forRoot<AuthedRequest>({
pool,
// derive from the verified request (session / JWT), never from client input
getContext: (req) => ({ tenantId: req.user.tenantId, userId: req.user.id }),
});

The @Filter({ security: true }) that consumes the context is plain UQL: see Multi-tenancy.

An interceptor runs after guards, so req.user is populated, which is what you want for tenant-from-JWT. The context reaches controllers and services, but not guards or exception filters. If you derive it from a header or sub-domain, mount your own middleware instead: withContext(getContext(req), () => next()).

Nest’s default platform is Express, so the Express middleware mounts in main.ts:

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // lets UqlModule end the pool on SIGTERM
app.use('/api', querierMiddleware({ pool, include: [User, Post] }));
await app.listen(3000);

Unknown routes fall through, so hand-written controllers coexist under the same prefix. On the Fastify platform, use the createRequestHandler bridge.