/** * migrateFromIndexedDB — 旧 IndexedDB 数据迁移到自研 KV 引擎 * @module migration/index * * v0.6.0: IndexedDB 从引擎中完全移除后,提供一次性迁移工具把旧库数据 * 导入新引擎(KVStoreEngine disk 模式 / AriaEngine)。 * * 旧库命名: * - disk 模式(IndexedDBEngine):库名 = dbName * - aria 模式(IndexedDBBackend):库名 = `aria-${dbName}` * * 仅此模块保留原生 IndexedDB 读取代码(一次性迁移用途,不参与运行时)。 */ import type { MetonaSqlark } from '../core'; import type { TableSchema, ColumnDef, FieldType } from '../constants'; export interface MigrationOptions { /** 旧库名(业务名,不含 aria- 前缀) */ dbName: string; /** * 旧引擎类型:仅支持 disk(IndexedDBEngine,每表一个 objectStore,行数据可直接读取)。 * aria 旧库(IndexedDBBackend)数据为引擎私有格式(SSTable/WAL),无法按行迁移。 */ engine: 'disk'; /** 目标数据库实例(已初始化,新引擎) */ target: MetonaSqlark; /** 进度回调 */ onProgress?: (done: number, total: number, table?: string) => void; } export interface MigrationResult { /** 已迁移的表 */ migratedTables: string[]; /** 迁移的行总数 */ rowCount: number; /** 跳过(无 schema 且无数据)的表 */ skippedTables: string[]; } /** 旧库中持久化 schema 的 store 名(IndexedDBEngine v0.3.2+) */ const SCHEMA_STORE = '__metona_schema'; function inferFieldType(value: unknown): FieldType { if (typeof value === 'number') return 'number'; if (typeof value === 'boolean') return 'boolean'; if (typeof value === 'object' && value !== null) return 'json'; return 'string'; } /** 从样例行推断 schema(旧库无持久化 schema 时回退) */ function inferSchema(tableName: string, rows: Record[]): TableSchema { const columns: Record = {}; if (rows.length === 0) return { name: tableName, columns }; const first = rows[0]; for (const key of Object.keys(first)) { columns[key] = { type: inferFieldType(first[key]), primaryKey: key === 'id', }; } return { name: tableName, columns }; } /** 打开旧 IndexedDB 库(只读) */ function openLegacyDB(idbName: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(idbName); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error(`Failed to open legacy IndexedDB "${idbName}"`)); }); } /** 读取 object store 全部记录 */ function readAllRecords(store: IDBObjectStore): Promise[]> { return new Promise((resolve, reject) => { const req = store.getAll(); req.onsuccess = () => resolve((req.result ?? []) as Record[]); req.onerror = () => reject(req.error); }); } /** 读取持久化 schema 记录 */ function readSchemas(db: IDBDatabase): Promise> { if (!db.objectStoreNames.contains(SCHEMA_STORE)) { return Promise.resolve({}); } return new Promise((resolve, reject) => { const req = db.transaction(SCHEMA_STORE, 'readonly').objectStore(SCHEMA_STORE).getAll(); req.onsuccess = () => { const result: Record = {}; for (const rec of (req.result ?? []) as { name: string; schema?: string }[]) { if (!rec.schema) continue; try { const schema = JSON.parse(rec.schema) as TableSchema; result[schema.name] = schema; } catch { /* 损坏记录跳过 */ } } resolve(result); }; req.onerror = () => reject(req.error); }); } /** * 将旧 IndexedDB 库迁移到目标引擎。 * @returns 迁移结果(表/行数统计) */ export async function migrateFromIndexedDB(opts: MigrationOptions): Promise { // v0.6.0: aria 旧库为引擎私有格式(SSTable/WAL),不支持按行迁移(运行时防御) if ((opts.engine as string) === 'aria') { throw new Error( 'Migration from AriaEngine IndexedDB backend is not supported ' + '(data is stored in engine-private SSTable/WAL format). ' + 'Only disk-mode IndexedDBEngine databases can be migrated.', ); } const idbName = opts.dbName; let db: IDBDatabase | null = null; try { db = await openLegacyDB(idbName); } catch (error) { throw new Error( `Legacy IndexedDB database "${idbName}" not found or unreadable: ${(error as Error).message}`, ); } const result: MigrationResult = { migratedTables: [], rowCount: 0, skippedTables: [] }; const schemas = await readSchemas(db); try { const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== SCHEMA_STORE); for (let i = 0; i < storeNames.length; i++) { const tableName = storeNames[i]; opts.onProgress?.(i, storeNames.length, tableName); const rows = await readAllRecords(db.transaction(tableName, 'readonly').objectStore(tableName)); // 表已存在于目标库 → 跳过(避免覆盖) const names = await opts.target.getTableNames(); if (names.includes(tableName)) { result.skippedTables.push(tableName); continue; } // schema:持久化优先,否则从数据推断(空表且无 schema → 跳过) let schema = schemas[tableName]; if (!schema) { if (rows.length === 0) { result.skippedTables.push(tableName); continue; } schema = inferSchema(tableName, rows); } // 写入目标引擎 await opts.target.defineTable(tableName, schema.columns); if (rows.length > 0) { await opts.target.table(tableName).insertMany(rows); } result.migratedTables.push(tableName); result.rowCount += rows.length; } } finally { db.close(); } return result; }