import { constants } from "node:fs"; import { open } from "node:fs/promises"; import type { AdapterContext, ArtifactStore, ArtifactStreamPut } from "@rakazo/adapter-kit"; import { ArtifactTooLargeError } from "@rakazo/adapter-kit"; import { ARTIFACT_MAX_BYTES_BY_KIND, attachmentKindOf } from "@rakazo/contracts"; import { validateAttachmentMimeType } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; export type StoreArtifactOwner = { spaceId: string; userId: string; botId: string | null; groupId: string | null; runId: string | null; }; export type StoreArtifactMetadata = { name: string; mimeType: string; width?: number; height?: number; durationMs?: number; parentArtifactId?: string; }; type StoreArtifactDeps = { prisma: Pick; artifacts: ArtifactStore; }; /** Largest artifact of this MIME type that may be streamed or generated. */ export function artifactMaxBytes(mimeType: string): number { return ARTIFACT_MAX_BYTES_BY_KIND[attachmentKindOf(mimeType)]; } /** * Streams bytes into the artifact store and records the artifact row; the stored blob is removed * again when the row cannot be written. Rejects disallowed MIME types and anything above the * per-kind cap (or the smaller `maxBytes`) without buffering the whole file. */ export async function storeArtifactStream( deps: StoreArtifactDeps, input: StoreArtifactOwner & StoreArtifactMetadata & { stream: ArtifactStreamPut["stream"]; maxBytes?: number; signal?: AbortSignal; }, ) { validateAttachmentMimeType(input.mimeType); const maxBytes = Math.min( input.maxBytes ?? Number.POSITIVE_INFINITY, artifactMaxBytes(input.mimeType), ); const operationId = `artifact-store:${input.runId ?? input.botId ?? input.userId}`; const context: AdapterContext = { operationId, traceId: operationId, spaceId: input.spaceId, userId: input.userId, ...(input.botId ? { botId: input.botId } : {}), ...(input.runId ? { runId: input.runId } : {}), signal: input.signal ?? new AbortController().signal, }; const stored = await deps.artifacts.putStream( { name: input.name, mimeType: input.mimeType, stream: input.stream, maxBytes }, context, ); try { return await deps.prisma.artifact.create({ data: { spaceId: input.spaceId, userId: input.userId, botId: input.botId, groupId: input.groupId, runId: input.runId, name: input.name, mimeType: input.mimeType, size: stored.size, hash: stored.sha256, storageKey: stored.key, width: input.width, height: input.height, durationMs: input.durationMs, parentArtifactId: input.parentArtifactId, }, }); } catch (error) { await deps.artifacts.remove(stored.key, context).catch(() => undefined); throw error; } } /** Stores a file a job produced on local disk (e.g. an ffmpeg output) as an artifact. */ export async function storeArtifactFromFile( deps: StoreArtifactDeps, input: StoreArtifactOwner & StoreArtifactMetadata & { path: string; signal?: AbortSignal }, ) { validateAttachmentMimeType(input.mimeType); const maxBytes = artifactMaxBytes(input.mimeType); const handle = await open(input.path, constants.O_RDONLY | O_NOFOLLOW); try { const info = await handle.stat(); if (!info.isFile()) throw new Error(`Not a file: ${input.name}`); if (info.size > maxBytes) throw new ArtifactTooLargeError(maxBytes); return await storeArtifactStream(deps, { ...input, stream: handle.createReadStream({ autoClose: false }), maxBytes, }); } finally { await handle.close(); } }