diff --git a/apps/server/src/types/hono.types.ts b/apps/server/src/types/hono.types.ts index 3d9130c6c..2c401f027 100644 --- a/apps/server/src/types/hono.types.ts +++ b/apps/server/src/types/hono.types.ts @@ -4,12 +4,22 @@ import type { User } from '@supabase/supabase-js'; declare module 'hono' { interface ContextVariableMap { - supabaseUser: User; - busterUser: BusterUser; - // This is the user's organization ID and role. It is set in the requireOrganizationAdmin and requireOrganization middleware. YOU MUST SET THIS IN THE MIDDLEWARE IF YOU USE THIS CONTEXT VARIABLE. - userOrganizationInfo: { - organizationId: string; - role: OrganizationRole; + /** + * The authenticated Supabase user. This object is readonly to prevent accidental mutation. + */ + readonly supabaseUser: User; + /** + * The Buster user object from the database. This object is readonly to ensure immutability. + */ + readonly busterUser: BusterUser; + /** + * This is the user's organization ID and role. It is set in the requireOrganizationAdmin and requireOrganization middleware. + * YOU MUST SET THIS IN THE MIDDLEWARE IF YOU USE THIS CONTEXT VARIABLE. + * The object and its properties are readonly to prevent mutation. + */ + readonly userOrganizationInfo: { + readonly organizationId: string; + readonly role: OrganizationRole; }; } } diff --git a/apps/server/src/utils/admin.ts b/apps/server/src/utils/admin.ts index e5fac8d51..4a1d724f7 100644 --- a/apps/server/src/utils/admin.ts +++ b/apps/server/src/utils/admin.ts @@ -1,5 +1,6 @@ import type { OrganizationRole } from '@buster/server-shared/organization'; +import { OrganizationRoleEnum } from '@buster/server-shared/organization'; export const isOrganizationAdmin = (role: OrganizationRole) => { - return role === 'workspace_admin' || role === 'data_admin'; + return role === OrganizationRoleEnum.workspace_admin || role === OrganizationRoleEnum.data_admin; }; diff --git a/packages/database/.cursor/global.mdc b/packages/database/.cursor/global.mdc index 47ff7eb64..db7352a7c 100644 --- a/packages/database/.cursor/global.mdc +++ b/packages/database/.cursor/global.mdc @@ -1,40 +1,177 @@ ---- -globs: src/* -alwaysApply: false -description: Global rules for the database directory ---- ## Overview The database package provides centralized database utilities, query builders, and shared types for consistent data access patterns across the application. +## 🚨 CARDINAL RULE + +**ALL DATABASE INTERACTIONS MUST GO THROUGH THE QUERIES FOLDER** + +Direct database access outside of the `src/queries/` directory is strictly prohibited. This ensures: +- Consistent error handling +- Input validation with Zod +- Proper type safety +- Centralized business logic +- Easier testing and maintenance + ## Directory Structure ``` database/ ├── src/ -│ ├── queries/ -│ │ ├── shared-types/ # Reusable query utilities and types +│ ├── queries/ # ALL database interactions go here +│ │ ├── shared-types/ # Reusable query utilities and types │ │ │ ├── pagination.types.ts # Pagination type definitions │ │ │ ├── with-pagination.ts # Pagination query utilities -│ │ │ └── index.ts # Exports all shared types -│ │ ├── chats/ # Chat-related queries -│ │ │ ├── chats.ts # Chat query functions -│ │ │ └── index.ts # Exports chat queries -│ │ ├── users/ # User-related queries -│ │ │ ├── user.ts # User query functions +│ │ │ └── index.ts # Exports all shared types +│ │ ├── chats/ # Chat-related queries +│ │ │ ├── chats.ts # Chat query functions +│ │ │ └── index.ts # Exports chat queries +│ │ ├── users/ # User-related queries +│ │ │ ├── user.ts # User query functions │ │ │ ├── users-to-organizations.ts │ │ │ ├── users-to-organizations.test.ts -│ │ │ └── index.ts # Exports user queries -│ │ ├── organizations/ # Organization-related queries -│ │ ├── messages/ # Message-related queries -│ │ ├── assets/ # Asset-related queries -│ │ ├── dataSources/ # Data source queries -│ │ ├── metadata/ # Metadata queries -│ │ └── index.ts # Exports all query modules -│ ├── schema/ # Database schema definitions -│ └── index.ts # Main package exports -└── drizzle/ # Migration files +│ │ │ └── index.ts # Exports user queries +│ │ ├── organizations/ # Organization-related queries +│ │ ├── messages/ # Message-related queries +│ │ ├── assets/ # Asset-related queries +│ │ ├── dataSources/ # Data source queries +│ │ ├── metadata/ # Metadata queries +│ │ └── index.ts # Exports all query modules +│ ├── schema.ts # Database schema definitions +│ ├── schema-types/ # TypeScript types for JSONB columns +│ ├── connection.ts # Database connection management +│ └── index.ts # Main package exports +└── drizzle/ # Migration files +``` + +## Core Query Patterns + +### 1. Input Validation with Zod + +**Every query function MUST validate its inputs using Zod:** + +```typescript +// ✅ ALWAYS define input schema +const GetUserInputSchema = z.object({ + userId: z.string().uuid('User ID must be a valid UUID'), + includeDeleted: z.boolean().optional().default(false), +}); + +type GetUserInput = z.infer; + +// ✅ ALWAYS validate at the start of the function +export async function getUser(params: GetUserInput) { + const validated = GetUserInputSchema.parse(params); + // Use validated input, not raw params +} +``` + +### 2. Error Handling Pattern + +**Always differentiate between validation errors and database errors:** + +```typescript +export async function queryFunction(params: Input) { + try { + // Validate input + const validated = InputSchema.parse(params); + + // Execute query + const result = await db.select()...; + + if (!result.length || !result[0]) { + throw new Error('Resource not found'); + } + + return result[0]; + } catch (error) { + // ✅ Handle Zod validation errors separately + if (error instanceof z.ZodError) { + throw new Error( + `Invalid input: ${error.errors.map((e) => e.message).join(', ')}` + ); + } + + // ✅ Log with context + console.error('Error in queryFunction:', { + params, + error: error instanceof Error ? error.message : error, + }); + + // ✅ Re-throw known errors + if (error instanceof Error) { + throw error; + } + + // ✅ Wrap unknown errors + throw new Error('Failed to execute query'); + } +} +``` + +### 3. Naming Conventions + +**Use consistent prefixes for all query functions:** + +- `get*` - For SELECT queries (single or multiple records) +- `create*` - For INSERT operations +- `update*` - For UPDATE operations +- `delete*` - For DELETE operations (soft or hard) +- `upsert*` - For INSERT ... ON CONFLICT UPDATE operations + +```typescript +// ✅ Good naming +export async function getUser(id: string) {} +export async function getUsersByOrganization(orgId: string) {} +export async function createUser(data: CreateUserInput) {} +export async function updateUser(id: string, data: UpdateUserInput) {} +export async function deleteUser(id: string) {} +``` + +### 4. Update Operations Pattern + +**Build update objects dynamically to handle optional fields:** + +```typescript +const UpdateOrganizationInputSchema = z.object({ + organizationId: z.string().uuid(), + name: z.string().optional(), + settings: z.object({...}).optional(), + colorPalettes: z.array(ColorPaletteSchema).optional(), +}); + +export async function updateOrganization(params: UpdateOrganizationInput) { + const validated = UpdateOrganizationInputSchema.parse(params); + + // ✅ Build update data dynamically + const updateData: Partial = { + updatedAt: new Date().toISOString(), + }; + + // ✅ Only include fields that were provided + if (validated.name !== undefined) { + updateData.name = validated.name; + } + + if (validated.settings !== undefined) { + updateData.settings = validated.settings; + } + + if (validated.colorPalettes !== undefined) { + updateData.organizationColorPalettes = validated.colorPalettes; + } + + // ✅ Ensure we have something to update + if (Object.keys(updateData).length === 1) { // Only updatedAt + throw new Error('No fields to update'); + } + + await db + .update(organizations) + .set(updateData) + .where(eq(organizations.id, validated.organizationId)); +} ``` ## Query Organization @@ -457,3 +594,110 @@ Don't add to `shared-types/` when: - ❌ The utility is domain-specific to one feature - ❌ It's a simple wrapper without added value - ❌ The pattern is unlikely to be reused # Database Package Cursor Rules + +Don't add to `shared-types/` when: +- ❌ The utility is domain-specific to one feature +- ❌ It's a simple wrapper without added value +- ❌ The pattern is unlikely to be reused # Database Package Cursor Rules + +--- + +# Database Migrations + +## Running Migrations with pnpm + +```bash +# Generate migration from schema changes +pnpm drizzle-kit generate:pg + +# Apply migrations to database +pnpm run migrations + +# Push schema changes directly (development only) +pnpm drizzle-kit push:pg + +# Drop all tables (careful!) +pnpm drizzle-kit drop + +# Check migration status +pnpm drizzle-kit check:pg +``` + +## Migration Best Practices + +1. **Always generate migrations for schema changes** - Don't use push:pg in production +2. **Review generated SQL** - Check the generated migration files before applying +3. **Test migrations** - Run migrations on a test database first +4. **Keep migrations small** - One logical change per migration +5. **Never edit applied migrations** - Create new migrations to fix issues + +--- + +# Testing Query Functions + +## Integration Testing Pattern + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getUserOrganization } from './organizations'; +import { testDb } from '@/test-utils'; + +describe('getUserOrganization', () => { + let testUserId: string; + + beforeEach(async () => { + // Setup test data + testUserId = await testDb.createTestUser(); + }); + + afterEach(async () => { + // Cleanup test data + await testDb.cleanup(); + }); + + it('should return user organization data', async () => { + const result = await getUserOrganization({ userId: testUserId }); + + 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'); + }); + + it('should return null for non-existent user', async () => { + const result = await getUserOrganization({ + userId: '00000000-0000-0000-0000-000000000000' + }); + + expect(result).toBeNull(); + }); +}); +``` + +## Unit Testing with Mocks + +```typescript +import { vi } from 'vitest'; +import { db } from '../../connection'; + +// Mock the database connection +vi.mock('../../connection', () => ({ + db: { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + } +})); + +describe('getUser (unit)', () => { + it('should validate input', async () => { + // Test pure validation logic without database + }); +}); +``` diff --git a/packages/database/.cursor/schema.mdc b/packages/database/.cursor/schema.mdc index 363d17317..a725477a1 100644 --- a/packages/database/.cursor/schema.mdc +++ b/packages/database/.cursor/schema.mdc @@ -1,37 +1,65 @@ ---- -globs: src/schema.ts -alwaysApply: true ---- +## Database Schema Definition Rules +The database schema is defined using Drizzle ORM in `src/schema.ts`. All table definitions and modifications should be made in this file. ## Adding a New JSONB Column When adding a new JSONB column to the database schema, it is important to ensure type safety and maintainability by defining the corresponding TypeScript type. Follow these steps: 1. **Define the TypeScript Type**: - - Navigate to the `@/schema-types` directory. + - Navigate to the `src/schema-types` directory. - Create a new file for your entity if it doesn't exist (e.g., `newEntity.ts`). - Define the TypeScript type for the JSONB column. For example: ```typescript export type NewEntityConfig = { key: string; - value: any; // Use specific types instead of 'any' for better type safety + value: string; // Use specific types instead of 'any' for better type safety + // Add proper typing for all fields }; + + // For arrays of objects + export type NewEntityConfigs = NewEntityConfig[]; ``` 2. **Export the Type**: - - Ensure the new type is exported from the `index.ts` file in the `@/schema-types` directory. + - Ensure the new type is exported from the `index.ts` file in the `src/schema-types` directory: + ```typescript + export * from './newEntity'; + ``` 3. **Consume the Type in Schema**: - - In the `src/schema.ts` file, use the defined type for the JSONB column. For example: + - In the `src/schema.ts` file, use the defined type for the JSONB column with the `.$type()` method: ```typescript import type { NewEntityConfig } from './schema-types'; export const newEntities = pgTable('new_entities', { // ... other columns - config: jsonb('config').$type().default(sql`'{}'::jsonb`).notNull(), + config: jsonb('config') + .$type() + .default(sql`'{}'::jsonb`) + .notNull(), + + // For nullable JSONB columns + metadata: jsonb('metadata') + .$type() + .default(sql`null`), }); ``` By following these steps, you ensure that the JSONB column is type-safe and maintainable, leveraging TypeScript's compile-time checks and IntelliSense support. +## Type Inference from Schema + +Always use `InferSelectModel` for deriving types from table definitions: + +```typescript +import { type InferSelectModel } from 'drizzle-orm'; +export type User = InferSelectModel; +``` + +This ensures types stay in sync with the database schema automatically. + + + +By following these steps, you ensure that the JSONB column is type-safe and maintainable, leveraging TypeScript's compile-time checks and IntelliSense support. + diff --git a/packages/server-shared/src/type-utilities/isEqual.ts b/packages/server-shared/src/type-utilities/isEqual.ts index 8e796b4cc..07ab67a0b 100644 --- a/packages/server-shared/src/type-utilities/isEqual.ts +++ b/packages/server-shared/src/type-utilities/isEqual.ts @@ -31,17 +31,3 @@ export type Equal = (() => T extends A ? 1 : 2) extends () => T exte * type _Check = Expect>; // Errors if types don't match exactly */ export type Expect = T; - -/** - * Variable assignment approach for type equality (most reliable) - * Copy this pattern for guaranteed type checking: - * - * @example - * const _check1: MyType = {} as DatabaseType; - * const _check2: DatabaseType = {} as MyType; - */ - -/** - * Legacy type equality checker - use Equal + Expect pattern instead - */ -export type IsEqual = Equal;