Files
SPipe/src/sessionStore.ts
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

184 lines
6.8 KiB
TypeScript

import type { NetworkModel } from './inpModel';
import type { BasemapType, Scenario, SimulationResult, UserGisLayer } from './types';
import type { CRS } from './crs';
import type { DisplayUnitMode } from './unitSystem';
import type { MapStyleSettings } from './StyleSettingsPanel';
// Legacy "GISPipe" prefix kept on purpose (here and in the gispipe_* localStorage
// keys): renaming storage keys would drop every user's saved session/settings.
const DATABASE_NAME = 'gispipe-session';
const DATABASE_VERSION = 1;
const STORE_NAME = 'autosave';
const LATEST_KEY = 'latest';
const TAB_KEY_STORAGE_NAME = 'gispipe_tab_session_key';
// Autosave keeps one record per browser tab (see getTabSessionKey), so the
// store is trimmed to the most recently-saved tabs to avoid growing forever
// as tabs are opened and closed over time.
const MAX_RETAINED_SNAPSHOTS = 10;
export interface SessionSnapshot {
version: 2;
savedAt: string;
fileName: string | null;
networkModel: NetworkModel;
crs: CRS;
basemap: BasemapType;
displayUnitMode: DisplayUnitMode;
styleSettings: MapStyleSettings;
isModified: boolean;
// Full project state -- without these, restoring a session silently lost
// every alternative scenario, its results, and any GIS reference layers.
rawInp: string | null;
scenarios: Scenario[];
activeScenarioId: string;
wasmResult: SimulationResult | null;
serverResult: SimulationResult | null;
resultInpSnapshot: string | null;
userLayers: UserGisLayer[];
}
/** Pre-P0-8 snapshot shape, kept only so an old autosave record from a
* previous session doesn't hard-fail to load; it restores with no
* scenarios/results/layers rather than being discarded outright. */
interface LegacySessionSnapshotV1 {
version: 1;
savedAt: string;
fileName: string | null;
networkModel: NetworkModel;
crs: CRS;
basemap: BasemapType;
displayUnitMode: DisplayUnitMode;
styleSettings: MapStyleSettings;
isModified: boolean;
}
type StoredSnapshot = (SessionSnapshot | LegacySessionSnapshotV1) & { id: string };
/**
* A stable id for this browser TAB (sessionStorage, unlike localStorage/
* IndexedDB, is private per tab and survives reloads of that same tab but
* not a fresh tab/window). Using it as the IndexedDB record key means
* multiple tabs autosaving concurrently each get their own record instead of
* clobbering a single shared 'latest' record.
*/
function getTabSessionKey(): string {
try {
let key = sessionStorage.getItem(TAB_KEY_STORAGE_NAME);
if (!key) {
key = `tab_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem(TAB_KEY_STORAGE_NAME, key);
}
return key;
} catch {
// sessionStorage unavailable (e.g. private-mode restrictions): fall back
// to the old shared key rather than failing autosave entirely.
return LATEST_KEY;
}
}
function openSessionDatabase(): Promise<IDBDatabase> {
const { promise, resolve, reject } = Promise.withResolvers<IDBDatabase>();
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 열기 실패'));
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
return promise;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
const { promise, resolve, reject } = Promise.withResolvers<T>();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 요청 실패'));
return promise;
}
export async function saveSessionSnapshot(snapshot: SessionSnapshot): Promise<void> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const { promise, resolve, reject } = Promise.withResolvers<void>();
const transaction = database.transaction(STORE_NAME, 'readwrite');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장 실패'));
transaction.onabort = () => reject(transaction.error ?? new Error('자동 저장 중단'));
const store = transaction.objectStore(STORE_NAME);
const stored: StoredSnapshot = { ...snapshot, id: key };
store.put(stored);
// Trim to the most recently-saved MAX_RETAINED_SNAPSHOTS records so
// autosaving from many tabs over time doesn't grow the store forever.
const allRequest = store.getAll();
allRequest.onsuccess = () => {
const all = (allRequest.result as StoredSnapshot[]) || [];
if (all.length > MAX_RETAINED_SNAPSHOTS) {
const sorted = [...all].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
for (const stale of sorted.slice(MAX_RETAINED_SNAPSHOTS)) {
store.delete(stale.id);
}
}
};
await promise;
} finally {
database.close();
}
}
/** Exported for unit testing the v1 -> v2 migration without a real IndexedDB. */
export function normalizeStoredSnapshot(stored: StoredSnapshot): SessionSnapshot | null {
if (!stored || !stored.networkModel) return null;
if (stored.version === 2) {
const { id: _id, ...snapshot } = stored;
return snapshot;
}
if (stored.version === 1) {
const { id: _id, ...legacy } = stored;
return {
...legacy,
version: 2,
rawInp: null,
scenarios: [],
activeScenarioId: 'base',
wasmResult: null,
serverResult: null,
resultInpSnapshot: null,
userLayers: [],
};
}
return null;
}
export async function loadSessionSnapshot(): Promise<SessionSnapshot | null> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const transaction = database.transaction(STORE_NAME, 'readonly');
const stored = (await requestToPromise(
transaction.objectStore(STORE_NAME).get(key)
)) as StoredSnapshot | undefined;
if (!stored) return null;
return normalizeStoredSnapshot(stored);
} finally {
database.close();
}
}
export async function clearSessionSnapshot(): Promise<void> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const { promise, resolve, reject } = Promise.withResolvers<void>();
const transaction = database.transaction(STORE_NAME, 'readwrite');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장본 삭제 실패'));
transaction.objectStore(STORE_NAME).delete(key);
await promise;
} finally {
database.close();
}
}