buster/packages/database/README.md

7.2 KiB

Database Package

The database layer for the Buster monorepo. This package owns ALL database interactions - no other package or app should use Drizzle ORM directly.

Installation

pnpm add @buster/database

Overview

@buster/database is the single source of truth for:

  • Database schema definitions
  • All database queries
  • Migrations
  • Database utilities and helpers

Cardinal Rule

🚨 ALL DATABASE INTERACTIONS MUST GO THROUGH THIS PACKAGE 🚨

No other package or app should:

  • Import Drizzle directly
  • Write raw SQL queries
  • Access database tables directly
  • Create database connections

Architecture

Apps/Packages → @buster/database → PostgreSQL (via Drizzle)
                      ↓
                   Queries folder
                (All DB operations)

File Organization

database/
├── src/
│   ├── queries/
│   │   ├── users/
│   │   │   ├── create-user.ts
│   │   │   ├── get-user.ts
│   │   │   ├── update-user.ts
│   │   │   ├── delete-user.ts
│   │   │   └── index.ts
│   │   ├── organizations/
│   │   ├── chats/
│   │   └── index.ts
│   ├── schema/
│   │   ├── users.ts
│   │   ├── organizations.ts
│   │   └── index.ts
│   └── index.ts

Usage

Basic Query Example

import { getUser, createUser, updateUser } from '@buster/database';

// Get a user
const user = await getUser({ 
  userId: '123',
  includeDeleted: false 
});

// Create a user
const newUser = await createUser({
  email: 'user@example.com',
  name: 'John Doe',
  organizationId: 'org-123'
});

// Update a user (upsert)
const updated = await updateUser({
  userId: '123',
  name: 'Jane Doe',
  updatedAt: new Date()
});

Query Pattern

Every query function must:

  1. Accept typed parameters (validated with Zod)
  2. Return typed results
  3. Handle errors appropriately
  4. Be pure and testable
import { z } from 'zod';

const GetUserParamsSchema = z.object({
  userId: z.string().uuid().describe('User ID to fetch'),
  includeDeleted: z.boolean().optional().describe('Include soft-deleted users')
});

type GetUserParams = z.infer<typeof GetUserParamsSchema>;

export async function getUser(params: GetUserParams) {
  const validated = GetUserParamsSchema.parse(params);
  
  const query = db
    .select()
    .from(users)
    .where(eq(users.id, validated.userId));
  
  if (!validated.includeDeleted) {
    query.where(isNull(users.deletedAt));
  }
  
  const result = await query;
  return result[0] || null;
}

Key Patterns

Soft Deletes

We NEVER hard delete records:

export async function deleteUser(params: DeleteUserParams) {
  const validated = DeleteUserParamsSchema.parse(params);
  
  // Soft delete by setting deletedAt
  const result = await db
    .update(users)
    .set({ 
      deletedAt: new Date(),
      updatedAt: new Date()
    })
    .where(eq(users.id, validated.userId))
    .returning();
  
  return result[0];
}

Upserts

Prefer upserts over separate insert/update logic:

export async function upsertUser(params: UpsertUserParams) {
  const validated = UpsertUserParamsSchema.parse(params);
  
  const result = await db
    .insert(users)
    .values(validated)
    .onConflictDoUpdate({
      target: users.id,
      set: {
        ...validated,
        updatedAt: new Date()
      }
    })
    .returning();
  
  return result[0];
}

Transactions

Use transactions for multi-step operations:

export async function createUserWithOrg(params: CreateUserWithOrgParams) {
  return await db.transaction(async (tx) => {
    const user = await tx.insert(users).values(params.user).returning();
    const org = await tx.insert(organizations).values(params.organization).returning();
    
    await tx.insert(organizationMembers).values({
      userId: user[0].id,
      orgId: org[0].id,
      role: 'owner'
    });
    
    return { user: user[0], organization: org[0] };
  });
}

Pagination

export async function getUsersPaginated(params: PaginationParams) {
  const { page = 1, pageSize = 20 } = params;
  const offset = (page - 1) * pageSize;
  
  const [items, [{ count }]] = await Promise.all([
    db.select().from(users).limit(pageSize).offset(offset),
    db.select({ count: count() }).from(users)
  ]);
  
  return {
    items,
    total: count,
    page,
    pageSize,
    hasMore: offset + items.length < count
  };
}

Schema Definition

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

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  isActive: boolean('is_active').default(true).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  deletedAt: timestamp('deleted_at') // For soft deletes
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

Migrations

# Generate migration
pnpm db:generate

# Run migrations
pnpm db:migrate

# Push to database (development)
pnpm db:push

Testing

Unit Tests

describe('getUser', () => {
  it('should return user when exists', async () => {
    const mockUser = { id: '123', email: 'test@example.com' };
    jest.spyOn(db, 'select').mockResolvedValue([mockUser]);
    
    const result = await getUser({ userId: '123' });
    expect(result).toEqual(mockUser);
  });
  
  it('should filter soft-deleted users by default', async () => {
    const result = await getUser({ userId: '123' });
    // Verify deletedAt filter was applied
  });
});

Integration Tests

describe('users.int.test.ts', () => {
  beforeEach(async () => {
    await db.delete(users); // Clean state
  });
  
  it('should create and retrieve user', async () => {
    const created = await createUser({
      email: 'test@example.com',
      name: 'Test User'
    });
    
    const retrieved = await getUser({ userId: created.id });
    expect(retrieved).toEqual(created);
  });
});

Best Practices

DO:

  • Organize queries by table/domain
  • Validate all inputs with Zod
  • Use soft deletes (deletedAt field)
  • Prefer upserts over separate insert/update
  • Return typed results
  • Use transactions for multi-step operations
  • Create focused, composable query functions

DON'T:

  • Use Drizzle outside this package
  • Write raw SQL unless absolutely necessary
  • Hard delete records
  • Create queries in random locations
  • Mix business logic with database queries
  • Forget to handle soft deletes in queries

Development

# Build
turbo build --filter=@buster/database

# Test
turbo test:unit --filter=@buster/database
turbo test:integration --filter=@buster/database

# Lint
turbo lint --filter=@buster/database

# Database operations
pnpm db:generate    # Generate migrations
pnpm db:migrate     # Run migrations
pnpm db:push        # Push schema to database
pnpm db:studio      # Open Drizzle Studio

Local Database Access

For integration testing:

psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres"