Skip to main content

Overview

Featul uses Drizzle ORM with PostgreSQL (via Neon serverless) for data persistence. Drizzle provides type-safe database queries with excellent TypeScript inference.

Database Provider

Neon Serverless

Featul uses Neon for PostgreSQL hosting:
  • Serverless: Auto-scaling with no connection limits
  • Branching: Database branches for development
  • Edge: Low-latency connections from serverless functions
  • Cost-effective: Pay for storage, not compute time

Connection Setup

Database client in packages/db/index.ts:1:
import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "./schema"
import { neon } from "@neondatabase/serverless"

const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
  throw new Error("DATABASE_URL is not set")
}

export const db = drizzle(neon(DATABASE_URL), { schema })
The HTTP-based driver (neon-http) is optimized for serverless environments.

Schema Organization

File Structure

Schemas are organized by domain in packages/db/schema/:
schema/
├── index.ts          # Re-exports all schemas
├── auth.ts           # Users, sessions, accounts
├── workspace.ts      # Workspaces, members, domains
├── feedback.ts       # Boards and feedback config
├── post.ts           # Posts, tags, updates
├── comment.ts        # Comments, reactions, mentions
├── vote.ts           # Votes and aggregates
├── changelog.ts      # Changelog entries
├── branding.ts       # Branding configuration
├── plan.ts           # Subscriptions and billing
├── integration.ts    # Third-party integrations
├── notra.ts          # Notra connection data
├── reservation.ts    # Workspace slug reservations
└── shared.ts         # Shared types and utilities

Schema Exports

All schemas are re-exported from packages/db/schema/index.ts:
export * from "./auth"
export * from "./workspace"
export * from "./feedback"
export * from "./post"
export * from "./comment"
export * from "./vote"
export * from "./plan"
export * from "./branding"
export * from "./reservation"
export * from "./changelog"
export * from "./integration"
export * from "./notra"
And consumed via the main export:
import { workspace, post, comment } from "@featul/db"

Schema Examples

Workspace Schema

From packages/db/schema/workspace.ts:5:
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
import { user } from './auth'

export const workspace = pgTable('workspace', {
  id: text('id').primaryKey().$defaultFn(() => `fl${createId()}`),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  domain: text('domain').notNull(),
  ownerId: text('owner_id')
    .notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  plan: text('plan', { enum: ['free', 'starter', 'professional'] })
    .notNull()
    .default('free'),
  isActive: boolean('is_active').default(true),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
  logo: text('logo'),
  primaryColor: text('primary_color').default('#3b82f6'),
  customDomain: text('custom_domain'),
  timezone: text('timezone').notNull().default('UTC'),
})

export type Workspace = typeof workspace.$inferSelect

Workspace Members

From packages/db/schema/workspace.ts:60:
export const workspaceMember = pgTable('workspace_member', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  workspaceId: text('workspace_id')
    .notNull()
    .references(() => workspace.id, { onDelete: 'cascade' }),
  userId: text('user_id')
    .notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  role: text('role', { enum: ['admin', 'member', 'viewer'] })
    .notNull()
    .default('member'),
  permissions: json('permissions')
    .$type<{
      canManageWorkspace: boolean
      canManageBilling: boolean
      canManageMembers: boolean
      canManageBoards: boolean
      canModerateAllBoards: boolean
      canConfigureBranding: boolean
    }>()
    .notNull()
    .default({
      canManageWorkspace: false,
      canManageBilling: false,
      canManageMembers: false,
      canManageBoards: false,
      canModerateAllBoards: false,
      canConfigureBranding: false,
    }),
  // ... more fields
}, (table) => ({
  workspaceMemberUnique: uniqueIndex('workspace_member_workspace_user_unique')
    .on(table.workspaceId, table.userId),
  workspaceMemberWorkspaceIdx: index('workspace_member_workspace_idx')
    .on(table.workspaceId),
}))

Custom Domain Verification

From packages/db/schema/workspace.ts:30:
export const workspaceDomain = pgTable('workspace_domain', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  workspaceId: text('workspace_id')
    .notNull()
    .references(() => workspace.id, { onDelete: 'cascade' }),
  host: text('host').notNull().unique(),
  cnameName: text('cname_name').notNull().default('feedback'),
  cnameTarget: text('cname_target').notNull().default('origin.featul.com'),
  txtName: text('txt_name').notNull(),
  txtValue: text('txt_value').notNull(),
  status: text('status', { enum: ['pending', 'verified', 'error'] })
    .notNull()
    .default('pending'),
  lastVerifiedAt: timestamp('last_verified_at'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
})

Querying the Database

Basic Queries

import { db, workspace } from "@featul/db"
import { eq } from "drizzle-orm"

// Select by slug
const [ws] = await db
  .select()
  .from(workspace)
  .where(eq(workspace.slug, "mantlz"))
  .limit(1)

// Select specific columns
const workspaces = await db
  .select({
    id: workspace.id,
    name: workspace.name,
    slug: workspace.slug,
  })
  .from(workspace)
  .where(eq(workspace.isActive, true))

Joins

import { post, board } from "@featul/db"
import { eq, and } from "drizzle-orm"

// Inner join
const posts = await db
  .select({
    id: post.id,
    title: post.title,
    boardName: board.name,
  })
  .from(post)
  .innerJoin(board, eq(post.boardId, board.id))
  .where(eq(board.workspaceId, workspaceId))

// Left join
const postsWithAuthor = await db
  .select({
    id: post.id,
    title: post.title,
    authorName: user.name,
  })
  .from(post)
  .leftJoin(user, eq(post.authorId, user.id))

