1/** 2 * @factory/storage — core types for the file storage layer (ARCHITECTURE.md §8). 3 * 4 * The pipeline this package serves: 5 * 6 * ``` 7 * Upload (presigned S3 PUT) → File record (status=uploading) → Virus scan 8 * → Metadata extraction (mime, dimensions, page count, duration) 9 * → Thumbnail/preview generation → S3 object + Postgres metadata 10 * → file.uploaded event → search index + permissions 11 * ``` 12 * 13 * Everything in this package is either PURE (keys, mime, presign, thumbnail 14 * planning, PNG encoding, metadata extraction over bytes) or a thin adapter 15 * over an injectable `fetchLike` (client) — so the whole contract is 16 * executable in plain Node with no S3/MinIO reachable. 17 */ 18 19// Root-barrel import (NOT the `@factory/shared/errors` subpath): the API 20// compiles with `moduleResolution: node10`, which resolves package `main` 21// but not `exports` subpaths — storage is now imported from the API 22// (TASK-0101), so this module must resolve under BOTH modes. The root barrel 23// re-exports `./errors.js` (packages/shared/src/index.ts), so the types are 24// identical; other packages keep the more precise subpath imports. 25import { ApiError, type ApiErrorOptions } from "@factory/shared"; 26 27/** Upload/file lifecycle state (mirrors prisma `FileStatus`, ARCHITECTURE.md §8). */ 28export const FILE_STATUSES = ["uploading", "scanning", "ready", "rejected", "error"] as const; 29 30export type FileStatus = (typeof FILE_STATUSES)[number]; 31 32/** 33 * File metadata as persisted in Postgres (prisma `File` + `file_versions`). 34 * 35 * `key` is the S3 object key — the single source of truth for where the 36 * object lives (see {@link ./keys.ts}). `metadata` carries the 37 * extraction output of the pipeline (page count, duration, …) that does not 38 * have a first-class column yet. 39 */ 40export interface FileMeta { 41 /** Id of the file (shared `file_*` id). */ 42 id: string; 43 /** Owning workspace (also the first segment of the object key). */ 44 workspaceId: string; 45 /** Plain file name (no paths) — what the user sees. */ 46 name: string; 47 /** Detected MIME type (magic bytes first — see {@link ./mime.ts}). */ 48 mimeType: string; 49 /** Size in bytes (≤ LIMITS.MAX_FILE_SIZE = 500 MB). */ 50 size: number; 51 /** S3 object key of the current version. */ 52 key: string; 53 /** Pipeline state. */ 54 status: FileStatus; 55 /** File version (file_versions row). Starts at 1. */ 56 version: number; 57 /** Set when the object is uploaded (ISO 8601). */ 58 uploadedAt?: string; 59 /** SHA-256 of the content (hex) — computed during metadata extraction. */ 60 sha256?: string; 61 /** Image width in px (if applicable). */ 62 width?: number; 63 /** Image height in px (if applicable). */ 64 height?: number; 65 /** Page count (pdf/docx) if extracted. */ 66 pageCount?: number; 67 /** Media duration in ms (audio/video) if extracted. */ 68 durationMs?: number; 69 /** S3 key of the small thumbnail (if generated). */ 70 thumbnailKey?: string; 71 /** Free-form extraction output (page count, duration, codec, …). */ 72 metadata: Record<string, unknown>; 73 /** Public URL when the bucket has a public prefix (otherwise undefined — 74 * private buckets serve via short-lived signed URLs). */ 75 publicUrl?: string; 76} 77 78/** 79 * Storage failure — thin, retryable-aware subclass of the canonical 80 * {@link ApiError} (`code: "STORAGE_ERROR"`, default status 502). 81 * 82 * `retryable` tells the API/worker layer whether a retry is worth it: 83 * 404s and invalid-request failures are not, 5xx/timeouts are. 84 */ 85export class StorageError extends ApiError { 86 /** Narrows the base `code` to the storage-specific codes. */ 87 declare readonly code: "STORAGE_ERROR" | "STORAGE_TIMEOUT"; 88 /** Whether retrying the same operation is meaningful. */ 89 readonly retryable: boolean; 90 91 constructor( 92 options: { 93 message: string; 94 code?: "STORAGE_ERROR" | "STORAGE_TIMEOUT"; 95 status?: number; 96 retryable?: boolean; 97 details?: unknown; 98 requestId?: string; 99 cause?: unknown; 100 } = { message: "Storage operation failed" }, 101 ) { 102 // Build the base options with only the fields that are actually present — 103 // `ApiErrorOptions` uses exact-optional semantics (no explicit `undefined`). 104 const baseOpts: ApiErrorOptions = { message: options.message }; 105 if (options.status !== undefined) baseOpts.status = options.status; 106 if (options.details !== undefined) baseOpts.details = options.details; 107 if (options.requestId !== undefined) baseOpts.requestId = options.requestId; 108 if (options.cause !== undefined) baseOpts.cause = options.cause; 109 super(options.code ?? "STORAGE_ERROR", baseOpts); 110 this.name = "StorageError"; 111 // Re-apply the narrowed code: ApiError's constructor assigns the full 112 // ErrorCode union; the cast narrows it back for typed consumers. 113 (this as { code: "STORAGE_ERROR" | "STORAGE_TIMEOUT" }).code = options.code ?? "STORAGE_ERROR"; 114 this.retryable = options.retryable ?? false; 115 } 116} 117 118/** 119 * S3 (or S3-compatible: MinIO in dev) endpoint configuration. 120 * 121 * Secrets come from the environment only (SECURITY.md) — this struct is 122 * built by the env loader, never hardcoded. 123 */ 124export interface S3Config { 125 /** Endpoint origin, e.g. `https://minio.local:9000` (AWS: `https://s3.<region>.amazonaws.com`). */ 126 endpoint: string; 127 /** AWS region, e.g. `us-east-1` (MinIO: any, typically `us-east-1`). */ 128 region: string; 129 /** Bucket name (private — public delivery only via signed URLs). */ 130 bucket: string; 131 accessKeyId: string; 132 secretAccessKey: string; 133 /** MinIO defaults to path-style addressing; AWS virtual-host style is the norm. */ 134 forcePathStyle?: boolean; 135 /** Base URL that serves the bucket publicly (optional — most buckets have none). */ 136 publicBaseUrl?: string; 137} 138 139/** A single signed S3 operation (what the pipeline hands the uploader). */ 140export interface SignedRequest { 141 method: "GET" | "PUT" | "DELETE" | "HEAD"; 142 /** Absolute URL (already carries the signed query params, if any). */ 143 url: string; 144 /** Headers the caller MUST send (authorization, content-type, …). */ 145 headers: Record<string, string>; 146 /** Lifetime of the signed URL, seconds (0 = one-shot header signing). */ 147 expiresInSec: number; 148} 149 150/** 151 * Injectable fetch — the seam that makes the S3 client testable without a 152 * network. Implementations: global `fetch` in prod, a fake in tests. 153 */ 154export type FetchLike = (input: string, init?: FetchInit) => Promise<FetchResponse>; 155 156export interface FetchInit { 157 method?: string; 158 headers?: Record<string, string>; 159 body?: Uint8Array | string | null; 160 signal?: AbortSignal | null; 161} 162 163export interface FetchResponse { 164 ok: boolean; 165 status: number; 166 /** Response headers, lower-cased keys (as undici delivers them). */ 167 headers: Record<string, string>; 168 body?: Uint8Array | null; 169 json<T>(): Promise<T>; 170 text(): Promise<string>; 171} 172