From 332853f39851b71b44d3d3a38067c3f9784404ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:51:39 +0700 Subject: [PATCH] chore: dedup dead code + compress utility - Hapus src/shared/utils/retry.ts (0 imports, dead) - Hapus src/shared/errors/index.ts (7 classes, 0 imports) - Hapus drizzle type exports dari schema.ts (tidak dipake) - Hapus src/config/index.ts, redirect 8 imports langsung ke env.ts - Ekstrak maybeCompressChunk ke shared/utils/compress.ts - Hapus duplikasi gzipSync di upload-file.ts + chunked-storage.ts - Lint clean, build clean --- src/application/use-cases/upload-file.ts | 36 +---- src/config/index.ts | 3 - src/index.ts | 2 +- .../persistence/drizzle/schema.ts | 13 -- .../telegram/chunked-storage.ts | 42 +----- .../http/controllers/auth-controller.ts | 2 +- .../http/controllers/s3-controller.ts | 2 +- .../http/controllers/upload-controller.ts | 2 +- .../http/controllers/web-api-controller.ts | 2 +- src/interfaces/http/middleware/auth.ts | 2 +- src/interfaces/http/middleware/rate-limit.ts | 2 +- src/interfaces/http/routes/index.ts | 2 +- src/shared/errors/index.ts | 73 ---------- src/shared/utils/compress.ts | 32 +++++ src/shared/utils/retry.ts | 135 ------------------ 15 files changed, 46 insertions(+), 304 deletions(-) delete mode 100644 src/config/index.ts delete mode 100644 src/shared/errors/index.ts create mode 100644 src/shared/utils/compress.ts delete mode 100644 src/shared/utils/retry.ts diff --git a/src/application/use-cases/upload-file.ts b/src/application/use-cases/upload-file.ts index 07ca9b9..bcf3a94 100644 --- a/src/application/use-cases/upload-file.ts +++ b/src/application/use-cases/upload-file.ts @@ -1,18 +1,14 @@ -import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { open } from 'node:fs/promises'; -import { gzipSync } from 'node:zlib'; import { nanoid } from 'nanoid'; import type { NewFilePart } from '../../domain/entities/file-part'; import type { IFilePartRepository } from '../../domain/ports/file-part-repository'; import type { IFileRepository } from '../../domain/ports/file-repository'; import type { ITelegramService } from '../../domain/ports/telegram-service'; +import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress'; import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file'; import type { UploadInput, UploadOutput } from '../dto/upload'; -/** Compression algorithm string literal used in chunked storage. */ -type ChunkCompressionAlgorithm = 'gzip' | null; - /** Metadata for a single uploaded chunk/part. */ interface UploadedPart { /** 1-based part number. */ @@ -28,7 +24,7 @@ interface UploadedPart { /** Stored (post-compression) size in bytes. */ storedSizeBytes: number; /** Compression algorithm applied, or null if uncompressed. */ - compressionAlgorithm: ChunkCompressionAlgorithm; + compressionAlgorithm: CompressionAlgorithm; /** SHA-256 hash of the original chunk content. */ etag: string; } @@ -82,32 +78,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => { return chunkSizeBytes; }; -/** - * Optionally gzip-compresses a chunk if compression is enabled and the chunk - * is large enough to benefit from it. - * - * @param chunk - The raw chunk buffer. - * @param compress - Whether compression is enabled. - * @param compressionMinSizeBytes - Minimum chunk size to attempt compression. - * @returns The (possibly compressed) buffer and the algorithm used. - */ -const maybeCompressChunk = ( - chunk: Buffer, - compress: boolean, - compressionMinSizeBytes: number, -): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => { - if (!compress || chunk.byteLength < compressionMinSizeBytes) { - return { bytes: chunk, compressionAlgorithm: null }; - } - - const gzipped = gzipSync(chunk); - if (gzipped.byteLength >= chunk.byteLength) { - return { bytes: chunk, compressionAlgorithm: null }; - } - - return { bytes: gzipped, compressionAlgorithm: 'gzip' }; -}; - /** * Reads the first 16 bytes from a file on disk for magic-byte detection. * @@ -258,7 +228,7 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) { throw new Error('Chunked upload produced no parts'); } - const fileId = randomUUID(); + const fileId = nanoid(); const publicId = nanoid(); const newFile = await deps.fileRepo.create({ diff --git a/src/config/index.ts b/src/config/index.ts deleted file mode 100644 index 744996d..0000000 --- a/src/config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { config } from '../env'; - -export { config }; diff --git a/src/index.ts b/src/index.ts index 79dd2a3..33ab8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { serve } from 'bun'; -import { config } from './config/index'; +import { config } from './env'; import { fileInfoCache } from './infrastructure/cache/index'; import { startBot } from './interfaces/bot/handler'; import { handleS3Request } from './interfaces/http/controllers/s3-controller'; diff --git a/src/infrastructure/persistence/drizzle/schema.ts b/src/infrastructure/persistence/drizzle/schema.ts index d7b3e58..1f9e322 100644 --- a/src/infrastructure/persistence/drizzle/schema.ts +++ b/src/infrastructure/persistence/drizzle/schema.ts @@ -1,4 +1,3 @@ -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; import { bigint, boolean, @@ -63,15 +62,3 @@ export const fileParts = pgTable('file_parts', { etag: text('etag').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); - -/** Type representing a file row selected from the database. */ -export type File = InferSelectModel; - -/** Type representing a file row being inserted into the database. */ -export type NewFile = InferInsertModel; - -/** Type representing a file part row selected from the database. */ -export type FilePart = InferSelectModel; - -/** Type representing a file part row being inserted into the database. */ -export type NewFilePart = InferInsertModel; diff --git a/src/infrastructure/telegram/chunked-storage.ts b/src/infrastructure/telegram/chunked-storage.ts index a386820..b4d34fc 100644 --- a/src/infrastructure/telegram/chunked-storage.ts +++ b/src/infrastructure/telegram/chunked-storage.ts @@ -1,22 +1,16 @@ import { createReadStream } from 'node:fs'; -import { gzipSync } from 'node:zlib'; import { nanoid } from 'nanoid'; import type { File as FileEntity } from '../../domain/entities/file'; -import type { CompressionAlgorithm, NewFilePart } from '../../domain/entities/file-part'; +import type { NewFilePart } from '../../domain/entities/file-part'; import type { IFilePartRepository } from '../../domain/ports/file-part-repository'; import type { IFileRepository } from '../../domain/ports/file-repository'; import type { ITelegramService } from '../../domain/ports/telegram-service'; import { config } from '../../env'; import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream'; import type { RangeParseResult } from '../../interfaces/s3/range'; +import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress'; import { computeHash } from '../../shared/utils/file'; -/** - * Chunk compression algorithm identifier. - * `"gzip"` if gzip compression was applied, `null` for uncompressed. - */ -export type ChunkCompressionAlgorithm = CompressionAlgorithm; - /** * Metadata about a single uploaded chunk (part) stored in Telegram. */ @@ -34,7 +28,7 @@ export interface ChunkedUploadPart { /** Stored (post-compression) size in bytes */ storedSizeBytes: number; /** Compression algorithm applied, or null */ - compressionAlgorithm: ChunkCompressionAlgorithm; + compressionAlgorithm: CompressionAlgorithm; /** ETag (SHA-256 hash) of the original chunk */ etag: string; } @@ -89,36 +83,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => { return chunkSizeBytes; }; -/** - * Optionally compress a chunk with gzip. - * - * Compression is skipped if: - * - The `compress` flag is false. - * - The chunk is smaller than `compressionMinSizeBytes`. - * - The compressed result is larger than the original. - * - * @param chunk - The raw chunk buffer. - * @param compress - Whether compression is enabled. - * @param compressionMinSizeBytes - Minimum chunk size to attempt compression. - * @returns The (possibly compressed) bytes and the algorithm used. - */ -const maybeCompressChunk = ( - chunk: Buffer, - compress: boolean, - compressionMinSizeBytes: number, -): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => { - if (!compress || chunk.byteLength < compressionMinSizeBytes) { - return { bytes: chunk, compressionAlgorithm: null }; - } - - const gzipped = gzipSync(chunk); - if (gzipped.byteLength >= chunk.byteLength) { - return { bytes: chunk, compressionAlgorithm: null }; - } - - return { bytes: gzipped, compressionAlgorithm: 'gzip' }; -}; - /** * Manages chunked storage of large files in Telegram. * diff --git a/src/interfaces/http/controllers/auth-controller.ts b/src/interfaces/http/controllers/auth-controller.ts index eed6a1d..2ae4a90 100644 --- a/src/interfaces/http/controllers/auth-controller.ts +++ b/src/interfaces/http/controllers/auth-controller.ts @@ -4,7 +4,7 @@ import { createLogoutUseCase, createMeUseCase, } from '../../../application/use-cases/authenticate'; -import { config } from '../../../config/index'; +import { config } from '../../../env'; import { checkBearerToken, clearSessionCookie, diff --git a/src/interfaces/http/controllers/s3-controller.ts b/src/interfaces/http/controllers/s3-controller.ts index 7f5fc72..2efed5b 100644 --- a/src/interfaces/http/controllers/s3-controller.ts +++ b/src/interfaces/http/controllers/s3-controller.ts @@ -1,8 +1,8 @@ import { createReadStream } from 'node:fs'; import { nanoid } from 'nanoid'; -import { config } from '../../../config/index'; import type { File as FileEntity } from '../../../domain/entities/file'; import type { ForwardResult } from '../../../domain/ports/telegram-service'; +import { config } from '../../../env'; import { bucketRepository, chunkedStorage, diff --git a/src/interfaces/http/controllers/upload-controller.ts b/src/interfaces/http/controllers/upload-controller.ts index d5caae5..2c1ef10 100644 --- a/src/interfaces/http/controllers/upload-controller.ts +++ b/src/interfaces/http/controllers/upload-controller.ts @@ -1,6 +1,6 @@ import { createWriteStream } from 'node:fs'; import { nanoid } from 'nanoid'; -import { config } from '../../../config/index'; +import { config } from '../../../env'; import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di'; import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher'; import logger from '../../../shared/logger/index'; diff --git a/src/interfaces/http/controllers/web-api-controller.ts b/src/interfaces/http/controllers/web-api-controller.ts index 4264093..78b7515 100644 --- a/src/interfaces/http/controllers/web-api-controller.ts +++ b/src/interfaces/http/controllers/web-api-controller.ts @@ -1,6 +1,6 @@ import { createReadStream } from 'node:fs'; import { nanoid } from 'nanoid'; -import { config } from '../../../config/index'; +import { config } from '../../../env'; import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di'; import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index'; import { botPool } from '../../../infrastructure/telegram/bot-pool'; diff --git a/src/interfaces/http/middleware/auth.ts b/src/interfaces/http/middleware/auth.ts index 96ed804..23901fd 100644 --- a/src/interfaces/http/middleware/auth.ts +++ b/src/interfaces/http/middleware/auth.ts @@ -1,5 +1,5 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; -import { config } from '../../../config/index'; +import { config } from '../../../env'; const ADMIN_USERNAME = 'admin'; const SIGNATURE_SEPARATOR = '.'; diff --git a/src/interfaces/http/middleware/rate-limit.ts b/src/interfaces/http/middleware/rate-limit.ts index 84332dd..f7829e1 100644 --- a/src/interfaces/http/middleware/rate-limit.ts +++ b/src/interfaces/http/middleware/rate-limit.ts @@ -1,4 +1,4 @@ -import { config } from '../../../config/index'; +import { config } from '../../../env'; import logger from '../../../shared/logger/index'; import { extractClientIp } from '../../../shared/utils/ip'; diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts index 02f2a2b..f54c608 100644 --- a/src/interfaces/http/routes/index.ts +++ b/src/interfaces/http/routes/index.ts @@ -1,4 +1,4 @@ -import { config } from '../../../config/index'; +import { config } from '../../../env'; import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger'; import { isS3Request } from '../../s3/auth'; import { extractS3BucketFromHost } from '../../s3/virtual-host'; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts deleted file mode 100644 index 180f704..0000000 --- a/src/shared/errors/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Base domain error class for all application-specific errors. - * Extends the built-in Error with a fixed name property for reliable - * instance checking across layers. - */ -export class DomainError extends Error { - constructor(msg: string) { - super(msg); - this.name = 'DomainError'; - } -} - -/** - * Thrown when a requested file cannot be found in storage. - */ -export class FileNotFoundError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'FileNotFoundError'; - } -} - -/** - * Thrown when a requested bucket does not exist. - */ -export class BucketNotFoundError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'BucketNotFoundError'; - } -} - -/** - * Thrown when a file exceeds the maximum allowed size for upload. - */ -export class FileTooLargeError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'FileTooLargeError'; - } -} - -/** - * Thrown when an attempt is made to upload a file that already exists - * (detected by content hash deduplication). - */ -export class DuplicateFileError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'DuplicateFileError'; - } -} - -/** - * Thrown when authentication fails or a valid session is not present. - */ -export class AuthenticationError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'AuthenticationError'; - } -} - -/** - * Thrown when input validation fails (e.g. missing required fields, - * invalid format, or constraint violations). - */ -export class ValidationError extends DomainError { - constructor(msg: string) { - super(msg); - this.name = 'ValidationError'; - } -} diff --git a/src/shared/utils/compress.ts b/src/shared/utils/compress.ts new file mode 100644 index 0000000..bbebb1b --- /dev/null +++ b/src/shared/utils/compress.ts @@ -0,0 +1,32 @@ +/** Compression algorithm for chunked file storage. */ +export type CompressionAlgorithm = 'gzip' | null; + +/** + * Optionally compress a chunk with gzip. + * + * Compression is skipped if: + * - The `compress` flag is false. + * - The chunk is smaller than `compressionMinSizeBytes`. + * - The compressed result is larger than the original. + * + * @param chunk - The raw chunk buffer. + * @param compress - Whether compression is enabled. + * @param compressionMinSizeBytes - Minimum chunk size to attempt compression. + * @returns The (possibly compressed) bytes and the algorithm used. + */ +export const maybeCompressChunk = ( + chunk: Buffer, + compress: boolean, + compressionMinSizeBytes: number, +): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => { + if (!compress || chunk.byteLength < compressionMinSizeBytes) { + return { bytes: chunk, compressionAlgorithm: null }; + } + + const gzipped = Bun.gzipSync(chunk); + if (gzipped.byteLength >= chunk.byteLength) { + return { bytes: chunk, compressionAlgorithm: null }; + } + + return { bytes: gzipped, compressionAlgorithm: 'gzip' }; +}; diff --git a/src/shared/utils/retry.ts b/src/shared/utils/retry.ts deleted file mode 100644 index 6d2a024..0000000 --- a/src/shared/utils/retry.ts +++ /dev/null @@ -1,135 +0,0 @@ -import logger from '../logger/index'; - -/** Configuration options for retry behaviour. */ -interface RetryOptions { - /** Maximum number of retry attempts (default: 3). */ - maxRetries?: number; - /** Delay before the first retry in milliseconds (default: 100). */ - initialDelayMs?: number; - /** Maximum delay between retries in milliseconds (default: 5000). */ - maxDelayMs?: number; - /** Multiplier for exponential backoff (default: 2). */ - backoffMultiplier?: number; - /** - * Predicate that determines whether a given error should trigger a retry. - * When omitted, transient network / timeout errors are retried. - */ - shouldRetry?: (error: unknown) => boolean; -} - -const DEFAULT_OPTIONS: Required = { - maxRetries: 3, - initialDelayMs: 100, - maxDelayMs: 5000, - backoffMultiplier: 2, - shouldRetry: (error: unknown) => { - const errorStr = error instanceof Error ? error.message : String(error); - // Retry on transient errors - return ( - errorStr.includes('ECONNREFUSED') || - errorStr.includes('ETIMEDOUT') || - errorStr.includes('ENOTFOUND') || - errorStr.includes('429') || - errorStr.includes('timeout') - ); - }, -}; - -/** - * Executes an async function with exponential backoff retry logic. - * - * The function is retried up to `maxRetries` times. Between attempts the - * delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors - * for which `shouldRetry` returns `true` trigger a retry; all others are - * thrown immediately. When all retries are exhausted the last error is - * thrown. - * - * @param fn - The async function to execute. - * @param options - Optional retry configuration overrides. - * @returns The resolved value of `fn`. - */ -export const withRetry = async ( - fn: () => Promise, - options: RetryOptions = {}, -): Promise => { - const opts = { ...DEFAULT_OPTIONS, ...options }; - let lastError: unknown; - let delay = opts.initialDelayMs; - - for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { - try { - return await fn(); - } catch (error: unknown) { - lastError = error; - const errorStr = error instanceof Error ? error.message : String(error); - - if (attempt === opts.maxRetries || !opts.shouldRetry(error)) { - logger.error('Retry exhausted', { - attempt, - maxRetries: opts.maxRetries, - error: errorStr, - }); - throw error; - } - - logger.warn('Retrying after error', { - attempt, - delay, - error: errorStr, - }); - - await new Promise((resolve) => setTimeout(resolve, delay)); - delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs); - } - } - - throw lastError; -}; - -/** - * Wraps an async function with a configurable timeout. - * - * If `fn` does not settle within `timeoutMs` milliseconds the returned - * promise rejects with a timeout error. The underlying `fn` continues - * executing but its result is ignored. - * - * @param fn - The async function to execute. - * @param timeoutMs - Timeout in milliseconds (default: 30000). - * @returns The resolved value of `fn`. - */ -export const withTimeout = async ( - fn: () => Promise, - timeoutMs: number = 30000, -): Promise => { - return Promise.race([ - fn(), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs), - ), - ]); -}; - -/** - * Executes a primary async function and falls back to a secondary function - * if the primary throws. - * - * The fallback function is called only when the primary rejects. If the - * fallback also throws the error propagates to the caller. - * - * @param primary - The primary async function to attempt first. - * @param fallback - The fallback async function invoked on failure. - * @returns The resolved value of `primary` or, on failure, of `fallback`. - */ -export const withFallback = async ( - primary: () => Promise, - fallback: () => Promise, -): Promise => { - try { - return await primary(); - } catch (error: unknown) { - logger.warn('Primary operation failed, using fallback', { - error: error instanceof Error ? error.message : String(error), - }); - return fallback(); - } -};