/* 343-3 브라우저 보관함(b_asset_cache) — 진짜 TS 를 가짜 IndexedDB 위에서 돌려 JSON 으로 냄. * 저장 끝 = 트랜잭션 완료 · 실패 한 줄 경고 · 열기 막힘 · 실패 null 안 기억 · 색인 열쇠 지우기. * 끝에 점군 이진 풀기(decodePointPreview) — 인자로 받은 파이썬 이진 파일을 풀어 냄. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); const load = (rel, requireStub) => { const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const exports = {}; new Function("exports", "require", "window", js)(exports, requireStub, global.window); return exports; }; const later = (fn) => setTimeout(fn, 0); global.IDBKeyRange = { upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }), lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }), bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }), }; function makeIdb(options = {}) { const data = new Map(); const stats = { valueReads: 0, opens: 0 }; let failLeft = options.failOpenTimes ?? 0; let created = false; function makeTx() { const tx = { oncomplete: null, onabort: null, error: null }; let pending = 0; let aborted = false; let done = false; const finish = () => later(() => { if (pending !== 0 || done) return; done = true; if (aborted) tx.onabort && tx.onabort(); else tx.oncomplete && tx.oncomplete(); }); const req = (fn) => { const r = { onsuccess: null, onerror: null, result: undefined }; pending += 1; later(() => { r.result = fn(); if (r.onsuccess) r.onsuccess(); pending -= 1; finish(); }); return r; }; const store = { get: (key) => req(() => { stats.valueReads += 1; return data.get(key); }), put: (value) => req(() => { // 용량 초과 — 요청은 성공으로 끝나도 트랜잭션이 통째 취소됨(실제 브라우저와 같음) if (options.abortPuts) { aborted = true; tx.error = { name: "QuotaExceededError" }; } else data.set(value.key, value); return value.key; }), delete: (keyOrRange) => req(() => { for (const k of [...data.keys()]) { if (typeof keyOrRange === "string" ? k === keyOrRange : keyOrRange.test(k)) { data.delete(k); } } }), openCursor: () => { throw new Error("값 커서를 쓰면 안 됨(값을 읽음)"); }, index: () => ({ openKeyCursor: (range) => { const keys = [...data.entries()] .filter(([, v]) => range.test(v.projectId)) .map(([k]) => k); let i = 0; const r = { onsuccess: null, result: null }; pending += 1; const step = () => later(() => { if (i < keys.length) { r.result = { primaryKey: keys[i], continue: () => ((i += 1), step()) }; r.onsuccess && r.onsuccess(); } else { r.result = null; r.onsuccess && r.onsuccess(); pending -= 1; finish(); } }); step(); return r; }, }), }; tx.objectStore = () => store; return tx; } const indexedDB = { open: () => { stats.opens += 1; const r = {}; later(() => { if (options.blockOpen) return r.onblocked && r.onblocked(); if (failLeft > 0) { failLeft -= 1; r.error = { name: "UnknownError" }; return r.onerror && r.onerror(); } const db = { objectStoreNames: { contains: () => created }, createObjectStore: () => ({ createIndex() {} }), deleteObjectStore() {}, transaction: () => makeTx(), close() {}, }; r.result = db; if (!created) { created = true; r.onupgradeneeded && r.onupgradeneeded(); } r.onsuccess && r.onsuccess(); }); return r; }, }; return { indexedDB, data, stats }; } const warnings = []; console.warn = (...args) => warnings.push(args.join(" ")); let fetches = 0; global.fetch = async (url, init) => { fetches += 1; const conditional = init && init.headers && init.headers["If-None-Match"]; return { ok: !conditional, status: conditional ? 304 : 200, headers: { get: (name) => (name.toLowerCase() === "etag" ? '"e1"' : null) }, body: null, arrayBuffer: async () => new TextEncoder().encode(`body:${url}`).buffer, }; }; const stub = (id) => { if (id.includes("config_frontend")) return { API_BASE_URL: "http://x/api" }; return {}; }; const fresh = (options) => { const idb = makeIdb(options); global.window = { indexedDB: idb.indexedDB }; warnings.length = 0; fetches = 0; return { idb, cache: load("A00_Common/b_asset_cache.ts", stub) }; }; const text = (buffer) => new TextDecoder().decode(buffer); (async () => { const out = {}; // 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐) { const { idb, cache } = fresh(); const first = text(await cache.fetchCachedBytes("P", "http://x/a")); const afterFirst = fetches; const second = text(await cache.fetchCachedBytes("P", "http://x/a")); out.store = { first, second, stored: idb.data.has("P|http://x/a"), afterFirst, warnings: [...warnings], }; } // 2. 용량 초과 — 저장이 취소되면 한 줄 경고 · 다음엔 다시 받음 { const { idb, cache } = fresh({ abortPuts: true }); await cache.fetchCachedBytes("P", "http://x/a"); await cache.writeCachedBytes("P", "http://x/b", new ArrayBuffer(4)); const before = fetches; await cache.fetchCachedBytes("P", "http://x/a"); out.quota = { stored: idb.data.size, warnings: [...warnings], refetched: fetches - before }; } // 3. 열기 실패 한 번 — null 을 기억하지 않고 다음 부름에서 다시 엶 { const { idb, cache } = fresh({ failOpenTimes: 1 }); const missed = await cache.readCachedBytes("P", "http://x/a"); await cache.writeCachedBytes("P", "http://x/a", new TextEncoder().encode("v").buffer); const hit = await cache.readCachedBytes("P", "http://x/a"); out.retry = { missed, hit: hit ? text(hit) : null, opens: idb.stats.opens, warnings: [...warnings], }; } // 4. 다른 탭이 막음 — 기다리지 않고 보관 없이 받음 { const { cache } = fresh({ blockOpen: true }); const body = text(await cache.fetchCachedBytes("P", "http://x/a")); out.blocked = { body, warnings: [...warnings] }; } // 5. 지우기 — 색인 열쇠만(값 안 읽음) · 앞머리 범위 { const { idb, cache } = fresh(); const buf = new ArrayBuffer(2); await cache.writeCachedBytes("P", "http://x/corridor/1", buf); await cache.writeCachedBytes("P", "http://x/corridor/2", buf); await cache.writeCachedBytes("P", "http://x/keep", buf); await cache.writeCachedBytes("Q", "http://x/keep", buf); await cache.writeCachedBytes("A", "http://x/keep", buf); const readsBefore = idb.stats.valueReads; await cache.purgeOtherProjects("P"); await cache.purgeAssetsWithPrefix("P", "http://x/corridor/"); out.purge = { keys: [...idb.data.keys()].sort(), valueReads: idb.stats.valueReads - readsBefore, }; } // 6. 등고선 주소 한 벌 { const { cache } = fresh(); out.contour = [ cache.contourAssetUrl("P", 7, 1, false), cache.contourAssetUrl("P", 7, 2, true, true), ]; } // 7. 점군 이진 풀기(파이썬이 만든 파일) if (process.argv[2]) { global.window = {}; const api = load("B04_PreProcess/B04_PreProcess_Api_Fetch.ts", stub); const bytes = fs.readFileSync(process.argv[2]); const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); const decoded = api.decodePointPreview(buffer); out.preview = { point_count: decoded.point_count, sampled_count: decoded.sampled_count, bounds: decoded.bounds, first: decoded.points[0], last: decoded.points[decoded.points.length - 1], rgbFirst: decoded.rgb ? decoded.rgb[0] : null, }; } process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error && error.stack ? error.stack : error)); process.exit(1); });