feat(shared): reorder AppError constructor to (message, code, status), add singleton logger, retry and TTL cache utilities
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number = 500,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
@@ -14,35 +14,39 @@ export class AppError extends Error {
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("VALIDATION_ERROR", 400, message, details);
|
||||
super(message, "VALIDATION_ERROR", 400, details);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id?: string) {
|
||||
super("NOT_FOUND", 404, `${resource} not found${id ? `: ${id}` : ""}`);
|
||||
super(
|
||||
`${resource} not found${id ? `: ${id}` : ""}`,
|
||||
"NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super("UNAUTHORIZED", 401, message);
|
||||
super(message, "UNAUTHORIZED", 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = "Forbidden") {
|
||||
super("FORBIDDEN", 403, message);
|
||||
super(message, "FORBIDDEN", 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFLICT", 409, message);
|
||||
super(message, "CONFLICT", 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
@@ -52,35 +56,35 @@ export class InternalServerError extends AppError {
|
||||
message = "Internal server error",
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super("INTERNAL_SERVER_ERROR", 500, message, details);
|
||||
super(message, "INTERNAL_SERVER_ERROR", 500, details);
|
||||
this.name = "InternalServerError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DATABASE_ERROR", 500, message, details);
|
||||
super(message, "DATABASE_ERROR", 500, details);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFIG_ERROR", 500, message);
|
||||
super(message, "CONFIG_ERROR", 500);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DISCORD_ERROR", 500, message, details);
|
||||
super(message, "DISCORD_ERROR", 500, details);
|
||||
this.name = "DiscordError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends AppError {
|
||||
constructor(operation: string) {
|
||||
super("TIMEOUT", 504, `${operation} timed out`);
|
||||
super(`${operation} timed out`, "TIMEOUT", 504);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import pino from "pino";
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
const rootLogger = pino({
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} as pino.LoggerOptions);
|
||||
|
||||
export function createLogger(context: string) {
|
||||
return pino({
|
||||
name: context,
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} as pino.LoggerOptions);
|
||||
}
|
||||
export type Logger = ReturnType<typeof createChildLogger>;
|
||||
|
||||
/**
|
||||
* Returns a child logger bound to the root singleton via pino's .child().
|
||||
* Preserves parent context and is efficient (no transport re-init per call).
|
||||
*/
|
||||
export function createChildLogger(context: string) {
|
||||
return createLogger(context);
|
||||
return rootLogger.child({ context });
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for createChildLogger for backwards compatibility.
|
||||
* @deprecated Use createChildLogger instead.
|
||||
*/
|
||||
export function createLogger(context: string) {
|
||||
return createChildLogger(context);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
@@ -26,7 +26,7 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
export function sanitizeString(str: string): string {
|
||||
return str.replace(/[<>]/g, "").trim().substring(0);
|
||||
return str.replace(/[<>]/g, "").trim();
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
@@ -59,3 +59,157 @@ export function calculatePagination(
|
||||
export function getOffset(page: number, limit: number): number {
|
||||
return (page - 1) * limit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry with exponential backoff (port of discord-gateway retry utility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Number of retry attempts (default: 3) */
|
||||
retries?: number;
|
||||
/** Initial delay in ms (default: 1000) */
|
||||
minTimeout?: number;
|
||||
/** Maximum delay in ms (default: 30000) */
|
||||
maxTimeout?: number;
|
||||
/** Multiplication factor for each retry (default: 2) */
|
||||
factor?: number;
|
||||
}
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
retries = 3,
|
||||
minTimeout = 1_000,
|
||||
maxTimeout = 30_000,
|
||||
factor = 2,
|
||||
} = options;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt === retries) break;
|
||||
const backoff = Math.min(
|
||||
minTimeout * factor ** attempt + Math.random() * 100,
|
||||
maxTimeout,
|
||||
);
|
||||
await delay(backoff);
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic in-memory TTL cache with LRU-style pruning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CacheEntry<V> {
|
||||
value: V;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface TtlCacheOptions<K> {
|
||||
/** Default TTL in ms for entries (default: 60000) */
|
||||
defaultTtlMs?: number;
|
||||
/** Maximum entries before pruning (default: 500) */
|
||||
maxEntries?: number;
|
||||
/** Called when an entry is evicted */
|
||||
onEvict?: (key: K, value: unknown) => void;
|
||||
}
|
||||
|
||||
export class TtlCache<K = string, V = unknown> {
|
||||
private store = new Map<K, CacheEntry<V>>();
|
||||
private readonly defaultTtlMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly onEvict?: (key: K, value: V) => void;
|
||||
|
||||
constructor(options: TtlCacheOptions<K> = {}) {
|
||||
this.defaultTtlMs = options.defaultTtlMs ?? 60_000;
|
||||
this.maxEntries = options.maxEntries ?? 500;
|
||||
this.onEvict = options.onEvict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value by key. Returns undefined if missing or expired.
|
||||
*/
|
||||
get(key: K): V | undefined {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value with optional custom TTL. Prunes oldest entries if at capacity.
|
||||
*/
|
||||
set(key: K, value: V, ttlMs?: number): void {
|
||||
if (this.store.size >= this.maxEntries) {
|
||||
this.prune();
|
||||
}
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists and is not expired (without removing it).
|
||||
*/
|
||||
has(key: K): boolean {
|
||||
return this.get(key) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific entry.
|
||||
*/
|
||||
delete(key: K): boolean {
|
||||
return this.store.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries.
|
||||
*/
|
||||
prune(): void {
|
||||
const now = Date.now();
|
||||
const toDelete: K[] = [];
|
||||
for (const [key, entry] of this.store) {
|
||||
if (now > entry.expiresAt) {
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of toDelete) {
|
||||
const entry = this.store.get(key);
|
||||
this.store.delete(key);
|
||||
if (entry && this.onEvict) this.onEvict(key, entry.value);
|
||||
}
|
||||
// If still over limit after TTL pruning, drop oldest entries
|
||||
if (this.store.size > this.maxEntries) {
|
||||
const keysToDelete = Array.from(this.store.keys()).slice(
|
||||
0,
|
||||
this.store.size - this.maxEntries,
|
||||
);
|
||||
for (const key of keysToDelete) {
|
||||
const entry = this.store.get(key);
|
||||
this.store.delete(key);
|
||||
if (entry && this.onEvict) this.onEvict(key, entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current number of entries (including possibly expired ones). */
|
||||
get size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
/** Remove all entries. */
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user