diff --git a/src/renderer/db/schema.ts b/src/renderer/db/schema.ts index 01add3e..022b60a 100644 --- a/src/renderer/db/schema.ts +++ b/src/renderer/db/schema.ts @@ -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 = { id: { type: 'string', primaryKey: true }, filePath: { type: 'string' }, @@ -80,8 +81,7 @@ const ACTIVE_TAB_COLUMNS: Record = { /** * v0.6.0: 数据损坏自愈 — - * AriaEngine 在异常退出(强杀/断电)时可能留下残缺的 SSTable 文件, - * 重新打开解析块偏移会抛 RangeError(offset is out of bounds)导致初始化失败。 + * 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。 * 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接) * → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。 * @@ -93,14 +93,12 @@ let dbPromise: Promise | 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 { return new Promise(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 { } } +/** + * 解析 IDB 库当前版本号。 + * sqlark 的 IndexedDBEngine 建表时会把版本号 +1,重启若用配置的固定版本打开 + * 会抛 VersionError(IDB_OPEN_ERROR),因此必须用库当前版本打开。 + * indexedDB.open(dbName) 不带版本参数 = 以任意版本打开,可读取当前版本。 + */ +function resolveDbVersion(): Promise { + return new Promise(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 { + 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 } diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index cf18053..ecbb84f 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -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.