1/** 2 * AI Software Factory — fixture: 500 MB SPARSE file generator (Phase 9, 3 * TASK-0105). 4 * 5 * The paid-plan per-file ceiling is 500 MB, and the ROADMAP exit criterion is 6 * "upload of a 500 MB file works". Running that on a CI runner must not cost 7 * 500 MB of DISK: `createSparseFile` writes a file whose LOGICAL size is 8 * `sizeBytes` but whose physical usage is a single small block — the content 9 * is a short marker at offset 0 followed by a file-system HOLE (a `truncate` 10 * extend, which allocates no data blocks). Holes read back as zeros, so the 11 * 500 MB buffer that gets streamed to the presigned PUT is deterministic 12 * (marker + zeros) and the object's sha256 is stable across runs. 13 * 14 * Only real disk cost in the whole 500 MB scenario: 15 * - this file: ~4 KB (one block, ext4/xfs) 16 * - the MinIO object: 500 MB (the point of the test) 17 * 18 * Usage (tests): 19 * 20 * import { SPARSE_500_MB, makeSparse500 } from "../../fixtures/files/make-sparse"; 21 * const sparse = await makeSparse500(); // unique path under os.tmpdir() 22 * await put(sparse.bytes); // 500,000,000 bytes to the presigned PUT 23 * await rm(sparse.path); // ~4 KB on disk 24 * 25 * `makeSparse500` materializes the full buffer, which costs 500 MB of HEAP 26 * (the upload must stream the actual bytes) — disk stays at ~4 KB. 27 */ 28import { truncateSync, writeFileSync } from "node:fs"; 29import { readFile, rm, stat, truncate, writeFile } from "node:fs/promises"; 30import os from "node:os"; 31import path from "node:path"; 32 33/** The paid-plan per-file ceiling — the exact ROADMAP exit-criterion size. */ 34export const SPARSE_500_MB = 500_000_000; // 500 MB decimal (matches the plan limit, 500 * 1000 * 1000) 35 36/** A short, recognizable marker written at offset 0 (the rest is a hole). */ 37export const SPARSE_MARKER = Buffer.from( 38 "factory-files-sparse-task-0105 — trailing bytes are a filesystem hole (zeros)", 39 "utf8", 40); 41 42/** 43 * Create a sparse file of exactly `sizeBytes` LOGICAL size at `filePath`. 44 * 45 * @returns `{ logicalSize, allocatedBytes }` — `allocatedBytes` (st_blocks * 46 * 512) proves the sparse-ness: it is a few KiB, not `sizeBytes`. 47 */ 48export async function createSparseFile( 49 filePath: string, 50 sizeBytes: number = SPARSE_500_MB, 51): Promise<{ logicalSize: number; allocatedBytes: number }> { 52 if (!Number.isInteger(sizeBytes) || sizeBytes < SPARSE_MARKER.length) { 53 throw new Error(`createSparseFile: sizeBytes must be >= ${SPARSE_MARKER.length}, got ${sizeBytes}`); 54 } 55 // 1) the only real data: one small block with the marker 56 await writeFile(filePath, SPARSE_MARKER); 57 // 2) extend to the full logical size — a single seek, NO block allocation 58 await truncate(filePath, sizeBytes); 59 const st = await stat(filePath); 60 return { logicalSize: st.size, allocatedBytes: st.blocks * 512 }; 61} 62 63/** 64 * Convenience: a unique 500 MB sparse file under `os.tmpdir()` plus the full 65 * byte buffer ready for the presigned PUT. Callers delete the file with 66 * `rm(path, { force: true })` when done (it is ~4 KB of disk). 67 */ 68export async function makeSparse500(): Promise<{ 69 path: string; 70 logicalSize: number; 71 allocatedBytes: number; 72 bytes: Buffer; 73}> { 74 const file = path.join(os.tmpdir(), `factory-sparse-${Date.now()}-${Math.random().toString(36).slice(2)}.bin`); 75 const created = await createSparseFile(file, SPARSE_500_MB); 76 // Holes read as zeros — the buffer is deterministic (marker + zeros). 77 const bytes = Buffer.from(await readFile(file)); 78 if (bytes.length !== SPARSE_500_MB) { 79 await rm(file, { force: true }); 80 throw new Error(`makeSparse500: expected ${SPARSE_500_MB} bytes, got ${bytes.length}`); 81 } 82 return { path: file, ...created, bytes }; 83} 84 85/** 86 * Sync variant (fixture bootstrap before an async context exists): same 87 * sparse file, no buffer. Kept sync-on-purpose — it is one small write + a 88 * truncate, never a hot path. 89 */ 90export function createSparseFileSync(filePath: string, sizeBytes: number = SPARSE_500_MB): string { 91 if (!Number.isInteger(sizeBytes) || sizeBytes < SPARSE_MARKER.length) { 92 throw new Error(`createSparseFileSync: sizeBytes must be >= ${SPARSE_MARKER.length}, got ${sizeBytes}`); 93 } 94 writeFileSync(filePath, SPARSE_MARKER); 95 truncateSync(filePath, sizeBytes); 96 return filePath; 97} 98