add mana core

This commit is contained in:
Wuesteon 2025-11-25 18:56:35 +01:00
parent ce71db2fc0
commit 754e87ebc0
112 changed files with 34765 additions and 548 deletions

View file

@ -0,0 +1,179 @@
import {
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
ERROR_CODE_RETRYABLE,
} from '../types/error-codes';
/**
* Additional context that can be attached to errors.
*/
export interface ErrorContext {
[key: string]: unknown;
}
/**
* Options for creating an AppError.
*/
export interface AppErrorOptions {
code: ErrorCode;
message: string;
cause?: Error | AppError;
context?: ErrorContext;
httpStatus?: number;
retryable?: boolean;
}
/**
* Base error class for all application errors.
*
* Follows Go-like error handling principles:
* - Errors are values, not exceptions
* - Support for error wrapping with context
* - Type-safe error checking
*
* @example
* ```typescript
* // Create a basic error
* const error = new AppError({
* code: ErrorCode.VALIDATION_FAILED,
* message: 'Invalid email format',
* });
*
* // Wrap an error with context (Go-like)
* const wrapped = error.wrap('validating user input');
* // Message becomes: "validating user input: Invalid email format"
*
* // Check error codes (like Go's errors.Is)
* if (error.hasCode(ErrorCode.VALIDATION_FAILED)) {
* // Handle validation error
* }
* ```
*/
export class AppError extends Error {
/** Standardized error code */
readonly code: ErrorCode;
/** HTTP status code for API responses */
readonly httpStatus: number;
/** Whether the operation can be retried */
readonly retryable: boolean;
/** Original error that caused this error (for wrapping) */
readonly cause?: Error | AppError;
/** Additional context information */
readonly context: ErrorContext;
/** Timestamp when error was created */
readonly timestamp: string;
constructor(options: AppErrorOptions) {
super(options.message);
this.name = 'AppError';
this.code = options.code;
this.cause = options.cause;
this.context = options.context ?? {};
this.timestamp = new Date().toISOString();
// Use provided values or defaults from mappings
this.httpStatus =
options.httpStatus ?? ERROR_CODE_TO_HTTP_STATUS[options.code];
this.retryable = options.retryable ?? ERROR_CODE_RETRYABLE[options.code];
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
}
/**
* Create a wrapped error with additional context.
* Similar to Go's `fmt.Errorf("context: %w", err)`.
*
* @param contextMessage - Description of the operation that failed
* @param additionalContext - Extra context data to include
* @returns A new AppError with the original as its cause
*
* @example
* ```typescript
* const wrapped = originalError.wrap('fetching user data');
* // Message: "fetching user data: original message"
* ```
*/
wrap(contextMessage: string, additionalContext?: ErrorContext): AppError {
return new AppError({
code: this.code,
message: `${contextMessage}: ${this.message}`,
cause: this,
context: { ...this.context, ...additionalContext },
httpStatus: this.httpStatus,
retryable: this.retryable,
});
}
/**
* Get the root cause of the error chain.
* Traverses the cause chain to find the original error.
*/
rootCause(): Error {
let current: Error = this;
while (current instanceof AppError && current.cause) {
current = current.cause;
}
return current;
}
/**
* Check if this error or any in the chain has the given code.
* Similar to Go's `errors.Is()`.
*
* @param code - The error code to check for
* @returns true if this error or any cause has the given code
*
* @example
* ```typescript
* if (error.hasCode(ErrorCode.INSUFFICIENT_CREDITS)) {
* // Show upgrade prompt
* }
* ```
*/
hasCode(code: ErrorCode): boolean {
let current: Error | undefined = this;
while (current) {
if (current instanceof AppError && current.code === code) {
return true;
}
current = current instanceof AppError ? current.cause : undefined;
}
return false;
}
/**
* Convert to JSON for API responses.
* Excludes stack traces and internal details.
*/
toJSON(): Record<string, unknown> {
return {
code: this.code,
message: this.message,
httpStatus: this.httpStatus,
retryable: this.retryable,
timestamp: this.timestamp,
...(Object.keys(this.context).length > 0 && { details: this.context }),
};
}
/**
* Convert to full JSON including stack and cause (for logging).
* Use this for server-side logging, not client responses.
*/
toFullJSON(): Record<string, unknown> {
return {
...this.toJSON(),
stack: this.stack,
cause:
this.cause instanceof AppError
? this.cause.toFullJSON()
: this.cause?.message,
};
}
}

