buster/packages/database/README.md

331 lines
7.2 KiB
Markdown

# 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
```bash
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
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
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
```bash
# Generate migration
pnpm db:generate
# Run migrations
pnpm db:migrate
# Push to database (development)
pnpm db:push
```
## Testing
### Unit Tests
```typescript
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
```typescript
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
```bash
# 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:
```bash
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres"
```