fix: add explicit snake_case-to-camelCase mapping for S3FileRecord, add missing findOrphanFilesByBucket

This commit is contained in:
asepharyana
2026-07-06 14:33:27 +07:00
parent caef31c703
commit a2ca489039
3 changed files with 3421 additions and 25 deletions
+46 -25
View File
@@ -2,11 +2,6 @@ import { eq, and, sql } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';
interface QueryResult {
rows: Record<string, unknown>[];
rowCount: number;
}
export interface S3FileRecord extends File {
bucketId: string;
s3Key: string;
@@ -30,6 +25,36 @@ export const findFileByBucketAndKey = async (
return result[0] || null;
};
const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => {
return {
id: row.id as string,
publicId: row.public_id as string,
telegramFileId: row.telegram_file_id as string,
telegramFileUniqueId: row.telegram_file_unique_id as string,
storageChatId: row.storage_chat_id as number,
storageMessageId: row.storage_message_id as number,
fileName: row.file_name as string,
mimeType: row.mime_type as string,
sizeBytes: row.size_bytes as number,
fileType: row.file_type as string,
uploaderId: row.uploader_id as number,
fileHash: row.file_hash as string | null,
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
archiveStorageMessageId: row.archive_storage_message_id as number | null,
archiveFileName: row.archive_file_name as string | null,
archiveEntryName: row.archive_entry_name as string | null,
archiveMimeType: row.archive_mime_type as string | null,
archiveSizeBytes: row.archive_size_bytes as number | null,
bucketId: row.bucket_id as string,
s3Key: row.s3_key as string,
storageBackend: (row.storage_backend as string) || 'telegram',
isDeleted: row.is_deleted as boolean,
multipartUploadId: row.multipart_upload_id as string | null,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
};
export const listObjectsByPrefix = async (
bucketId: string,
prefix: string,
@@ -37,7 +62,6 @@ export const listObjectsByPrefix = async (
maxKeys: number,
startAfter: string | null,
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => {
// Use raw SQL for the complex prefix/startAfter query
let query = sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${prefix + '%'}`;
if (startAfter) {
@@ -46,29 +70,25 @@ export const listObjectsByPrefix = async (
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
const result = (await db.execute(query)) as unknown as QueryResult;
const rawResult = (await db.execute(query)) as unknown as {
rows: Record<string, unknown>[];
};
if (delimiter === '/') {
const prefixSet = new Set<string>();
const objects: S3FileRecord[] = [];
for (const row of result.rows) {
for (const row of rawResult.rows) {
const s3Key = row.s3_key as string;
const relativeKey = s3Key.substring(prefix.length);
const slashIndex = relativeKey.indexOf('/');
if (slashIndex >= 0) {
// It's under a subfolder — extract the folder prefix
const folderPrefix = prefix + relativeKey.substring(0, slashIndex + 1);
if (folderPrefix !== prefix) {
prefixSet.add(folderPrefix);
}
} else {
// It's a direct child object
objects.push({
...row,
bucketId: row.bucket_id as string,
s3Key: s3Key,
} as unknown as S3FileRecord);
objects.push(mapDbRowToS3Record(row));
}
}
@@ -79,14 +99,7 @@ export const listObjectsByPrefix = async (
}
return {
objects: result.rows.slice(0, maxKeys).map(
(row) =>
({
...row,
bucketId: row.bucket_id as string,
s3Key: row.s3_key as string,
}) as unknown as S3FileRecord,
),
objects: rawResult.rows.slice(0, maxKeys).map(mapDbRowToS3Record),
prefixes: [],
};
};
@@ -94,7 +107,7 @@ export const listObjectsByPrefix = async (
export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<boolean> => {
const result = (await db.execute(
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
)) as unknown as QueryResult;
)) as unknown as { rows: Record<string, unknown>[] };
return result.rows.length > 0;
};
@@ -113,6 +126,14 @@ export const softDeleteFilesBatch = async (
export const countBucketObjects = async (bucketId: string): Promise<number> => {
const result = (await db.execute(
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
)) as unknown as QueryResult;
)) as unknown as { rows: Record<string, unknown>[] };
return Number(result.rows[0]?.count || 0);
};
export const findOrphanFilesByBucket = async (bucketId: string): Promise<File[]> => {
return await db
.select()
.from(fileSchema)
.where(and(eq(fileSchema.bucketId, bucketId), eq(fileSchema.isDeleted, true)))
.limit(100);
};