import type { SimulationResultsSnapshot } from "./SimulationResultsView"; export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; const DATABASE = "system-simulation-results"; // Pack short output columns together: an eight-branch result needs seven writes, // rather than one write per output column and fourteen serial transactions. const PACKED_VALUES = 262144; type Header = { snapshot: SimulationResultsSnapshot; lengths: Record; layout?: "packed-f64-v1"; }; type PackedChunk = { offset: number; values: Float64Array }; let database: Promise | undefined; let saveSequence = 0; let pendingSaves = 0; let ownedCacheId: string | undefined; export function resultSavePending() { return pendingSaves > 0; } function openDatabase() { if (!database) database = new Promise((resolve, reject) => { let blocked = false; const request = indexedDB.open(DATABASE, 1); request.onupgradeneeded = () => { request.result.createObjectStore("headers"); request.result.createObjectStore("chunks"); }; request.onerror = () => reject(request.error); request.onblocked = () => { blocked = true; reject(new Error("结果数据库被其他页面占用,请关闭旧页面后重试")); }; request.onsuccess = () => { const db = request.result; if (blocked) { db.close(); return; } db.onversionchange = () => { db.close(); database = undefined; }; resolve(db); }; }).catch((error) => { database = undefined; throw error; }); return database; } function writeSnapshot(db: IDBDatabase, cacheId: string, chunks: PackedChunk[], header: Header) { return new Promise((resolve, reject) => { const transaction = db.transaction(["headers", "chunks"], "readwrite"); transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? new Error("结果保存事务已中止")); transaction.onerror = () => {}; // onabort reports the transaction failure once. try { const store = transaction.objectStore("chunks"); for (const chunk of chunks) store.put(chunk, [cacheId, chunk.offset]); transaction.objectStore("headers").put(header, cacheId); transaction.commit?.(); } catch (error) { transaction.abort(); reject(error); } }); } function deleteCache(db: IDBDatabase, cacheId: string) { return new Promise((resolve, reject) => { const transaction = db.transaction(["headers", "chunks"], "readwrite"); transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error); transaction.objectStore("headers").delete(cacheId); transaction.objectStore("chunks").delete(IDBKeyRange.bound([cacheId], [cacheId, []])); }); } /** Publish the small session pointer only after the atomic snapshot write commits. */ export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) { const sequence = ++saveSequence; const cacheId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${sequence}-${Math.random().toString(36).slice(2)}`; pendingSaves++; let db: IDBDatabase | undefined; let committed = false; let writeStarted = false; try { // Yield before copying so the ready result and controls can paint first. await new Promise((resolve) => setTimeout(resolve, 0)); if (sequence !== saveSequence) return false; const columns = Object.entries(snapshot.result.series); const lengths: Record = Object.create(null); let total = 0; for (const [name, values] of columns) { lengths[name] = values.length; total += values.length; } const chunks: PackedChunk[] = []; let columnIndex = 0; let columnOffset = 0; for (let offset = 0; offset < total; offset += PACKED_VALUES) { if (sequence !== saveSequence) return false; const values = new Float64Array(Math.min(PACKED_VALUES, total - offset)); let written = 0; while (written < values.length) { const source = columns[columnIndex][1]; const count = Math.min(source.length - columnOffset, values.length - written); if (columnOffset === 0 && count === source.length) values.set(source, written); else for (let i = 0; i < count; i++) values[written + i] = source[columnOffset + i]; written += count; columnOffset += count; if (columnOffset === source.length) { columnIndex++; columnOffset = 0; } } chunks.push({ offset, values }); // Keep cancellation responsive without holding an idle IndexedDB transaction open. if (offset + values.length < total) await new Promise((resolve) => setTimeout(resolve, 0)); } if (sequence !== saveSequence) return false; db = await openDatabase(); if (sequence !== saveSequence) return false; writeStarted = true; await writeSnapshot(db, cacheId, chunks, { snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } }, lengths, layout: "packed-f64-v1", }); if (sequence !== saveSequence) return false; sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId })); committed = true; // Only retire this page's own previous save, never a snapshot loaded from another tab. const previous = ownedCacheId; ownedCacheId = cacheId; if (previous) void deleteCache(db, previous).catch(() => {}); return true; } finally { pendingSaves--; if (db && writeStarted && !committed) await deleteCache(db, cacheId).catch(() => {}); } } function cacheError() { return new Error("结果缓存不完整或分块无效,请重新载入结果文件"); } function restorePacked(header: Header, chunks: PackedChunk[]) { const columns = Object.entries(header.lengths); const total = columns.reduce((sum, [, length]) => sum + length, 0); let loaded = 0; let columnIndex = 0; let columnOffset = 0; for (const chunk of chunks) { if (!chunk || chunk.offset !== loaded || !(chunk.values instanceof Float64Array) || chunk.values.length === 0 || loaded + chunk.values.length > total) throw cacheError(); let read = 0; while (read < chunk.values.length) { while (columnIndex < columns.length && columnOffset === columns[columnIndex][1]) { columnIndex++; columnOffset = 0; } if (columnIndex >= columns.length) throw cacheError(); const [name, length] = columns[columnIndex]; const count = Math.min(length - columnOffset, chunk.values.length - read); const target = header.snapshot.result.series[name]; for (let i = 0; i < count; i++) target[columnOffset + i] = chunk.values[read + i]; read += count; columnOffset += count; } loaded += chunk.values.length; } if (loaded !== total) throw cacheError(); } export async function loadStoredResultSnapshot(): Promise { const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY); if (!raw) return null; const value = JSON.parse(raw); // Read legacy sessionStorage data without rewriting or duplicating it at startup. if (value?.storage !== "indexeddb") return value; if (value.version !== 1 || typeof value.cacheId !== "string") return null; const db = await openDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction(["headers", "chunks"], "readonly"); let result: SimulationResultsSnapshot | null = null; let failure: unknown = null; transaction.oncomplete = () => failure ? reject(failure) : resolve(result); transaction.onabort = () => reject(transaction.error); const request = transaction.objectStore("headers").get(value.cacheId); request.onsuccess = () => { const header = request.result as Header | undefined; if (!header) { failure = new Error("结果缓存已丢失,请重新载入结果文件"); return; } try { result = header.snapshot; for (const [name, length] of Object.entries(header.lengths)) { if (!Number.isSafeInteger(length) || length < 0) throw cacheError(); result.result.series[name] = new Array(length); } const range = IDBKeyRange.bound([value.cacheId], [value.cacheId, []]); if (header.layout === "packed-f64-v1") { // A handful of packed records can be restored with one IndexedDB delivery. const blocks = transaction.objectStore("chunks").getAll(range); blocks.onsuccess = () => { try { restorePacked(header, blocks.result as PackedChunk[]); } catch (error) { failure = error; } }; return; } if (header.layout !== undefined) throw cacheError(); // Compatibility with the original [cacheId, columnName, offset] records. const loaded: Record = Object.create(null); const cursor = transaction.objectStore("chunks").openCursor(range); cursor.onsuccess = () => { const entry = cursor.result; if (!entry) { for (const [name, length] of Object.entries(header.lengths)) { if ((loaded[name] ?? 0) !== length) failure = cacheError(); } return; } const [, name, offset] = entry.key as [string, string, number]; const block = entry.value as Float64Array; const values = result!.result.series[name]; if (!values || !(block instanceof Float64Array) || offset !== (loaded[name] ?? 0) || offset + block.length > values.length) { failure = cacheError(); } else { for (let i = 0; i < block.length; i++) values[offset + i] = block[i]; loaded[name] = offset + block.length; } entry.continue(); }; } catch (error) { failure = error; } }; }); }