Files
MetonaSqlark/src/migration/index.ts
T
thzxx 24b3ca1079
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
release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
2026-08-10 13:56:04 +08:00

171 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}