1/** 2 * apps/worker — files pipeline status machine (TASK-0102 §2). 3 * 4 * The File lifecycle and the ONLY legal transitions: 5 * 6 * UPLOADING ──→ SCANNING ──→ READY 7 * ├──→ VIRUS_DETECTED 8 * └──→ FAILED 9 * READY ─────────────────→ FAILED (a later re-scan only — TASK-0104) 10 * 11 * READY is terminal for the pipeline: re-running a READY file's job is a 12 * no-op (the processor returns early — the file is already done). Any 13 * transition NOT in this table throws {@link InvalidTransitionError}, which 14 * the processor logs as a pipeline invariant violation and fails the row 15 * FAILED (never silently accept a bad state). 16 * 17 * Pure + unit-tested (DoD: the full transition table, valid and invalid). 18 */ 19import type { FileStatus } from "@prisma/client"; 20 21export class InvalidTransitionError extends Error { 22 constructor(from: FileStatus | "ABSENT", to: FileStatus) { 23 super(`invalid file status transition: ${from} → ${to}`); 24 this.name = "InvalidTransitionError"; 25 } 26} 27 28/** from → allowed "to" statuses. */ 29export const ALLOWED_TRANSITIONS: Record<FileStatus, readonly FileStatus[]> = { 30 UPLOADING: ["SCANNING"], 31 SCANNING: ["READY", "VIRUS_DETECTED", "FAILED"], 32 READY: ["FAILED"], // a later re-scan only (TASK-0104 re-scan) 33 VIRUS_DETECTED: [], 34 FAILED: [] 35}; 36 37/** True when `from → to` is a legal transition (per the table above). */ 38export function isValidTransition(from: FileStatus, to: FileStatus): boolean { 39 return (ALLOWED_TRANSITIONS[from] ?? []).includes(to); 40} 41 42/** 43 * Assert a transition is legal. Throws {@link InvalidTransitionError} 44 * otherwise. The pipeline calls this before EVERY status write. 45 */ 46export function assertTransition(from: FileStatus, to: FileStatus): void { 47 if (!isValidTransition(from, to)) { 48 throw new InvalidTransitionError(from, to); 49 } 50} 51