View file

@ -0,0 +1,79 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type AuthErrorCode =
| ErrorCode.AUTHENTICATION_REQUIRED
| ErrorCode.INVALID_TOKEN
| ErrorCode.TOKEN_EXPIRED
| ErrorCode.PERMISSION_DENIED
| ErrorCode.RESOURCE_NOT_OWNED;
/**
* Error for authentication and authorization failures.
* HTTP Status: 401 (auth) or 403 (authorization)
*
* @example
* ```typescript
* // Authentication errors (401)
* return err(AuthError.unauthorized());
* return err(AuthError.invalidToken('Token has been revoked'));
* return err(AuthError.tokenExpired());
*
* // Authorization errors (403)
* return err(AuthError.forbidden('Admin access required'));
* return err(AuthError.notOwned('Story', storyId));
* ```
*/
export class AuthError extends AppError {
constructor(code: AuthErrorCode, message: string, context?: ErrorContext) {
super({ code, message, context });
this.name = 'AuthError';
}
/**
* Create an error for missing authentication.
* HTTP 401 Unauthorized
*/
static unauthorized(message = 'Authentication required'): AuthError {
return new AuthError(ErrorCode.AUTHENTICATION_REQUIRED, message);
}
/**
* Create an error for an invalid token.
* HTTP 401 Unauthorized
*/
static invalidToken(message = 'Invalid or malformed token'): AuthError {
return new AuthError(ErrorCode.INVALID_TOKEN, message);
}
/**
* Create an error for an expired token.
* HTTP 401 Unauthorized
*/
static tokenExpired(message = 'Token has expired'): AuthError {
return new AuthError(ErrorCode.TOKEN_EXPIRED, message);
}
/**
* Create an error for insufficient permissions.
* HTTP 403 Forbidden
*/
static forbidden(message = 'Permission denied'): AuthError {
return new AuthError(ErrorCode.PERMISSION_DENIED, message);
}
/**
* Create an error when a user tries to access a resource they don't own.
* HTTP 403 Forbidden
*
* @param resourceType - Type of resource (e.g., 'Story', 'Character')
* @param resourceId - ID of the resource
*/
static notOwned(resourceType: string, resourceId: string): AuthError {
return new AuthError(
ErrorCode.RESOURCE_NOT_OWNED,
`${resourceType} does not belong to you`,
{ resourceType, resourceId }
);
}
}

View file

