fix: 存档根治——自建 IndexedDB 持久化后端注入 (0.1.38)
This commit is contained in:
+113
-28
@@ -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<string, unknown>
|
||||
}> {
|
||||
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<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 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) {
|
||||
|
||||
Reference in New Issue
Block a user