fix: 存档根治——自建 IndexedDB 持久化后端注入 (0.1.38)

This commit is contained in:
2026-09-13 08:33:49 +08:00
parent c0efc63434
commit fb23afa322
2 changed files with 387 additions and 28 deletions
+113 -28
View File
@@ -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 诊断面板展示
// 最终方案:
// · 自建 IndexedDBBackendidb-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<string, unknown>
}> {
const opfsOk = await probeOpfs()
if (opfsOk) {
_backendType = 'opfs'
return {
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) }
}
}
// 降级到 KVStoreIndexedDB 后端)
// 探测 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<string, any>
/**
* 通过反射链找到并替换 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<void> {
// 测试环境用 mock backendsetSqlarkBackend),没有 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 backendKVStore.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 内部有大量组件引用 backendFileManager 等),直接替换不安全
// → 跳过,让 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) {