Skip to content

Drizzle plugin

@warden/drizzle is Warden’s first-party database integration. It uses Drizzle ORM — a lightweight, type-safe, SQL-first ORM — and ships a thin provider that accepts your configured drizzle instance, registers it in Warden’s DI container, and hands the connection lifecycle to the framework.

Warden endorses two dialects first-party: MySQL/MariaDB for production and SQLite for local development. Other dialects (e.g. Postgres) work too — nothing in the plugin stops you — but first-party feature packages (@warden/audit, @warden/cache) ship migration stubs for MySQL and SQLite only.

Install the plugin along with Drizzle and your database driver:

Terminal window
pnpm add @warden/drizzle drizzle-orm better-sqlite3
pnpm add -D drizzle-kit @types/better-sqlite3

Publish the drizzle config stub into your project, then fill in your driver:

Terminal window
pnpm warden publish drizzle --package=@warden/drizzle

That drops config/drizzle.ts. Edit it:

config/drizzle.ts
import {Config} from '@warden/core';
import {DrizzleServiceProvider} from '@warden/drizzle';
import {drizzle} from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
const db = drizzle(new Database('./dev.sqlite'));
export default Config.define(DrizzleServiceProvider, {
client: db,
dialect: 'sqlite',
});

Register DrizzleServiceProvider in your bootstrap:

import {Bot} from '@warden/core';
import {DrizzleServiceProvider} from '@warden/drizzle';
new Bot()
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts'))
.intents(i => i.defaults())
.plugins([DrizzleServiceProvider])
.start();

The provider binds DrizzleService into the container — your drizzle instance, the configured dialect, and the connection lifecycle wrapped up in one class. Inject it anywhere; framework-owned feature plugins (Audit, Cache, Permissions) pick it up themselves and select the right schema and migration stub automatically.

Inject DrizzleService and ask for your typed handle. Pin a Db alias to your schema once and every read path infers the row shape from there:

src/db/types.ts
import type {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema';
export type Db = BetterSQLite3Database<typeof schema>;

Then in a command (or, more often, a repository):

import {command, Subcommand, CommandInteraction, Params, Embed} from '@warden/core';
import {DrizzleService} from '@warden/drizzle';
import {cases} from '@/db/schema';
import type {Db} from '@/db/types';
import {eq, desc} from 'drizzle-orm';
@command({parent: 'mod', name: 'history', description: 'View moderation history'})
export default class ModHistoryCommand extends Subcommand {
constructor(private drizzle: DrizzleService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const db = this.drizzle.get<Db>();
const target = interaction.options.getUser('user')!;
const rows = await db
.select()
.from(cases)
.where(eq(cases.targetId, target.id))
.orderBy(desc(cases.createdAt))
.limit(10);
if (rows.length === 0) {
await interaction.followUp({embeds: [Embed.info(`${target.username} has a clean record.`)]});
return;
}
const lines = rows.map(c => `**#${c.id}** ${c.type}${c.reason}`);
await interaction.followUp({embeds: [Embed.info(`Moderation history for ${target.username}`).setDescription(lines.join('\n'))]});
}
}

drizzle.get() (no generic) returns the shared DrizzleClient contract — useful for plugin code that doesn’t know your schema. drizzle.get<Db>() returns your concrete drizzle instance with full row inference. Read the dialect off the service directly: drizzle.dialect is 'mysql' or 'sqlite'.

Define schemas using Drizzle’s native API. A common layout is src/db/schema/:

src/db/schema/users.ts
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({autoIncrement: true}),
discordId: text('discord_id').notNull().unique(),
username: text('username').notNull(),
createdAt: integer('created_at', {mode: 'timestamp'}).notNull(),
});

Warden owns the database workflow through warden db:* commands. You don’t invoke drizzle-kit directly:

CommandWhat it does
warden db:migrateApply pending migrations.
warden db:migrate:statusShow applied + pending.
warden db:migrate:freshDrop all tables, re-migrate. --seed composes with seeding.
warden db:wipeDrop every table.
warden make:migration <name>Generate a new migration. Auto-includes schemas from installed @warden/* packages (audit, cache, permissions) — prints what it included. --no-framework-schemas opts out.

Destructive commands (db:migrate in prod, db:wipe, db:migrate:fresh) confirm interactively. In CI pass --force to skip the prompt; running non-interactively without --force in production refuses outright.

A new primitive. Create a class in src/db/seeders/ that extends TypedSeeder<Db> and is decorated with @seeder():

import {seeder, TypedSeeder} from '@warden/drizzle';
import type {Db} from '@/db/types';
@seeder()
export default class DevDataSeeder extends TypedSeeder<Db> {
async run(db: Db): Promise<void> {
// full schema inference inside — `db.insert(...).values({...}).returning()`
// gives you back the typed row, no casts.
}
}

Extending TypedSeeder<Db> is the path most apps want — db is your concrete drizzle instance, complete with row types. If you’re writing a seeder for a plugin (where you don’t know the consumer’s schema), implement Seeder instead and accept the structural DrizzleClient.

Seeders are picked up via .discover((d) => d.load('./src/db/seeders/**/*.ts')) in your cli.ts.load() is the unchecked escape hatch for plugin decorators outside the core kind buckets. Run via warden db:seed or warden db:seed --class=DevDataSeeder. Seeders never run implicitly — always explicit.

Add autoMigrate: 'dev' to config/drizzle.ts to have the provider apply pending migrations at boot in development. Production hard-ignores the flag with a warning log; always run warden db:migrate from your deploy script for prod.

warden make:migration auto-includes schemas from installed @warden/* packages (audit, cache, permissions) — you don’t need to publish separate migration stubs. Run one make:migration and the generated SQL covers both your app tables and the framework ones.