Drizzle ORM Guide

Overview

Drizzle ORM provides type-safe database queries alongside the Supabase client. It solves three key pain points:

  1. Type casting - No more as unknown as casts for nested relations
  2. Transactions - Atomic multi-table operations with rollback
  3. Complex joins - Relational queries with automatic typing

When to Use Each Client

Use CaseClientReason
Auth operationsSupabaseNative auth integration
Storage uploadsSupabaseS3-compatible API
RLS-dependent readsSupabasePolicies enforce access
Complex joinsDrizzleType-safe relations
TransactionsDrizzleAtomic writes
Type-safe writesDrizzleFull inference
Batch operationsDrizzleEfficient bulk inserts

Setup

Connection String

Get from: Supabase Dashboard → Settings → Database → Connection string (Transaction mode)

# .env.local
DATABASE_URL=postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres

Important: Use port 6543 (Supavisor transaction mode) for connection pooling.

Files

packages/db/src/drizzle/
├── client.ts        # getDrizzleClient(), connection pooling
├── schema.ts        # Table definitions, relations, enums (32+ tables)
├── queries.ts       # 49 type-safe query functions
├── seed-queries.ts  # Seed pipeline queries
└── feature-flags.ts # Feature flag management

Schema Definition

Tables are defined in schema.ts using Drizzle's builder API:

import { pgTable, uuid, text, timestamp, jsonb, real } from 'drizzle-orm/pg-core'

export const factRecords = pgTable('fact_records', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull(),
  context: text('context'),
  facts: jsonb('facts').notNull(),           // Key-value pairs per schema
  notabilityScore: real('notability_score'),
  status: factRecordStatusEnum('status').default('pending').notNull(),
  topicCategoryId: uuid('topic_category_id').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
})

Relations

Define relationships for the relational query builder:

import { relations } from 'drizzle-orm'

export const factRecordsRelations = relations(factRecords, ({ one, many }) => ({
  topicCategory: one(topicCategories, {
    fields: [factRecords.topicCategoryId],
    references: [topicCategories.id],
  }),
  challengeContent: many(factChallengeContent),
  cardInteractions: many(cardInteractions),
}))

Query Patterns

Basic Queries

import { getDrizzleClient } from './client'
import { eq, and, desc } from 'drizzle-orm'
import { trackedUrls } from './schema'

// Find one
const url = await db.query.trackedUrls.findFirst({
  where: eq(trackedUrls.id, id),
})

// Find many with filters
const urls = await db.query.trackedUrls.findMany({
  where: and(
    eq(trackedUrls.userId, userId),
    eq(trackedUrls.isActive, true)
  ),
  orderBy: [desc(trackedUrls.createdAt)],
  limit: 20,
})

Relational Queries

// Get tracked URLs with brand site (no type casting!)
const urlsWithBrand = await db.query.trackedUrls.findMany({
  where: eq(trackedUrls.userId, userId),
  with: {
    brandSite: true,
  },
})

// Nested relations
const changesWithDetails = await db.query.urlChanges.findMany({
  where: eq(urlChanges.trackedUrlId, trackedUrlId),
  with: {
    trackedUrl: {
      with: { brandSite: true },
    },
    summaries: true,
  },
})

Inserts

// Single insert with returning
const [created] = await db
  .insert(trackedUrls)
  .values({
    userId,
    url: input.url,
    canonicalUrl: input.canonicalUrl,
  })
  .returning()

// Upsert with conflict handling
const [result] = await db
  .insert(brandSites)
  .values({ canonicalDomain, source: 'user_added' })
  .onConflictDoUpdate({
    target: brandSites.canonicalDomain,
    set: { updatedAt: new Date() },
  })
  .returning()

Updates

await db
  .update(trackedUrls)
  .set({
    title: input.title,
    updatedAt: new Date(),
  })
  .where(eq(trackedUrls.id, id))

Transactions

// Atomic multi-table operation
await db.transaction(async (tx) => {
  // Insert URL check
  const [urlCheck] = await tx
    .insert(urlChecks)
    .values({ trackedUrlId, contentHash, ... })
    .returning()

  // Insert change if detected
  if (hasChange) {
    await tx.insert(urlChanges).values({
      urlCheckId: urlCheck.id,
      trackedUrlId,
      ...
    })
  }

  // Update tracked URL
  await tx
    .update(trackedUrls)
    .set({ lastCheckedAt: new Date() })
    .where(eq(trackedUrls.id, trackedUrlId))
})

Batch Operations

// Batch insert
const results = await db
  .insert(urlChecks)
  .values(checks.map(c => ({ ... })))
  .returning()

// Batch insert with conflict skip
await db
  .insert(notificationDeliveries)
  .values(deliveries)
  .onConflictDoNothing({ target: notificationDeliveries.dedupeKey })

Available Queries

The queries.ts file exports 49 type-safe functions organized by domain:

User & Subscriptions

  • getUserSubscriptionWithPlan(userId)
  • getUserEffectivePlan(userId)
  • getFreePlan()
  • getAllActivePlans()
  • getNotificationPreferences(userId)

Domain Management

  • getOrCreateDomain(canonicalDomain)
  • getDomainById(id)
  • getDomainComplexityCache(canonicalDomain)
  • updateDomainComplexityCache(...)
  • getPendingDomainsForReview(options)
  • approveDomain(domainId, adminUserId)
  • rejectDomain(domainId, adminUserId, reason)
  • getOrCreateDomainWithVerification(...)
  • getVisibleDomainsForUser(userId)
  • linkDomainToBrand(domainId, brandId)
  • autoLinkOrCreateBrandForDomain(domainId)

Brand Categories

  • getBrandCategories(brandId)
  • getBrandPrimaryCategory(brandId)
  • getBrandsInCategory(categorySlug, includeChildren)
  • assignBrandCategory(brandId, categorySlug, isPrimary)
  • removeBrandCategory(brandId, categorySlug)
  • getL1CategoriesWithCounts()

AI Cost Tracking

  • recordAICost(params) — records per-call cost
  • getDailyCostBreakdown(date) — cost by model and feature
  • getDailyAnthropicSpend(date) — for spend cap enforcement
  • getDailyGoogleSpend(date)
  • getDailyCostTotals(startDate, endDate)

Challenge Content

  • getChallengeContentById(id)
  • getChallengeContentForFact(factRecordId)
  • insertChallengeContent(content)
  • updateChallengeContentImage(id, imageUrl)

Challenge Groups & Progress

  • getGroupProgress(userId, factRecordId)
  • upsertGroupProgress(userId, factRecordId)
  • updateGroupProgress(id, updates)
  • getChallengesByGroup(challengeGroupId)
  • setFactChallengeGroupId(factRecordId, groupId)

See packages/db/src/drizzle/seed-queries.ts for additional seed pipeline queries and feature-flags.ts for feature flag management.

Adding New Tables

  1. Create SQL migration in supabase/migrations/
  2. Add schema in packages/db/src/drizzle/schema.ts:
    export const newTable = pgTable('new_table', { ... })
    export const newTableRelations = relations(newTable, ({ one, many }) => ({ ... }))
    
  3. Add queries in packages/db/src/drizzle/queries.ts
  4. Run type check: bun run typecheck --filter=@eko/db

Troubleshooting

"DATABASE_URL is not configured"

Add to .env.local:

DATABASE_URL=postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres

"prepared statement already exists"

Use transaction mode pooler (port 6543) with prepare: false in client config.

Type errors after schema change

  1. Run bun run db:types to regenerate types
  2. Restart TypeScript language server

Related docs: