Sources, assets with keyset search, analyses, segments, uploads, collections with re-clustering, people with consent and complete biometric removal, marks, brand kits, projects with versioned plans, renders with atomic claims, publications, metrics and the funnel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
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<MediaUpload> {
|
|
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<MediaUpload> {
|
|
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<MediaUpload> {
|
|
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<MediaUpload> {
|
|
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<MediaUpload> {
|
|
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,
|
|
};
|
|
}
|