/** * apps/worker — files pipeline status machine (TASK-0102 §2). * * The File lifecycle and the ONLY legal transitions: * * UPLOADING ──→ SCANNING ──→ READY * ├──→ VIRUS_DETECTED * └──→ FAILED * READY ─────────────────→ FAILED (a later re-scan only — TASK-0104) * * READY is terminal for the pipeline: re-running a READY file's job is a * no-op (the processor returns early — the file is already done). Any * transition NOT in this table throws {@link InvalidTransitionError}, which * the processor logs as a pipeline invariant violation and fails the row * FAILED (never silently accept a bad state). * * Pure + unit-tested (DoD: the full transition table, valid and invalid). */ import type { FileStatus } from "@prisma/client"; export class InvalidTransitionError extends Error { constructor(from: FileStatus | "ABSENT", to: FileStatus) { super(`invalid file status transition: ${from} → ${to}`); this.name = "InvalidTransitionError"; } } /** from → allowed "to" statuses. */ export const ALLOWED_TRANSITIONS: Record = { UPLOADING: ["SCANNING"], SCANNING: ["READY", "VIRUS_DETECTED", "FAILED"], READY: ["FAILED"], // a later re-scan only (TASK-0104 re-scan) VIRUS_DETECTED: [], FAILED: [] }; /** True when `from → to` is a legal transition (per the table above). */ export function isValidTransition(from: FileStatus, to: FileStatus): boolean { return (ALLOWED_TRANSITIONS[from] ?? []).includes(to); } /** * Assert a transition is legal. Throws {@link InvalidTransitionError} * otherwise. The pipeline calls this before EVERY status write. */ export function assertTransition(from: FileStatus, to: FileStatus): void { if (!isValidTransition(from, to)) { throw new InvalidTransitionError(from, to); } }