release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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<string, unknown>[]): TableSchema {
|
||||
const columns: Record<string, ColumnDef> = {};
|
||||
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<IDBDatabase> {
|
||||
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<Record<string, unknown>[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve((req.result ?? []) as Record<string, unknown>[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取持久化 schema 记录 */
|
||||
function readSchemas(db: IDBDatabase): Promise<Record<string, TableSchema>> {
|
||||
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<string, TableSchema> = {};
|
||||
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<MigrationResult> {
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user