evOo-local/packages/db/src/media-uploads.ts
Claude c1c39b6e46
feat(media): space-scoped Media Studio repositories
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
2026-10-03 17:55:49 +00:00

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,
};
}