/** * @factory/storage — core types for the file storage layer (ARCHITECTURE.md §8). * * The pipeline this package serves: * * ``` * Upload (presigned S3 PUT) → File record (status=uploading) → Virus scan * → Metadata extraction (mime, dimensions, page count, duration) * → Thumbnail/preview generation → S3 object + Postgres metadata * → file.uploaded event → search index + permissions * ``` * * Everything in this package is either PURE (keys, mime, presign, thumbnail * planning, PNG encoding, metadata extraction over bytes) or a thin adapter * over an injectable `fetchLike` (client) — so the whole contract is * executable in plain Node with no S3/MinIO reachable. */ // Root-barrel import (NOT the `@factory/shared/errors` subpath): the API // compiles with `moduleResolution: node10`, which resolves package `main` // but not `exports` subpaths — storage is now imported from the API // (TASK-0101), so this module must resolve under BOTH modes. The root barrel // re-exports `./errors.js` (packages/shared/src/index.ts), so the types are // identical; other packages keep the more precise subpath imports. import { ApiError, type ApiErrorOptions } from "@factory/shared"; /** Upload/file lifecycle state (mirrors prisma `FileStatus`, ARCHITECTURE.md §8). */ export const FILE_STATUSES = ["uploading", "scanning", "ready", "rejected", "error"] as const; export type FileStatus = (typeof FILE_STATUSES)[number]; /** * File metadata as persisted in Postgres (prisma `File` + `file_versions`). * * `key` is the S3 object key — the single source of truth for where the * object lives (see {@link ./keys.ts}). `metadata` carries the * extraction output of the pipeline (page count, duration, …) that does not * have a first-class column yet. */ export interface FileMeta { /** Id of the file (shared `file_*` id). */ id: string; /** Owning workspace (also the first segment of the object key). */ workspaceId: string; /** Plain file name (no paths) — what the user sees. */ name: string; /** Detected MIME type (magic bytes first — see {@link ./mime.ts}). */ mimeType: string; /** Size in bytes (≤ LIMITS.MAX_FILE_SIZE = 500 MB). */ size: number; /** S3 object key of the current version. */ key: string; /** Pipeline state. */ status: FileStatus; /** File version (file_versions row). Starts at 1. */ version: number; /** Set when the object is uploaded (ISO 8601). */ uploadedAt?: string; /** SHA-256 of the content (hex) — computed during metadata extraction. */ sha256?: string; /** Image width in px (if applicable). */ width?: number; /** Image height in px (if applicable). */ height?: number; /** Page count (pdf/docx) if extracted. */ pageCount?: number; /** Media duration in ms (audio/video) if extracted. */ durationMs?: number; /** S3 key of the small thumbnail (if generated). */ thumbnailKey?: string; /** Free-form extraction output (page count, duration, codec, …). */ metadata: Record; /** Public URL when the bucket has a public prefix (otherwise undefined — * private buckets serve via short-lived signed URLs). */ publicUrl?: string; } /** * Storage failure — thin, retryable-aware subclass of the canonical * {@link ApiError} (`code: "STORAGE_ERROR"`, default status 502). * * `retryable` tells the API/worker layer whether a retry is worth it: * 404s and invalid-request failures are not, 5xx/timeouts are. */ export class StorageError extends ApiError { /** Narrows the base `code` to the storage-specific codes. */ declare readonly code: "STORAGE_ERROR" | "STORAGE_TIMEOUT"; /** Whether retrying the same operation is meaningful. */ readonly retryable: boolean; constructor( options: { message: string; code?: "STORAGE_ERROR" | "STORAGE_TIMEOUT"; status?: number; retryable?: boolean; details?: unknown; requestId?: string; cause?: unknown; } = { message: "Storage operation failed" }, ) { // Build the base options with only the fields that are actually present — // `ApiErrorOptions` uses exact-optional semantics (no explicit `undefined`). const baseOpts: ApiErrorOptions = { message: options.message }; if (options.status !== undefined) baseOpts.status = options.status; if (options.details !== undefined) baseOpts.details = options.details; if (options.requestId !== undefined) baseOpts.requestId = options.requestId; if (options.cause !== undefined) baseOpts.cause = options.cause; super(options.code ?? "STORAGE_ERROR", baseOpts); this.name = "StorageError"; // Re-apply the narrowed code: ApiError's constructor assigns the full // ErrorCode union; the cast narrows it back for typed consumers. (this as { code: "STORAGE_ERROR" | "STORAGE_TIMEOUT" }).code = options.code ?? "STORAGE_ERROR"; this.retryable = options.retryable ?? false; } } /** * S3 (or S3-compatible: MinIO in dev) endpoint configuration. * * Secrets come from the environment only (SECURITY.md) — this struct is * built by the env loader, never hardcoded. */ export interface S3Config { /** Endpoint origin, e.g. `https://minio.local:9000` (AWS: `https://s3..amazonaws.com`). */ endpoint: string; /** AWS region, e.g. `us-east-1` (MinIO: any, typically `us-east-1`). */ region: string; /** Bucket name (private — public delivery only via signed URLs). */ bucket: string; accessKeyId: string; secretAccessKey: string; /** MinIO defaults to path-style addressing; AWS virtual-host style is the norm. */ forcePathStyle?: boolean; /** Base URL that serves the bucket publicly (optional — most buckets have none). */ publicBaseUrl?: string; } /** A single signed S3 operation (what the pipeline hands the uploader). */ export interface SignedRequest { method: "GET" | "PUT" | "DELETE" | "HEAD"; /** Absolute URL (already carries the signed query params, if any). */ url: string; /** Headers the caller MUST send (authorization, content-type, …). */ headers: Record; /** Lifetime of the signed URL, seconds (0 = one-shot header signing). */ expiresInSec: number; } /** * Injectable fetch — the seam that makes the S3 client testable without a * network. Implementations: global `fetch` in prod, a fake in tests. */ export type FetchLike = (input: string, init?: FetchInit) => Promise; export interface FetchInit { method?: string; headers?: Record; body?: Uint8Array | string | null; signal?: AbortSignal | null; } export interface FetchResponse { ok: boolean; status: number; /** Response headers, lower-cased keys (as undici delivers them). */ headers: Record; body?: Uint8Array | null; json(): Promise; text(): Promise; }