|
||
---|---|---|
.. | ||
.cursor | ||
drizzle | ||
scripts | ||
src | ||
supabase | ||
.env.example | ||
.gitignore | ||
CLAUDE.md | ||
README.md | ||
biome.json | ||
drizzle.config.ts | ||
package.json | ||
tsconfig.json | ||
turbo.json | ||
vitest.config.ts |
README.md
@buster/database
A TypeScript database library using Drizzle ORM with PostgreSQL connection pooling. This library provides a centralized database connection and schema management for the Buster application.
Architecture & Patterns
Schema Definition (src/schema.ts
)
The database schema is defined using Drizzle ORM. All table definitions and modifications should be made in this file.
JSONB Columns with Type Safety
For JSONB columns, we use a specific pattern to ensure type safety:
// 1. Define the type in src/schema-types/[entity].ts
export type OrganizationColorPalette = {
id: string | number;
colors: string[]; // Hex color codes
};
export type OrganizationColorPalettes = OrganizationColorPalette[];
// 2. Export from src/schema-types/index.ts
export * from './organization';
// 3. Use in schema.ts with .$type<T>()
import type { OrganizationColorPalettes } from './schema-types';
export const organizations = pgTable('organizations', {
// ... other columns
organizationColorPalettes: jsonb('organization_color_palettes')
.$type<OrganizationColorPalettes>()
.default(sql`'[]'::jsonb`)
.notNull(),
});
Query Organization (src/queries/
)
ALL DATABASE INTERACTIONS MUST GO THROUGH THE QUERIES FOLDER. Direct database access outside of this folder is not allowed.
Directory Structure
queries/
├── shared-types/ # Reusable query utilities and types
│ ├── pagination.types.ts
│ ├── with-pagination.ts
│ └── index.ts
├── organizations/ # Domain-specific queries
│ ├── organizations.ts
│ ├── update-organization.ts
│ └── index.ts
├── users/
│ ├── user.ts
│ ├── users-to-organizations.ts
│ └── index.ts
└── index.ts # Exports all query modules
Query Function Pattern
Every query function follows this pattern:
import { z } from 'zod';
import { type InferSelectModel } from 'drizzle-orm';
import { db } from '../../connection';
import { tableName } from '../../schema';
// 1. Type inference from schema
type TableType = InferSelectModel<typeof tableName>;
// 2. Input validation schema using Zod
const GetSomethingInputSchema = z.object({
id: z.string().uuid('ID must be a valid UUID'),
// ... other fields
});
type GetSomethingInput = z.infer<typeof GetSomethingInputSchema>;
// 3. Query function with proper error handling
export async function getSomething(params: GetSomethingInput): Promise<TableType> {
try {
// Validate input
const validated = GetSomethingInputSchema.parse(params);
// Execute query
const result = await db
.select()
.from(tableName)
.where(/* conditions */)
.limit(1);
if (!result.length || !result[0]) {
throw new Error('Resource not found');
}
return result[0];
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(
`Invalid input: ${error.errors.map((e) => e.message).join(', ')}`
);
}
throw error;
}
}
Pagination Pattern
For paginated queries, use the shared pagination utilities:
import { withPagination, createPaginatedResponse, type PaginatedResponse } from '../shared-types';
const GetUsersInputSchema = z.object({
page: z.number().optional().default(1),
page_size: z.number().optional().default(250),
// ... other filters
});
export async function getUsers(params: GetUsersInput): Promise<PaginatedResponse<User>> {
const { page, page_size } = GetUsersInputSchema.parse(params);
// Build base query
const baseQuery = db
.select()
.from(users)
.where(/* conditions */)
.$dynamic(); // Required for withPagination
// Apply pagination
const getData = withPagination(baseQuery, users.name, page, page_size);
// Get total count
const getTotal = db
.select({ count: count() })
.from(users)
.where(/* same conditions */);
// Execute in parallel
const [data, totalResult] = await Promise.all([getData, getTotal]);
return createPaginatedResponse({
data,
page,
page_size,
total: totalResult[0]?.count ?? 0,
});
}
Type Safety Patterns
Using Pick for Specific Types
When you need only specific fields from multiple tables:
// Type-safe subset using Pick
type OrganizationUser = Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'> &
Pick<UserToOrganization, 'role' | 'status'>;
Update Operations
For update operations, validate input and build update objects dynamically:
const UpdateOrganizationInputSchema = z.object({
organizationId: z.string().uuid(),
organizationColorPalettes: z.array(ColorPaletteSchema).optional(),
});
export async function updateOrganization(params: UpdateOrganizationInput): Promise<void> {
const validated = UpdateOrganizationInputSchema.parse(params);
// Build update data dynamically
const updateData: Partial<Organization> = {
updatedAt: new Date().toISOString(),
};
if (validated.organizationColorPalettes !== undefined) {
updateData.organizationColorPalettes = validated.organizationColorPalettes;
}
await db
.update(organizations)
.set(updateData)
.where(eq(organizations.id, validated.organizationId));
}
Best Practices
- Always use Zod for input validation - Parse inputs before using them in queries
- Export types alongside functions - Make types available for consumers
- Use InferSelectModel for type safety - Derive types from schema definitions
- Handle errors explicitly - Differentiate between validation errors and database errors
- Use transactions for multi-step operations - Ensure data consistency
- Parallel queries when possible - Use
Promise.all()
for independent queries - Consistent naming - Use
get
,create
,update
,delete
prefixes
Adding New Queries
- Create a new folder under
queries/
for your domain if it doesn't exist - Create query files following the pattern above
- Export all functions from an
index.ts
in your domain folder - Add the export to the main
queries/index.ts
Running Migrations
# Generate migration from schema changes
pnpm drizzle-kit generate:pg
# Apply migrations
pnpm run migrations
# Push schema changes directly (development only)
pnpm drizzle-kit push:pg
Testing
Query functions should be tested with integration tests that use a test database. Unit tests can mock the database connection for isolated testing.
// Example test structure
describe('getUserOrganization', () => {
it('should return user organization data', async () => {
const result = await getUserOrganization({ userId: 'valid-uuid' });
expect(result).toMatchObject({
organizationId: expect.any(String),
role: expect.any(String),
});
});
it('should throw on invalid UUID', async () => {
await expect(getUserOrganization({ userId: 'invalid' }))
.rejects.toThrow('User ID must be a valid UUID');
});
});