Database Architecture

This document describes the database implementation using Drizzle ORM with PostgreSQL in the AgentPress monorepo.

Overview

AgentPress uses:

  • PostgreSQL 18.1 with pgvector extension
  • Drizzle ORM for type-safe database operations
  • 30+ tables organized by domain

Configuration

Drizzle Config

File: packages/api/drizzle.config.ts

export default defineConfig({
  schema: "./src/db",
  out: "./migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  strict: true,
  casing: "camelCase",
});

Database Connection

File: packages/api/src/db/config.ts

Two database clients are configured:

// Main client for OLTP operations
export const db = drizzle(client, { schema });

// Analytics client with 30-second timeout
export const analyticsDb = drizzle(analyticsClient, { schema });

Environment Variables

DATABASE_URL=postgresql://postgres:postgres@localhost:5555/AgentPress

Schema Organization

Location: packages/api/src/db/schema/

Core Tables

TablePurpose
usersUser accounts and authentication
orgsOrganizations/workspaces
org_membersUser-organization membership
sessionsUser session management
invitationsUser invitations
passwordResetsPassword reset tokens

Agent & Conversation Tables

TablePurpose
agentsAI agent definitions with model config
agentVersionsAgent version history
threadsConversations with message counts
messagesIndividual messages with parts
personasAgent persona configurations

Knowledge Base Tables

TablePurpose
knowledgeBasesKnowledge base definitions
knowledgeBaseFilesFile-based sources
knowledgeBaseFreeformTextsFreeform text entries
knowledgeBaseTablesStructured table data
knowledgeBaseWebUrlsWeb URLs with crawling
embeddingsVector embeddings (halfvec)

Tool & Task Tables

TablePurpose
toolsTool definitions with schemas
tasksTask execution records
schedulesRecurring/one-time task templates

Schema Definitions

Users Table

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: varchar("email", { length: 255 }).unique().notNull(),
  username: varchar("username", { length: 255 }).unique(),
  firstName: varchar("first_name", { length: 255 }),
  lastName: varchar("last_name", { length: 255 }),

  // OAuth fields
  githubId: text("github_id"),
  googleId: text("google_id"),

  // Password (bcrypt hash)
  password: text("password"),

  // Role & Provider
  role: userRoleEnum("role").notNull().default(EUserRole.GUEST),
  provider: authProviderEnum("provider").notNull().default(EProvider.EMAIL),

  // Timestamps
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Agents Table

export const agents = pgTable("agents", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: varchar("name", { length: 255 }).notNull(),
  description: text("description"),
  prompt: text("prompt"),

  // Model Configuration
  primaryModelProvider: varchar("primary_model_provider", { length: 255 }),
  primaryModel: varchar("primary_model", { length: 255 }),
  primaryModelTemperature: real("primary_model_temperature"),

  // Tool & RAG Configuration (JSONB arrays)
  toolsEnabled: jsonb("tools_enabled").$type<string[]>(),
  ragEnabled: jsonb("rag_enabled").$type<string[]>(),

  // Status
  enabled: boolean("enabled").default(true),
  archived: boolean("archived").default(false),
});

Embeddings Table (Vector)

export const embeddings = pgTable("embeddings", {
  id: uuid("id").primaryKey().defaultRandom(),
  resourceId: uuid("resource_id").references(() => knowledgeBaseFiles.id, {
    onDelete: "cascade",
  }),

  // Vector column with HNSW index
  embedding: halfvec("embedding", { dimensions: 3072 }),

  metadata: jsonb("metadata"),
});

// HNSW index for similarity search
// CREATE INDEX ON embeddings USING hnsw (embedding halfvec_cosine_ops)

Query Patterns

Basic CRUD

import { eq } from "drizzle-orm";
import { agents } from "../db";
import { db } from "../db/config";

