// N11 (PLAN-STAGED.md D2) — RAM staging ของเอกสาร base64 จาก e-Court: "แหล่ง bytes // ชั่วคราว" ทดแทน pdf_url ที่ fetch ได้ — annotation/bookmark เก็บถาวรใน DB ปกติ // (row files คือ FK anchor, staging เป็นแค่เนื้อไฟล์) // - เก็บใน Map บน globalThis (pattern เดียวกับ lib/annotate-events.ts/db/index.ts — // Next dev แยก bundle "use server"/route handler แล้ว HMR recompile จะ duplicate // module-level state) · ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียว (ความหมาย // "ไม่เก็บไฟล์" ของแผน) // - TTL sliding จาก lastAccessAt + LRU evict ตาม total cap + lazy sweep ตอน put // (ไม่มี setInterval ค้างใน dev — บริบทเดียวกับ rule ของ annotate-events) // - single-process (ข้อจำกัดเดียวกับ SSE pub/sub — invariant #11) // - pure module: ไม่ import อะไรที่ลาก @/db มาด้วย — unit test รันได้ไม่ต้องตั้ง DATABASE_URL import { isPdfBytes } from "@/lib/document-versioning"; /** ext ของ sentinel = ผลตรวจ magic bytes ที่ stage route สร้างเอง (pdf/jpg/png/tif) */ export const STAGED_EXTS = ["pdf", "jpg", "png", "tif"] as const; export type StagedExt = (typeof STAGED_EXTS)[number]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; type StagedEntry = { bytes: Buffer; ext: StagedExt; createdAt: number; lastAccessAt: number; }; type StagedGlobal = { __stagedDocs?: Map }; const g = globalThis as typeof globalThis & StagedGlobal; const stagedDocs: Map = g.__stagedDocs ?? (g.__stagedDocs = new Map()); /** env จำนวนเต็มบวก — ค่าเสีย/ว่าง = ใช้ default (ปรับได้ทุก environment) */ function positiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw.trim() === "") return fallback; const n = Number(raw); return Number.isInteger(n) && n > 0 ? n : fallback; } /** TTL แบบ sliding — default 8 ชม. เท่า NOTE_SESSION_TTL ให้ครอบ session ทั้งก้อน * (ระหว่าง annotate ไม่มีการอ่าน bytes การเปิดดูครั้งเดียวจึงต้องไม่หมดกลาง session) */ export function stagedDocTtlMs(): number { return positiveIntEnv("STAGED_DOC_TTL_SECONDS", 28_800) * 1000; } /** โควตารวมทุกไฟล์ใน RAM — เกิน = LRU evict รายการ lastAccessAt เก่าสุด */ export function stagedMaxTotalBytes(): number { return positiveIntEnv("STAGED_MAX_TOTAL_BYTES", 256 * 1024 * 1024); } /** ขนาดเนื้อไฟล์ต่อไฟล์ (คู่กับ base64 cap ที่ stage route ใช้ตัดกลาง stream) */ export function stagedMaxFileBytes(): number { return positiveIntEnv("STAGED_MAX_FILE_BYTES", 100 * 1024 * 1024); } /** ความยาว body (JSON) สูงสุดที่ stage route รับ = base64 ยาวสุดที่ decode แล้ว * ได้ ≤ stagedMaxFileBytes บวก headroom ของ envelope (fid/title/typeId + วงเล็บ) */ export function stagedMaxBodyBytes(): number { return 4 * Math.ceil(stagedMaxFileBytes() / 3) + 8192; } // config guard: total cap ต่ำกว่าไฟล์เดี่ยวสูงสุด = ไฟล์ใหญ่สุดโดน 413 ตลอด — เตือน // หนึ่งครั้งตอนบูต (ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้) if (stagedMaxTotalBytes() < stagedMaxFileBytes()) { console.warn( "[staged] STAGED_MAX_TOTAL_BYTES ต่ำกว่า STAGED_MAX_FILE_BYTES — ไฟล์เดี่ยวขนาดใหญ่สุดจะโดนปฏิเสธ (413) เสมอ", ); } /** sentinel URL ใน files.pdf_url — `staged:.` (ext ติดไปเสมอเพื่อให้ * fileKindFromUrl/imageFormatFromUrl ทำงานทุกจุดโดยไม่แก้ — D2/การตัดสินใจข้อ 7) */ export function stagedSourceUrl(fileId: string, ext: StagedExt): string { return `staged:${fileId}.${ext}`; } /** แกะ sentinel — validate uuid + ext ซ้ำ (ทั้งคู่ server generate เอง การ validate * เป็น defense กัน row ถูกเขียนค้างระหว่างอัปเดตไม่สำเร็จ) */ export function parseStagedSource( url: string, ): { fileId: string; ext: StagedExt } | null { if (!url.startsWith("staged:")) return null; const rest = url.slice("staged:".length); const dot = rest.lastIndexOf("."); if (dot <= 0) return null; const fileId = rest.slice(0, dot); const ext = rest.slice(dot + 1).toLowerCase(); if (!UUID_RE.test(fileId)) return null; if (!(STAGED_EXTS as readonly string[]).includes(ext)) return null; return { fileId, ext: ext as StagedExt }; } export function isStagedSource(url: string): boolean { return parseStagedSource(url) !== null; } /** ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ (D1) — ext ของ sentinel * มาจากผลตรวจนี้ · ไม่รู้จัก = null → route ตอบ 400 */ export function detectStagedExt(bytes: Buffer): StagedExt | null { if (isPdfBytes(bytes)) return "pdf"; const b = bytes; if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) { return "jpg"; } if ( b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 ) { return "png"; } if ( b.length >= 4 && ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a && b[3] === 0x00) || // II*\0 little-endian (b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00 && b[3] === 0x2a)) // MM\0* big-endian ) { return "tif"; } return null; } /** ไฟล์เดี่ยวใหญ่กว่าโควตารวม — stage route แปลงเป็น 413 (ไม่ไล่ evict เพื่อนแล้ว * ก็ยังไม่พอ) */ export class StagedCapError extends Error {} function totalStagedBytes(): number { let sum = 0; stagedDocs.forEach((e) => { sum += e.bytes.length; }); return sum; } /** เก็บกวาด entry หมดอายุตอน put — lazy sweep ไม่มี timer (กัน setInterval ค้างใน dev) */ function sweepExpiredStagedDocs(now: number): void { const ttl = stagedDocTtlMs(); // Array.from แทน for...of บน Map โดยตรง — tsconfig ไม่ได้ตั้ง target ≥ es2015 // (pattern เดียวกับ parseVersionUploadForm ของ lib/document-versioning.ts) · // เก็บเป็น array ก่อนเพราะลบระหว่างเดิน Map ไม่ได้ const expired: string[] = []; stagedDocs.forEach((e, id) => { if (now - e.lastAccessAt > ttl) expired.push(id); }); for (const id of expired) stagedDocs.delete(id); } /** เก็บ/แทนที่ bytes ใต้ fileId — re-POST fid เดิม = replace ช่องเดิม (ไม่งอกใหม่ต่อครั้ง) * · แทรกแล้วเกิน total cap = ไล่ evict รายการ lastAccessAt เก่าสุดจนคืนใต้ cap * (รายการที่เพิ่งใส่ lastAccessAt = now จึงไม่ถูกไล่เอง) */ export function putStagedDoc( fileId: string, bytes: Buffer, ext: StagedExt, ): void { const now = Date.now(); sweepExpiredStagedDocs(now); if (bytes.length > stagedMaxTotalBytes()) { throw new StagedCapError( "ไฟล์ใหญ่เกินโควตารวมของพื้นที่จัดเตรียมเอกสาร (STAGED_MAX_TOTAL_BYTES)", ); } stagedDocs.set(fileId, { bytes, ext, createdAt: now, lastAccessAt: now }); while (totalStagedBytes() > stagedMaxTotalBytes()) { let oldestId: string | null = null; let oldestAt = Infinity; stagedDocs.forEach((e, id) => { if (e.lastAccessAt < oldestAt) { oldestAt = e.lastAccessAt; oldestId = id; } }); if (oldestId === null) break; stagedDocs.delete(oldestId); } } /** ผู้อ่าน bytes จริง (pdf route / export) — สำเร็จ = touch ยืด TTL (sliding) แล้วคืน * Buffer · miss ทุกกรณี (ไม่ใช่ sentinel / ไม่มี entry / หมดอายุ / ext ไม่ตรงกับ * entry) = null ให้ caller ตอบ 410 gate · Buffer คืนเป็น reference ของ entry — * ผู้อ่านต้องไม่ mutate (fixPdfActualText/pdf-lib สร้างชุด bytes ใหม่เองตรวจแล้ว) */ export function getStagedDoc(url: string): Buffer | null { const parsed = parseStagedSource(url); if (parsed === null) return null; const entry = stagedDocs.get(parsed.fileId); if (entry === undefined) return null; if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) { stagedDocs.delete(parsed.fileId); return null; } // ext ของ sentinel ไม่ตรง entry (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือน // หมดอายุ — ไม่มี sentinel โกหก และไม่ touch entry ของ ext จริง if (entry.ext !== parsed.ext) return null; entry.lastAccessAt = Date.now(); return entry.bytes; } /** เช็ค "มีอยู่ไหม" อย่างเดียว (expire + ext เทียบชุดเดียวกับ getStagedDoc) โดยไม่ * copy ไม่ touch TTL — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง session * route ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า — การอ่านจริงเกิดที่ viewer) */ export function hasStagedDoc(url: string): boolean { const parsed = parseStagedSource(url); if (parsed === null) return false; const entry = stagedDocs.get(parsed.fileId); if (entry === undefined) return false; if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) return false; return entry.ext === parsed.ext; } /** ลบ entry ตาม fileId — ใช้คืน RAM ของ "ผู้แพ้" ตอน insert ชน 23505 (D3) */ export function deleteStagedDoc(fileId: string): void { stagedDocs.delete(fileId); } /** เฉพาะ test — ล้าง store ทั้งก้อนระหว่างเคส (Map บน globalThis ชื่นชมข้ามเคส) */ export function __clearStagedDocsForTest(): void { stagedDocs.clear(); }