beta

Drizzle

Drizzle ORM integration for PostgreSQL, MySQL, and SQLite.

@miiajs/drizzle integrates Drizzle ORM with MiiaJS, providing connection management, retry logic, and DI-based access to the database.

Installation

Install the core package and a database driver. MiiaJS is bun-native - swap bun add for npm install, pnpm add, or yarn add if you use a different package manager.

bun add @miiajs/drizzle drizzle-orm postgres

Setup

Configure the module

import { Module } from '@miiajs/core'
import { DrizzleModule } from '@miiajs/drizzle'

@Module({
  imports: [
    DrizzleModule.configure({
      dialect: 'postgres',
      connection: { url: 'postgres://localhost:5432/mydb' },
    }),
  ],
})
class AppModule {}

With ConfigService (from @miiajs/config):

import { ConfigService } from '@miiajs/config'

DrizzleModule.configure((resolve) => {
  const config = resolve(ConfigService)
  return {
    dialect: 'postgres',
    connection: {
      url: config.getOrThrow('DATABASE_URL'),
      retry: { attempts: 10, delay: 10_000 },
    },
  }
})

Configuration options

interface DrizzleModuleOptions {
  dialect: 'postgres' | 'mysql' | 'sqlite'
  connection: {
    url: string
    retry?: {
      attempts?: number  // Default: 10
      delay?: number     // Default: 10_000 ms
    }
  }
  schema?: Record<string, unknown>
  casing?: 'snake_case' | 'camelCase'
}

Define schemas

Use standard Drizzle table definitions:

import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 255 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  role: varchar('role', { length: 50 }).notNull().default('user'),
  createdAt: timestamp('created_at').default(sql`now()`),
})

Register schemas

All schemas live in DrizzleModule.configure({ schema }). Drizzle is schema-first - tables are imported directly into services as values, so there is no per-feature DI registration. Use a barrel file (src/db.schema.ts) that re-exports every table for a single typeof schema reference:

// src/db.schema.ts
export * from './user/user.schema.js'
export * from './post/post.schema.js'
// src/app.module.ts
import * as schema from './db.schema.js'

@Module({
  imports: [
    DrizzleModule.configure({
      dialect: 'postgres',
      connection: { url: DATABASE_URL },
      schema,
    }),
  ],
})
class AppModule {}

Use in services

Define a typed DI token once with drizzleDb<TDb>() and inject it with the framework's standard inject() helper:

// src/db.ts
import { drizzleDb } from '@miiajs/drizzle'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import type * as schema from './db.schema.js'

export const db = drizzleDb<PostgresJsDatabase<typeof schema>>()
// src/user/user.service.ts
import { Injectable, inject } from '@miiajs/core'
import { eq } from 'drizzle-orm'
import { db } from '../db.js'
import { users } from './user.schema.js'

@Injectable()
class UserService {
  private db = inject(db)

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

  async findById(id: number) {
    const [user] = await this.db
      .select()
      .from(users)
      .where(eq(users.id, id))
    return user ?? null
  }

  async create(data: { name: string; email: string }) {
    const [user] = await this.db
      .insert(users)
      .values(data)
      .returning()
    return user
  }

  async update(id: number, data: Partial<{ name: string; email: string }>) {
    const [user] = await this.db
      .update(users)
      .set(data)
      .where(eq(users.id, id))
      .returning()
    return user
  }

  async delete(id: number) {
    await this.db.delete(users).where(eq(users.id, id))
  }
}

Type safety

drizzleDb<TDb>() accepts the concrete Drizzle database type as a generic parameter. The type flows through inject(db) so every call site gets full autocompletion for select, insert, update, delete, execute, and - when you pass a schema - db.query.* relational queries.

For MySQL and SQLite, swap the type accordingly: MySql2Database<typeof schema> from drizzle-orm/mysql2, or BetterSQLite3Database<typeof schema> from drizzle-orm/better-sqlite3.

db.ts is the single source of truth.drizzleDb() memoizes by name only - the generic is compile-time. Calling drizzleDb<TypeA>() in one file and drizzleDb<TypeB>() in another resolves to the same runtime DI token, but projects different TypeScript types at each site. TypeScript will not catch the mismatch. Always import the typed db from your single db.ts (or analytics.ts, etc) - do not re-call drizzleDb<...>() elsewhere.

