📝 docs: add comprehensive Claude Code guidelines

Add detailed documentation for Claude Code in .claude/ directory:
- code-style.md: formatting, naming, linting rules
- database.md: Drizzle ORM patterns and schema conventions
- testing.md: Jest/Vitest patterns with mock factories
- nestjs-backend.md: controller, service, DTO patterns
- error-handling.md: Go-style Result types and error codes
- sveltekit-web.md: Svelte 5 runes and store patterns
- expo-mobile.md: React Native with NativeWind
- authentication.md: Mana Core Auth integration

Update root CLAUDE.md to reference new guidelines
This commit is contained in:
Wuesteon 2025-12-03 00:44:49 +01:00
parent 2154c3b37a
commit 0b539bde6b
10 changed files with 4971 additions and 0 deletions

View file

@ -0,0 +1,592 @@
# Error Handling Guidelines
## Philosophy: Go-Style Error Handling
We use **explicit error handling** inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return `Result<T>` types that force callers to handle errors explicitly.
### Why?
1. **Explicit over implicit** - Errors are part of the function signature
2. **No surprise exceptions** - You know exactly what can fail
3. **Consistent error codes** - Same codes across frontend and backend
4. **Better error messages** - Structured errors with codes and context
## Package: @manacore/shared-errors
The error handling system is implemented in `packages/shared-errors/`. Import from it:
```typescript
import {
// Result type and helpers
Result, AsyncResult, ok, err, isOk, isErr,
unwrap, unwrapOr, map, andThen, match,
tryCatch, tryCatchAsync, combine,
// Error codes
ErrorCode, ERROR_CODE_TO_HTTP_STATUS,
// Error classes
AppError, ValidationError, NotFoundError,
AuthError, CreditError, ServiceError,
RateLimitError, NetworkError, DatabaseError,
// Type guards
isAppError, isValidationError, isNotFoundError,
hasErrorCode, isRetryable, getHttpStatus,
// Utilities
wrap, toAppError,
} from '@manacore/shared-errors';
```
## Core Types
### Result Type
```typescript
// Result represents success or failure
export type Result<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Async version for async functions
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
// Create success result
const user = ok({ id: '123', name: 'John' });
// Create failure result
const error = err(new NotFoundError('User', userId));
```
### Error Classes
```typescript
// Base error class
class AppError extends Error {
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
}
// Specialized error classes
ValidationError.invalidInput('email', 'must be valid email');
NotFoundError.user(userId);
AuthError.tokenExpired();
CreditError.insufficient(required, available);
ServiceError.generation('AI generation failed');
RateLimitError.exceeded(retryAfter);
NetworkError.timeout();
DatabaseError.constraint('unique_email');
```
## Error Codes
All error codes are defined in `@manacore/shared-errors`:
```typescript
export enum ErrorCode {
// Validation (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Authentication (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authorization (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Not Found (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
```
### HTTP Status Mapping
```typescript
import { ERROR_CODE_TO_HTTP_STATUS, getHttpStatus } from '@manacore/shared-errors';
// Get HTTP status for an error code
const status = ERROR_CODE_TO_HTTP_STATUS[ErrorCode.RESOURCE_NOT_FOUND]; // 404
// Or use helper function
const status = getHttpStatus(error); // Returns appropriate HTTP status
```
### Retryable Errors
```typescript
import { isRetryable } from '@manacore/shared-errors';
// Check if an error is worth retrying
if (isRetryable(error)) {
await delay(1000);
return retry(operation);
}
```
## Backend Usage
### Service Layer
```typescript
// src/files/file.service.ts
import { Injectable, Inject } from '@nestjs/common';
import {
AsyncResult, ok, err, isOk,
NotFoundError, DatabaseError, ValidationError
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
@Injectable()
export class FileService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findById(id: string, userId: string): AsyncResult<File> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(
eq(files.id, id),
eq(files.userId, userId),
eq(files.isDeleted, false)
));
if (!file) {
return err(new NotFoundError('File', id));
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
async create(userId: string, dto: CreateFileDto): AsyncResult<File> {
// Validation
if (!dto.name?.trim()) {
return err(ValidationError.required('name'));
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
// Handle unique constraint violations
if (error.code === '23505') {
return err(DatabaseError.constraint('A file with this name already exists'));
}
return err(DatabaseError.query('Failed to create file', error));
}
}
async delete(id: string, userId: string): AsyncResult<void> {
const fileResult = await this.findById(id, userId);
if (!isOk(fileResult)) {
return fileResult; // Propagate error
}
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
}
```
### Controller Layer
```typescript
// src/files/file.controller.ts
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { isOk, unwrap } from '@manacore/shared-errors';
import { FileService } from './file.service';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get(':id')
async getFile(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.findById(id, user.userId);
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
return { file: result.value };
}
@Post()
async createFile(
@Body() dto: CreateFileDto,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.create(user.userId, dto);
if (!isOk(result)) {
throw result.error;
}
return { file: result.value };
}
@Delete(':id')
async deleteFile(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
) {
// Alternative: use unwrap() which throws on error
unwrap(await this.fileService.delete(id, user.userId));
return { success: true };
}
}
```
### Exception Filter
The package provides a ready-to-use exception filter:
```typescript
// In main.ts or app.module.ts
import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
// Apply globally
app.useGlobalFilters(new AppExceptionFilter());
```
The filter automatically:
- Maps `ErrorCode` to HTTP status codes
- Returns consistent JSON error format
- Logs server errors (5xx)
Custom filter example:
```typescript
// src/common/filters/app-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('AppException');
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
// Log server errors
if (status >= 500) {
this.logger.error(exception.message, exception.stack);
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
}
```
## Frontend Usage
### API Client
```typescript
// lib/api/client.ts
import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
try {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? {
code: ErrorCode.UNKNOWN_ERROR,
message: 'Request failed',
},
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed');
}
}
// Typed API methods
export const api = {
files: {
get: (id: string) => apiRequest<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) => apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
};
```
### Component Usage (Svelte 5)
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter(f => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
```
### Component Usage (React Native)
```typescript
// hooks/useFiles.ts
import { useState, useCallback } from 'react';
import { api } from '../services/api';
import { ErrorCode, Result, AppError } from '@manacore/shared-errors';
export function useFiles() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const result = await api.files.list();
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
setLoading(false);
}, []);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (!result.ok) {
return false;
}
setFiles(prev => prev.filter(f => f.id !== id));
return true;
}, []);
return { files, loading, error, loadFiles, deleteFile };
}
```
## Error Chaining
### Wrapping Errors with Context
```typescript
async function processUpload(userId: string, file: File): Promise<Result<FileRecord>> {
// Validate file
const validationResult = validateFile(file);
if (!validationResult.ok) {
return validationResult; // Return validation error as-is
}
// Upload to storage
const uploadResult = await storageService.upload(file);
if (!uploadResult.ok) {
// Add context to storage error
return err(
ErrorCode.UPLOAD_FAILED,
`Failed to upload file: ${uploadResult.error.message}`,
{ originalError: uploadResult.error }
);
}
// Save to database
const saveResult = await fileService.create(userId, {
name: file.name,
storagePath: uploadResult.data.path,
});
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
return saveResult;
}
```
## Logging Errors
```typescript
import { Logger } from '@nestjs/common';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
try {
// ... operation
} catch (error) {
// Log full error for debugging
this.logger.error('Failed to create file', {
userId,
fileName: dto.name,
error: error.message,
stack: error.stack,
});
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
}
```
## Best Practices
### Do's
1. **Always check result.ok before accessing data**
2. **Use specific error codes** rather than generic ones
3. **Include helpful messages** for debugging
4. **Log errors at the service layer**
5. **Return early on errors** to avoid nested conditions
### Don'ts
1. **Don't throw exceptions in services** - use Result instead
2. **Don't expose internal error details** to users
3. **Don't use try-catch for flow control**
4. **Don't ignore error results** - always handle them
5. **Don't use string error codes** - use the ErrorCode enum