chore: dedup dead code + compress utility
Deploy FileDrop / deploy (push) Successful in 43s
Deploy FileDrop / deploy (push) Successful in 43s
- 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
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { config } from '../env';
|
||||
|
||||
export { config };
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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<typeof files>;
|
||||
|
||||
/** Type representing a file row being inserted into the database. */
|
||||
export type NewFile = InferInsertModel<typeof files>;
|
||||
|
||||
/** Type representing a file part row selected from the database. */
|
||||
export type FilePart = InferSelectModel<typeof fileParts>;
|
||||
|
||||
/** Type representing a file part row being inserted into the database. */
|
||||
export type NewFilePart = InferInsertModel<typeof fileParts>;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createLogoutUseCase,
|
||||
createMeUseCase,
|
||||
} from '../../../application/use-cases/authenticate';
|
||||
import { config } from '../../../config/index';
|
||||
import { config } from '../../../env';
|
||||
import {
|
||||
checkBearerToken,
|
||||
clearSessionCookie,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '.';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
};
|
||||
@@ -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<RetryOptions> = {
|
||||
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 <T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> => {
|
||||
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 <T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number = 30000,
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
fn(),
|
||||
new Promise<T>((_, 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 <T>(
|
||||
primary: () => Promise<T>,
|
||||
fallback: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await primary();
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Primary operation failed, using fallback', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallback();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user