@truxie/drizzle
v0.1.0
Published
Drizzle ORM for truxie — a lazily-connected database, an ambient TransactionHost with savepoints and after-commit hooks, a transaction interceptor and a health indicator
Maintainers
Readme
@truxie/drizzle
Drizzle ORM for truxie: a Postgres database, an ambient TransactionHost with savepoints and after-commit callbacks, an opt-in TransactionInterceptor, and a health indicator.
npm install @truxie/drizzle drizzle-orm postgres@Module({
imports: [DrizzleModule.forRoot({url: process.env.DATABASE_URL, schema, max: 20, applicationName: 'api'})],
})
export class AppModule {}The client connects on its first query, so the application boots without a reachable database. On shutdown the pool closes last, after everything that injected it has stopped (truxie ≥ 0.2 tears down dependents first).
Configuring
// Options known up front
DrizzleModule.forRoot({url, schema})
// Options from configuration
DrizzleModule.forRootAsync({
imports: [ConfigModule],
inject: [DATABASE_CONFIG],
useFactory: (config: DatabaseConfig) => ({url: config.url, max: config.poolSize}),
})
// A database you built yourself: PGlite in tests, node-postgres, a shared client
DrizzleModule.forClient(drizzle(pglite), {close: () => pglite.close()})| Option | |
|---|---|
| url | Postgres connection string (forRoot / forRootAsync). |
| max | Pool size. It must cover concurrent transactions plus workers. |
| applicationName | Reported to Postgres as application_name. |
| schema | Drizzle schema, for db.query.*. |
| closeTimeoutSec | How long client.end() waits for running queries on shutdown. Default 5. |
| onTransactionStart(tx) | Runs right after every outermost BEGIN, never for savepoints. Use it for set_config(...) and row-level security. |
| onError(err) | Receives errors thrown by afterCommit callbacks. Default console.error. |
postgres is only loaded by forRoot / forRootAsync, so a process using forClient doesn't need it installed.
Repositories read tx.db on every call
TransactionHost.db is the open transaction of the current async context, or the root database outside one. Read it on every call and never store it in a field, or a repository keeps using whichever handle it saw first.
@Injectable()
@Inject(TransactionHost)
export class OrderRepository {
constructor(private readonly tx: TransactionHost) {}
async create(input: NewOrder): Promise<Order> {
const [row] = await this.tx.db.insert(orders).values(input).returning()
return toDomain(row)
}
}Use cases own the transaction
Wrap multi-write work in run. Everything the repositories do inside it joins one transaction without a handle being passed down.
await this.tx.run(async () => {
const order = await this.orders.create(input)
await this.stock.reserve(order.items)
// Enqueued only once the transaction commits, so the job never points at rows that were rolled back
this.tx.afterCommit(() => this.queue.add('ship', {orderId: order.id}))
})- Commit and rollback.
run(fn, cfg?)commits whenfnresolves and rolls back when it throws.cfgis drizzle'sPgTransactionConfig(isolationLevel,accessMode,deferrable). - Nesting. A nested
runopens a savepoint. If it fails, only that level rolls back, and the outerfncan catch the error and carry on. A nestedruncan't ask for a different isolation level; it throws instead of silently ignoring the request. afterCommit(fn). Callbacks run after the outermost commit, outside the transaction, in the order they were registered. A savepoint's callbacks are dropped if it rolls back. Outside a transaction,fnruns on the next microtask. Errors go toonErrorand are never rethrown, because the work they follow has already committed.inTransactiontells you whether the current context is insiderun.
The context is kept in an AsyncLocalStorage on globalThis, so two copies of this package in one bundle still share it.
Pitfalls
- Never call a slow external service inside
run(an LLM, a payment provider, another HTTP API). The transaction holds a pool connection for the whole call. Do the call first, or after the commit withafterCommit. - Await everything started inside
run. Un-awaited work keeps using a transaction that has already ended. - Size the pool (
max) for concurrent transactions plus workers.
Per-route transactions
@Post('/orders')
@UseInterceptors(TransactionInterceptor)
create(@Body() body: CreateOrder) { … }The handler and the interceptors after this one run in one transaction. Prefer run in the use case whenever the route also calls something slow.
Health
DrizzleHealthIndicator runs select 1 and has the shape of @truxie/health's HealthIndicator. Pass the instance, because the health module constructs classes without injecting anything:
HealthModule.forRootAsync({
inject: [DrizzleHealthIndicator],
useFactory: (db: DrizzleHealthIndicator) => ({checks: [{name: 'database', check: db}]}),
})Errors
try {
await this.users.create(input)
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictError('Email already registered')
throw err
}isUniqueViolation checks for SQLSTATE 23505 through the whole cause chain, so drizzle's DrizzleQueryError wrapper is seen through.
Testing
import {withRollback} from '@truxie/drizzle/testing'
it('creates the order', () => withRollback(host, async () => {
await createOrder.execute(input)
expect(await orders.count()).toBe(1)
}))withRollback(host, fn) runs fn in a transaction that is always rolled back and returns fn's result. afterCommit callbacks registered inside it never run. With PGlite, wire the database through DrizzleModule.forClient(drizzle(pglite)).
Exports
DrizzleModule, TransactionHost, TransactionInterceptor, DrizzleHealthIndicator, isUniqueViolation, DRIZZLE_DB (the root Database), DRIZZLE_OPTIONS, and the types Database, DrizzleModuleOptions, DrizzleClientOptions and DrizzleTransactionOptions. @truxie/drizzle/testing exports withRollback.
