@fluojs/drizzle
English 한국어
Node.js-only Drizzle ORM integration for fluo with a transaction-aware database wrapper and an optional dispose hook.
Table of Contents
Installation
npm install @fluojs/drizzle drizzle-orm@^0.45.2
npm install pg
@fluojs/drizzle requires Drizzle ORM >=0.45.2. Consumers using an older Drizzle ORM release must upgrade the peer and refresh their lockfile before adopting this major @fluojs/drizzle release. The fluo integration API is unchanged, but applications should run their driver-specific query and migration tests against the upgraded ORM.
Runtime Support
The root @fluojs/drizzle package requires Node.js >=24.0.0 <27. It imports Node's node:async_hooks module to maintain the ambient transaction context and its package manifest declares that package-owned support contract. Upgrade Node 20 and Node 22 hosts to Node.js >=24.0.0 <27; Node versions below 24 and Node 27+ are unsupported.
Drizzle ORM itself can target drivers such as Bun SQL or Cloudflare D1, but those driver runtimes are outside this fluo wrapper until a non-Node transaction-context adapter is documented.
Non-Node runtimes should not import the root package. For Bun, Deno, Cloudflare Workers, or other non-Node Drizzle drivers, register the raw Drizzle driver handle behind application-owned fluo providers such as { provide, useFactory } or { provide, useValue }, then inject that application token into repositories. The canonical package chooser/surface docs and the Bun/Cloudflare book chapters show those raw-provider patterns.
When to Use
- when an application running Node.js
>=24.0.0 <27 needs Drizzle to participate in the same module, DI, and lifecycle model as the rest of the app
- when repositories need a single
current() seam that switches between the root handle and the active transaction handle
- when application shutdown should also run an explicit cleanup hook for the underlying driver resources
Quick Start
import { ConfigModule, ConfigService } from '@fluojs/config';
import { Module } from '@fluojs/core';
import { DrizzleModule } from '@fluojs/drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
@Module({
imports: [
ConfigModule.forRoot({
global: true,
processEnv: {
DATABASE_URL: process.env.DATABASE_URL,
},
}),
DrizzleModule.forRootAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
const pool = new Pool({
connectionString: config.getOrThrow<string>('DATABASE_URL'),
});
return {
database: drizzle(pool),
dispose: async () => {
await pool.end();
},
};
},
}),
],
})
export class AppModule {}
forRootAsync(...) accepts only inject and useFactory for its factory dependencies; it does not discover NestJS imports, useClass, useExisting, or decorator metadata. Its generated async module has no imports, so a token exported only by a sibling module or by a parent module's import is not visible to the options provider. Register factory dependencies through a global module instead. The ConfigModule.forRoot(...) registration above exports ConfigService globally by default; global: true is shown explicitly because that global export makes ConfigService visible to the generated async Drizzle module. For another token, make the module that owns and exports it global before bootstrap rather than relying on the importing application's providers or imports.
Common Patterns
Service Transaction Boundary (@Transaction)
The @Transaction() decorator is the recommended way to define transaction boundaries in your service layer. It ensures that all repository calls made within the decorated method share the same Drizzle transaction.
import { Inject } from '@fluojs/core';
import { Transaction, DrizzleDatabase, type DrizzleDatabaseFacade } from '@fluojs/drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { users, profiles } from './schema';
type AppDatabase = ReturnType<typeof drizzle>;
@Inject(DrizzleDatabase)
export class UserRepository {
constructor(private readonly db: DrizzleDatabaseFacade<AppDatabase>) {}
async create(data: any) {
const [user] = await this.db.insert(users).values(data).returning();
if (!user) {
throw new Error('User insert did not return a row.');
}
return user;
}
async initProfile(userId: string) {
return this.db.insert(profiles).values({ userId });
}
}
@Inject(UserRepository)
export class UserService {
constructor(private readonly repo: UserRepository) {}
@Transaction()
async onboardUser(dto: any) {
const user = await this.repo.create(dto);
await this.repo.initProfile(user.id);
return user;
}
}
Calls to @Transaction() methods are reentrant. If a decorated method calls another decorated method, they share the same underlying Drizzle transaction.
By default, @Transaction() selects its target with a small host-object heuristic: it first checks this.db, then direct properties on the decorated instance, then a nested .db property on those values, and uses the first value that exposes a transaction(...) method. If none of those candidates match, the decorated instance itself becomes the transaction target. This keeps common constructor(private readonly db: DrizzleDatabase<...>) services and self-contained facade hosts concise, but services with more than one Drizzle wrapper should not rely on property order. Pass an explicit accessor such as @Transaction((self) => self.ordersDb) or @Transaction((self) => self.analyticsDb, options) whenever the decorated host owns multiple transaction-capable clients or wraps a repository that also exposes .db.
Manual Transactions and current()
The DrizzleDatabase provides a current() method that returns the active transaction handle if inside a transaction scope, or the root handle otherwise. Use this as an escape hatch when you need to pass the handle to external utilities or perform advanced manual transaction plumbing.
import { DrizzleDatabase } from '@fluojs/drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { users } from './schema';
type AppDatabase = ReturnType<typeof drizzle>;
export class AdvancedRepository {
constructor(private readonly db: DrizzleDatabase<AppDatabase>) {}
async customOperation() {
const tx = this.db.current();
return tx.select().from(users);
}
}
Use db.transaction() for manual transaction blocks:
await this.db.transaction(async () => {
const current = this.db.current();
await current.insert(users).values(user);
await current.insert(profiles).values(profile);
});
Nested calls reuse the active transaction boundary. If a nested call passes transaction options while a boundary is already active, the package rejects those nested options instead of silently changing the existing transaction.
When database.transaction(...) is unavailable and strictTransactions is false (the default), transaction() and requestTransaction() intentionally fail open (fail-open fallback) by running the callback directly against the root handle. This is useful for local fakes, read-only adapters, or gradual migrations, but it is not atomic and should not be treated as a real database transaction. Set strictTransactions: true in production paths that require rollback guarantees; startup and readiness diagnostics then surface missing database.transaction(...) support and transaction helpers throw instead of silently running without a transaction. Fail-open callbacks still run in a root-handle ALS context, so nested helpers reuse the fallback boundary, nested request work inherits the ambient request AbortSignal, and shutdown drains nested direct execution before disposal. This context preservation does not add rollback atomicity.
Async work created inside a transaction can inherit its ALS context even when it runs after the owning transaction has committed, rolled back, or otherwise settled. A later transaction(...) or requestTransaction(...) call from that inherited continuation is treated as a fresh lifecycle-tracked root instead of reusing the closed transaction handle. Shutdown drains that fresh root before dispose(database), while calls that begin before the owner settles continue to share the active boundary.
Request-Wide Controller Boundaries
Prefer service-level @Transaction() for business operations. If you are migrating a NestJS controller/interceptor pattern where an entire request must be transactional, call requestTransaction(...) explicitly at the controller, route adapter, or request orchestration boundary and pass the request AbortSignal when one is available:
import { Inject } from '@fluojs/core';
import { Controller, Post, type RequestContext } from '@fluojs/http';
import { DrizzleDatabase } from '@fluojs/drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { CheckoutService } from './checkout.service';
type AppDatabase = ReturnType<typeof drizzle>;
@Controller('/checkout')
@Inject(DrizzleDatabase, CheckoutService)
export class CheckoutController {
constructor(
private readonly db: DrizzleDatabase<AppDatabase>,
private readonly checkout: CheckoutService,
) {}
@Post()
create(input: CheckoutInput, context: RequestContext) {
return this.db.requestTransaction(
() => this.checkout.createOrder(input),
context.request.signal,
);
}
}
DrizzleTransactionInterceptor is a deprecated 1.x compatibility bridge for existing NestJS interceptor imports. It delegates to requestTransaction(...) and forwards the request AbortSignal. New code should move business transaction boundaries to services and reserve explicit requestTransaction(...) for rare controller-level cases where all request work, not just a service method, must share the same boundary. Decorating a controller method with @Transaction() remains a compatibility path when the controller owns an explicit DrizzleDatabase target, but requestTransaction(...) is the clearer request-wide API because it can receive the request AbortSignal directly.
Named clients
Register each additional client with a non-empty name and inject its package-owned token instead of the DrizzleDatabase class token:
const ANALYTICS_DRIZZLE = getDrizzleHandleProviderToken('analytics');
DrizzleModule.forRoot({ database: primaryDatabase });
DrizzleModule.forRoot({ database: analyticsDatabase, name: 'analytics' });
@Inject(ANALYTICS_DRIZZLE)
class AnalyticsService {
constructor(private readonly analytics: DrizzleDatabase<AnalyticsDatabase>) {}
@Transaction((self: AnalyticsService) => self.analytics)
async rebuild() {}
}
getDrizzleDatabaseToken, getDrizzleDisposeToken, getDrizzleOptionsToken, and
getDrizzleHandleProviderToken return distinct stable identities for each trimmed name. Named clients are
non-global and independently own ALS transaction context, shutdown drain, disposal, and status. A consumer must import
a module that exports the matching named token; names do not create isolated runtime containers. Omitting name
preserves the existing default tokens, DrizzleDatabase class token, and interceptor behavior.
Shutdown and status contracts
During application shutdown, DrizzleDatabase aborts any still-active request transaction, waits for open request and manual transaction callbacks to settle or roll back, and only then runs the optional dispose(database) hook. This includes fail-open manual transaction(...) callbacks when database.transaction(...) is unavailable and strictTransactions is false, so direct-execution fallbacks still drain before pools or externally managed resources are closed.
Transaction continuations that start a new boundary after their inherited owner settles no longer reuse the closed transaction handle. They become independently tracked roots, and shutdown waits for those continuation roots before disposal.
Nested requestTransaction(...) calls opened inside an existing request boundary observe the ambient request abort signal while still reusing the active Drizzle transaction. Nested requestTransaction(...) calls opened inside an existing manual transaction boundary also join shutdown settlement tracking without opening a second Drizzle transaction, and their settlement handle remains tracked until the outer manual transaction settles so shutdown drains that outer boundary before dispose(database) runs. The platform status activity count is intentionally shorter lived: once the nested request callback settles, details.activeRequestTransactions is decremented even if the outer manual transaction continues running.
New transaction(...) and requestTransaction(...) calls are rejected once shutdown begins, so disposal cannot overtake a late transaction that starts after the shutdown boundary is crossed.
If the request signal aborts after the request callback has completed but before the underlying Drizzle transaction runner finishes committing or rolling back, requestTransaction(...) waits for that runner to settle first and then rejects with the abort reason. This keeps Drizzle cleanup serialized with request cancellation while making the late request abort visible to the caller instead of returning the completed callback result.
createDrizzlePlatformStatusSnapshot(...) and DrizzleDatabase.createPlatformStatusSnapshot() expose the same contract to diagnostics surfaces:
readiness.status is not-ready while Drizzle is shutting down or stopped, and when strictTransactions is enabled without database.transaction(...) support.
health.status is degraded while request transactions are draining during shutdown and unhealthy after disposal.
details.activeRequestTransactions, details.lifecycleState, details.strictTransactions, and details.supportsTransaction describe the current request transaction and transaction-capability state.
details.transactionContext: 'als' identifies the async-local transaction context used by request and service transaction boundaries.
ownership.externallyManaged: true and ownership.ownsResources: false mean the package runs your configured dispose hook but does not claim ownership of the underlying driver resources.
Manual Module Composition
Use DrizzleModule.forRoot(...) / forRootAsync(...) to register Drizzle. When you need to compose Drizzle support inside a custom defineModule(...) registration, import the module entrypoint there as well.
import { defineModule } from '@fluojs/runtime';
import { DrizzleModule } from '@fluojs/drizzle';
const database = {
transaction: async <T>(callback: (tx: typeof database) => Promise<T>) => callback(database),
};
class ManualDrizzleModule {}
defineModule(ManualDrizzleModule, {
imports: [DrizzleModule.forRoot({ database })],
});
Public API Overview
DrizzleModule.forRoot(options) / DrizzleModule.forRootAsync(options)
DrizzleDatabase
DrizzleDatabaseFacade<TDatabase>
DrizzleTransactionInterceptor (deprecated 1.x request-transaction compatibility bridge)
Transaction
DRIZZLE_DATABASE, DRIZZLE_DISPOSE, DRIZZLE_HANDLE_PROVIDER, DRIZZLE_OPTIONS
getDrizzleDatabaseToken(name?), getDrizzleDisposeToken(name?), getDrizzleHandleProviderToken(name?), getDrizzleOptionsToken(name?)
DrizzleDatabase.createFacade(...) (compatibility-only provider wiring helper; prefer DrizzleModule.forRoot(...) / forRootAsync(...) for application registration)
createDrizzlePlatformStatusSnapshot(...)
DrizzleDatabaseLike
DrizzleModuleOptions
DrizzleHandleProvider
DRIZZLE_HANDLE_PROVIDER is an alias token for the lifecycle-aware DrizzleDatabase wrapper. Health integrations such as @fluojs/terminus use this token to read createPlatformStatusSnapshot() before falling back to raw database pings.
DrizzleModule exports DRIZZLE_DATABASE, DRIZZLE_DISPOSE, and DRIZZLE_OPTIONS for importing modules. DRIZZLE_DATABASE injects the configured raw Drizzle handle, so it bypasses the lifecycle-aware facade and ambient transaction-handle selection. Prefer DrizzleDatabase or DrizzleDatabaseFacade for application repositories; inject the raw token only for integrations that require the configured driver handle. DRIZZLE_DISPOSE exposes the configured optional cleanup hook, and DRIZZLE_OPTIONS exposes normalized runtime options.
Use DrizzleDatabase<TDatabase> when a provider only needs wrapper methods such as current(), transaction(...), requestTransaction(...), or createPlatformStatusSnapshot(). Use DrizzleDatabaseFacade<TDatabase> for repository injections that call Drizzle query methods directly; the facade forwards those calls to the active transaction handle when one exists and to the root handle otherwise. DrizzleDatabase.createFacade(...) is retained as a low-level compatibility helper for module-provider wiring; application code should prefer DrizzleModule.forRoot(...) / forRootAsync(...).
Transaction is a standard TC39 method decorator for service-layer transaction boundaries. It resolves a transaction-capable target from the decorated host by checking this.db, then direct properties, then nested .db properties, then falling back to the decorated instance itself; it also accepts an accessor for explicit client selection and can forward Drizzle transaction options to the outer boundary.
DrizzleModule
DrizzleModule.forRoot(options) / DrizzleModule.forRootAsync(options)
forRootAsync(...) accepts DI-aware Drizzle options whose factory returns the database/dispose/transaction settings; pass global on the top-level async registration when the providers should be visible globally.
forRootAsync(...) resolves options once per application container. Reusing the same module definition across tests or multi-app processes creates isolated database/dispose results for each container instead of sharing a memoized factory result.
- Supports
strictTransactions: true to throw if transaction support is missing.
- Additional named registrations are non-global. Consumers import a module that exports the matching
getDrizzle*Token(name) and inject through that token; names do not create isolated runtime containers. Each registration owns independent ALS transaction context, drain, disposal, and status; select it explicitly with @Transaction((self) => self.analytics).
database must be a concrete object/function handle for both sync and async registration; missing handles are rejected during module registration or async bootstrap.
Related Packages
@fluojs/runtime: owns module startup and shutdown sequencing
@fluojs/http: provides request lifecycle primitives that can be paired with explicit requestTransaction(...) boundaries
@fluojs/prisma and @fluojs/mongoose: alternate ORM/ODM integrations with the same fluo runtime model
Example Sources
packages/drizzle/src/vertical-slice.test.ts
packages/drizzle/src/module.test.ts
packages/drizzle/src/public-api.test.ts