export const agentRepo = {
  getAll: async () => {
    return db.select().from(agents).orderBy(agents.createdAt);
  },

  getById: async (id: string) => {
    return db
      .select()
      .from(agents)
      .where(eq(agents.id, id))
      .then((rows) => rows[0]);
  },

  create: async (data: IAgent) => {
    return db
      .insert(agents)
      .values({ ...data, createdAt: new Date(), updatedAt: new Date() })
      .returning();
  },

  update: async (data: Partial<IAgent>) => {
    const { id, ...rest } = data;
    return db
      .update(agents)
      .set({ ...rest, updatedAt: new Date() })
      .where(eq(agents.id, id!))
      .returning()
      .then((rows) => rows[0]);
  },

  delete: async (id: string) => {
    return db.delete(agents).where(eq(agents.id, id)).returning();
  },
};

Filtering with Pagination

import { and, desc, eq, gte, ilike } from "drizzle-orm";

getPaginated: async (options: GetOptions) => {
  let conditions: SQL[] = [eq(threads.userId, options.user.id)];

  if (options.search) {
    conditions.push(ilike(threads.name, `%${options.search}%`));
  }

  if (options.agentId) {
    conditions.push(eq(threads.agentId, options.agentId));
  }

  const offset = ((options.page || 1) - 1) * (options.pageSize || 20);

  return db
    .select()
    .from(threads)
    .where(and(...conditions))
    .orderBy(desc(threads.createdAt))
    .limit(options.pageSize || 20)
    .offset(offset);
};

Transactions

async createUser(data: UserData): Promise<IUser | undefined> {
  const now = new Date();

  return db.transaction(async (tx) => {
    // Create user
    const [createdUser] = await tx
      .insert(users)
      .values({ ...data, createdAt: now, updatedAt: now })
      .returning();

    // Add to default org
    const defaultOrg = await this.getDefaultOrg();
    if (createdUser && defaultOrg) {
      await tx.insert(orgMembers).values({
        userId: createdUser.id,
        orgId: defaultOrg.id,
      });
    }

    return createdUser;
  });
}

Indexes

Composite Indexes

// Thread filtering with date ordering
index("threads_user_id_created_at_idx").on(threads.userId, threads.createdAt);

// Message ordering within threads
index("messages_thread_id_created_at_idx").on(
  messages.threadId,
  messages.createdAt,
);

Full-Text Search Index

CREATE INDEX messages_text_content_gin_idx
ON messages USING gin(to_tsvector('english', text_content));

Vector Index (HNSW)

CREATE INDEX ON embeddings USING hnsw (embedding halfvec_cosine_ops);

Migrations

Location

  • Migrations: apps/api/migrations/
  • Format: SQL files with timestamp prefix
  • Tracking: __drizzle_migrations__ table

Commands

cd apps/api

# Generate migration from schema changes
bun db:generate

# Apply pending migrations
bun db:migrate

# Push schema directly (development only)
bun db:push

# Open Drizzle Studio
bun db:studio

Repository Pattern

Repositories provide data access abstraction:

Location: packages/api/src/repositories/

repositories/
├── agentRepo.ts
├── analyticsRepo.ts
├── embeddingsRepo.ts
├── feedbackRepo.ts
├── knowledgeBasesRepo.ts
├── messages/
│   ├── messageRepo.ts
│   └── cachedMessageRepo.ts
├── threads/
│   ├── threadRepo.ts
│   └── cachedThreadRepo.ts
├── tasksRepo.ts
└── userRepo.ts

Cached Repository Pattern

// cachedThreadRepo.ts
export const cachedThreadRepository = {
  ...threadRepository,

  async getById(id: string) {
    const cached = await redis.get(`thread:${id}`);
    if (cached) return JSON.parse(cached);

    const thread = await threadRepository.getById(id);
    await redis.setex(`thread:${id}`, 300, JSON.stringify(thread));
    return thread;
  },
};

Best Practices

  1. Always use transactions for multi-table operations
  2. Add indexes for frequently filtered columns
  3. Use JSONB for flexible schema fields
  4. Denormalize read-heavy aggregated data
  5. Separate analytics client for long-running queries
  6. Test migrations in development before production
  7. Use returning() to get inserted/updated rows

On this page