Insert

// Insert single
const [created] = await db
  .insert(workspace)
  .values({
    name: "Acme Inc",
    slug: "acme",
    domain: "https://acme.com",
    ownerId: userId,
  })
  .returning()

// Insert multiple
await db.insert(board).values([
  { workspaceId: ws.id, name: "Features", slug: "features" },
  { workspaceId: ws.id, name: "Bugs", slug: "bugs" },
])

Update

await db
  .update(workspace)
  .set({ 
    name: "New Name",
    updatedAt: new Date(),
  })
  .where(eq(workspace.id, workspaceId))

Delete

await db
  .delete(workspace)
  .where(eq(workspace.id, workspaceId))
// Cascading deletes are handled by database foreign keys

Aggregations

From packages/api/src/router/workspace.ts:138:
import { sql } from "drizzle-orm"

const rows = await db
  .select({
    status: post.roadmapStatus,
    count: sql<number>`count(*)`,
  })
  .from(post)
  .innerJoin(board, eq(post.boardId, board.id))
  .where(eq(board.workspaceId, workspaceId))
  .groupBy(post.roadmapStatus)

Migrations

Configuration

Drizzle Kit config in packages/db/drizzle.config.ts:
import { defineConfig } from "drizzle-kit"
import "dotenv/config"

export default defineConfig({
  out: "./drizzle",
  schema: "./schema/index.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL ?? "",
  },
})

Generate Migration

After modifying schema files:
bun run db:generate
This creates SQL migration files in packages/db/drizzle/.

Push Schema (Development)

For rapid development, push schema directly:
bun run db:push
This syncs your schema to the database without creating migration files. Use only in development.

Run Migrations (Production)

Apply pending migrations:
bun run db:migrate
This executes all pending migration SQL files.

Drizzle Studio

Open the visual database browser:
bun run db:studio
Access at https://local.drizzle.studio

Type Safety

Inferred Types

Drizzle automatically infers types from schema:
import { workspace } from "@featul/db"

// Insert type (fields that can be provided)
type WorkspaceInsert = typeof workspace.$inferInsert

// Select type (fields returned from queries)
type WorkspaceSelect = typeof workspace.$inferSelect

Custom Types with JSON

import { json } from 'drizzle-orm/pg-core'

type Permissions = {
  canManageWorkspace: boolean
  canManageBilling: boolean
}

export const member = pgTable('member', {
  permissions: json('permissions').$type<Permissions>().notNull(),
})

Best Practices

ID Generation

Use CUID2 for unique IDs:
import { createId } from '@paralleldrive/cuid2'

id: text('id').primaryKey().$defaultFn(() => createId())
Workspaces use prefixed IDs:
id: text('id').primaryKey().$defaultFn(() => `fl${createId()}`)

Timestamps

Always include timestamps:
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),

Foreign Keys

Use cascading deletes for parent-child relationships:
workspaceId: text('workspace_id')
  .notNull()
  .references(() => workspace.id, { onDelete: 'cascade' })

Indexes

Add indexes for frequently queried columns:
(table) => ({
  workspaceIdx: index('post_workspace_idx').on(table.workspaceId),
  statusIdx: index('post_status_idx').on(table.roadmapStatus),
  createdAtIdx: index('post_created_idx').on(table.createdAt),
})

Unique Constraints

(table) => ({
  emailUnique: uniqueIndex('user_email_unique').on(table.email),
  workspaceUserUnique: uniqueIndex('member_workspace_user_unique')
    .on(table.workspaceId, table.userId),
})

Advanced Patterns

Conditional Queries

const query = db.select().from(post)

if (boardId) {
  query.where(eq(post.boardId, boardId))
}

if (status) {
  query.where(eq(post.roadmapStatus, status))
}

const results = await query

Transactions

await db.transaction(async (tx) => {
  const [workspace] = await tx
    .insert(workspace)
    .values({ name, slug, ownerId })
    .returning()

  await tx.insert(workspaceMember).values({
    workspaceId: workspace.id,
    userId: ownerId,
    role: 'admin',
  })
})

Prepared Statements

const getWorkspaceBySlug = db
  .select()
  .from(workspace)
  .where(eq(workspace.slug, sql.placeholder('slug')))
  .prepare('get_workspace_by_slug')

// Execute prepared statement
const [ws] = await getWorkspaceBySlug.execute({ slug: 'mantlz' })

Performance Tips

Select Only Needed Columns

// Bad
const posts = await db.select().from(post)

// Good
const posts = await db
  .select({ id: post.id, title: post.title })
  .from(post)

Use Indexes

Add indexes for:
  • Foreign keys (workspaceId, userId, etc.)
  • Frequently filtered columns (status, createdAt)
  • Unique constraints (email, slug)

Batch Operations

// Insert many at once
await db.insert(post).values(postsToCreate)

// Update many with single query
await db
  .update(post)
  .set({ status: 'archived' })
  .where(eq(post.boardId, boardId))

Limit Results

const recentPosts = await db
  .select()
  .from(post)
  .orderBy(desc(post.createdAt))
  .limit(20)

Troubleshooting

Type Errors

If types are not updating:
  1. Ensure schema files are saved
  2. Restart TypeScript server
  3. Check imports are from @featul/db

Migration Conflicts

If migrations fail:
  1. Check database state with bun run db:studio
  2. Review migration SQL in drizzle/ directory
  3. Manually fix database if needed
  4. Regenerate migrations from clean state

Connection Issues

Verify DATABASE_URL is set:
echo $DATABASE_URL
Ensure it’s in .env.local for local development.

Build docs developers (and LLMs) love