Files
ChronicleOfTheImmortalClan/src/renderer/game/storage/db.ts
T

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 不抛错但数据不持久化),也有同样问题。
//
// 最终方案:
// · 自建 IndexedDBBackendidb-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<any> }
let MetonaSqlark: AnyFactory | null = null
interface SqlarkLike {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>
}
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<boolean> {
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<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 环境)——静默跳过
}
}
// ────────────────────────────────────────────────────────────────────────────
// SqlarkDriver——根治 open 失败后永久锁死问题
// ────────────────────────────────────────────────────────────────────────────
export class SqlarkDriver implements SaveDbDriver {
private db: SqlarkLike | null = null
private opening: Promise<void> | null = null
private lastError: string | null = null
constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> {
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<void> {
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<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql, params)
return null
}
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
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<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql)
return null
}
}
export async function makeDriver(): Promise<SaveDbDriver> {
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<AnyFactory> {
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<number, SaveSlot>()
/** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */
export function _resetSlotCache(): void {
slotCache.clear()
}
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
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<void> | null = null
private metaError: string | null = null
async ensureMeta(): Promise<void> {
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<number, SaveMeta>()
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<void> {
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<void> {
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<StorageHealthReport> {
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 }