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
+1
View File
@@ -14,6 +14,7 @@
"test": "vitest run"
},
"dependencies": {
"@bete/shared": "workspace:*",
"@discordjs/voice": "^0.19.2",
"@types/pg": "^8.20.0",
"axios": "^1.16.1",
+1 -1
View File
@@ -16,7 +16,7 @@ import { createRecordingsRouter } from "../modules/recordings/recordings.routes.
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { errorHandler } from "../shared/middlewares/index.js";
const logger = createChildLogger("http.app");
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServer, type Server } from "node:http";
import { config } from "../shared/config/index.js";
import { initializeDatabase } from "../shared/database/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { createHttpApp } from "./app.js";
import { createWebSocketServer } from "../ws/server.js";
import { startRedisBridge } from "../ws/redis-bridge.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { startHttpServer } from "./http/server.js";
import { createChildLogger } from "./shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import type { Server } from "node:http";
const logger = createChildLogger("backend");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { analysisService } from "./analysis.service.js";
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm";
import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("analysis.service");
@@ -1,18 +1,14 @@
import type { NextFunction, Request, Response } from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
import {
asyncHandler,
requireParam,
} from "../../shared/middlewares/index.js";
import { analyticsQuerySchema } from "./analytics.schema.js";
import { analyticsService } from "./analytics.service.js";
const logger = createChildLogger("analytics.controller");
function requireQueryString(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing query parameter: ${name}`);
}
return value;
}
export function handleGetOverview(
req: Request,
res: Response,
@@ -32,7 +28,7 @@ export function handleGetDailyTrend(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const guildId = requireQueryString(req.query.guildId, "guildId");
const guildId = requireParam(req.query.guildId, "query parameter", "guildId");
const hours = req.query.hours ? Number(req.query.hours) : 24;
logger.debug({ guildId, hours }, "Handling get daily trend");
const result = await analyticsService.getDailyTrend(guildId, hours);
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("analytics.repository");
@@ -1,6 +1,6 @@
import { config } from "../../shared/config/index.js";
import { ForbiddenError, ValidationError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { ForbiddenError, ValidationError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { analyticsRepository } from "./analytics.repository.js";
import type { AnalyticsQuery } from "./analytics.schema.js";
@@ -1,8 +1,8 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { config } from "../../shared/config/index.js";
import { UnauthorizedError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { UnauthorizedError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
const logger = createChildLogger("auth.routes");
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("health.repository");
@@ -1,4 +1,4 @@
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js";
const logger = createChildLogger("health.service");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { queue, skip, stop, setVolume, getStatus } from "./media.service.js";
@@ -1,4 +1,4 @@
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
@@ -1,21 +1,14 @@
import type { NextFunction, Request, Response } from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
import {
asyncHandler,
requireParam,
} from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.controller");
function requireRouteParam(
value: string | string[] | undefined,
name: string,
): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing route parameter: ${name}`);
}
return value;
}
export function handleListMessages(
req: Request,
res: Response,
@@ -35,7 +28,7 @@ export function handleGetMessagesByChannel(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireRouteParam(req.params.channelId, "channelId");
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
@@ -49,7 +42,7 @@ export function handleGetMessageById(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const id = requireRouteParam(req.params.id, "id");
const id = requireParam(req.params.id, "route parameter", "id");
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
@@ -62,7 +55,7 @@ export function handleGetAttachmentsByChannel(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireRouteParam(req.params.channelId, "channelId");
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import type {
MessageCreate,
MessageQuery,
@@ -1,7 +1,7 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
handleGetAttachmentsByChannel,
@@ -1,5 +1,5 @@
import { NotFoundError, ValidationError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { NotFoundError, ValidationError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { messagesRepository } from "./messages.repository.js";
import type { MessageQuery } from "./messages.schema.js";
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { recordingsService } from "./recordings.service.js";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("recordings.service");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { uiStateService } from "./ui-state.service.js";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("ui-state.service");
@@ -1,7 +1,9 @@
import Redis from "ioredis";
import { config } from "../../shared/config/index.js";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
publishCommand,
readRedisStatus,
} from "../../shared/redis/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("voice.service");
@@ -24,109 +26,14 @@ export interface VoiceStatus {
activeChannelName: string | null;
}
interface CommandReply {
id: string;
success: boolean;
data: unknown;
error?: string;
}
// --- Redis command client ---
let commandRedis: Redis | null = null;
let statusRedis: Redis | null = null;
function getCommandRedis(): Redis {
if (!commandRedis) {
commandRedis = config.REDIS_URL
? new Redis(config.REDIS_URL, { keyPrefix: "" })
: new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
keyPrefix: "",
});
}
return commandRedis;
}
function getStatusRedis(): Redis {
if (!statusRedis) {
statusRedis = config.REDIS_URL
? new Redis(config.REDIS_URL, { keyPrefix: "" })
: new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
keyPrefix: "",
});
}
return statusRedis;
}
async function sendCommand<T = unknown>(
type: string,
payload: Record<string, unknown>,
timeoutMs = 10000,
): Promise<T | null> {
const redis = getCommandRedis();
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const replyChannel = `backend:command:reply:${id}`;
return new Promise<T | null>((resolve) => {
const timer = setTimeout(() => {
redis.unsubscribe(replyChannel).catch(() => {});
resolve(null);
}, timeoutMs);
redis.subscribe(replyChannel, (err) => {
if (err) {
clearTimeout(timer);
resolve(null);
return;
}
});
const handler = (_ch: string, msg: string) => {
if (_ch === replyChannel) {
clearTimeout(timer);
redis.unsubscribe(replyChannel).catch(() => {});
try {
const reply: CommandReply = JSON.parse(msg);
resolve(reply.success ? (reply.data as T) : null);
} catch {
resolve(null);
}
}
};
redis.on("message", handler);
redis
.publish(
"backend:command",
JSON.stringify({ id, type, payload, replyChannel }),
)
.catch(() => {
clearTimeout(timer);
resolve(null);
});
});
}
async function readStatus<T>(key: string): Promise<T | null> {
try {
const val = await getStatusRedis().get(key);
return val ? (JSON.parse(val) as T) : null;
} catch {
return null;
}
}
/**
* Get guilds — query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/
export async function getGuilds(): Promise<Guild[]> {
const fromGateway = await sendCommand<Guild[]>("guilds:list", {});
if (fromGateway && fromGateway.length > 0) return fromGateway;
const reply = await publishCommand<Guild[]>("guilds:list", {});
if (reply?.success && reply.data && reply.data.length > 0)
return reply.data;
// Fallback: Postgres with synthetic names
logger.warn(
@@ -149,10 +56,11 @@ export async function getGuilds(): Promise<Guild[]> {
* Falls back to database if gateway unreachable.
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
const fromGateway = await sendCommand<Channel[]>("guilds:text-channels", {
const reply = await publishCommand<Channel[]>("guilds:text-channels", {
guildId,
});
if (fromGateway && fromGateway.length > 0) return fromGateway;
if (reply?.success && reply.data && reply.data.length > 0)
return reply.data;
// Fallback: Postgres with synthetic names
logger.warn(
@@ -176,16 +84,16 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
* Get voice channels — query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
const channels = await sendCommand<Channel[]>("voice:channels", { guildId });
return channels ?? [];
const reply = await publishCommand<Channel[]>("voice:channels", { guildId });
return reply?.success && reply.data ? reply.data : [];
}
/**
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export async function getVoiceStatus(): Promise<VoiceStatus> {
const cached = await readStatus<VoiceStatus>("voice:status");
if (cached) return cached;
const cached = await readRedisStatus("voice:status");
if (cached) return cached as unknown as VoiceStatus;
return {
connected: false,
activeGuildId: null,
@@ -201,38 +109,34 @@ export async function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
const result = await sendCommand<VoiceStatus>("voice:connect", {
const reply = await publishCommand<VoiceStatus>("voice:connect", {
guildId,
channelId,
});
if (result) return result;
if (reply?.success && reply.data) return reply.data;
// Fallback: read from Redis status key
const cached = await readStatus<VoiceStatus>("voice:status");
return (
cached ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
}
);
const cached = await readRedisStatus("voice:status");
return (cached as unknown as VoiceStatus) ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
}
/**
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
const result = await sendCommand<VoiceStatus>("voice:disconnect", {});
if (result) return result;
const reply = await publishCommand<VoiceStatus>("voice:disconnect", {});
if (reply?.success && reply.data) return reply.data;
const cached = await readStatus<VoiceStatus>("voice:status");
return (
cached ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
}
);
const cached = await readRedisStatus("voice:status");
return (cached as unknown as VoiceStatus) ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
}
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { config } from "../config/index.js";
import { createChildLogger } from "../logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("database");
@@ -1,65 +0,0 @@
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
) {
super(message);
this.name = "AppError";
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public details?: Record<string, unknown>,
) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, "NOT_FOUND", 404);
this.name = "NotFoundError";
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = "Unauthorized") {
super(message, "UNAUTHORIZED", 401);
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends AppError {
constructor(message: string = "Forbidden") {
super(message, "FORBIDDEN", 403);
this.name = "ForbiddenError";
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, "CONFLICT", 409);
this.name = "ConflictError";
}
}
export class DatabaseError extends AppError {
constructor(
message: string,
public originalError?: Error,
) {
super(message, "DATABASE_ERROR", 500);
this.name = "DatabaseError";
}
}
export class ConfigError extends AppError {
constructor(message: string) {
super(message, "CONFIG_ERROR", 500);
this.name = "ConfigError";
}
}
@@ -1,24 +0,0 @@
import pino from "pino";
import { config } from "../config/index.js";
const isDev = config.NODE_ENV === "development";
export const logger = pino({
level: config.LOG_LEVEL,
transport: isDev
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
}
: undefined,
});
export function createChildLogger(context: string) {
return logger.child({ context });
}
export type Logger = ReturnType<typeof createChildLogger>;
@@ -1,6 +1,10 @@
import type { NextFunction, Request, Response } from "express";
import { AppError, UnauthorizedError } from "../errors/index.js";
import { createChildLogger } from "../logger/index.js";
import {
AppError,
UnauthorizedError,
ValidationError,
} from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("middleware");
@@ -46,5 +50,13 @@ export function asyncHandler(
};
}
// Import ValidationError for type checking
import { ValidationError } from "../errors/index.js";
/**
* Validate that a value is a non-empty string, or throw a descriptive error.
* Use for both route params and query string values.
*/
export function requireParam(value: unknown, kind: string, name: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing ${kind}: ${name}`);
}
return value;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import Redis from "ioredis";
import { config } from "../config/index.js";
import { createChildLogger } from "../logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("redis.command-channel");
-1
View File
@@ -12,7 +12,6 @@
type BroadcastFn = (data: unknown) => void;
type BroadcastRawFn = (type: string, data: unknown) => void;
// Extend globalThis with broadcast function types
declare global {
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
var __broadcastFns:
+4 -15
View File
@@ -1,6 +1,7 @@
import Redis from "ioredis";
import { config } from "../shared/config/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { getCommandPublisher } from "../shared/redis/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { broadcastRaw } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
@@ -27,9 +28,8 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
];
let subscriber: Redis | null = null;
let publisher: Redis | null = null;
function createRedisInstance(): Redis {
function createSubscriber(): Redis {
if (config.REDIS_URL) {
return new Redis(config.REDIS_URL, { keyPrefix: "" });
}
@@ -40,17 +40,6 @@ function createRedisInstance(): Redis {
});
}
function createSubscriber(): Redis {
return createRedisInstance();
}
function getPublisher(): Redis {
if (!publisher) {
publisher = createRedisInstance();
}
return publisher;
}
/**
* Publish a command to the Discord Gateway via Redis.
* The DG's commandHandler listens on "backend:command" channel.
@@ -58,7 +47,7 @@ function getPublisher(): Redis {
export async function publishCommand(
payload: Record<string, unknown>,
): Promise<void> {
const pub = getPublisher();
const pub = getCommandPublisher();
const envelope = {
type: "command",
data: payload,
+1 -2
View File
@@ -1,6 +1,6 @@
import type { Server } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("ws.server");
@@ -10,7 +10,6 @@ interface BroadcastEvent {
timestamp: string;
}
// Extend globalThis with broadcast function types
declare global {
var __broadcastFns:
| {