Skip to content

Latest commit

 

History

150 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

better-drizzle


Drizzle ORM, but better 🚀

Minimal, type-safe repository helpers for Drizzle ORM.

better-drizzle wraps an existing Drizzle client and gives each table a small, consistent API for reads, writes, pagination, nested filters, relation loading, and optional hooks. The goal is simple: keep Drizzle's type-safety, remove repetitive query glue, and stay close enough to the metal that performance still matters.

Website: https://better-drizzle.com/

Sponsors

Neon

Sponsored by Neon

Neon is the serverless Postgres platform built for modern developer workflows.

Sponsors

Neon

Sponsored by Neon

Neon is the serverless Postgres platform built for modern developer workflows.

npm install better-drizzle drizzle-orm

Why

Drizzle is excellent when you want explicit SQL-first control.

It gets repetitive when every service ends up re-writing the same patterns:

  • point lookups
  • nested relation reads and writes
  • pagination payloads
  • existence checks
  • count helpers
  • CRUD return shapes
  • nested where filters

better-drizzle packages those patterns into a small repository-style API without trying to replace Drizzle itself.

What it improves

  • Less repeated query code for common CRUD flows
  • Nested relation filters with Drizzle-backed typing
  • Batched nested include and select with typed payload inference
  • Atomic connect, disconnect, and set relation writes
  • Direct and inferred many-to-many relations through simple junction tables
  • Offset and cursor pagination helpers with typed metadata
  • Thenable .explain() on read helpers for query-plan inspection
  • Optional lifecycle hooks for cross-cutting behavior
  • First-class plugins with setup, transforms, and client/model extensions
  • Fast paths for simple reads and writes to reduce wrapper overhead
  • Consistent table delegates: findMany, findFirst, create, update, updateEach, delete, paginate, cursor, count, exists, upsert, upsertMany

Querying your database with Better client

import { better } from 'better-drizzle';
import { drizzle } from 'drizzle-orm/better-sqlite3';

const db = drizzle(sqlite, { schema });

const client = better(db, { schema });

const user = await client.users.findFirst({
	where: { id: 1 },
});

const posts = await client.posts.findMany({
	where: {
		published: true,
		author: {
			is: {
				active: true,
			},
		},
	},
	include: {
		author: true,
	},
	orderBy: [{ id: 'desc' }],
	take: 20,
});

const users = await client.users.findMany({
	include: {
		_count: {
			select: {
				posts: { where: { published: true } },
			},
		},
		posts: {
			where: { published: true },
			orderBy: { score: 'desc' },
			take: 3,
		},
	},
});

_count uses correlated SQL subqueries, so relation counts do not add a query per row or a separate count round-trip.

Check whether a user exists or not and count after it.

const exists = await client.users.exists({
	where: { id: 123 },
});

const count = await client.users.count({
	where: {
		name: { contains: 'drizzle-orm' },
	},
});

const plan = await client.users
	.findMany({
		where: { active: true },
		orderBy: [{ id: 'asc' }],
	})
	.explain({
		analyze: true,
		comment: 'users.active.explain',
	});

.explain() is available on read helpers (findMany, findFirst, findOne, findUnique, count, exists, paginate, cursor). PostgreSQL returns the richest output, while MySQL and SQLite ignore unsupported explain options and return the plan shape their drivers support.

Create and update the user.

const someUser = await client.users.create({
	data: {
		id: 123,
		name: 'better',
	},
});

const user = await client.users.update({
	data: {
		name: 'better-drizzle',
		posts: {
			connect: { id: 456 },
			disconnect: { id: 789 },
		},
	},
	where: { id: someUser.id },
});

const maybeCreated = await client.users.create({
	data: {
		email: 'better@example.com',
		id: 124,
		name: 'better-again',
	},
	skipDuplicates: true,
});

if (!maybeCreated) {
	console.log('user already existed');
}

const batch = await client.users.upsertMany({
	data: [
		{ id: 123, name: 'better', email: 'better@example.com', active: true },
		{ id: 124, name: 'batch', email: 'batch@example.com', active: false },
	],
	target: 'email',
	update: ['name', 'active'],
	select: {
		id: true,
		name: true,
	},
});

You can where queries like drizzle too

const { count } = await client.users.delete({
	where: eq(users.id, 123),
});

You can also resolve repositories dynamically.

const users = client.repository('users');

The repository name can be the TypeScript schema key or the database table name.

Transactions

