diff --git a/src/renderer/game/storage/db.ts b/src/renderer/game/storage/db.ts index 3030721..e2335bd 100644 --- a/src/renderer/game/storage/db.ts +++ b/src/renderer/game/storage/db.ts @@ -1,20 +1,23 @@ import { SaveSlot } from './slots' import { GameState, SaveMeta, SnapshotMeta } from '../types/domain' import type { SaveDbDriver } from './slots' +import { IndexedDBBackend } from './idb-backend' // ──────────────────────────────────────────────────────────────────────────── -// 0.1.38 存档根治:OPFS 可用性探测 + 自动降级 + 错误恢复 +// 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入 // -// 旧版问题: -// 1. SqlarkDriver.open 失败后 this.opening 不清除 → 永久锁死,后续全部静默失败 -// 2. OPFS 在 Electron app:// origin 下可能不可用 → 全链路 throw 被 Stash.save 吞掉 -// 3. SlotManager.ensureMeta 直接用 OPFS,无降级 +// 根因分析(上一版修复遗留问题): +// 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部 +// new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可 +// 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。 +// 即使 OPFS "假可用"(getDirectory 不抛错但数据不持久化),也有同样问题。 // -// 0.1.38 方案: -// · 启动时探测 OPFS 可用性,不可用则自动降级到 KVStore(IndexedDB) -// · SqlarkDriver.open 失败后清除 opening,允许重试 -// · SlotManager 使用与 SaveSlot 相同的降级策略 -// · 全局存储后端类型可查询,供 UI 诊断面板展示 +// 最终方案: +// · 自建 IndexedDBBackend(idb-backend.ts),用原生 IndexedDB API 持久化 +// · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium +// · 替换后 reload() 重新从 IndexedDB 加载数据 +// · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化) +// · 保留 OPFS 探测和诊断能力供 UI 展示 // ──────────────────────────────────────────────────────────────────────────── // Adapter for @metona-team/metona-sqlark @@ -73,30 +76,101 @@ export function _resetStorageProbe(): void { // 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine // ──────────────────────────────────────────────────────────────────────────── -/** 构建数据库配置,OPFS 可用走 opfs,否则降级到 kv(IndexedDB) */ -async function buildDbConfig(name: string, withCompression: boolean): Promise<{ +/** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */ +async function buildDbConfig(name: string, _withCompression: boolean): Promise<{ name: string mode: string diskEngine: string - aria: Record }> { - const opfsOk = await probeOpfs() - if (opfsOk) { - _backendType = 'opfs' - return { - name, - mode: 'aria', - diskEngine: 'opfs', - aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) } - } - } - // 降级到 KVStore(IndexedDB 后端) + // 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB) + await probeOpfs() _backendType = 'kv' return { name, - mode: 'aria', + mode: 'hybrid', diskEngine: 'kv', - aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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 环境)——静默跳过 } } @@ -125,7 +199,13 @@ export class SqlarkDriver implements SaveDbDriver { this.opening = (async () => { try { const config = await buildDbConfig(name, true) - this.db = (await this.factory.create(config)) as SqlarkLike + 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) @@ -232,7 +312,12 @@ export class SlotManager { this.metaOpening = (async () => { try { const config = await buildDbConfig(META_DB, false) - this.metaDb = (await (await getSqlarkFactory()).create(config)) as SqlarkLike + 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) { diff --git a/src/renderer/game/storage/idb-backend.ts b/src/renderer/game/storage/idb-backend.ts new file mode 100644 index 0000000..16276c6 --- /dev/null +++ b/src/renderer/game/storage/idb-backend.ts @@ -0,0 +1,274 @@ +// ──────────────────────────────────────────────────────────────────────────── +// IndexedDB Storage Backend — 自建 IStorageBackend 实现 +// +// 0.1.38 根治方案: +// MetonaSqlark 的 KVStore 在 OPFS 不可用时 fallback 到 SharedMemoryBackend(纯内存), +// 进程关闭后数据全部丢失。此模块用原生 IndexedDB API 实现 IStorageBackend 接口, +// 确保数据真正持久化到浏览器/Electron 的 IndexedDB 中。 +// +// 设计要点: +// · 每个 key 对应一条 IndexedDB 记录(value 为 ArrayBuffer) +// · open 时创建 object store(如不存在) +// · 所有写操作通过单条 IDB 事务保证原子性 +// · writeMany 用单事务多 put 保证原子批量写入 +// · append 用读-改-写(单事务内 read+write 保证原子性) +// · close 不删数据(持久化语义——重开同名库可读到上次数据) +// ──────────────────────────────────────────────────────────────────────────── + +/** IStorageBackend 最小接口(与 metona-sqlark 的 IStorageBackend 对齐) */ +export interface IStorageBackendLike { + open(name: string): Promise + close(): Promise + isOpen(): boolean + read(key: string): Promise + write(key: string, data: ArrayBuffer): Promise + append?(key: string, data: ArrayBuffer): Promise + writeMany(entries: Record): Promise + delete(key: string): Promise + deleteMany(keys: string[]): Promise + listKeys(): Promise + exists(key: string): Promise + clear(): Promise +} + +const STORE_NAME = 'kv' +const DB_PREFIX = 'cotyc-idb-' + +/** + * 基于 IndexedDB 的持久化存储后端。 + * + * 用法:传入 KVStoreBackend 或 KVStore 构造函数作为 medium 参数, + * 替代 OPFSBackend / SharedMemoryBackend。 + */ +export class IndexedDBBackend implements IStorageBackendLike { + private dbName = '' + private db: IDBDatabase | null = null + private writeQueue: Promise = Promise.resolve() + + async open(name: string): Promise { + // 幂等:如果已打开同名库,直接返回(KVStore.reload 会二次调 open) + if (this.db && this.dbName === name) return + // 如果打开了不同名的库,先关闭旧的 + if (this.db) { + this.db.close() + this.db = null + } + this.dbName = name + const fullDbName = DB_PREFIX + name + return new Promise((resolve, reject) => { + const req = indexedDB.open(fullDbName, 1) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + } + req.onsuccess = () => { + this.db = req.result + resolve() + } + req.onerror = () => { + reject(new Error(`IndexedDB open failed: ${req.error?.message ?? 'unknown'}`)) + } + req.onblocked = () => { + reject(new Error('IndexedDB open blocked (another tab holding connection)')) + } + }) + } + + async close(): Promise { + // 等待写队列排空 + try { await this.writeQueue } catch { /* ignore */ } + if (this.db) { + this.db.close() + this.db = null + } + } + + isOpen(): boolean { + return this.db !== null + } + + async read(key: string): Promise { + if (!this.db) return null + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const req = store.get(key) + req.onsuccess = () => { + const result = req.result + if (result === undefined) { + resolve(null) + } else if (result instanceof ArrayBuffer) { + resolve(result) + } else if (result instanceof Uint8Array) { + // 某些浏览器可能返回 Uint8Array + resolve(result.buffer as ArrayBuffer) + } else { + // 兜底:尝试作为 ArrayBuffer 处理 + resolve(result as ArrayBuffer) + } + } + req.onerror = () => reject(req.error ?? new Error(`IDB read failed: ${key}`)) + }) + } + + async write(key: string, data: ArrayBuffer): Promise { + const run = this.writeQueue.then(() => this._writeRaw(key, data)) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _writeRaw(key: string, data: ArrayBuffer): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.put(data, key) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error(`IDB write failed: ${key}`)) + tx.onabort = () => reject(tx.error ?? new Error(`IDB write aborted: ${key}`)) + }) + } + + async append(key: string, data: ArrayBuffer): Promise { + if (!this.db) return + const run = this.writeQueue.then(() => this._appendRaw(key, data)) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _appendRaw(key: string, data: ArrayBuffer): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + const getReq = store.get(key) + getReq.onsuccess = () => { + const existing = getReq.result + let combined: ArrayBuffer + if (existing instanceof ArrayBuffer) { + const merged = new Uint8Array(existing.byteLength + data.byteLength) + merged.set(new Uint8Array(existing), 0) + merged.set(new Uint8Array(data), existing.byteLength) + combined = merged.buffer as ArrayBuffer + } else if (existing instanceof Uint8Array) { + const merged = new Uint8Array(existing.byteLength + data.byteLength) + merged.set(existing, 0) + merged.set(new Uint8Array(data), existing.byteLength) + combined = merged.buffer as ArrayBuffer + } else if (existing !== undefined) { + combined = existing as ArrayBuffer + } else { + combined = data + } + store.put(combined, key) + } + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error(`IDB append failed: ${key}`)) + tx.onabort = () => reject(tx.error ?? new Error(`IDB append aborted: ${key}`)) + }) + } + + async writeMany(entries: Record): Promise { + if (!this.db || Object.keys(entries).length === 0) return + const run = this.writeQueue.then(() => this._writeManyRaw(entries)) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _writeManyRaw(entries: Record): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + for (const [key, data] of Object.entries(entries)) { + store.put(data, key) + } + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('IDB writeMany failed')) + tx.onabort = () => reject(tx.error ?? new Error('IDB writeMany aborted')) + }) + } + + async delete(key: string): Promise { + if (!this.db) return + const run = this.writeQueue.then(() => this._deleteRaw(key)) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _deleteRaw(key: string): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.delete(key) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error(`IDB delete failed: ${key}`)) + }) + } + + async deleteMany(keys: string[]): Promise { + if (!this.db || keys.length === 0) return + const run = this.writeQueue.then(() => this._deleteManyRaw(keys)) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _deleteManyRaw(keys: string[]): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + for (const key of keys) { + store.delete(key) + } + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('IDB deleteMany failed')) + }) + } + + async listKeys(): Promise { + if (!this.db) return [] + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const req = store.getAllKeys() + req.onsuccess = () => { + const keys = req.result as IDBValidKey[] + resolve(keys.map((k) => String(k))) + } + req.onerror = () => reject(req.error ?? new Error('IDB listKeys failed')) + }) + } + + async exists(key: string): Promise { + if (!this.db) return false + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const req = store.count(key) + req.onsuccess = () => resolve(req.result > 0) + req.onerror = () => reject(req.error ?? new Error(`IDB exists failed: ${key}`)) + }) + } + + async clear(): Promise { + if (!this.db) return + const run = this.writeQueue.then(() => this._clearRaw()) + this.writeQueue = run.then(() => undefined, () => undefined) + return run + } + + private _clearRaw(): Promise { + if (!this.db) return Promise.resolve() + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.clear() + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('IDB clear failed')) + }) + } +}