tapflow
Self-hosted iOS & Android simulator streaming for the whole team
Type-safe repository API for Drizzle ORM. findMany, nested relation filters, include/select, pagination, upsert, transactions, hooks, and plugins for PostgreSQL, MySQL, and SQLite.
Developer toolsTypeScript · MDXGitHub @almeidazsOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
ORM, but better
Translation pending
Loading the full README.
Read the full README ↗Type-safe repository helpers for Drizzle ORM.
Keep Drizzle's type-safety. Drop the query glue you rewrite in every service.
const client = better(drizzle({ client: pool, relations }));
const users = await client.users.findMany({
where: { posts: { some: { published: true } } },
include: {
posts: { where: { published: true }, take: 3 },
_count: { select: { posts: true } },
},
});
A nested relation filter, three posts per user, and a relation count - typed end to end from your Drizzle schema and defineRelations(...) config. One query per relation node, never one per row.
No codegen. No client process. No new schema language. It is still your Drizzle client underneath, and you can drop back to it at any line.
npm install better-drizzle [email protected]
[!IMPORTANT] better-drizzle supports only Drizzle ORM 1.x (
drizzle-orm>=1.0.0-rc.4 <1.0.0-rc.5; later release candidates are not supported yet) and itsdefineRelations(...)API.drizzle-orm0.x is not supported - projects on 0.x must stay on better-drizzle0.2.x. Installdrizzle-ormwith the explicit version: until Drizzle 1.0 is taggedlatest, a plain install resolves to 0.x. See upgrading.
Tables live in your schema, relations are declared with Drizzle's defineRelations, and the Drizzle instance receives them. better() reads tables and relations from that instance.
// schema.ts
import { integer, pgTable, text } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: integer().primaryKey(),
email: text().notNull(),
});
export const posts = pgTable('posts', {
id: integer().primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
title: text().notNull(),
});
// relations.ts
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
users: { posts: r.many.posts() },
posts: { author: r.one.users({ from: r.posts.userId, to: r.users.id }) },
}));
// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { better } from 'better-drizzle';
import { relations } from './relations';
export const client = better(drizzle({ connection: process.env.DATABASE_URL!, relations }));
With no relations, pass defineRelations(schema) without a callback.
| Raw Drizzle | better-drizzle | |
|---|---|---|
| Point lookups | db.select().from().where(eq(...)) + unwrap | findUnique({ where: { email } }) |
| Relation loading | manual joins, or db.query config | include / select, payload inferred |
| Nested relation filters | hand-built exists subqueries | some / every / none / is |
| Pagination | rebuild metadata and cursors every time | paginate() / cursor() → { data, pagination } |
| Not-found handling | check for undefined everywhere | nullable result or .throw() |
| Cross-cutting concerns | sprinkled through call sites | hooks and plugins |
| Timestamps / soft delete | repeated in every write | official plugins |
Describe the link. It resolves the rows and runs the writes in one transaction.
await client.posts.create({
data: {
title: 'Hello',
author: { connect: { email: '[email protected]' } },
},
});
connect, disconnect, and set work on create, update, and both branches of upsert.
Many-to-many is declared once in defineRelations with .through(), and after that you never name the junction in a query:
// relations.ts
users: {
groups: r.many.groups({
from: r.users.id.through(r.memberships.userId),
to: r.groups.id.through(r.memberships.groupId),
}),
},
const users = await client.users.findMany({
include: { groups: true },
});
const page = await client.users.paginate({
limit: 20,
skip: 40,
orderBy: [{ id: 'asc' }],
where: { active: true },
});
page.pagination carries total, pageCount, hasNext, and hasPrevious. Use cursor() instead for feed-style navigation and you get nextCursor and previousCursor computed for you.
orderBy accepts a field map or an array. Specify { direction, nulls } when NULL placement matters: orderBy: { lastSeenAt: { direction: 'desc', nulls: 'last' } }. It also sorts by relations in the same statement: a one relation takes the related table's fields (orderBy: { author: { name: 'asc' } }), and a many or .through() relation takes its row count (orderBy: { posts: { _count: 'desc' } }). Relation sorts cannot be combined with cursor pagination.
Operations that can legitimately match nothing say so in the type - and let you opt into throwing when it is genuinely exceptional.
const user = await client.users.findUnique({ where: { id } });
// ^? User | null
const user = await client.users.findUnique({ where: { id } }).throw();
// ^? User
Define the read once with param(), then execute it with new values. Each param takes its type from the column or option it stands in for, and the result keeps the read's shape.
import { param } from 'better-drizzle';
const findUserByEmail = client.users
.findUnique({ where: { email: param('email') } })
.prepare('users.by-email');
const user = await findUserByEmail.execute({ email: '[email protected]' });
// ^? User | null execute({ email: 1 }) is a type error
Every read can be prepared, including paginate() and cursor(). Plugin transforms, before hooks, and beforeQuery run once at prepare time; afterQuery, intercepts (including the cache plugin), and plugin after hooks run on every execution. See prepared statements.
Declare the shape with Drizzle's $type<T>() and every scalar leaf becomes a typed dot path. PostgreSQL.
const referrals = await client.accounts.findMany({
where: {
settings: { json: { referrer: { endsWith: '@acme.com' } } },
},
});
Paths and values are bound parameters, and the generated predicate is guarded by jsonb_typeof, so one row with the wrong type cannot break the cast.
The same dot paths work for partial updates through jsonb_set, leaving the rest of the document untouched:
await client.accounts.update({
where: { id },
data: { settings: { 'plan.tier': 'pro' } },
});
On typed JSONB columns, dotted paths and the { json: ... } wrapper both check paths and values against $type<T>(); use the wrapper for single-level keys. Path updates create missing object ancestors, treat SQL NULL and non-object JSONB roots as {}, and preserve existing object ancestors and unrelated keys. A scalar, array, or JSON null at an intermediate path is replaced with {}. Duplicate or ancestor/descendant paths are rejected, as are values containing nested undefined. Untyped JSONB columns keep open path names and JSON-encodable values.
const rows = await db
.select()
.from(users)
.where(client.users.$where({ age: { gte: 18 }, posts: { some: { published: true } } }));
$where() compiles the same typed where as findMany into a Drizzle SQL condition (undefined when empty), for joins, subqueries, and hand-written queries. Plugin scopes are ANDed in: soft-delete visibility always, and the tenant filter on a tenant-scoped client.
const users = await client.users.findMany({
where: { active: true },
lock: {
mode: 'update',
skipLocked: true,
},
});
PostgreSQL and MySQL. SQLite fails fast instead of silently dropping the lock, and locks: { transactionsOnly: true } enforces that locked reads only run inside a transaction.
The callback receives a full client bound to the transaction, so delegates, plugins, hooks, and nested savepoints all keep working.
await client.transaction(async (tx) => {
const user = await tx.users.create({
data: { name: 'Alice', email: '[email protected]' },
});
tx.afterCommit(() => sendWelcomeEmail(user.email));
});
Also available: automatic retries on deadlock and serialization failures, afterRollback, and isolation levels where the dialect supports them.
Package setup, transforms, and typed extensions once, instead of wrapping better(...) yourself.
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(db, {
plugins: [rules(recommended()), timestamps(), softDelete(), zod()],
});
That gets you runtime guardrails, automatic timestamps, soft deletes with restore(), and Zod schemas generated from your tables at client.users.$zod. Plugins can add their own typed operation args, so client.users.findMany({ deleted: 'only' }) type-checks.
better-drizzle/uuid fills UUIDv7 ids on create and makes them optional in create types; buuid() stores them as 16 bytes on MySQL and SQLite. See UUIDs.
Pair better-drizzle/eslint with the runtime rules to catch the statically-checkable subset in your editor.
better-drizzle/ata (experimental) validates the same operations against compiled JSON Schema, as a faster alternative to the Zod plugin. See ata.
[!WARNING]
better-drizzle/cacheis experimental in0.3.x: its options,$cacheAPI, store interface, and entry format can change in a patch release.
better-drizzle/cache caches the reads you opt into, and better-drizzle/cache/redis stores them in the Redis client you already have. Observed writes invalidate declared dependencies after they commit, including included relations and declared foreign-key cascades. Commit and invalidation are separate operations; concurrent reads and store failures can leave stale results cached, so use database reads when consistency must be exact. No Redis key is ever scanned. Raw Drizzle writes still need a manual $cache.invalidate().
import { cache } from 'better-drizzle/cache';
import { redis } from 'better-drizzle/cache/redis';
const client = better(db, {
plugins: [cache({ store: redis({ client: redisClient }), ttl: '5m' })],
});
const user = await client.users.findUnique({ where: { id }, cache: true });
better-drizzle/tenantis experimental in0.4.x.
better-drizzle/tenant isolates tenants that share tables. client.$tenant({ organizationId }) returns a client whose reads, relation loads, relation filters, _count, relation sorts, writes, upserts, and connect lookups only see that tenant; creates stamp the tenant column, and scoped models throw TENANT_REQUIRED without a tenant. Raw SQL on scoped clients is denied unless opted in, and PostgreSQL row-level security settings can run per transaction. See tenant.
import { tenant } from 'better-drizzle/tenant';
const client = better(db, { plugins: [tenant({ columns: 'organizationId', global: ['plans'] })] });
const scoped = client.$tenant({ organizationId: session.orgId });
const projects = await scoped.projects.findMany({ include: { tasks: true } });
Measured against raw Drizzle doing the same work and returning the same shape - not against a lower-level query that does less.
Relation loading wins because the batched loader issues one query per relation node instead of the per-row work the equivalent hand-written code ends up doing. Numbers are SQLite in-memory to isolate wrapper overhead from I/O; reproduce them with bun run bench:report.
Full tables, methodology, and the cases where the wrapper costs you: benchmarks.
.explain() on any read without running it · updateEach for per-row batch updates in one statement · upsertMany · $withContext for request-scoped metadata · extends() for your own helpers · raw SQL with its own hooks · lifecycle hooks for auditing and tracing.
Atomic updates (increment, decrement, multiply, divide, toggle) · typed PostgreSQL array filters and mutations.
Give your agent the current API instead of older training data:
npx skills add almeidazs/better-drizzle installs the skill for Claude Code, Codex, Cursor, GitHub Copilot, OpenCode, and other agents that read SKILL.md.llms.txt, llms-full.txt, and a .md version of every page (https://better-drizzle.com/docs/querying/relations.md).The skill ships only Markdown: no scripts and no install commands. See the AI docs.
Getting started · Querying · Writing · Plugins · Why better-drizzle? · Limitations