release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
CI / test (20.x) (push) Successful in 10m53s
CI / test (22.x) (push) Successful in 10m47s
CI / test (24.x) (push) Successful in 10m42s
CI / e2e (push) Successful in 10m27s
CI / test (18.x) (push) Successful in 11m1s

This commit is contained in:
thzxx
2026-08-10 13:56:04 +08:00
parent 2668bd47cf
commit 24b3ca1079
74 changed files with 8016 additions and 7688 deletions
+170
View File
@@ -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;
/**
* 旧引擎类型:仅支持 diskIndexedDBEngine,每表一个 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;
}