Skip to main content

Database

The @zeltjs/db package provides ORM-agnostic database abstraction with automatic transaction propagation using AsyncLocalStorage.

Installation

pnpm add @zeltjs/db

Overview

Zelt's database abstraction solves a common problem: propagating transactions through your service layer without passing transaction objects explicitly. Using Node.js AsyncLocalStorage, transactions automatically flow through async call chains.

Creating a Database Service

Extend DatabaseService to integrate your ORM:

import { DatabaseService } from '@zeltjs/db';
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

export class DrizzleService extends DatabaseService<PostgresJsDatabase> {
  private sql!: postgres.Sql;

  async setup(): Promise<PostgresJsDatabase> {
    this.sql = postgres(process.env.DATABASE_URL!);
    const db = drizzle(this.sql);

    this.onShutdown(async () => {
      await this.sql.end();
    });

    return db;
  }

  transaction<T>(
    client: PostgresJsDatabase,
    fn: (tx: PostgresJsDatabase) => Promise<T>,
  ): Promise<T> {
    return client.transaction(fn);
  }
}

Key points:

  • setup() — Initialize and return your database client
  • transaction() — Execute a function within a transaction
  • onShutdown() — Register cleanup handlers for graceful shutdown

Using the Database Service

Direct Usage

Inject the service and access the client:

@Injectable()
export class UserRepository {
  constructor(private db = inject(DrizzleService)) {}

  async findAll() {
    return this.db.client.select().from(users);
  }

  async create(name: string, email: string) {
    return this.db.client.insert(users).values({ name, email });
  }
}

The client property automatically returns:

  • The transaction client if inside a transaction
  • The original client otherwise

Transaction Decorator

Create a decorator for your database service:

import { createTransactionDecorator } from '@zeltjs/db';

export const Transaction = createTransactionDecorator(DrizzleService);

Apply it to methods that should run in a transaction:

import { Injectable, inject } from '@zeltjs/core';

@Injectable()
export class OrderService {
  constructor(
    private orderRepo = inject(OrderRepository),
    private inventoryRepo = inject(InventoryRepository),
  ) {}

  @Transaction()
  async placeOrder(userId: string, items: OrderItem[]) {
    const order = await this.orderRepo.create(userId, items);

    for (const item of items) {
      await this.inventoryRepo.decrement(item.productId, item.quantity);
    }

    return order;
  }
}

All repository calls within placeOrder automatically use the same transaction. If any operation fails, the entire transaction rolls back.

Transaction Middleware

For request-scoped transactions, use middleware:

import { createTransactionMiddleware } from '@zeltjs/db';

export const TransactionMiddleware = createTransactionMiddleware(DrizzleService);

Apply to controllers:

import { Controller, Post, body, UseMiddleware } from '@zeltjs/core';

@Controller('/orders')
@UseMiddleware(TransactionMiddleware)
export class OrderController {
  constructor(private orderService = inject(OrderService)) {}

  @Post('/')
  async create(data = body<CreateOrderDto>()) {
    return this.orderService.placeOrder(data.userId, data.items);
  }
}

Every request to this controller runs in a transaction.

Transaction Propagation

Transactions propagate through async call chains automatically:

@Injectable()
export class PaymentService {
  @Transaction()
  async processPayment(orderId: string, amount: number) {
    await this.ledgerRepo.debit(orderId, amount);
    await this.notificationService.sendReceipt(orderId);
  }
}

@Injectable()
export class OrderService {
  @Transaction()
  async completeOrder(orderId: string) {
    await this.orderRepo.markComplete(orderId);
    await this.paymentService.processPayment(orderId, 100);
  }
}

When completeOrder calls processPayment, both run in the same transaction — the inner @Transaction() joins the existing transaction rather than starting a new one.

Lifecycle Integration

DatabaseService integrates with Zelt's lifecycle system:

import { createApp, http } from '@zeltjs/core';

const app = createApp([http({
    controllers: [OrderController],
  })], { configs: [DrizzleService] });

The service:

  1. Calls setup() during app startup
  2. Calls shutdown() handlers during app shutdown

API Reference

DatabaseService

Property/MethodDescription
clientCurrent database client (transaction-aware)
setup()Abstract: Initialize database connection
transaction(client, fn)Abstract: Execute function in transaction
withTransaction(fn)Run function in a new or existing transaction
onShutdown(fn)Register shutdown handler

Factory Functions

FunctionDescription
createTransactionDecorator(Service)Create @Transaction() decorator
createTransactionMiddleware(Service)Create transaction middleware class