feat(shared): reorder AppError constructor to (message, code, status), add singleton logger, retry and TTL cache utilities

This commit is contained in:
MythEclipse
2026-06-02 21:06:42 +07:00
parent 9045e7e347
commit 2d79d8aefd
50 changed files with 401 additions and 449 deletions
+16 -12
View File
@@ -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";
}
}
+28 -19
View File
@@ -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);
}
+156 -2
View File
@@ -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();
}
}