@repo/db
Drizzle schema and typed data access on Cloudflare D1.
@repo/db owns the database layer: a single Drizzle schema covering every table (including the Better Auth tables) and the client that wraps the D1 binding in a fully typed Drizzle client. Any code running in the Worker calls database() and gets end-to-end types from the schema.
Exports
@repo/db:database(),setDatabase(db),createDb(d1), theDatabasetype, and aschemare-export.@repo/db/search: the FTS5 full-text primitive,ftsIndex()andftsSearch().@repo/db/seed-data: deterministic seed constants (seedUser,seedOrganization,seedInvitation) shared with the e2e tests.
Usage
database() resolves the Worker's DB binding (already declared, and simulated automatically in local dev) and reuses one client for the lifetime of the isolate:
import { database, schema } from '@repo/db';
import { eq } from 'drizzle-orm';
const db = await database();
const organization = await db.query.organization.findFirst({
where: eq(schema.organization.id, context.organizationId),
});Call database() wherever the database is needed rather than threading a Database through call signatures or building a client per request. In unit tests, setDatabase(db) points it at an in-memory sqlite database instead; createDb(d1) stays exported for the one caller that needs a client synchronously (the Better Auth adapter in @repo/auth).
Tables
src/schema/ is the single source of truth, one file per domain, re-exported
flat from src/schema/index.ts:
assets.ts:assetauth.ts:user,session,account,verification,deviceCode,apikeyorganizations.ts:organization,member,invitationbilling.ts:subscription,usagedocuments.ts:documentnotifications.ts:notification,notificationPreference,notificationSettingswebhooks.ts:webhookEndpoint,webhookDelivery
Schema changes
Edit the domain file in private/db/src/schema/, then push with drizzle-kit:
cd private/db
pnpm push # local sqlite created by `vp dev`
pnpm push:remote # production D1pnpm push:remote reads CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_D1_TOKEN from private/db/.env (gitignored, see .env.example). The token needs the Account > D1 > Edit permission.
Full-text search
D1 supports SQLite's FTS5 module, but Drizzle Kit does not manage virtual tables. @repo/db/search bridges that: an index is declared against the schema objects, and scripts/push-fts.ts applies it right after drizzle-kit push, so one pnpm push still leaves the database whole.
import { asset } from '../schema/index.ts';
import { ftsIndex } from './fts.ts';
export const assetIndex = ftsIndex({
table: asset,
id: asset.id,
columns: [asset.name],
});Because the table and column names are read off the Drizzle objects, renaming a column in src/schema/ moves the index with it or stops compiling. Declaring an index creates asset_fts and the three triggers that keep it in step with asset, so no write path has to remember to reindex.
Searching is deliberately two steps:
const ids = await ftsSearch(assetIndex, query, 10);
const files = await db
.select()
.from(schema.asset)
.where(and(inArray(schema.asset.id, ids), eq(schema.asset.organizationId, organizationId)));
files.sort(byRank(ids));ftsSearch returns ranked keys and nothing else. Hydrating them with an ordinary Drizzle select is what keeps results typed by the schema, and it is where tenant scoping lives. That second predicate is not optional: an index covers its whole table, so user_fts matches people in every organization and only the membership join keeps other tenants out. byRank restores bm25 order, which a lookup by key does not preserve.
Whatever the user typed is turned into an FTS5 MATCH expression by ftsMatch, which quotes each word and makes the last one a prefix. Raw input is a query language of its own, where a stray " or a bare AND is a syntax error rather than a search.
The command menu in apps/web is the worked example: see apps/web.
Seeding
pnpm seed force-pushes the schema and writes the deterministic seed data. Tests import the same constants:
import { seedOrganization, seedUser } from '@repo/db/seed-data';