import { loadEnv } from "../../config/env.js"; import type { S3Client, S3Config } from "@factory/storage"; import { S3Client as StorageClient } from "@factory/storage"; import { m } from "@factory/observability"; /** * TASK-0101 — S3 client factory (consolidated on `@factory/storage`). * * The previous `s3-presign.ts` (a local SigV4 signer + single-bucket * config) is DELETED: presigning now lives in `packages/storage` * (query-string SigV4, `presignPut`/`presignGet`) and bucket config comes * from the spec env pair `S3_BUCKET_FILES` / `S3_BUCKET_THUMBS` * (`S3_BUCKET` remains a legacy alias for the files bucket — AGENTS.md 6). * * Decision record: the API talks to the storage layer ONLY through * `@factory/storage` (client + presign + keys + mime) — no second SigV4 * implementation in the app tree. */ export type FilesS3 = Pick< S3Client, | "headObject" | "readPrefix" | "getObject" | "deleteObject" | "listObjects" | "putObject" >; /** Config for the uploaded-objects bucket (files). */ export function filesBucketConfig(env = loadEnv()): S3Config { return { endpoint: env.S3_ENDPOINT.replace(/\/+$/, ""), region: env.S3_REGION, bucket: env.S3_BUCKET_FILES || env.S3_BUCKET, accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY, forcePathStyle: true, }; } /** Config for the thumbnails bucket. */ export function thumbsBucketConfig(env = loadEnv()): S3Config { return { endpoint: env.S3_ENDPOINT.replace(/\/+$/, ""), region: env.S3_REGION, bucket: env.S3_BUCKET_THUMBS, accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY, forcePathStyle: true, }; } /** Signed-URL lifetime (seconds) — spec default 900. */ export function signExpirySeconds(env = loadEnv()): number { return env.S3_SIGN_EXPIRY_SECONDS; } /** * TASK-0135: the spec's storage metrics (`storage_ops_total` / * `storage_op_duration_seconds`, op = put|get|delete|head|list). * * Central, not per-call-site: `@factory/storage`'s S3Client already invokes an * `onRequest` observability hook around EVERY actual S3 REST request (the * hook is called once BEFORE and once AFTER the fetch — only the call with a * numeric `durationMs` is a completion). We map the S3 REST verb to the spec's * op label (a path-style LIST has no key → `list`; GET/PUT/DELETE/HEAD on a * key → get/put/delete/head) and record once per completion. No-op when OTel * is disabled (the instruments are no-op). Presigning (`presignPut`/`presignGet`) * is local URL construction — it performs no S3 request, so it is NOT counted * as an op (recorded decision). */ function storageMetricsHook(info: { method: string; url: string; status?: number; ok?: boolean; durationMs?: number; }): void { if (info.durationMs === undefined) return; // the pre-request call — no duration yet const u = new URL(info.url); // LIST is a bucket-level GET carrying `list-type=2` (see S3Client.listObjects) // — a plain GET on a key is an object download (get), even though object // keys contain slashes. PUT/DELETE/HEAD are unambiguous. const op = info.method === "PUT" ? "put" : info.method === "DELETE" ? "delete" : info.method === "HEAD" ? "head" : u.searchParams.has("list-type") ? "list" : "get"; m.storage.op({ op }, info.durationMs / 1000); } /** A fresh files-bucket client per call is cheap (stateless); DI-friendly. */ export function makeFilesClient(env = loadEnv()): S3Client { return new StorageClient({ config: filesBucketConfig(env), onRequest: storageMetricsHook }); } /** A fresh thumbs-bucket client. */ export function makeThumbsClient(env = loadEnv()): S3Client { return new StorageClient({ config: thumbsBucketConfig(env), onRequest: storageMetricsHook }); } /** * Delete every object under `prefix` (list-then-delete, paginated past the * 1000-key cap). Best-effort per key (a per-key delete failure does not abort * the sweep — the orphan sweeper backstops any leak). Returns the number of * keys deleted. * * TASK-0104: `File.thumbnailKey` records ONE artifact (the 480 variant), but a * file can own SEVERAL thumbnail objects (160/480/1280 for an image, a first- * page/frame for pdf/video, under `thumbs/{ws}/{fileId}/{size}/…`). Purge must * remove the WHOLE set (spec §3 "…+ thumbnails + DB rows"), not just the single * recorded key — otherwise the other variants become orphans. */ export async function deletePrefix(s3: S3Client, prefix: string): Promise { let deleted = 0; let token: string | undefined; for (;;) { const page = await s3.listObjects(prefix, 1000, token); for (const key of page.keys) { await s3.deleteObject(key).catch(() => undefined); deleted += 1; } if (!page.truncated || !page.nextToken) break; token = page.nextToken; } return deleted; }