429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
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 不抛错但数据不持久化),也有同样问题。
|
||
//
|
||
// 最终方案:
|
||
// · 自建 IndexedDBBackend(idb-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 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 环境)——静默跳过
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// 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 }
|