From aa386734a80d680fc3f1823e5bd72f8179e7bdff Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Wed, 12 Nov 2025 04:40:53 +0900 Subject: [PATCH 01/28] Refactor error handling and response management - Updated error handling in controllers to use the new ApiResponse facade. - Replaced legacy response helpers with ApiResponse methods for consistency. - Enhanced documentation for RFC9457 error handling and quick start guides. - Introduced helper functions for accessing Application and Prisma client from Fastify. - Improved dependency injection pattern by attaching Application instance to Fastify. - Added new database module with enhanced Prisma client management features. - Updated service provider registration to streamline command registration. - Refactored README files for clarity and accuracy regarding application structure and commands. --- .roo/rules/frourio.md | 124 ++++- backend-api/@frouvel/kaname/config/README.md | 1 - backend-api/@frouvel/kaname/console/README.md | 13 +- .../@frouvel/kaname/database/README.md | 455 ++++++++++++++++++ backend-api/@frouvel/kaname/docs/README.md | 81 ++-- .../kaname/docs/RFC9457_ERROR_HANDLING.md | 70 ++- .../kaname/docs/RFC9457_QUICK_START.md | 60 +-- .../@frouvel/kaname/foundation/HttpKernel.ts | 4 + .../@frouvel/kaname/foundation/README.md | 39 +- .../@frouvel/kaname/foundation/helpers.ts | 70 +++ .../@frouvel/kaname/foundation/index.ts | 2 + backend-api/README.md | 34 +- package.json | 3 +- 13 files changed, 819 insertions(+), 137 deletions(-) create mode 100644 backend-api/@frouvel/kaname/database/README.md create mode 100644 backend-api/@frouvel/kaname/foundation/helpers.ts diff --git a/.roo/rules/frourio.md b/.roo/rules/frourio.md index ffd8a34..48a8117 100644 --- a/.roo/rules/frourio.md +++ b/.roo/rules/frourio.md @@ -561,7 +561,7 @@ export class UserRepository implements IUserRepository { ```ts import { IUserRepository, UserRepository } from '../repository/User.repository'; -import { getPrismaClient } from '$/service/getPrismaClient'; +import { getPrismaClient } from '$/@frouvel/kaname/database'; export interface IValidateUserAgeService { handleByAge: (args: { age: number }) => Promise; @@ -605,7 +605,7 @@ export class ValidateUserAgeService implements IValidateUserAgeService { ```ts import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; -import { getPrismaClient } from '$/service/getPrismaClient'; +import { getPrismaClient } from '$/@frouvel/kaname/database'; import { IUserRepository, UserRepository } from '../repository/User.repository'; export class FindUserUseCase { @@ -637,7 +637,7 @@ export class FindUserUseCase { ```ts import { ValidationError } from '$/@frouvel/kaname/error/CommonErrors'; -import { getPrismaClient } from '$/service/getPrismaClient'; +import { getPrismaClient } from '$/@frouvel/kaname/database'; import { IUserRepository, UserRepository } from '../repository/User.repository'; import { IValidateUserAgeService, ValidateUserAgeService } from '../service/ValidateUserAge.service'; @@ -684,6 +684,124 @@ export class CreateUserUseCase { } ``` +## Dependency Injection with Application Container + +The framework provides a Laravel-style Application container for proper dependency injection. This is the **recommended approach** for accessing Prisma and other services. + +### Accessing Application in Controllers + +Controllers can access the Application container through the Fastify instance using helper functions: + +```ts +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { getApp, getPrisma } from '$/@frouvel/kaname/foundation'; +import { FindUserUseCase } from '$/domain/user/usecase/FindUser.usecase'; +import { defineController } from './$relay'; + +export default defineController((fastify) => ({ + get: ({ params }) => { + const app = getApp(fastify); // Get Application container + + return FindUserUseCase.create(app) + .handleById({ id: params.id }) + .then(ApiResponse.success) + .catch(ApiResponse.method.get); + }, +})); +``` + +**Alternative: Direct Prisma access** + +```ts +import { getPrisma } from '$/@frouvel/kaname/foundation'; +import { defineController } from './$relay'; + +export default defineController((fastify) => ({ + get: () => { + const prisma = getPrisma(fastify); // Direct Prisma access + // Use for simple queries without UseCase + }, +})); +``` + +### Container-Based UseCase Pattern + +UseCases should receive the Application instance to access container-registered services: + +```ts +import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; +import type { Application } from '$/@frouvel/kaname/foundation'; +import type { PrismaClient } from '@prisma/client'; +import { IUserRepository, UserRepository } from '../repository/User.repository'; + +export class FindUserUseCase { + private readonly _userRepository: IUserRepository; + + private constructor(args: { userRepository: IUserRepository }) { + this._userRepository = args.userRepository; + } + + static create(app: Application) { + const prisma = app.make('prisma'); + return new FindUserUseCase({ + userRepository: new UserRepository(prisma), + }); + } + + async handleById(args: { id: number }) { + const user = await this._userRepository.findById({ id: args.id }); + + if (!user) { + throw NotFoundError.create(`User with ID ${args.id} not found`, { + userId: args.id, + }); + } + + return user.toDto(); + } +} +``` + +### Legacy Pattern (Not Recommended) + +The old pattern using `getPrismaClient()` directly still works but bypasses the container: + +```ts +import { getPrismaClient } from '$/@frouvel/kaname/database'; + +// ❌ Bypasses container - avoid in new code +static create() { + return new FindUserUseCase({ + userRepository: new UserRepository(getPrismaClient()), + }); +} +``` + +**Why avoid this?** +- Bypasses the Application container +- Harder to test (can't mock via container) +- Inconsistent with framework architecture +- Breaks the dependency injection pattern + +### Container-Registered Services + +The following services are available via the container: + +- `prisma` - [`PrismaClient`](backend-api/@frouvel/kaname/database/PrismaClientManager.ts) instance +- `app` - Application instance itself +- `HttpKernel` - HTTP request handler +- `ConsoleKernel` - Console command handler +- `fastify` - Fastify instance (when running HTTP server) +- `config` - Configuration object (after LoadConfiguration bootstrapper) + +Example: + +```ts +const app = getApp(fastify); +const prisma = app.make('prisma'); +const config = app.make>('config'); +``` + ## Best Practices Summary ### Response Handling diff --git a/backend-api/@frouvel/kaname/config/README.md b/backend-api/@frouvel/kaname/config/README.md index 144570f..40fe311 100644 --- a/backend-api/@frouvel/kaname/config/README.md +++ b/backend-api/@frouvel/kaname/config/README.md @@ -216,5 +216,4 @@ const value = config('key'); ## See Also - [Configuration Files Documentation](../../../config/README.md) -- [Environment Variables](../env/README.md) - [Application Container](../foundation/README.md) \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/console/README.md b/backend-api/@frouvel/kaname/console/README.md index 007c129..ee63cee 100644 --- a/backend-api/@frouvel/kaname/console/README.md +++ b/backend-api/@frouvel/kaname/console/README.md @@ -109,22 +109,25 @@ export class MyCommand extends Command { ### 2. Register Your Command -Register the command in [`ConsoleServiceProvider`](../../../bootstrap/providers/ConsoleServiceProvider.ts): +Register the command in [`AppServiceProvider`](../../../app/providers/AppServiceProvider.ts): ```typescript import type { Application, ServiceProvider } from '$/@frouvel/kaname/foundation'; import type { ConsoleKernel } from '$/@frouvel/kaname/foundation'; -import { MyCommand } from '$/path/to/MyCommand'; +import { MyCommand } from '$/app/console/MyCommand'; -export class ConsoleServiceProvider implements ServiceProvider { - register(_app: Application): void {} +export class AppServiceProvider implements ServiceProvider { + register(_app: Application): void { + // Register application services here + } async boot(app: Application): Promise { const kernel = app.make('ConsoleKernel'); + // Register your custom commands here kernel.registerCommands([ new MyCommand(app), - // ... other commands + // Add more commands here ]); } } diff --git a/backend-api/@frouvel/kaname/database/README.md b/backend-api/@frouvel/kaname/database/README.md new file mode 100644 index 0000000..dc43c57 --- /dev/null +++ b/backend-api/@frouvel/kaname/database/README.md @@ -0,0 +1,455 @@ +# Database Module + +Enhanced Prisma Client management with connection pooling, retry logic, and graceful shutdown handling. + +## Overview + +The `@frouvel/kaname/database` module provides a production-ready Prisma Client wrapper with: + +- **Connection Pool Management**: Configurable connection pooling via environment variables +- **Automatic Retry Logic**: Exponential backoff for failed operations +- **Graceful Shutdown**: Proper connection cleanup on application exit +- **Health Checks**: Database connection health monitoring +- **Pagination Extension**: Automatic integration with `@frouvel/kaname/paginator` + +## Quick Start + +```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; + +const prisma = getPrismaClient(); + +// Use Prisma client as normal +const users = await prisma.user.findMany(); +``` + +## Configuration + +### Environment Variables + +Configure database connection pooling via environment variables: + +```bash +# Maximum number of connections in the pool (default: 10) +DATABASE_CONNECTION_POOL_SIZE=10 + +# Connection timeout in seconds (default: 30) +DATABASE_CONNECTION_TIMEOUT=30 + +# Pool timeout in seconds (default: 2) +DATABASE_POOL_TIMEOUT=2 +``` + +### Connection String Enhancement + +The module automatically enhances your `DATABASE_URL` with pool parameters if not already present. + +**Before:** +``` +postgresql://user:pass@localhost:5432/mydb +``` + +**After:** +``` +postgresql://user:pass@localhost:5432/mydb?connection_limit=10&pool_timeout=2&connect_timeout=30 +``` + +## Core Functions + +### `getPrismaClient()` + +Get the singleton Prisma client instance. + +```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; + +const prisma = getPrismaClient(); +``` + +**Features:** +- Returns the same instance on subsequent calls (singleton pattern) +- Auto-connects on first use +- Registers graceful shutdown handlers +- Applies pagination extension automatically + +### `disconnectPrismaClient()` + +Manually disconnect the Prisma client (useful for testing). + +```typescript +import { disconnectPrismaClient } from '$/@frouvel/kaname/database'; + +await disconnectPrismaClient(); +``` + +### `withRetry()` + +Execute database operations with automatic retry logic. + +```typescript +import { withRetry } from '$/@frouvel/kaname/database'; + +const result = await withRetry( + async () => { + return await prisma.user.findMany(); + }, + 3, // maxRetries (default: 3) + 1000 // initialDelay in ms (default: 1000) +); +``` + +**Retry Strategy:** +- Exponential backoff: delay × 2^(attempt-1) +- Attempt 1: 1000ms delay +- Attempt 2: 2000ms delay +- Attempt 3: 4000ms delay + +### `checkDatabaseConnection()` + +Check database connection health. + +```typescript +import { checkDatabaseConnection } from '$/@frouvel/kaname/database'; + +const isHealthy = await checkDatabaseConnection(); + +if (!isHealthy) { + console.error('Database connection failed'); +} +``` + +**Features:** +- Uses `withRetry()` internally (2 retries, 500ms delay) +- Returns `true` if connection is healthy +- Returns `false` if all retries fail + +### `resetPrismaConnection()` + +Reset the Prisma connection (useful when connection is stale). + +```typescript +import { resetPrismaConnection } from '$/@frouvel/kaname/database'; + +await resetPrismaConnection(); + +// Next call to getPrismaClient() will create a new connection +const prisma = getPrismaClient(); +``` + +## Usage in Repositories + +### Basic Usage + +```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { UserModel } from '$/prisma/__generated__/models/User.model'; + +export class UserRepository { + constructor(private readonly _prisma: PrismaClient) {} + + async findById(args: { id: number }): Promise { + const data = await this._prisma.user.findUnique({ + where: { id: args.id }, + }); + + if (!data) return null; + + return UserModel.fromPrismaValue({ self: data }); + } +} +``` + +### In Use Cases + +```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { UserRepository } from '../repository/User.repository'; + +export class FindUserUseCase { + private readonly _userRepository: IUserRepository; + + private constructor(args: { userRepository: IUserRepository }) { + this._userRepository = args.userRepository; + } + + static create() { + return new FindUserUseCase({ + userRepository: new UserRepository(getPrismaClient()), + }); + } + + async handleById(args: { id: number }) { + const user = await this._userRepository.findById({ id: args.id }); + + if (!user) { + throw NotFoundError.create(`User with ID ${args.id} not found`, { + userId: args.id, + }); + } + + return user.toDto(); + } +} +``` + +## Service Provider Integration + +The database module is automatically registered via [`DatabaseServiceProvider`](../foundation/providers/DatabaseServiceProvider.ts): + +```typescript +// backend-api/bootstrap/app.ts +import { + DatabaseServiceProvider, + ConsoleServiceProvider, +} from '$/@frouvel/kaname/foundation'; + +const providers = [ + DatabaseServiceProvider, // Registers 'prisma' singleton + ConsoleServiceProvider, + // ... other providers +]; +``` + +**What it does:** +1. Registers Prisma client as singleton in the application container +2. Connects to database on boot +3. Implements graceful disconnection on shutdown + +## Pagination Extension + +The Prisma client is automatically extended with pagination capabilities: + +```typescript +const prisma = getPrismaClient(); + +// Use pagination extension methods +const result = await prisma.user.paginate({ + limit: 10, + page: 1, +}); +``` + +See [`@frouvel/kaname/paginator/README.md`](../paginator/README.md) for details on pagination features. + +## Graceful Shutdown + +Shutdown handlers are automatically registered to ensure proper cleanup: + +```typescript +// Listens for these signals: +- SIGINT (Ctrl+C) +- SIGTERM (container shutdown) +- beforeExit (Node.js process exit) +``` + +**Shutdown Process:** +1. Log disconnect message +2. Call `prisma.$disconnect()` +3. Set prisma instance to null +4. Log completion + +## Best Practices + +### 1. Use Singleton Pattern + +Always use `getPrismaClient()` instead of creating new instances: + +```typescript +// ✅ Good +import { getPrismaClient } from '$/@frouvel/kaname/database'; +const prisma = getPrismaClient(); + +// ❌ Bad +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +``` + +### 2. Use Retry Logic for Critical Operations + +```typescript +import { withRetry, getPrismaClient } from '$/@frouvel/kaname/database'; + +const prisma = getPrismaClient(); + +// Wrap critical operations +const result = await withRetry(async () => { + return await prisma.user.create({ + data: { /* ... */ }, + }); +}); +``` + +### 3. Health Checks in Production + +```typescript +import { checkDatabaseConnection } from '$/@frouvel/kaname/database'; + +// Add to your health check endpoint +export default defineController(() => ({ + get: async () => { + const dbHealthy = await checkDatabaseConnection(); + + return ApiResponse.success({ + database: dbHealthy ? 'healthy' : 'unhealthy', + }); + }, +})); +``` + +### 4. Access via Container + +In service providers or when you need dependency injection: + +```typescript +import type { Application } from '$/@frouvel/kaname/foundation'; +import type { PrismaClient } from '@prisma/client'; + +const prisma = app.make('prisma'); +``` + +## Testing + +### Reset Connection Between Tests + +```typescript +import { resetPrismaConnection } from '$/@frouvel/kaname/database'; + +afterEach(async () => { + await resetPrismaConnection(); +}); +``` + +### Mock Prisma Client + +```typescript +import { vi } from 'vitest'; + +vi.mock('$/@frouvel/kaname/database', () => ({ + getPrismaClient: () => mockPrismaClient, +})); +``` + +## Architecture + +``` +@frouvel/kaname/database/ +├── index.ts # Main exports +├── PrismaClientManager.ts # Core implementation +└── README.md # This file +``` + +**Features in `PrismaClientManager.ts`:** + +1. **Connection Pool Configuration** (`createPrismaClient`) + - Reads environment variables + - Enhances DATABASE_URL with pool parameters + - Configures logging based on environment + +2. **Singleton Management** (`getPrismaClient`) + - Creates instance on first call + - Returns same instance on subsequent calls + - Auto-connects and registers shutdown handlers + +3. **Retry Logic** (`withRetry`) + - Exponential backoff algorithm + - Configurable max retries and delay + - Detailed error logging + +4. **Health Checks** (`checkDatabaseConnection`) + - Simple SELECT 1 query + - Uses retry logic internally + - Returns boolean status + +5. **Connection Management** (`resetPrismaConnection`, `disconnectPrismaClient`) + - Clean disconnection + - Instance reset + - Preparation for reconnection + +## Environment-Specific Configuration + +### Development + +```typescript +// Logging enabled: query, info, warn, error +// Connection pool: 10 connections (or env override) +``` + +### Production + +```typescript +// Logging: warn, error only +// Connection pool: configured via env (recommended: 10-20) +// Always cache configuration: npm run artisan config:cache +``` + +### Testing + +```typescript +// Use separate test database +// Reset connections between tests +// Mock Prisma client where appropriate +``` + +## Troubleshooting + +### Connection Pool Exhausted + +**Symptoms:** `PrismaPool` errors, timeout errors + +**Solution:** +```bash +# Increase pool size +DATABASE_CONNECTION_POOL_SIZE=20 +``` + +### Slow Queries + +**Symptoms:** Timeouts, slow response times + +**Solution:** +```bash +# Increase connection timeout +DATABASE_CONNECTION_TIMEOUT=60 +``` + +### Connection Leaks + +**Symptoms:** Connections not released, memory growth + +**Solution:** +```typescript +// Ensure all queries complete +// Check for unhandled promise rejections +// Use withRetry for flaky operations +``` + +### Stale Connections + +**Symptoms:** "Connection reset" errors + +**Solution:** +```typescript +import { resetPrismaConnection } from '$/@frouvel/kaname/database'; + +await resetPrismaConnection(); +``` + +## Migration from Old Pattern + +**Before:** +```typescript +import { getPrismaClient } from '$/service/getPrismaClient'; +``` + +**After:** +```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; +``` + +All other usage remains the same! + +## See Also + +- [`@frouvel/kaname/paginator`](../paginator/README.md) - Pagination extension +- [`DatabaseServiceProvider`](../foundation/providers/DatabaseServiceProvider.ts) - Service provider +- [Prisma Documentation](https://www.prisma.io/docs) - Official Prisma docs \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/docs/README.md b/backend-api/@frouvel/kaname/docs/README.md index 6d39b4d..0765289 100644 --- a/backend-api/@frouvel/kaname/docs/README.md +++ b/backend-api/@frouvel/kaname/docs/README.md @@ -55,8 +55,8 @@ export type Methods = DefineMethods<{ **Backend (controller.ts):** ```typescript -import { returnSuccess, returnGetError } from '$/app/http/response'; -import { NotFoundError } from '$/app/error/CommonErrors'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; export default defineController(() => ({ get: ({ params }) => { @@ -67,9 +67,9 @@ export default defineController(() => ({ userId: params.id, }); } - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnGetError(error); + return ApiResponse.method.get(error); } }, })); @@ -105,56 +105,61 @@ if (isApiSuccess(response)) { ``` backend-api/ -├── app/ +├── @frouvel/kaname/ │ ├── error/ │ │ ├── FrourioFrameworkError.ts # Base error class │ │ ├── CommonErrors.ts # Pre-built error classes │ │ └── index.ts # Barrel export -│ └── http/ -│ ├── rfc9457.types.ts # Type definitions -│ ├── rfc9457.response.ts # Response helpers -│ └── response.ts # Main exports +│ ├── http/ +│ │ ├── ApiResponse.ts # Main API response facade +│ │ ├── ResponseBuilder.ts # Fluent validation API +│ │ └── type/ +│ │ └── nfc9457.ts # RFC9457 type definitions +│ └── docs/ +│ ├── RFC9457_QUICK_START.md # Quick reference +│ ├── RFC9457_ERROR_HANDLING.md # Complete guide +│ └── RFC9457_MIGRATION.md # Migration guide ├── api/ │ └── example-rfc9457/ # Example implementation -└── docs/ - ├── RFC9457_QUICK_START.md # Quick reference - ├── RFC9457_ERROR_HANDLING.md # Complete guide - └── RFC9457_MIGRATION.md # Migration guide +└── commonTypesWithClient/ + └── ProblemDetails.types.ts # Shared with frontend ``` -### Available Response Helpers +### ApiResponse Facade Methods ```typescript -// Success -returnSuccess(data) - -// Generic errors (auto-detect status from error type) -returnGetError(error) // Default: 404 -returnPostError(error) // Default: 500 -returnPutError(error) // Default: 500 -returnPatchError(error) // Default: 403 -returnDeleteError(error) // Default: 500 - -// Specific status codes -returnBadRequest(detail, extensions?) // 400 -returnUnauthorized(detail, extensions?) // 401 -returnForbidden(detail, extensions?) // 403 -returnNotFound(detail, extensions?) // 404 -returnConflict(detail, extensions?) // 409 -returnInternalServerError(detail, extensions?) // 500 +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; + +// Success response +ApiResponse.success(data) + +// Method-specific error handlers (auto-detect status from error type) +ApiResponse.method.get(error) // Default: 404 +ApiResponse.method.post(error) // Default: 500 +ApiResponse.method.put(error) // Default: 500 +ApiResponse.method.patch(error) // Default: 403 +ApiResponse.method.delete(error) // Default: 500 + +// Specific status code methods +ApiResponse.badRequest(detail, extensions?) // 400 +ApiResponse.unauthorized(detail, extensions?) // 401 +ApiResponse.forbidden(detail, extensions?) // 403 +ApiResponse.notFound(detail, extensions?) // 404 +ApiResponse.conflict(detail, extensions?) // 409 +ApiResponse.internalServerError(detail, extensions?) // 500 ``` ### Available Error Classes ```typescript import { - ValidationError, // 400 - Validation failures - UnauthorizedError, // 401 - Authentication failures - ForbiddenError, // 403 - Authorization failures - NotFoundError, // 404 - Resource not found - BadRequestError, // 400 - Malformed requests - InternalServerError, // 500 - Unexpected errors -} from '$/app/error/CommonErrors'; + ValidationError, // 400 - Validation failures + UnauthorizedError, // 401 - Authentication failures + ForbiddenError, // 403 - Authorization failures + NotFoundError, // 404 - Resource not found + BadRequestError, // 400 - Malformed requests + InternalServerError, // 500 - Unexpected errors +} from '$/@frouvel/kaname/error/CommonErrors'; ``` ### Getting Started diff --git a/backend-api/@frouvel/kaname/docs/RFC9457_ERROR_HANDLING.md b/backend-api/@frouvel/kaname/docs/RFC9457_ERROR_HANDLING.md index 1cae11c..3b591ab 100644 --- a/backend-api/@frouvel/kaname/docs/RFC9457_ERROR_HANDLING.md +++ b/backend-api/@frouvel/kaname/docs/RFC9457_ERROR_HANDLING.md @@ -39,19 +39,13 @@ Additional fields can be added for context: ## Basic Usage -### 1. Using Response Helpers +### 1. Using ApiResponse Facade The simplest way to return RFC9457-compliant errors: ```typescript import { defineController } from './$relay'; -import { - returnSuccess, - returnGetError, - returnPostError, - returnNotFound, - returnBadRequest, -} from '$/app/http/response'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; export default defineController(() => ({ get: ({ params }) => { @@ -59,29 +53,29 @@ export default defineController(() => ({ const user = findUser(params.id); if (!user) { - return returnNotFound(`User with ID ${params.id} not found`, { + return ApiResponse.notFound(`User with ID ${params.id} not found`, { userId: params.id, }); } - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnGetError(error); + return ApiResponse.method.get(error); } }, post: ({ body }) => { try { if (!body.email) { - return returnBadRequest('Email is required', { + return ApiResponse.badRequest('Email is required', { field: 'email', }); } const user = createUser(body); - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnPostError(error); + return ApiResponse.method.post(error); } }, })); @@ -92,8 +86,8 @@ export default defineController(() => ({ Create specific error types for your domain: ```typescript -import { NotFoundError, ValidationError } from '$/app/error/CommonErrors'; -import { returnGetError } from '$/app/http/response'; +import { NotFoundError, ValidationError } from '$/@frouvel/kaname/error/CommonErrors'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; export default defineController(() => ({ get: ({ params }) => { @@ -107,9 +101,9 @@ export default defineController(() => ({ }); } - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnGetError(error); + return ApiResponse.method.get(error); } }, })); @@ -120,7 +114,7 @@ export default defineController(() => ({ Extend `AbstractFrourioFrameworkError` for domain-specific errors: ```typescript -import { AbstractFrourioFrameworkError } from '$/app/error/FrourioFrameworkError'; +import { AbstractFrourioFrameworkError } from '$/@frouvel/kaname/error/FrourioFrameworkError'; export class UserAlreadyExistsError extends AbstractFrourioFrameworkError { constructor(args: { @@ -153,42 +147,44 @@ export default defineController(() => ({ } const user = createUser(body); - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnPostError(error); + return ApiResponse.method.post(error); } }, })); ``` -## Available Response Helpers +## ApiResponse Facade Methods -### Generic Helpers +### Method-Specific Error Handlers ```typescript +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; + // Automatically determines status from error type -returnGetError(error) // Default: 404 -returnPostError(error) // Default: 500 -returnPutError(error) // Default: 500 -returnPatchError(error) // Default: 403 -returnDeleteError(error) // Default: 500 +ApiResponse.method.get(error); // Default: 404 +ApiResponse.method.post(error); // Default: 500 +ApiResponse.method.put(error); // Default: 500 +ApiResponse.method.patch(error); // Default: 403 +ApiResponse.method.delete(error); // Default: 500 ``` -### Specific Status Code Helpers +### Specific Status Code Methods ```typescript -returnBadRequest(detail, extensions?) // 400 -returnUnauthorized(detail, extensions?) // 401 -returnForbidden(detail, extensions?) // 403 -returnNotFound(detail, extensions?) // 404 -returnConflict(detail, extensions?) // 409 -returnInternalServerError(detail, extensions?) // 500 +ApiResponse.badRequest(detail, extensions?); // 400 +ApiResponse.unauthorized(detail, extensions?); // 401 +ApiResponse.forbidden(detail, extensions?); // 403 +ApiResponse.notFound(detail, extensions?); // 404 +ApiResponse.conflict(detail, extensions?); // 409 +ApiResponse.internalServerError(detail, extensions?); // 500 ``` ### Example with Extensions ```typescript -return returnNotFound('Resource not found', { +return ApiResponse.notFound('Resource not found', { resourceType: 'User', resourceId: params.id, searchedAt: new Date().toISOString(), @@ -197,7 +193,7 @@ return returnNotFound('Resource not found', { ## Common Error Classes -Pre-built error classes in `$/app/error/CommonErrors.ts`: +Pre-built error classes in `$/@frouvel/kaname/error/CommonErrors.ts`: - **ValidationError** - For validation failures (400) - **UnauthorizedError** - For authentication failures (401) diff --git a/backend-api/@frouvel/kaname/docs/RFC9457_QUICK_START.md b/backend-api/@frouvel/kaname/docs/RFC9457_QUICK_START.md index a9ff9ab..dadaba8 100644 --- a/backend-api/@frouvel/kaname/docs/RFC9457_QUICK_START.md +++ b/backend-api/@frouvel/kaname/docs/RFC9457_QUICK_START.md @@ -5,8 +5,8 @@ Quick reference for using RFC9457-compliant error handling in controllers. ## TL;DR ```typescript -import { returnSuccess, returnGetError, returnNotFound } from '$/app/http/response'; -import { NotFoundError } from '$/app/error/CommonErrors'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; export default defineController(() => ({ get: ({ params }) => { @@ -15,9 +15,9 @@ export default defineController(() => ({ if (!item) { throw NotFoundError.create(`Item ${params.id} not found`, { itemId: params.id }); } - return returnSuccess(item); + return ApiResponse.success(item); } catch (error) { - return returnGetError(error); + return ApiResponse.method.get(error); } }, })); @@ -25,22 +25,28 @@ export default defineController(() => ({ ## Quick Reference -### Import Response Helpers +### Import ApiResponse Facade ```typescript -import { - returnSuccess, // For successful responses - returnGetError, // For GET errors (default: 404) - returnPostError, // For POST errors (default: 500) - returnPutError, // For PUT errors (default: 500) - returnPatchError, // For PATCH errors (default: 403) - returnDeleteError, // For DELETE errors (default: 500) - returnNotFound, // For 404 errors - returnBadRequest, // For 400 errors - returnUnauthorized, // For 401 errors - returnForbidden, // For 403 errors - returnConflict, // For 409 errors -} from '$/app/http/response'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; + +// Success response +ApiResponse.success(data); + +// Method-specific error handlers (auto-detect status from error type) +ApiResponse.method.get(error); // Default: 404 +ApiResponse.method.post(error); // Default: 500 +ApiResponse.method.put(error); // Default: 500 +ApiResponse.method.patch(error); // Default: 403 +ApiResponse.method.delete(error); // Default: 500 + +// Specific status code methods +ApiResponse.notFound(detail, extensions?); // 404 +ApiResponse.badRequest(detail, extensions?); // 400 +ApiResponse.unauthorized(detail, extensions?); // 401 +ApiResponse.forbidden(detail, extensions?); // 403 +ApiResponse.conflict(detail, extensions?); // 409 +ApiResponse.internalServerError(detail, extensions?); // 500 ``` ### Import Error Classes @@ -53,19 +59,19 @@ import { ForbiddenError, BadRequestError, InternalServerError, -} from '$/app/error/CommonErrors'; +} from '$/@frouvel/kaname/error/CommonErrors'; ``` ## Common Patterns -### Pattern 1: Direct Return with Helper +### Pattern 1: Direct Return with ApiResponse ```typescript post: ({ body }) => { if (!body.email) { - return returnBadRequest('Email is required', { field: 'email' }); + return ApiResponse.badRequest('Email is required', { field: 'email' }); } - return returnSuccess({ created: true }); + return ApiResponse.success({ created: true }); }, ``` @@ -78,9 +84,9 @@ get: ({ params }) => { if (!user) { throw NotFoundError.create(`User ${params.id} not found`, { userId: params.id }); } - return returnSuccess(user); + return ApiResponse.success(user); } catch (error) { - return returnGetError(error); + return ApiResponse.method.get(error); } }, ``` @@ -91,8 +97,8 @@ get: ({ params }) => { post: ({ body }) => CreateUserUseCase.create() .handle({ email: body.email }) - .then(returnSuccess) - .catch(returnPostError), + .then(ApiResponse.success) + .catch(ApiResponse.method.post), ``` ## Response Format @@ -121,7 +127,7 @@ Content-Type: application/problem+json ## Type Definitions ```typescript -import type { ProblemDetails } from '$/app/http/rfc9457.types'; +import type { ProblemDetails } from 'commonTypesWithClient'; export type Methods = DefineMethods<{ get: { diff --git a/backend-api/@frouvel/kaname/foundation/HttpKernel.ts b/backend-api/@frouvel/kaname/foundation/HttpKernel.ts index 2e05e3f..4a0756a 100644 --- a/backend-api/@frouvel/kaname/foundation/HttpKernel.ts +++ b/backend-api/@frouvel/kaname/foundation/HttpKernel.ts @@ -108,10 +108,14 @@ export class HttpKernel extends Kernel { // Set up error handler this.setupErrorHandler(app); + // Attach Application instance to Fastify for DI access + app.decorate('app', this._app); + // Register routes via Frourio server(app, { basePath: process.env.API_BASE_PATH }); console.log('[HttpKernel] Fastify instance configured'); + console.log('[HttpKernel] Application instance attached to Fastify'); return app; } diff --git a/backend-api/@frouvel/kaname/foundation/README.md b/backend-api/@frouvel/kaname/foundation/README.md index d82a70c..b1e1125 100644 --- a/backend-api/@frouvel/kaname/foundation/README.md +++ b/backend-api/@frouvel/kaname/foundation/README.md @@ -32,11 +32,15 @@ bootstrap/app.ts creates Application → Kernel bootstraps → Request/Command h └── README.md # This file Application-specific: -backend-api/bootstrap/ -├── app.ts # Application entry point -├── providers/ # Application-specific providers -│ └── DatabaseServiceProvider.ts -└── cache/ # Bootstrap cache files +backend-api/ +├── @frouvel/kaname/foundation/providers/ # Framework providers +│ ├── DatabaseServiceProvider.ts +│ └── ConsoleServiceProvider.ts +├── app/providers/ # Application providers +│ └── AppServiceProvider.ts +└── bootstrap/ + ├── app.ts # Application entry point + └── cache/ # Bootstrap cache files ``` ## Bootstrap Flow @@ -101,11 +105,13 @@ The Application class provides an IoC (Inversion of Control) container for depen ### Binding Services ```typescript +import { getPrismaClient } from '$/@frouvel/kaname/database'; + // Bind a service (creates new instance each time) app.bind('myService', () => new MyService()); // Bind a singleton (same instance every time) -app.singleton('database', () => getPrismaClient()); +app.singleton('prisma', () => getPrismaClient()); ``` ### Resolving Services @@ -149,13 +155,20 @@ Register your provider in your application's bootstrap file: ```typescript // bootstrap/app.ts -import { DatabaseServiceProvider } from './providers/DatabaseServiceProvider'; -import { MyServiceProvider } from './providers/MyServiceProvider'; +import { + DatabaseServiceProvider, + ConsoleServiceProvider, +} from '$/@frouvel/kaname/foundation'; +import { AppServiceProvider } from '$/app/providers/AppServiceProvider'; // After creating the application const providers = [ + // Framework providers DatabaseServiceProvider, - MyServiceProvider, + ConsoleServiceProvider, + + // Application providers + AppServiceProvider, ]; providers.forEach(Provider => { @@ -171,7 +184,7 @@ For improved performance in production, you can cache your configuration: ### Cache Configuration ```bash -npm run cli config:cache +npm run artisan config:cache ``` This creates `bootstrap/cache/config.cache.json` which is automatically loaded in production. @@ -179,7 +192,7 @@ This creates `bootstrap/cache/config.cache.json` which is automatically loaded i ### Clear Configuration Cache ```bash -npm run cli config:clear +npm run artisan config:clear ``` ## Creating Custom Bootstrappers @@ -249,8 +262,8 @@ app.bootstrapPath(); // /path/to/backend-api/bootstrap/cache | `App\Http\Kernel` | `@frouvel/kaname/foundation/HttpKernel` | | `App\Console\Kernel` | `@frouvel/kaname/foundation/ConsoleKernel` | | `ServiceProvider` | `@frouvel/kaname/foundation/ServiceProvider` | -| `php artisan config:cache` | `npm run cli config:cache` | -| `php artisan config:clear` | `npm run cli config:clear` | +| `php artisan config:cache` | `npm run artisan config:cache` | +| `php artisan config:clear` | `npm run artisan config:clear` | ## Example: Full Application Lifecycle diff --git a/backend-api/@frouvel/kaname/foundation/helpers.ts b/backend-api/@frouvel/kaname/foundation/helpers.ts new file mode 100644 index 0000000..a986137 --- /dev/null +++ b/backend-api/@frouvel/kaname/foundation/helpers.ts @@ -0,0 +1,70 @@ +/** + * Foundation Helpers + * + * Helper functions for accessing framework services in application code. + */ + +import type { FastifyInstance } from 'fastify'; +import type { Application } from './Application'; +import type { PrismaClient } from '@prisma/client'; + +/** + * Get the Application instance from Fastify + * + * The Application instance is attached to Fastify by HttpKernel during bootstrap. + * This helper provides type-safe access to the container. + * + * @param fastify - The Fastify instance + * @returns The Application container instance + * @throws Error if Application is not attached to Fastify + * + * @example + * ```ts + * import { getApp } from '$/@frouvel/kaname/foundation/helpers'; + * + * export default defineController((fastify) => ({ + * get: () => { + * const app = getApp(fastify); + * const prisma = app.make('prisma'); + * // Use prisma... + * } + * })); + * ``` + */ +export function getApp(fastify: FastifyInstance): Application { + const app = (fastify as any).app; + + if (!app) { + throw new Error( + 'Application instance not found on Fastify. ' + + 'Make sure HttpKernel has attached the Application instance.', + ); + } + + return app as Application; +} + +/** + * Get Prisma client from the container + * + * Convenience helper to get the database client directly. + * + * @param fastify - The Fastify instance + * @returns PrismaClient instance from the container + * + * @example + * ```ts + * import { getPrisma } from '$/@frouvel/kaname/foundation/helpers'; + * + * export default defineController((fastify) => ({ + * get: () => { + * const prisma = getPrisma(fastify); + * // Use prisma... + * } + * })); + * ``` + */ +export function getPrisma(fastify: FastifyInstance): PrismaClient { + const app = getApp(fastify); + return app.make('prisma'); +} \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/foundation/index.ts b/backend-api/@frouvel/kaname/foundation/index.ts index 29da1cc..0231131 100644 --- a/backend-api/@frouvel/kaname/foundation/index.ts +++ b/backend-api/@frouvel/kaname/foundation/index.ts @@ -26,3 +26,5 @@ export { DatabaseServiceProvider, SwaggerServiceProvider, } from './providers'; + +export { getApp, getPrisma } from './helpers'; diff --git a/backend-api/README.md b/backend-api/README.md index ec876f4..dde9063 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -41,6 +41,7 @@ npm run dev ``` This will start: + - 📦 Build watcher - 🏃 Application server - 🏰 Frourio code generator @@ -63,16 +64,18 @@ npm run artisan --help npm run artisan --help # Built-in commands -npm run artisan inspire # Display an inspiring quote -npm run artisan config:cache # Cache configuration -npm run artisan config:clear # Clear configuration cache -npm run artisan generate:config-types # Generate type-safe config types -npm run artisan greet "John" --title "Dr." # Greet command example +npm run artisan inspire # Display an inspiring quote +npm run artisan tinker # Interactive REPL with app context +npm run artisan config:cache # Cache configuration +npm run artisan config:clear # Clear configuration cache +npm run artisan generate:config-types # Generate type-safe config types +npm run artisan greet "John" --title "Dr." # Greet command example # OpenAPI/Swagger commands npm run artisan openapi:generate # Generate OpenAPI spec file (YAML) npm run artisan openapi:generate -f json # Generate as JSON npm run artisan openapi:generate -o ./openapi.yaml # Custom output path + ``` ### Creating Custom Commands @@ -99,12 +102,18 @@ export class MyCommand extends Command { } ``` -Register in `bootstrap/providers/ConsoleServiceProvider.ts`: +Register in `app/providers/AppServiceProvider.ts`: ```typescript -kernel.registerCommands([ - new MyCommand(app), -]); +// In the boot() method +async boot(app: Application): Promise { + const kernel = app.make('ConsoleKernel'); + + kernel.registerCommands([ + new MyCommand(app), + // Add more custom commands here + ]); +} ``` ## Scripts @@ -119,10 +128,11 @@ kernel.registerCommands([ ### Building - `npm run build` - Build for production -- `npm run generate` - Generate Aspida, Frourio, and Prisma types +- `npm run generate` - Generate Aspida, Frourio, Prisma, and config types - `npm run generate:aspida` - Generate Aspida types - `npm run generate:frourio` - Generate Frourio files - `npm run generate:prisma` - Generate Prisma client +- `npm run generate:config` - Generate type-safe configuration types ### Database @@ -151,7 +161,6 @@ kernel.registerCommands([ ### Utility - `npm run tsx` - Run TypeScript files with tsx -- `npm run cli` - Run the legacy CLI (deprecated, use artisan instead) ## Project Structure @@ -297,6 +306,7 @@ SWAGGER_VERSION=1.0.0 ``` For detailed documentation, see: + - [Swagger Module README](@frouvel/kaname/swagger/README.md) - [Usage Guide](@frouvel/kaname/swagger/USAGE_GUIDE.md) @@ -318,4 +328,4 @@ For detailed documentation, see: ## License -ISC \ No newline at end of file +ISC diff --git a/package.json b/package.json index da0b6e9..9fd4730 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "typecheck:server": "npm run typecheck --prefix backend-api", "lint": "concurrently --names=\"Frontend(Web),Backend(API)\" \"npm run lint:client\" \"npm run lint:server\" -c blue,cyan", "lint:client": "npm run lint --prefix frontend-web", - "lint:server": "npm run lint --prefix backend-api" + "lint:server": "npm run lint --prefix backend-api", + "artisan": "npm run artisan --prefix backend-api" }, "author": "", "license": "ISC", From d63a96b43a688b44d3c5bf95bb4b67350ba0685d Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 14 Nov 2025 04:48:02 +0900 Subject: [PATCH 02/28] feat(tests): introduce comprehensive testing framework with integration and database test cases - Added IntegrationTestCase for full integration testing with server and database lifecycle management. - Created DatabaseTestCase for handling database operations in tests. - Implemented TestCase as a base class for common testing functionality and lifecycle hooks. - Developed a setup script for configuring the test environment, including server and database setup. - Added factory utilities for generating test data and fake data generators. - Updated vite.config.mts to use the new testing setup. - Created migration guide for transitioning to the new testing framework. - Added example integration tests for health and user endpoints. - Enhanced README documentation for the testing module and its usage. --- backend-api/@frouvel/kaname/README.md | 12 + .../@frouvel/kaname/testing/ApiClient.ts | 281 +++++++++ .../kaname/testing/DatabaseTestCase.ts | 111 ++++ .../@frouvel/kaname/testing/Factory.ts | 233 +++++++ .../kaname/testing/IntegrationTestCase.ts | 242 ++++++++ .../kaname/testing/MIGRATION_GUIDE.md | 376 +++++++++++ backend-api/@frouvel/kaname/testing/README.md | 582 ++++++++++++++++++ .../@frouvel/kaname/testing/TestCase.ts | 105 ++++ backend-api/@frouvel/kaname/testing/index.ts | 17 + backend-api/@frouvel/kaname/testing/setup.ts | 158 +++++ backend-api/README.md | 51 +- .../integration/health.integration.test.ts | 30 + .../integration/users.integration.test.ts | 65 ++ backend-api/vite.config.mts | 2 +- 14 files changed, 2262 insertions(+), 3 deletions(-) create mode 100644 backend-api/@frouvel/kaname/testing/ApiClient.ts create mode 100644 backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts create mode 100644 backend-api/@frouvel/kaname/testing/Factory.ts create mode 100644 backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts create mode 100644 backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md create mode 100644 backend-api/@frouvel/kaname/testing/README.md create mode 100644 backend-api/@frouvel/kaname/testing/TestCase.ts create mode 100644 backend-api/@frouvel/kaname/testing/index.ts create mode 100644 backend-api/@frouvel/kaname/testing/setup.ts create mode 100644 backend-api/tests/integration/health.integration.test.ts create mode 100644 backend-api/tests/integration/users.integration.test.ts diff --git a/backend-api/@frouvel/kaname/README.md b/backend-api/@frouvel/kaname/README.md index 5d1b6bf..4c42d6b 100644 --- a/backend-api/@frouvel/kaname/README.md +++ b/backend-api/@frouvel/kaname/README.md @@ -4,6 +4,18 @@ Core framework modules for frourio-framework, inspired by Laravel's Illuminate n ## Modules +### Testing (`@frouvel/kaname/testing`) + +Comprehensive testing utilities including: +- **Test Case Classes**: `TestCase`, `DatabaseTestCase`, `IntegrationTestCase` +- **Factory Pattern**: Generate test data with `Factory` and `fake` helpers +- **API Client**: Fluent HTTP request interface for integration tests +- **Assertions**: Rich assertion helpers for API responses + +[📖 Testing Documentation](./testing/README.md) + +## Modules + ### Console PHP Artisan-like command line interface for managing your application. diff --git a/backend-api/@frouvel/kaname/testing/ApiClient.ts b/backend-api/@frouvel/kaname/testing/ApiClient.ts new file mode 100644 index 0000000..d316adc --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/ApiClient.ts @@ -0,0 +1,281 @@ +import type { FastifyInstance } from 'fastify'; + +export interface ApiResponse { + status: number; + body: T; + headers: Record; + raw: any; +} + +/** + * API Client for integration testing + * Provides a fluent interface for making HTTP requests to the test server + */ +export class ApiClient { + private headers: Record = {}; + private authToken?: string; + + constructor(private readonly server: FastifyInstance) {} + + /** + * Set custom headers for the next request + */ + withHeaders(headers: Record): this { + this.headers = { ...this.headers, ...headers }; + return this; + } + + /** + * Set authorization token for the next request + */ + withAuth(token: string): this { + this.authToken = token; + return this; + } + + /** + * Set bearer token for the next request + */ + withBearerToken(token: string): this { + return this.withAuth(token); + } + + /** + * Make a GET request + */ + async get(path: string): Promise> { + return this.request('GET', path); + } + + /** + * Make a POST request + */ + async post(path: string, body?: any): Promise> { + return this.request('POST', path, body); + } + + /** + * Make a PUT request + */ + async put(path: string, body?: any): Promise> { + return this.request('PUT', path, body); + } + + /** + * Make a PATCH request + */ + async patch(path: string, body?: any): Promise> { + return this.request('PATCH', path, body); + } + + /** + * Make a DELETE request + */ + async delete(path: string): Promise> { + return this.request('DELETE', path); + } + + /** + * Make a generic HTTP request + */ + private async request( + method: string, + path: string, + body?: any, + ): Promise> { + const headers = { ...this.headers }; + + // Add auth header if token is set + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + + // Add content-type for body requests + if (body && !headers['content-type']) { + headers['content-type'] = 'application/json'; + } + + const response = await this.server.inject({ + method: method as any, + url: path, + payload: body, + headers, + }); + + // Reset headers and auth for next request + this.headers = {}; + this.authToken = undefined; + + let parsedBody: T; + try { + parsedBody = response.json(); + } catch { + parsedBody = response.body as T; + } + + return { + status: response.statusCode, + body: parsedBody, + headers: response.headers, + raw: response, + }; + } + + /** + * Assert response status is as expected + */ + assertStatus(response: ApiResponse, expected: number): void { + if (response.status !== expected) { + throw new Error( + `Expected status ${expected} but got ${response.status}.\nBody: ${JSON.stringify(response.body, null, 2)}`, + ); + } + } + + /** + * Assert response is successful (2xx) + */ + assertOk(response: ApiResponse): void { + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Expected successful response but got ${response.status}.\nBody: ${JSON.stringify(response.body, null, 2)}`, + ); + } + } + + /** + * Assert response is created (201) + */ + assertCreated(response: ApiResponse): void { + this.assertStatus(response, 201); + } + + /** + * Assert response is no content (204) + */ + assertNoContent(response: ApiResponse): void { + this.assertStatus(response, 204); + } + + /** + * Assert response is bad request (400) + */ + assertBadRequest(response: ApiResponse): void { + this.assertStatus(response, 400); + } + + /** + * Assert response is unauthorized (401) + */ + assertUnauthorized(response: ApiResponse): void { + this.assertStatus(response, 401); + } + + /** + * Assert response is forbidden (403) + */ + assertForbidden(response: ApiResponse): void { + this.assertStatus(response, 403); + } + + /** + * Assert response is not found (404) + */ + assertNotFound(response: ApiResponse): void { + this.assertStatus(response, 404); + } + + /** + * Assert response is validation error (422) + */ + assertUnprocessable(response: ApiResponse): void { + this.assertStatus(response, 422); + } + + /** + * Assert response contains expected data + */ + assertResponseContains( + response: ApiResponse, + expected: Record, + ): void { + const body = response.body; + for (const [key, value] of Object.entries(expected)) { + if (body[key] !== value) { + throw new Error( + `Expected ${key} to be ${JSON.stringify(value)} but got ${JSON.stringify(body[key])}`, + ); + } + } + } + + /** + * Assert response body matches expected shape + */ + assertResponseShape( + response: ApiResponse, + shape: Record, + ): void { + const body = response.body; + for (const [key, type] of Object.entries(shape)) { + const actualType = typeof body[key]; + if (actualType !== type) { + throw new Error( + `Expected ${key} to be of type ${type} but got ${actualType}`, + ); + } + } + } + + /** + * Assert array response has expected length + */ + assertArrayLength(response: ApiResponse, expected: number): void { + if (!Array.isArray(response.body)) { + throw new Error(`Expected response body to be an array`); + } + if (response.body.length !== expected) { + throw new Error( + `Expected array length to be ${expected} but got ${response.body.length}`, + ); + } + } + + /** + * Assert response has pagination metadata + */ + assertHasPagination(response: ApiResponse): void { + const body = response.body; + if (!body.meta || typeof body.meta !== 'object') { + throw new Error('Expected response to have pagination metadata'); + } + + const requiredFields = [ + 'currentPage', + 'totalPages', + 'totalCount', + 'perPage', + ]; + for (const field of requiredFields) { + if (!(field in body.meta)) { + throw new Error(`Expected pagination meta to have ${field}`); + } + } + } + + /** + * Assert response is RFC9457 Problem Details + */ + assertProblemDetails(response: ApiResponse): void { + const body = response.body; + if (typeof body.type !== 'string') { + throw new Error('Expected ProblemDetails to have type field'); + } + if (typeof body.title !== 'string') { + throw new Error('Expected ProblemDetails to have title field'); + } + if (typeof body.status !== 'number') { + throw new Error('Expected ProblemDetails to have status field'); + } + } +} diff --git a/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts b/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts new file mode 100644 index 0000000..c74b38d --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts @@ -0,0 +1,111 @@ +import { TestCase } from './TestCase'; +import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { exec } from 'child_process'; +import util from 'util'; + +const execPromise = util.promisify(exec); + +/** + * DatabaseTestCase provides database-specific testing utilities + * Automatically handles database migrations and cleanup + */ +export abstract class DatabaseTestCase extends TestCase { + protected prisma: ReturnType; + private static isMigrated = false; + + constructor() { + super(); + this.prisma = getPrismaClient(); + } + + /** + * Run database migrations before tests + */ + protected async setUpBeforeClass(): Promise { + await super.setUpBeforeClass(); + + if (!DatabaseTestCase.isMigrated) { + try { + await execPromise('npx prisma migrate deploy'); + DatabaseTestCase.isMigrated = true; + } catch (error) { + console.error('Migration failed:', error); + console.log('Resetting test database...'); + try { + await execPromise('npx prisma migrate reset --force'); + DatabaseTestCase.isMigrated = true; + } catch (resetError) { + console.error('Database reset failed:', resetError); + throw resetError; + } + } + } + } + + /** + * Refresh database (truncate all tables) before each test + */ + protected async setUp(): Promise { + await super.setUp(); + await this.refreshDatabase(); + } + + /** + * Disconnect from database after each test + */ + protected async tearDown(): Promise { + await this.prisma.$disconnect(); + await super.tearDown(); + } + + /** + * Disconnect from database after all tests + */ + protected async tearDownAfterClass(): Promise { + await this.prisma.$disconnect(); + await super.tearDownAfterClass(); + } + + /** + * Truncate all tables in the database + */ + protected async refreshDatabase(): Promise { + const tablenames = await this.prisma.$queryRaw< + Array<{ tablename: string }> + >`SELECT tablename FROM pg_tables WHERE schemaname='public'`; + + const tables = tablenames + .map(({ tablename }) => tablename) + .filter((name) => name !== '_prisma_migrations') + .map((name) => `"public"."${name}"`) + .join(', '); + + if (tables) { + try { + await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`); + } catch (error) { + console.error('Failed to truncate tables:', error); + throw error; + } + } + } + + /** + * Seed the database with test data + * Override this method in test classes to provide custom seeding + */ + protected async seed(): Promise { + // Override in subclass + } + + /** + * Run database operations in a transaction + */ + protected async transaction( + fn: (tx: ReturnType) => Promise, + ): Promise { + return this.prisma.$transaction(async (tx) => { + return fn(tx as ReturnType); + }); + } +} \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/Factory.ts b/backend-api/@frouvel/kaname/testing/Factory.ts new file mode 100644 index 0000000..c35a50a --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/Factory.ts @@ -0,0 +1,233 @@ +import type { PrismaClient } from '@prisma/client'; + +/** + * Factory function type for creating test data + */ +export type FactoryFunction = (overrides?: Partial) => T; + +/** + * Factory builder function type + */ +export type FactoryBuilder = ( + count?: number, + overrides?: Partial, +) => T | T[]; + +/** + * Base Factory class for creating test data + * Provides a fluent interface for building test data + */ +export abstract class Factory { + protected prisma?: PrismaClient; + protected count = 1; + protected states: Map> = new Map(); + protected currentState?: string; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma; + } + + /** + * Define the default attributes for the factory + */ + protected abstract definition(): T; + + /** + * Create a specific state for the factory + */ + protected state(name: string, attributes: Partial): this { + this.states.set(name, attributes); + return this; + } + + /** + * Apply a state to the factory + */ + public as(state: string): this { + this.currentState = state; + return this; + } + + /** + * Set the number of models to create + */ + public times(count: number): this { + this.count = count; + return this; + } + + /** + * Create model(s) with the given attributes + */ + public make(overrides?: Partial): T | T[] { + const items: T[] = []; + + for (let i = 0; i < this.count; i++) { + const definition = this.definition(); + const stateAttributes = this.currentState + ? this.states.get(this.currentState) || {} + : {}; + + const merged = { + ...definition, + ...stateAttributes, + ...(overrides || {}), + } as T; + + items.push(merged); + } + + // Reset state + this.count = 1; + this.currentState = undefined; + + return items.length === 1 ? items[0] : items; + } + + /** + * Create and persist model(s) with the given attributes + * Subclasses should override this to implement persistence logic + */ + public async create(overrides?: Partial): Promise { + throw new Error( + 'create() must be implemented in subclass with Prisma persistence', + ); + } + + /** + * Create a raw object without persisting + */ + public raw(overrides?: Partial): T { + return this.make(overrides) as T; + } + + /** + * Create multiple raw objects without persisting + */ + public rawMany(count: number, overrides?: Partial): T[] { + return this.times(count).make(overrides) as T[]; + } +} + +/** + * Simple factory function creator for quick test data generation + */ +export function defineFactory( + definition: () => T, +): FactoryFunction { + return (overrides?: Partial) => { + return { + ...definition(), + ...(overrides || {}), + }; + }; +} + +/** + * Sequence helper for generating sequential values + */ +export class Sequence { + private static counters: Map = new Map(); + + static next(key: string, start = 1): number { + const current = this.counters.get(key) || start; + this.counters.set(key, current + 1); + return current; + } + + static reset(key?: string): void { + if (key) { + this.counters.delete(key); + } else { + this.counters.clear(); + } + } +} + +/** + * Faker-like random data generators + */ +export const fake = { + /** + * Generate a random string + */ + string: (length = 10): string => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }, + + /** + * Generate a random email + */ + email: (): string => { + return `${fake.string(8)}@test.com`; + }, + + /** + * Generate a random number + */ + number: (min = 0, max = 100): number => { + return Math.floor(Math.random() * (max - min + 1)) + min; + }, + + /** + * Generate a random boolean + */ + boolean: (): boolean => { + return Math.random() > 0.5; + }, + + /** + * Generate a random UUID + */ + uuid: (): string => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + }, + + /** + * Generate a random date + */ + date: (start?: Date, end?: Date): Date => { + const startTime = start ? start.getTime() : Date.now() - 365 * 24 * 60 * 60 * 1000; + const endTime = end ? end.getTime() : Date.now(); + return new Date(startTime + Math.random() * (endTime - startTime)); + }, + + /** + * Pick a random element from an array + */ + pick: (array: T[]): T => { + return array[Math.floor(Math.random() * array.length)]; + }, + + /** + * Generate a random name + */ + name: (): string => { + const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank', 'Grace', 'Henry']; + return fake.pick(names); + }, + + /** + * Generate a random text + */ + text: (words = 10): string => { + const wordList = [ + 'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'I', + 'it', 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', + ]; + const result: string[] = []; + for (let i = 0; i < words; i++) { + result.push(fake.pick(wordList)); + } + return result.join(' '); + }, +}; \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts b/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts new file mode 100644 index 0000000..8e4f64b --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts @@ -0,0 +1,242 @@ +import { DatabaseTestCase } from './DatabaseTestCase'; +import type { FastifyInstance } from 'fastify'; +import { init } from '$/service/app'; +import { env } from '$/env'; + +/** + * IntegrationTestCase provides full integration testing with server and database + * Automatically starts a test server and manages database lifecycle + */ +export abstract class IntegrationTestCase extends DatabaseTestCase { + protected declare server: FastifyInstance; + protected baseURL: string; + private static testPort: number; + + constructor() { + super(); + // Use a different port for each test run to avoid conflicts + IntegrationTestCase.testPort = env.API_SERVER_PORT + 11; + this.baseURL = `http://localhost:${IntegrationTestCase.testPort}`; + } + + /** + * Start the test server + */ + protected async setUpBeforeClass(): Promise { + await super.setUpBeforeClass(); + + this.server = init(); + await this.server.listen({ + port: IntegrationTestCase.testPort, + host: '0.0.0.0', + }); + } + + /** + * Close the test server + */ + protected async tearDownAfterClass(): Promise { + if (this.server) { + await this.server.close(); + } + await super.tearDownAfterClass(); + } + + /** + * Make a GET request to the test server + */ + protected async get( + path: string, + headers?: Record, + ): Promise { + const response = await this.server.inject({ + method: 'GET', + url: path, + headers, + }); + + let body; + try { + body = response.json(); + } catch { + body = response.body; + } + + return { + status: response.statusCode, + body, + headers: response.headers, + }; + } + + /** + * Make a POST request to the test server + */ + protected async post( + path: string, + body?: any, + headers?: Record, + ): Promise { + const response = await this.server.inject({ + method: 'POST', + url: path, + payload: body, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }); + + let responseBody; + try { + responseBody = response.json(); + } catch { + responseBody = response.body; + } + + return { + status: response.statusCode, + body: responseBody, + headers: response.headers, + }; + } + + /** + * Make a PUT request to the test server + */ + protected async put( + path: string, + body?: any, + headers?: Record, + ): Promise { + const response = await this.server.inject({ + method: 'PUT', + url: path, + payload: body, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }); + + let responseBody; + try { + responseBody = response.json(); + } catch { + responseBody = response.body; + } + + return { + status: response.statusCode, + body: responseBody, + headers: response.headers, + }; + } + + /** + * Make a PATCH request to the test server + */ + protected async patch( + path: string, + body?: any, + headers?: Record, + ): Promise { + const response = await this.server.inject({ + method: 'PATCH', + url: path, + payload: body, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }); + + let responseBody; + try { + responseBody = response.json(); + } catch { + responseBody = response.body; + } + + return { + status: response.statusCode, + body: responseBody, + headers: response.headers, + }; + } + + /** + * Make a DELETE request to the test server + */ + protected async delete( + path: string, + headers?: Record, + ): Promise { + const response = await this.server.inject({ + method: 'DELETE', + url: path, + headers, + }); + + let body; + try { + body = response.json(); + } catch { + body = response.body; + } + + return { + status: response.statusCode, + body, + headers: response.headers, + }; + } + + /** + * Create an authorization header with JWT token + */ + protected authHeader(token: string): Record { + return { + Authorization: `Bearer ${token}`, + }; + } + + /** + * Assert response status + */ + protected assertStatus(response: any, expected: number): void { + if (response.status !== expected) { + throw new Error( + `Expected status ${expected} but got ${response.status}. Body: ${JSON.stringify(response.body, null, 2)}`, + ); + } + } + + /** + * Assert response is successful (2xx) + */ + protected assertOk(response: any): void { + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Expected successful response but got ${response.status}. Body: ${JSON.stringify(response.body, null, 2)}`, + ); + } + } + + /** + * Assert response contains expected data + */ + protected assertResponseContains( + response: any, + expected: Record, + ): void { + const body = response.body; + for (const [key, value] of Object.entries(expected)) { + if (body[key] !== value) { + throw new Error( + `Expected ${key} to be ${value} but got ${body[key]}`, + ); + } + } + } +} \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md b/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md new file mode 100644 index 0000000..ef44976 --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md @@ -0,0 +1,376 @@ +# Migration Guide: Moving to Framework Testing + +This guide helps you migrate from the old `tests/setup.ts` to the new `@frouvel/kaname/testing` framework. + +## Quick Migration + +### 1. Update `vite.config.mts` + +**Before:** +```typescript +export default defineConfig({ + test: { + setupFiles: ['tests/setup.ts'], + // ... + }, +}); +``` + +**After:** +```typescript +export default defineConfig({ + test: { + setupFiles: ['@frouvel/kaname/testing/setup.ts'], + // ... + }, +}); +``` + +### 2. Migrate Test Files + +**Before (Old Setup Style):** +```typescript +import { expect, it, describe } from 'vitest'; + +describe('Users API', () => { + it('returns users', async () => { + // Test logic + }); +}); +``` + +**After (New TestCase Style):** +```typescript +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class UsersTest extends IntegrationTestCase { + run() { + this.suite('Users API', () => { + this.test('returns users', async () => { + const response = await this.get('/api/users'); + this.assertOk(response); + }); + }); + } +} + +new UsersTest().run(); +``` + +## Detailed Migration Steps + +### Step 1: Remove Old Test Setup + +You can safely remove `tests/setup.ts` as it's now handled by the framework: + +```bash +rm tests/setup.ts +``` + +### Step 2: Create Integration Test Directory + +```bash +mkdir -p tests/integration +``` + +### Step 3: Migrate Individual Test Files + +#### Example: Migrating a Database Test + +**Before:** +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { getPrismaClient } from '$/service/getPrismaClient'; + +const prisma = getPrismaClient(); + +describe('User Repository', () => { + beforeEach(async () => { + await prisma.user.deleteMany(); + }); + + it('creates a user', async () => { + const user = await prisma.user.create({ + data: { name: 'Test', email: 'test@test.com', age: 25 } + }); + expect(user).toBeDefined(); + }); +}); +``` + +**After:** +```typescript +import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class UserRepositoryTest extends DatabaseTestCase { + run() { + this.suite('User Repository', () => { + this.test('creates a user', async () => { + const user = await this.prisma.user.create({ + data: { name: 'Test', email: 'test@test.com', age: 25 } + }); + expect(user).toBeDefined(); + }); + }); + } +} + +new UserRepositoryTest().run(); +``` + +#### Example: Migrating an API Integration Test + +**Before:** +```typescript +import { describe, it, expect } from 'vitest'; + +describe('Users API', () => { + it('GET /api/users returns users', async () => { + const response = await fetch('http://localhost:8091/api/users'); + const data = await response.json(); + expect(data).toHaveProperty('data'); + }); +}); +``` + +**After:** +```typescript +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class UsersApiTest extends IntegrationTestCase { + run() { + this.suite('Users API', () => { + this.test('GET /api/users returns users', async () => { + const response = await this.get('/api/users'); + this.assertOk(response); + expect(response.body).toHaveProperty('data'); + }); + }); + } +} + +new UsersApiTest().run(); +``` + +### Step 4: File Naming Convention + +Rename your test files to follow the new convention: + +```bash +# Integration tests +mv tests/users.test.ts tests/integration/users.integration.test.ts + +# Unit tests +mv tests/services/user.test.ts tests/unit/services/user.test.ts +``` + +## Benefits of Migration + +### 1. Better Test Organization + +**Before:** +- Mixed lifecycle hooks (beforeEach, afterEach) +- Manual database cleanup +- No consistent structure + +**After:** +- Clean class-based organization +- Automatic database cleanup +- Consistent lifecycle hooks + +### 2. Less Boilerplate + +**Before:** +```typescript +import { beforeEach, afterEach } from 'vitest'; +import { getPrismaClient } from '$/service/getPrismaClient'; + +const prisma = getPrismaClient(); + +beforeEach(async () => { + await prisma.user.deleteMany(); + await prisma.user.create({ data: { ... } }); +}); + +afterEach(async () => { + await prisma.$disconnect(); +}); +``` + +**After:** +```typescript +class MyTest extends IntegrationTestCase { + protected async seed(): Promise { + await this.prisma.user.create({ data: { ... } }); + } +} +``` + +### 3. Rich Assertion Helpers + +**Before:** +```typescript +expect(response.status).toBe(200); +expect(response.body).toHaveProperty('data'); +``` + +**After:** +```typescript +this.assertOk(response); +this.assertResponseContains(response, { data: expect.any(Array) }); +``` + +### 4. Built-in HTTP Client + +**Before:** +```typescript +const response = await fetch(`${baseUrl}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(data), +}); +``` + +**After:** +```typescript +const response = await this.post('/api/users', data, this.authHeader(token)); +``` + +## Common Patterns + +### Pattern 1: Setup Data + +**Before:** +```typescript +let userId: string; + +beforeEach(async () => { + const user = await prisma.user.create({ ... }); + userId = user.id; +}); +``` + +**After:** +```typescript +class MyTest extends IntegrationTestCase { + private userId?: string; + + protected async setUp(): Promise { + await super.setUp(); + const user = await this.prisma.user.create({ ... }); + this.userId = user.id; + } +} +``` + +### Pattern 2: Authentication + +**Before:** +```typescript +let authToken: string; + +beforeEach(async () => { + const response = await fetch('/api/auth/login', { ... }); + const data = await response.json(); + authToken = data.token; +}); +``` + +**After:** +```typescript +class MyTest extends IntegrationTestCase { + private authToken?: string; + + protected async setUp(): Promise { + await super.setUp(); + const response = await this.post('/api/auth/login', { ... }); + this.authToken = response.body.token; + } +} +``` + +### Pattern 3: Shared Test Data + +**Before:** +```typescript +const testUser = { + name: 'Test User', + email: 'test@test.com', + age: 25, +}; + +beforeEach(async () => { + await prisma.user.create({ data: testUser }); +}); +``` + +**After:** +```typescript +class MyTest extends IntegrationTestCase { + protected async seed(): Promise { + await this.prisma.user.create({ + data: { + name: 'Test User', + email: 'test@test.com', + age: 25, + }, + }); + } +} +``` + +## Troubleshooting + +### Issue: "Cannot find module '@frouvel/kaname/testing'" + +**Solution:** Ensure your `tsconfig.json` includes the correct path mapping: + +```json +{ + "compilerOptions": { + "paths": { + "$/*": ["./"] + } + } +} +``` + +### Issue: Database not cleaning between tests + +**Solution:** Ensure your test file name includes `.integration.test.ts`: + +```typescript +// ✅ Correct +users.integration.test.ts + +// ❌ Wrong (won't trigger cleanup) +users.test.ts +``` + +### Issue: Server already running error + +**Solution:** The test server uses `API_SERVER_PORT + 11`. Make sure this port is available or change it in the test configuration. + +## Need Help? + +- Check the [Testing README](./README.md) for full documentation +- Review example tests in `tests/integration/` +- Open an issue if you encounter problems + +## Checklist + +- [ ] Updated `vite.config.mts` to use framework setup +- [ ] Removed old `tests/setup.ts` +- [ ] Created `tests/integration/` directory +- [ ] Migrated all integration tests to `IntegrationTestCase` +- [ ] Renamed test files with `.integration.test.ts` suffix +- [ ] Updated imports to use `@frouvel/kaname/testing` +- [ ] Ran tests to verify everything works +- [ ] Removed manual database cleanup code +- [ ] Updated test documentation \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/README.md b/backend-api/@frouvel/kaname/testing/README.md new file mode 100644 index 0000000..4aad4d5 --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/README.md @@ -0,0 +1,582 @@ +# Testing Module + +Comprehensive testing utilities for the frourio-framework, providing a clean and expressive API for writing integration tests. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Test Cases](#test-cases) + - [TestCase](#testcase) + - [DatabaseTestCase](#databasetestcase) + - [IntegrationTestCase](#integrationtestcase) +- [Factories](#factories) +- [API Client](#api-client) +- [Setup & Configuration](#setup--configuration) +- [Best Practices](#best-practices) +- [Examples](#examples) + +## Overview + +The testing module provides: + +- **Test Case Base Classes**: Structured testing with lifecycle hooks +- **Database Management**: Automatic migrations, seeding, and cleanup +- **Integration Testing**: Full HTTP server testing with Fastify inject +- **Factory Pattern**: Generate test data easily +- **API Client**: Fluent interface for making HTTP requests +- **Assertions**: Rich assertion helpers for API testing + +## Installation + +The testing module is already included in the `@frouvel/kaname` framework. No additional installation required. + +## Quick Start + +### 1. Configure Vitest + +Your `vite.config.mts` should use the framework setup: + +```typescript +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + setupFiles: ['@frouvel/kaname/testing/setup.ts'], + env: { + DATABASE_URL: process.env.TEST_DATABASE_URL ?? '', + }, + }, +}); +``` + +### 2. Create Your First Test + +```typescript +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class MyFirstTest extends IntegrationTestCase { + run() { + this.suite('My First Test Suite', () => { + this.test('can make a request', async () => { + const response = await this.get('/api/health'); + this.assertOk(response); + expect(response.body).toHaveProperty('status'); + }); + }); + } +} + +new MyFirstTest().run(); +``` + +### 3. Run Tests + +```bash +npm test +``` + +## Test Cases + +### TestCase + +Base class for all tests. Provides lifecycle hooks and test organization. + +```typescript +import { TestCase } from '$/@frouvel/kaname/testing'; + +class MyTest extends TestCase { + protected async setUpBeforeClass(): Promise { + // Runs once before all tests in the suite + } + + protected async tearDownAfterClass(): Promise { + // Runs once after all tests in the suite + } + + protected async setUp(): Promise { + // Runs before each test + } + + protected async tearDown(): Promise { + // Runs after each test + } + + run() { + this.suite('My Test Suite', () => { + this.test('my test', () => { + expect(true).toBe(true); + }); + + this.skip('skipped test', () => { + // This test will be skipped + }); + + this.only('only this test runs', () => { + // Only this test will run + }); + }); + } +} + +new MyTest().run(); +``` + +### DatabaseTestCase + +Extends `TestCase` with database functionality. Automatically handles migrations and cleanup. + +```typescript +import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class MyDatabaseTest extends DatabaseTestCase { + protected async seed(): Promise { + // Seed data before each test + await this.prisma.user.create({ + data: { + name: 'Test User', + email: 'test@example.com', + age: 25, + }, + }); + } + + run() { + this.suite('Database Operations', () => { + this.test('can query database', async () => { + const users = await this.prisma.user.findMany(); + expect(users).toHaveLength(1); + }); + + this.test('database is refreshed between tests', async () => { + // Each test starts fresh + const users = await this.prisma.user.findMany(); + expect(users).toHaveLength(1); // Only seeded data + }); + + this.test('can use transactions', async () => { + await this.transaction(async (tx) => { + await tx.user.create({ + data: { + name: 'Transaction User', + email: 'tx@example.com', + age: 30, + }, + }); + }); + }); + }); + } +} + +new MyDatabaseTest().run(); +``` + +### IntegrationTestCase + +Extends `DatabaseTestCase` with full HTTP server testing capabilities. + +```typescript +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class MyIntegrationTest extends IntegrationTestCase { + run() { + this.suite('API Integration Tests', () => { + this.test('can make HTTP requests', async () => { + const response = await this.get('/api/users'); + this.assertOk(response); + }); + + this.test('can create resources', async () => { + const response = await this.post('/api/users', { + name: 'New User', + email: 'new@example.com', + age: 25, + }); + this.assertCreated(response); + }); + + this.test('can use authentication', async () => { + const token = 'my-jwt-token'; + const response = await this.get( + '/api/protected', + this.authHeader(token), + ); + this.assertOk(response); + }); + }); + } +} + +new MyIntegrationTest().run(); +``` + +## Factories + +Generate test data easily with the factory pattern. + +### Simple Factory + +```typescript +import { defineFactory, fake } from '$/@frouvel/kaname/testing'; + +const userFactory = defineFactory(() => ({ + name: fake.name(), + email: fake.email(), + age: fake.number(18, 80), +})); + +// Use in tests +const user = userFactory(); +const userWithOverrides = userFactory({ name: 'Specific Name' }); +``` + +### Fake Data Generators + +```typescript +import { fake } from '$/@frouvel/kaname/testing'; + +fake.string(10); // Random string +fake.email(); // Random email +fake.number(1, 100); // Random number between 1-100 +fake.boolean(); // Random boolean +fake.uuid(); // Random UUID +fake.date(); // Random date +fake.name(); // Random name +fake.text(10); // Random text with 10 words +fake.pick(['a', 'b', 'c']); // Pick random element +``` + +### Sequences + +```typescript +import { Sequence } from '$/@frouvel/kaname/testing'; + +// Generate sequential values +const userId = Sequence.next('user'); // 1 +const userId2 = Sequence.next('user'); // 2 + +// Reset sequence +Sequence.reset('user'); +``` + +### Advanced Factory (with Prisma) + +```typescript +import { Factory } from '$/@frouvel/kaname/testing'; +import type { PrismaClient } from '@prisma/client'; + +class UserFactory extends Factory<{ + name: string; + email: string; + age: number; +}> { + protected definition() { + return { + name: fake.name(), + email: fake.email(), + age: fake.number(18, 80), + }; + } + + async create(overrides?: any) { + const data = this.make(overrides); + return this.prisma!.user.create({ data }); + } +} + +// Use in tests +const factory = new UserFactory(prisma); +const user = await factory.create(); +const users = await factory.times(5).create(); +``` + +## API Client + +The `ApiClient` provides a fluent interface for making HTTP requests in tests. + +```typescript +import { ApiClient } from '$/@frouvel/kaname/testing'; + +const client = new ApiClient(server); + +// Basic requests +await client.get('/api/users'); +await client.post('/api/users', { name: 'User' }); +await client.put('/api/users/1', { name: 'Updated' }); +await client.patch('/api/users/1', { name: 'Patched' }); +await client.delete('/api/users/1'); + +// With headers +await client + .withHeaders({ 'X-Custom': 'value' }) + .get('/api/users'); + +// With authentication +await client + .withBearerToken('jwt-token') + .get('/api/protected'); + +// Assertions +const response = await client.get('/api/users'); +client.assertOk(response); +client.assertStatus(response, 200); +client.assertCreated(response); +client.assertNotFound(response); +client.assertUnauthorized(response); +client.assertResponseContains(response, { name: 'User' }); +client.assertHasPagination(response); +client.assertProblemDetails(response); +``` + +## Setup & Configuration + +### Test Environment Options + +```typescript +import { setupTestEnvironment } from '$/@frouvel/kaname/testing'; + +setupTestEnvironment({ + startServer: true, // Start test server + runMigrations: true, // Run database migrations + refreshDatabase: true, // Refresh DB before each test + port: 8091, // Custom test server port + seed: async () => { // Custom seed function + // Seed test data + }, +}); +``` + +### Environment Variables + +Create a `.env.test` file: + +```env +NODE_ENV=test +DATABASE_URL=postgresql://user:pass@localhost:5432/test_db +TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/test_db +API_SERVER_PORT=8080 +``` + +## Best Practices + +### 1. Name Tests Descriptively + +```typescript +// ❌ Bad +this.test('test 1', async () => { ... }); + +// ✅ Good +this.test('GET /api/users returns paginated users', async () => { ... }); +``` + +### 2. Use File Naming Convention + +```typescript +// Integration tests +tests/integration/users.integration.test.ts + +// Unit tests +tests/unit/services/UserService.test.ts +``` + +### 3. Seed Data in setUp/seed Methods + +```typescript +class MyTest extends IntegrationTestCase { + protected async seed(): Promise { + // Seed data here + await this.prisma.user.createMany({ ... }); + } +} +``` + +### 4. Use Assertion Helpers + +```typescript +// ❌ Less expressive +expect(response.status).toBe(200); + +// ✅ More expressive +this.assertOk(response); +this.assertCreated(response); +this.assertNotFound(response); +``` + +### 5. Test One Thing Per Test + +```typescript +// ❌ Testing too much +this.test('user CRUD', async () => { + // Create, read, update, delete all in one test +}); + +// ✅ One concern per test +this.test('can create user', async () => { ... }); +this.test('can read user', async () => { ... }); +this.test('can update user', async () => { ... }); +this.test('can delete user', async () => { ... }); +``` + +## Examples + +### Testing with Authentication + +```typescript +class AuthTest extends IntegrationTestCase { + private token?: string; + + protected async setUp(): Promise { + await super.setUp(); + + // Login and get token + const response = await this.post('/api/auth/login', { + email: 'admin@test.com', + password: 'password', + }); + + this.token = response.body.token; + } + + run() { + this.suite('Protected Routes', () => { + this.test('requires authentication', async () => { + const response = await this.get('/api/protected'); + this.assertUnauthorized(response); + }); + + this.test('allows authenticated requests', async () => { + const response = await this.get( + '/api/protected', + this.authHeader(this.token!), + ); + this.assertOk(response); + }); + }); + } +} +``` + +### Testing Pagination + +```typescript +class PaginationTest extends IntegrationTestCase { + protected async seed(): Promise { + // Create 25 users + await this.prisma.user.createMany({ + data: Array.from({ length: 25 }, (_, i) => ({ + name: `User ${i + 1}`, + email: `user${i + 1}@test.com`, + age: 20 + i, + })), + }); + } + + run() { + this.suite('Pagination', () => { + this.test('returns first page', async () => { + const response = await this.get('/api/users?page=1&limit=10'); + + this.assertOk(response); + expect(response.body.data).toHaveLength(10); + expect(response.body.meta.currentPage).toBe(1); + expect(response.body.meta.totalPages).toBe(3); + expect(response.body.meta.totalCount).toBe(25); + }); + + this.test('returns last page', async () => { + const response = await this.get('/api/users?page=3&limit=10'); + + this.assertOk(response); + expect(response.body.data).toHaveLength(5); + expect(response.body.meta.currentPage).toBe(3); + }); + }); + } +} +``` + +### Testing Error Responses (RFC9457) + +```typescript +class ErrorHandlingTest extends IntegrationTestCase { + run() { + this.suite('Error Handling', () => { + this.test('returns RFC9457 Problem Details', async () => { + const response = await this.get('/api/users/invalid-id'); + + this.assertNotFound(response); + expect(response.body).toMatchObject({ + type: expect.any(String), + title: expect.any(String), + status: 404, + detail: expect.any(String), + }); + }); + + this.test('handles validation errors', async () => { + const response = await this.post('/api/users', { + name: '', // Invalid + email: 'not-an-email', // Invalid + age: -1, // Invalid + }); + + expect([400, 422]).toContain(response.status); + expect(response.body.type).toContain('validation'); + }); + }); + } +} +``` + +## Running Tests + +```bash +# Run all tests +npm test + +# Run specific test file +npm test tests/integration/users.integration.test.ts + +# Run tests in watch mode +npm test -- --watch + +# Run tests with coverage +npm test -- --coverage +``` + +## Troubleshooting + +### Database Connection Issues + +Ensure `TEST_DATABASE_URL` is set in your environment: + +```env +TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/test_db +``` + +### Port Already in Use + +The test server uses `API_SERVER_PORT + 11`. If this port is busy, change it in your test environment configuration. + +### Migration Failures + +Reset the test database: + +```bash +npx prisma migrate reset --force +``` + +## Additional Resources + +- [Vitest Documentation](https://vitest.dev/) +- [Prisma Testing Guide](https://www.prisma.io/docs/guides/testing) +- [Fastify Testing](https://www.fastify.io/docs/latest/Guides/Testing/) +- [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/TestCase.ts b/backend-api/@frouvel/kaname/testing/TestCase.ts new file mode 100644 index 0000000..c9d8a16 --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/TestCase.ts @@ -0,0 +1,105 @@ +import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import type { TestFunction } from 'vitest'; + +/** + * Base TestCase class for all tests + * Provides common testing functionality and lifecycle hooks + */ +export abstract class TestCase { + protected server?: FastifyInstance; + + /** + * Run before all tests in the test suite + */ + protected async setUpBeforeClass(): Promise { + // Override in subclass if needed + } + + /** + * Run after all tests in the test suite + */ + protected async tearDownAfterClass(): Promise { + // Override in subclass if needed + } + + /** + * Run before each test + */ + protected async setUp(): Promise { + // Override in subclass if needed + } + + /** + * Run after each test + */ + protected async tearDown(): Promise { + // Override in subclass if needed + } + + /** + * Register lifecycle hooks with Vitest + */ + protected registerHooks(): void { + beforeAll(async () => { + await this.setUpBeforeClass(); + }); + + afterAll(async () => { + await this.tearDownAfterClass(); + }); + + beforeEach(async () => { + await this.setUp(); + }); + + afterEach(async () => { + await this.tearDown(); + }); + } + + /** + * Run a test suite + */ + protected suite(name: string, fn: () => void): void { + describe(name, () => { + this.registerHooks(); + fn(); + }); + } + + /** + * Define a test + */ + protected test(name: string, fn: TestFunction): void { + it(name, fn); + } + + /** + * Define a test that should be skipped + */ + protected skip(name: string, fn: TestFunction): void { + it.skip(name, fn); + } + + /** + * Define a test that should be the only one to run + */ + protected only(name: string, fn: TestFunction): void { + it.only(name, fn); + } + + /** + * Define a test that is expected to fail + */ + protected fails(name: string, fn: TestFunction): void { + it.fails(name, fn); + } + + /** + * Define a concurrent test + */ + protected concurrent(name: string, fn: TestFunction): void { + it.concurrent(name, fn); + } +} \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/index.ts b/backend-api/@frouvel/kaname/testing/index.ts new file mode 100644 index 0000000..f068551 --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/index.ts @@ -0,0 +1,17 @@ +/** + * Testing Module + * + * Provides comprehensive testing utilities for the frourio-framework + * + * @module @frouvel/kaname/testing + */ + +export { TestCase } from './TestCase'; +export { DatabaseTestCase } from './DatabaseTestCase'; +export { IntegrationTestCase } from './IntegrationTestCase'; +export { Factory, defineFactory, Sequence, fake } from './Factory'; +export type { FactoryFunction, FactoryBuilder } from './Factory'; +export { setupTestEnvironment } from './setup'; +export type { TestEnvironmentOptions } from './setup'; +export { ApiClient } from './ApiClient'; +export type { ApiResponse } from './ApiClient'; \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/testing/setup.ts b/backend-api/@frouvel/kaname/testing/setup.ts new file mode 100644 index 0000000..7fb75e8 --- /dev/null +++ b/backend-api/@frouvel/kaname/testing/setup.ts @@ -0,0 +1,158 @@ +import type { FastifyInstance } from 'fastify'; +import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; +import util from 'util'; +import { exec } from 'child_process'; +import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { env } from '$/env'; +import { init } from '$/service/app'; + +const execPromise = util.promisify(exec); + +export interface TestEnvironmentOptions { + /** + * Whether to start a test server + */ + startServer?: boolean; + + /** + * Whether to run database migrations + */ + runMigrations?: boolean; + + /** + * Whether to refresh database before each test + */ + refreshDatabase?: boolean; + + /** + * Custom port for test server (defaults to API_SERVER_PORT + 11) + */ + port?: number; + + /** + * Custom seed function to run before each test + */ + seed?: () => Promise; +} + +/** + * Setup test environment for Vitest + * This function should be called in vitest.config.mts setupFiles + */ +export function setupTestEnvironment(options: TestEnvironmentOptions = {}) { + const { + startServer = true, + runMigrations = true, + refreshDatabase = true, + port = env.API_SERVER_PORT + 11, + seed, + } = options; + + let server: FastifyInstance | undefined; + const prisma = getPrismaClient(); + let isMigrated = false; + + /** + * Check if this is an integration test + */ + const isIntegrationTest = (file: { filepath?: string } | undefined) => { + return file?.filepath?.includes('integration.test'); + }; + + /** + * Check if server is needed for this test + */ + const needsServer = (file: { filepath?: string } | undefined) => { + return startServer && isIntegrationTest(file); + }; + + /** + * Refresh database by truncating all tables + */ + const refreshDatabaseTables = async () => { + const tablenames = await prisma.$queryRaw< + Array<{ tablename: string }> + >`SELECT tablename FROM pg_tables WHERE schemaname='public'`; + + const tables = tablenames + .map(({ tablename }) => tablename) + .filter((name) => name !== '_prisma_migrations') + .map((name) => `"public"."${name}"`) + .join(', '); + + if (tables) { + try { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`); + } catch (error) { + console.error('Failed to truncate tables:', error); + } + } + }; + + // Register lifecycle hooks + beforeAll(async (info) => { + if (!needsServer({ filepath: info.file.filepath })) return; + + // Run migrations once + if (runMigrations && !isMigrated) { + try { + await execPromise('npx prisma migrate deploy'); + isMigrated = true; + } catch (error) { + console.error('Migration failed:', error); + console.log('Resetting test database...'); + try { + await execPromise('npx prisma migrate reset --force'); + isMigrated = true; + } catch (resetError) { + console.error('Database reset failed:', resetError); + throw resetError; + } + } + } + + // Start test server + server = init(); + await server.listen({ port, host: '0.0.0.0' }); + }); + + beforeEach(async (info) => { + if (!isIntegrationTest({ filepath: info?.task?.file?.name })) return; + + // Refresh database before each test + if (refreshDatabase) { + await refreshDatabaseTables(); + } + + // Run custom seed function + if (seed) { + await seed(); + } + }); + + afterEach(async (info) => { + if (!isIntegrationTest({ filepath: info?.task?.file?.name })) return; + + await prisma.$disconnect(); + }); + + afterAll(async (info) => { + if (!needsServer({ filepath: info.file.filepath })) return; + + // Clear mocks + vi.clearAllMocks(); + vi.clearAllTimers(); + + // Close server + if (server) { + await server.close(); + } + + await prisma.$disconnect(); + }); + + return { + server, + prisma, + }; +} \ No newline at end of file diff --git a/backend-api/README.md b/backend-api/README.md index dde9063..bb123f3 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -251,11 +251,57 @@ export default defineController(() => ({ ## Testing +The project includes a comprehensive testing framework at `@frouvel/kaname/testing`. + +### Quick Start + +```typescript +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; + +class MyTest extends IntegrationTestCase { + run() { + this.suite('My Test Suite', () => { + this.test('my test', async () => { + const response = await this.get('/api/health'); + this.assertOk(response); + }); + }); + } +} + +new MyTest().run(); +``` + +### Running Tests + ```bash -npm run test +# Run all tests +npm test + +# Run specific test file +npm test tests/integration/users.integration.test.ts + +# Run tests in watch mode +npm test -- --watch + +# Run tests with coverage +npm test -- --coverage ``` -Tests are written using Vitest. +### Features + +- **Test Case Classes**: `TestCase`, `DatabaseTestCase`, `IntegrationTestCase` +- **Factory Pattern**: Generate test data with `Factory` and `fake` helpers +- **API Client**: Fluent HTTP request interface +- **Automatic Setup**: Database migrations, seeding, and cleanup +- **Rich Assertions**: Expressive assertion helpers + +### Documentation + +- [Testing Framework Documentation](@frouvel/kaname/testing/README.md) +- [Migration Guide](@frouvel/kaname/testing/MIGRATION_GUIDE.md) +- [Example Tests](tests/integration/) ## Environment Variables @@ -313,6 +359,7 @@ For detailed documentation, see: ## Documentation - [Artisan Console](@frouvel/kaname/console/README.md) +- [Testing Framework](@frouvel/kaname/testing/README.md) - [Swagger/OpenAPI](@frouvel/kaname/swagger/README.md) - [RFC9457 Error Handling](docs/RFC9457_ERROR_HANDLING.md) - [Response Builder](docs/RESPONSE_BUILDER.md) diff --git a/backend-api/tests/integration/health.integration.test.ts b/backend-api/tests/integration/health.integration.test.ts new file mode 100644 index 0000000..6a3714b --- /dev/null +++ b/backend-api/tests/integration/health.integration.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'vitest'; +import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; + +/** + * Example Integration Test for Health Endpoint + * + * This test demonstrates the basic usage of IntegrationTestCase + */ +class HealthIntegrationTest extends IntegrationTestCase { + run() { + this.suite('Health API', () => { + this.test('GET /api/health returns OK', async () => { + const response = await this.get('/api/health'); + + expect(response.status).toBe(200); + expect(response.body).toBe('OK'); + }); + + this.test('GET /api/health returns correct status', async () => { + const response = await this.get('/api/health'); + + this.assertOk(response); + expect(response.body).toBe('OK'); + }); + }); + } +} + +// Run the test suite +new HealthIntegrationTest().run(); diff --git a/backend-api/tests/integration/users.integration.test.ts b/backend-api/tests/integration/users.integration.test.ts new file mode 100644 index 0000000..221a2b5 --- /dev/null +++ b/backend-api/tests/integration/users.integration.test.ts @@ -0,0 +1,65 @@ +import { expect } from 'vitest'; +import { TestCase } from '$/@frouvel/kaname/testing'; +import { fake } from '$/@frouvel/kaname/testing'; + +/** + * Example Test demonstrating testing utilities + * + * NOTE: This uses TestCase (not IntegrationTestCase) because + * it doesn't need database or HTTP server functionality. + */ +class TestingUtilitiesTest extends TestCase { + run() { + this.suite('Testing Framework Utilities', () => { + this.test('can use fake data generators', () => { + // Demonstrate fake data utilities + const randomString = fake.string(10); + const randomEmail = fake.email(); + const randomNumber = fake.number(1, 100); + const randomBoolean = fake.boolean(); + const randomUuid = fake.uuid(); + const randomName = fake.name(); + const randomText = fake.text(5); + + expect(randomString).toHaveLength(10); + expect(randomEmail).toContain('@'); + expect(randomEmail).toContain('test.com'); + expect(randomNumber).toBeGreaterThanOrEqual(1); + expect(randomNumber).toBeLessThanOrEqual(100); + expect(typeof randomBoolean).toBe('boolean'); + expect(randomUuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(typeof randomName).toBe('string'); + expect(randomName.length).toBeGreaterThan(0); + expect(typeof randomText).toBe('string'); + }); + + this.test('fake.pick selects from array', () => { + const options = ['option1', 'option2', 'option3']; + const selected = fake.pick(options); + + expect(options).toContain(selected); + }); + + this.test('fake.date generates valid dates', () => { + const date = fake.date(); + + expect(date).toBeInstanceOf(Date); + expect(date.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + this.test('fake.date can generate dates in range', () => { + const start = new Date('2024-01-01'); + const end = new Date('2024-12-31'); + const date = fake.date(start, end); + + expect(date.getTime()).toBeGreaterThanOrEqual(start.getTime()); + expect(date.getTime()).toBeLessThanOrEqual(end.getTime()); + }); + }); + } +} + +// Run the test suite +new TestingUtilitiesTest().run(); diff --git a/backend-api/vite.config.mts b/backend-api/vite.config.mts index 3129b56..2550a1e 100644 --- a/backend-api/vite.config.mts +++ b/backend-api/vite.config.mts @@ -14,7 +14,7 @@ export default defineConfig({ env: { DATABASE_URL: process.env.TEST_DATABASE_URL ?? '', }, - setupFiles: ['tests/setup.ts'], + setupFiles: ['@frouvel/kaname/testing/setup.ts'], includeSource: ['**/*.ts'], hookTimeout: 100000, testTimeout: 10000, From de7885ea1e619def24c4a87fe9164f7415dd5751 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 14 Nov 2025 05:17:09 +0900 Subject: [PATCH 03/28] feat(database): Implement DatabaseManager for Prisma and Drizzle support - Added DatabaseManager class for managing database connections and providing direct access to Prisma and Drizzle clients. - Introduced DatabaseManager interface for type safety and structure. - Created README.md for database module with usage examples and configuration details. - Updated index.ts to export new DatabaseManager and related types. - Added support for multiple database connections and transactions. - Implemented graceful shutdown and health check methods. - Deprecated legacy Prisma utilities in favor of the new DB facade. feat(http): Introduce ApiResponseBuilder for streamlined response handling - Added ApiResponseBuilder class for building API responses with validation and error handling. - Updated ApiResponse to export ApiResponseBuilder and maintain backward compatibility with ResponseBuilder. - Created tests for ApiResponseBuilder to ensure validation and response handling works as expected. - Updated example controller to utilize ApiResponseBuilder for cleaner validation logic. fix(config): Refactor database configuration to support multiple drivers - Updated database configuration to support both Prisma and Drizzle ORM. - Simplified connection definitions and added example configurations for read replicas and analytics. - Ensured type safety with updated DatabaseConfig type definitions. --- backend-api/@frouvel/kaname/config/example.ts | 6 +- .../@frouvel/kaname/database/ARCHITECTURE.md | 365 +++++++++++ backend-api/@frouvel/kaname/database/DB.ts | 249 +++++++ .../kaname/database/DatabaseManager.ts | 267 ++++++++ .../@frouvel/kaname/database/README.md | 612 +++++++++--------- .../contracts/DatabaseManager.interface.ts | 110 ++++ backend-api/@frouvel/kaname/database/index.ts | 17 +- .../@frouvel/kaname/http/ApiResponse.ts | 6 +- ...der.test.ts => ApiResponseBuilder.test.ts} | 34 +- ...sponseBuilder.ts => ApiResponseBuilder.ts} | 12 +- .../api/example/error/rfc9457/controller.ts | 12 +- backend-api/config/database.ts | 94 +-- 12 files changed, 1389 insertions(+), 395 deletions(-) create mode 100644 backend-api/@frouvel/kaname/database/ARCHITECTURE.md create mode 100644 backend-api/@frouvel/kaname/database/DB.ts create mode 100644 backend-api/@frouvel/kaname/database/DatabaseManager.ts create mode 100644 backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts rename backend-api/@frouvel/kaname/http/{ResponseBuilder.test.ts => ApiResponseBuilder.test.ts} (90%) rename backend-api/@frouvel/kaname/http/{ResponseBuilder.ts => ApiResponseBuilder.ts} (93%) diff --git a/backend-api/@frouvel/kaname/config/example.ts b/backend-api/@frouvel/kaname/config/example.ts index 2149ab9..2634676 100644 --- a/backend-api/@frouvel/kaname/config/example.ts +++ b/backend-api/@frouvel/kaname/config/example.ts @@ -37,10 +37,10 @@ export function demonstrateConfigUsage() { ); console.log( - ` Function: config('database.pool.max'): ${config('database.pool.max')}`, + ` Function: config('database.connections.default.pool.max'): ${config('database.connections.default.pool.max')}`, ); console.log( - ` Property: configObject.database.pool.max: ${configObject.database.pool.max}`, + ` Property: configObject.database.connections.default.pool.max: ${configObject.database.connections.default.pool?.max}`, ); // Example 4: Default values (function only) @@ -56,7 +56,7 @@ export function demonstrateConfigUsage() { const debug: boolean = configObject.app.debug; console.log(` Debug mode (boolean): ${debug}`); - const poolMax: number = configObject.database.pool.max; + const poolMax: number | undefined = configObject.database.connections.default.pool?.max; console.log(` DB Pool Max (number): ${poolMax}`); const jwtAlgorithm: 'HS256' = configObject.jwt.algorithm; diff --git a/backend-api/@frouvel/kaname/database/ARCHITECTURE.md b/backend-api/@frouvel/kaname/database/ARCHITECTURE.md new file mode 100644 index 0000000..22d2487 --- /dev/null +++ b/backend-api/@frouvel/kaname/database/ARCHITECTURE.md @@ -0,0 +1,365 @@ +# Database Abstraction Architecture + +## Overview + +This document outlines the architecture for database abstraction in frourio-framework, supporting multiple ORMs (Prisma, Drizzle) similar to Laravel's Eloquent ORM and DB facade patterns. + +## Goals + +1. **ORM Agnostic**: Support both Prisma and Drizzle ORM seamlessly +2. **Consistent API**: Provide unified interface regardless of underlying ORM +3. **Type Safety**: Maintain TypeScript type safety across ORMs +4. **Testing**: Easy mocking and testing regardless of ORM choice +5. **Migration**: Simple switching between ORMs with minimal code changes + +## Architecture Overview + +``` +┌─────────────────────────────────────────────┐ +│ Application Layer │ +│ (UseCases, Services, Repositories) │ +└─────────────────┬───────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Database Manager (Facade) │ +│ - DB.connection() │ +│ - DB.transaction() │ +│ - DB.table() │ +└─────────────────┬───────────────────────────┘ + │ + ┌─────────┴──────────┐ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Prisma │ │ Drizzle │ +│ Adapter │ │ Adapter │ +└──────────────┘ └──────────────┘ +``` + +## Core Components + +### 1. Database Connection Interface + +```typescript +interface DatabaseConnection { + // Execute raw queries + raw(query: string, params?: any[]): Promise; + + // Transaction support + transaction(callback: (tx: DatabaseConnection) => Promise): Promise; + + // Get underlying client + client(): any; + + // Connection management + connect(): Promise; + disconnect(): Promise; +} +``` + +### 2. Query Builder Interface + +```typescript +interface QueryBuilder { + select(...columns: string[]): this; + where(column: string, operator: string, value: any): this; + whereIn(column: string, values: any[]): this; + orderBy(column: string, direction?: 'asc' | 'desc'): this; + limit(count: number): this; + offset(count: number): this; + + // Execution methods + first(): Promise; + get(): Promise; + count(): Promise; + insert(data: Partial): Promise; + update(data: Partial): Promise; + delete(): Promise; +} +``` + +### 3. Database Manager (Facade) + +```typescript +class DatabaseManager { + private adapters: Map = new Map(); + private defaultConnection: string = 'default'; + + // Register ORM adapter + extend(name: string, adapter: DatabaseAdapter): void; + + // Get connection + connection(name?: string): DatabaseConnection; + + // Query builder + table(tableName: string): QueryBuilder; + + // Transaction wrapper + transaction(callback: (db: DatabaseManager) => Promise): Promise; + + // Raw queries + raw(query: string, params?: any[]): Promise; +} +``` + +### 4. ORM Adapters + +#### Prisma Adapter + +```typescript +class PrismaAdapter implements DatabaseAdapter { + constructor(private client: PrismaClient) {} + + getConnection(): DatabaseConnection { + return new PrismaConnection(this.client); + } + + createQueryBuilder(table: string): QueryBuilder { + return new PrismaQueryBuilder(this.client, table); + } +} +``` + +#### Drizzle Adapter + +```typescript +class DrizzleAdapter implements DatabaseAdapter { + constructor(private client: DrizzleClient) {} + + getConnection(): DatabaseConnection { + return new DrizzleConnection(this.client); + } + + createQueryBuilder(table: string): QueryBuilder { + return new DrizzleQueryBuilder(this.client, table); + } +} +``` + +## Usage Patterns + +### Pattern 1: Using DB Facade + +```typescript +import { DB } from '$/@frouvel/kaname/database'; + +// Raw queries +const users = await DB.raw( + 'SELECT * FROM users WHERE age > ?', + [18] +); + +// Query builder +const activeUsers = await DB.table('users') + .where('status', '=', 'active') + .where('age', '>', 18) + .orderBy('created_at', 'desc') + .get(); + +// Transactions +await DB.transaction(async (db) => { + const user = await db.table('users').insert({ name: 'John' }); + await db.table('profiles').insert({ user_id: user.id }); +}); +``` + +### Pattern 2: Repository Pattern + +```typescript +export class UserRepository { + private db = DB.connection(); + + async findById(id: string): Promise { + return DB.table('users') + .where('id', '=', id) + .first(); + } + + async createUser(data: CreateUserDto): Promise { + return DB.table('users').insert(data); + } + + async paginate(page: number, limit: number) { + const users = await DB.table('users') + .limit(limit) + .offset((page - 1) * limit) + .get(); + + const total = await DB.table('users').count(); + + return { users, total }; + } +} +``` + +### Pattern 3: Direct Client Access (when needed) + +```typescript +import { DB } from '$/@frouvel/kaname/database'; + +// Get Prisma client +const prisma = DB.connection().client() as PrismaClient; +await prisma.user.findMany({ + include: { posts: true } +}); + +// Get Drizzle client +const drizzle = DB.connection('drizzle').client() as DrizzleClient; +await drizzle.select().from(users); +``` + +## Configuration + +### config/database.ts + +```typescript +export default { + default: 'prisma', // or 'drizzle' + + connections: { + prisma: { + driver: 'prisma', + url: env('DATABASE_URL'), + pool: { + min: env('DB_POOL_MIN', 2), + max: env('DB_POOL_MAX', 10), + }, + }, + + drizzle: { + driver: 'drizzle', + client: 'postgres', + connection: { + host: env('DB_HOST', 'localhost'), + port: env('DB_PORT', 5432), + user: env('DB_USER'), + password: env('DB_PASSWORD'), + database: env('DB_DATABASE'), + }, + }, + }, +}; +``` + +## Testing Support + +### Updated DatabaseTestCase + +```typescript +export abstract class DatabaseTestCase extends TestCase { + protected db = DB.connection(); + + protected async refreshDatabase(): Promise { + // ORM-agnostic database refresh + await this.db.raw('TRUNCATE TABLE users, posts CASCADE'); + } + + protected async seed(): Promise { + // Override in test classes + } + + protected async transaction(fn: (db: DatabaseManager) => Promise) { + return DB.transaction(fn); + } +} +``` + +### Test Example + +```typescript +class UserRepositoryTest extends DatabaseTestCase { + private repository: UserRepository; + + protected async setUp(): Promise { + await super.setUp(); + this.repository = new UserRepository(); + } + + run() { + this.suite('UserRepository', () => { + this.test('can create user', async () => { + const user = await this.repository.createUser({ + name: 'John Doe', + email: 'john@example.com', + }); + + expect(user.id).toBeDefined(); + expect(user.name).toBe('John Doe'); + }); + }); + } +} +``` + +## Migration Path + +### Phase 1: Core Abstraction +- [ ] Create DatabaseConnection interface +- [ ] Create QueryBuilder interface +- [ ] Create DatabaseAdapter interface +- [ ] Implement DatabaseManager (DB facade) + +### Phase 2: Prisma Adapter +- [ ] Implement PrismaConnection +- [ ] Implement PrismaQueryBuilder +- [ ] Implement PrismaAdapter +- [ ] Register in DatabaseManager + +### Phase 3: Drizzle Adapter +- [ ] Implement DrizzleConnection +- [ ] Implement DrizzleQueryBuilder +- [ ] Implement DrizzleAdapter +- [ ] Register in DatabaseManager + +### Phase 4: Testing Support +- [ ] Update DatabaseTestCase +- [ ] Update test helpers +- [ ] Create migration utilities +- [ ] Add test examples + +### Phase 5: Documentation +- [ ] API documentation +- [ ] Migration guide +- [ ] Best practices +- [ ] Example repositories + +## Benefits + +1. **Flexibility**: Switch ORMs without changing application code +2. **Consistency**: Unified API across different ORMs +3. **Testing**: Easy to mock and test +4. **Type Safety**: Full TypeScript support +5. **Gradual Migration**: Migrate from one ORM to another incrementally + +## Trade-offs + +### Pros +- ORM-agnostic application code +- Easier testing and mocking +- Consistent API across projects +- Simpler ORM migration + +### Cons +- Additional abstraction layer +- Potential performance overhead +- May not expose all ORM-specific features +- Learning curve for new pattern + +## Recommendations + +1. **Start with Prisma**: Use Prisma adapter as the default +2. **Facade Pattern**: Use DB facade for common operations +3. **Direct Access**: Use direct client access for complex queries +4. **Repository Pattern**: Encapsulate database logic in repositories +5. **Testing**: Use the abstraction layer in tests for flexibility + +## Future Enhancements + +1. **Migration System**: ORM-agnostic migration framework +2. **Seeding**: Unified seeding interface +3. **Connection Pooling**: Advanced pool management +4. **Query Logging**: Centralized query logging +5. **Performance Monitoring**: Query performance tracking +6. **Schema Builder**: Programmatic schema definition +7. **Multi-tenancy**: Support for multi-tenant databases \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/database/DB.ts b/backend-api/@frouvel/kaname/database/DB.ts new file mode 100644 index 0000000..a76b486 --- /dev/null +++ b/backend-api/@frouvel/kaname/database/DB.ts @@ -0,0 +1,249 @@ +import { DatabaseManager } from './DatabaseManager'; +import type { + DatabaseConfig, + DatabaseManager as IDatabaseManager, +} from './contracts/DatabaseManager.interface'; + +/** + * DB Facade + * + * Global singleton for database access with zero performance overhead. + * Provides direct pass-through to Prisma/Drizzle clients. + * + * @example + * ```typescript + * import { DB } from '$/@frouvel/kaname/database'; + * + * // Direct Prisma access (zero overhead) + * const prisma = DB.prisma(); + * const users = await prisma.user.findMany({ + * where: { age: { gt: 18 } }, + * include: { posts: true } + * }); + * + * // Transactions + * await DB.transaction(async (prisma) => { + * await prisma.user.create({ data: { name: 'John' } }); + * await prisma.profile.create({ data: { userId: 1 } }); + * }); + * + * // Multiple connections + * const readReplica = DB.prisma('read-replica'); + * const analytics = DB.drizzle('analytics'); + * ``` + */ +class DBFacade { + private manager: DatabaseManager | null = null; + private config: DatabaseConfig | null = null; + + /** + * Initialize the database manager with configuration + */ + init(config: DatabaseConfig): void { + this.config = config; + this.manager = new DatabaseManager(config); + } + + /** + * Get the underlying database manager instance + * Useful for advanced usage + */ + getManager(): IDatabaseManager { + if (!this.manager) { + throw new Error( + 'Database manager not initialized. Call DB.init(config) first or use the DatabaseServiceProvider.', + ); + } + return this.manager; + } + + /** + * Register a pre-configured database client + * + * @example + * ```typescript + * import { PrismaClient } from '@prisma/client'; + * + * const prisma = new PrismaClient(); + * DB.register('default', prisma, 'prisma'); + * ``` + */ + register(name: string, client: any, driver: 'prisma' | 'drizzle'): void { + if (!this.manager) { + // Auto-initialize if not already initialized + this.init({ + default: name, + connections: {}, + }); + } + this.manager!.registerClient(name, client, driver); + } + + /** + * Get Prisma client for direct access (zero overhead) + * + * @example + * ```typescript + * const prisma = DB.prisma(); + * const users = await prisma.user.findMany(); + * + * // Use Prisma's full API + * const result = await prisma.user.findMany({ + * where: { age: { gte: 18 } }, + * include: { posts: { take: 5 } }, + * orderBy: { createdAt: 'desc' } + * }); + * ``` + */ + prisma(connection?: string): T { + const client = this.getManager().prisma(connection); + if (!client) { + const name = connection || this.getManager().getDefaultConnection(); + throw new Error( + `Prisma client not available for connection '${name}'. Make sure the connection is configured with driver: 'prisma'.`, + ); + } + return client; + } + + /** + * Get Drizzle client for direct access (zero overhead) + * + * @example + * ```typescript + * const db = DB.drizzle(); + * const users = await db.select().from(usersTable); + * + * // Use Drizzle's full API + * const result = await db + * .select() + * .from(users) + * .where(eq(users.age, 18)) + * .leftJoin(posts, eq(users.id, posts.userId)); + * ``` + */ + drizzle(connection?: string): T { + const client = this.getManager().drizzle(connection); + if (!client) { + const name = connection || this.getManager().getDefaultConnection(); + throw new Error( + `Drizzle client not available for connection '${name}'. Make sure the connection is configured with driver: 'drizzle'.`, + ); + } + return client; + } + + /** + * Get the underlying database client (ORM-agnostic) + * + * @example + * ```typescript + * const client = DB.client(); + * // client is either Prisma or Drizzle depending on configuration + * ``` + */ + client(connection?: string): T { + return this.getManager().client(connection); + } + + /** + * Execute a function within a database transaction + * + * @example + * ```typescript + * // Prisma transaction + * await DB.transaction(async (prisma) => { + * const user = await prisma.user.create({ data: { name: 'John' } }); + * await prisma.profile.create({ data: { userId: user.id } }); + * }); + * + * // Drizzle transaction + * await DB.transaction(async (tx) => { + * await tx.insert(users).values({ name: 'John' }); + * await tx.insert(profiles).values({ userId: 1 }); + * }); + * ``` + */ + async transaction( + callback: (client: any) => Promise, + connection?: string, + ): Promise { + return this.getManager().transaction(callback, connection); + } + + /** + * Disconnect from a specific database connection + */ + async disconnect(connection?: string): Promise { + return this.getManager().disconnect(connection); + } + + /** + * Disconnect from all database connections + * + * @example + * ```typescript + * // In your app shutdown handler + * process.on('SIGTERM', async () => { + * await DB.disconnectAll(); + * process.exit(0); + * }); + * ``` + */ + async disconnectAll(): Promise { + return this.getManager().disconnectAll(); + } + + /** + * Check if a connection is established + */ + isConnected(connection?: string): boolean { + return this.getManager().isConnected(connection); + } + + /** + * Get the default connection name + */ + getDefaultConnection(): string { + return this.getManager().getDefaultConnection(); + } + + /** + * Set the default connection + * + * @example + * ```typescript + * // Switch to read replica for read-heavy operations + * DB.setDefaultConnection('read-replica'); + * const users = DB.prisma().user.findMany(); + * + * // Switch back to primary + * DB.setDefaultConnection('primary'); + * ``` + */ + setDefaultConnection(name: string): void { + return this.getManager().setDefaultConnection(name); + } + + /** + * Reset the database manager (useful for testing) + */ + reset(): void { + this.manager = null; + this.config = null; + } +} + +/** + * Global DB Facade instance + * + * Import this in your code to access database functionality. + * + * @example + * ```typescript + * import { DB } from '$/@frouvel/kaname/database'; + * + * const users = await DB.prisma().user.findMany(); + * ``` + */ +export const DB = new DBFacade(); \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/database/DatabaseManager.ts b/backend-api/@frouvel/kaname/database/DatabaseManager.ts new file mode 100644 index 0000000..963872f --- /dev/null +++ b/backend-api/@frouvel/kaname/database/DatabaseManager.ts @@ -0,0 +1,267 @@ +import type { + DatabaseManager as IDatabaseManager, + ConnectionConfig, +} from './contracts/DatabaseManager.interface'; +import type { PrismaClient } from '@prisma/client'; + +/** + * Database Manager + * + * Manages database connections and provides direct pass-through access + * to ORM clients (Prisma, Drizzle) for zero performance overhead. + * + * @example + * ```typescript + * // Direct Prisma access (zero overhead) + * const prisma = DB.prisma(); + * const users = await prisma.user.findMany(); + * + * // Transactions + * await DB.transaction(async (tx) => { + * await tx.user.create({ data: { name: 'John' } }); + * }); + * + * // Multiple connections + * const readReplica = DB.prisma('read-replica'); + * ``` + */ +export class DatabaseManager implements IDatabaseManager { + private defaultConnection: string; + private connections: Map = new Map(); + private clients: Map = new Map(); + private connectedClients: Set = new Set(); + + constructor(config: { + default: string; + connections: Record; + }) { + this.defaultConnection = config.default; + + // Store connection configurations + for (const [name, connConfig] of Object.entries(config.connections)) { + this.connections.set(name, connConfig); + } + } + + /** + * Get the default connection name + */ + getDefaultConnection(): string { + return this.defaultConnection; + } + + /** + * Set the default connection + */ + setDefaultConnection(name: string): void { + if (!this.connections.has(name)) { + throw new Error(`Connection '${name}' is not configured`); + } + this.defaultConnection = name; + } + + /** + * Get Prisma client for direct access (zero overhead) + */ + prisma(connection?: string): T | null { + const name = connection || this.defaultConnection; + const config = this.connections.get(name); + + if (!config || config.driver !== 'prisma') { + return null; + } + + return this.getOrCreateClient(name); + } + + /** + * Get Drizzle client for direct access (zero overhead) + */ + drizzle(connection?: string): T | null { + const name = connection || this.defaultConnection; + const config = this.connections.get(name); + + if (!config || config.driver !== 'drizzle') { + return null; + } + + return this.getOrCreateClient(name); + } + + /** + * Get the underlying client (ORM-agnostic) + */ + client(connection?: string): T { + const name = connection || this.defaultConnection; + return this.getOrCreateClient(name); + } + + /** + * Execute within a transaction + */ + async transaction( + callback: (client: any) => Promise, + connection?: string, + ): Promise { + const name = connection || this.defaultConnection; + const config = this.connections.get(name); + + if (!config) { + throw new Error(`Connection '${name}' is not configured`); + } + + const client = this.getOrCreateClient(name); + + // Prisma transaction + if (config.driver === 'prisma') { + return (client as any).$transaction(callback); + } + + // Drizzle transaction + if (config.driver === 'drizzle') { + return (client as any).transaction(callback); + } + + throw new Error(`Unsupported driver: ${config.driver}`); + } + + /** + * Disconnect from a specific database + */ + async disconnect(connection?: string): Promise { + const name = connection || this.defaultConnection; + const client = this.clients.get(name); + + if (!client) { + return; + } + + const config = this.connections.get(name); + if (!config) { + return; + } + + try { + if (config.driver === 'prisma') { + await client.$disconnect(); + } else if (config.driver === 'drizzle') { + // Drizzle disconnect logic + if (client.end) { + await client.end(); + } + } + + this.connectedClients.delete(name); + } catch (error) { + console.error(`Failed to disconnect from '${name}':`, error); + } + } + + /** + * Disconnect from all databases + */ + async disconnectAll(): Promise { + const disconnectPromises = Array.from(this.clients.keys()).map((name) => + this.disconnect(name), + ); + + await Promise.all(disconnectPromises); + this.clients.clear(); + this.connectedClients.clear(); + } + + /** + * Check if a connection is established + */ + isConnected(connection?: string): boolean { + const name = connection || this.defaultConnection; + return this.connectedClients.has(name); + } + + /** + * Register a pre-configured client + * Useful for testing or when you already have a client instance + */ + registerClient( + name: string, + client: any, + driver: 'prisma' | 'drizzle', + ): void { + this.clients.set(name, client); + this.connections.set(name, { + driver, + // Other config doesn't matter since we're using a pre-configured client + }); + this.connectedClients.add(name); + } + + /** + * Get or create a client for the given connection + */ + private getOrCreateClient(name: string): T { + // Return existing client if available + if (this.clients.has(name)) { + return this.clients.get(name) as T; + } + + const config = this.connections.get(name); + if (!config) { + throw new Error(`Connection '${name}' is not configured`); + } + + // Create new client based on driver + const client = this.createClient(name, config); + this.clients.set(name, client); + this.connectedClients.add(name); + + return client as T; + } + + /** + * Create a new database client + */ + private createClient(name: string, config: ConnectionConfig): any { + if (config.driver === 'prisma') { + return this.createPrismaClient(config); + } + + if (config.driver === 'drizzle') { + return this.createDrizzleClient(config); + } + + throw new Error(`Unsupported driver: ${config.driver}`); + } + + /** + * Create Prisma client + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private createPrismaClient(config: ConnectionConfig): any { + // Dynamic import to avoid bundling Prisma if not used + try { + // In actual implementation, this would be the real Prisma client + // For now, we'll throw an error to indicate it needs to be provided + throw new Error( + 'Prisma client must be registered using registerClient() or provided via dependency injection', + ); + } catch (error) { + throw new Error(`Failed to create Prisma client: ${error}`); + } + } + + /** + * Create Drizzle client + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private createDrizzleClient(config: ConnectionConfig): any { + // Dynamic import to avoid bundling Drizzle if not used + try { + // In actual implementation, this would create a Drizzle client + throw new Error( + 'Drizzle client must be registered using registerClient() or provided via dependency injection', + ); + } catch (error) { + throw new Error(`Failed to create Drizzle client: ${error}`); + } + } +} diff --git a/backend-api/@frouvel/kaname/database/README.md b/backend-api/@frouvel/kaname/database/README.md index dc43c57..5d30c1f 100644 --- a/backend-api/@frouvel/kaname/database/README.md +++ b/backend-api/@frouvel/kaname/database/README.md @@ -1,455 +1,437 @@ # Database Module -Enhanced Prisma Client management with connection pooling, retry logic, and graceful shutdown handling. +Database abstraction layer supporting both Prisma and Drizzle ORM with **zero performance overhead** through direct pass-through access. -## Overview +## Philosophy -The `@frouvel/kaname/database` module provides a production-ready Prisma Client wrapper with: +Unlike traditional ORMs that add abstraction layers, our approach: -- **Connection Pool Management**: Configurable connection pooling via environment variables -- **Automatic Retry Logic**: Exponential backoff for failed operations -- **Graceful Shutdown**: Proper connection cleanup on application exit -- **Health Checks**: Database connection health monitoring -- **Pagination Extension**: Automatic integration with `@frouvel/kaname/paginator` +1. **Direct Pass-Through**: Zero overhead access to Prisma/Drizzle clients +2. **No Query Builder**: Use each ORM's native API (they're already excellent!) +3. **Simple Facade**: Just connection management and transactions +4. **Type-Safe**: Full TypeScript support maintained ## Quick Start -```typescript -import { getPrismaClient } from '$/@frouvel/kaname/database'; - -const prisma = getPrismaClient(); - -// Use Prisma client as normal -const users = await prisma.user.findMany(); -``` - -## Configuration +### Using Prisma (Default) -### Environment Variables - -Configure database connection pooling via environment variables: +```typescript +import { DB } from '$/@frouvel/kaname/database'; -```bash -# Maximum number of connections in the pool (default: 10) -DATABASE_CONNECTION_POOL_SIZE=10 +// Direct Prisma access - zero overhead! +const prisma = DB.prisma(); -# Connection timeout in seconds (default: 30) -DATABASE_CONNECTION_TIMEOUT=30 +// Use Prisma's full API +const users = await prisma.user.findMany({ + where: { age: { gte: 18 } }, + include: { posts: true }, + orderBy: { createdAt: 'desc' }, +}); -# Pool timeout in seconds (default: 2) -DATABASE_POOL_TIMEOUT=2 +// Transactions +await DB.transaction(async (tx) => { + const user = await tx.user.create({ + data: { name: 'John Doe', email: 'john@example.com' }, + }); + + await tx.profile.create({ + data: { userId: user.id, bio: 'Hello!' }, + }); +}); ``` -### Connection String Enhancement - -The module automatically enhances your `DATABASE_URL` with pool parameters if not already present. +### Using Drizzle -**Before:** -``` -postgresql://user:pass@localhost:5432/mydb -``` - -**After:** -``` -postgresql://user:pass@localhost:5432/mydb?connection_limit=10&pool_timeout=2&connect_timeout=30 +```typescript +import { DB } from '$/@frouvel/kaname/database'; +import { users, posts } from '$/schema'; +import { eq } from 'drizzle-orm'; + +// Direct Drizzle access - zero overhead! +const db = DB.drizzle(); + +// Use Drizzle's full API +const result = await db + .select() + .from(users) + .where(eq(users.age, 18)) + .leftJoin(posts, eq(users.id, posts.userId)); + +// Transactions +await DB.transaction(async (tx) => { + const [user] = await tx.insert(users).values({ + name: 'John Doe', + email: 'john@example.com' + }).returning(); + + await tx.insert(profiles).values({ + userId: user.id, + bio: 'Hello!', + }); +}); ``` -## Core Functions - -### `getPrismaClient()` +## Configuration -Get the singleton Prisma client instance. +### config/database.ts ```typescript -import { getPrismaClient } from '$/@frouvel/kaname/database'; - -const prisma = getPrismaClient(); +import type { DatabaseConfig } from '$/@frouvel/kaname/database'; +import { env } from '$/env'; + +export default { + // Default connection + default: 'primary', + + // Connection configurations + connections: { + // Prisma connection + primary: { + driver: 'prisma', + url: env('DATABASE_URL'), + pool: { + min: env('DB_POOL_MIN', 2), + max: env('DB_POOL_MAX', 10), + }, + }, + + // Drizzle connection (optional) + analytics: { + driver: 'drizzle', + connection: { + host: env('ANALYTICS_DB_HOST', 'localhost'), + port: env('ANALYTICS_DB_PORT', 5432), + user: env('ANALYTICS_DB_USER'), + password: env('ANALYTICS_DB_PASSWORD'), + database: env('ANALYTICS_DB_DATABASE'), + }, + }, + + // Read replica (optional) + 'read-replica': { + driver: 'prisma', + url: env('READ_REPLICA_URL'), + }, + }, +} satisfies DatabaseConfig; ``` -**Features:** -- Returns the same instance on subsequent calls (singleton pattern) -- Auto-connects on first use -- Registers graceful shutdown handlers -- Applies pagination extension automatically +## API Reference -### `disconnectPrismaClient()` +### DB.prisma() -Manually disconnect the Prisma client (useful for testing). +Get direct access to Prisma client (zero overhead). ```typescript -import { disconnectPrismaClient } from '$/@frouvel/kaname/database'; +const prisma = DB.prisma(); // Default connection +const replica = DB.prisma('read-replica'); // Specific connection -await disconnectPrismaClient(); +// Use Prisma's full API +const users = await prisma.user.findMany({ + where: { status: 'active' }, + include: { posts: { take: 5 } }, +}); ``` -### `withRetry()` +### DB.drizzle() -Execute database operations with automatic retry logic. +Get direct access to Drizzle client (zero overhead). ```typescript -import { withRetry } from '$/@frouvel/kaname/database'; +const db = DB.drizzle(); // Default connection +const analytics = DB.drizzle('analytics'); // Specific connection -const result = await withRetry( - async () => { - return await prisma.user.findMany(); - }, - 3, // maxRetries (default: 3) - 1000 // initialDelay in ms (default: 1000) -); +// Use Drizzle's full API +const users = await db.select().from(usersTable); ``` -**Retry Strategy:** -- Exponential backoff: delay × 2^(attempt-1) -- Attempt 1: 1000ms delay -- Attempt 2: 2000ms delay -- Attempt 3: 4000ms delay +### DB.transaction() -### `checkDatabaseConnection()` - -Check database connection health. +Execute operations within a transaction. ```typescript -import { checkDatabaseConnection } from '$/@frouvel/kaname/database'; +// Prisma transaction +await DB.transaction(async (prisma) => { + await prisma.user.create({ data: { name: 'John' } }); + await prisma.profile.create({ data: { userId: 1 } }); +}); -const isHealthy = await checkDatabaseConnection(); +// Drizzle transaction +await DB.transaction(async (tx) => { + await tx.insert(users).values({ name: 'John' }); + await tx.insert(profiles).values({ userId: 1 }); +}); -if (!isHealthy) { - console.error('Database connection failed'); -} +// Specific connection +await DB.transaction(async (tx) => { + // ... operations +}, 'read-replica'); ``` -**Features:** -- Uses `withRetry()` internally (2 retries, 500ms delay) -- Returns `true` if connection is healthy -- Returns `false` if all retries fail - -### `resetPrismaConnection()` +### DB.client() -Reset the Prisma connection (useful when connection is stale). +Get the underlying client (ORM-agnostic). ```typescript -import { resetPrismaConnection } from '$/@frouvel/kaname/database'; +const client = DB.client(); // Returns Prisma or Drizzle based on config +``` -await resetPrismaConnection(); +### Multiple Connections -// Next call to getPrismaClient() will create a new connection -const prisma = getPrismaClient(); +```typescript +// Switch default connection +DB.setDefaultConnection('read-replica'); +const users = DB.prisma().user.findMany(); // Uses read replica + +// Or specify connection directly +const primary = DB.prisma('primary'); +const replica = DB.prisma('read-replica'); +const analytics = DB.drizzle('analytics'); ``` -## Usage in Repositories +## Usage Patterns -### Basic Usage +### Pattern 1: Direct Usage (Simplest) ```typescript -import { getPrismaClient } from '$/@frouvel/kaname/database'; -import { UserModel } from '$/prisma/__generated__/models/User.model'; +import { DB } from '$/@frouvel/kaname/database'; -export class UserRepository { - constructor(private readonly _prisma: PrismaClient) {} - - async findById(args: { id: number }): Promise { - const data = await this._prisma.user.findUnique({ - where: { id: args.id }, +export class UserService { + async getActiveUsers() { + const prisma = DB.prisma(); + return prisma.user.findMany({ + where: { status: 'active' }, }); - - if (!data) return null; - - return UserModel.fromPrismaValue({ self: data }); } } ``` -### In Use Cases +### Pattern 2: Repository Pattern (Recommended) ```typescript -import { getPrismaClient } from '$/@frouvel/kaname/database'; -import { UserRepository } from '../repository/User.repository'; +import { DB } from '$/@frouvel/kaname/database'; -export class FindUserUseCase { - private readonly _userRepository: IUserRepository; +export class UserRepository { + private prisma = DB.prisma(); - private constructor(args: { userRepository: IUserRepository }) { - this._userRepository = args.userRepository; + async findById(id: string) { + return this.prisma.user.findUnique({ + where: { id }, + include: { posts: true }, + }); } - static create() { - return new FindUserUseCase({ - userRepository: new UserRepository(getPrismaClient()), - }); + async create(data: CreateUserDto) { + return this.prisma.user.create({ data }); } - async handleById(args: { id: number }) { - const user = await this._userRepository.findById({ id: args.id }); - - if (!user) { - throw NotFoundError.create(`User with ID ${args.id} not found`, { - userId: args.id, - }); - } + async paginate(page: number, limit: number) { + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + take: limit, + skip: (page - 1) * limit, + }), + this.prisma.user.count(), + ]); - return user.toDto(); + return { users, total }; } } ``` -## Service Provider Integration - -The database module is automatically registered via [`DatabaseServiceProvider`](../foundation/providers/DatabaseServiceProvider.ts): +### Pattern 3: Multiple ORMs ```typescript -// backend-api/bootstrap/app.ts -import { - DatabaseServiceProvider, - ConsoleServiceProvider, -} from '$/@frouvel/kaname/foundation'; +import { DB } from '$/@frouvel/kaname/database'; +import { analyticsEvents } from '$/schema'; + +export class AnalyticsService { + private prisma = DB.prisma('primary'); + private analytics = DB.drizzle('analytics'); + + async trackUserAction(userId: string, action: string) { + // Store in Drizzle analytics DB + await this.analytics.insert(analyticsEvents).values({ + userId, + action, + timestamp: new Date(), + }); -const providers = [ - DatabaseServiceProvider, // Registers 'prisma' singleton - ConsoleServiceProvider, - // ... other providers -]; + // Update user stats in Prisma + await this.prisma.user.update({ + where: { id: userId }, + data: { lastActiveAt: new Date() }, + }); + } +} ``` -**What it does:** -1. Registers Prisma client as singleton in the application container -2. Connects to database on boot -3. Implements graceful disconnection on shutdown - -## Pagination Extension +## Testing Support -The Prisma client is automatically extended with pagination capabilities: +The `DatabaseTestCase` works seamlessly with the DB facade: ```typescript -const prisma = getPrismaClient(); - -// Use pagination extension methods -const result = await prisma.user.paginate({ - limit: 10, - page: 1, -}); -``` +import { DatabaseTestCase, DB } from '$/@frouvel/kaname/testing'; +import { expect } from 'vitest'; -See [`@frouvel/kaname/paginator/README.md`](../paginator/README.md) for details on pagination features. +class UserRepositoryTest extends DatabaseTestCase { + private repository: UserRepository; -## Graceful Shutdown + protected async setUp() { + await super.setUp(); + this.repository = new UserRepository(); + } -Shutdown handlers are automatically registered to ensure proper cleanup: + run() { + this.suite('UserRepository', () => { + this.test('can create user', async () => { + const user = await this.repository.create({ + name: 'John Doe', + email: 'john@example.com', + }); + + expect(user).toBeDefined(); + expect(user.name).toBe('John Doe'); + + // Can also use DB facade directly in tests + const found = await DB.prisma().user.findUnique({ + where: { id: user.id }, + }); + expect(found).toBeDefined(); + }); + }); + } +} -```typescript -// Listens for these signals: -- SIGINT (Ctrl+C) -- SIGTERM (container shutdown) -- beforeExit (Node.js process exit) +new UserRepositoryTest().run(); ``` -**Shutdown Process:** -1. Log disconnect message -2. Call `prisma.$disconnect()` -3. Set prisma instance to null -4. Log completion +## Migration from Direct Prisma -## Best Practices - -### 1. Use Singleton Pattern - -Always use `getPrismaClient()` instead of creating new instances: +### Before ```typescript -// ✅ Good import { getPrismaClient } from '$/@frouvel/kaname/database'; -const prisma = getPrismaClient(); - -// ❌ Bad -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); -``` - -### 2. Use Retry Logic for Critical Operations - -```typescript -import { withRetry, getPrismaClient } from '$/@frouvel/kaname/database'; const prisma = getPrismaClient(); - -// Wrap critical operations -const result = await withRetry(async () => { - return await prisma.user.create({ - data: { /* ... */ }, - }); -}); -``` - -### 3. Health Checks in Production - -```typescript -import { checkDatabaseConnection } from '$/@frouvel/kaname/database'; - -// Add to your health check endpoint -export default defineController(() => ({ - get: async () => { - const dbHealthy = await checkDatabaseConnection(); - - return ApiResponse.success({ - database: dbHealthy ? 'healthy' : 'unhealthy', - }); - }, -})); +const users = await prisma.user.findMany(); ``` -### 4. Access via Container - -In service providers or when you need dependency injection: +### After ```typescript -import type { Application } from '$/@frouvel/kaname/foundation'; -import type { PrismaClient } from '@prisma/client'; +import { DB } from '$/@frouvel/kaname/database'; -const prisma = app.make('prisma'); +const prisma = DB.prisma(); +const users = await prisma.user.findMany(); ``` -## Testing +**Note**: `getPrismaClient()` still works for backward compatibility but is deprecated. -### Reset Connection Between Tests +## Performance -```typescript -import { resetPrismaConnection } from '$/@frouvel/kaname/database'; - -afterEach(async () => { - await resetPrismaConnection(); -}); -``` - -### Mock Prisma Client +**Zero overhead!** The DB facade provides direct pass-through to the underlying ORM client: ```typescript -import { vi } from 'vitest'; - -vi.mock('$/@frouvel/kaname/database', () => ({ - getPrismaClient: () => mockPrismaClient, -})); -``` - -## Architecture +// These are equivalent in performance: +const prisma = getPrismaClient(); +const users1 = await prisma.user.findMany(); +const users2 = await DB.prisma().user.findMany(); +// Same speed - DB.prisma() just returns the client directly! ``` -@frouvel/kaname/database/ -├── index.ts # Main exports -├── PrismaClientManager.ts # Core implementation -└── README.md # This file -``` - -**Features in `PrismaClientManager.ts`:** - -1. **Connection Pool Configuration** (`createPrismaClient`) - - Reads environment variables - - Enhances DATABASE_URL with pool parameters - - Configures logging based on environment - -2. **Singleton Management** (`getPrismaClient`) - - Creates instance on first call - - Returns same instance on subsequent calls - - Auto-connects and registers shutdown handlers -3. **Retry Logic** (`withRetry`) - - Exponential backoff algorithm - - Configurable max retries and delay - - Detailed error logging +## Why Not a Query Builder? -4. **Health Checks** (`checkDatabaseConnection`) - - Simple SELECT 1 query - - Uses retry logic internally - - Returns boolean status +Query builders add abstraction and learning curves. Instead: -5. **Connection Management** (`resetPrismaConnection`, `disconnectPrismaClient`) - - Clean disconnection - - Instance reset - - Preparation for reconnection +1. **Prisma** already has excellent TypeScript support and query API +2. **Drizzle** is designed to be close to SQL with great DX +3. **Zero Overhead**: Direct access means no performance penalty +4. **Full Features**: Access to all ORM-specific features +5. **Type Safety**: Maintain full TypeScript types from your ORM -## Environment-Specific Configuration +## Advanced Usage -### Development +### Graceful Shutdown ```typescript -// Logging enabled: query, info, warn, error -// Connection pool: 10 connections (or env override) +process.on('SIGTERM', async () => { + await DB.disconnectAll(); + process.exit(0); +}); ``` -### Production +### Health Checks ```typescript -// Logging: warn, error only -// Connection pool: configured via env (recommended: 10-20) -// Always cache configuration: npm run artisan config:cache +export async function checkDatabaseHealth() { + try { + const prisma = DB.prisma(); + await prisma.$queryRaw`SELECT 1`; + return { status: 'healthy' }; + } catch (error) { + return { status: 'unhealthy', error }; + } +} ``` -### Testing +### Custom Connection Registration ```typescript -// Use separate test database -// Reset connections between tests -// Mock Prisma client where appropriate -``` - -## Troubleshooting - -### Connection Pool Exhausted - -**Symptoms:** `PrismaPool` errors, timeout errors - -**Solution:** -```bash -# Increase pool size -DATABASE_CONNECTION_POOL_SIZE=20 -``` +import { PrismaClient } from '@prisma/client'; +import { DB } from '$/@frouvel/kaname/database'; -### Slow Queries +// Create custom Prisma client with extensions +const prisma = new PrismaClient().$extends({ + // Your extensions here +}); -**Symptoms:** Timeouts, slow response times +// Register it +DB.register('custom', prisma, 'prisma'); -**Solution:** -```bash -# Increase connection timeout -DATABASE_CONNECTION_TIMEOUT=60 +// Use it +const users = await DB.prisma('custom').user.findMany(); ``` -### Connection Leaks +## Best Practices -**Symptoms:** Connections not released, memory growth +1. **Use DB Facade**: Import `DB` instead of `getPrismaClient()` +2. **Repository Pattern**: Encapsulate data access in repository classes +3. **Connection Names**: Use descriptive names (`primary`, `read-replica`, `analytics`) +4. **Transactions**: Always use `DB.transaction()` for multiple operations +5. **Testing**: Use `DatabaseTestCase` for integration tests +6. **Read Replicas**: Route read-heavy operations to read replicas -**Solution:** -```typescript -// Ensure all queries complete -// Check for unhandled promise rejections -// Use withRetry for flaky operations -``` +## Troubleshooting -### Stale Connections +### "Database manager not initialized" -**Symptoms:** "Connection reset" errors +Make sure `DatabaseServiceProvider` is registered in your `bootstrap/app.ts`: -**Solution:** ```typescript -import { resetPrismaConnection } from '$/@frouvel/kaname/database'; +import { DatabaseServiceProvider } from '$/@frouvel/kaname/foundation'; -await resetPrismaConnection(); +const providers = [ + DatabaseServiceProvider, + // ... other providers +]; ``` -## Migration from Old Pattern +### Type Errors with Prisma Client -**Before:** -```typescript -import { getPrismaClient } from '$/service/getPrismaClient'; -``` +Make sure you've generated Prisma client: -**After:** -```typescript -import { getPrismaClient } from '$/@frouvel/kaname/database'; +```bash +npm run generate:prisma ``` -All other usage remains the same! +### Multiple Connections Not Working + +Check your `config/database.ts` has all connections defined and the connection name matches. ## See Also -- [`@frouvel/kaname/paginator`](../paginator/README.md) - Pagination extension -- [`DatabaseServiceProvider`](../foundation/providers/DatabaseServiceProvider.ts) - Service provider -- [Prisma Documentation](https://www.prisma.io/docs) - Official Prisma docs \ No newline at end of file +- [Architecture Document](./ARCHITECTURE.md) - Detailed architectural design +- [Prisma Documentation](https://www.prisma.io/docs) +- [Drizzle Documentation](https://orm.drizzle.team/docs/overview) \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts b/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts new file mode 100644 index 0000000..ca66c3f --- /dev/null +++ b/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts @@ -0,0 +1,110 @@ +/** + * Database Manager Interface + * + * Provides a simple facade for database operations while allowing + * direct access to underlying ORM clients for zero performance overhead. + */ + +export interface DatabaseManager { + /** + * Get the default database connection name + */ + getDefaultConnection(): string; + + /** + * Set the default database connection + */ + setDefaultConnection(name: string): void; + + /** + * Get Prisma client for direct access + * Returns null if Prisma is not configured + */ + prisma(connection?: string): T | null; + + /** + * Get Drizzle client for direct access + * Returns null if Drizzle is not configured + */ + drizzle(connection?: string): T | null; + + /** + * Get the underlying client for the specified connection + * This is ORM-agnostic and returns whatever client is configured + */ + client(connection?: string): T; + + /** + * Execute a function within a database transaction + * The transaction type depends on the configured ORM + */ + transaction( + callback: (client: any) => Promise, + connection?: string, + ): Promise; + + /** + * Disconnect from database + */ + disconnect(connection?: string): Promise; + + /** + * Disconnect from all databases + */ + disconnectAll(): Promise; + + /** + * Check if a connection is established + */ + isConnected(connection?: string): boolean; +} + +/** + * Database Configuration + */ +export interface DatabaseConfig { + /** + * Default connection name + */ + default: string; + + /** + * Database connections configuration + */ + connections: Record; +} + +/** + * Connection Configuration + */ +export interface ConnectionConfig { + /** + * ORM driver: 'prisma' or 'drizzle' + */ + driver: 'prisma' | 'drizzle'; + + /** + * Connection URL (for Prisma) + */ + url?: string; + + /** + * Connection details (for Drizzle) + */ + connection?: { + host: string; + port: number; + user: string; + password: string; + database: string; + }; + + /** + * Connection pool settings + */ + pool?: { + min?: number; + max?: number; + idleTimeoutMillis?: number; + }; +} \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/database/index.ts b/backend-api/@frouvel/kaname/database/index.ts index 46f8de0..4d8dffd 100644 --- a/backend-api/@frouvel/kaname/database/index.ts +++ b/backend-api/@frouvel/kaname/database/index.ts @@ -1,9 +1,24 @@ /** * Database Module * - * Provides Prisma client management and database utilities. + * Provides database abstraction supporting Prisma and Drizzle ORM. + * Use the DB facade for ORM-agnostic access with zero performance overhead. */ +// DB Facade (recommended) +export { DB } from './DB'; + +// Database Manager +export { DatabaseManager } from './DatabaseManager'; + +// Interfaces +export type { + DatabaseManager as IDatabaseManager, + DatabaseConfig, + ConnectionConfig, +} from './contracts/DatabaseManager.interface'; + +// Legacy Prisma utilities (deprecated, use DB.prisma() instead) export { getPrismaClient, disconnectPrismaClient, diff --git a/backend-api/@frouvel/kaname/http/ApiResponse.ts b/backend-api/@frouvel/kaname/http/ApiResponse.ts index 8018a46..f872164 100644 --- a/backend-api/@frouvel/kaname/http/ApiResponse.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponse.ts @@ -365,8 +365,10 @@ export const ApiResponse = { }, } as const; -// Export ResponseBuilder -export { ResponseBuilder } from './ResponseBuilder'; +// Export ApiResponseBuilder +export { ApiResponseBuilder } from './ApiResponseBuilder'; +// Keep ResponseBuilder as deprecated alias for backward compatibility +export { ApiResponseBuilder as ResponseBuilder } from './ApiResponseBuilder'; // Export types export type { diff --git a/backend-api/@frouvel/kaname/http/ResponseBuilder.test.ts b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts similarity index 90% rename from backend-api/@frouvel/kaname/http/ResponseBuilder.test.ts rename to backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts index 6ac0471..b0fb781 100644 --- a/backend-api/@frouvel/kaname/http/ResponseBuilder.test.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts @@ -1,15 +1,15 @@ /** - * ResponseBuilder Tests + * ApiResponseBuilder Tests * - * Tests for the ResponseBuilder class + * Tests for the ApiResponseBuilder class */ import { describe, it, expect } from 'vitest'; -import { ResponseBuilder } from '$/@frouvel/kaname/http/ResponseBuilder'; +import { ApiResponseBuilder } from '$/@frouvel/kaname/http/ApiResponseBuilder'; import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; import { z } from 'zod'; -describe('ResponseBuilder', () => { +describe('ApiResponseBuilder', () => { describe('withValidation', () => { it('should successfully validate data', () => { const schema = z.object({ @@ -19,7 +19,7 @@ describe('ResponseBuilder', () => { const validData = { name: 'John', age: 30 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(validData, schema) .handle((data) => { expect(data).toEqual(validData); @@ -38,7 +38,7 @@ describe('ResponseBuilder', () => { const invalidData = { name: 'John', age: 'not-a-number' }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(invalidData, schema) .handle((data) => ApiResponse.success(data)); @@ -58,7 +58,7 @@ describe('ResponseBuilder', () => { const invalidData = { email: 'invalid-email', age: -5 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(invalidData, schema) .handle((data) => ApiResponse.success(data)); @@ -75,7 +75,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ name: z.string() }); const data = { name: 'Test' }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .handle((validData) => { return ApiResponse.success({ processed: validData.name }); @@ -89,7 +89,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ age: z.number() }); const data = { age: 15 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .handle((validData) => { if (validData.age < 18) { @@ -113,7 +113,7 @@ describe('ResponseBuilder', () => { let handlerCalled = false; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(invalidData, schema) .handle(() => { handlerCalled = true; @@ -130,7 +130,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ value: z.number() }); const data = { value: 42 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .then((validData) => ApiResponse.success(validData)); @@ -144,7 +144,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ name: z.string() }); const data = { name: 'Auto' }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .executeWithSuccess((validData) => { return { processed: validData.name }; @@ -158,7 +158,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ age: z.number() }); const data = { age: 15 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .executeWithSuccess((validData) => { if (validData.age < 18) { @@ -175,7 +175,7 @@ describe('ResponseBuilder', () => { const schema = z.object({ id: z.number() }); const data = { id: 1 }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .executeWithSuccess((validData) => { return ApiResponse.success({ @@ -217,7 +217,7 @@ describe('ResponseBuilder', () => { }, }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(validData, schema) .handle((data) => { expect(data.user.profile.age).toBe(30); @@ -239,7 +239,7 @@ describe('ResponseBuilder', () => { nullable: null, }; - const result = ResponseBuilder.create() + const result = ApiResponseBuilder.create() .withValidation(data, schema) .handle((validData) => { expect(validData.required).toBe('test'); @@ -260,7 +260,7 @@ describe('ResponseBuilder', () => { active: z.boolean(), }); - ResponseBuilder.create() + ApiResponseBuilder.create() .withValidation({ name: 'Test', age: 30, active: true }, schema) .handle((data) => { // TypeScript should infer these types correctly diff --git a/backend-api/@frouvel/kaname/http/ResponseBuilder.ts b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts similarity index 93% rename from backend-api/@frouvel/kaname/http/ResponseBuilder.ts rename to backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts index b8441f3..4b6dacc 100644 --- a/backend-api/@frouvel/kaname/http/ResponseBuilder.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts @@ -25,7 +25,7 @@ type ApiResponseFunctions = /** * Response Builder for fluent API */ -export class ResponseBuilder { +export class ApiResponseBuilder { private validatedData: T | null = null; private validationError: ReturnType | null = null; @@ -33,10 +33,10 @@ export class ResponseBuilder { private constructor() {} /** - * Create a new ResponseBuilder instance + * Create a new ApiResponseBuilder instance */ static create() { - return new ResponseBuilder(); + return new ApiResponseBuilder(); } /** @@ -44,7 +44,7 @@ export class ResponseBuilder { * * @example * ```typescript - * return ResponseBuilder.create() + * return ApiResponseBuilder.create() * .withValidation(body, userSchema) * .handle((data) => { * if (data.age < 18) return ApiResponse.forbidden('未成年は登録できません'); @@ -55,10 +55,10 @@ export class ResponseBuilder { withValidation( data: unknown, schema: S, - ): ResponseBuilder> { + ): ApiResponseBuilder> { const result = Validator.validate(data, schema); - const builder = new ResponseBuilder>(); + const builder = new ApiResponseBuilder>(); if (result.isError) { builder.validationError = result.response; diff --git a/backend-api/api/example/error/rfc9457/controller.ts b/backend-api/api/example/error/rfc9457/controller.ts index c8f4347..dbe3d9c 100644 --- a/backend-api/api/example/error/rfc9457/controller.ts +++ b/backend-api/api/example/error/rfc9457/controller.ts @@ -7,7 +7,7 @@ import { defineController } from './$relay'; import { ApiResponse, - ResponseBuilder, + ApiResponseBuilder, } from '$/@frouvel/kaname/http/ApiResponse'; import { NotFoundError, @@ -97,11 +97,11 @@ export default defineController(() => ({ } }, - // Example: Using ResponseBuilder with .handle() - Clean validation + // Example: Using ApiResponseBuilder with .handle() - Clean validation // Separates schema validation from business logic // Perfect for one-liner controller actions with complex validation patch: ({ body }) => - ResponseBuilder.create() + ApiResponseBuilder.create() .withValidation( body, z.object({ @@ -136,16 +136,16 @@ export default defineController(() => ({ // Success response return ApiResponse.success({ - message: 'データが正常に更新されました (ResponseBuilder)', + message: 'データが正常に更新されました (ApiResponseBuilder)', data, }); }), - // Example: Alternative ResponseBuilder syntax with .then() alias + // Example: Alternative ApiResponseBuilder syntax with .then() alias // .then() is just an alias for .handle() - same functionality // Use whichever reads better in your context options: ({ body }) => - ResponseBuilder.create() + ApiResponseBuilder.create() .withValidation( body, z.object({ diff --git a/backend-api/config/database.ts b/backend-api/config/database.ts index cbe2099..eeb4f20 100644 --- a/backend-api/config/database.ts +++ b/backend-api/config/database.ts @@ -1,53 +1,57 @@ +import type { DatabaseConfig } from '$/@frouvel/kaname/database'; +import { env } from '$/env'; + /** * Database Configuration * - * Configuration for database connections. + * Configure database connections for your application. + * Supports both Prisma and Drizzle ORM. */ +const databaseConfig = { + /** + * Default database connection + */ + default: 'default', -import { z } from 'zod'; - -export const databaseConfigSchema = z.object({ - default: z.string(), - connections: z.object({ - postgresql: z.object({ - url: z.string().url(), - schema: z.string(), - }), - }), - pool: z.object({ - min: z.number().positive(), - max: z.number().positive(), - }), - migrations: z.object({ - tableName: z.string(), - directory: z.string(), - }), - seeds: z.object({ - directory: z.string(), - }), -}); - -export type DatabaseConfig = z.infer; - -export default databaseConfigSchema.parse({ - default: process.env.DB_CONNECTION || 'postgresql', + /** + * Database connections + */ connections: { - postgresql: { - url: - process.env.DATABASE_URL || - 'postgresql://root:root@localhost:5432/frourio_framework', - schema: process.env.DB_SCHEMA || 'public', + /** + * Default Prisma connection + */ + default: { + driver: 'prisma', + url: env.DATABASE_URL, + pool: { + min: env.DB_POOL_MIN, + max: env.DB_POOL_MAX, + }, }, + + // Example: Read replica (uncomment to use) + // 'read-replica': { + // driver: 'prisma', + // url: env.READ_REPLICA_URL, + // pool: { + // min: 2, + // max: 5, + // }, + // }, + + // Example: Drizzle connection (uncomment to use) + // analytics: { + // driver: 'drizzle', + // connection: { + // host: env.ANALYTICS_DB_HOST, + // port: env.ANALYTICS_DB_PORT, + // user: env.ANALYTICS_DB_USER, + // password: env.ANALYTICS_DB_PASSWORD, + // database: env.ANALYTICS_DB_DATABASE, + // }, + // }, }, - pool: { - min: Number(process.env.DB_POOL_MIN || 2), - max: Number(process.env.DB_POOL_MAX || 10), - }, - migrations: { - tableName: process.env.DB_MIGRATIONS_TABLE || 'migrations', - directory: './prisma/migrations', - }, - seeds: { - directory: './prisma/seeders', - }, -}); +} satisfies DatabaseConfig; + +export default databaseConfig; +export type { DatabaseConfig }; \ No newline at end of file From a87653b53554a8f908ab6b08c9de7644507032a0 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 14 Nov 2025 06:05:22 +0900 Subject: [PATCH 04/28] feat: add Prisma and Drizzle ORM setup with User repository implementation - Created Prisma schema for User model with fields: id, name, email, age, createdAt, and updatedAt. - Added seed files for development and production environments. - Implemented IUserRepository interface for user operations. - Developed UserRepositoryDrizzle and UserRepository classes for Drizzle and Prisma ORM respectively. - Configured Drizzle ORM with PostgreSQL settings. - Updated package.json scripts for migration and seeding processes. - Added necessary dependencies for Drizzle ORM and PostgreSQL. --- .roo/rules/frourio.md | 249 ++++--- backend-api/@frouvel/kaname/database/DB.ts | 46 +- .../kaname/database/DatabaseManager.ts | 20 +- .../kaname/database/DrizzleClientManager.ts | 56 ++ backend-api/@frouvel/kaname/database/index.ts | 11 +- .../@frouvel/kaname/docs/RESPONSE_BUILDER.md | 30 +- .../kaname/http/ApiResponseBuilder.test.ts | 28 +- .../kaname/http/ApiResponseBuilder.ts | 4 +- .../kaname/testing/DatabaseTestCase.ts | 4 +- backend-api/@frouvel/kaname/testing/setup.ts | 4 +- .../api/example/error/rfc9457/controller.ts | 4 +- backend-api/config/database.ts | 4 +- backend-api/database/drizzle/schema/index.ts | 7 + backend-api/database/drizzle/schema/users.ts | 18 + backend-api/{ => database}/prisma/.gitignore | 0 .../prisma/migrations/migration_lock.toml | 0 .../{ => database}/prisma/schema.prisma | 5 +- backend-api/{ => database}/prisma/seed.dev.ts | 0 .../{ => database}/prisma/seed.production.ts | 0 .../prisma/seeders/dev-only/.keep | 0 .../repository/User.repository.interface.ts | 37 + .../repository/drizzle/User.repository.ts | 61 ++ .../user/repository/prisma/User.repository.ts | 51 ++ backend-api/drizzle.config.ts | 14 + backend-api/package-lock.json | 641 ++++++++++++++++++ backend-api/package.json | 25 +- 26 files changed, 1125 insertions(+), 194 deletions(-) create mode 100644 backend-api/@frouvel/kaname/database/DrizzleClientManager.ts create mode 100644 backend-api/database/drizzle/schema/index.ts create mode 100644 backend-api/database/drizzle/schema/users.ts rename backend-api/{ => database}/prisma/.gitignore (100%) rename backend-api/{ => database}/prisma/migrations/migration_lock.toml (100%) rename backend-api/{ => database}/prisma/schema.prisma (83%) rename backend-api/{ => database}/prisma/seed.dev.ts (100%) rename backend-api/{ => database}/prisma/seed.production.ts (100%) rename backend-api/{ => database}/prisma/seeders/dev-only/.keep (100%) create mode 100644 backend-api/domain/user/repository/User.repository.interface.ts create mode 100644 backend-api/domain/user/repository/drizzle/User.repository.ts create mode 100644 backend-api/domain/user/repository/prisma/User.repository.ts create mode 100644 backend-api/drizzle.config.ts diff --git a/.roo/rules/frourio.md b/.roo/rules/frourio.md index 48a8117..b35ba36 100644 --- a/.roo/rules/frourio.md +++ b/.roo/rules/frourio.md @@ -24,24 +24,24 @@ The `f-f` includes a variety of composable functions and several best practices The [`ApiResponse`](backend-api/@frouvel/kaname/http/ApiResponse.ts:284) facade provides a unified API for creating HTTP responses: ```ts -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { ApiResponse } from "$/@frouvel/kaname/http/ApiResponse"; // Success response -ApiResponse.success({ id: 1, name: 'John' }); +ApiResponse.success({ id: 1, name: "John" }); // Error responses -ApiResponse.notFound('User not found', { userId: '123' }); -ApiResponse.badRequest('Invalid input', { field: 'email' }); -ApiResponse.unauthorized('Invalid token'); -ApiResponse.forbidden('Insufficient permissions'); -ApiResponse.conflict('User already exists'); -ApiResponse.internalServerError('Database error'); +ApiResponse.notFound("User not found", { userId: "123" }); +ApiResponse.badRequest("Invalid input", { field: "email" }); +ApiResponse.unauthorized("Invalid token"); +ApiResponse.forbidden("Insufficient permissions"); +ApiResponse.conflict("User already exists"); +ApiResponse.internalServerError("Database error"); // Method-specific error handlers -ApiResponse.method.get(error); // 404 default -ApiResponse.method.post(error); // 500 default -ApiResponse.method.put(error); // 500 default -ApiResponse.method.patch(error); // 403 default +ApiResponse.method.get(error); // 404 default +ApiResponse.method.post(error); // 500 default +ApiResponse.method.put(error); // 500 default +ApiResponse.method.patch(error); // 403 default ApiResponse.method.delete(error); // 500 default ``` @@ -50,22 +50,22 @@ ApiResponse.method.delete(error); // 500 default The [`ResponseBuilder`](backend-api/@frouvel/kaname/http/ResponseBuilder.ts:28) provides a fluent API for validation and response creation: ```ts -import { ResponseBuilder } from '$/@frouvel/kaname/http/ApiResponse'; -import { z } from 'zod'; +import { ResponseBuilder } from "$/@frouvel/kaname/http/ApiResponse"; +import { z } from "zod"; // Pattern 1: .handle() - Full control over response ResponseBuilder.create() - .withValidation(body, userSchema) + .withZodValidation(body, userSchema) .handle((data) => { if (data.age < 18) { - return ApiResponse.forbidden('未成年は登録できません'); + return ApiResponse.forbidden("未成年は登録できません"); } return ApiResponse.success(data); }); // Pattern 2: .then() - Alias for .handle() ResponseBuilder.create() - .withValidation(body, userSchema) + .withZodValidation(body, userSchema) .then((data) => { // Same as .handle() return ApiResponse.success(data); @@ -73,13 +73,13 @@ ResponseBuilder.create() // Pattern 3: .executeWithSuccess() - Auto-wraps in success response ResponseBuilder.create() - .withValidation(body, userSchema) + .withZodValidation(body, userSchema) .executeWithSuccess((data) => { if (data.age < 18) { - return ApiResponse.forbidden('未成年です'); + return ApiResponse.forbidden("未成年です"); } // No need to return ApiResponse.success(), it's automatic - return { message: 'Success', data }; + return { message: "Success", data }; }); ``` @@ -88,18 +88,18 @@ ResponseBuilder.create() Structured error classes that automatically convert to RFC9457 Problem Details: ```ts -import { - NotFoundError, - ValidationError, - UnauthorizedError -} from '$/@frouvel/kaname/error/CommonErrors'; +import { + NotFoundError, + ValidationError, + UnauthorizedError, +} from "$/@frouvel/kaname/error/CommonErrors"; // Throw structured errors -throw NotFoundError.create('User not found', { userId: '123' }); -throw ValidationError.create('Invalid input', { - errors: [{ field: 'email', message: 'Invalid format' }] +throw NotFoundError.create("User not found", { userId: "123" }); +throw ValidationError.create("Invalid input", { + errors: [{ field: "email", message: "Invalid format" }], }); -throw UnauthorizedError.create('Invalid token', { reason: 'Expired' }); +throw UnauthorizedError.create("Invalid token", { reason: "Expired" }); ``` ### `f-f` file structure @@ -118,9 +118,9 @@ Modern controller patterns using `@frouvel/kaname`: **Pattern 1: Simple UseCase with error handling** ```ts -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; -import { FindUserUseCase } from '$/domain/user/usecase/FindUser.usecase'; -import { defineController } from './$relay'; +import { ApiResponse } from "$/@frouvel/kaname/http/ApiResponse"; +import { FindUserUseCase } from "$/domain/user/usecase/FindUser.usecase"; +import { defineController } from "./$relay"; export default defineController(() => ({ get: ({ params }) => @@ -134,10 +134,13 @@ export default defineController(() => ({ **Pattern 2: With validation using ResponseBuilder** ```ts -import { ApiResponse, ResponseBuilder } from '$/@frouvel/kaname/http/ApiResponse'; -import { CreateUserUseCase } from '$/domain/user/usecase/CreateUser.usecase'; -import { defineController } from './$relay'; -import { z } from 'zod'; +import { + ApiResponse, + ResponseBuilder, +} from "$/@frouvel/kaname/http/ApiResponse"; +import { CreateUserUseCase } from "$/domain/user/usecase/CreateUser.usecase"; +import { defineController } from "./$relay"; +import { z } from "zod"; const createUserSchema = z.object({ name: z.string().min(1), @@ -148,15 +151,15 @@ const createUserSchema = z.object({ export default defineController(() => ({ post: ({ body }) => ResponseBuilder.create() - .withValidation(body, createUserSchema) + .withZodValidation(body, createUserSchema) .handle((data) => { if (data.age < 18) { - return ApiResponse.forbidden('未成年は登録できません', { + return ApiResponse.forbidden("未成年は登録できません", { minAge: 18, providedAge: data.age, }); } - + return CreateUserUseCase.create() .handle(data) .then(ApiResponse.success) @@ -168,10 +171,10 @@ export default defineController(() => ({ **Pattern 3: Multiple operations with pagination** ```ts -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; -import { PaginateUsersUseCase } from '$/domain/user/usecase/PaginateUsers.usecase'; -import { CreateUserUseCase } from '$/domain/user/usecase/CreateUser.usecase'; -import { defineController } from './$relay'; +import { ApiResponse } from "$/@frouvel/kaname/http/ApiResponse"; +import { PaginateUsersUseCase } from "$/domain/user/usecase/PaginateUsers.usecase"; +import { CreateUserUseCase } from "$/domain/user/usecase/CreateUser.usecase"; +import { defineController } from "./$relay"; export default defineController(() => ({ get: ({ query }) => @@ -183,7 +186,7 @@ export default defineController(() => ({ }) .then(ApiResponse.success) .catch(ApiResponse.method.get), - + post: ({ body }) => CreateUserUseCase.create() .handle({ @@ -199,18 +202,18 @@ export default defineController(() => ({ **Pattern 4: Direct response without UseCase** ```ts -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; -import { defineController } from './$relay'; +import { ApiResponse } from "$/@frouvel/kaname/http/ApiResponse"; +import { defineController } from "./$relay"; export default defineController(() => ({ - get: () => ApiResponse.success({ message: 'Health check OK' }), - + get: () => ApiResponse.success({ message: "Health check OK" }), + delete: ({ params }) => { // Direct business logic if (!canDelete(params.id)) { - return ApiResponse.forbidden('Cannot delete this resource'); + return ApiResponse.forbidden("Cannot delete this resource"); } - + deleteResource(params.id); return ApiResponse.success({ deleted: true }); }, @@ -225,8 +228,8 @@ export default defineController(() => ({ - typical index.ts example given below ```ts -import type { DefineMethods } from 'aspida'; -import type { ProblemDetails } from 'commonTypesWithClient'; +import type { DefineMethods } from "aspida"; +import type { ProblemDetails } from "commonTypesWithClient"; export type Methods = DefineMethods<{ get: { @@ -239,15 +242,17 @@ export type Methods = DefineMethods<{ ``` ```ts -import { UserModelDto } from 'commonTypesWithClient'; -import type { DefineMethods } from 'aspida'; -import type { ProblemDetails } from 'commonTypesWithClient'; +import { UserModelDto } from "commonTypesWithClient"; +import type { DefineMethods } from "aspida"; +import type { ProblemDetails } from "commonTypesWithClient"; export type Methods = DefineMethods<{ get: { - resBody: { - user: UserModelDto; - } | ProblemDetails; + resBody: + | { + user: UserModelDto; + } + | ProblemDetails; }; }>; ``` @@ -257,8 +262,8 @@ import { PaginationMeta, UserModelDto, ProblemDetails, -} from 'commonTypesWithClient'; -import type { DefineMethods } from 'aspida'; +} from "commonTypesWithClient"; +import type { DefineMethods } from "aspida"; export type Methods = DefineMethods<{ get: { @@ -267,10 +272,12 @@ export type Methods = DefineMethods<{ limit: number; searchValue?: string; }; - resBody: { - data: UserModelDto[]; - meta: PaginationMeta; - } | ProblemDetails; + resBody: + | { + data: UserModelDto[]; + meta: PaginationMeta; + } + | ProblemDetails; }; post: { reqBody: { @@ -330,7 +337,7 @@ export type Methods = DefineMethods<{ - example model look like this ```ts -import { Prisma, User as PrismaUser } from '@prisma/client'; +import { Prisma, User as PrismaUser } from "@prisma/client"; type Omit = Pick>; type PartialBy = Omit & Partial>; @@ -441,10 +448,10 @@ export class UserModel { - **MUST** use [`createPaginationMeta`](backend-api/@frouvel/kaname/paginator/createPaginationMeta.ts:3) from `@frouvel/kaname/paginator` for pagination ```ts -import { createPaginationMeta } from '$/@frouvel/kaname/paginator/createPaginationMeta'; -import { PaginationMeta } from 'commonTypesWithClient'; -import { UserModel } from '$/prisma/__generated__/models/User.model'; -import { PrismaClient } from '@prisma/client'; +import { createPaginationMeta } from "$/@frouvel/kaname/paginator/createPaginationMeta"; +import { PaginationMeta } from "commonTypesWithClient"; +import { UserModel } from "$/prisma/__generated__/models/User.model"; +import { PrismaClient } from "@prisma/client"; export interface IUserRepository { create(args: { @@ -528,7 +535,7 @@ export class UserRepository implements IUserRepository { where, take: args.limit, skip: args.limit * (args.page - 1), - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }); return { @@ -560,8 +567,8 @@ export class UserRepository implements IUserRepository { - handleByUserId(args: {userId: string}) ```ts -import { IUserRepository, UserRepository } from '../repository/User.repository'; -import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { IUserRepository, UserRepository } from "../repository/User.repository"; +import { getPrismaClient } from "$/@frouvel/kaname/database"; export interface IValidateUserAgeService { handleByAge: (args: { age: number }) => Promise; @@ -604,9 +611,9 @@ export class ValidateUserAgeService implements IValidateUserAgeService { - follows same class coding rules as service ```ts -import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; -import { getPrismaClient } from '$/@frouvel/kaname/database'; -import { IUserRepository, UserRepository } from '../repository/User.repository'; +import { NotFoundError } from "$/@frouvel/kaname/error/CommonErrors"; +import { getPrismaClient } from "$/@frouvel/kaname/database"; +import { IUserRepository, UserRepository } from "../repository/User.repository"; export class FindUserUseCase { private readonly _userRepository: IUserRepository; @@ -636,10 +643,13 @@ export class FindUserUseCase { ``` ```ts -import { ValidationError } from '$/@frouvel/kaname/error/CommonErrors'; -import { getPrismaClient } from '$/@frouvel/kaname/database'; -import { IUserRepository, UserRepository } from '../repository/User.repository'; -import { IValidateUserAgeService, ValidateUserAgeService } from '../service/ValidateUserAge.service'; +import { ValidationError } from "$/@frouvel/kaname/error/CommonErrors"; +import { getPrismaClient } from "$/@frouvel/kaname/database"; +import { IUserRepository, UserRepository } from "../repository/User.repository"; +import { + IValidateUserAgeService, + ValidateUserAgeService, +} from "../service/ValidateUserAge.service"; export class CreateUserUseCase { private readonly _userRepository: IUserRepository; @@ -667,7 +677,7 @@ export class CreateUserUseCase { }); if (!isValidAge) { - throw ValidationError.create('User age is below minimum requirement', { + throw ValidationError.create("User age is below minimum requirement", { minAge: 18, providedAge: args.age, }); @@ -693,15 +703,15 @@ The framework provides a Laravel-style Application container for proper dependen Controllers can access the Application container through the Fastify instance using helper functions: ```ts -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; -import { getApp, getPrisma } from '$/@frouvel/kaname/foundation'; -import { FindUserUseCase } from '$/domain/user/usecase/FindUser.usecase'; -import { defineController } from './$relay'; +import { ApiResponse } from "$/@frouvel/kaname/http/ApiResponse"; +import { getApp, getPrisma } from "$/@frouvel/kaname/foundation"; +import { FindUserUseCase } from "$/domain/user/usecase/FindUser.usecase"; +import { defineController } from "./$relay"; export default defineController((fastify) => ({ get: ({ params }) => { - const app = getApp(fastify); // Get Application container - + const app = getApp(fastify); // Get Application container + return FindUserUseCase.create(app) .handleById({ id: params.id }) .then(ApiResponse.success) @@ -713,12 +723,12 @@ export default defineController((fastify) => ({ **Alternative: Direct Prisma access** ```ts -import { getPrisma } from '$/@frouvel/kaname/foundation'; -import { defineController } from './$relay'; +import { getPrisma } from "$/@frouvel/kaname/foundation"; +import { defineController } from "./$relay"; export default defineController((fastify) => ({ get: () => { - const prisma = getPrisma(fastify); // Direct Prisma access + const prisma = getPrisma(fastify); // Direct Prisma access // Use for simple queries without UseCase }, })); @@ -729,10 +739,10 @@ export default defineController((fastify) => ({ UseCases should receive the Application instance to access container-registered services: ```ts -import { NotFoundError } from '$/@frouvel/kaname/error/CommonErrors'; -import type { Application } from '$/@frouvel/kaname/foundation'; -import type { PrismaClient } from '@prisma/client'; -import { IUserRepository, UserRepository } from '../repository/User.repository'; +import { NotFoundError } from "$/@frouvel/kaname/error/CommonErrors"; +import type { Application } from "$/@frouvel/kaname/foundation"; +import type { PrismaClient } from "@prisma/client"; +import { IUserRepository, UserRepository } from "../repository/User.repository"; export class FindUserUseCase { private readonly _userRepository: IUserRepository; @@ -742,7 +752,7 @@ export class FindUserUseCase { } static create(app: Application) { - const prisma = app.make('prisma'); + const prisma = app.make("prisma"); return new FindUserUseCase({ userRepository: new UserRepository(prisma), }); @@ -778,6 +788,7 @@ static create() { ``` **Why avoid this?** + - Bypasses the Application container - Harder to test (can't mock via container) - Inconsistent with framework architecture @@ -798,31 +809,36 @@ Example: ```ts const app = getApp(fastify); -const prisma = app.make('prisma'); -const config = app.make>('config'); +const prisma = app.make("prisma"); +const config = app.make>("config"); ``` ## Best Practices Summary ### Response Handling + - **MUST** use [`ApiResponse`](backend-api/@frouvel/kaname/http/ApiResponse.ts:284) facade for all controller responses - **SHOULD** use [`ResponseBuilder`](backend-api/@frouvel/kaname/http/ResponseBuilder.ts:28) for endpoints with complex validation - **MUST** use method-specific error handlers: `ApiResponse.method.get()`, `ApiResponse.method.post()`, etc. ### Error Handling + - **SHOULD** throw structured errors from [`@frouvel/kaname/error`](backend-api/@frouvel/kaname/error/) in UseCases - **MUST** include relevant context in error details - All errors automatically convert to RFC9457 Problem Details format ### Type Definitions + - **MUST** include [`ProblemDetails`](backend-api/commonTypesWithClient/ProblemDetails.types.ts) union type in all response types - **MUST** import shared types from `commonTypesWithClient` ### Pagination + - **MUST** use [`createPaginationMeta`](backend-api/@frouvel/kaname/paginator/createPaginationMeta.ts:3) from `@frouvel/kaname/paginator` - **MUST** return `{ data: T[]; meta: PaginationMeta }` structure ### Class Patterns + - Private fields: `private readonly _fieldName` - Private constructor with static `create()` factory method - Public execution methods: `handle()`, `handleById()`, `handleByUserId()`, etc. @@ -836,6 +852,7 @@ The framework now includes a comprehensive database management module for Prisma **Location**: [`@frouvel/kaname/database/`](backend-api/@frouvel/kaname/database/) **Features**: + - Connection pool management with configurable parameters - Automatic retry logic with exponential backoff - Graceful shutdown handling @@ -843,16 +860,18 @@ The framework now includes a comprehensive database management module for Prisma - Connection reset capabilities **Usage**: + ```ts -import { getPrismaClient } from '$/@frouvel/kaname/database'; +import { getPrismaClient } from "$/@frouvel/kaname/database"; const prisma = getPrismaClient(); // Prisma client ready to use with connection pooling ``` **Environment Variables**: + - `DATABASE_CONNECTION_POOL_SIZE`: Max connections (default: 10) -- `DATABASE_CONNECTION_TIMEOUT`: Timeout in seconds (default: 30) +- `DATABASE_CONNECTION_TIMEOUT`: Timeout in seconds (default: 30) - `DATABASE_POOL_TIMEOUT`: Pool timeout in seconds (default: 2) ### Framework Service Providers (`@frouvel/kaname/foundation/providers`) @@ -868,7 +887,7 @@ The framework provides built-in service providers that handle core functionality - Handles database connection on boot - Implements graceful disconnection -2. **ConsoleServiceProvider**: Registers built-in console commands +2. **ConsoleServiceProvider**: Registers built-in console commands - `config:cache` - Cache configuration for production - `config:clear` - Clear configuration cache - `generate:config-types` - Generate type-safe config types @@ -877,12 +896,13 @@ The framework provides built-in service providers that handle core functionality - `tinker` - Interactive REPL with app context **Registration** ([`bootstrap/app.ts`](backend-api/bootstrap/app.ts)): + ```ts import { Application, DatabaseServiceProvider, ConsoleServiceProvider, -} from '$/@frouvel/kaname/foundation'; +} from "$/@frouvel/kaname/foundation"; const providers = [ DatabaseServiceProvider, @@ -896,6 +916,7 @@ const providers = [ **Unified CLI**: All console commands are now managed through a single `npm run artisan` interface. **Available Commands**: + ```bash npm run artisan # List all commands npm run artisan config:cache # Cache config @@ -938,9 +959,10 @@ backend-api/ **3-Step Process**: 1. **Create command** in `app/console/YourCommand.ts`: + ```ts -import { Command } from '$/@frouvel/kaname/console/Command'; -import type { Application } from '$/@frouvel/kaname/foundation'; +import { Command } from "$/@frouvel/kaname/console/Command"; +import type { Application } from "$/@frouvel/kaname/foundation"; export class YourCommand extends Command { constructor(app: Application) { @@ -949,14 +971,10 @@ export class YourCommand extends Command { protected signature() { return { - name: 'your:command', - description: 'Your command description', - arguments: [ - { name: 'arg', description: 'An argument', required: true } - ], - options: [ - { flags: '-o, --option ', description: 'An option' } - ] + name: "your:command", + description: "Your command description", + arguments: [{ name: "arg", description: "An argument", required: true }], + options: [{ flags: "-o, --option ", description: "An option" }], }; } @@ -965,21 +983,23 @@ export class YourCommand extends Command { if (options.option) { this.line(`Option value: ${options.option}`); } - this.success('Done!'); + this.success("Done!"); } } ``` 2. **Import** in `app/providers/AppServiceProvider.ts`: + ```ts -import { YourCommand } from '$/app/console/YourCommand'; +import { YourCommand } from "$/app/console/YourCommand"; ``` 3. **Register** in `AppServiceProvider.boot()`: + ```ts async boot(app: Application): Promise { const kernel = app.make('ConsoleKernel'); - + kernel.registerCommands([ new YourCommand(app), // More commands... @@ -992,11 +1012,13 @@ async boot(app: Application): Promise { The framework includes standalone scripts for fast CI/CD builds: **Config Generation** ([`@frouvel/kaname/scripts/generate-config-types.ts`](backend-api/@frouvel/kaname/scripts/generate-config-types.ts)): + - Generates `config/$types.ts` without app bootstrap - No database connection required - Fast execution (<1s) **NPM Scripts** ([`package.json`](backend-api/package.json)): + ```json { "scripts": { @@ -1008,6 +1030,7 @@ The framework includes standalone scripts for fast CI/CD builds: ``` **CI Workflow**: + ```bash npm run generate # Generates aspida, frourio, prisma, AND config types npm run typecheck # All types available, no errors diff --git a/backend-api/@frouvel/kaname/database/DB.ts b/backend-api/@frouvel/kaname/database/DB.ts index a76b486..4bfb877 100644 --- a/backend-api/@frouvel/kaname/database/DB.ts +++ b/backend-api/@frouvel/kaname/database/DB.ts @@ -1,3 +1,4 @@ +import type { PrismaClient } from '@prisma/client'; import { DatabaseManager } from './DatabaseManager'; import type { DatabaseConfig, @@ -6,27 +7,27 @@ import type { /** * DB Facade - * + * * Global singleton for database access with zero performance overhead. * Provides direct pass-through to Prisma/Drizzle clients. - * + * * @example * ```typescript * import { DB } from '$/@frouvel/kaname/database'; - * + * * // Direct Prisma access (zero overhead) * const prisma = DB.prisma(); * const users = await prisma.user.findMany({ * where: { age: { gt: 18 } }, * include: { posts: true } * }); - * + * * // Transactions * await DB.transaction(async (prisma) => { * await prisma.user.create({ data: { name: 'John' } }); * await prisma.profile.create({ data: { userId: 1 } }); * }); - * + * * // Multiple connections * const readReplica = DB.prisma('read-replica'); * const analytics = DB.drizzle('analytics'); @@ -59,11 +60,11 @@ class DBFacade { /** * Register a pre-configured database client - * + * * @example * ```typescript * import { PrismaClient } from '@prisma/client'; - * + * * const prisma = new PrismaClient(); * DB.register('default', prisma, 'prisma'); * ``` @@ -76,17 +77,18 @@ class DBFacade { connections: {}, }); } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.manager!.registerClient(name, client, driver); } /** * Get Prisma client for direct access (zero overhead) - * + * * @example * ```typescript * const prisma = DB.prisma(); * const users = await prisma.user.findMany(); - * + * * // Use Prisma's full API * const result = await prisma.user.findMany({ * where: { age: { gte: 18 } }, @@ -95,7 +97,7 @@ class DBFacade { * }); * ``` */ - prisma(connection?: string): T { + prisma(connection?: string): T { const client = this.getManager().prisma(connection); if (!client) { const name = connection || this.getManager().getDefaultConnection(); @@ -108,12 +110,12 @@ class DBFacade { /** * Get Drizzle client for direct access (zero overhead) - * + * * @example * ```typescript * const db = DB.drizzle(); * const users = await db.select().from(usersTable); - * + * * // Use Drizzle's full API * const result = await db * .select() @@ -135,7 +137,7 @@ class DBFacade { /** * Get the underlying database client (ORM-agnostic) - * + * * @example * ```typescript * const client = DB.client(); @@ -148,7 +150,7 @@ class DBFacade { /** * Execute a function within a database transaction - * + * * @example * ```typescript * // Prisma transaction @@ -156,7 +158,7 @@ class DBFacade { * const user = await prisma.user.create({ data: { name: 'John' } }); * await prisma.profile.create({ data: { userId: user.id } }); * }); - * + * * // Drizzle transaction * await DB.transaction(async (tx) => { * await tx.insert(users).values({ name: 'John' }); @@ -180,7 +182,7 @@ class DBFacade { /** * Disconnect from all database connections - * + * * @example * ```typescript * // In your app shutdown handler @@ -210,13 +212,13 @@ class DBFacade { /** * Set the default connection - * + * * @example * ```typescript * // Switch to read replica for read-heavy operations * DB.setDefaultConnection('read-replica'); * const users = DB.prisma().user.findMany(); - * + * * // Switch back to primary * DB.setDefaultConnection('primary'); * ``` @@ -236,14 +238,14 @@ class DBFacade { /** * Global DB Facade instance - * + * * Import this in your code to access database functionality. - * + * * @example * ```typescript * import { DB } from '$/@frouvel/kaname/database'; - * + * * const users = await DB.prisma().user.findMany(); * ``` */ -export const DB = new DBFacade(); \ No newline at end of file +export const DB = new DBFacade(); diff --git a/backend-api/@frouvel/kaname/database/DatabaseManager.ts b/backend-api/@frouvel/kaname/database/DatabaseManager.ts index 963872f..f61abed 100644 --- a/backend-api/@frouvel/kaname/database/DatabaseManager.ts +++ b/backend-api/@frouvel/kaname/database/DatabaseManager.ts @@ -3,6 +3,8 @@ import type { ConnectionConfig, } from './contracts/DatabaseManager.interface'; import type { PrismaClient } from '@prisma/client'; +import { getPrismaClient } from './PrismaClientManager'; +import { getDrizzleClient } from './DrizzleClientManager'; /** * Database Manager @@ -237,13 +239,8 @@ export class DatabaseManager implements IDatabaseManager { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars private createPrismaClient(config: ConnectionConfig): any { - // Dynamic import to avoid bundling Prisma if not used try { - // In actual implementation, this would be the real Prisma client - // For now, we'll throw an error to indicate it needs to be provided - throw new Error( - 'Prisma client must be registered using registerClient() or provided via dependency injection', - ); + return getPrismaClient(); } catch (error) { throw new Error(`Failed to create Prisma client: ${error}`); } @@ -252,14 +249,13 @@ export class DatabaseManager implements IDatabaseManager { /** * Create Drizzle client */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars private createDrizzleClient(config: ConnectionConfig): any { - // Dynamic import to avoid bundling Drizzle if not used try { - // In actual implementation, this would create a Drizzle client - throw new Error( - 'Drizzle client must be registered using registerClient() or provided via dependency injection', - ); + const connectionString = config.url; + if (!connectionString) { + throw new Error('Drizzle connection URL is required in config'); + } + return getDrizzleClient(connectionString); } catch (error) { throw new Error(`Failed to create Drizzle client: ${error}`); } diff --git a/backend-api/@frouvel/kaname/database/DrizzleClientManager.ts b/backend-api/@frouvel/kaname/database/DrizzleClientManager.ts new file mode 100644 index 0000000..d3a4270 --- /dev/null +++ b/backend-api/@frouvel/kaname/database/DrizzleClientManager.ts @@ -0,0 +1,56 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import * as schema from '$/database/drizzle/schema'; + +let drizzleClient: PostgresJsDatabase | null = null; +let postgresConnection: ReturnType | null = null; + +/** + * Get Drizzle client instance + * + * @example + * ```typescript + * const db = getDrizzleClient(); + * const users = await db.select().from(schema.users); + * ``` + */ +export function getDrizzleClient( + connectionString?: string, +): PostgresJsDatabase { + if (drizzleClient) { + return drizzleClient; + } + + const dbUrl = connectionString || process.env.DATABASE_URL; + if (!dbUrl) { + throw new Error('DATABASE_URL is not defined'); + } + + // Create postgres connection + postgresConnection = postgres(dbUrl); + + // Create Drizzle client with schema + drizzleClient = drizzle(postgresConnection, { schema }); + + return drizzleClient; +} + +/** + * Disconnect Drizzle client + */ +export async function disconnectDrizzleClient(): Promise { + if (postgresConnection) { + await postgresConnection.end(); + postgresConnection = null; + drizzleClient = null; + } +} + +/** + * Reset Drizzle connection (useful for testing) + */ +export function resetDrizzleConnection(): void { + drizzleClient = null; + postgresConnection = null; +} diff --git a/backend-api/@frouvel/kaname/database/index.ts b/backend-api/@frouvel/kaname/database/index.ts index 4d8dffd..8006a81 100644 --- a/backend-api/@frouvel/kaname/database/index.ts +++ b/backend-api/@frouvel/kaname/database/index.ts @@ -18,11 +18,18 @@ export type { ConnectionConfig, } from './contracts/DatabaseManager.interface'; -// Legacy Prisma utilities (deprecated, use DB.prisma() instead) +// Prisma utilities export { getPrismaClient, disconnectPrismaClient, withRetry, checkDatabaseConnection, resetPrismaConnection, -} from './PrismaClientManager'; \ No newline at end of file +} from './PrismaClientManager'; + +// Drizzle utilities +export { + getDrizzleClient, + disconnectDrizzleClient, + resetDrizzleConnection, +} from './DrizzleClientManager'; \ No newline at end of file diff --git a/backend-api/@frouvel/kaname/docs/RESPONSE_BUILDER.md b/backend-api/@frouvel/kaname/docs/RESPONSE_BUILDER.md index efcd9fa..fbdbad0 100644 --- a/backend-api/@frouvel/kaname/docs/RESPONSE_BUILDER.md +++ b/backend-api/@frouvel/kaname/docs/RESPONSE_BUILDER.md @@ -20,7 +20,7 @@ import { z } from 'zod'; export default defineController(() => ({ post: ({ body }) => ResponseBuilder.create() - .withValidation( + .withZodValidation( body, z.object({ name: z.string().min(1, '名前は必須です'), @@ -43,7 +43,7 @@ export default defineController(() => ({ export default defineController(() => ({ put: ({ body }) => ResponseBuilder.create() - .withValidation( + .withZodValidation( body, z.object({ age: z.number().positive(), @@ -71,7 +71,7 @@ export default defineController(() => ({ ```typescript ResponseBuilder.create() - .withValidation(body, schema) + .withZodValidation(body, schema) .then((data) => ApiResponse.success(data)); ``` @@ -83,7 +83,7 @@ ResponseBuilder.create() export default defineController(() => ({ post: ({ body }) => ResponseBuilder.create() - .withValidation( + .withZodValidation( body, z.object({ name: z.string(), @@ -122,7 +122,7 @@ return Validator.validateAndExecute(body, schema, (data) => { ```typescript return ResponseBuilder.create() - .withValidation(body, schema) + .withZodValidation(body, schema) .handle((data) => { if (data.age < 18) { return ApiResponse.forbidden('18歳未満です'); @@ -144,15 +144,17 @@ return ResponseBuilder.create() 新しいBuilderインスタンスを作成 -### `.withValidation(data, schema)` +### `.withZodValidation(data, schema)` Zodスキーマでデータをバリデーション **パラメータ:** + - `data`: バリデーション対象のデータ - `schema`: Zodスキーマ **戻り値:** + - バリデーション済みデータを持つBuilderインスタンス ### `.handle(handler)` @@ -160,9 +162,11 @@ Zodスキーマでデータをバリデーション バリデーション済みデータでハンドラーを実行 **パラメータ:** + - `handler`: `(data: T) => Response` 形式の関数 **戻り値:** + - ハンドラーの戻り値、またはバリデーションエラー ### `.then(handler)` @@ -174,9 +178,11 @@ Zodスキーマでデータをバリデーション ハンドラーを実行し、エラーでない場合は自動的に`ApiResponse.success()`でラップ **パラメータ:** + - `handler`: `(data: T) => any | ApiResponse` 形式の関数 **戻り値:** + - エラーレスポンス、または成功レスポンス ## エラーハンドリング @@ -207,7 +213,7 @@ Zodスキーマでデータをバリデーション TypeScriptの型推論により、バリデーション後のデータは完全に型安全です: ```typescript -.withValidation( +.withZodValidation( body, z.object({ name: z.string(), @@ -225,6 +231,7 @@ TypeScriptの型推論により、バリデーション後のデータは完全 ## ベストプラクティス 1. **スキーマは再利用可能にする** + ```typescript const userSchema = z.object({ name: z.string(), @@ -232,10 +239,11 @@ TypeScriptの型推論により、バリデーション後のデータは完全 }); // 複数の場所で使用 - ResponseBuilder.create().withValidation(body, userSchema) + ResponseBuilder.create().withZodValidation(body, userSchema); ``` 2. **ビジネスロジックはhandler内で** + ```typescript .handle((data) => { // ✅ ビジネスロジック @@ -251,10 +259,10 @@ TypeScriptの型推論により、バリデーション後のデータは完全 // ✅ 簡潔で読みやすい post: ({ body }) => ResponseBuilder.create() - .withValidation(body, schema) - .handle((data) => ApiResponse.success(data)) + .withZodValidation(body, schema) + .handle((data) => ApiResponse.success(data)); ``` ## 実装例 -完全な実装例は [`backend-api/api/example-rfc9457/controller.ts`](../api/example-rfc9457/controller.ts) の `options` メソッドを参照してください。 \ No newline at end of file +完全な実装例は [`backend-api/api/example-rfc9457/controller.ts`](../api/example-rfc9457/controller.ts) の `options` メソッドを参照してください。 diff --git a/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts index b0fb781..46effe8 100644 --- a/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.test.ts @@ -10,7 +10,7 @@ import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; import { z } from 'zod'; describe('ApiResponseBuilder', () => { - describe('withValidation', () => { + describe('withZodValidation', () => { it('should successfully validate data', () => { const schema = z.object({ name: z.string(), @@ -20,7 +20,7 @@ describe('ApiResponseBuilder', () => { const validData = { name: 'John', age: 30 }; const result = ApiResponseBuilder.create() - .withValidation(validData, schema) + .withZodValidation(validData, schema) .handle((data) => { expect(data).toEqual(validData); return ApiResponse.success(data); @@ -39,7 +39,7 @@ describe('ApiResponseBuilder', () => { const invalidData = { name: 'John', age: 'not-a-number' }; const result = ApiResponseBuilder.create() - .withValidation(invalidData, schema) + .withZodValidation(invalidData, schema) .handle((data) => ApiResponse.success(data)); expect(result.status).toBe(400); @@ -59,7 +59,7 @@ describe('ApiResponseBuilder', () => { const invalidData = { email: 'invalid-email', age: -5 }; const result = ApiResponseBuilder.create() - .withValidation(invalidData, schema) + .withZodValidation(invalidData, schema) .handle((data) => ApiResponse.success(data)); expect(result.status).toBe(400); @@ -76,7 +76,7 @@ describe('ApiResponseBuilder', () => { const data = { name: 'Test' }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .handle((validData) => { return ApiResponse.success({ processed: validData.name }); }); @@ -90,7 +90,7 @@ describe('ApiResponseBuilder', () => { const data = { age: 15 }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .handle((validData) => { if (validData.age < 18) { return ApiResponse.forbidden('18歳未満は登録できません', { @@ -114,7 +114,7 @@ describe('ApiResponseBuilder', () => { let handlerCalled = false; const result = ApiResponseBuilder.create() - .withValidation(invalidData, schema) + .withZodValidation(invalidData, schema) .handle(() => { handlerCalled = true; return ApiResponse.success({}); @@ -131,7 +131,7 @@ describe('ApiResponseBuilder', () => { const data = { value: 42 }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .then((validData) => ApiResponse.success(validData)); expect(result.status).toBe(200); @@ -145,7 +145,7 @@ describe('ApiResponseBuilder', () => { const data = { name: 'Auto' }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .executeWithSuccess((validData) => { return { processed: validData.name }; }); @@ -159,7 +159,7 @@ describe('ApiResponseBuilder', () => { const data = { age: 15 }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .executeWithSuccess((validData) => { if (validData.age < 18) { return ApiResponse.forbidden('18歳未満です'); @@ -176,7 +176,7 @@ describe('ApiResponseBuilder', () => { const data = { id: 1 }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .executeWithSuccess((validData) => { return ApiResponse.success({ message: 'Custom success', @@ -218,7 +218,7 @@ describe('ApiResponseBuilder', () => { }; const result = ApiResponseBuilder.create() - .withValidation(validData, schema) + .withZodValidation(validData, schema) .handle((data) => { expect(data.user.profile.age).toBe(30); return ApiResponse.success(data); @@ -240,7 +240,7 @@ describe('ApiResponseBuilder', () => { }; const result = ApiResponseBuilder.create() - .withValidation(data, schema) + .withZodValidation(data, schema) .handle((validData) => { expect(validData.required).toBe('test'); expect(validData.optional).toBeUndefined(); @@ -261,7 +261,7 @@ describe('ApiResponseBuilder', () => { }); ApiResponseBuilder.create() - .withValidation({ name: 'Test', age: 30, active: true }, schema) + .withZodValidation({ name: 'Test', age: 30, active: true }, schema) .handle((data) => { // TypeScript should infer these types correctly const name: string = data.name; diff --git a/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts index 4b6dacc..ae3d609 100644 --- a/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponseBuilder.ts @@ -45,14 +45,14 @@ export class ApiResponseBuilder { * @example * ```typescript * return ApiResponseBuilder.create() - * .withValidation(body, userSchema) + * .withZodValidation(body, userSchema) * .handle((data) => { * if (data.age < 18) return ApiResponse.forbidden('未成年は登録できません'); * return ApiResponse.success(data); * }); * ``` */ - withValidation( + withZodValidation( data: unknown, schema: S, ): ApiResponseBuilder> { diff --git a/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts b/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts index c74b38d..2ea9fe9 100644 --- a/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts +++ b/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts @@ -26,13 +26,13 @@ export abstract class DatabaseTestCase extends TestCase { if (!DatabaseTestCase.isMigrated) { try { - await execPromise('npx prisma migrate deploy'); + await execPromise('npx prisma migrate deploy --schema=./database/prisma/schema.prisma'); DatabaseTestCase.isMigrated = true; } catch (error) { console.error('Migration failed:', error); console.log('Resetting test database...'); try { - await execPromise('npx prisma migrate reset --force'); + await execPromise('npx prisma migrate reset --force --schema=./database/prisma/schema.prisma'); DatabaseTestCase.isMigrated = true; } catch (resetError) { console.error('Database reset failed:', resetError); diff --git a/backend-api/@frouvel/kaname/testing/setup.ts b/backend-api/@frouvel/kaname/testing/setup.ts index 7fb75e8..34f37fd 100644 --- a/backend-api/@frouvel/kaname/testing/setup.ts +++ b/backend-api/@frouvel/kaname/testing/setup.ts @@ -96,13 +96,13 @@ export function setupTestEnvironment(options: TestEnvironmentOptions = {}) { // Run migrations once if (runMigrations && !isMigrated) { try { - await execPromise('npx prisma migrate deploy'); + await execPromise('npx prisma migrate deploy --schema=./database/prisma/schema.prisma'); isMigrated = true; } catch (error) { console.error('Migration failed:', error); console.log('Resetting test database...'); try { - await execPromise('npx prisma migrate reset --force'); + await execPromise('npx prisma migrate reset --force --schema=./database/prisma/schema.prisma'); isMigrated = true; } catch (resetError) { console.error('Database reset failed:', resetError); diff --git a/backend-api/api/example/error/rfc9457/controller.ts b/backend-api/api/example/error/rfc9457/controller.ts index dbe3d9c..d42fbc7 100644 --- a/backend-api/api/example/error/rfc9457/controller.ts +++ b/backend-api/api/example/error/rfc9457/controller.ts @@ -102,7 +102,7 @@ export default defineController(() => ({ // Perfect for one-liner controller actions with complex validation patch: ({ body }) => ApiResponseBuilder.create() - .withValidation( + .withZodValidation( body, z.object({ name: z.string().min(1, '名前は必須です'), @@ -146,7 +146,7 @@ export default defineController(() => ({ // Use whichever reads better in your context options: ({ body }) => ApiResponseBuilder.create() - .withValidation( + .withZodValidation( body, z.object({ name: z.string().min(1, '名前は必須です'), diff --git a/backend-api/config/database.ts b/backend-api/config/database.ts index eeb4f20..10959cd 100644 --- a/backend-api/config/database.ts +++ b/backend-api/config/database.ts @@ -1,5 +1,5 @@ -import type { DatabaseConfig } from '$/@frouvel/kaname/database'; -import { env } from '$/env'; +import type { DatabaseConfig } from '../@frouvel/kaname/database'; +import { env } from '../env'; /** * Database Configuration diff --git a/backend-api/database/drizzle/schema/index.ts b/backend-api/database/drizzle/schema/index.ts new file mode 100644 index 0000000..f32bde6 --- /dev/null +++ b/backend-api/database/drizzle/schema/index.ts @@ -0,0 +1,7 @@ +/** + * Drizzle Schema Index + * + * Central export for all Drizzle schema definitions + */ + +export * from './users'; \ No newline at end of file diff --git a/backend-api/database/drizzle/schema/users.ts b/backend-api/database/drizzle/schema/users.ts new file mode 100644 index 0000000..e5953b2 --- /dev/null +++ b/backend-api/database/drizzle/schema/users.ts @@ -0,0 +1,18 @@ +import { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core'; + +/** + * Users Table Schema (Drizzle) + * + * Matches the Prisma User model for consistency + */ +export const users = pgTable('User', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + age: integer('age').notNull(), + createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updatedAt', { mode: 'date' }).notNull().defaultNow(), +}); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; \ No newline at end of file diff --git a/backend-api/prisma/.gitignore b/backend-api/database/prisma/.gitignore similarity index 100% rename from backend-api/prisma/.gitignore rename to backend-api/database/prisma/.gitignore diff --git a/backend-api/prisma/migrations/migration_lock.toml b/backend-api/database/prisma/migrations/migration_lock.toml similarity index 100% rename from backend-api/prisma/migrations/migration_lock.toml rename to backend-api/database/prisma/migrations/migration_lock.toml diff --git a/backend-api/prisma/schema.prisma b/backend-api/database/prisma/schema.prisma similarity index 83% rename from backend-api/prisma/schema.prisma rename to backend-api/database/prisma/schema.prisma index 32ea419..a651410 100644 --- a/backend-api/prisma/schema.prisma +++ b/backend-api/database/prisma/schema.prisma @@ -15,7 +15,10 @@ generator frourio_framework_prisma_model_generator { } model User { - id String @id @default(uuid()) + id Int @id @default(autoincrement()) + name String + email String @unique + age Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend-api/prisma/seed.dev.ts b/backend-api/database/prisma/seed.dev.ts similarity index 100% rename from backend-api/prisma/seed.dev.ts rename to backend-api/database/prisma/seed.dev.ts diff --git a/backend-api/prisma/seed.production.ts b/backend-api/database/prisma/seed.production.ts similarity index 100% rename from backend-api/prisma/seed.production.ts rename to backend-api/database/prisma/seed.production.ts diff --git a/backend-api/prisma/seeders/dev-only/.keep b/backend-api/database/prisma/seeders/dev-only/.keep similarity index 100% rename from backend-api/prisma/seeders/dev-only/.keep rename to backend-api/database/prisma/seeders/dev-only/.keep diff --git a/backend-api/domain/user/repository/User.repository.interface.ts b/backend-api/domain/user/repository/User.repository.interface.ts new file mode 100644 index 0000000..f93f83f --- /dev/null +++ b/backend-api/domain/user/repository/User.repository.interface.ts @@ -0,0 +1,37 @@ +export interface IUserRepository { + findById(args: { id: number }): Promise<{ + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + } | null>; + + create(data: { name: string; email: string; age: number }): Promise<{ + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }>; + + update(args: { + id: number; + data: { + name?: string; + email?: string; + age?: number; + }; + }): Promise<{ + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }>; + + deleteById(args: { id: number }): Promise; +} diff --git a/backend-api/domain/user/repository/drizzle/User.repository.ts b/backend-api/domain/user/repository/drizzle/User.repository.ts new file mode 100644 index 0000000..e51c547 --- /dev/null +++ b/backend-api/domain/user/repository/drizzle/User.repository.ts @@ -0,0 +1,61 @@ +import type { IUserRepository } from '../User.repository.interface'; +import { DB } from '$/@frouvel/kaname/database'; +import { users } from '$/database/drizzle/schema'; +import { eq } from 'drizzle-orm'; + +/** + * Drizzle User Repository + * + * Uses DB facade for database access (same pattern as Prisma repository). + * Demonstrates ORM-agnostic repository interface. + */ +export class UserRepositoryDrizzle implements IUserRepository { + private db = DB.drizzle(); + + async findById(args: { id: number }) { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.id, args.id)) + .limit(1); + + return user || null; + } + + async create(data: { name: string; email: string; age: number }) { + const [user] = await this.db + .insert(users) + .values({ + name: data.name, + email: data.email, + age: data.age, + }) + .returning(); + + return user; + } + + async update(args: { + id: number; + data: { + name?: string; + email?: string; + age?: number; + }; + }) { + const [user] = await this.db + .update(users) + .set({ + ...args.data, + updatedAt: new Date(), + }) + .where(eq(users.id, args.id)) + .returning(); + + return user; + } + + async deleteById(args: { id: number }) { + await this.db.delete(users).where(eq(users.id, args.id)); + } +} \ No newline at end of file diff --git a/backend-api/domain/user/repository/prisma/User.repository.ts b/backend-api/domain/user/repository/prisma/User.repository.ts new file mode 100644 index 0000000..820a4b7 --- /dev/null +++ b/backend-api/domain/user/repository/prisma/User.repository.ts @@ -0,0 +1,51 @@ +import type { IUserRepository } from '../User.repository.interface'; +import { DB } from '$/@frouvel/kaname/database'; + +/** + * Prisma User Repository + * + * Best practice: Use DB facade instead of injecting PrismaClient + * - No constructor injection needed + * - Automatic connection management + * - Zero overhead (direct pass-through) + * - Easy to test (can swap connections) + */ +export class UserRepository implements IUserRepository { + private _prisma = DB.prisma(); + + async findById(args: { id: number }) { + return this._prisma.user.findUnique({ + where: { id: args.id }, + }); + } + + async create(data: { name: string; email: string; age: number }) { + return this._prisma.user.create({ + data: { + name: data.name, + email: data.email, + age: data.age, + }, + }); + } + + async update(args: { + id: number; + data: { + name?: string; + email?: string; + age?: number; + }; + }) { + return this._prisma.user.update({ + where: { id: args.id }, + data: args.data, + }); + } + + async deleteById(args: { id: number }) { + await this._prisma.user.delete({ + where: { id: args.id }, + }); + } +} diff --git a/backend-api/drizzle.config.ts b/backend-api/drizzle.config.ts new file mode 100644 index 0000000..15a1fb5 --- /dev/null +++ b/backend-api/drizzle.config.ts @@ -0,0 +1,14 @@ +import type { Config } from 'drizzle-kit'; +import { config } from 'dotenv'; + +config(); + +export default { + schema: './database/drizzle/schema/*', + out: './database/drizzle/migrations', + dialect: 'postgresql', + dbCredentials: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + url: process.env.DATABASE_URL!, + }, +} satisfies Config; diff --git a/backend-api/package-lock.json b/backend-api/package-lock.json index e53e561..5c29ea9 100644 --- a/backend-api/package-lock.json +++ b/backend-api/package-lock.json @@ -26,8 +26,10 @@ "comment-parser": "^1.4.1", "dayjs": "^1.11.19", "dotenv": "^17.2.3", + "drizzle-orm": "^0.44.7", "fastify": "^5.6.1", "pino-pretty": "^13.1.2", + "postgres": "^3.4.7", "yaml": "^2.8.1", "zod": "^3.25.61" }, @@ -38,6 +40,7 @@ "@typescript-eslint/parser": "^8.46.3", "commonTypesWithClient": "file:./commonTypesWithClient", "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.7", "esbuild": "^0.25.0", "esbuild-node-externals": "^1.18.0", "eslint": "^9.39.1", @@ -71,6 +74,449 @@ "axios": "" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2514,6 +2960,13 @@ "node": ">=8" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -2979,6 +3432,147 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.7", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.7.tgz", + "integrity": "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.7", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", + "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3164,6 +3758,19 @@ "esbuild": "0.12 - 0.25" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5143,6 +5750,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5671,6 +6291,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5681,6 +6311,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/backend-api/package.json b/backend-api/package.json index 438e77c..cb99f1c 100644 --- a/backend-api/package.json +++ b/backend-api/package.json @@ -18,18 +18,22 @@ "generate": "concurrently --names=\"🛡️:aspida,🏰:frourio,💽:prisma,⚙️:config,📄:openapi\" \"npm run generate:aspida\" \"npm run generate:frourio\" \"npm run generate:prisma\" \"npm run generate:config\" \"npm run generate:openapi\" -c cyan", "generate:aspida": "aspida", "generate:frourio": "frourio", - "generate:prisma": "prisma generate", + "generate:prisma": "prisma generate --schema=./database/prisma/schema.prisma", "generate:config": "tsx @frouvel/kaname/scripts/generate-config-types.ts", "generate:openapi": "tsx @frouvel/kaname/scripts/generate-openapi-spec.ts", - "migrate:dev": "prisma migrate dev && npx prisma db seed", - "migrate:dev:createonly": "prisma migrate dev --create-only", - "migrate:deploy": "prisma migrate deploy && npx prisma db seed", - "migrate:reset": "prisma migrate reset", + "drizzle:generate": "drizzle-kit generate", + "drizzle:migrate": "drizzle-kit migrate", + "drizzle:push": "drizzle-kit push", + "drizzle:studio": "drizzle-kit studio", + "migrate:dev": "prisma migrate dev --schema=./database/prisma/schema.prisma && npx prisma db seed", + "migrate:dev:createonly": "prisma migrate dev --create-only --schema=./database/prisma/schema.prisma", + "migrate:deploy": "prisma migrate deploy --schema=./database/prisma/schema.prisma && npx prisma db seed", + "migrate:reset": "prisma migrate reset --schema=./database/prisma/schema.prisma", "prestart": "npm run generate && npm run migrate:deploy", "start": "concurrently --names=\"🏃:run\" \"npm run start:run\" -c cyan", "start:run": "node --enable-source-maps index.js", - "seed:dev": "tsx prisma/seed.dev.ts", - "seed:production": "tsx prisma/seed.production.ts", + "seed:dev": "tsx database/prisma/seed.dev.ts", + "seed:production": "tsx database/prisma/seed.production.ts", "test": "vitest run --pool=forks", "typecheck": "tsc --noEmit", "lint": "npx eslint .", @@ -38,7 +42,7 @@ "artisan": "tsx @frouvel/kaname/artisan/index.ts" }, "prisma": { - "seed": "tsx prisma/seed.production.ts" + "seed": "tsx database/prisma/seed.production.ts" }, "dependencies": { "@aspida/axios": "^1.14.0", @@ -58,8 +62,10 @@ "comment-parser": "^1.4.1", "dayjs": "^1.11.19", "dotenv": "^17.2.3", + "drizzle-orm": "^0.44.7", "fastify": "^5.6.1", "pino-pretty": "^13.1.2", + "postgres": "^3.4.7", "yaml": "^2.8.1", "zod": "^3.25.61" }, @@ -70,6 +76,7 @@ "@typescript-eslint/parser": "^8.46.3", "commonTypesWithClient": "file:./commonTypesWithClient", "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.7", "esbuild": "^0.25.0", "esbuild-node-externals": "^1.18.0", "eslint": "^9.39.1", @@ -86,4 +93,4 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.0.8" } -} \ No newline at end of file +} From 2e4cbf69377462d56462a20cf5c6f8c58a6c8096 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 14 Nov 2025 06:29:30 +0900 Subject: [PATCH 05/28] feat(database): Refactor DatabaseServiceProvider to improve connection handling and initialization feat(tests): Update IntegrationTestCase and setup to utilize HttpKernel for server initialization feat(migrations): Add User table migration with createdAt and updatedAt fields chore: Update migration_lock.toml comments for clarity refactor: Remove deprecated app initialization logic from app.ts --- .../providers/DatabaseServiceProvider.ts | 69 ++++++++++------- .../kaname/testing/IntegrationTestCase.ts | 6 +- backend-api/@frouvel/kaname/testing/setup.ts | 6 +- .../migrations/20251113212419/migration.sql | 14 ++++ .../prisma/migrations/migration_lock.toml | 4 +- backend-api/service/app.ts | 77 ------------------- backend-api/tests/setup.ts | 6 +- 7 files changed, 69 insertions(+), 113 deletions(-) create mode 100644 backend-api/database/prisma/migrations/20251113212419/migration.sql delete mode 100644 backend-api/service/app.ts diff --git a/backend-api/@frouvel/kaname/foundation/providers/DatabaseServiceProvider.ts b/backend-api/@frouvel/kaname/foundation/providers/DatabaseServiceProvider.ts index f5e8640..e2ab5fb 100644 --- a/backend-api/@frouvel/kaname/foundation/providers/DatabaseServiceProvider.ts +++ b/backend-api/@frouvel/kaname/foundation/providers/DatabaseServiceProvider.ts @@ -1,41 +1,54 @@ /** * Database Service Provider * - * Framework-level service provider that registers database services. - * Registers Prisma client as a singleton and handles connection setup. + * Initializes the DB facade and registers database connections. + * Automatically sets up Prisma client. */ -import type { Application, ServiceProvider } from '../Application'; -import type { PrismaClient } from '@prisma/client'; -import { getPrismaClient } from '../../database'; +import type { + Application, + ServiceProvider, +} from '$/@frouvel/kaname/foundation'; +import { DB, getPrismaClient } from '$/@frouvel/kaname/database'; export class DatabaseServiceProvider implements ServiceProvider { - /** - * Register database services - */ - register(app: Application): void { - // Register Prisma client as a singleton - app.singleton('prisma', () => getPrismaClient()); + async register(app: Application): Promise { + // Get database config from container + // Config is loaded by LoadConfiguration bootstrapper + let dbConfig; + try { + const config = app.make>('config'); + dbConfig = config.database; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Config not loaded yet, use default config + dbConfig = { + default: 'default', + connections: { + default: { + driver: 'prisma', + }, + }, + }; + } + + // Initialize DB facade with config + DB.init(dbConfig); - console.log('[DatabaseServiceProvider] Database services registered'); + // Register Prisma client + const prisma = getPrismaClient(); + DB.register('default', prisma, 'prisma'); + + // Register in container + app.singleton('prisma', () => prisma); + app.singleton('db', () => DB); } - /** - * Boot database services - */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars async boot(app: Application): Promise { - // Connect to database on boot - const prisma = app.make('prisma'); - - try { - await prisma.$connect(); - console.log('[DatabaseServiceProvider] Database connection established'); - } catch (error) { - console.error( - '[DatabaseServiceProvider] Failed to connect to database:', - error, - ); - throw error; + // Database is ready + if (DB.isConnected()) { + console.log('[Database] Connection established'); } } -} \ No newline at end of file +} diff --git a/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts b/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts index 8e4f64b..f080b12 100644 --- a/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts +++ b/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts @@ -1,6 +1,7 @@ import { DatabaseTestCase } from './DatabaseTestCase'; import type { FastifyInstance } from 'fastify'; -import { init } from '$/service/app'; +import app from '$/bootstrap/app'; +import type { HttpKernel } from '$/@frouvel/kaname/foundation'; import { env } from '$/env'; /** @@ -25,7 +26,8 @@ export abstract class IntegrationTestCase extends DatabaseTestCase { protected async setUpBeforeClass(): Promise { await super.setUpBeforeClass(); - this.server = init(); + const kernel = app.make('HttpKernel'); + this.server = await kernel.handle(); await this.server.listen({ port: IntegrationTestCase.testPort, host: '0.0.0.0', diff --git a/backend-api/@frouvel/kaname/testing/setup.ts b/backend-api/@frouvel/kaname/testing/setup.ts index 34f37fd..9ca519f 100644 --- a/backend-api/@frouvel/kaname/testing/setup.ts +++ b/backend-api/@frouvel/kaname/testing/setup.ts @@ -4,7 +4,8 @@ import util from 'util'; import { exec } from 'child_process'; import { getPrismaClient } from '$/@frouvel/kaname/database'; import { env } from '$/env'; -import { init } from '$/service/app'; +import app from '$/bootstrap/app'; +import type { HttpKernel } from '$/@frouvel/kaname/foundation'; const execPromise = util.promisify(exec); @@ -112,7 +113,8 @@ export function setupTestEnvironment(options: TestEnvironmentOptions = {}) { } // Start test server - server = init(); + const kernel = app.make('HttpKernel'); + server = await kernel.handle(); await server.listen({ port, host: '0.0.0.0' }); }); diff --git a/backend-api/database/prisma/migrations/20251113212419/migration.sql b/backend-api/database/prisma/migrations/20251113212419/migration.sql new file mode 100644 index 0000000..b4d81ef --- /dev/null +++ b/backend-api/database/prisma/migrations/20251113212419/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "age" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/backend-api/database/prisma/migrations/migration_lock.toml b/backend-api/database/prisma/migrations/migration_lock.toml index fbffa92..044d57c 100644 --- a/backend-api/database/prisma/migrations/migration_lock.toml +++ b/backend-api/database/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend-api/service/app.ts b/backend-api/service/app.ts deleted file mode 100644 index 555db7a..0000000 --- a/backend-api/service/app.ts +++ /dev/null @@ -1,77 +0,0 @@ -import server from '$/$server'; -import cookie from '@fastify/cookie'; -import cors from '@fastify/cors'; -import helmet from '@fastify/helmet'; -import jwt from '@fastify/jwt'; -import { config } from 'dotenv'; -import type { FastifyServerFactory } from 'fastify'; -import Fastify from 'fastify'; -import { env } from '$/env'; -import { CORS_ORIGINS } from '$/config/cors'; -import { AbstractFrourioFrameworkError } from '$/@frouvel/kaname/error/FrourioFrameworkError'; -import { PROBLEM_DETAILS_MEDIA_TYPE } from '$/@frouvel/kaname/http/ApiResponse'; - -config(); - -export const init = (serverFactory?: FastifyServerFactory) => { - // Sentry.init({ - // dsn: process.env.SENTRY_DSN, - // integrations: [new ProfilingIntegration()], - // // Performance Monitoring - // tracesSampleRate: 1.0, // Capture 100% of the transactions - // // Set sampling rate for profiling - this is relative to tracesSampleRate - // profilesSampleRate: 1.0, - // }); - - console.log(`Application running in ${env.NODE_ENV} mode.`); - - const app = Fastify({ - maxParamLength: 1000, // This defaults to 100: returns 404 error params surpass this length - ...serverFactory, - logger: - env.NODE_ENV === 'production' - ? true // Defualt log - : { - level: 'info', - // Using a simpler logger configuration to avoid worker thread issues - transport: { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'SYS:standard', - ignore: 'pid,hostname', - }, - }, - }, - }); - - app.register(helmet); - app.register(cors, { - origin: CORS_ORIGINS, - credentials: true, - methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], - }); - app.register(cookie); - app.register(jwt, { secret: process.env.API_JWT_SECRET ?? '' }); - - app.setErrorHandler((error, request, reply) => { - if (error instanceof AbstractFrourioFrameworkError) { - console.error({ - error, - requestId: request.id, - body: request.body, - params: request.params, - query: request.query, - }); - - reply - .status(error.httpStatusCode) - .header('Content-Type', PROBLEM_DETAILS_MEDIA_TYPE) - .send(error.toProblemDetails()); - } - }); - server(app, { basePath: process.env.API_BASE_PATH }); - - return app; -}; diff --git a/backend-api/tests/setup.ts b/backend-api/tests/setup.ts index 8b7cb95..bb9fa5b 100644 --- a/backend-api/tests/setup.ts +++ b/backend-api/tests/setup.ts @@ -4,7 +4,8 @@ import util from 'util'; import { exec } from 'child_process'; import { getPrismaClient } from '$/@frouvel/kaname/database'; import { env } from '$/env'; -import { init } from '$/service/app'; +import app from '$/bootstrap/app'; +import type { HttpKernel } from '$/@frouvel/kaname/foundation'; let server: FastifyInstance; const prisma = getPrismaClient(); @@ -41,7 +42,8 @@ let isMigrated = false; beforeAll(async (info) => { if (unneededServer({ filepath: info.file.filepath })) return; - server = init(); + const kernel = app.make('HttpKernel'); + server = await kernel.handle(); // since +1 is used for websocket, +11 is used for testing API server await server.listen({ port: env.API_SERVER_PORT + 11, host: '0.0.0.0' }); From 6a35e9d6300291850b0935585d63d3baf42247f7 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 14 Nov 2025 07:14:34 +0900 Subject: [PATCH 06/28] feat(user): implement pagination in UserRepository and add PaginateUserUsecase --- backend-api/app/console/ExampleCommand.ts | 2 +- .../app/providers/AppServiceProvider.ts | 18 +++++++++-- .../repository/User.repository.interface.ts | 25 +++++++++++++++ .../user/repository/prisma/User.repository.ts | 32 +++++++++++++++++++ .../user/usecase/PaginateUser.usecase.ts | 23 +++++++++++++ .../health}/health.integration.test.ts | 0 6 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 backend-api/domain/user/usecase/PaginateUser.usecase.ts rename backend-api/tests/integration/{ => api/health}/health.integration.test.ts (100%) diff --git a/backend-api/app/console/ExampleCommand.ts b/backend-api/app/console/ExampleCommand.ts index 4ac4b09..1f3468d 100644 --- a/backend-api/app/console/ExampleCommand.ts +++ b/backend-api/app/console/ExampleCommand.ts @@ -41,4 +41,4 @@ export class ExampleCommand extends Command { this.info(''); this.success('Command executed successfully!'); } -} \ No newline at end of file +} diff --git a/backend-api/app/providers/AppServiceProvider.ts b/backend-api/app/providers/AppServiceProvider.ts index 4e1d934..56e18a9 100644 --- a/backend-api/app/providers/AppServiceProvider.ts +++ b/backend-api/app/providers/AppServiceProvider.ts @@ -15,10 +15,24 @@ import type { ConsoleKernel } from '$/@frouvel/kaname/foundation'; import { ExampleCommand } from '$/app/console/ExampleCommand'; import { GenerateOpenApiCommand } from '$/app/console/GenerateOpenApiCommand'; +// Import repositories +import type { IUserRepository } from '$/domain/user/repository/User.repository.interface'; +import { UserRepository } from '$/domain/user/repository/prisma/User.repository'; + +// Import use cases +import { PaginateUserUsecase } from '$/domain/user/usecase/PaginateUser.usecase'; + export class AppServiceProvider implements ServiceProvider { - // eslint-disable-next-line @typescript-eslint/no-unused-vars register(app: Application): void { - // Register any application services here + // Bind IUserRepository to Prisma implementation + app.bind('IUserRepository', () => new UserRepository()); + + // Bind UseCases with dependency injection + app.bind('PaginateUserUsecase', () => { + const userRepository = app.make('IUserRepository'); + return new PaginateUserUsecase(userRepository); + }); + console.log('[AppServiceProvider] Application services registered'); } diff --git a/backend-api/domain/user/repository/User.repository.interface.ts b/backend-api/domain/user/repository/User.repository.interface.ts index f93f83f..a1b2757 100644 --- a/backend-api/domain/user/repository/User.repository.interface.ts +++ b/backend-api/domain/user/repository/User.repository.interface.ts @@ -1,4 +1,29 @@ +import type { LengthAwarePaginator } from '$/@frouvel/kaname/paginator'; + export interface IUserRepository { + paginate(args: { + page: number; + perPage: number; + search?: string; + }): Promise<{ + data: { + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }[]; + meta: LengthAwarePaginator<{ + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }>; + }>; + findById(args: { id: number }): Promise<{ id: number; name: string; diff --git a/backend-api/domain/user/repository/prisma/User.repository.ts b/backend-api/domain/user/repository/prisma/User.repository.ts index 820a4b7..16eb263 100644 --- a/backend-api/domain/user/repository/prisma/User.repository.ts +++ b/backend-api/domain/user/repository/prisma/User.repository.ts @@ -1,5 +1,6 @@ import type { IUserRepository } from '../User.repository.interface'; import { DB } from '$/@frouvel/kaname/database'; +import { LengthAwarePaginator } from '$/@frouvel/kaname/paginator'; /** * Prisma User Repository @@ -13,6 +14,37 @@ import { DB } from '$/@frouvel/kaname/database'; export class UserRepository implements IUserRepository { private _prisma = DB.prisma(); + async paginate(args: { page: number; perPage: number; search?: string }) { + const where = args.search + ? { + OR: [ + { name: { contains: args.search } }, + { email: { contains: args.search } }, + ], + } + : {}; + + const [data, total] = await Promise.all([ + this._prisma.user.findMany({ + where, + take: args.perPage, + skip: args.perPage * (args.page - 1), + orderBy: { createdAt: 'desc' }, + }), + this._prisma.user.count({ where }), + ]); + + return { + data, + meta: LengthAwarePaginator.create({ + data, + total, + perPage: args.perPage, + currentPage: args.page, + }), + }; + } + async findById(args: { id: number }) { return this._prisma.user.findUnique({ where: { id: args.id }, diff --git a/backend-api/domain/user/usecase/PaginateUser.usecase.ts b/backend-api/domain/user/usecase/PaginateUser.usecase.ts new file mode 100644 index 0000000..08e350a --- /dev/null +++ b/backend-api/domain/user/usecase/PaginateUser.usecase.ts @@ -0,0 +1,23 @@ +import type { IUserRepository } from '../repository/User.repository.interface'; + +export class PaginateUserUsecase { + constructor(private readonly _userRepository: IUserRepository) {} + + async execute(args: { page: number; perPage: number; search?: string }) { + const { data: users, meta } = await this._userRepository.paginate({ + page: args.page, + perPage: args.perPage, + search: args.search, + }); + + return { + users: users.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + createdAt: u.createdAt.toISOString(), + })), + meta, + }; + } +} diff --git a/backend-api/tests/integration/health.integration.test.ts b/backend-api/tests/integration/api/health/health.integration.test.ts similarity index 100% rename from backend-api/tests/integration/health.integration.test.ts rename to backend-api/tests/integration/api/health/health.integration.test.ts From d2d613d5403e9da3cd79ed60bd0510b2bf308a39 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Tue, 18 Nov 2025 16:35:28 +0900 Subject: [PATCH 07/28] feat(user): implement UsersController with pagination and user creation functionality --- .../api/users/_id@string/controller.ts | 99 ++++++++++++++----- backend-api/api/users/controller.ts | 64 ++++++++++++ 2 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 backend-api/api/users/controller.ts diff --git a/backend-api/api/users/_id@string/controller.ts b/backend-api/api/users/_id@string/controller.ts index 844d444..a41af39 100644 --- a/backend-api/api/users/_id@string/controller.ts +++ b/backend-api/api/users/_id@string/controller.ts @@ -1,30 +1,77 @@ -import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +/** + * User Detail Controller Example + * + * Demonstrates single resource operations with DB facade + */ + import { defineController } from './$relay'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { DB } from '$/@frouvel/kaname/database'; export default defineController(() => ({ - get: ({ params }) => - // TODO: Implement FindUserUseCase - ApiResponse.success({ - id: parseInt(params.id), - name: 'John Doe', - email: 'john@example.com', - age: 25, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }), - - patch: ({ params, body }) => - // TODO: Implement UpdateUserUseCase - ApiResponse.success({ - id: parseInt(params.id), - name: body.name || 'John Doe', - email: body.email || 'john@example.com', - age: body.age || 25, - updatedAt: new Date().toISOString(), - }), - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - delete: ({ params }) => - // TODO: Implement DeleteUserUseCase - ApiResponse.success({ success: true as const }), + /** + * GET /users/:id + * Example: Finding a single resource + */ + get: ({ params }) => { + const prisma = DB.prisma(); + const userId = parseInt(params.id, 10); + + return prisma.user + .findUnique({ where: { id: userId } }) + .then((user) => { + if (!user) { + return ApiResponse.notFound('User not found', { userId }); + } + + return ApiResponse.success({ + id: user.id, + name: user.name, + email: user.email, + age: user.age, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }); + }) + .catch(ApiResponse.method.get); + }, + + /** + * PATCH /users/:id + * Example: Updating a resource + */ + patch: ({ params, body }) => { + const prisma = DB.prisma(); + const userId = parseInt(params.id, 10); + + return prisma.user + .update({ + where: { id: userId }, + data: body, + }) + .then((user) => + ApiResponse.success({ + id: user.id, + name: user.name, + email: user.email, + age: user.age, + updatedAt: user.updatedAt.toISOString(), + }), + ) + .catch(ApiResponse.method.patch); + }, + + /** + * DELETE /users/:id + * Example: Deleting a resource + */ + delete: ({ params }) => { + const prisma = DB.prisma(); + const userId = parseInt(params.id, 10); + + return prisma.user + .delete({ where: { id: userId } }) + .then(() => ApiResponse.success({ success: true as const })) + .catch(ApiResponse.method.delete); + }, })); diff --git a/backend-api/api/users/controller.ts b/backend-api/api/users/controller.ts new file mode 100644 index 0000000..539b560 --- /dev/null +++ b/backend-api/api/users/controller.ts @@ -0,0 +1,64 @@ +/** + * Users Controller + * + * Demonstrates proper dependency injection with Application container + */ + +import { defineController } from './$relay'; +import { ApiResponse } from '$/@frouvel/kaname/http/ApiResponse'; +import { DB } from '$/@frouvel/kaname/database'; +import app from '$/bootstrap/app'; +import type { PaginateUserUsecase } from '$/domain/user/usecase/PaginateUser.usecase'; + +export default defineController(() => ({ + /** + * GET /users + * Uses PaginateUserUsecase resolved from the Application container + */ + get: ({ query }) => { + const paginateUserUsecase = app.make( + 'PaginateUserUsecase', + ); + + return paginateUserUsecase + .execute({ + page: query.page, + perPage: query.limit, + search: query.search, + }) + .then((result) => + ApiResponse.success({ + data: result.users, + meta: result.meta.toResponse().meta, + }), + ) + .catch(ApiResponse.method.get); + }, + + /** + * POST /users + * Example: Direct Prisma usage with validation + */ + post: ({ body }) => { + const prisma = DB.prisma(); + + return prisma.user + .create({ + data: { + name: body.name, + email: body.email, + age: body.age, + }, + }) + .then((user) => + ApiResponse.success({ + id: user.id, + name: user.name, + email: user.email, + age: user.age, + createdAt: user.createdAt.toISOString(), + }), + ) + .catch(ApiResponse.method.post); + }, +})); From 0536522a9bad99fd1e1eef77463f0115da8ea4e5 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Tue, 18 Nov 2025 18:01:20 +0900 Subject: [PATCH 08/28] refactor(factory): improve code readability and formatting in Factory.ts --- .../@frouvel/kaname/testing/Factory.ts | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/backend-api/@frouvel/kaname/testing/Factory.ts b/backend-api/@frouvel/kaname/testing/Factory.ts index c35a50a..f57dbe2 100644 --- a/backend-api/@frouvel/kaname/testing/Factory.ts +++ b/backend-api/@frouvel/kaname/testing/Factory.ts @@ -88,6 +88,7 @@ export abstract class Factory { * Create and persist model(s) with the given attributes * Subclasses should override this to implement persistence logic */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public async create(overrides?: Partial): Promise { throw new Error( 'create() must be implemented in subclass with Prisma persistence', @@ -112,9 +113,7 @@ export abstract class Factory { /** * Simple factory function creator for quick test data generation */ -export function defineFactory( - definition: () => T, -): FactoryFunction { +export function defineFactory(definition: () => T): FactoryFunction { return (overrides?: Partial) => { return { ...definition(), @@ -152,7 +151,8 @@ export const fake = { * Generate a random string */ string: (length = 10): string => { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); @@ -196,7 +196,9 @@ export const fake = { * Generate a random date */ date: (start?: Date, end?: Date): Date => { - const startTime = start ? start.getTime() : Date.now() - 365 * 24 * 60 * 60 * 1000; + const startTime = start + ? start.getTime() + : Date.now() - 365 * 24 * 60 * 60 * 1000; const endTime = end ? end.getTime() : Date.now(); return new Date(startTime + Math.random() * (endTime - startTime)); }, @@ -212,7 +214,16 @@ export const fake = { * Generate a random name */ name: (): string => { - const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank', 'Grace', 'Henry']; + const names = [ + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', + 'Frank', + 'Grace', + 'Henry', + ]; return fake.pick(names); }, @@ -221,8 +232,26 @@ export const fake = { */ text: (words = 10): string => { const wordList = [ - 'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'I', - 'it', 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', + 'the', + 'be', + 'to', + 'of', + 'and', + 'a', + 'in', + 'that', + 'have', + 'I', + 'it', + 'for', + 'not', + 'on', + 'with', + 'he', + 'as', + 'you', + 'do', + 'at', ]; const result: string[] = []; for (let i = 0; i < words; i++) { @@ -230,4 +259,4 @@ export const fake = { } return result.join(' '); }, -}; \ No newline at end of file +}; From b03915c798d6c3940c6e7880a5698485e9ff5566 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 00:12:17 +0900 Subject: [PATCH 09/28] feat(user): implement pagination and search functionality in UserRepositoryDrizzle --- .../repository/drizzle/User.repository.ts | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/backend-api/domain/user/repository/drizzle/User.repository.ts b/backend-api/domain/user/repository/drizzle/User.repository.ts index e51c547..e3972f2 100644 --- a/backend-api/domain/user/repository/drizzle/User.repository.ts +++ b/backend-api/domain/user/repository/drizzle/User.repository.ts @@ -1,7 +1,8 @@ import type { IUserRepository } from '../User.repository.interface'; import { DB } from '$/@frouvel/kaname/database'; -import { users } from '$/database/drizzle/schema'; -import { eq } from 'drizzle-orm'; +import { users, type User } from '$/database/drizzle/schema'; +import { eq, or, like, count, desc } from 'drizzle-orm'; +import { LengthAwarePaginator } from '$/@frouvel/kaname/paginator'; /** * Drizzle User Repository @@ -12,6 +13,70 @@ import { eq } from 'drizzle-orm'; export class UserRepositoryDrizzle implements IUserRepository { private db = DB.drizzle(); + async paginate(args: { page: number; perPage: number; search?: string }): Promise<{ + data: { + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }[]; + meta: LengthAwarePaginator<{ + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }>; + }> { + const where = args.search + ? or( + like(users.name, `%${args.search}%`), + like(users.email, `%${args.search}%`), + ) + : undefined; + + const [data, [{ total }]] = await Promise.all([ + this.db + .select() + .from(users) + .where(where) + .limit(args.perPage) + .offset(args.perPage * (args.page - 1)) + .orderBy(desc(users.createdAt)), + this.db + .select({ total: count() }) + .from(users) + .where(where), + ]); + + return { + data: data as { + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }[], + meta: LengthAwarePaginator.create({ + data: data as { + id: number; + name: string; + email: string; + age: number; + createdAt: Date; + updatedAt: Date; + }[], + total: total, + perPage: args.perPage, + currentPage: args.page, + }), + }; + } + async findById(args: { id: number }) { const [user] = await this.db .select() From b72ad909c5cfdd9b139e3cf568e5ff875f7ea15f Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 00:36:04 +0900 Subject: [PATCH 10/28] feat(tests): add comprehensive tests for UserRepositoryDrizzle including create, find, update, delete, and pagination functionalities --- .../repository/drizzle/User.repository.ts | 22 +- .../drizzle/User.repository.test.ts | 446 ++++++++++++++++++ 2 files changed, 459 insertions(+), 9 deletions(-) create mode 100644 backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts diff --git a/backend-api/domain/user/repository/drizzle/User.repository.ts b/backend-api/domain/user/repository/drizzle/User.repository.ts index e3972f2..3f02105 100644 --- a/backend-api/domain/user/repository/drizzle/User.repository.ts +++ b/backend-api/domain/user/repository/drizzle/User.repository.ts @@ -1,19 +1,23 @@ import type { IUserRepository } from '../User.repository.interface'; import { DB } from '$/@frouvel/kaname/database'; -import { users, type User } from '$/database/drizzle/schema'; +import { users } from '$/database/drizzle/schema'; import { eq, or, like, count, desc } from 'drizzle-orm'; import { LengthAwarePaginator } from '$/@frouvel/kaname/paginator'; /** * Drizzle User Repository - * + * * Uses DB facade for database access (same pattern as Prisma repository). * Demonstrates ORM-agnostic repository interface. */ export class UserRepositoryDrizzle implements IUserRepository { private db = DB.drizzle(); - async paginate(args: { page: number; perPage: number; search?: string }): Promise<{ + async paginate(args: { + page: number; + perPage: number; + search?: string; + }): Promise<{ data: { id: number; name: string; @@ -46,10 +50,7 @@ export class UserRepositoryDrizzle implements IUserRepository { .limit(args.perPage) .offset(args.perPage * (args.page - 1)) .orderBy(desc(users.createdAt)), - this.db - .select({ total: count() }) - .from(users) - .where(where), + this.db.select({ total: count() }).from(users).where(where), ]); return { @@ -70,7 +71,7 @@ export class UserRepositoryDrizzle implements IUserRepository { createdAt: Date; updatedAt: Date; }[], - total: total, + total, perPage: args.perPage, currentPage: args.page, }), @@ -88,12 +89,15 @@ export class UserRepositoryDrizzle implements IUserRepository { } async create(data: { name: string; email: string; age: number }) { + const now = new Date(); const [user] = await this.db .insert(users) .values({ name: data.name, email: data.email, age: data.age, + createdAt: now, + updatedAt: now, }) .returning(); @@ -123,4 +127,4 @@ export class UserRepositoryDrizzle implements IUserRepository { async deleteById(args: { id: number }) { await this.db.delete(users).where(eq(users.id, args.id)); } -} \ No newline at end of file +} diff --git a/backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts b/backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts new file mode 100644 index 0000000..4fbba8f --- /dev/null +++ b/backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts @@ -0,0 +1,446 @@ +/* eslint-disable max-lines */ +import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { UserRepositoryDrizzle } from '$/domain/user/repository/drizzle/User.repository'; +import { DB } from '$/@frouvel/kaname/database'; +import { users } from '$/database/drizzle/schema'; +import { getDrizzleClient } from '$/@frouvel/kaname/database'; +import { expect } from 'vitest'; + +class UserRepositoryDrizzleTest extends DatabaseTestCase { + private repository!: UserRepositoryDrizzle; + private db!: ReturnType; + + protected async setUpBeforeClass(): Promise { + await super.setUpBeforeClass(); + + // Initialize Drizzle client and register with DB facade + this.db = getDrizzleClient(); + DB.register('default', this.db, 'drizzle'); + } + + protected async setUp(): Promise { + await super.setUp(); + + this.repository = new UserRepositoryDrizzle(); + + // Clean up users table before each test + await this.db.delete(users); + } + + protected async tearDownAfterClass(): Promise { + await this.db.delete(users); + await DB.disconnectAll(); + await super.tearDownAfterClass(); + } + + run() { + this.suite('UserRepositoryDrizzle', () => { + this.suite('create', () => { + this.test('should create a new user successfully', async () => { + const userData = { + name: 'John Doe', + email: 'john@example.com', + age: 25, + }; + + const user = await this.repository.create(userData); + + expect(user).toBeDefined(); + expect(user.id).toBeDefined(); + expect(user.name).toBe(userData.name); + expect(user.email).toBe(userData.email); + expect(user.age).toBe(userData.age); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + }); + + this.test('should create multiple users with unique ids', async () => { + const user1 = await this.repository.create({ + name: 'User One', + email: 'user1@example.com', + age: 20, + }); + + const user2 = await this.repository.create({ + name: 'User Two', + email: 'user2@example.com', + age: 30, + }); + + expect(user1.id).not.toBe(user2.id); + }); + }); + + this.suite('findById', () => { + this.test('should find an existing user by id', async () => { + const created = await this.repository.create({ + name: 'Jane Doe', + email: 'jane@example.com', + age: 28, + }); + + const found = await this.repository.findById({ id: created.id }); + + expect(found).toBeDefined(); + expect(found?.id).toBe(created.id); + expect(found?.name).toBe('Jane Doe'); + expect(found?.email).toBe('jane@example.com'); + expect(found?.age).toBe(28); + }); + + this.test('should return null for non-existent user', async () => { + const found = await this.repository.findById({ id: 99999 }); + expect(found).toBeNull(); + }); + }); + + this.suite('update', () => { + this.test('should update user name', async () => { + const created = await this.repository.create({ + name: 'Old Name', + email: 'user@example.com', + age: 25, + }); + + const updated = await this.repository.update({ + id: created.id, + data: { name: 'New Name' }, + }); + + expect(updated.id).toBe(created.id); + expect(updated.name).toBe('New Name'); + expect(updated.email).toBe('user@example.com'); + expect(updated.age).toBe(25); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual( + created.updatedAt.getTime(), + ); + }); + + this.test('should update user email', async () => { + const created = await this.repository.create({ + name: 'User', + email: 'old@example.com', + age: 25, + }); + + const updated = await this.repository.update({ + id: created.id, + data: { email: 'new@example.com' }, + }); + + expect(updated.email).toBe('new@example.com'); + }); + + this.test('should update user age', async () => { + const created = await this.repository.create({ + name: 'User', + email: 'user@example.com', + age: 25, + }); + + const updated = await this.repository.update({ + id: created.id, + data: { age: 30 }, + }); + + expect(updated.age).toBe(30); + }); + + this.test('should update multiple fields at once', async () => { + const created = await this.repository.create({ + name: 'Old Name', + email: 'old@example.com', + age: 25, + }); + + const updated = await this.repository.update({ + id: created.id, + data: { + name: 'New Name', + email: 'new@example.com', + age: 30, + }, + }); + + expect(updated.name).toBe('New Name'); + expect(updated.email).toBe('new@example.com'); + expect(updated.age).toBe(30); + }); + }); + + this.suite('deleteById', () => { + this.test('should delete an existing user', async () => { + const created = await this.repository.create({ + name: 'To Delete', + email: 'delete@example.com', + age: 25, + }); + + await this.repository.deleteById({ id: created.id }); + + const found = await this.repository.findById({ id: created.id }); + expect(found).toBeNull(); + }); + + this.test( + 'should not throw error when deleting non-existent user', + async () => { + await expect( + this.repository.deleteById({ id: 99999 }), + ).resolves.toBeUndefined(); + }, + ); + }); + + this.suite('paginate', () => { + this.test('should return paginated results', async () => { + // Create test users + await Promise.all([ + this.repository.create({ + name: 'Alice Smith', + email: 'alice@example.com', + age: 25, + }), + this.repository.create({ + name: 'Bob Johnson', + email: 'bob@example.com', + age: 30, + }), + this.repository.create({ + name: 'Charlie Brown', + email: 'charlie@example.com', + age: 35, + }), + this.repository.create({ + name: 'Diana Prince', + email: 'diana@example.com', + age: 28, + }), + this.repository.create({ + name: 'Eve Wilson', + email: 'eve@example.com', + age: 32, + }), + ]); + + const result = await this.repository.paginate({ + page: 1, + perPage: 2, + }); + + expect(result.data).toHaveLength(2); + expect(result.meta).toBeDefined(); + expect(result.meta.currentPage).toBe(1); + expect(result.meta.perPage).toBe(2); + expect(result.meta.total).toBe(5); + expect(result.meta.lastPage).toBe(3); + }); + + this.test('should return second page correctly', async () => { + await Promise.all([ + this.repository.create({ + name: 'User 1', + email: 'user1@example.com', + age: 20, + }), + this.repository.create({ + name: 'User 2', + email: 'user2@example.com', + age: 21, + }), + this.repository.create({ + name: 'User 3', + email: 'user3@example.com', + age: 22, + }), + this.repository.create({ + name: 'User 4', + email: 'user4@example.com', + age: 23, + }), + this.repository.create({ + name: 'User 5', + email: 'user5@example.com', + age: 24, + }), + ]); + + const result = await this.repository.paginate({ + page: 2, + perPage: 2, + }); + + expect(result.data).toHaveLength(2); + expect(result.meta.currentPage).toBe(2); + expect(result.meta.from).toBe(3); + expect(result.meta.to).toBe(4); + }); + + this.test('should search by name', async () => { + await Promise.all([ + this.repository.create({ + name: 'Alice Smith', + email: 'alice@example.com', + age: 25, + }), + this.repository.create({ + name: 'Bob Johnson', + email: 'bob@example.com', + age: 30, + }), + ]); + + const result = await this.repository.paginate({ + page: 1, + perPage: 10, + search: 'Alice', + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].name).toBe('Alice Smith'); + expect(result.meta.total).toBe(1); + }); + + this.test('should search by email', async () => { + await Promise.all([ + this.repository.create({ + name: 'Alice', + email: 'alice@example.com', + age: 25, + }), + this.repository.create({ + name: 'Bob', + email: 'bob@test.com', + age: 30, + }), + ]); + + const result = await this.repository.paginate({ + page: 1, + perPage: 10, + search: 'bob@', + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].email).toBe('bob@test.com'); + }); + + this.test('should return empty results for no matches', async () => { + await this.repository.create({ + name: 'User', + email: 'user@example.com', + age: 25, + }); + + const result = await this.repository.paginate({ + page: 1, + perPage: 10, + search: 'nonexistent', + }); + + expect(result.data).toHaveLength(0); + expect(result.meta.total).toBe(0); + }); + + this.test('should order results by createdAt desc', async () => { + await this.repository.create({ + name: 'First', + email: 'first@example.com', + age: 25, + }); + await this.repository.create({ + name: 'Second', + email: 'second@example.com', + age: 26, + }); + await this.repository.create({ + name: 'Third', + email: 'third@example.com', + age: 27, + }); + + const result = await this.repository.paginate({ + page: 1, + perPage: 5, + }); + + // Verify results are ordered by createdAt descending + for (let i = 0; i < result.data.length - 1; i++) { + expect(result.data[i].createdAt.getTime()).toBeGreaterThanOrEqual( + result.data[i + 1].createdAt.getTime(), + ); + } + }); + + this.test( + 'should return correct pagination metadata for empty result', + async () => { + const result = await this.repository.paginate({ + page: 1, + perPage: 10, + }); + + expect(result.data).toHaveLength(0); + expect(result.meta.total).toBe(0); + expect(result.meta.currentPage).toBe(1); + expect(result.meta.lastPage).toBe(0); // 0 pages when no results (Math.ceil(0/10) = 0) + expect(result.meta.from).toBeNull(); + expect(result.meta.to).toBeNull(); + }, + ); + }); + + this.suite('data integrity', () => { + this.test('should maintain data types correctly', async () => { + const created = await this.repository.create({ + name: 'Type Test', + email: 'type@example.com', + age: 25, + }); + + const found = await this.repository.findById({ id: created.id }); + + expect(typeof found?.id).toBe('number'); + expect(typeof found?.name).toBe('string'); + expect(typeof found?.email).toBe('string'); + expect(typeof found?.age).toBe('number'); + expect(found?.createdAt).toBeInstanceOf(Date); + expect(found?.updatedAt).toBeInstanceOf(Date); + }); + + this.test('should handle paginate return types correctly', async () => { + await this.repository.create({ + name: 'Pagination Test', + email: 'pagination@example.com', + age: 25, + }); + + const result = await this.repository.paginate({ + page: 1, + perPage: 10, + }); + + // Verify data structure + expect(Array.isArray(result.data)).toBe(true); + expect(result.meta).toBeDefined(); + expect(typeof result.meta.total).toBe('number'); + expect(typeof result.meta.perPage).toBe('number'); + expect(typeof result.meta.currentPage).toBe('number'); + expect(typeof result.meta.lastPage).toBe('number'); + + // Verify each item has correct types + result.data.forEach((user) => { + expect(typeof user.id).toBe('number'); + expect(typeof user.name).toBe('string'); + expect(typeof user.email).toBe('string'); + expect(typeof user.age).toBe('number'); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + }); + }); + }); + }); + } +} + +new UserRepositoryDrizzleTest().run(); From 7d47f54413bb32daf2820aed8d5a240bc82ba4b4 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 00:46:30 +0900 Subject: [PATCH 11/28] feat(tests): add UserRepositoryDrizzle tests for create, find, update, delete, and pagination functionalities --- .../@frouvel/kaname/config/config.test.ts | 195 ++++++++++++++---- .../drizzle/User.repository.test.ts | 0 2 files changed, 159 insertions(+), 36 deletions(-) rename backend-api/{tests => }/domain/user/repository/drizzle/User.repository.test.ts (100%) diff --git a/backend-api/@frouvel/kaname/config/config.test.ts b/backend-api/@frouvel/kaname/config/config.test.ts index 033c91d..87994a7 100644 --- a/backend-api/@frouvel/kaname/config/config.test.ts +++ b/backend-api/@frouvel/kaname/config/config.test.ts @@ -1,45 +1,50 @@ /** * Configuration Helper Tests + * + * Framework-level tests for configuration utilities */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { Application } from '$/@frouvel/kaname/foundation'; -// Mock the app module before importing config helpers +// Mock the bootstrap/app module before importing config helpers +// Vitest hoists vi.mock() calls automatically vi.mock('$/bootstrap/app', () => { - const mockApp = { - make: vi.fn((key: string) => { - if (key === 'config') { - return { - app: { - name: 'Test App', - env: 'test', - debug: true, - nested: { - value: 'deep value', - }, - }, - database: { - connections: { - postgresql: { - url: 'postgresql://test', - }, - }, - }, - }; - } - throw new Error(`Service [${key}] not found in container`); - }), + const testApp = new Application('/tmp/test-config'); + + const testConfig = { + app: { + name: 'Test App', + env: 'test' as const, + debug: true, + nested: { + value: 'deep value', + }, + }, + database: { + connections: { + postgresql: { + url: 'postgresql://test', + }, + }, + }, + custom: { + feature: { + enabled: true, + limit: 100, + }, + }, }; - return { default: mockApp }; + + testApp.singleton('config', () => testConfig); + + return { default: testApp }; }); +// Import config helpers after mock is set up (hoisting handles order) import { config, hasConfig, configAll } from './config'; describe('Configuration Helper', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - describe('config()', () => { it('should get top-level config value', () => { const appName = config('app.name'); @@ -66,22 +71,55 @@ describe('Configuration Helper', () => { expect(value).toBeUndefined(); }); - it('should handle type parameter', () => { + it('should handle type parameter correctly', () => { const debug = config('app.debug'); expect(typeof debug).toBe('boolean'); expect(debug).toBe(true); }); + + it('should handle number types', () => { + const limit = config('custom.feature.limit'); + expect(typeof limit).toBe('number'); + expect(limit).toBe(100); + }); + + it('should handle object types', () => { + const feature = config<{ enabled: boolean; limit: number }>( + 'custom.feature', + ); + expect(feature).toEqual({ + enabled: true, + limit: 100, + }); + }); }); describe('hasConfig()', () => { - it('should return true for existing config', () => { + it('should return true for existing top-level config', () => { + expect(hasConfig('app')).toBe(true); + expect(hasConfig('database')).toBe(true); + expect(hasConfig('custom')).toBe(true); + }); + + it('should return true for existing nested config', () => { expect(hasConfig('app.name')).toBe(true); expect(hasConfig('database.connections.postgresql.url')).toBe(true); + expect(hasConfig('custom.feature.enabled')).toBe(true); }); - it('should return false for non-existing config', () => { - expect(hasConfig('non.existent')).toBe(false); - expect(hasConfig('app.non.existent')).toBe(false); + it('should return false for non-existing top-level config', () => { + expect(hasConfig('nonexistent')).toBe(false); + }); + + it('should return false for non-existing nested config', () => { + expect(hasConfig('app.nonexistent')).toBe(false); + expect(hasConfig('database.connections.mysql')).toBe(false); + expect(hasConfig('custom.feature.nonexistent')).toBe(false); + }); + + it('should return false for partially invalid paths', () => { + expect(hasConfig('app.name.invalid')).toBe(false); + expect(hasConfig('database.invalid.postgresql.url')).toBe(false); }); }); @@ -98,9 +136,94 @@ describe('Configuration Helper', () => { }); }); + it('should get all config for database file', () => { + const dbConfig = configAll('database'); + expect(dbConfig).toEqual({ + connections: { + postgresql: { + url: 'postgresql://test', + }, + }, + }); + }); + + it('should get all config for custom file', () => { + const customConfig = configAll('custom'); + expect(customConfig).toEqual({ + feature: { + enabled: true, + limit: 100, + }, + }); + }); + it('should return undefined for non-existent file', () => { - const config = configAll('nonexistent'); + const config = configAll('nonexistent' as any); expect(config).toBeUndefined(); }); + + it('should preserve nested object structure', () => { + const appConfig = configAll('app') as any; + expect(appConfig?.nested).toEqual({ + value: 'deep value', + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty string config key', () => { + const value = config('', 'default'); + expect(value).toBe('default'); + }); + + it('should handle config key with trailing dots', () => { + const value = config('app.name.', 'default'); + expect(value).toBe('default'); + }); + + it('should handle config key with leading dots', () => { + const value = config('.app.name', 'default'); + expect(value).toBe('default'); + }); + + it('should handle null as default value', () => { + const value = config('non.existent', null); + expect(value).toBeNull(); + }); + + it('should handle boolean false as default value', () => { + const value = config('non.existent', false); + expect(value).toBe(false); + }); + + it('should handle zero as default value', () => { + const value = config('non.existent', 0); + expect(value).toBe(0); + }); + }); + + describe('Type Safety', () => { + it('should support string type parameter', () => { + const name = config('app.name'); + expect(typeof name).toBe('string'); + }); + + it('should support boolean type parameter', () => { + const debug = config('app.debug'); + expect(typeof debug).toBe('boolean'); + }); + + it('should support number type parameter', () => { + const limit = config('custom.feature.limit'); + expect(typeof limit).toBe('number'); + }); + + it('should support complex object type parameter', () => { + const feature = config<{ enabled: boolean; limit: number }>( + 'custom.feature', + ); + expect(feature).toHaveProperty('enabled'); + expect(feature).toHaveProperty('limit'); + }); }); }); diff --git a/backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts b/backend-api/domain/user/repository/drizzle/User.repository.test.ts similarity index 100% rename from backend-api/tests/domain/user/repository/drizzle/User.repository.test.ts rename to backend-api/domain/user/repository/drizzle/User.repository.test.ts From e23431c86325ea62ce467d20ae4eb15050852e2f Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 00:51:33 +0900 Subject: [PATCH 12/28] refactor(tests): enhance Configuration Helper tests for clarity and structure --- .../@frouvel/kaname/config/config.test.ts | 135 ++++++------------ 1 file changed, 43 insertions(+), 92 deletions(-) diff --git a/backend-api/@frouvel/kaname/config/config.test.ts b/backend-api/@frouvel/kaname/config/config.test.ts index 87994a7..744153c 100644 --- a/backend-api/@frouvel/kaname/config/config.test.ts +++ b/backend-api/@frouvel/kaname/config/config.test.ts @@ -1,50 +1,51 @@ /** * Configuration Helper Tests - * - * Framework-level tests for configuration utilities */ -import { describe, it, expect, vi } from 'vitest'; -import { Application } from '$/@frouvel/kaname/foundation'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; -// Mock the bootstrap/app module before importing config helpers -// Vitest hoists vi.mock() calls automatically +// Mock the app module before importing config helpers vi.mock('$/bootstrap/app', () => { - const testApp = new Application('/tmp/test-config'); - - const testConfig = { - app: { - name: 'Test App', - env: 'test' as const, - debug: true, - nested: { - value: 'deep value', - }, - }, - database: { - connections: { - postgresql: { - url: 'postgresql://test', - }, - }, - }, - custom: { - feature: { - enabled: true, - limit: 100, - }, - }, + const mockApp = { + make: vi.fn((key: string) => { + if (key === 'config') { + return { + app: { + name: 'Test App', + env: 'test', + debug: true, + nested: { + value: 'deep value', + }, + }, + database: { + connections: { + postgresql: { + url: 'postgresql://test', + }, + }, + }, + custom: { + feature: { + enabled: true, + limit: 100, + }, + }, + }; + } + throw new Error(`Service [${key}] not found in container`); + }), }; - - testApp.singleton('config', () => testConfig); - - return { default: testApp }; + return { default: mockApp }; }); -// Import config helpers after mock is set up (hoisting handles order) import { config, hasConfig, configAll } from './config'; describe('Configuration Helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('config()', () => { it('should get top-level config value', () => { const appName = config('app.name'); @@ -71,7 +72,7 @@ describe('Configuration Helper', () => { expect(value).toBeUndefined(); }); - it('should handle type parameter correctly', () => { + it('should handle type parameter', () => { const debug = config('app.debug'); expect(typeof debug).toBe('boolean'); expect(debug).toBe(true); @@ -84,9 +85,7 @@ describe('Configuration Helper', () => { }); it('should handle object types', () => { - const feature = config<{ enabled: boolean; limit: number }>( - 'custom.feature', - ); + const feature = config<{ enabled: boolean; limit: number }>('custom.feature'); expect(feature).toEqual({ enabled: true, limit: 100, @@ -95,31 +94,15 @@ describe('Configuration Helper', () => { }); describe('hasConfig()', () => { - it('should return true for existing top-level config', () => { - expect(hasConfig('app')).toBe(true); - expect(hasConfig('database')).toBe(true); - expect(hasConfig('custom')).toBe(true); - }); - - it('should return true for existing nested config', () => { + it('should return true for existing config', () => { expect(hasConfig('app.name')).toBe(true); expect(hasConfig('database.connections.postgresql.url')).toBe(true); expect(hasConfig('custom.feature.enabled')).toBe(true); }); - it('should return false for non-existing top-level config', () => { - expect(hasConfig('nonexistent')).toBe(false); - }); - - it('should return false for non-existing nested config', () => { - expect(hasConfig('app.nonexistent')).toBe(false); - expect(hasConfig('database.connections.mysql')).toBe(false); - expect(hasConfig('custom.feature.nonexistent')).toBe(false); - }); - - it('should return false for partially invalid paths', () => { - expect(hasConfig('app.name.invalid')).toBe(false); - expect(hasConfig('database.invalid.postgresql.url')).toBe(false); + it('should return false for non-existing config', () => { + expect(hasConfig('non.existent')).toBe(false); + expect(hasConfig('app.non.existent')).toBe(false); }); }); @@ -161,13 +144,6 @@ describe('Configuration Helper', () => { const config = configAll('nonexistent' as any); expect(config).toBeUndefined(); }); - - it('should preserve nested object structure', () => { - const appConfig = configAll('app') as any; - expect(appConfig?.nested).toEqual({ - value: 'deep value', - }); - }); }); describe('Edge Cases', () => { @@ -201,29 +177,4 @@ describe('Configuration Helper', () => { expect(value).toBe(0); }); }); - - describe('Type Safety', () => { - it('should support string type parameter', () => { - const name = config('app.name'); - expect(typeof name).toBe('string'); - }); - - it('should support boolean type parameter', () => { - const debug = config('app.debug'); - expect(typeof debug).toBe('boolean'); - }); - - it('should support number type parameter', () => { - const limit = config('custom.feature.limit'); - expect(typeof limit).toBe('number'); - }); - - it('should support complex object type parameter', () => { - const feature = config<{ enabled: boolean; limit: number }>( - 'custom.feature', - ); - expect(feature).toHaveProperty('enabled'); - expect(feature).toHaveProperty('limit'); - }); - }); -}); +}); \ No newline at end of file From 06047d80df3f35a102845edf9930a0a1b4295c37 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 00:55:58 +0900 Subject: [PATCH 13/28] refactor(tests): streamline Configuration Helper tests by mocking app module and improving setup --- .../@frouvel/kaname/config/config.test.ts | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/backend-api/@frouvel/kaname/config/config.test.ts b/backend-api/@frouvel/kaname/config/config.test.ts index 744153c..19a4126 100644 --- a/backend-api/@frouvel/kaname/config/config.test.ts +++ b/backend-api/@frouvel/kaname/config/config.test.ts @@ -2,48 +2,42 @@ * Configuration Helper Tests */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -// Mock the app module before importing config helpers -vi.mock('$/bootstrap/app', () => { - const mockApp = { - make: vi.fn((key: string) => { - if (key === 'config') { - return { - app: { - name: 'Test App', - env: 'test', - debug: true, - nested: { - value: 'deep value', - }, - }, - database: { - connections: { - postgresql: { - url: 'postgresql://test', - }, - }, - }, - custom: { - feature: { - enabled: true, - limit: 100, - }, - }, - }; - } - throw new Error(`Service [${key}] not found in container`); - }), - }; - return { default: mockApp }; -}); - +import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; +import app from '$/bootstrap/app'; import { config, hasConfig, configAll } from './config'; +const mockConfig = { + app: { + name: 'Test App', + env: 'test', + debug: true, + nested: { + value: 'deep value', + }, + }, + database: { + connections: { + postgresql: { + url: 'postgresql://test', + }, + }, + }, + custom: { + feature: { + enabled: true, + limit: 100, + }, + }, +}; + describe('Configuration Helper', () => { + beforeAll(() => { + // Register mock config in the application container for unit tests + app.singleton('config', () => mockConfig); + }); + beforeEach(() => { - vi.clearAllMocks(); + // No-op: placeholder for future per-test setup }); describe('config()', () => { @@ -177,4 +171,4 @@ describe('Configuration Helper', () => { expect(value).toBe(0); }); }); -}); \ No newline at end of file +}); From 79f90e8e1201cb2631294ab673aa13375877def4 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:07:17 +0900 Subject: [PATCH 14/28] feat(tests): implement TestCaseDatabase and TestCaseIntegration for improved database testing utilities --- backend-api/@frouvel/kaname/README.md | 2 +- .../@frouvel/kaname/database/ARCHITECTURE.md | 8 ++--- .../@frouvel/kaname/database/README.md | 8 ++--- .../kaname/testing/MIGRATION_GUIDE.md | 22 ++++++------- backend-api/@frouvel/kaname/testing/README.md | 32 +++++++++---------- ...atabaseTestCase.ts => TestCaseDatabase.ts} | 12 +++---- ...tionTestCase.ts => TestCaseIntegration.ts} | 14 ++++---- backend-api/@frouvel/kaname/testing/index.ts | 6 ++-- backend-api/README.md | 6 ++-- .../drizzle/User.repository.test.ts | 4 +-- .../api/health/health.integration.test.ts | 6 ++-- .../integration/users.integration.test.ts | 2 +- 12 files changed, 61 insertions(+), 61 deletions(-) rename backend-api/@frouvel/kaname/testing/{DatabaseTestCase.ts => TestCaseDatabase.ts} (91%) rename backend-api/@frouvel/kaname/testing/{IntegrationTestCase.ts => TestCaseIntegration.ts} (93%) diff --git a/backend-api/@frouvel/kaname/README.md b/backend-api/@frouvel/kaname/README.md index 4c42d6b..23e36e7 100644 --- a/backend-api/@frouvel/kaname/README.md +++ b/backend-api/@frouvel/kaname/README.md @@ -7,7 +7,7 @@ Core framework modules for frourio-framework, inspired by Laravel's Illuminate n ### Testing (`@frouvel/kaname/testing`) Comprehensive testing utilities including: -- **Test Case Classes**: `TestCase`, `DatabaseTestCase`, `IntegrationTestCase` +- **Test Case Classes**: `TestCase`, `TestCaseDatabase`, `TestCaseIntegration` - **Factory Pattern**: Generate test data with `Factory` and `fake` helpers - **API Client**: Fluent HTTP request interface for integration tests - **Assertions**: Rich assertion helpers for API responses diff --git a/backend-api/@frouvel/kaname/database/ARCHITECTURE.md b/backend-api/@frouvel/kaname/database/ARCHITECTURE.md index 22d2487..790104b 100644 --- a/backend-api/@frouvel/kaname/database/ARCHITECTURE.md +++ b/backend-api/@frouvel/kaname/database/ARCHITECTURE.md @@ -244,10 +244,10 @@ export default { ## Testing Support -### Updated DatabaseTestCase +### Updated TestCaseDatabase ```typescript -export abstract class DatabaseTestCase extends TestCase { +export abstract class TestCaseDatabase extends TestCase { protected db = DB.connection(); protected async refreshDatabase(): Promise { @@ -268,7 +268,7 @@ export abstract class DatabaseTestCase extends TestCase { ### Test Example ```typescript -class UserRepositoryTest extends DatabaseTestCase { +class UserRepositoryTest extends TestCaseDatabase { private repository: UserRepository; protected async setUp(): Promise { @@ -313,7 +313,7 @@ class UserRepositoryTest extends DatabaseTestCase { - [ ] Register in DatabaseManager ### Phase 4: Testing Support -- [ ] Update DatabaseTestCase +- [ ] Update TestCaseDatabase - [ ] Update test helpers - [ ] Create migration utilities - [ ] Add test examples diff --git a/backend-api/@frouvel/kaname/database/README.md b/backend-api/@frouvel/kaname/database/README.md index 5d30c1f..1be60f5 100644 --- a/backend-api/@frouvel/kaname/database/README.md +++ b/backend-api/@frouvel/kaname/database/README.md @@ -268,13 +268,13 @@ export class AnalyticsService { ## Testing Support -The `DatabaseTestCase` works seamlessly with the DB facade: +The `TestCaseDatabase` works seamlessly with the DB facade: ```typescript -import { DatabaseTestCase, DB } from '$/@frouvel/kaname/testing'; +import { TestCaseDatabase, DB } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class UserRepositoryTest extends DatabaseTestCase { +class UserRepositoryTest extends TestCaseDatabase { private repository: UserRepository; protected async setUp() { @@ -400,7 +400,7 @@ const users = await DB.prisma('custom').user.findMany(); 2. **Repository Pattern**: Encapsulate data access in repository classes 3. **Connection Names**: Use descriptive names (`primary`, `read-replica`, `analytics`) 4. **Transactions**: Always use `DB.transaction()` for multiple operations -5. **Testing**: Use `DatabaseTestCase` for integration tests +5. **Testing**: Use `TestCaseDatabase` for integration tests 6. **Read Replicas**: Route read-heavy operations to read replicas ## Troubleshooting diff --git a/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md b/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md index ef44976..0c3b955 100644 --- a/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md +++ b/backend-api/@frouvel/kaname/testing/MIGRATION_GUIDE.md @@ -41,10 +41,10 @@ describe('Users API', () => { **After (New TestCase Style):** ```typescript -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class UsersTest extends IntegrationTestCase { +class UsersTest extends TestCaseIntegration { run() { this.suite('Users API', () => { this.test('returns users', async () => { @@ -101,10 +101,10 @@ describe('User Repository', () => { **After:** ```typescript -import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseDatabase } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class UserRepositoryTest extends DatabaseTestCase { +class UserRepositoryTest extends TestCaseDatabase { run() { this.suite('User Repository', () => { this.test('creates a user', async () => { @@ -137,10 +137,10 @@ describe('Users API', () => { **After:** ```typescript -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class UsersApiTest extends IntegrationTestCase { +class UsersApiTest extends TestCaseIntegration { run() { this.suite('Users API', () => { this.test('GET /api/users returns users', async () => { @@ -202,7 +202,7 @@ afterEach(async () => { **After:** ```typescript -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { protected async seed(): Promise { await this.prisma.user.create({ data: { ... } }); } @@ -258,7 +258,7 @@ beforeEach(async () => { **After:** ```typescript -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { private userId?: string; protected async setUp(): Promise { @@ -284,7 +284,7 @@ beforeEach(async () => { **After:** ```typescript -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { private authToken?: string; protected async setUp(): Promise { @@ -312,7 +312,7 @@ beforeEach(async () => { **After:** ```typescript -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { protected async seed(): Promise { await this.prisma.user.create({ data: { @@ -368,7 +368,7 @@ users.test.ts - [ ] Updated `vite.config.mts` to use framework setup - [ ] Removed old `tests/setup.ts` - [ ] Created `tests/integration/` directory -- [ ] Migrated all integration tests to `IntegrationTestCase` +- [ ] Migrated all integration tests to `TestCaseIntegration` - [ ] Renamed test files with `.integration.test.ts` suffix - [ ] Updated imports to use `@frouvel/kaname/testing` - [ ] Ran tests to verify everything works diff --git a/backend-api/@frouvel/kaname/testing/README.md b/backend-api/@frouvel/kaname/testing/README.md index 4aad4d5..776bef1 100644 --- a/backend-api/@frouvel/kaname/testing/README.md +++ b/backend-api/@frouvel/kaname/testing/README.md @@ -9,8 +9,8 @@ Comprehensive testing utilities for the frourio-framework, providing a clean and - [Quick Start](#quick-start) - [Test Cases](#test-cases) - [TestCase](#testcase) - - [DatabaseTestCase](#databasetestcase) - - [IntegrationTestCase](#integrationtestcase) + - [TestCaseDatabase](#testcasedatabase) + - [TestCaseIntegration](#testcaseintegration) - [Factories](#factories) - [API Client](#api-client) - [Setup & Configuration](#setup--configuration) @@ -57,10 +57,10 @@ export default defineConfig({ ### 2. Create Your First Test ```typescript -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class MyFirstTest extends IntegrationTestCase { +class MyFirstTest extends TestCaseIntegration { run() { this.suite('My First Test Suite', () => { this.test('can make a request', async () => { @@ -127,15 +127,15 @@ class MyTest extends TestCase { new MyTest().run(); ``` -### DatabaseTestCase +### TestCaseDatabase Extends `TestCase` with database functionality. Automatically handles migrations and cleanup. ```typescript -import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseDatabase } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class MyDatabaseTest extends DatabaseTestCase { +class MyDatabaseTest extends TestCaseDatabase { protected async seed(): Promise { // Seed data before each test await this.prisma.user.create({ @@ -178,15 +178,15 @@ class MyDatabaseTest extends DatabaseTestCase { new MyDatabaseTest().run(); ``` -### IntegrationTestCase +### TestCaseIntegration -Extends `DatabaseTestCase` with full HTTP server testing capabilities. +Extends `TestCaseDatabase` with full HTTP server testing capabilities. ```typescript -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class MyIntegrationTest extends IntegrationTestCase { +class MyIntegrationTest extends TestCaseIntegration { run() { this.suite('API Integration Tests', () => { this.test('can make HTTP requests', async () => { @@ -390,7 +390,7 @@ tests/unit/services/UserService.test.ts ### 3. Seed Data in setUp/seed Methods ```typescript -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { protected async seed(): Promise { // Seed data here await this.prisma.user.createMany({ ... }); @@ -430,7 +430,7 @@ this.test('can delete user', async () => { ... }); ### Testing with Authentication ```typescript -class AuthTest extends IntegrationTestCase { +class AuthTest extends TestCaseIntegration { private token?: string; protected async setUp(): Promise { @@ -467,7 +467,7 @@ class AuthTest extends IntegrationTestCase { ### Testing Pagination ```typescript -class PaginationTest extends IntegrationTestCase { +class PaginationTest extends TestCaseIntegration { protected async seed(): Promise { // Create 25 users await this.prisma.user.createMany({ @@ -506,7 +506,7 @@ class PaginationTest extends IntegrationTestCase { ### Testing Error Responses (RFC9457) ```typescript -class ErrorHandlingTest extends IntegrationTestCase { +class ErrorHandlingTest extends TestCaseIntegration { run() { this.suite('Error Handling', () => { this.test('returns RFC9457 Problem Details', async () => { @@ -579,4 +579,4 @@ npx prisma migrate reset --force - [Vitest Documentation](https://vitest.dev/) - [Prisma Testing Guide](https://www.prisma.io/docs/guides/testing) - [Fastify Testing](https://www.fastify.io/docs/latest/Guides/Testing/) -- [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) \ No newline at end of file +- [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) diff --git a/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts b/backend-api/@frouvel/kaname/testing/TestCaseDatabase.ts similarity index 91% rename from backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts rename to backend-api/@frouvel/kaname/testing/TestCaseDatabase.ts index 2ea9fe9..3ae8928 100644 --- a/backend-api/@frouvel/kaname/testing/DatabaseTestCase.ts +++ b/backend-api/@frouvel/kaname/testing/TestCaseDatabase.ts @@ -6,10 +6,10 @@ import util from 'util'; const execPromise = util.promisify(exec); /** - * DatabaseTestCase provides database-specific testing utilities + * TestCaseDatabase provides database-specific testing utilities * Automatically handles database migrations and cleanup */ -export abstract class DatabaseTestCase extends TestCase { +export abstract class TestCaseDatabase extends TestCase { protected prisma: ReturnType; private static isMigrated = false; @@ -24,16 +24,16 @@ export abstract class DatabaseTestCase extends TestCase { protected async setUpBeforeClass(): Promise { await super.setUpBeforeClass(); - if (!DatabaseTestCase.isMigrated) { + if (!TestCaseDatabase.isMigrated) { try { await execPromise('npx prisma migrate deploy --schema=./database/prisma/schema.prisma'); - DatabaseTestCase.isMigrated = true; + TestCaseDatabase.isMigrated = true; } catch (error) { console.error('Migration failed:', error); console.log('Resetting test database...'); try { await execPromise('npx prisma migrate reset --force --schema=./database/prisma/schema.prisma'); - DatabaseTestCase.isMigrated = true; + TestCaseDatabase.isMigrated = true; } catch (resetError) { console.error('Database reset failed:', resetError); throw resetError; @@ -108,4 +108,4 @@ export abstract class DatabaseTestCase extends TestCase { return fn(tx as ReturnType); }); } -} \ No newline at end of file +} diff --git a/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts b/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts similarity index 93% rename from backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts rename to backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts index f080b12..9d2ca9f 100644 --- a/backend-api/@frouvel/kaname/testing/IntegrationTestCase.ts +++ b/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts @@ -1,14 +1,14 @@ -import { DatabaseTestCase } from './DatabaseTestCase'; +import { TestCaseDatabase } from './TestCaseDatabase'; import type { FastifyInstance } from 'fastify'; import app from '$/bootstrap/app'; import type { HttpKernel } from '$/@frouvel/kaname/foundation'; import { env } from '$/env'; /** - * IntegrationTestCase provides full integration testing with server and database + * TestCaseIntegration provides full integration testing with server and database * Automatically starts a test server and manages database lifecycle */ -export abstract class IntegrationTestCase extends DatabaseTestCase { +export abstract class TestCaseIntegration extends TestCaseDatabase { protected declare server: FastifyInstance; protected baseURL: string; private static testPort: number; @@ -16,8 +16,8 @@ export abstract class IntegrationTestCase extends DatabaseTestCase { constructor() { super(); // Use a different port for each test run to avoid conflicts - IntegrationTestCase.testPort = env.API_SERVER_PORT + 11; - this.baseURL = `http://localhost:${IntegrationTestCase.testPort}`; + TestCaseIntegration.testPort = env.API_SERVER_PORT + 11; + this.baseURL = `http://localhost:${TestCaseIntegration.testPort}`; } /** @@ -29,7 +29,7 @@ export abstract class IntegrationTestCase extends DatabaseTestCase { const kernel = app.make('HttpKernel'); this.server = await kernel.handle(); await this.server.listen({ - port: IntegrationTestCase.testPort, + port: TestCaseIntegration.testPort, host: '0.0.0.0', }); } @@ -241,4 +241,4 @@ export abstract class IntegrationTestCase extends DatabaseTestCase { } } } -} \ No newline at end of file +} diff --git a/backend-api/@frouvel/kaname/testing/index.ts b/backend-api/@frouvel/kaname/testing/index.ts index f068551..8e57532 100644 --- a/backend-api/@frouvel/kaname/testing/index.ts +++ b/backend-api/@frouvel/kaname/testing/index.ts @@ -7,11 +7,11 @@ */ export { TestCase } from './TestCase'; -export { DatabaseTestCase } from './DatabaseTestCase'; -export { IntegrationTestCase } from './IntegrationTestCase'; +export { TestCaseDatabase } from './TestCaseDatabase'; +export { TestCaseIntegration } from './TestCaseIntegration'; export { Factory, defineFactory, Sequence, fake } from './Factory'; export type { FactoryFunction, FactoryBuilder } from './Factory'; export { setupTestEnvironment } from './setup'; export type { TestEnvironmentOptions } from './setup'; export { ApiClient } from './ApiClient'; -export type { ApiResponse } from './ApiClient'; \ No newline at end of file +export type { ApiResponse } from './ApiClient'; diff --git a/backend-api/README.md b/backend-api/README.md index bb123f3..9824e1b 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -256,10 +256,10 @@ The project includes a comprehensive testing framework at `@frouvel/kaname/testi ### Quick Start ```typescript -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; import { expect } from 'vitest'; -class MyTest extends IntegrationTestCase { +class MyTest extends TestCaseIntegration { run() { this.suite('My Test Suite', () => { this.test('my test', async () => { @@ -291,7 +291,7 @@ npm test -- --coverage ### Features -- **Test Case Classes**: `TestCase`, `DatabaseTestCase`, `IntegrationTestCase` +- **Test Case Classes**: `TestCase`, `TestCaseDatabase`, `TestCaseIntegration` - **Factory Pattern**: Generate test data with `Factory` and `fake` helpers - **API Client**: Fluent HTTP request interface - **Automatic Setup**: Database migrations, seeding, and cleanup diff --git a/backend-api/domain/user/repository/drizzle/User.repository.test.ts b/backend-api/domain/user/repository/drizzle/User.repository.test.ts index 4fbba8f..1afbdf2 100644 --- a/backend-api/domain/user/repository/drizzle/User.repository.test.ts +++ b/backend-api/domain/user/repository/drizzle/User.repository.test.ts @@ -1,12 +1,12 @@ /* eslint-disable max-lines */ -import { DatabaseTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseDatabase } from '$/@frouvel/kaname/testing'; import { UserRepositoryDrizzle } from '$/domain/user/repository/drizzle/User.repository'; import { DB } from '$/@frouvel/kaname/database'; import { users } from '$/database/drizzle/schema'; import { getDrizzleClient } from '$/@frouvel/kaname/database'; import { expect } from 'vitest'; -class UserRepositoryDrizzleTest extends DatabaseTestCase { +class UserRepositoryDrizzleTest extends TestCaseDatabase { private repository!: UserRepositoryDrizzle; private db!: ReturnType; diff --git a/backend-api/tests/integration/api/health/health.integration.test.ts b/backend-api/tests/integration/api/health/health.integration.test.ts index 6a3714b..9c15d83 100644 --- a/backend-api/tests/integration/api/health/health.integration.test.ts +++ b/backend-api/tests/integration/api/health/health.integration.test.ts @@ -1,12 +1,12 @@ import { expect } from 'vitest'; -import { IntegrationTestCase } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; /** * Example Integration Test for Health Endpoint * - * This test demonstrates the basic usage of IntegrationTestCase + * This test demonstrates the basic usage of TestCaseIntegration */ -class HealthIntegrationTest extends IntegrationTestCase { +class HealthIntegrationTest extends TestCaseIntegration { run() { this.suite('Health API', () => { this.test('GET /api/health returns OK', async () => { diff --git a/backend-api/tests/integration/users.integration.test.ts b/backend-api/tests/integration/users.integration.test.ts index 221a2b5..3ba79db 100644 --- a/backend-api/tests/integration/users.integration.test.ts +++ b/backend-api/tests/integration/users.integration.test.ts @@ -5,7 +5,7 @@ import { fake } from '$/@frouvel/kaname/testing'; /** * Example Test demonstrating testing utilities * - * NOTE: This uses TestCase (not IntegrationTestCase) because + * NOTE: This uses TestCase (not TestCaseIntegration) because * it doesn't need database or HTTP server functionality. */ class TestingUtilitiesTest extends TestCase { From 8511f6d6d36284e232c5f2234472dddec0785615 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:10:19 +0900 Subject: [PATCH 15/28] refactor(tests): format object type test for better readability --- backend-api/@frouvel/kaname/config/config.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend-api/@frouvel/kaname/config/config.test.ts b/backend-api/@frouvel/kaname/config/config.test.ts index 19a4126..3b86224 100644 --- a/backend-api/@frouvel/kaname/config/config.test.ts +++ b/backend-api/@frouvel/kaname/config/config.test.ts @@ -79,7 +79,9 @@ describe('Configuration Helper', () => { }); it('should handle object types', () => { - const feature = config<{ enabled: boolean; limit: number }>('custom.feature'); + const feature = config<{ enabled: boolean; limit: number }>( + 'custom.feature', + ); expect(feature).toEqual({ enabled: true, limit: 100, From b03bf28bfc453a0af59f9ce0c20fe3532a0b2877 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:14:22 +0900 Subject: [PATCH 16/28] feat(database): implement driver abstraction for Prisma and Drizzle, allowing custom driver integration --- backend-api/@frouvel/kaname/database/DB.ts | 2 +- .../kaname/database/DatabaseManager.ts | 89 +++++++------------ .../@frouvel/kaname/database/README.md | 18 +++- .../contracts/DatabaseDriver.interface.ts | 26 ++++++ .../contracts/DatabaseManager.interface.ts | 15 +++- .../kaname/database/drivers/DrizzleDriver.ts | 37 ++++++++ .../kaname/database/drivers/PrismaDriver.ts | 27 ++++++ backend-api/@frouvel/kaname/database/index.ts | 3 +- 8 files changed, 153 insertions(+), 64 deletions(-) create mode 100644 backend-api/@frouvel/kaname/database/contracts/DatabaseDriver.interface.ts create mode 100644 backend-api/@frouvel/kaname/database/drivers/DrizzleDriver.ts create mode 100644 backend-api/@frouvel/kaname/database/drivers/PrismaDriver.ts diff --git a/backend-api/@frouvel/kaname/database/DB.ts b/backend-api/@frouvel/kaname/database/DB.ts index 4bfb877..fc8ea25 100644 --- a/backend-api/@frouvel/kaname/database/DB.ts +++ b/backend-api/@frouvel/kaname/database/DB.ts @@ -69,7 +69,7 @@ class DBFacade { * DB.register('default', prisma, 'prisma'); * ``` */ - register(name: string, client: any, driver: 'prisma' | 'drizzle'): void { + register(name: string, client: any, driver: string): void { if (!this.manager) { // Auto-initialize if not already initialized this.init({ diff --git a/backend-api/@frouvel/kaname/database/DatabaseManager.ts b/backend-api/@frouvel/kaname/database/DatabaseManager.ts index f61abed..76779bb 100644 --- a/backend-api/@frouvel/kaname/database/DatabaseManager.ts +++ b/backend-api/@frouvel/kaname/database/DatabaseManager.ts @@ -2,9 +2,9 @@ import type { DatabaseManager as IDatabaseManager, ConnectionConfig, } from './contracts/DatabaseManager.interface'; -import type { PrismaClient } from '@prisma/client'; -import { getPrismaClient } from './PrismaClientManager'; -import { getDrizzleClient } from './DrizzleClientManager'; +import type { DatabaseDriver } from './contracts/DatabaseDriver.interface'; +import { PrismaDriver } from './drivers/PrismaDriver'; +import { DrizzleDriver } from './drivers/DrizzleDriver'; /** * Database Manager @@ -32,11 +32,16 @@ export class DatabaseManager implements IDatabaseManager { private connections: Map = new Map(); private clients: Map = new Map(); private connectedClients: Set = new Set(); + private drivers: Map = new Map(); constructor(config: { default: string; connections: Record; }) { + // Register built-in drivers + this.registerDriver('prisma', new PrismaDriver()); + this.registerDriver('drizzle', new DrizzleDriver()); + this.defaultConnection = config.default; // Store connection configurations @@ -65,7 +70,7 @@ export class DatabaseManager implements IDatabaseManager { /** * Get Prisma client for direct access (zero overhead) */ - prisma(connection?: string): T | null { + prisma(connection?: string): T | null { const name = connection || this.defaultConnection; const config = this.connections.get(name); @@ -113,18 +118,8 @@ export class DatabaseManager implements IDatabaseManager { } const client = this.getOrCreateClient(name); - - // Prisma transaction - if (config.driver === 'prisma') { - return (client as any).$transaction(callback); - } - - // Drizzle transaction - if (config.driver === 'drizzle') { - return (client as any).transaction(callback); - } - - throw new Error(`Unsupported driver: ${config.driver}`); + const driver = this.getDriver(config.driver); + return driver.transaction(client, callback); } /** @@ -144,15 +139,8 @@ export class DatabaseManager implements IDatabaseManager { } try { - if (config.driver === 'prisma') { - await client.$disconnect(); - } else if (config.driver === 'drizzle') { - // Drizzle disconnect logic - if (client.end) { - await client.end(); - } - } - + const driver = this.getDriver(config.driver); + await driver.disconnect(client); this.connectedClients.delete(name); } catch (error) { console.error(`Failed to disconnect from '${name}':`, error); @@ -187,8 +175,11 @@ export class DatabaseManager implements IDatabaseManager { registerClient( name: string, client: any, - driver: 'prisma' | 'drizzle', + driver: string, ): void { + if (!this.drivers.has(driver)) { + throw new Error(`Unsupported driver '${driver}'. Register the driver before using it.`); + } this.clients.set(name, client); this.connections.set(name, { driver, @@ -197,6 +188,13 @@ export class DatabaseManager implements IDatabaseManager { this.connectedClients.add(name); } + /** + * Register or override a database driver + */ + registerDriver(name: string, driver: DatabaseDriver): void { + this.drivers.set(name, driver); + } + /** * Get or create a client for the given connection */ @@ -223,41 +221,18 @@ export class DatabaseManager implements IDatabaseManager { * Create a new database client */ private createClient(name: string, config: ConnectionConfig): any { - if (config.driver === 'prisma') { - return this.createPrismaClient(config); - } - - if (config.driver === 'drizzle') { - return this.createDrizzleClient(config); - } - - throw new Error(`Unsupported driver: ${config.driver}`); - } - - /** - * Create Prisma client - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private createPrismaClient(config: ConnectionConfig): any { - try { - return getPrismaClient(); - } catch (error) { - throw new Error(`Failed to create Prisma client: ${error}`); - } + const driver = this.getDriver(config.driver); + return driver.createClient(config, name); } /** - * Create Drizzle client + * Get driver by name or throw */ - private createDrizzleClient(config: ConnectionConfig): any { - try { - const connectionString = config.url; - if (!connectionString) { - throw new Error('Drizzle connection URL is required in config'); - } - return getDrizzleClient(connectionString); - } catch (error) { - throw new Error(`Failed to create Drizzle client: ${error}`); + private getDriver(name: string): DatabaseDriver { + const driver = this.drivers.get(name); + if (!driver) { + throw new Error(`Unsupported driver: ${name}`); } + return driver; } } diff --git a/backend-api/@frouvel/kaname/database/README.md b/backend-api/@frouvel/kaname/database/README.md index 1be60f5..661d832 100644 --- a/backend-api/@frouvel/kaname/database/README.md +++ b/backend-api/@frouvel/kaname/database/README.md @@ -116,6 +116,22 @@ export default { } satisfies DatabaseConfig; ``` +### Adding a custom driver + +You can plug in another ORM/driver by implementing `DatabaseDriver` and registering it before use: + +```typescript +import { DB, type DatabaseDriver } from '$/@frouvel/kaname/database'; + +const myDriver: DatabaseDriver = { + createClient: (config) => /* return client from config */ {}, + transaction: (client, cb) => client.transaction(cb), + disconnect: (client) => client.disconnect?.(), +}; + +DB.getManager().registerDriver('my-driver', myDriver); +``` + ## API Reference ### DB.prisma() @@ -434,4 +450,4 @@ Check your `config/database.ts` has all connections defined and the connection n - [Architecture Document](./ARCHITECTURE.md) - Detailed architectural design - [Prisma Documentation](https://www.prisma.io/docs) -- [Drizzle Documentation](https://orm.drizzle.team/docs/overview) \ No newline at end of file +- [Drizzle Documentation](https://orm.drizzle.team/docs/overview) diff --git a/backend-api/@frouvel/kaname/database/contracts/DatabaseDriver.interface.ts b/backend-api/@frouvel/kaname/database/contracts/DatabaseDriver.interface.ts new file mode 100644 index 0000000..d1934e3 --- /dev/null +++ b/backend-api/@frouvel/kaname/database/contracts/DatabaseDriver.interface.ts @@ -0,0 +1,26 @@ +import type { ConnectionConfig } from './DatabaseManager.interface'; + +/** + * DatabaseDriver defines how a specific ORM/driver integrates with the + * DatabaseManager. Each driver is responsible for creating its client, + * handling transactions, and cleaning up connections. + */ +export interface DatabaseDriver { + /** + * Create a client instance for the given connection configuration. + */ + createClient(config: ConnectionConfig, name: string): any; + + /** + * Execute a callback within a transaction using the provided client. + */ + transaction( + client: any, + callback: (client: any) => Promise, + ): Promise; + + /** + * Disconnect / clean up the client. + */ + disconnect(client: any): Promise; +} diff --git a/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts b/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts index ca66c3f..217e0c1 100644 --- a/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts +++ b/backend-api/@frouvel/kaname/database/contracts/DatabaseManager.interface.ts @@ -1,10 +1,12 @@ /** * Database Manager Interface - * + * * Provides a simple facade for database operations while allowing * direct access to underlying ORM clients for zero performance overhead. */ +import type { DatabaseDriver } from './DatabaseDriver.interface'; + export interface DatabaseManager { /** * Get the default database connection name @@ -57,6 +59,11 @@ export interface DatabaseManager { * Check if a connection is established */ isConnected(connection?: string): boolean; + + /** + * Register a database driver (e.g., prisma, drizzle, etc.) + */ + registerDriver(name: string, driver: DatabaseDriver): void; } /** @@ -79,9 +86,9 @@ export interface DatabaseConfig { */ export interface ConnectionConfig { /** - * ORM driver: 'prisma' or 'drizzle' + * ORM driver name (e.g., 'prisma', 'drizzle') */ - driver: 'prisma' | 'drizzle'; + driver: string; /** * Connection URL (for Prisma) @@ -107,4 +114,4 @@ export interface ConnectionConfig { max?: number; idleTimeoutMillis?: number; }; -} \ No newline at end of file +} diff --git a/backend-api/@frouvel/kaname/database/drivers/DrizzleDriver.ts b/backend-api/@frouvel/kaname/database/drivers/DrizzleDriver.ts new file mode 100644 index 0000000..bbbf53c --- /dev/null +++ b/backend-api/@frouvel/kaname/database/drivers/DrizzleDriver.ts @@ -0,0 +1,37 @@ +import type { DatabaseDriver } from '../contracts/DatabaseDriver.interface'; +import type { ConnectionConfig } from '../contracts/DatabaseManager.interface'; +import { getDrizzleClient } from '../DrizzleClientManager'; + +/** + * Drizzle ORM database driver + */ +export class DrizzleDriver implements DatabaseDriver { + createClient(config: ConnectionConfig): any { + const url = config.url ?? this.buildUrlFromConnection(config); + if (!url) { + throw new Error('Drizzle connection URL is required in config'); + } + return getDrizzleClient(url); + } + + async transaction( + client: any, + callback: (client: any) => Promise, + ): Promise { + return client.transaction(callback); + } + + async disconnect(client: any): Promise { + if (client?.end) { + await client.end(); + } + } + + private buildUrlFromConnection(config: ConnectionConfig): string | undefined { + const conn = config.connection; + if (!conn) return undefined; + + const auth = conn.password ? `${conn.user}:${conn.password}` : conn.user; + return `postgresql://${auth}@${conn.host}:${conn.port}/${conn.database}`; + } +} diff --git a/backend-api/@frouvel/kaname/database/drivers/PrismaDriver.ts b/backend-api/@frouvel/kaname/database/drivers/PrismaDriver.ts new file mode 100644 index 0000000..d9b3e0e --- /dev/null +++ b/backend-api/@frouvel/kaname/database/drivers/PrismaDriver.ts @@ -0,0 +1,27 @@ +import type { DatabaseDriver } from '../contracts/DatabaseDriver.interface'; +import type { ConnectionConfig } from '../contracts/DatabaseManager.interface'; +import { getPrismaClient } from '../PrismaClientManager'; + +/** + * Prisma database driver + */ +export class PrismaDriver implements DatabaseDriver { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createClient(_config: ConnectionConfig): any { + // Prisma client creation is centralized in PrismaClientManager to reuse connections. + return getPrismaClient(); + } + + async transaction( + client: any, + callback: (client: any) => Promise, + ): Promise { + return client.$transaction(callback); + } + + async disconnect(client: any): Promise { + if (client?.$disconnect) { + await client.$disconnect(); + } + } +} diff --git a/backend-api/@frouvel/kaname/database/index.ts b/backend-api/@frouvel/kaname/database/index.ts index 8006a81..e6c514f 100644 --- a/backend-api/@frouvel/kaname/database/index.ts +++ b/backend-api/@frouvel/kaname/database/index.ts @@ -17,6 +17,7 @@ export type { DatabaseConfig, ConnectionConfig, } from './contracts/DatabaseManager.interface'; +export type { DatabaseDriver } from './contracts/DatabaseDriver.interface'; // Prisma utilities export { @@ -32,4 +33,4 @@ export { getDrizzleClient, disconnectDrizzleClient, resetDrizzleConnection, -} from './DrizzleClientManager'; \ No newline at end of file +} from './DrizzleClientManager'; From c808d270229a5c0d40855279c9da64b812e8578d Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:23:11 +0900 Subject: [PATCH 17/28] feat(database): add custom error classes for database management and update error handling --- backend-api/@frouvel/kaname/database/DB.ts | 5 +- .../kaname/database/DatabaseErrors.ts | 58 +++++++++++++++++++ .../kaname/database/DatabaseManager.ts | 26 ++++++--- backend-api/@frouvel/kaname/database/index.ts | 9 +++ .../kaname/error/FrourioFrameworkError.ts | 13 +++++ 5 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 backend-api/@frouvel/kaname/database/DatabaseErrors.ts diff --git a/backend-api/@frouvel/kaname/database/DB.ts b/backend-api/@frouvel/kaname/database/DB.ts index fc8ea25..609ba37 100644 --- a/backend-api/@frouvel/kaname/database/DB.ts +++ b/backend-api/@frouvel/kaname/database/DB.ts @@ -4,6 +4,7 @@ import type { DatabaseConfig, DatabaseManager as IDatabaseManager, } from './contracts/DatabaseManager.interface'; +import { DatabaseNotInitializedError } from './DatabaseErrors'; /** * DB Facade @@ -51,9 +52,7 @@ class DBFacade { */ getManager(): IDatabaseManager { if (!this.manager) { - throw new Error( - 'Database manager not initialized. Call DB.init(config) first or use the DatabaseServiceProvider.', - ); + throw new DatabaseNotInitializedError(); } return this.manager; } diff --git a/backend-api/@frouvel/kaname/database/DatabaseErrors.ts b/backend-api/@frouvel/kaname/database/DatabaseErrors.ts new file mode 100644 index 0000000..fc4d25e --- /dev/null +++ b/backend-api/@frouvel/kaname/database/DatabaseErrors.ts @@ -0,0 +1,58 @@ +import { AbstractFrourioFrameworkError } from '$/@frouvel/kaname/error/FrourioFrameworkError'; + +const DATABASE_ERROR_TYPE_BASE = 'https://example.com/errors/database'; + +export class DatabaseNotInitializedError extends AbstractFrourioFrameworkError { + constructor() { + super({ + message: + 'Database manager not initialized. Call DB.init(config) first or use the DatabaseServiceProvider.', + code: 'DATABASE_NOT_INITIALIZED', + typeUri: `${DATABASE_ERROR_TYPE_BASE}/not-initialized`, + }); + } +} + +export class DatabaseConnectionNotConfiguredError extends AbstractFrourioFrameworkError { + constructor(connection: string) { + super({ + message: `Connection '${connection}' is not configured`, + code: 'DATABASE_CONNECTION_NOT_CONFIGURED', + details: { connection }, + typeUri: `${DATABASE_ERROR_TYPE_BASE}/connection-not-configured`, + }); + } +} + +export class UnsupportedDatabaseDriverError extends AbstractFrourioFrameworkError { + constructor(driver: string) { + super({ + message: `Unsupported database driver: '${driver}'`, + code: 'DATABASE_UNSUPPORTED_DRIVER', + details: { driver }, + typeUri: `${DATABASE_ERROR_TYPE_BASE}/unsupported-driver`, + }); + } +} + +export class DatabaseClientUnavailableError extends AbstractFrourioFrameworkError { + constructor(connection: string, driver: string) { + super({ + message: `Database client not available for connection '${connection}' using driver '${driver}'`, + code: 'DATABASE_CLIENT_UNAVAILABLE', + details: { connection, driver }, + typeUri: `${DATABASE_ERROR_TYPE_BASE}/client-unavailable`, + }); + } +} + +export class DatabaseClientCreationError extends AbstractFrourioFrameworkError { + constructor(driver: string, cause?: unknown) { + super({ + message: `Failed to create database client for driver '${driver}'`, + code: 'DATABASE_CLIENT_CREATION_FAILED', + details: { driver, cause }, + typeUri: `${DATABASE_ERROR_TYPE_BASE}/client-creation-failed`, + }); + } +} diff --git a/backend-api/@frouvel/kaname/database/DatabaseManager.ts b/backend-api/@frouvel/kaname/database/DatabaseManager.ts index 76779bb..5559cc4 100644 --- a/backend-api/@frouvel/kaname/database/DatabaseManager.ts +++ b/backend-api/@frouvel/kaname/database/DatabaseManager.ts @@ -5,6 +5,12 @@ import type { import type { DatabaseDriver } from './contracts/DatabaseDriver.interface'; import { PrismaDriver } from './drivers/PrismaDriver'; import { DrizzleDriver } from './drivers/DrizzleDriver'; +import { + DatabaseClientCreationError, + DatabaseConnectionNotConfiguredError, + DatabaseNotInitializedError, + UnsupportedDatabaseDriverError, +} from './DatabaseErrors'; /** * Database Manager @@ -62,7 +68,7 @@ export class DatabaseManager implements IDatabaseManager { */ setDefaultConnection(name: string): void { if (!this.connections.has(name)) { - throw new Error(`Connection '${name}' is not configured`); + throw new DatabaseConnectionNotConfiguredError(name); } this.defaultConnection = name; } @@ -114,7 +120,7 @@ export class DatabaseManager implements IDatabaseManager { const config = this.connections.get(name); if (!config) { - throw new Error(`Connection '${name}' is not configured`); + throw new DatabaseConnectionNotConfiguredError(name); } const client = this.getOrCreateClient(name); @@ -130,12 +136,12 @@ export class DatabaseManager implements IDatabaseManager { const client = this.clients.get(name); if (!client) { - return; + throw new DatabaseNotInitializedError(); } const config = this.connections.get(name); if (!config) { - return; + throw new DatabaseConnectionNotConfiguredError(name); } try { @@ -178,7 +184,7 @@ export class DatabaseManager implements IDatabaseManager { driver: string, ): void { if (!this.drivers.has(driver)) { - throw new Error(`Unsupported driver '${driver}'. Register the driver before using it.`); + throw new UnsupportedDatabaseDriverError(driver); } this.clients.set(name, client); this.connections.set(name, { @@ -206,7 +212,7 @@ export class DatabaseManager implements IDatabaseManager { const config = this.connections.get(name); if (!config) { - throw new Error(`Connection '${name}' is not configured`); + throw new DatabaseConnectionNotConfiguredError(name); } // Create new client based on driver @@ -222,7 +228,11 @@ export class DatabaseManager implements IDatabaseManager { */ private createClient(name: string, config: ConnectionConfig): any { const driver = this.getDriver(config.driver); - return driver.createClient(config, name); + try { + return driver.createClient(config, name); + } catch (error) { + throw new DatabaseClientCreationError(config.driver, error); + } } /** @@ -231,7 +241,7 @@ export class DatabaseManager implements IDatabaseManager { private getDriver(name: string): DatabaseDriver { const driver = this.drivers.get(name); if (!driver) { - throw new Error(`Unsupported driver: ${name}`); + throw new UnsupportedDatabaseDriverError(name); } return driver; } diff --git a/backend-api/@frouvel/kaname/database/index.ts b/backend-api/@frouvel/kaname/database/index.ts index e6c514f..6e65612 100644 --- a/backend-api/@frouvel/kaname/database/index.ts +++ b/backend-api/@frouvel/kaname/database/index.ts @@ -34,3 +34,12 @@ export { disconnectDrizzleClient, resetDrizzleConnection, } from './DrizzleClientManager'; + +// Database-specific errors +export { + DatabaseNotInitializedError, + DatabaseConnectionNotConfiguredError, + UnsupportedDatabaseDriverError, + DatabaseClientUnavailableError, + DatabaseClientCreationError, +} from './DatabaseErrors'; diff --git a/backend-api/@frouvel/kaname/error/FrourioFrameworkError.ts b/backend-api/@frouvel/kaname/error/FrourioFrameworkError.ts index f5e3f06..38d2a25 100644 --- a/backend-api/@frouvel/kaname/error/FrourioFrameworkError.ts +++ b/backend-api/@frouvel/kaname/error/FrourioFrameworkError.ts @@ -27,6 +27,13 @@ export enum ErrorCode { // Email related errors (6000-6999) EMAIL_SEND_FAILED = 'EMAIL_SEND_FAILED', INVALID_EMAIL_FORMAT = 'INVALID_EMAIL_FORMAT', + + // Database related errors (7000-7999) + DATABASE_NOT_INITIALIZED = 'DATABASE_NOT_INITIALIZED', + DATABASE_CONNECTION_NOT_CONFIGURED = 'DATABASE_CONNECTION_NOT_CONFIGURED', + DATABASE_UNSUPPORTED_DRIVER = 'DATABASE_UNSUPPORTED_DRIVER', + DATABASE_CLIENT_UNAVAILABLE = 'DATABASE_CLIENT_UNAVAILABLE', + DATABASE_CLIENT_CREATION_FAILED = 'DATABASE_CLIENT_CREATION_FAILED', } /** @@ -54,6 +61,12 @@ export const ErrorCodeToHttpStatus: Record = { [ErrorCode.EMAIL_SEND_FAILED]: 500, [ErrorCode.INVALID_EMAIL_FORMAT]: 400, + + [ErrorCode.DATABASE_NOT_INITIALIZED]: 500, + [ErrorCode.DATABASE_CONNECTION_NOT_CONFIGURED]: 500, + [ErrorCode.DATABASE_UNSUPPORTED_DRIVER]: 500, + [ErrorCode.DATABASE_CLIENT_UNAVAILABLE]: 500, + [ErrorCode.DATABASE_CLIENT_CREATION_FAILED]: 500, }; /** From bc94c9801165c1845b685cf24bd9c68215f758ee Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:25:46 +0900 Subject: [PATCH 18/28] feat(database): update error type URI to RFC 9457 format and add documentation for database errors --- .../kaname/database/DatabaseErrors.ts | 14 ++++- docs/errors/database.md | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/errors/database.md diff --git a/backend-api/@frouvel/kaname/database/DatabaseErrors.ts b/backend-api/@frouvel/kaname/database/DatabaseErrors.ts index fc4d25e..6b7bd98 100644 --- a/backend-api/@frouvel/kaname/database/DatabaseErrors.ts +++ b/backend-api/@frouvel/kaname/database/DatabaseErrors.ts @@ -1,6 +1,18 @@ import { AbstractFrourioFrameworkError } from '$/@frouvel/kaname/error/FrourioFrameworkError'; -const DATABASE_ERROR_TYPE_BASE = 'https://example.com/errors/database'; +/** + * Base URI for RFC 9457 Problem Details type URIs for database errors. + * + * This URI uniquely identifies the error type and can optionally point to + * human-readable documentation. It does not need to be a resolvable URL. + * + * In production, you should configure this to use your actual domain: + * - Example: 'https://yourdomain.com/docs/errors/database' + * - Or use app config: config('app.url') + '/docs/errors/database' + * + * @see https://www.rfc-editor.org/rfc/rfc9457.html + */ +const DATABASE_ERROR_TYPE_BASE = 'urn:frourio:errors:database'; export class DatabaseNotInitializedError extends AbstractFrourioFrameworkError { constructor() { diff --git a/docs/errors/database.md b/docs/errors/database.md new file mode 100644 index 0000000..0f5a1a4 --- /dev/null +++ b/docs/errors/database.md @@ -0,0 +1,53 @@ +# Database Error Reference + +This page documents database-layer errors emitted by `@frouvel/kaname/database`. Every error derives from the framework's RFC9457-compatible `AbstractFrourioFrameworkError`, so you can surface them to clients as Problem Details without losing context. + +## How to handle + +```ts +import { DatabaseNotInitializedError } from '$/@frouvel/kaname/database'; + +try { + const prisma = DB.prisma(); + // ... +} catch (error) { + if (error instanceof DatabaseNotInitializedError) { + // Recover or bubble up as a Problem Details response + return error.toProblemDetails(); + } + throw error; +} +``` + +Typically you will convert these errors to HTTP responses in a global handler: + +```ts +import { AbstractFrourioFrameworkError } from '$/@frouvel/kaname/error'; + +function toProblem(error: unknown) { + if (error instanceof AbstractFrourioFrameworkError) { + return error.toProblemDetails(); + } + // fallback + return { + type: 'about:blank', + title: 'Internal Server Error', + status: 500, + detail: 'An unexpected error occurred.', + }; +} +``` + +## Error catalog + +| Error class | Code | HTTP status | When it happens | Typical resolution | +| --- | --- | --- | --- | --- | +| `DatabaseNotInitializedError` | `DATABASE_NOT_INITIALIZED` | 500 | `DB` facade used before initialization (e.g., missing `DatabaseServiceProvider` or `DB.init`). | Ensure `DatabaseServiceProvider` runs early or call `DB.init` in setup. | +| `DatabaseConnectionNotConfiguredError` | `DATABASE_CONNECTION_NOT_CONFIGURED` | 500 | Requested connection name is missing in `config/database`. | Add the connection config or adjust the requested name. | +| `UnsupportedDatabaseDriverError` | `DATABASE_UNSUPPORTED_DRIVER` | 500 | A driver name is not registered in `DatabaseManager`. | Register a custom `DatabaseDriver` via `DB.getManager().registerDriver(name, driver)`. | +| `DatabaseClientUnavailableError` | `DATABASE_CLIENT_UNAVAILABLE` | 500 | Client lookup failed for the given connection/driver. | Verify the connection is configured for that driver and initialized. | +| `DatabaseClientCreationError` | `DATABASE_CLIENT_CREATION_FAILED` | 500 | Client factory threw while creating a Prisma/Drizzle/custom client. | Check connection URL/credentials and driver setup; review the nested `cause` in the error details. | + +## Custom drivers + +When adding a new ORM/driver, implement `DatabaseDriver` and register it. Be sure to surface driver-specific issues using `DatabaseClientCreationError` (or your own subclass) so they remain RFC-friendly and visible to users. From 48718d5cf7fe130731f323eaf0fa5dfa7e2e8c9a Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:32:08 +0900 Subject: [PATCH 19/28] feat(api): enhance error handling by using ErrorStatus type in returnProblemDetails function --- .../@frouvel/kaname/http/ApiResponse.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/backend-api/@frouvel/kaname/http/ApiResponse.ts b/backend-api/@frouvel/kaname/http/ApiResponse.ts index f872164..0eb078c 100644 --- a/backend-api/@frouvel/kaname/http/ApiResponse.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponse.ts @@ -15,6 +15,25 @@ import type { import { DEFAULT_PROBLEM_TYPE } from './type/nfc9457'; import { AbstractFrourioFrameworkError } from '../error/FrourioFrameworkError'; +// Align with Frourio's generated HttpStatusNoOk union in $server.ts +type ErrorStatus = + | 301 + | 302 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 409 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505; + // ============================================================================ // Core Response Helpers // ============================================================================ @@ -91,12 +110,16 @@ const returnSuccess = (val: T) => ({ * Return RFC9457-compliant error response * @internal */ -function returnProblemDetails(error: unknown, defaultStatus: number = 500) { +function returnProblemDetails( + error: unknown, + defaultStatus: ErrorStatus = 500, +) { const problemDetails = errorToProblemDetails(error); - const status = problemDetails.status || defaultStatus; + const status = + (problemDetails.status as ErrorStatus | undefined) ?? defaultStatus; return { - status: status as any, + status, body: problemDetails, }; } From 6b29afadd018ae3d269f71653e63cccf031db5a3 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:34:16 +0900 Subject: [PATCH 20/28] feat(testing): add TestApiClient for integration testing with comprehensive HTTP request methods --- .../kaname/testing/{ApiClient.ts => TestApiClient.ts} | 2 +- backend-api/@frouvel/kaname/testing/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename backend-api/@frouvel/kaname/testing/{ApiClient.ts => TestApiClient.ts} (99%) diff --git a/backend-api/@frouvel/kaname/testing/ApiClient.ts b/backend-api/@frouvel/kaname/testing/TestApiClient.ts similarity index 99% rename from backend-api/@frouvel/kaname/testing/ApiClient.ts rename to backend-api/@frouvel/kaname/testing/TestApiClient.ts index d316adc..d8f61a1 100644 --- a/backend-api/@frouvel/kaname/testing/ApiClient.ts +++ b/backend-api/@frouvel/kaname/testing/TestApiClient.ts @@ -11,7 +11,7 @@ export interface ApiResponse { * API Client for integration testing * Provides a fluent interface for making HTTP requests to the test server */ -export class ApiClient { +export class TestApiClient { private headers: Record = {}; private authToken?: string; diff --git a/backend-api/@frouvel/kaname/testing/index.ts b/backend-api/@frouvel/kaname/testing/index.ts index 8e57532..4974f0f 100644 --- a/backend-api/@frouvel/kaname/testing/index.ts +++ b/backend-api/@frouvel/kaname/testing/index.ts @@ -13,5 +13,5 @@ export { Factory, defineFactory, Sequence, fake } from './Factory'; export type { FactoryFunction, FactoryBuilder } from './Factory'; export { setupTestEnvironment } from './setup'; export type { TestEnvironmentOptions } from './setup'; -export { ApiClient } from './ApiClient'; -export type { ApiResponse } from './ApiClient'; +export { TestApiClient } from './TestApiClient'; +export type { ApiResponse } from './TestApiClient'; From 3b858b45c3dccfa72df38609c507790c186859de Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:35:24 +0900 Subject: [PATCH 21/28] feat(testing): rename ApiClient to TestApiClient for clarity in testing utilities --- backend-api/@frouvel/kaname/testing/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend-api/@frouvel/kaname/testing/README.md b/backend-api/@frouvel/kaname/testing/README.md index 776bef1..33d9251 100644 --- a/backend-api/@frouvel/kaname/testing/README.md +++ b/backend-api/@frouvel/kaname/testing/README.md @@ -300,12 +300,12 @@ const users = await factory.times(5).create(); ## API Client -The `ApiClient` provides a fluent interface for making HTTP requests in tests. +The `TestApiClient` provides a fluent interface for making HTTP requests in tests. ```typescript -import { ApiClient } from '$/@frouvel/kaname/testing'; +import { TestApiClient } from '$/@frouvel/kaname/testing'; -const client = new ApiClient(server); +const client = new TestApiClient(server); // Basic requests await client.get('/api/users'); From 852dd13d3c55508ef1d73802079e6658f1a8af3e Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:40:26 +0900 Subject: [PATCH 22/28] feat(tests): add integration test for Health API using TestApiClient --- .../tests/integration/api/health.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend-api/tests/integration/api/health.test.ts diff --git a/backend-api/tests/integration/api/health.test.ts b/backend-api/tests/integration/api/health.test.ts new file mode 100644 index 0000000..2dd00ba --- /dev/null +++ b/backend-api/tests/integration/api/health.test.ts @@ -0,0 +1,19 @@ +import { expect } from 'vitest'; +import { TestApiClient } from '$/@frouvel/kaname/testing'; +import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; + +class HealthApiTest extends TestCaseIntegration { + run() { + this.suite('Health API (TestApiClient)', () => { + this.test('GET /api/health returns OK', async () => { + const client = new TestApiClient(this.server); + const response = await client.get('/api/health'); + + client.assertOk(response); + expect(response.body).toBe('OK'); + }); + }); + } +} + +new HealthApiTest().run(); From 8b4d3b318007125c6467d602fc7f5a792c0e05a2 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:40:51 +0900 Subject: [PATCH 23/28] feat(tests): remove Health API integration test file --- .../tests/integration/api/health.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 backend-api/tests/integration/api/health.test.ts diff --git a/backend-api/tests/integration/api/health.test.ts b/backend-api/tests/integration/api/health.test.ts deleted file mode 100644 index 2dd00ba..0000000 --- a/backend-api/tests/integration/api/health.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect } from 'vitest'; -import { TestApiClient } from '$/@frouvel/kaname/testing'; -import { TestCaseIntegration } from '$/@frouvel/kaname/testing'; - -class HealthApiTest extends TestCaseIntegration { - run() { - this.suite('Health API (TestApiClient)', () => { - this.test('GET /api/health returns OK', async () => { - const client = new TestApiClient(this.server); - const response = await client.get('/api/health'); - - client.assertOk(response); - expect(response.body).toBe('OK'); - }); - }); - } -} - -new HealthApiTest().run(); From 3d1a81ea17c58965952605b72a4396a470cfa549 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:49:43 +0900 Subject: [PATCH 24/28] fix(package): remove extra spaces in script names for consistency --- frontend-web/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend-web/package.json b/frontend-web/package.json index 5082e30..d08210e 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -3,15 +3,15 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "concurrently --names=\"⏭️ :next,🛡️ :aspida,📂:path\" \"npm run dev:next\" \"npm run dev:aspida\" \"npm run dev:path\" -c blue", + "dev": "concurrently --names=\"⏭️ :next,🛡️:aspida,📂:path\" \"npm run dev:next\" \"npm run dev:aspida\" \"npm run dev:path\" -c blue", "dev:next": "next dev", "dev:aspida": "aspida --watch --config aspida.config.cjs", "dev:path": "pathpida --enableStatic --ignorePath .gitignore --watch", - "build": "concurrently --names=\"📂:path,🛡️ :aspida,⏭️ :next\" \"npm run build:path\" \"npm run build:aspida\" \"npm run build:next\" -c blue", + "build": "concurrently --names=\"📂:path,🛡️:aspida,⏭️ :next\" \"npm run build:path\" \"npm run build:aspida\" \"npm run build:next\" -c blue", "build:path": "npm run generate:path", "build:aspida": "npm run generate:aspida", "build:next": "next build", - "generate": "concurrently --names=\"📂:path,🛡️ :aspida\" \"npm run generate:path\" \"npm run generate:aspida\" -c blue", + "generate": "concurrently --names=\"📂:path,🛡️:aspida\" \"npm run generate:path\" \"npm run generate:aspida\" -c blue", "generate:path": "pathpida --enableStatic --ignorePath .gitignore", "generate:aspida": "aspida --config aspida.config.cjs", "test": "vitest run --pool=forks", @@ -73,4 +73,4 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.0.8" } -} +} \ No newline at end of file From c955fbd0e33968ddd162f0046cdacb01feb4c59e Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 01:57:51 +0900 Subject: [PATCH 25/28] feat(config): enhance configuration management with nested paths and testing settings --- backend-api/@frouvel/kaname/config/config.ts | 46 +++++++++++++++++-- backend-api/@frouvel/kaname/config/index.ts | 4 +- .../@frouvel/kaname/foundation/HttpKernel.ts | 5 +- .../providers/SwaggerServiceProvider.ts | 2 +- .../kaname/testing/TestCaseIntegration.ts | 5 +- backend-api/@frouvel/kaname/testing/setup.ts | 10 ++-- backend-api/config/app.ts | 4 +- backend-api/config/testing.ts | 32 +++++++++++++ 8 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 backend-api/config/testing.ts diff --git a/backend-api/@frouvel/kaname/config/config.ts b/backend-api/@frouvel/kaname/config/config.ts index 52799ea..75f8594 100644 --- a/backend-api/@frouvel/kaname/config/config.ts +++ b/backend-api/@frouvel/kaname/config/config.ts @@ -8,6 +8,37 @@ import app from '$/bootstrap/app'; import type { Config, ConfigPaths } from '$/config/$types'; +/** + * Build a dot-notation key union from the Config type for autocomplete and inference. + */ +type DotPrefix = T extends '' ? '' : `.${T}`; +type PrevDepth = [never, 0, 1, 2, 3, 4]; +type NestedPaths = Depth extends 0 + ? '' + : T extends readonly any[] + ? '' // stop at arrays to avoid prototype keys like 'length' + : T extends object + ? { + [K in Extract]: `${K}${DotPrefix>}`; + }[Extract] + : ''; + +export type ConfigPath = NestedPaths; + +/** + * Resolve the value type at a given dot-notation path. + */ +type PathValue = P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? PathValue + : never + : P extends keyof T + ? T[P] + : never; + /** * Get a configuration value using dot notation with autocomplete * @@ -17,10 +48,15 @@ import type { Config, ConfigPaths } from '$/config/$types'; * config('database.pool.max') // type: any * config('non.existent.key', 'default') // Returns 'default' */ -export function config( +export function config

( + key: P, + defaultValue?: D, +): PathValue | D; +export function config(key: ConfigPaths | string, defaultValue?: T): T; +export function config( key: ConfigPaths | string, - defaultValue?: T, -): T { + defaultValue?: unknown, +): unknown { const configs = app.make>('config'); const keys = (key as string).split('.'); @@ -30,11 +66,11 @@ export function config( if (value && typeof value === 'object' && k in value) { value = value[k]; } else { - return defaultValue as T; + return defaultValue; } } - return value as T; + return value; } /** diff --git a/backend-api/@frouvel/kaname/config/index.ts b/backend-api/@frouvel/kaname/config/index.ts index dbf3dd9..0c50e52 100644 --- a/backend-api/@frouvel/kaname/config/index.ts +++ b/backend-api/@frouvel/kaname/config/index.ts @@ -15,4 +15,6 @@ export type { CorsConfig, DatabaseConfig, JwtConfig, -} from '$/config/$types'; \ No newline at end of file + TestingConfig, +} from '$/config/$types'; +export type { ConfigPath } from './config'; diff --git a/backend-api/@frouvel/kaname/foundation/HttpKernel.ts b/backend-api/@frouvel/kaname/foundation/HttpKernel.ts index 4a0756a..50823c8 100644 --- a/backend-api/@frouvel/kaname/foundation/HttpKernel.ts +++ b/backend-api/@frouvel/kaname/foundation/HttpKernel.ts @@ -112,7 +112,8 @@ export class HttpKernel extends Kernel { app.decorate('app', this._app); // Register routes via Frourio - server(app, { basePath: process.env.API_BASE_PATH }); + const appConfig = config('app'); + server(app, { basePath: appConfig.apiBasePath }); console.log('[HttpKernel] Fastify instance configured'); console.log('[HttpKernel] Application instance attached to Fastify'); @@ -163,7 +164,7 @@ export class HttpKernel extends Kernel { // JWT authentication await app.register(jwt, { - secret: process.env.API_JWT_SECRET ?? '', + secret: config('jwt.secret'), }); // Swagger/OpenAPI documentation diff --git a/backend-api/@frouvel/kaname/foundation/providers/SwaggerServiceProvider.ts b/backend-api/@frouvel/kaname/foundation/providers/SwaggerServiceProvider.ts index d2d3310..b3e05a3 100644 --- a/backend-api/@frouvel/kaname/foundation/providers/SwaggerServiceProvider.ts +++ b/backend-api/@frouvel/kaname/foundation/providers/SwaggerServiceProvider.ts @@ -27,7 +27,7 @@ export class SwaggerServiceProvider implements ServiceProvider { description: swaggerConfig.description, servers: swaggerConfig.servers, basePath: app.basePath(), - apiBasePath: process.env.API_BASE_PATH || '', + apiBasePath: appConfig.apiBasePath || '', tagDescriptions: swaggerConfig.tagDescriptions, }, app.basePath(), diff --git a/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts b/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts index 9d2ca9f..966551d 100644 --- a/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts +++ b/backend-api/@frouvel/kaname/testing/TestCaseIntegration.ts @@ -2,7 +2,7 @@ import { TestCaseDatabase } from './TestCaseDatabase'; import type { FastifyInstance } from 'fastify'; import app from '$/bootstrap/app'; import type { HttpKernel } from '$/@frouvel/kaname/foundation'; -import { env } from '$/env'; +import { config } from '$/@frouvel/kaname/config'; /** * TestCaseIntegration provides full integration testing with server and database @@ -16,7 +16,8 @@ export abstract class TestCaseIntegration extends TestCaseDatabase { constructor() { super(); // Use a different port for each test run to avoid conflicts - TestCaseIntegration.testPort = env.API_SERVER_PORT + 11; + const testingConfig = config('testing.server'); + TestCaseIntegration.testPort = testingConfig.port; this.baseURL = `http://localhost:${TestCaseIntegration.testPort}`; } diff --git a/backend-api/@frouvel/kaname/testing/setup.ts b/backend-api/@frouvel/kaname/testing/setup.ts index 9ca519f..767245d 100644 --- a/backend-api/@frouvel/kaname/testing/setup.ts +++ b/backend-api/@frouvel/kaname/testing/setup.ts @@ -3,7 +3,7 @@ import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; import util from 'util'; import { exec } from 'child_process'; import { getPrismaClient } from '$/@frouvel/kaname/database'; -import { env } from '$/env'; +import { config } from '$/@frouvel/kaname/config'; import app from '$/bootstrap/app'; import type { HttpKernel } from '$/@frouvel/kaname/foundation'; @@ -43,9 +43,9 @@ export interface TestEnvironmentOptions { export function setupTestEnvironment(options: TestEnvironmentOptions = {}) { const { startServer = true, - runMigrations = true, - refreshDatabase = true, - port = env.API_SERVER_PORT + 11, + runMigrations = config('testing.database.runMigrations'), + refreshDatabase = config('testing.database.refreshBeforeEach'), + port = config('testing.server.port'), seed, } = options; @@ -157,4 +157,4 @@ export function setupTestEnvironment(options: TestEnvironmentOptions = {}) { server, prisma, }; -} \ No newline at end of file +} diff --git a/backend-api/config/app.ts b/backend-api/config/app.ts index 890fe7e..7039285 100644 --- a/backend-api/config/app.ts +++ b/backend-api/config/app.ts @@ -14,6 +14,7 @@ export const appConfigSchema = z.object({ timezone: z.string(), locale: z.string(), fallbackLocale: z.string(), + apiBasePath: z.string().default(''), }); export type AppConfig = z.infer; @@ -26,4 +27,5 @@ export default appConfigSchema.parse({ timezone: process.env.TZ || 'UTC', locale: process.env.APP_LOCALE || 'en', fallbackLocale: process.env.APP_FALLBACK_LOCALE || 'en', -}); \ No newline at end of file + apiBasePath: process.env.API_BASE_PATH || '', +}); diff --git a/backend-api/config/testing.ts b/backend-api/config/testing.ts new file mode 100644 index 0000000..d39ed54 --- /dev/null +++ b/backend-api/config/testing.ts @@ -0,0 +1,32 @@ +/** + * Testing Configuration + * + * Centralizes testing-related settings so framework code does not read env directly. + */ + +import { z } from 'zod'; +import { env } from '../env'; + +export const testingConfigSchema = z.object({ + server: z.object({ + host: z.string().default('0.0.0.0'), + port: z.number(), + }), + database: z.object({ + runMigrations: z.boolean().default(true), + refreshBeforeEach: z.boolean().default(true), + }), +}); + +export type TestingConfig = z.infer; + +export default testingConfigSchema.parse({ + server: { + host: '0.0.0.0', + port: env.API_SERVER_PORT + 11, + }, + database: { + runMigrations: true, + refreshBeforeEach: true, + }, +}); From 260a172fae2c20c47f8a50bf9e86588f1028a2bf Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 02:16:38 +0900 Subject: [PATCH 26/28] feat(config): implement defineConfig helper for typed configuration management --- .../@frouvel/kaname/config/defineConfig.ts | 36 +++++++ backend-api/@frouvel/kaname/config/index.ts | 2 + backend-api/config/admin.ts | 33 ++++--- backend-api/config/app.ts | 47 +++++----- backend-api/config/cors.ts | 54 ++++++----- backend-api/config/database.ts | 94 +++++++++---------- backend-api/config/jwt.ts | 59 ++++++------ backend-api/config/swagger.ts | 81 ++++++++-------- backend-api/config/testing.ts | 42 +++++---- 9 files changed, 254 insertions(+), 194 deletions(-) create mode 100644 backend-api/@frouvel/kaname/config/defineConfig.ts diff --git a/backend-api/@frouvel/kaname/config/defineConfig.ts b/backend-api/@frouvel/kaname/config/defineConfig.ts new file mode 100644 index 0000000..65d875d --- /dev/null +++ b/backend-api/@frouvel/kaname/config/defineConfig.ts @@ -0,0 +1,36 @@ +import type { z } from 'zod'; + +type DefinedConfig = z.infer & { + schema: TSchema; +}; + +/** + * Shorthand helper to define typed configuration modules. + * Pass a Zod schema and a loader function that returns raw values. + * + * @example + * const config = defineConfig({ + * schema: z.object({ foo: z.string() }), + * load: () => ({ foo: process.env.FOO || 'bar' }), + * }); + * + * export type FooConfig = ConfigType; + * export const fooConfigSchema = config.schema; + * export default config; + */ +export function defineConfig(options: { + schema: TSchema; + load: () => z.input; +}): DefinedConfig { + const parsed = options.schema.parse(options.load()); + return Object.assign(parsed, { schema: options.schema }); +} + +/** + * Extract the inferred config type from a defineConfig() result. + */ +export type ConfigType = T extends { + schema: infer S extends z.ZodTypeAny; +} + ? z.infer + : never; diff --git a/backend-api/@frouvel/kaname/config/index.ts b/backend-api/@frouvel/kaname/config/index.ts index 0c50e52..495b410 100644 --- a/backend-api/@frouvel/kaname/config/index.ts +++ b/backend-api/@frouvel/kaname/config/index.ts @@ -5,6 +5,8 @@ */ export { config, hasConfig, configAll, configObject } from './config'; +export { defineConfig } from './defineConfig'; +export type { ConfigType } from './defineConfig'; // Re-export types from user-space configuration export type { diff --git a/backend-api/config/admin.ts b/backend-api/config/admin.ts index ae6ce2a..fddddc6 100644 --- a/backend-api/config/admin.ts +++ b/backend-api/config/admin.ts @@ -1,23 +1,28 @@ /** * Admin Configuration - * + * * Configuration for admin user credentials and settings. */ import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; +import { env } from '../env'; -export const adminConfigSchema = z.object({ - email: z.string().email(), - password: z.string().min(1), - sessionTimeout: z.number().positive(), - tokenExpiration: z.number().positive(), +const adminConfig = defineConfig({ + schema: z.object({ + email: z.string().email(), + password: z.string().min(1), + sessionTimeout: z.number().positive(), + tokenExpiration: z.number().positive(), + }), + load: () => ({ + email: env.ADMIN_EMAIL, + password: env.ADMIN_PASSWORD, + sessionTimeout: env.ADMIN_SESSION_TIMEOUT, + tokenExpiration: env.ADMIN_TOKEN_EXPIRATION, + }), }); -export type AdminConfig = z.infer; - -export default adminConfigSchema.parse({ - email: process.env.ADMIN_EMAIL || 'admin@frourio-framework.com', - password: process.env.ADMIN_PASSWORD || 'Qwerty1!', - sessionTimeout: Number(process.env.ADMIN_SESSION_TIMEOUT || 3600), - tokenExpiration: Number(process.env.ADMIN_TOKEN_EXPIRATION || 86400), -}); \ No newline at end of file +export type AdminConfig = ConfigType; +export const adminConfigSchema = adminConfig.schema; +export default adminConfig; diff --git a/backend-api/config/app.ts b/backend-api/config/app.ts index 7039285..1136612 100644 --- a/backend-api/config/app.ts +++ b/backend-api/config/app.ts @@ -5,27 +5,32 @@ */ import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; +import { env } from '../env'; -export const appConfigSchema = z.object({ - name: z.string(), - env: z.enum(['development', 'production', 'test']), - debug: z.boolean(), - url: z.string().url(), - timezone: z.string(), - locale: z.string(), - fallbackLocale: z.string(), - apiBasePath: z.string().default(''), +const appConfig = defineConfig({ + schema: z.object({ + name: z.string(), + env: z.enum(['development', 'production', 'test']), + debug: z.boolean(), + url: z.string().url(), + timezone: z.string(), + locale: z.string(), + fallbackLocale: z.string(), + apiBasePath: z.string().default(''), + }), + load: () => ({ + name: env.APP_NAME, + env: env.NODE_ENV, + debug: env.APP_DEBUG, + url: env.APP_URL, + timezone: env.TZ, + locale: env.APP_LOCALE, + fallbackLocale: env.APP_FALLBACK_LOCALE, + apiBasePath: env.API_BASE_PATH, + }), }); -export type AppConfig = z.infer; - -export default appConfigSchema.parse({ - name: process.env.APP_NAME || 'Frourio Framework', - env: process.env.NODE_ENV || 'development', - debug: String(process.env.APP_DEBUG) === 'true', - url: process.env.APP_URL || 'http://localhost:8080', - timezone: process.env.TZ || 'UTC', - locale: process.env.APP_LOCALE || 'en', - fallbackLocale: process.env.APP_FALLBACK_LOCALE || 'en', - apiBasePath: process.env.API_BASE_PATH || '', -}); +export type AppConfig = ConfigType; +export const appConfigSchema = appConfig.schema; +export default appConfig; diff --git a/backend-api/config/cors.ts b/backend-api/config/cors.ts index e927558..a4f95d2 100644 --- a/backend-api/config/cors.ts +++ b/backend-api/config/cors.ts @@ -5,38 +5,44 @@ */ import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; +import { env } from '../env'; -export const corsConfigSchema = z.object({ - origins: z.array(z.string()), - methods: z.array(z.string()), - allowedHeaders: z.array(z.string()), - exposedHeaders: z.array(z.string()), - credentials: z.boolean(), - maxAge: z.number().positive(), +const corsConfig = defineConfig({ + schema: z.object({ + origins: z.array(z.string()), + methods: z.array(z.string()), + allowedHeaders: z.array(z.string()), + exposedHeaders: z.array(z.string()), + credentials: z.boolean(), + maxAge: z.number().positive(), + }), + load: () => ({ + origins: CORS_ORIGINS, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + ], + exposedHeaders: ['X-Total-Count'], + credentials: true, + maxAge: 86400, + }), }); -export type CorsConfig = z.infer; +export type CorsConfig = ConfigType; +export const corsConfigSchema = corsConfig.schema; // Helper to get CORS origins for backward compatibility export const CORS_ORIGINS = [ - process.env.WEB_FRONTEND_URL || 'http://localhost:3000', - ...(process.env.CORS_ADDITIONAL_ORIGINS - ? process.env.CORS_ADDITIONAL_ORIGINS.split(',') + env.WEB_FRONTEND_URL, + ...(env.CORS_ADDITIONAL_ORIGINS + ? env.CORS_ADDITIONAL_ORIGINS.split(',') .map((o) => o.trim()) .filter(Boolean) : []), ]; -export default corsConfigSchema.parse({ - origins: CORS_ORIGINS, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'X-Requested-With', - 'Accept', - ], - exposedHeaders: ['X-Total-Count'], - credentials: true, - maxAge: 86400, -}); +export default corsConfig; diff --git a/backend-api/config/database.ts b/backend-api/config/database.ts index 10959cd..16b941e 100644 --- a/backend-api/config/database.ts +++ b/backend-api/config/database.ts @@ -1,57 +1,49 @@ -import type { DatabaseConfig } from '../@frouvel/kaname/database'; +import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; import { env } from '../env'; -/** - * Database Configuration - * - * Configure database connections for your application. - * Supports both Prisma and Drizzle ORM. - */ -const databaseConfig = { - /** - * Default database connection - */ - default: 'default', - - /** - * Database connections - */ - connections: { - /** - * Default Prisma connection - */ - default: { - driver: 'prisma', - url: env.DATABASE_URL, - pool: { - min: env.DB_POOL_MIN, - max: env.DB_POOL_MAX, +const databaseConfig = defineConfig({ + schema: z.object({ + default: z.string(), + connections: z.record( + z.object({ + driver: z.string(), + url: z.string().optional(), + connection: z + .object({ + host: z.string(), + port: z.number(), + user: z.string(), + password: z.string(), + database: z.string(), + }) + .optional(), + pool: z + .object({ + min: z.number().optional(), + max: z.number().optional(), + idleTimeoutMillis: z.number().optional(), + }) + .optional(), + }), + ), + }), + load: () => ({ + default: 'default', + connections: { + default: { + driver: 'prisma', + url: env.DATABASE_URL, + pool: { + min: env.DB_POOL_MIN, + max: env.DB_POOL_MAX, + }, }, + // Add additional connections here (e.g., read replicas or Drizzle) }, + }), +}); - // Example: Read replica (uncomment to use) - // 'read-replica': { - // driver: 'prisma', - // url: env.READ_REPLICA_URL, - // pool: { - // min: 2, - // max: 5, - // }, - // }, - - // Example: Drizzle connection (uncomment to use) - // analytics: { - // driver: 'drizzle', - // connection: { - // host: env.ANALYTICS_DB_HOST, - // port: env.ANALYTICS_DB_PORT, - // user: env.ANALYTICS_DB_USER, - // password: env.ANALYTICS_DB_PASSWORD, - // database: env.ANALYTICS_DB_DATABASE, - // }, - // }, - }, -} satisfies DatabaseConfig; - +export type DatabaseConfig = ConfigType; +export const databaseConfigSchema = databaseConfig.schema; export default databaseConfig; -export type { DatabaseConfig }; \ No newline at end of file diff --git a/backend-api/config/jwt.ts b/backend-api/config/jwt.ts index 865d8ec..f8cde02 100644 --- a/backend-api/config/jwt.ts +++ b/backend-api/config/jwt.ts @@ -5,35 +5,40 @@ */ import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; +import { env } from '../env'; -export const jwtConfigSchema = z.object({ - secret: z.string().min(1), - expiresIn: z.number().positive(), - refreshExpiresIn: z.number().positive(), - scope: z.object({ - admin: z.tuple([z.literal('admin')]), - user: z.object({ - default: z.tuple([z.literal('user')]), +const jwtConfig = defineConfig({ + schema: z.object({ + secret: z.string().min(1), + expiresIn: z.number().positive(), + refreshExpiresIn: z.number().positive(), + scope: z.object({ + admin: z.tuple([z.literal('admin')]), + user: z.object({ + default: z.tuple([z.literal('user')]), + }), }), + algorithm: z.literal('HS256'), + issuer: z.string(), + audience: z.string(), + }), + load: () => ({ + secret: env.API_JWT_SECRET, + expiresIn: env.JWT_EXPIRES_IN, + refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN, + scope: { + admin: ['admin'] as ['admin'], + user: { + default: ['user'] as ['user'], + }, + }, + algorithm: 'HS256' as const, + issuer: env.JWT_ISSUER, + audience: env.JWT_AUDIENCE, }), - algorithm: z.literal('HS256'), - issuer: z.string(), - audience: z.string(), }); -export type JwtConfig = z.infer; - -export default jwtConfigSchema.parse({ - secret: process.env.API_JWT_SECRET || 'your-secret-key-change-this-in-production', - expiresIn: Number(process.env.JWT_EXPIRES_IN || 86400), - refreshExpiresIn: Number(process.env.JWT_REFRESH_EXPIRES_IN || 604800), - scope: { - admin: ['admin'] as const, - user: { - default: ['user'] as const, - }, - }, - algorithm: 'HS256' as const, - issuer: process.env.JWT_ISSUER || 'frourio-framework', - audience: process.env.JWT_AUDIENCE || 'frourio-framework-api', -}); \ No newline at end of file +export type JwtConfig = ConfigType; +export const jwtConfigSchema = jwtConfig.schema; +export default jwtConfig; diff --git a/backend-api/config/swagger.ts b/backend-api/config/swagger.ts index fff2a71..8f285ec 100644 --- a/backend-api/config/swagger.ts +++ b/backend-api/config/swagger.ts @@ -5,25 +5,8 @@ */ import { z } from 'zod'; - -const swaggerConfigSchema = z.object({ - enabled: z.boolean(), - path: z.string(), - title: z.string(), - version: z.string(), - description: z.string().optional(), - servers: z - .array( - z.object({ - url: z.string(), - description: z.string().optional(), - }), - ) - .optional(), - tagDescriptions: z.record(z.string(), z.string()).optional(), -}); - -export type SwaggerConfig = z.infer; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; +import { env } from '../env'; /** * Tag descriptions for API grouping @@ -37,24 +20,46 @@ const tagDescriptions: Record = { Example: 'Example endpoints for testing', }; -const isEnabled = (() => { - if (process.env.SWAGGER_ENABLED !== undefined) { - return String(process.env.SWAGGER_ENABLED).toLowerCase() === 'true'; - } - return process.env.NODE_ENV !== 'production'; -})(); +const swaggerConfig = defineConfig({ + schema: z.object({ + enabled: z.boolean(), + path: z.string(), + title: z.string(), + version: z.string(), + description: z.string().optional(), + servers: z + .array( + z.object({ + url: z.string(), + description: z.string().optional(), + }), + ) + .optional(), + tagDescriptions: z.record(z.string(), z.string()).optional(), + }), + load: () => { + const enabled = + env.SWAGGER_ENABLED !== undefined + ? env.SWAGGER_ENABLED + : env.NODE_ENV !== 'production'; -export default swaggerConfigSchema.parse({ - enabled: isEnabled, - path: process.env.SWAGGER_PATH || '/api-docs', - title: process.env.SWAGGER_TITLE || process.env.APP_NAME || 'API', - version: process.env.SWAGGER_VERSION || '1.0.0', - description: process.env.SWAGGER_DESCRIPTION || 'API Documentation', - servers: [ - { - url: process.env.APP_URL || 'http://localhost:31577', - description: process.env.NODE_ENV === 'production' ? 'Production' : 'Development', - }, - ], - tagDescriptions, + return { + enabled, + path: env.SWAGGER_PATH || '/api-docs', + title: env.SWAGGER_TITLE || env.APP_NAME || 'API', + version: env.SWAGGER_VERSION || '1.0.0', + description: env.SWAGGER_DESCRIPTION || 'API Documentation', + servers: [ + { + url: env.APP_URL, + description: env.NODE_ENV === 'production' ? 'Production' : 'Development', + }, + ], + tagDescriptions, + }; + }, }); + +export type SwaggerConfig = ConfigType; +export const swaggerConfigSchema = swaggerConfig.schema; +export default swaggerConfig; diff --git a/backend-api/config/testing.ts b/backend-api/config/testing.ts index d39ed54..85e2ba2 100644 --- a/backend-api/config/testing.ts +++ b/backend-api/config/testing.ts @@ -5,28 +5,32 @@ */ import { z } from 'zod'; +import { defineConfig, type ConfigType } from '$/@frouvel/kaname/config'; import { env } from '../env'; -export const testingConfigSchema = z.object({ - server: z.object({ - host: z.string().default('0.0.0.0'), - port: z.number(), +const testingConfig = defineConfig({ + schema: z.object({ + server: z.object({ + host: z.string().default('0.0.0.0'), + port: z.number(), + }), + database: z.object({ + runMigrations: z.boolean().default(true), + refreshBeforeEach: z.boolean().default(true), + }), }), - database: z.object({ - runMigrations: z.boolean().default(true), - refreshBeforeEach: z.boolean().default(true), + load: () => ({ + server: { + host: '0.0.0.0', + port: env.API_SERVER_PORT + 11, + }, + database: { + runMigrations: true, + refreshBeforeEach: true, + }, }), }); -export type TestingConfig = z.infer; - -export default testingConfigSchema.parse({ - server: { - host: '0.0.0.0', - port: env.API_SERVER_PORT + 11, - }, - database: { - runMigrations: true, - refreshBeforeEach: true, - }, -}); +export type TestingConfig = ConfigType; +export const testingConfigSchema = testingConfig.schema; +export default testingConfig; From 989104a1cbe232a50f64345d74c5edc2a5a578b4 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 02:38:32 +0900 Subject: [PATCH 27/28] refactor(ApiResponse): simplify error status handling in returnProblemDetails function --- backend-api/@frouvel/kaname/http/ApiResponse.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend-api/@frouvel/kaname/http/ApiResponse.ts b/backend-api/@frouvel/kaname/http/ApiResponse.ts index 0eb078c..6ea202c 100644 --- a/backend-api/@frouvel/kaname/http/ApiResponse.ts +++ b/backend-api/@frouvel/kaname/http/ApiResponse.ts @@ -16,7 +16,7 @@ import { DEFAULT_PROBLEM_TYPE } from './type/nfc9457'; import { AbstractFrourioFrameworkError } from '../error/FrourioFrameworkError'; // Align with Frourio's generated HttpStatusNoOk union in $server.ts -type ErrorStatus = +type HttpStatusNoOk = | 301 | 302 | 400 @@ -112,11 +112,11 @@ const returnSuccess = (val: T) => ({ */ function returnProblemDetails( error: unknown, - defaultStatus: ErrorStatus = 500, + defaultStatus: HttpStatusNoOk = 500, ) { const problemDetails = errorToProblemDetails(error); const status = - (problemDetails.status as ErrorStatus | undefined) ?? defaultStatus; + (problemDetails.status as HttpStatusNoOk | undefined) ?? defaultStatus; return { status, From 031f828f5127e91670e1ce0d22c61aaecebbc923 Mon Sep 17 00:00:00 2001 From: mikana0918 Date: Fri, 21 Nov 2025 02:47:03 +0900 Subject: [PATCH 28/28] feat(ci): add API_JWT_SECRET environment variable for CI jobs --- .github/workflows/ci-frourio.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci-frourio.yml b/.github/workflows/ci-frourio.yml index 8a1f377..ad777b2 100644 --- a/.github/workflows/ci-frourio.yml +++ b/.github/workflows/ci-frourio.yml @@ -12,6 +12,7 @@ jobs: API_SERVER_PORT: 8080 API_ORIGIN: http://localhost:8080 API_BASE_PATH: /api + API_JWT_SECRET: test_secret DATABASE_URL: postgresql://root:test@localhost:5432/test TEST_DATABASE_URL: postgresql://root:test@localhost:5432/test services: @@ -67,5 +68,3 @@ jobs: cd backend-api npm run migrate:dev npm run test - env: - API_JWT_SECRET: test_secret