import { ExportStatus, Prisma } from "@prisma/client"; import { PipelineError } from "./errors"; /** * Export lifecycle state machine (RenderPipeline2 §4). * The server is the sole authority on state; workers *request* transitions. * * NOTE (Phase 2 interim): RENDERING → READY_FOR_QC is temporarily legal. * Phase 3 inserts VALIDATING between them (§17.3) — remove READY_FOR_QC from * RENDERING's list when the validation engine ships. */ export const EXPORT_TRANSITIONS: Record = { QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only RENDERING: [ "VALIDATING", "GENERATING_PREVIEW", // interim: preview stage runs before validation exists "RENDER_FAILED", "QUEUED", // lease expiry / retryable fail with attempts remaining (T5) "CANCELLED", "READY_FOR_QC", // preview disabled / manual mark-done ], RENDER_FAILED: ["QUEUED", "SUPERSEDED"], VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW"], VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"], GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"], PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"], READY_FOR_QC: ["QC_FAILED", "READY_FOR_DELIVERY", "SUPERSEDED"], QC_FAILED: ["SUPERSEDED"], READY_FOR_DELIVERY: ["PACKAGED", "SUPERSEDED"], PACKAGED: ["DELIVERED"], DELIVERED: ["ARCHIVED"], SUPERSEDED: [], ARCHIVED: [], CANCELLED: [], }; export type TransitionActor = { type: "WORKER" | "USER" | "SYSTEM"; id?: string | null; note?: string | null; }; export function isTransitionAllowed(from: ExportStatus, to: ExportStatus): boolean { return EXPORT_TRANSITIONS[from]?.includes(to) ?? false; } /** * Applies a state transition inside an existing transaction: validates * legality, stamps statusChangedAt, appends the ExportEvent audit row. * Throws PipelineError(409) on an illegal transition (§4.2 rules). */ export async function applyTransition( tx: Prisma.TransactionClient, exportRow: { id: string; status: ExportStatus }, to: ExportStatus, actor: TransitionActor, extraData: Prisma.ExportUpdateInput = {} ) { if (!isTransitionAllowed(exportRow.status, to)) { throw new PipelineError(409, `Invalid transition ${exportRow.status} → ${to}`); } const updated = await tx.export.update({ where: { id: exportRow.id }, data: { status: to, statusChangedAt: new Date(), ...extraData }, }); await tx.exportEvent.create({ data: { exportId: exportRow.id, fromStatus: exportRow.status, toStatus: to, actorType: actor.type, actorId: actor.id ?? null, note: actor.note ?? null, }, }); return updated; }