fix: correct import paths and add missing protocol files for DDD structure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-28 18:19:45 +07:00
parent 234ca7b14c
commit 4568644922
34 changed files with 6097 additions and 5 deletions
@@ -0,0 +1,16 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { fileParts, files } from './schema';
const client = postgres(process.env.DATABASE_URL!, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
/** Drizzle ORM database client initialized with the files and fileParts schema. */
export const db = drizzle(client, { schema: { fileParts, files } });
export { fileParts, files };
export default db;
@@ -0,0 +1,56 @@
import postgres from 'postgres';
import { config } from '../../../env';
import { getErrorMessage } from '../../../shared/utils/file';
import logger from '../../../shared/logger/index';
/**
* Run raw SQL migration from schema.sql.
* Safe to call multiple times — all statements use IF NOT EXISTS.
* Searches multiple relative paths to support execution from compiled dist,
* bun --hot, or direct script invocation.
*/
export const runMigration = async (): Promise<void> => {
// In compiled dist: import.meta.dir = .../dist/infrastructure/persistence/drizzle/
// In source via bun --hot: import.meta.dir = .../src/infrastructure/persistence/drizzle/
const dir = import.meta.dir || '';
const candidates = [
`${dir}/../../../../schema.sql`, // from dist/
`${dir}/../../../schema.sql`, // from src/infrastructure/persistence/
`${dir}/../../schema.sql`, // from src/infrastructure/
`${dir}/../schema.sql`, // from src/infrastructure/persistence/drizzle/
`${dir}/schema.sql`, // from next to file (bun run directly)
];
let schemaSql: string | null = null;
for (const p of candidates) {
const file = Bun.file(p);
const exists = await file.exists();
if (exists) {
schemaSql = await file.text();
break;
}
}
if (!schemaSql) {
logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`);
process.exitCode = 1;
return;
}
const sql = postgres(config.databaseUrl, { max: 1 });
try {
await sql.unsafe(schemaSql);
logger.info('Database migration completed');
} catch (error: unknown) {
logger.error('Database migration failed', { error: getErrorMessage(error) });
process.exitCode = 1;
} finally {
await sql.end();
}
};
// When run directly: `bun src/infrastructure/persistence/drizzle/migrate.ts`
if (import.meta.path === Bun.main) {
await runMigration();
}
@@ -0,0 +1,77 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import {
bigint,
boolean,
integer,
pgTable,
serial,
text,
timestamp,
uuid,
} from 'drizzle-orm/pg-core';
/**
* Files table definition.
* Stores metadata about uploaded files including Telegram storage references,
* S3 bucket information, multipart upload tracking, and archive entries.
*/
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
publicId: text('public_id').unique().notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
fileType: text('file_type').notNull(),
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
fileHash: text('file_hash'),
archiveTelegramFileId: text('archive_telegram_file_id'),
archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }),
archiveFileName: text('archive_file_name'),
archiveEntryName: text('archive_entry_name'),
archiveMimeType: text('archive_mime_type'),
archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }),
bucketId: text('bucket_id'),
s3Key: text('s3_key'),
storageBackend: text('storage_backend').default('telegram'),
isDeleted: boolean('is_deleted').default(false),
multipartUploadId: text('multipart_upload_id'),
partCount: integer('part_count'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
/**
* File parts table definition.
* Stores chunks of multipart uploads with per-part Telegram storage references
* and compression metadata.
*/
export const fileParts = pgTable('file_parts', {
id: serial('id').primaryKey(),
fileId: uuid('file_id').notNull(),
partNumber: integer('part_number').notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
storedSizeBytes: bigint('stored_size_bytes', { mode: 'number' }).notNull(),
compressionAlgorithm: text('compression_algorithm'),
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>;
@@ -0,0 +1,100 @@
import { sql } from 'drizzle-orm';
import { db } from '../drizzle/index';
import type { Bucket } from '../../../domain/entities/bucket';
import type { IBucketRepository } from '../../../domain/ports/bucket-repository';
/** Raw result row from `db.execute()`. */
type QueryRow = Record<string, unknown>;
/** Array of raw result rows. */
type QueryResult = QueryRow[];
/**
* Maps a raw database row to a {@link Bucket} domain entity.
*/
const mapRowToBucket = (row: Record<string, unknown>): Bucket => ({
id: row.id as string,
name: row.name as string,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
});
/**
* Drizzle-backed implementation of {@link IBucketRepository}.
*
* Delegates to the same SQL queries as the original `src/db/buckets.ts`
* module, using raw SQL for drizzle tables that are not part of the
* typed schema.
*/
export class DrizzleBucketRepository implements IBucketRepository {
/**
* {@inheritDoc IBucketRepository.create}
*/
async create(name: string): Promise<Bucket> {
const result = (await db.execute(
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
)) as unknown as QueryResult;
return mapRowToBucket(result[0]!);
}
/**
* {@inheritDoc IBucketRepository.findByName}
*/
async findByName(name: string): Promise<Bucket | null> {
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
if (result.length === 0) return null;
return mapRowToBucket(result[0]!);
}
/**
* {@inheritDoc IBucketRepository.list}
*/
async list(): Promise<Bucket[]> {
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
)) as unknown as QueryResult;
return result.map(mapRowToBucket);
}
/**
* {@inheritDoc IBucketRepository.delete}
*
* Cascade-deletes multipart and file rows that hold foreign-key
* references to the bucket before deleting the bucket itself.
* Failures during cascade are silently caught to match the original
* defensive-cleanup behaviour.
*/
async delete(name: string): Promise<boolean> {
// Cascade-delete rows that hold FK references to the bucket
await db
.execute(
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
const result = (await db.execute(
sql`DELETE FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.length > 0;
}
/**
* {@inheritDoc IBucketRepository.exists}
*/
async exists(name: string): Promise<boolean> {
const result = (await db.execute(
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.length > 0;
}
}
@@ -0,0 +1,106 @@
import { sql } from 'drizzle-orm';
import { db } from '../drizzle/index';
import type { FilePart, NewFilePart } from '../../../domain/entities/file-part';
import type { IFilePartRepository } from '../../../domain/ports/file-part-repository';
/** Compression algorithm type matching the domain entity. */
type CompressionAlgorithm = 'gzip' | null;
/** Safely converts a raw value to a number, defaulting to 0. */
const toNumber = (value: unknown): number => Number(value ?? 0);
/**
* Maps a raw database row (snake_case keys) to a {@link FilePart}
* domain entity.
*/
const mapRowToFilePart = (row: Record<string, unknown>): FilePart => ({
id: toNumber(row.id),
fileId: row.file_id as string,
partNumber: toNumber(row.part_number),
telegramFileId: row.telegram_file_id as string,
telegramFileUniqueId: row.telegram_file_unique_id as string,
storageChatId: toNumber(row.storage_chat_id),
storageMessageId: toNumber(row.storage_message_id),
sizeBytes: toNumber(row.size_bytes),
storedSizeBytes: toNumber(row.stored_size_bytes),
compressionAlgorithm:
(row.compression_algorithm as CompressionAlgorithm) || null,
etag: row.etag as string,
createdAt: new Date(row.created_at as string),
});
/**
* Drizzle-backed implementation of {@link IFilePartRepository}.
*
* Delegates to the same SQL queries as the original `src/db/file-parts.ts`
* module, using raw SQL for all operations.
*/
export class DrizzleFilePartRepository implements IFilePartRepository {
/**
* {@inheritDoc IFilePartRepository.insert}
*/
async insert(parts: NewFilePart[]): Promise<void> {
for (const part of parts) {
await db.execute(
sql`INSERT INTO file_parts (
file_id,
part_number,
telegram_file_id,
telegram_file_unique_id,
storage_chat_id,
storage_message_id,
size_bytes,
stored_size_bytes,
compression_algorithm,
etag
) VALUES (
${part.fileId}::uuid,
${part.partNumber},
${part.telegramFileId},
${part.telegramFileUniqueId},
${part.storageChatId},
${part.storageMessageId},
${part.sizeBytes},
${part.storedSizeBytes},
${part.compressionAlgorithm},
${part.etag}
)`,
);
}
}
/**
* {@inheritDoc IFilePartRepository.listByFileId}
*/
async listByFileId(fileId: string): Promise<FilePart[]> {
const result = (await db.execute(
sql`SELECT id,
file_id,
part_number,
telegram_file_id,
telegram_file_unique_id,
storage_chat_id,
storage_message_id,
size_bytes,
stored_size_bytes,
compression_algorithm,
etag,
created_at
FROM file_parts
WHERE file_id = ${fileId}::uuid
ORDER BY part_number`,
)) as unknown as Record<string, unknown>[];
return result.map(mapRowToFilePart);
}
/**
* {@inheritDoc IFilePartRepository.countByFileId}
*/
async countByFileId(fileId: string): Promise<number> {
const result = (await db.execute(
sql`SELECT COUNT(*) AS count FROM file_parts WHERE file_id = ${fileId}::uuid`,
)) as unknown as Record<string, unknown>[];
return toNumber(result[0]?.count);
}
}
@@ -0,0 +1,241 @@
import { and, eq, sql } from 'drizzle-orm';
import { db, files as fileSchema } from '../drizzle/index';
import type { File, NewFile } from '../../../domain/entities/file';
import type {
IFileRepository,
S3FileRecord,
} from '../../../domain/ports/file-repository';
/** Safely converts a raw value to a number, defaulting to 0. */
const toNumber = (value: unknown): number => Number(value ?? 0);
/**
* Escape special LIKE wildcard characters (`%`, `_`, `\`) so that
* a user-supplied prefix can be safely used in a LIKE expression.
*/
const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&');
/**
* Maps a raw database row (snake_case keys) to an {@link S3FileRecord}
* domain entity. Used only when raw SQL via `db.execute()` returns
* un-typed result sets.
*/
const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => ({
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: toNumber(row.storage_chat_id),
storageMessageId: toNumber(row.storage_message_id),
fileName: row.file_name as string,
mimeType: row.mime_type as string,
sizeBytes: toNumber(row.size_bytes),
fileType: row.file_type as string,
uploaderId: toNumber(row.uploader_id),
fileHash: row.file_hash as string | null,
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
archiveStorageMessageId:
row.archive_storage_message_id === null
? null
: toNumber(row.archive_storage_message_id),
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 === null
? null
: toNumber(row.archive_size_bytes),
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,
partCount:
row.part_count === null || row.part_count === undefined
? null
: toNumber(row.part_count),
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
});
/**
* Drizzle-backed implementation of {@link IFileRepository}.
*
* Delegates to the same SQL queries as the original `src/db/files.ts` and
* `src/db/files-ext.ts` modules while presenting a clean domain interface.
*/
export class DrizzleFileRepository implements IFileRepository {
/**
* {@inheritDoc IFileRepository.findByHash}
*/
async findByHash(hash: string): Promise<File | null> {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
return result[0] || null;
}
/**
* {@inheritDoc IFileRepository.findByPublicId}
*/
async findByPublicId(publicId: string): Promise<File | null> {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.publicId, publicId))
.limit(1);
return result[0] || null;
}
/**
* {@inheritDoc IFileRepository.findByUniqueId}
*/
async findByUniqueId(telegramFileUniqueId: string): Promise<File | null> {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
.limit(1);
return result[0] || null;
}
/**
* {@inheritDoc IFileRepository.findByBucketAndKey}
*/
async findByBucketAndKey(
bucketId: string,
s3Key: string,
): Promise<File | null> {
const result = await db
.select()
.from(fileSchema)
.where(
and(
eq(fileSchema.bucketId, bucketId),
eq(fileSchema.s3Key, s3Key),
eq(fileSchema.isDeleted, false),
),
)
.limit(1);
return result[0] || null;
}
/**
* {@inheritDoc IFileRepository.create}
*/
async create(file: NewFile): Promise<File> {
const result = await db
.insert(fileSchema)
.values(file)
.returning();
return result[0]!;
}
/**
* {@inheritDoc IFileRepository.listByPrefix}
*/
async listByPrefix(
bucketId: string,
prefix: string,
delimiter: string | null,
maxKeys: number,
startAfter: string | null,
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> {
let query = prefix
? sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${escapeLike(prefix)}%`}`
: sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`;
if (startAfter) {
query = sql`${query} AND s3_key > ${startAfter}`;
}
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
const rawResult = (await db.execute(
query,
)) as unknown as Record<string, unknown>[];
if (delimiter === '/') {
const prefixSet = new Set<string>();
const objects: S3FileRecord[] = [];
for (const row of rawResult) {
const s3Key = row.s3_key as string;
const relativeKey = s3Key.substring(prefix.length);
const slashIndex = relativeKey.indexOf('/');
if (slashIndex >= 0) {
const folderPrefix =
prefix + relativeKey.substring(0, slashIndex + 1);
if (folderPrefix !== prefix) {
prefixSet.add(folderPrefix);
}
} else {
objects.push(mapDbRowToS3Record(row));
}
}
return {
objects: objects.slice(0, maxKeys),
prefixes: Array.from(prefixSet).sort(),
};
}
return {
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
prefixes: [],
};
}
/**
* {@inheritDoc IFileRepository.softDelete}
*/
async softDelete(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 Record<string, unknown>[];
return result.length > 0;
}
/**
* {@inheritDoc IFileRepository.softDeleteBatch}
*/
async softDeleteBatch(
bucketId: string,
keys: string[],
): Promise<number> {
let deleted = 0;
for (const key of keys) {
const ok = await this.softDelete(bucketId, key);
if (ok) deleted++;
}
return deleted;
}
/**
* {@inheritDoc IFileRepository.countByBucket}
*/
async countByBucket(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 Record<string, unknown>[];
return Number(result[0]?.count || 0);
}
/**
* {@inheritDoc IFileRepository.findOrphansByBucket}
*/
async findOrphansByBucket(bucketId: string): Promise<File[]> {
return await db
.select()
.from(fileSchema)
.where(
and(
eq(fileSchema.bucketId, bucketId),
eq(fileSchema.isDeleted, true),
),
)
.limit(100);
}
}
@@ -0,0 +1,149 @@
import { sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { db } from '../drizzle/index';
import type { MultipartUpload, MultipartPart } from '../../../domain/entities/multipart';
import type { IMultipartRepository } from '../../../domain/ports/multipart-repository';
/**
* Maps a raw database row to a {@link MultipartUpload} domain entity.
*/
const mapRowToMultipartUpload = (
r: Record<string, unknown>,
): MultipartUpload => ({
uploadId: r.upload_id as string,
bucketId: r.bucket_id as string,
s3Key: r.s3_key as string,
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: (r.initiated_by as string | null) || '',
});
/**
* Drizzle-backed implementation of {@link IMultipartRepository}.
*
* Delegates to the same SQL queries as the original `src/db/multipart.ts`
* module, using raw SQL for all operations on the un-typed
* `multipart_uploads` and `multipart_parts` tables.
*/
export class DrizzleMultipartRepository implements IMultipartRepository {
/**
* {@inheritDoc IMultipartRepository.create}
*/
async create(
bucketId: string,
s3Key: string,
initiatedBy: string,
): Promise<string> {
const uploadId = nanoid(32);
await db.execute(
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
);
return uploadId;
}
/**
* {@inheritDoc IMultipartRepository.findById}
*/
async findById(uploadId: string): Promise<MultipartUpload | null> {
const result = (await db.execute(
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
)) as unknown as Record<string, unknown>[];
if (result.length === 0) return null;
const r = result[0]!;
return {
uploadId: r.upload_id as string,
bucketId: r.bucket_id as string,
s3Key: r.s3_key as string,
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: '',
};
}
/**
* {@inheritDoc IMultipartRepository.complete}
*/
async complete(uploadId: string): Promise<void> {
await db.execute(
sql`UPDATE multipart_uploads SET status = 'completed' WHERE upload_id = ${uploadId}`,
);
}
/**
* {@inheritDoc IMultipartRepository.abort}
*/
async abort(uploadId: string): Promise<void> {
await db.execute(
sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`,
);
}
/**
* {@inheritDoc IMultipartRepository.insertPart}
*/
async insertPart(
part: Omit<MultipartPart, 'id' | 'createdAt'>,
): Promise<void> {
await db.execute(
sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag)
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
);
}
/**
* {@inheritDoc IMultipartRepository.listParts}
*/
async listParts(uploadId: string): Promise<MultipartPart[]> {
const result = (await db.execute(
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
)) as unknown as Record<string, unknown>[];
return result.map((r) => ({
id: r.id as number,
uploadId: r.upload_id as string,
partNumber: r.part_number as number,
telegramFileId: r.telegram_file_id as string,
telegramFileUniqueId: r.telegram_file_unique_id as string,
storageMessageId: r.storage_message_id as number,
sizeBytes: Number(r.size_bytes),
etag: r.etag as string,
createdAt: new Date(r.created_at as string),
}));
}
/**
* {@inheritDoc IMultipartRepository.listByBucket}
*/
async listByBucket(
bucketId: string,
maxUploads: number,
keyMarker: string | null,
): Promise<{
uploads: MultipartUpload[];
isTruncated: boolean;
nextKeyMarker: string | null;
}> {
const limit = Math.min(Math.max(maxUploads || 1000, 1), 1000);
const result = (await db.execute(
keyMarker
? sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
FROM multipart_uploads
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' AND s3_key > ${keyMarker}
ORDER BY s3_key, initiated_at
LIMIT ${limit + 1}`
: sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
FROM multipart_uploads
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress'
ORDER BY s3_key, initiated_at
LIMIT ${limit + 1}`,
)) as unknown as Record<string, unknown>[];
const uploads = result.slice(0, limit).map(mapRowToMultipartUpload);
return {
uploads,
isTruncated: result.length > limit,
nextKeyMarker:
result.length > limit ? uploads.at(-1)?.s3Key || null : null,
};
}
}