Files
Aislo/resources/tester/helper_343_3_asset_cache.cjs
T
eomsangdonandClaude Opus 5.5 e0051bff72 perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌
- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:21:51 +09:00

263 lines
8.7 KiB
JavaScript

/* 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);
});