Relational queries

When schemas include relations, db.query.* is typed automatically through the typeof schema generic:

import { relations } from 'drizzle-orm'

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}))

Make sure the relations live in the schema barrel so they are part of typeof schema. Then:

const usersWithPosts = await this.db.query.users.findMany({
  with: { posts: true },
})

Migrations

@miiajs/drizzle handles the runtime connection, but it does not run migrations on startup - onInit() only connects and retries on transient failures. Schema changes are a build/deploy step handled by drizzle-kit.

Install it as a dev dependency:

bun add -D drizzle-kit

Create drizzle.config.ts at the project root:

import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/db.schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

Then generate and apply migrations:

# Generate a new SQL migration from your schema changes
npx drizzle-kit generate

# Apply pending migrations against the database
npx drizzle-kit migrate

Commit both the generated SQL files in drizzle/ and the drizzle/meta/ snapshots - the snapshot is the baseline for the next generate.

Bun and Deno auto-load .env when running scripts, so drizzle-kit sees DATABASE_URL through process.env without extra setup. For Node.js, pass node --env-file=.env or import dotenv/config at the top of drizzle.config.ts.
Heads up: drizzle-kit uses dialect: 'postgresql' while DrizzleModule.configure() uses dialect: 'postgres'. That's a naming mismatch between drizzle-kit and drizzle-orm/postgres-js, not a MiiaJS convention. Use whichever the surrounding API expects.

A complete working example - including drizzle.config.ts, db:generate / db:migrate scripts, and a committed initial migration - lives in examples/drizzle-app.

Multiple connections

Use named connections for multi-database setups. Each connection gets its own schema barrel and its own typed drizzleDb<TDb>(name) token:

// src/db.ts - tokens for both connections
import { drizzleDb } from '@miiajs/drizzle'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import type * as mainSchema from './db.schema.js'
import type * as analyticsSchema from './analytics/db.schema.js'

export const db = drizzleDb<PostgresJsDatabase<typeof mainSchema>>()
export const analytics = drizzleDb<PostgresJsDatabase<typeof analyticsSchema>>('analytics')
// Root module - one configure() per connection
import * as mainSchema from './db.schema.js'
import * as analyticsSchema from './analytics/db.schema.js'

@Module({
  imports: [
    DrizzleModule.configure({
      dialect: 'postgres',
      connection: { url: MAIN_DB },
      schema: mainSchema,
    }),
    DrizzleModule.configure(
      {
        dialect: 'postgres',
        connection: { url: ANALYTICS_DB },
        schema: analyticsSchema,
      },
      'analytics',
    ),
    UserModule,
    AnalyticsModule,
  ],
})
class AppModule {}

Inject the connection you need:

@Injectable()
class AnalyticsService {
  private db = inject(analytics)

  async getEvents() {
    return this.db.select().from(events)
  }
}

Supported databases

DialectDriverVersion
postgrespostgres>= 3.0.0
mysqlmysql2>= 3.0.0
sqlitebetter-sqlite3>= 11.0.0

All drivers are optional peer dependencies - install only what you need.

Connection lifecycle

  • onInit - establishes connection with automatic retry on transient errors (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN)
  • onDestroy - closes the connection gracefully

Testing

Override services at the DI level with TestApp.override() - handlers call your service, not the database directly, so you don't need to stub the Drizzle proxy API.

import { TestApp } from '@miiajs/core/testing'
import { AppModule } from './app.module.js'
import { UsersService } from './users/users.service.js'

const app = await TestApp.create(AppModule)
  .override(UsersService, {
    findAll: async () => [{ id: 1, name: 'Alice' }]
  })
  .compile()

const res = await app.request('GET', '/users')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([{ id: 1, name: 'Alice' }])

await app.close()

For unit tests that exercise the service itself, override the database token instead:

import { drizzleDb } from '@miiajs/drizzle'

const app = await TestApp.create(AppModule)
  .override(drizzleDb(), mockDb)
  .compile()

Exports

import {
  DrizzleModule,
  drizzleDb,
  type Dialect,
  type DrizzleModuleOptions,
  type DrizzleDbToken,
} from '@miiajs/drizzle'