Transactions live on the client, not on individual models. The callback receives a full Better Drizzle client bound to the underlying transaction, so model delegates, plugin args, transforms, hooks, and nested transactions all keep working.

const user = await client.transaction(async (tx) => {
	const created = await tx.users.create({
		data: {
			email: 'better@example.com',
			id: 123,
			name: 'better',
		},
	});

	tx.afterCommit(async () => {
		await sendWelcomeEmail(created.email);
	});

	return created;
});

On PostgreSQL and MySQL, read helpers also accept lock for row-level locking:

await client.transaction(async (tx) => {
	const jobs = await tx.posts.findMany({
		where: { id: { gt: 0 } },
		lock: {
			mode: 'update',
			skipLocked: true,
		},
	});

	return jobs;
});

If you want to enforce that locked reads only happen inside transactions, enable it once on the client:

const client = better(db, {
	schema,
	locks: {
		transactionsOnly: true,
	},
});

Plugins

Plugins let you package setup logic, query transforms, and reusable client/model extensions without wrapping better(...) yourself.

Plugins can also extend the built-in operation args in a fully typed way through operationArgs, so custom fields like deleted or mode flow from the delegate call into plugin transforms and hooks.

import { better } from 'better-drizzle';
import { recommended, rules } from '@better-drizzle/rules';
import { softDelete } from '@better-drizzle/soft-delete';
import { timestamps } from '@better-drizzle/timestamps';
import { zod } from '@better-drizzle/zod';

const client = better(drizzle, {
	schema,
	plugins: [
		rules(
			recommended({
				noRawUnsafe: true,
			}),
		),
		timestamps({
			createdAt: 'created_at',
			updatedAt: 'updated_at',
		}),
		zod({
			validate: {
				create: true,
				result: true,
				update: true,
				upsert: true,
			},
		}),
		softDelete({
			column: 'deletedAt',
			deletedByColumn: 'deletedById',
			defaults: {
				mode: 'soft',
				visibility: 'without',
			},
		}),
	],
});

await client.users.delete({
	where: { id: 1 },
});

await client.users.findMany({
	deleted: 'only',
});

await client.users.restore({
	where: { id: 1 },
});

Now you can enforce repository guardrails, soft delete rows, auto-generate Zod schemas, and also have timestamps fields injected automatically.

For editor and CI feedback before runtime, pair the runtime guardrails with @better-drizzle/eslint and its flat-config presets. The ESLint package mirrors the statically-checkable subset of @better-drizzle/rules for direct Better Drizzle callsites.

Hooks

The client accepts optional hooks through better(db, options). This is useful for auditing, tracing, metrics, authorization, and other cross-cutting concerns that you do not want duplicated in every repository call.

The hook layer is optional. If you do not need it, do not pass it.

Always assign a random UUID in the user before creating it.

const client = better(drizzle, {
	schema,
	hooks: {
		beforeCreate({ data: user }) {
			user.organizationId = randomUUID();
		},
	},
});

meta can still be passed per call, but you can now scope default metadata once on the client with $withContext(...). Scoped values are merged into repository calls, raw SQL hooks, and transaction hooks, and per-call meta overrides matching keys.

type RequestMeta = {
	organizationId?: string;
	requestId?: string;
	userId?: string;
};

const client = better<typeof schema, RequestMeta>(drizzle, { schema });

const scoped = client.$withContext({
	organizationId: 'org_123',
	requestId: 'req_123',
});

await scoped.users.create({
	data: { name: 'Alice' },
	meta: { requestId: 'req_456', userId: 'user_7' },
});

Performance

See the full benchmark suite and results in benchmark/README.md. The suite covers reads, writes, and transactions (including nested savepoints) with fair API-parity comparisons against raw Drizzle.

Contributors

AI Skills

better-drizzle now ships a first-party agent skill pack under skills/better-drizzle for AI coding agents that need accurate repository context, API guidance, and review guardrails.

  • Canonical skill format: SKILL.md plus task-specific files in references/
  • Multi-agent support: AGENTS.md for repo context, CLAUDE.md and GEMINI.md as thin adapters, and the skill pack as the canonical workflow layer
  • Scope: querying, writes, transactions, plugins, performance-sensitive changes, and review-time checks for API correctness
  • Security posture: zero scripts, zero network, no secret-reading instructions, and explicit prompt-injection resistance guidance

See the website AI page for installation notes, supported agent surfaces, and the security model: https://better-drizzle.com/docs/ai

Releases

Used by

Contributors

Languages