mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
Merge branch 'feat/mana-core'
This commit is contained in:
commit
28d167a978
112 changed files with 34765 additions and 548 deletions
|
|
@ -30,18 +30,18 @@ const DEFAULT_STORAGE_KEYS: StorageKeys = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Default API endpoints
|
||||
* Default API endpoints - Updated for Mana Core Auth
|
||||
*/
|
||||
const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
||||
signIn: '/auth/signin',
|
||||
signUp: '/auth/signup',
|
||||
signOut: '/auth/logout',
|
||||
refresh: '/auth/refresh',
|
||||
validate: '/auth/validate',
|
||||
forgotPassword: '/auth/forgot-password',
|
||||
googleSignIn: '/auth/google-signin',
|
||||
appleSignIn: '/auth/apple-signin',
|
||||
credits: '/auth/credits',
|
||||
signIn: '/api/v1/auth/login',
|
||||
signUp: '/api/v1/auth/register',
|
||||
signOut: '/api/v1/auth/logout',
|
||||
refresh: '/api/v1/auth/refresh',
|
||||
validate: '/api/v1/auth/validate',
|
||||
forgotPassword: '/api/v1/auth/forgot-password',
|
||||
googleSignIn: '/api/v1/auth/google-signin',
|
||||
appleSignIn: '/api/v1/auth/apple-signin',
|
||||
credits: '/api/v1/credits/balance',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -68,7 +68,12 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
const response = await fetch(`${baseUrl}${endpoints.signIn}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
deviceId: deviceInfo?.deviceId,
|
||||
deviceName: deviceInfo?.deviceName
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -76,7 +81,9 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
return service.handleAuthError(response.status, errorData);
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
const data = await response.json();
|
||||
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
|
||||
const refreshToken = data.refreshToken;
|
||||
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
|
|
@ -106,7 +113,7 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -123,22 +130,9 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Check if email verification is required
|
||||
if (responseData.confirmationRequired) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (appToken && refreshToken) {
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
storage.setItem(storageKeys.USER_EMAIL, email),
|
||||
]);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
// Mana Core Auth returns user data immediately on registration
|
||||
// User needs to sign in separately to get tokens
|
||||
return { success: true, needsVerification: false };
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
|
|
@ -219,7 +213,7 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
const response = await fetch(`${baseUrl}${endpoints.refresh}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -232,7 +226,9 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
const data = await response.json();
|
||||
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
|
||||
const refreshToken = data.refreshToken;
|
||||
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh - missing tokens');
|
||||
|
|
@ -431,9 +427,9 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
|
||||
const data = await response.json();
|
||||
return {
|
||||
credits: data.credits || 0,
|
||||
maxCreditLimit: data.max_credit_limit || 1000,
|
||||
userId: data.id || 'unknown',
|
||||
credits: (data.balance || 0) + (data.freeCreditsRemaining || 0),
|
||||
maxCreditLimit: data.maxCreditLimit || 1000,
|
||||
userId: data.userId || 'unknown',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching user credits:', error);
|
||||
|
|
|
|||
30
packages/shared-errors/package.json
Normal file
30
packages/shared-errors/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@manacore/shared-errors",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Go-like error handling system for Manacore backends",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./nestjs": "./src/nestjs/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": ">=10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.0.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
179
packages/shared-errors/src/errors/app-error.ts
Normal file
179
packages/shared-errors/src/errors/app-error.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
packages/shared-errors/src/errors/auth-error.ts
Normal file
79
packages/shared-errors/src/errors/auth-error.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
packages/shared-errors/src/errors/credit-error.ts
Normal file
35
packages/shared-errors/src/errors/credit-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
packages/shared-errors/src/errors/database-error.ts
Normal file
54
packages/shared-errors/src/errors/database-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
packages/shared-errors/src/errors/index.ts
Normal file
9
packages/shared-errors/src/errors/index.ts
Normal 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';
|
||||
63
packages/shared-errors/src/errors/network-error.ts
Normal file
63
packages/shared-errors/src/errors/network-error.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
packages/shared-errors/src/errors/not-found-error.ts
Normal file
45
packages/shared-errors/src/errors/not-found-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
packages/shared-errors/src/errors/rate-limit-error.ts
Normal file
31
packages/shared-errors/src/errors/rate-limit-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
103
packages/shared-errors/src/errors/service-error.ts
Normal file
103
packages/shared-errors/src/errors/service-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
59
packages/shared-errors/src/errors/validation-error.ts
Normal file
59
packages/shared-errors/src/errors/validation-error.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
1
packages/shared-errors/src/guards/index.ts
Normal file
1
packages/shared-errors/src/guards/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './type-guards';
|
||||
158
packages/shared-errors/src/guards/type-guards.ts
Normal file
158
packages/shared-errors/src/guards/type-guards.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { AppError } from '../errors/app-error';
|
||||
import { ValidationError } from '../errors/validation-error';
|
||||
import { AuthError } from '../errors/auth-error';
|
||||
import { NotFoundError } from '../errors/not-found-error';
|
||||
import { CreditError } from '../errors/credit-error';
|
||||
import { ServiceError } from '../errors/service-error';
|
||||
import { RateLimitError } from '../errors/rate-limit-error';
|
||||
import { NetworkError } from '../errors/network-error';
|
||||
import { DatabaseError } from '../errors/database-error';
|
||||
import { ErrorCode } from '../types/error-codes';
|
||||
|
||||
/**
|
||||
* Check if error is an AppError.
|
||||
* Similar to Go's `errors.As()`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (isAppError(error)) {
|
||||
* console.log(error.code); // TypeScript knows error is AppError
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isAppError(error: unknown): error is AppError {
|
||||
return error instanceof AppError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a ValidationError.
|
||||
*/
|
||||
export function isValidationError(error: unknown): error is ValidationError {
|
||||
return error instanceof ValidationError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is an AuthError.
|
||||
*/
|
||||
export function isAuthError(error: unknown): error is AuthError {
|
||||
return error instanceof AuthError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a NotFoundError.
|
||||
*/
|
||||
export function isNotFoundError(error: unknown): error is NotFoundError {
|
||||
return error instanceof NotFoundError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a CreditError.
|
||||
*/
|
||||
export function isCreditError(error: unknown): error is CreditError {
|
||||
return error instanceof CreditError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a ServiceError.
|
||||
*/
|
||||
export function isServiceError(error: unknown): error is ServiceError {
|
||||
return error instanceof ServiceError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a RateLimitError.
|
||||
*/
|
||||
export function isRateLimitError(error: unknown): error is RateLimitError {
|
||||
return error instanceof RateLimitError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a NetworkError.
|
||||
*/
|
||||
export function isNetworkError(error: unknown): error is NetworkError {
|
||||
return error instanceof NetworkError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a DatabaseError.
|
||||
*/
|
||||
export function isDatabaseError(error: unknown): error is DatabaseError {
|
||||
return error instanceof DatabaseError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error has a specific error code.
|
||||
* Similar to Go's `errors.Is()`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (hasErrorCode(error, ErrorCode.INSUFFICIENT_CREDITS)) {
|
||||
* showUpgradePrompt();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function hasErrorCode(error: unknown, code: ErrorCode): boolean {
|
||||
if (!isAppError(error)) {
|
||||
return false;
|
||||
}
|
||||
return error.hasCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first error in the chain matching a predicate.
|
||||
* Traverses the cause chain looking for a matching error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const creditError = findError(error, isCreditError);
|
||||
* if (creditError) {
|
||||
* console.log('Required:', creditError.requiredCredits);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function findError<T extends AppError>(
|
||||
error: unknown,
|
||||
predicate: (e: AppError) => e is T
|
||||
): T | undefined {
|
||||
let current: unknown = error;
|
||||
while (current) {
|
||||
if (isAppError(current) && predicate(current)) {
|
||||
return current;
|
||||
}
|
||||
current = isAppError(current) ? current.cause : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable.
|
||||
* Works with both AppError and standard Error.
|
||||
*/
|
||||
export function isRetryable(error: unknown): boolean {
|
||||
if (isAppError(error)) {
|
||||
return error.retryable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code for an error.
|
||||
* Returns 500 for non-AppError errors.
|
||||
*/
|
||||
export function getHttpStatus(error: unknown): number {
|
||||
if (isAppError(error)) {
|
||||
return error.httpStatus;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error code for an error.
|
||||
* Returns UNKNOWN_ERROR for non-AppError errors.
|
||||
*/
|
||||
export function getErrorCode(error: unknown): ErrorCode {
|
||||
if (isAppError(error)) {
|
||||
return error.code;
|
||||
}
|
||||
return ErrorCode.UNKNOWN_ERROR;
|
||||
}
|
||||
108
packages/shared-errors/src/index.ts
Normal file
108
packages/shared-errors/src/index.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* @manacore/shared-errors
|
||||
*
|
||||
* Go-like error handling system for NestJS backends.
|
||||
*
|
||||
* Features:
|
||||
* - Result<T, E> type for explicit error handling
|
||||
* - Standardized error codes and HTTP status mappings
|
||||
* - Error wrapping with context (like Go's fmt.Errorf)
|
||||
* - Type guards for type-safe error checking (like Go's errors.Is/As)
|
||||
* - NestJS exception filter for consistent API responses
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a service
|
||||
* import {
|
||||
* Result, ok, err, AsyncResult,
|
||||
* ValidationError, NotFoundError, ServiceError
|
||||
* } from '@manacore/shared-errors';
|
||||
*
|
||||
* async function getUser(id: string): AsyncResult<User> {
|
||||
* if (!isValidId(id)) {
|
||||
* return err(ValidationError.invalidInput('id', 'must be a valid UUID'));
|
||||
* }
|
||||
*
|
||||
* const user = await db.findUser(id);
|
||||
* if (!user) {
|
||||
* return err(new NotFoundError('User', id));
|
||||
* }
|
||||
*
|
||||
* return ok(user);
|
||||
* }
|
||||
*
|
||||
* // In a controller
|
||||
* import { isOk } from '@manacore/shared-errors';
|
||||
*
|
||||
* const result = await userService.getUser(id);
|
||||
* if (!isOk(result)) {
|
||||
* throw result.error; // Caught by AppExceptionFilter
|
||||
* }
|
||||
* return result.value;
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
ErrorCode,
|
||||
ERROR_CODE_TO_HTTP_STATUS,
|
||||
ERROR_CODE_RETRYABLE,
|
||||
} from './types/error-codes';
|
||||
|
||||
export {
|
||||
type Result,
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
isOk,
|
||||
isErr,
|
||||
unwrap,
|
||||
unwrapOr,
|
||||
unwrapOrElse,
|
||||
map,
|
||||
mapErr,
|
||||
andThen,
|
||||
match,
|
||||
tryCatch,
|
||||
tryCatchAsync,
|
||||
combine,
|
||||
fromNullable,
|
||||
toNullable,
|
||||
} from './types/result';
|
||||
|
||||
// Errors
|
||||
export {
|
||||
AppError,
|
||||
type ErrorContext,
|
||||
type AppErrorOptions,
|
||||
} from './errors/app-error';
|
||||
|
||||
export { ValidationError } from './errors/validation-error';
|
||||
export { AuthError } from './errors/auth-error';
|
||||
export { NotFoundError } from './errors/not-found-error';
|
||||
export { CreditError } from './errors/credit-error';
|
||||
export { ServiceError } from './errors/service-error';
|
||||
export { RateLimitError } from './errors/rate-limit-error';
|
||||
export { NetworkError } from './errors/network-error';
|
||||
export { DatabaseError } from './errors/database-error';
|
||||
|
||||
// Guards
|
||||
export {
|
||||
isAppError,
|
||||
isValidationError,
|
||||
isAuthError,
|
||||
isNotFoundError,
|
||||
isCreditError,
|
||||
isServiceError,
|
||||
isRateLimitError,
|
||||
isNetworkError,
|
||||
isDatabaseError,
|
||||
hasErrorCode,
|
||||
findError,
|
||||
isRetryable,
|
||||
getHttpStatus,
|
||||
getErrorCode,
|
||||
} from './guards/type-guards';
|
||||
|
||||
// Utils
|
||||
export { wrap, toAppError, cause, rootCause } from './utils/wrap';
|
||||
259
packages/shared-errors/src/nestjs/app-exception.filter.ts
Normal file
259
packages/shared-errors/src/nestjs/app-exception.filter.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import {
|
||||
type ExceptionFilter,
|
||||
Catch,
|
||||
type ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { isAppError, isCreditError, isRateLimitError } from '../guards/type-guards';
|
||||
import { ErrorCode } from '../types/error-codes';
|
||||
|
||||
/**
|
||||
* Standard error response format returned by all backends.
|
||||
*/
|
||||
export interface ErrorResponseBody {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global exception filter that converts all errors to a consistent format.
|
||||
*
|
||||
* Handles:
|
||||
* - AppError and subclasses (from shared-errors)
|
||||
* - NestJS HttpException
|
||||
* - Standard JavaScript Error
|
||||
* - Unknown errors
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In main.ts
|
||||
* import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
|
||||
*
|
||||
* async function bootstrap() {
|
||||
* const app = await NestFactory.create(AppModule);
|
||||
* app.useGlobalFilters(new AppExceptionFilter());
|
||||
* await app.listen(3000);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Catch()
|
||||
export class AppExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AppExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const errorResponse = this.buildErrorResponse(exception, request);
|
||||
|
||||
this.logError(exception, request, errorResponse);
|
||||
|
||||
response.status(errorResponse.statusCode).json(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the error response body based on the exception type.
|
||||
*/
|
||||
private buildErrorResponse(
|
||||
exception: unknown,
|
||||
request: Request
|
||||
): ErrorResponseBody {
|
||||
// Handle AppError and subclasses
|
||||
if (isAppError(exception)) {
|
||||
return this.buildAppErrorResponse(exception, request);
|
||||
}
|
||||
|
||||
// Handle NestJS HttpException
|
||||
if (exception instanceof HttpException) {
|
||||
return this.buildHttpExceptionResponse(exception, request);
|
||||
}
|
||||
|
||||
// Handle standard Error
|
||||
if (exception instanceof Error) {
|
||||
return this.buildStandardErrorResponse(exception, request);
|
||||
}
|
||||
|
||||
// Handle unknown errors
|
||||
return this.buildUnknownErrorResponse(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response for AppError and subclasses.
|
||||
*/
|
||||
private buildAppErrorResponse(
|
||||
exception: AppError,
|
||||
request: Request
|
||||
): ErrorResponseBody {
|
||||
const baseResponse: ErrorResponseBody = {
|
||||
statusCode: exception.httpStatus,
|
||||
error: exception.code,
|
||||
message: exception.message,
|
||||
retryable: exception.retryable,
|
||||
timestamp: exception.timestamp,
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
// Add credit-specific fields for CreditError
|
||||
if (isCreditError(exception)) {
|
||||
baseResponse.details = {
|
||||
requiredCredits: exception.requiredCredits,
|
||||
availableCredits: exception.availableCredits,
|
||||
...exception.context,
|
||||
};
|
||||
}
|
||||
// Add retry-after for RateLimitError
|
||||
else if (isRateLimitError(exception) && exception.retryAfter) {
|
||||
baseResponse.details = {
|
||||
retryAfter: exception.retryAfter,
|
||||
...exception.context,
|
||||
};
|
||||
}
|
||||
// Add other context if present
|
||||
else if (Object.keys(exception.context).length > 0) {
|
||||
baseResponse.details = exception.context;
|
||||
}
|
||||
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response for NestJS HttpException.
|
||||
*/
|
||||
private buildHttpExceptionResponse(
|
||||
exception: HttpException,
|
||||
request: Request
|
||||
): ErrorResponseBody {
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
let message: string;
|
||||
let details: Record<string, unknown> | undefined;
|
||||
|
||||
if (typeof exceptionResponse === 'object') {
|
||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
||||
message =
|
||||
typeof responseObj.message === 'string'
|
||||
? responseObj.message
|
||||
: Array.isArray(responseObj.message)
|
||||
? (responseObj.message as string[]).join(', ')
|
||||
: exception.message;
|
||||
|
||||
// Extract any additional details
|
||||
const { message: _, error: __, statusCode: ___, ...rest } = responseObj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
details = rest;
|
||||
}
|
||||
} else {
|
||||
message = String(exceptionResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
error: this.httpStatusToErrorCode(status),
|
||||
message,
|
||||
retryable: status >= 500,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
...(details && { details }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response for standard JavaScript Error.
|
||||
*/
|
||||
private buildStandardErrorResponse(
|
||||
exception: Error,
|
||||
request: Request
|
||||
): ErrorResponseBody {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
return {
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error: ErrorCode.INTERNAL_ERROR,
|
||||
message: isProduction
|
||||
? 'An unexpected error occurred'
|
||||
: exception.message,
|
||||
retryable: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response for unknown error types.
|
||||
*/
|
||||
private buildUnknownErrorResponse(request: Request): ErrorResponseBody {
|
||||
return {
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error: ErrorCode.UNKNOWN_ERROR,
|
||||
message: 'An unexpected error occurred',
|
||||
retryable: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HTTP status code to ErrorCode.
|
||||
*/
|
||||
private httpStatusToErrorCode(status: number): string {
|
||||
const statusToCode: Record<number, string> = {
|
||||
400: ErrorCode.VALIDATION_FAILED,
|
||||
401: ErrorCode.AUTHENTICATION_REQUIRED,
|
||||
402: ErrorCode.PAYMENT_REQUIRED,
|
||||
403: ErrorCode.PERMISSION_DENIED,
|
||||
404: ErrorCode.RESOURCE_NOT_FOUND,
|
||||
409: ErrorCode.CONFLICT,
|
||||
429: ErrorCode.RATE_LIMIT_EXCEEDED,
|
||||
500: ErrorCode.INTERNAL_ERROR,
|
||||
502: ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||
503: ErrorCode.SERVICE_UNAVAILABLE,
|
||||
504: ErrorCode.TIMEOUT,
|
||||
};
|
||||
return statusToCode[status] || ErrorCode.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the error with appropriate level based on status code.
|
||||
*/
|
||||
private logError(
|
||||
exception: unknown,
|
||||
request: Request,
|
||||
response: ErrorResponseBody
|
||||
): void {
|
||||
const logData = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: response.statusCode,
|
||||
error: response.error,
|
||||
message: response.message,
|
||||
userId: (request as Request & { user?: { sub?: string } }).user?.sub,
|
||||
};
|
||||
|
||||
// Log 5xx errors as errors, others as warnings
|
||||
if (response.statusCode >= 500) {
|
||||
this.logger.error(
|
||||
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`,
|
||||
isAppError(exception)
|
||||
? JSON.stringify(exception.toFullJSON(), null, 2)
|
||||
: exception instanceof Error
|
||||
? exception.stack
|
||||
: undefined
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/shared-errors/src/nestjs/index.ts
Normal file
1
packages/shared-errors/src/nestjs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AppExceptionFilter, type ErrorResponseBody } from './app-exception.filter';
|
||||
162
packages/shared-errors/src/types/error-codes.ts
Normal file
162
packages/shared-errors/src/types/error-codes.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Standardized error codes across all backends.
|
||||
* Follows pattern: CATEGORY_SPECIFIC_ERROR
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// Validation Errors (400)
|
||||
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
|
||||
INVALID_FORMAT = 'INVALID_FORMAT',
|
||||
|
||||
// Authentication Errors (401)
|
||||
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
|
||||
INVALID_TOKEN = 'INVALID_TOKEN',
|
||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
||||
|
||||
// Authorization Errors (403)
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
|
||||
|
||||
// Not Found Errors (404)
|
||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
|
||||
// Payment/Credit Errors (402)
|
||||
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
|
||||
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
|
||||
|
||||
// Conflict Errors (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 (502/503/504)
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
CONNECTION_REFUSED = 'CONNECTION_REFUSED',
|
||||
|
||||
// Database Errors
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
|
||||
|
||||
// Unknown
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps error codes to default HTTP status codes.
|
||||
*/
|
||||
export const ERROR_CODE_TO_HTTP_STATUS: Record<ErrorCode, number> = {
|
||||
// Validation (400)
|
||||
[ErrorCode.VALIDATION_FAILED]: 400,
|
||||
[ErrorCode.INVALID_INPUT]: 400,
|
||||
[ErrorCode.MISSING_REQUIRED_FIELD]: 400,
|
||||
[ErrorCode.INVALID_FORMAT]: 400,
|
||||
|
||||
// Authentication (401)
|
||||
[ErrorCode.AUTHENTICATION_REQUIRED]: 401,
|
||||
[ErrorCode.INVALID_TOKEN]: 401,
|
||||
[ErrorCode.TOKEN_EXPIRED]: 401,
|
||||
|
||||
// Authorization (403)
|
||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
||||
[ErrorCode.RESOURCE_NOT_OWNED]: 403,
|
||||
|
||||
// Not Found (404)
|
||||
[ErrorCode.RESOURCE_NOT_FOUND]: 404,
|
||||
[ErrorCode.USER_NOT_FOUND]: 404,
|
||||
|
||||
// Payment (402)
|
||||
[ErrorCode.INSUFFICIENT_CREDITS]: 402,
|
||||
[ErrorCode.PAYMENT_REQUIRED]: 402,
|
||||
|
||||
// Conflict (409)
|
||||
[ErrorCode.CONFLICT]: 409,
|
||||
[ErrorCode.DUPLICATE_ENTRY]: 409,
|
||||
|
||||
// Rate Limit (429)
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
|
||||
[ErrorCode.TOO_MANY_REQUESTS]: 429,
|
||||
|
||||
// Service Errors (500)
|
||||
[ErrorCode.INTERNAL_ERROR]: 500,
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
|
||||
[ErrorCode.GENERATION_FAILED]: 500,
|
||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: 502,
|
||||
|
||||
// Network Errors
|
||||
[ErrorCode.NETWORK_ERROR]: 502,
|
||||
[ErrorCode.TIMEOUT]: 504,
|
||||
[ErrorCode.CONNECTION_REFUSED]: 503,
|
||||
|
||||
// Database Errors
|
||||
[ErrorCode.DATABASE_ERROR]: 500,
|
||||
[ErrorCode.CONSTRAINT_VIOLATION]: 409,
|
||||
|
||||
// Unknown
|
||||
[ErrorCode.UNKNOWN_ERROR]: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps error codes to default retryable status.
|
||||
*/
|
||||
export const ERROR_CODE_RETRYABLE: Record<ErrorCode, boolean> = {
|
||||
// Validation - not retryable (user needs to fix input)
|
||||
[ErrorCode.VALIDATION_FAILED]: false,
|
||||
[ErrorCode.INVALID_INPUT]: false,
|
||||
[ErrorCode.MISSING_REQUIRED_FIELD]: false,
|
||||
[ErrorCode.INVALID_FORMAT]: false,
|
||||
|
||||
// Authentication - not retryable (need new credentials)
|
||||
[ErrorCode.AUTHENTICATION_REQUIRED]: false,
|
||||
[ErrorCode.INVALID_TOKEN]: false,
|
||||
[ErrorCode.TOKEN_EXPIRED]: false,
|
||||
|
||||
// Authorization - not retryable (permission issue)
|
||||
[ErrorCode.PERMISSION_DENIED]: false,
|
||||
[ErrorCode.RESOURCE_NOT_OWNED]: false,
|
||||
|
||||
// Not Found - not retryable (resource doesn't exist)
|
||||
[ErrorCode.RESOURCE_NOT_FOUND]: false,
|
||||
[ErrorCode.USER_NOT_FOUND]: false,
|
||||
|
||||
// Payment - not retryable (need more credits)
|
||||
[ErrorCode.INSUFFICIENT_CREDITS]: false,
|
||||
[ErrorCode.PAYMENT_REQUIRED]: false,
|
||||
|
||||
// Conflict - not retryable (data issue)
|
||||
[ErrorCode.CONFLICT]: false,
|
||||
[ErrorCode.DUPLICATE_ENTRY]: false,
|
||||
|
||||
// Rate Limit - retryable (after waiting)
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: true,
|
||||
[ErrorCode.TOO_MANY_REQUESTS]: true,
|
||||
|
||||
// Service Errors - retryable (transient issues)
|
||||
[ErrorCode.INTERNAL_ERROR]: true,
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: true,
|
||||
[ErrorCode.GENERATION_FAILED]: true,
|
||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: true,
|
||||
|
||||
// Network Errors - retryable (transient issues)
|
||||
[ErrorCode.NETWORK_ERROR]: true,
|
||||
[ErrorCode.TIMEOUT]: true,
|
||||
[ErrorCode.CONNECTION_REFUSED]: true,
|
||||
|
||||
// Database Errors - not retryable (except transient, but safer to say no)
|
||||
[ErrorCode.DATABASE_ERROR]: false,
|
||||
[ErrorCode.CONSTRAINT_VIOLATION]: false,
|
||||
|
||||
// Unknown - retryable (might be transient)
|
||||
[ErrorCode.UNKNOWN_ERROR]: true,
|
||||
};
|
||||
2
packages/shared-errors/src/types/index.ts
Normal file
2
packages/shared-errors/src/types/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './error-codes';
|
||||
export * from './result';
|
||||
331
packages/shared-errors/src/types/result.ts
Normal file
331
packages/shared-errors/src/types/result.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { AppError } from '../errors/app-error';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
/**
|
||||
* Result type representing either success or failure.
|
||||
* Inspired by Go's (value, error) return pattern and Rust's Result type.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a service
|
||||
* async function getUser(id: string): AsyncResult<User> {
|
||||
* const user = await db.findUser(id);
|
||||
* if (!user) {
|
||||
* return err(new NotFoundError('User', id));
|
||||
* }
|
||||
* return ok(user);
|
||||
* }
|
||||
*
|
||||
* // In a controller (Go-like explicit unwrap)
|
||||
* const result = await userService.getUser(id);
|
||||
* if (!isOk(result)) {
|
||||
* throw result.error;
|
||||
* }
|
||||
* return result.value;
|
||||
* ```
|
||||
*/
|
||||
export type Result<T, E extends AppError = AppError> =
|
||||
| { readonly ok: true; readonly value: T; readonly error?: never }
|
||||
| { readonly ok: false; readonly error: E; readonly value?: never };
|
||||
|
||||
/**
|
||||
* Async version of Result - use this as return type for async functions.
|
||||
*/
|
||||
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
|
||||
|
||||
/**
|
||||
* Create a success Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* return ok({ name: 'John', email: 'john@example.com' });
|
||||
* ```
|
||||
*/
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failure Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* return err(new ValidationError('Invalid email'));
|
||||
* return err(NotFoundError.user(userId));
|
||||
* ```
|
||||
*/
|
||||
export function err<E extends AppError>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Result is success.
|
||||
* Use this for type narrowing in conditionals.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.getData();
|
||||
* if (isOk(result)) {
|
||||
* console.log(result.value); // TypeScript knows value exists
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isOk<T, E extends AppError>(
|
||||
result: Result<T, E>
|
||||
): result is { ok: true; value: T } {
|
||||
return result.ok === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Result is failure.
|
||||
* Use this for type narrowing in conditionals.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.getData();
|
||||
* if (isErr(result)) {
|
||||
* console.error(result.error.message); // TypeScript knows error exists
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isErr<T, E extends AppError>(
|
||||
result: Result<T, E>
|
||||
): result is { ok: false; error: E } {
|
||||
return result.ok === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the value or throw if error.
|
||||
* Use sparingly - prefer explicit error checking.
|
||||
*
|
||||
* @throws The error if Result is a failure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use when you want to propagate errors as exceptions
|
||||
* const value = unwrap(result);
|
||||
* ```
|
||||
*/
|
||||
export function unwrap<T, E extends AppError>(result: Result<T, E>): T {
|
||||
if (isOk(result)) {
|
||||
return result.value;
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the value or return a default value.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const users = unwrapOr(result, []); // Returns [] if error
|
||||
* ```
|
||||
*/
|
||||
export function unwrapOr<T, E extends AppError>(
|
||||
result: Result<T, E>,
|
||||
defaultValue: T
|
||||
): T {
|
||||
return isOk(result) ? result.value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the value or compute a default from the error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const value = unwrapOrElse(result, (error) => {
|
||||
* console.error('Failed:', error.message);
|
||||
* return fallbackValue;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function unwrapOrElse<T, E extends AppError>(
|
||||
result: Result<T, E>,
|
||||
fn: (error: E) => T
|
||||
): T {
|
||||
return isOk(result) ? result.value : fn(result.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the success value to a new value.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await getUser(id);
|
||||
* const nameResult = map(result, user => user.name);
|
||||
* ```
|
||||
*/
|
||||
export function map<T, U, E extends AppError>(
|
||||
result: Result<T, E>,
|
||||
fn: (value: T) => U
|
||||
): Result<U, E> {
|
||||
return isOk(result) ? ok(fn(result.value)) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the error to a new error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = mapErr(originalResult, error =>
|
||||
* error.wrap('while processing user')
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function mapErr<T, E extends AppError, F extends AppError>(
|
||||
result: Result<T, E>,
|
||||
fn: (error: E) => F
|
||||
): Result<T, F> {
|
||||
return isErr(result) ? err(fn(result.error)) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain Results (flatMap) - use when the mapping function returns a Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = andThen(getUserResult, user =>
|
||||
* getPermissions(user.id)
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function andThen<T, U, E extends AppError>(
|
||||
result: Result<T, E>,
|
||||
fn: (value: T) => Result<U, E>
|
||||
): Result<U, E> {
|
||||
return isOk(result) ? fn(result.value) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern matching for Result - handle both success and failure cases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const message = match(result, {
|
||||
* ok: (user) => `Welcome, ${user.name}!`,
|
||||
* err: (error) => `Error: ${error.message}`,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function match<T, E extends AppError, U>(
|
||||
result: Result<T, E>,
|
||||
handlers: {
|
||||
ok: (value: T) => U;
|
||||
err: (error: E) => U;
|
||||
}
|
||||
): U {
|
||||
return isOk(result) ? handlers.ok(result.value) : handlers.err(result.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to execute a synchronous function and wrap in Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = tryCatch(() => JSON.parse(jsonString));
|
||||
* ```
|
||||
*/
|
||||
export function tryCatch<T>(fn: () => T): Result<T, AppError> {
|
||||
try {
|
||||
return ok(fn());
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return err(error);
|
||||
}
|
||||
return err(
|
||||
new AppError({
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to execute an async function and wrap in Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await tryCatchAsync(() => fetch(url).then(r => r.json()));
|
||||
* ```
|
||||
*/
|
||||
export async function tryCatchAsync<T>(
|
||||
fn: () => Promise<T>
|
||||
): AsyncResult<T, AppError> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return err(error);
|
||||
}
|
||||
return err(
|
||||
new AppError({
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple Results - returns first error or array of all values.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await Promise.all([
|
||||
* getUser(id1),
|
||||
* getUser(id2),
|
||||
* getUser(id3),
|
||||
* ]);
|
||||
* const combined = combine(results);
|
||||
* if (isOk(combined)) {
|
||||
* const [user1, user2, user3] = combined.value;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function combine<T, E extends AppError>(
|
||||
results: Result<T, E>[]
|
||||
): Result<T[], E> {
|
||||
const values: T[] = [];
|
||||
for (const result of results) {
|
||||
if (isErr(result)) {
|
||||
return result;
|
||||
}
|
||||
values.push(result.value);
|
||||
}
|
||||
return ok(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a nullable value to a Result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = fromNullable(
|
||||
* maybeUser,
|
||||
* () => new NotFoundError('User', id)
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function fromNullable<T, E extends AppError>(
|
||||
value: T | null | undefined,
|
||||
errorFn: () => E
|
||||
): Result<T, E> {
|
||||
return value != null ? ok(value) : err(errorFn());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Result to a nullable value (loses error information).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const user = toNullable(result); // User | null
|
||||
* ```
|
||||
*/
|
||||
export function toNullable<T, E extends AppError>(
|
||||
result: Result<T, E>
|
||||
): T | null {
|
||||
return isOk(result) ? result.value : null;
|
||||
}
|
||||
1
packages/shared-errors/src/utils/index.ts
Normal file
1
packages/shared-errors/src/utils/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './wrap';
|
||||
96
packages/shared-errors/src/utils/wrap.ts
Normal file
96
packages/shared-errors/src/utils/wrap.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { AppError, type ErrorContext } from '../errors/app-error';
|
||||
import { ErrorCode } from '../types/error-codes';
|
||||
import { isAppError } from '../guards/type-guards';
|
||||
|
||||
/**
|
||||
* Wrap an error with context.
|
||||
* Similar to Go's `fmt.Errorf("context: %w", err)`.
|
||||
*
|
||||
* @param error - The error to wrap (can be any type)
|
||||
* @param context - Description of the operation that failed
|
||||
* @param additionalContext - Extra context data to include
|
||||
* @returns An AppError with the original as its cause
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* await fetchData();
|
||||
* } catch (error) {
|
||||
* return err(wrap(error, 'fetching user data'));
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function wrap(
|
||||
error: unknown,
|
||||
context: string,
|
||||
additionalContext?: ErrorContext
|
||||
): AppError {
|
||||
if (isAppError(error)) {
|
||||
return error.wrap(context, additionalContext);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new AppError({
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
message: `${context}: ${message}`,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
context: additionalContext,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any error to AppError.
|
||||
* If already an AppError, returns it unchanged.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* await riskyOperation();
|
||||
* } catch (error) {
|
||||
* return err(toAppError(error));
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function toAppError(error: unknown): AppError {
|
||||
if (isAppError(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new AppError({
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
message: error.message,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return new AppError({
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
message: String(error),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cause of an error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const originalError = cause(wrappedError);
|
||||
* ```
|
||||
*/
|
||||
export function cause(error: AppError): Error | undefined {
|
||||
return error.cause;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root cause of an error chain.
|
||||
* Traverses all causes to find the original error.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const original = rootCause(deeplyWrappedError);
|
||||
* ```
|
||||
*/
|
||||
export function rootCause(error: AppError): Error {
|
||||
return error.rootCause();
|
||||
}
|
||||
19
packages/shared-errors/tsconfig.json
Normal file
19
packages/shared-errors/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue