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 { SaveSlot } from './slots'
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain' import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
import type { SaveDbDriver } from './slots' import type { SaveDbDriver } from './slots'
import { IndexedDBBackend } from './idb-backend'
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// 0.1.38 存档根治:OPFS 可用性探测 + 自动降级 + 错误恢复 // 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入
// //
// 旧版问题: // 根因分析(上一版修复遗留问题
// 1. SqlarkDriver.open 失败后 this.opening 不清除 → 永久锁死,后续全部静默失败 // 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部
// 2. OPFS 在 Electron app:// origin 下可能不可用 → 全链路 throw 被 Stash.save 吞掉 // new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可
// 3. SlotManager.ensureMeta 直接用 OPFS,无降级 // 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。
// 即使 OPFS "假可用"getDirectory 不抛错但数据不持久化),也有同样问题。
// //
// 0.1.38 方案: // 最终方案:
// · 启动时探测 OPFS 可用性,不可用则自动降级到 KVStore(IndexedDB // · 自建 IndexedDBBackendidb-backend.ts),用原生 IndexedDB API 持久化
// · SqlarkDriver.open 失败后清除 opening,允许重试 // · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium
// · SlotManager 使用与 SaveSlot 相同的降级策略 // · 替换后 reload() 重新从 IndexedDB 加载数据
// · 全局存储后端类型可查询,供 UI 诊断面板展示 // · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化)
// · 保留 OPFS 探测和诊断能力供 UI 展示
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// Adapter for @metona-team/metona-sqlark // Adapter for @metona-team/metona-sqlark
@@ -73,30 +76,101 @@ export function _resetStorageProbe(): void {
// 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine // 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
/** 构建数据库配置,OPFS 可用走 opfs,否则降级到 kv(IndexedDB */ /** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */
async function buildDbConfig(name: string, withCompression: boolean): Promise<{ async function buildDbConfig(name: string, _withCompression: boolean): Promise<{
name: string name: string
mode: string mode: string
diskEngine: string diskEngine: string
aria: Record<string, unknown>
}> { }> {
const opfsOk = await probeOpfs() // 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB)
if (opfsOk) { await probeOpfs()
_backendType = 'opfs'
return {
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) }
}
}
// 降级到 KVStoreIndexedDB 后端)
_backendType = 'kv' _backendType = 'kv'
return { return {
name, name,
mode: 'aria', mode: 'hybrid',
diskEngine: 'kv', 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 () => { this.opening = (async () => {
try { try {
const config = await buildDbConfig(name, true) 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 this.lastError = null
} catch (e) { } catch (e) {
this.lastError = String(e) this.lastError = String(e)
@@ -232,7 +312,12 @@ export class SlotManager {
this.metaOpening = (async () => { this.metaOpening = (async () => {
try { try {
const config = await buildDbConfig(META_DB, false) 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)`) await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
this.metaError = null this.metaError = null
} catch (e) { } catch (e) {
+274
View File
@@ -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<void>
close(): Promise<void>
isOpen(): boolean
read(key: string): Promise<ArrayBuffer | null>
write(key: string, data: ArrayBuffer): Promise<void>
append?(key: string, data: ArrayBuffer): Promise<void>
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>
delete(key: string): Promise<void>
deleteMany(keys: string[]): Promise<void>
listKeys(): Promise<string[]>
exists(key: string): Promise<boolean>
clear(): Promise<void>
}
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<void> = Promise.resolve()
async open(name: string): Promise<void> {
// 幂等:如果已打开同名库,直接返回(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<void> {
// 等待写队列排空
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<ArrayBuffer | null> {
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<void> {
const run = this.writeQueue.then(() => this._writeRaw(key, data))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _writeRaw(key: string, data: ArrayBuffer): Promise<void> {
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<void> {
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<void> {
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<string, ArrayBuffer>): Promise<void> {
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<string, ArrayBuffer>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string[]> {
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<boolean> {
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<void> {
if (!this.db) return
const run = this.writeQueue.then(() => this._clearRaw())
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _clearRaw(): Promise<void> {
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'))
})
}
}