@ -0,0 +1,35 @@
import { ErrorCode } from '../types/error-codes';
import { AppError } from './app-error';
/**
* Error for insufficient credits/mana.
* HTTP Status: 402 Payment Required
*
* @example
* ```typescript
* return err(new CreditError(100, 50, 'story_generation'));
* // Message: "Insufficient credits. Required: 100, Available: 50"
* ```
*/
export class CreditError extends AppError {
/** Credits required for the operation */
readonly requiredCredits: number;
/** Credits currently available */
readonly availableCredits: number;
constructor(
requiredCredits: number,
availableCredits: number,
operation?: string
) {
super({
code: ErrorCode.INSUFFICIENT_CREDITS,
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${availableCredits}`,
context: { requiredCredits, availableCredits, operation },
});
this.name = 'CreditError';
this.requiredCredits = requiredCredits;
this.availableCredits = availableCredits;
}
}

View file

@ -0,0 +1,54 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type DatabaseErrorCode = ErrorCode.DATABASE_ERROR | ErrorCode.CONSTRAINT_VIOLATION;
/**
* Error for database-level failures.
* HTTP Status: 500 (database), 409 (constraint violation)
*
* @example
* ```typescript
* // Constraint violation (e.g., unique constraint)
* return err(DatabaseError.constraintViolation('email', 'Email already exists'));
*
* // Generic database error
* return err(DatabaseError.queryFailed('Failed to fetch user data', originalError));
* ```
*/
export class DatabaseError extends AppError {
constructor(
code: DatabaseErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'DatabaseError';
}
/**
* Create a constraint violation error (e.g., unique constraint).
*
* @param field - The field that violated the constraint
* @param message - Description of the violation
*/
static constraintViolation(field: string, message: string): DatabaseError {
return new DatabaseError(
ErrorCode.CONSTRAINT_VIOLATION,
message,
undefined,
{ field }
);
}
/**
* Create a generic database query error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static queryFailed(message: string, cause?: Error): DatabaseError {
return new DatabaseError(ErrorCode.DATABASE_ERROR, message, cause);
}
}

View file

@ -0,0 +1,9 @@
export { AppError, type ErrorContext, type AppErrorOptions } from './app-error';
export { ValidationError } from './validation-error';
export { AuthError } from './auth-error';
export { NotFoundError } from './not-found-error';
export { CreditError } from './credit-error';
export { ServiceError } from './service-error';
export { RateLimitError } from './rate-limit-error';
export { NetworkError } from './network-error';
export { DatabaseError } from './database-error';

View file

@ -0,0 +1,63 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type NetworkErrorCode =
| ErrorCode.NETWORK_ERROR
| ErrorCode.TIMEOUT
| ErrorCode.CONNECTION_REFUSED;
/**
* Error for network-level failures (timeouts, connection issues, etc.).
* HTTP Status: 502 (gateway), 503 (connection refused), 504 (timeout)
*
* @example
* ```typescript
* // Timeout
* return err(NetworkError.timeout('Fetching user profile'));
*
* // Connection refused
* return err(NetworkError.connectionRefused('Database'));
*
* // Generic network error
* return err(new NetworkError(ErrorCode.NETWORK_ERROR, 'DNS resolution failed'));
* ```
*/
export class NetworkError extends AppError {
constructor(
code: NetworkErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'NetworkError';
}
/**
* Create a timeout error.
*
* @param operation - Description of the operation that timed out
*/
static timeout(operation: string): NetworkError {
return new NetworkError(
ErrorCode.TIMEOUT,
`Operation timed out: ${operation}`,
undefined,
{ operation }
);
}
/**
* Create a connection refused error.
*
* @param service - Name of the service that refused connection
*/
static connectionRefused(service: string): NetworkError {
return new NetworkError(
ErrorCode.CONNECTION_REFUSED,
`Connection refused: ${service}`,
undefined,
{ service }
);
}
}

View file

@ -0,0 +1,45 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
/**
* Error for when a requested resource is not found.
* HTTP Status: 404 Not Found
*
* @example
* ```typescript
* // Generic resource not found
* return err(new NotFoundError('User', userId));
*
* // Using factory methods
* return err(NotFoundError.user(userId));
* return err(NotFoundError.resource('Story', storyId));
* ```
*/
export class NotFoundError extends AppError {
constructor(
resourceType: string,
identifier: string,
context?: ErrorContext
) {
super({
code: ErrorCode.RESOURCE_NOT_FOUND,
message: `${resourceType} not found: ${identifier}`,
context: { resourceType, identifier, ...context },
});
this.name = 'NotFoundError';
}
/**
* Create a not found error for a user.
*/
static user(userId: string): NotFoundError {
return new NotFoundError('User', userId);
}
/**
* Create a not found error for any resource type.
*/
static resource(resourceType: string, identifier: string): NotFoundError {
return new NotFoundError(resourceType, identifier);
}
}

View file

@ -0,0 +1,31 @@
import { ErrorCode } from '../types/error-codes';
import { AppError } from './app-error';
/**
* Error for rate limiting.
* HTTP Status: 429 Too Many Requests
*
* @example
* ```typescript
* // Basic rate limit error
* return err(new RateLimitError());
*
* // With retry-after information
* return err(new RateLimitError('Too many requests', 60));
* // Client should wait 60 seconds before retrying
* ```
*/
export class RateLimitError extends AppError {
/** Seconds to wait before retrying (if known) */
readonly retryAfter?: number;
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
super({
code: ErrorCode.RATE_LIMIT_EXCEEDED,
message,
context: retryAfter ? { retryAfter } : {},
});
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}

View file

@ -0,0 +1,103 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type ServiceErrorCode =
| ErrorCode.INTERNAL_ERROR
| ErrorCode.SERVICE_UNAVAILABLE
| ErrorCode.GENERATION_FAILED
| ErrorCode.EXTERNAL_SERVICE_ERROR;
/**
* Error for service-level failures (internal errors, external API failures, etc.).
* HTTP Status: 500 (internal), 502 (external), 503 (unavailable)
*
* @example
* ```typescript
* // AI generation failed
* return err(ServiceError.generationFailed('OpenAI', 'Rate limit exceeded', originalError));
*
* // External service unavailable
* return err(ServiceError.unavailable('Payment Service'));
*
* // External API error
* return err(ServiceError.externalError('Stripe', 'Card declined'));
*
* // Internal error
* return err(ServiceError.internal('Failed to process request'));
* ```
*/
export class ServiceError extends AppError {
constructor(
code: ServiceErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'ServiceError';
}
/**
* Create an error for AI/content generation failures.
*
* @param service - Name of the service (e.g., 'OpenAI', 'Azure OpenAI')
* @param reason - Why the generation failed
* @param cause - Original error if available
*/
static generationFailed(
service: string,
reason: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.GENERATION_FAILED,
`${service} generation failed: ${reason}`,
cause,
{ service }
);
}
/**
* Create an error for a service that is temporarily unavailable.
*
* @param service - Name of the unavailable service
*/
static unavailable(service: string): ServiceError {
return new ServiceError(
ErrorCode.SERVICE_UNAVAILABLE,
`${service} is temporarily unavailable`,
undefined,
{ service }
);
}
/**
* Create an error for external API failures.
*
* @param service - Name of the external service
* @param message - Error message or description
* @param cause - Original error if available
*/
static externalError(
service: string,
message: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.EXTERNAL_SERVICE_ERROR,
`${service} error: ${message}`,
cause,
{ service }
);
}
/**
* Create an internal server error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static internal(message: string, cause?: Error): ServiceError {
return new ServiceError(ErrorCode.INTERNAL_ERROR, message, cause);
}
}

View file

@ -0,0 +1,59 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
/**
* Error for validation failures (invalid input, missing fields, etc.).
* HTTP Status: 400 Bad Request
*
* @example
* ```typescript
* // Using factory methods
* return err(ValidationError.invalidInput('email', 'must be a valid email address'));
* return err(ValidationError.missingField('password'));
*
* // Direct construction
* return err(new ValidationError('Age must be a positive number', { field: 'age' }));
* ```
*/
export class ValidationError extends AppError {
constructor(message: string, context?: ErrorContext) {
super({
code: ErrorCode.VALIDATION_FAILED,
message,
context,
});
this.name = 'ValidationError';
}
/**
* Create a validation error for an invalid field value.
*
* @param field - The field name that failed validation
* @param reason - Why the validation failed
*/
static invalidInput(field: string, reason: string): ValidationError {
return new ValidationError(`Invalid ${field}: ${reason}`, { field, reason });
}
/**
* Create a validation error for a missing required field.
*
* @param field - The field name that is missing
*/
static missingField(field: string): ValidationError {
return new ValidationError(`Missing required field: ${field}`, { field });
}
/**
* Create a validation error for an invalid format.
*
* @param field - The field name with invalid format
* @param expectedFormat - Description of the expected format
*/
static invalidFormat(field: string, expectedFormat: string): ValidationError {
return new ValidationError(
`Invalid format for ${field}: expected ${expectedFormat}`,
{ field, expectedFormat }
);
}
}