fix: 存储引擎切换为 HybridEngine(write-through 直写 IDB)+ 动态版本解析

- AriaEngine 的 SSTable/WAL 多 key 非原子写入在异常退出/强杀时留下半写文件,
  重启解析越界(offset is out of bounds)导致每次关闭后必损坏
- HybridEngine 每行单 key 原子写入 IDB,实测 close/强杀/多次重启数据完好
- 解决 sqlark IndexedDBEngine 建表升级版本号导致的 VersionError:
  启动时解析库当前版本再打开
- Zen 专注模式宽度改为全宽(max-width: none)
This commit is contained in:
thzxx
2026-08-09 17:17:39 +08:00
parent 0e06219969
commit b762488ac9
2 changed files with 50 additions and 27 deletions
+48 -25
View File
@@ -37,18 +37,19 @@ export type RecentFile = {
} }
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃 // v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
// v0.5.0: 存储引擎AriaEngine自研 LSM-Tree + WAL + MVCC,对标 SQLite // v0.6.0: 存储引擎HybridEnginewrite-through:内存读写 + 直写 IndexedDB)。
// 弃用 AriaEngine 原因:其 SSTable/WAL 多 key 非原子写入在异常退出/强杀时
// 留下半写文件,重启解析越界(offset is out of bounds)导致反复损坏。
// Hybrid 每行单 key 原子写入 IDB,强杀/关闭中断不损坏(已实测)。
const DB_NAME = 'MarkLiteV2' const DB_NAME = 'MarkLiteV2'
/** /**
* v0.6.0: 版本化 Schema 迁移 * v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)
* create 时 version 传 0(表示尚未应用任何 schema),migrateTo(SCHEMA_VERSION) * 注意: sqlark 的 IndexedDBEngine 每次建表会把 IDB 版本号 +1,且打开时使用
* 会依次执行所有 version > 0 的迁移。注意 _version 不持久化(每次启动从配置值 * 配置的固定版本,重启后配置版本低于库当前版本会抛 IDB_OPEN_ERRORVersionError)。
* 开始),因此每个迁移内部必须幂等(表存在检查) * 因此启动时先用 indexedDB.open(无版本) 解析库当前版本,再以该版本打开
* 未来升级: 新增 addMigration(N) + 提升 SCHEMA_VERSION,迁移内部做增量变更。 * 未来 schema 变更: 在 createDatabase 的幂等建表段追加增量变更逻辑
*/ */
const SCHEMA_VERSION = 1
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = { const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true }, id: { type: 'string', primaryKey: true },
filePath: { type: 'string' }, filePath: { type: 'string' },
@@ -80,8 +81,7 @@ const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
/** /**
* v0.6.0: 数据损坏自愈 — * v0.6.0: 数据损坏自愈 —
* AriaEngine 在异常退出(强杀/断电)时可能留下残缺的 SSTable 文件, * 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。
* 重新打开解析块偏移会抛 RangeErroroffset is out of bounds)导致初始化失败。
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接) * 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。 * → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
* *
@@ -93,14 +93,12 @@ let dbPromise: Promise<MetonaSqlark> | null = null
let initFailedNotified = false let initFailedNotified = false
let healTriggered = false let healTriggered = false
// IndexedDBBackend 的内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}`
const RESET_PENDING_KEY = 'marklite-db-reset-pending' const RESET_PENDING_KEY = 'marklite-db-reset-pending'
/** 删除损坏数据库(无活动连接时立即成功) */ /** 删除损坏数据库(无活动连接时立即成功) */
function deleteStorageDatabase(): Promise<void> { function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME) const req = indexedDB.deleteDatabase(DB_NAME)
req.onsuccess = () => resolve() req.onsuccess = () => resolve()
req.onerror = () => resolve() req.onerror = () => resolve()
req.onblocked = () => resolve() req.onblocked = () => resolve()
@@ -122,25 +120,50 @@ async function resetCorruptDatabaseIfNeeded(): Promise<void> {
} }
} }
/**
* 解析 IDB 库当前版本号。
* sqlark 的 IndexedDBEngine 建表时会把版本号 +1,重启若用配置的固定版本打开
* 会抛 VersionErrorIDB_OPEN_ERROR),因此必须用库当前版本打开。
* indexedDB.open(dbName) 不带版本参数 = 以任意版本打开,可读取当前版本。
*/
function resolveDbVersion(): Promise<number> {
return new Promise<number>(resolve => {
try {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => {
const version = req.result?.version ?? 1
try {
req.result?.close()
} catch {
/* 忽略 */
}
resolve(version || 1)
}
req.onerror = () => resolve(1)
req.onblocked = () => resolve(1)
} catch {
resolve(1)
}
})
}
async function createDatabase(): Promise<MetonaSqlark> { async function createDatabase(): Promise<MetonaSqlark> {
const version = await resolveDbVersion()
const db = await create({ const db = await create({
name: DB_NAME, name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离 mode: 'hybrid', // HybridEngine: write-through — 内存读写 + 直写 IndexedDB(每行单 key 原子写入,强杀不损坏)
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory diskEngine: 'indexeddb',
version: 0, // 0 = 尚未应用任何 schema 迁移 version,
onError: (err: Error) => logError('数据库错误', err), onError: (err: Error) => logError('数据库错误', err),
}) })
// v1: 初始四表结构 // 幂等建表(getTableNames 基于持久化的 __metona_schema,重启后不会重复升级版本)
db.addMigration(1, async d => { const tables = await db.getTableNames()
const tables = await d.getTableNames() if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('tabSnapshots')) await d.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS) if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
if (!tables.includes('settings')) await d.defineTable('settings', SETTINGS_COLUMNS) if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('recentFiles')) await d.defineTable('recentFiles', RECENT_FILES_COLUMNS) if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
if (!tables.includes('activeTab')) await d.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
})
await db.migrateTo(SCHEMA_VERSION)
return db return db
} }
+2 -2
View File
@@ -160,9 +160,9 @@ body {
height: 100%; height: 100%;
} }
/* v0.6.0: Zen 专注模式宽 — 覆盖内置 820px(同优先级下运行时注入的样式会赢,用更高 specificity */ /* v0.6.0: Zen 专注模式宽 — 覆盖内置 820px(同优先级下运行时注入的样式会赢,用更高 specificity */
.metona-editor-wrapper .me-wrapper.me-zen .me-body { .metona-editor-wrapper .me-wrapper.me-zen .me-body {
max-width: 1200px; max-width: none;
} }
/* FIX: Allow text selection in the preview pane. /* FIX: Allow text selection in the preview pane.