import type { MediaUpload } from "@rakazo/contracts"; import { MEDIA_UPLOAD_CHUNK_BYTES } from "@rakazo/contracts"; import type { PrismaClient } from "./client.js"; import { Prisma } from "./client.js"; import { InvalidMediaInputError, lockMediaKey, MediaStateConflictError, mapMediaUpload, } from "./media-shared.js"; import { IsolationError } from "./scope.js"; export interface BeginMediaUpload { assetId: string; userId: string; sizeBytes: number; sha256: string; fileName: string; chunkBytes?: number; } function chunkCountOf(row: { sizeBytes: bigint; chunkBytes: number }): number { return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes)); } /** Resumable chunked uploads of originals from client-pushed sources. */ export function createMediaUploadRepos(prisma: PrismaClient) { /** Opens an upload, or resumes the open one for the same asset and content. */ async function beginUpload(spaceId: string, input: BeginMediaUpload): Promise { const chunkBytes = input.chunkBytes ?? MEDIA_UPLOAD_CHUNK_BYTES; if (!Number.isInteger(input.sizeBytes) || input.sizeBytes <= 0) { throw new InvalidMediaInputError("Upload size must be positive"); } if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) { throw new InvalidMediaInputError("Chunk size must be positive"); } return prisma.$transaction(async (tx) => { const asset = await tx.mediaAsset.findFirst({ where: { id: input.assetId, spaceId }, select: { id: true }, }); if (!asset) throw new IsolationError(); await lockMediaKey(tx, `upload:${input.assetId}`); const open = await tx.mediaUpload.findFirst({ where: { spaceId, assetId: input.assetId, status: "open", sha256: input.sha256, sizeBytes: BigInt(input.sizeBytes), chunkBytes, }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); if (open) return mapMediaUpload(open); const row = await tx.mediaUpload.create({ data: { spaceId, assetId: input.assetId, userId: input.userId, sizeBytes: BigInt(input.sizeBytes), chunkBytes, sha256: input.sha256, fileName: input.fileName, }, }); return mapMediaUpload(row); }); } async function requireUpload(spaceId: string, uploadId: string) { const row = await prisma.mediaUpload.findFirst({ where: { id: uploadId, spaceId } }); if (!row) throw new IsolationError(); return row; } async function uploadStatus(spaceId: string, uploadId: string): Promise { return mapMediaUpload(await requireUpload(spaceId, uploadId)); } /** Full row (sha256, file name, owner) for the upload route. */ async function getUploadRecord(spaceId: string, uploadId: string) { return requireUpload(spaceId, uploadId); } /** Idempotent: the index is appended once, atomically, however often it is reported. */ async function markChunkReceived( spaceId: string, uploadId: string, index: number, ): Promise { const row = await requireUpload(spaceId, uploadId); if (!Number.isInteger(index) || index < 0 || index >= chunkCountOf(row)) { throw new InvalidMediaInputError("Chunk index out of range"); } if (row.status !== "open") throw new MediaStateConflictError("Upload is not open"); await prisma.$executeRaw(Prisma.sql` UPDATE media_uploads SET "receivedChunks" = array_append("receivedChunks", ${index}::int), "updatedAt" = now() WHERE id = ${uploadId} AND "spaceId" = ${spaceId} AND status = 'open' AND NOT (${index}::int = ANY("receivedChunks")) `); return uploadStatus(spaceId, uploadId); } /** * Closes a fully received upload and points the asset at the stored original. The * caller has verified the assembled file against `sha256` before calling. */ async function completeUpload( spaceId: string, uploadId: string, stored: { storagePath: string }, ): Promise { return prisma.$transaction(async (tx) => { const row = await tx.mediaUpload.findFirst({ where: { id: uploadId, spaceId } }); if (!row) throw new IsolationError(); if (row.status === "complete") return mapMediaUpload(row); if (row.status !== "open") throw new MediaStateConflictError("Upload is not open"); const received = new Set(row.receivedChunks); for (let index = 0; index < chunkCountOf(row); index++) { if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks"); } const updated = await tx.mediaUpload.update({ where: { id: row.id }, data: { status: "complete" }, }); await tx.mediaAsset.update({ where: { id: row.assetId }, data: { storage: "uploaded", storagePath: stored.storagePath, contentHash: row.sha256, sizeBytes: row.sizeBytes, ingestState: "indexed", ingestError: null, }, }); return mapMediaUpload(updated); }); } async function failUpload(spaceId: string, uploadId: string): Promise { const result = await prisma.mediaUpload.updateMany({ where: { id: uploadId, spaceId, status: "open" }, data: { status: "failed" }, }); if (result.count !== 1) { await requireUpload(spaceId, uploadId); throw new MediaStateConflictError("Upload is not open"); } return uploadStatus(spaceId, uploadId); } return { beginUpload, uploadStatus, getUploadRecord, markChunkReceived, completeUpload, failUpload, }; }