0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open
This commit is contained in:
+216
-19
@@ -2,6 +2,21 @@ import { SaveSlot } from './slots'
|
||||
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
|
||||
import type { SaveDbDriver } from './slots'
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 0.1.38 存档根治:OPFS 可用性探测 + 自动降级 + 错误恢复
|
||||
//
|
||||
// 旧版问题:
|
||||
// 1. SqlarkDriver.open 失败后 this.opening 不清除 → 永久锁死,后续全部静默失败
|
||||
// 2. OPFS 在 Electron app:// origin 下可能不可用 → 全链路 throw 被 Stash.save 吞掉
|
||||
// 3. SlotManager.ensureMeta 直接用 OPFS,无降级
|
||||
//
|
||||
// 0.1.38 方案:
|
||||
// · 启动时探测 OPFS 可用性,不可用则自动降级到 KVStore(IndexedDB)
|
||||
// · SqlarkDriver.open 失败后清除 opening,允许重试
|
||||
// · SlotManager 使用与 SaveSlot 相同的降级策略
|
||||
// · 全局存储后端类型可查询,供 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
|
||||
@@ -14,36 +29,128 @@ interface SqlarkLike {
|
||||
|
||||
export function setSqlarkBackend(impl: AnyFactory): void {
|
||||
MetonaSqlark = impl
|
||||
_backendType = 'mock'
|
||||
}
|
||||
|
||||
export async function makeDriver(): Promise<SaveDbDriver> {
|
||||
const factory = await getSqlarkFactory()
|
||||
return new SqlarkDriver(factory)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
}
|
||||
|
||||
class SqlarkDriver implements SaveDbDriver {
|
||||
/** 获取当前存储后端类型(供 UI 诊断面板) */
|
||||
export function getStorageBackendType(): StorageBackendType {
|
||||
return _backendType
|
||||
}
|
||||
|
||||
/** 重置探测状态(测试用) */
|
||||
export function _resetStorageProbe(): void {
|
||||
_opfsProbed = false
|
||||
_opfsAvailable = false
|
||||
_backendType = 'unknown'
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 构建数据库配置,OPFS 可用走 opfs,否则降级到 kv(IndexedDB) */
|
||||
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 后端)
|
||||
_backendType = 'kv'
|
||||
return {
|
||||
name,
|
||||
mode: 'aria',
|
||||
diskEngine: 'kv',
|
||||
aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) }
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
if (this.opening) return this.opening
|
||||
// 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试
|
||||
if (this.opening) {
|
||||
try {
|
||||
return await this.opening
|
||||
} catch {
|
||||
// 上次失败,清除后走下方重试逻辑
|
||||
this.opening = null
|
||||
}
|
||||
}
|
||||
this.opening = (async () => {
|
||||
this.db = (await this.factory.create({
|
||||
name,
|
||||
mode: 'aria',
|
||||
diskEngine: 'opfs',
|
||||
aria: { walSyncMode: 'full', compression: true }
|
||||
})) as SqlarkLike
|
||||
this.opening = null
|
||||
try {
|
||||
const config = await buildDbConfig(name, true)
|
||||
this.db = (await this.factory.create(config)) as SqlarkLike
|
||||
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> {
|
||||
@@ -65,6 +172,11 @@ class SqlarkDriver implements SaveDbDriver {
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeDriver(): Promise<SaveDbDriver> {
|
||||
const factory = await getSqlarkFactory()
|
||||
return new SqlarkDriver(factory)
|
||||
}
|
||||
|
||||
const META_DB = 'cotyc-appmeta'
|
||||
|
||||
let singletonManager: SlotManager | null = null
|
||||
@@ -83,6 +195,12 @@ export async function getSqlarkFactory(): Promise<AnyFactory> {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -93,18 +211,43 @@ export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
||||
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
|
||||
this.metaDb = (await (await getSqlarkFactory()).create({
|
||||
name: META_DB,
|
||||
mode: 'aria',
|
||||
diskEngine: 'opfs',
|
||||
aria: { walSyncMode: 'full' }
|
||||
})) as SqlarkLike
|
||||
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
|
||||
if (this.metaOpening) {
|
||||
try {
|
||||
return await this.metaOpening
|
||||
} catch {
|
||||
this.metaOpening = null
|
||||
}
|
||||
}
|
||||
this.metaOpening = (async () => {
|
||||
try {
|
||||
const config = await buildDbConfig(META_DB, false)
|
||||
this.metaDb = (await (await getSqlarkFactory()).create(config)) as SqlarkLike
|
||||
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)[]> {
|
||||
@@ -142,5 +285,59 @@ export class SlotManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 存储健康诊断——供 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 }
|
||||
|
||||
Reference in New Issue
Block a user