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