import { SaveSlot } from './slots' import { GameState, SaveMeta, SnapshotMeta } from '../types/domain' import type { SaveDbDriver } from './slots' import { IndexedDBBackend } from './idb-backend' // ──────────────────────────────────────────────────────────────────────────── // 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入 // // 根因分析(上一版修复遗留问题): // 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部 // new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可 // 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。 // 即使 OPFS "假可用"(getDirectory 不抛错但数据不持久化),也有同样问题。 // // 最终方案: // · 自建 IndexedDBBackend(idb-backend.ts),用原生 IndexedDB API 持久化 // · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium // · 替换后 reload() 重新从 IndexedDB 加载数据 // · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化) // · 保留 OPFS 探测和诊断能力供 UI 展示 // ──────────────────────────────────────────────────────────────────────────── // Adapter for @metona-team/metona-sqlark // The driver interface keeps the engine swappable (testable in node too). // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFactory = { create: (config: any) => Promise } let MetonaSqlark: AnyFactory | null = null interface SqlarkLike { query: (sql: string, params?: unknown[]) => Promise } export function setSqlarkBackend(impl: AnyFactory): void { MetonaSqlark = impl _backendType = 'mock' } // ──────────────────────────────────────────────────────────────────────────── // OPFS 可用性探测(一次性,进程级缓存) // ──────────────────────────────────────────────────────────────────────────── export type StorageBackendType = 'opfs' | 'kv' | 'memory' | 'mock' | 'unknown' let _backendType: StorageBackendType = 'unknown' let _opfsProbed = false let _opfsAvailable = false /** 探测 OPFS 是否可用(navigator.storage.getDirectory 不抛错即视为可用) */ export async function probeOpfs(): Promise { if (_opfsProbed) return _opfsAvailable _opfsProbed = true try { if (typeof navigator !== 'undefined' && navigator.storage && typeof navigator.storage.getDirectory === 'function') { const root = await navigator.storage.getDirectory() _opfsAvailable = !!root } } catch { _opfsAvailable = false } return _opfsAvailable } /** 获取当前存储后端类型(供 UI 诊断面板) */ export function getStorageBackendType(): StorageBackendType { return _backendType } /** 重置探测状态(测试用) */ export function _resetStorageProbe(): void { _opfsProbed = false _opfsAvailable = false _backendType = 'unknown' } // ──────────────────────────────────────────────────────────────────────────── // 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine // ──────────────────────────────────────────────────────────────────────────── /** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */ async function buildDbConfig(name: string, _withCompression: boolean): Promise<{ name: string mode: string diskEngine: string }> { // 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB) await probeOpfs() _backendType = 'kv' return { name, mode: 'hybrid', diskEngine: 'kv', } } // ──────────────────────────────────────────────────────────────────────────── // IndexedDB 后端注入——通过反射链替换底层 KVStore.medium // ──────────────────────────────────────────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any type Reflective = Record /** * 通过反射链找到并替换 MetonaSqlark 实例底层 KVStore 的 medium。 * * 反射路径(两种模式): * · mode='aria': instance.engine.backend → KVStoreBackend.kv.medium * instance.engine.backend → OPFSBackend (直接替换 this.backend) * · mode='hybrid': instance.engine.diskEngine.kv.medium * * 替换后重新 open KVStore 并 reload HybridEngine 内存缓存。 */ async function injectIndexedDBBackend(instance: SqlarkLike & { engine?: unknown }, dbName: string): Promise { // 测试环境用 mock backend(setSqlarkBackend),没有 engine 属性——跳过 const engine = (instance as Reflective).engine as Reflective | undefined if (!engine) return try { // 尝试 mode='hybrid' 路径:engine.diskEngine.kv.medium const diskEngine = engine.diskEngine as Reflective | undefined if (diskEngine?.kv) { const kv = diskEngine.kv as Reflective const oldMedium = kv.medium as Reflective | undefined const oldName = oldMedium?.constructor?.name ?? '' // 无论 OPFS 还是 SharedMemory,都替换为 IndexedDB // OPFS 在 Electron app:// 下可能"假可用"(不抛错但不持久化) if (oldName !== 'IndexedDBBackend') { const idbBackend = new IndexedDBBackend() // 先打开新的 IndexedDB backend(KVStore.reload 会再次调 open,幂等) await idbBackend.open(dbName) // 直接替换 medium(不先关闭旧的——kv.reload 内部会先排空 opQueue 再 open 新 medium) kv.medium = idbBackend // 重新加载 KVStore 从新 medium 读取数据 if (typeof kv.reload === 'function') { await kv.reload() } // 让 HybridEngine 从磁盘重新加载到内存 if (typeof engine.reloadMemoryFromDisk === 'function') { await engine.reloadMemoryFromDisk() } _backendType = 'kv' } return } // 尝试 mode='aria' 路径:engine.backend const ariaBackend = engine.backend as Reflective | undefined if (ariaBackend) { const backendName = ariaBackend.constructor?.name ?? '' // 如果是 KVStoreBackend,走 kv.medium 路径 if (backendName === 'KVStoreBackend' && ariaBackend.kv) { const kv = ariaBackend.kv as Reflective const oldMedium = kv.medium as Reflective | undefined const oldName = oldMedium?.constructor?.name ?? '' if (oldName !== 'IndexedDBBackend') { const idbBackend = new IndexedDBBackend() await idbBackend.open(dbName) try { await oldMedium?.close?.() } catch { /* ignore */ } kv.medium = idbBackend if (typeof kv.reload === 'function') { await kv.reload() } _backendType = 'kv' } return } // 如果是 OPFSBackend,直接替换 engine.backend // 但 AriaEngine 内部有大量组件引用 backend(FileManager 等),直接替换不安全 // → 跳过,让 OPFS 自己工作(如果 OPFS 确实持久化就没问题) // 如果 OPFS 不持久化,应该走 buildDbConfig 的 kv 降级路径 } } catch { // 反射失败(可能是 mock/test 环境)——静默跳过 } } // ──────────────────────────────────────────────────────────────────────────── // SqlarkDriver——根治 open 失败后永久锁死问题 // ──────────────────────────────────────────────────────────────────────────── export class SqlarkDriver implements SaveDbDriver { private db: SqlarkLike | null = null private opening: Promise | null = null private lastError: string | null = null constructor(private factory: AnyFactory) {} async open(name: string): Promise { if (this.db) return // 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试 if (this.opening) { try { return await this.opening } catch { // 上次失败,清除后走下方重试逻辑 this.opening = null } } this.opening = (async () => { try { const config = await buildDbConfig(name, true) const instance = (await this.factory.create(config)) as SqlarkLike & { engine?: unknown } // 0.1.38 最终方案:注入 IndexedDB 持久化后端 // 无论 OPFS 是否可用,都替换为 IndexedDBBackend 确保数据跨进程持久化 await injectIndexedDBBackend(instance, name) this.db = instance this.lastError = null } catch (e) { this.lastError = String(e) throw e } finally { // 无论成功失败都清除 opening,允许后续重试 this.opening = null } })() return this.opening } async close(): Promise { this.db = null this.opening = null } /** 获取上次错误信息(供诊断) */ getLastError(): string | null { return this.lastError } /** 判断数据库是否已成功打开 */ isOpen(): boolean { return this.db !== null } async run(sql: string, params: unknown[] = []): Promise { if (!this.db) throw new Error('db not open') await this.db.query(sql, params) return null } async all(sql: string, params: unknown[] = []): Promise { if (!this.db) throw new Error('db not open') const rows = (await this.db.query(sql, params)) as T[] return rows ?? [] } async exec(sql: string): Promise { if (!this.db) throw new Error('db not open') await this.db.query(sql) return null } } export async function makeDriver(): Promise { const factory = await getSqlarkFactory() return new SqlarkDriver(factory) } const META_DB = 'cotyc-appmeta' let singletonManager: SlotManager | null = null export function getSlotManager(): SlotManager { if (!singletonManager) singletonManager = new SlotManager() return singletonManager } export async function getSqlarkFactory(): Promise { if (!MetonaSqlark) { const mod = await import('@metona-team/metona-sqlark') MetonaSqlark = mod.MetonaSqlark ?? mod.MeSqlark } if (!MetonaSqlark) throw new Error('metona-sqlark not available') return MetonaSqlark } const slotCache = new Map() /** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */ export function _resetSlotCache(): void { slotCache.clear() } export async function getSaveSlot(slot: number): Promise { let s = slotCache.get(slot) if (!s) { const factory = await getSqlarkFactory() s = new SaveSlot(slot, new SqlarkDriver(factory)) slotCache.set(slot, s) } return s } // ──────────────────────────────────────────────────────────────────────────── // SlotManager——0.1.38 同样走降级策略 + 错误恢复 // ──────────────────────────────────────────────────────────────────────────── export class SlotManager { private metaDb: SqlarkLike | null = null private metaOpening: Promise | null = null private metaError: string | null = null async ensureMeta(): Promise { if (this.metaDb) return if (this.metaOpening) { try { return await this.metaOpening } catch { this.metaOpening = null } } this.metaOpening = (async () => { try { const config = await buildDbConfig(META_DB, false) const instance = (await (await getSqlarkFactory()).create(config)) as SqlarkLike & { engine?: unknown } // 注入 IndexedDB 持久化后端 await injectIndexedDBBackend(instance, META_DB) this.metaDb = instance await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`) this.metaError = null } catch (e) { this.metaError = String(e) throw e } finally { this.metaOpening = null } })() return this.metaOpening } /** 获取 meta DB 的最后错误(供诊断) */ getMetaError(): string | null { return this.metaError } async listSlotMetas(): Promise<(SaveMeta | null)[]> { await this.ensureMeta() const rows = await this.metaDb!.query(`SELECT slot, meta FROM slots ORDER BY slot`) const map = new Map() for (const r of rows as { slot: number; meta: string }[]) { try { map.set(r.slot, JSON.parse(r.meta) as SaveMeta) } catch { map.set(r.slot, null as unknown as SaveMeta) } } const out: (SaveMeta | null)[] = [] for (let i = 1; i <= 4; i++) { out.push(map.get(i) ?? null) } return out } async updateSlotMeta(slot: number, meta: SaveMeta): Promise { await this.ensureMeta() const existing = await this.metaDb!.query(`SELECT slot FROM slots WHERE slot = ?`, [slot]) const json = JSON.stringify(meta) if (existing.length > 0) { await this.metaDb!.query(`UPDATE slots SET meta = ? WHERE slot = ?`, [json, slot]) } else { await this.metaDb!.query(`INSERT INTO slots (slot, meta) VALUES (?, ?)`, [slot, json]) } } async removeSlotMeta(slot: number): Promise { await this.ensureMeta() await this.metaDb!.query(`DELETE FROM slots WHERE slot = ?`, [slot]) } } // ──────────────────────────────────────────────────────────────────────────── // 存储健康诊断——供 UI 诊断面板调用 // ──────────────────────────────────────────────────────────────────────────── export interface StorageHealthReport { backendType: StorageBackendType opfsAvailable: boolean persistGranted: boolean | null driverOpen: boolean driverError: string | null metaError: string | null slotCount: number } /** 全面诊断存储状态——供 SettingsPanel 健康面板调用 */ export async function diagnoseStorage(): Promise { const opfsAvailable = await probeOpfs() const persistGranted = typeof navigator !== 'undefined' && navigator.storage ? (await navigator.storage.persisted?.().catch(() => null) ?? null) : null let driverOpen = false let driverError: string | null = null // 尝试打开 slot 1 做探测 try { const slot = await getSaveSlot(1) await slot.open() driverOpen = true } catch (e) { driverError = String(e).slice(0, 200) } const manager = getSlotManager() const metaError = manager.getMetaError() let slotCount = 0 try { const metas = await manager.listSlotMetas() slotCount = metas.filter((m) => m !== null).length } catch { // meta DB 不可用 } return { backendType: _backendType, opfsAvailable, persistGranted, driverOpen, driverError, metaError, slotCount } } export type { GameState, SaveMeta, SnapshotMeta }