185 lines
6.5 KiB
TypeScript
185 lines
6.5 KiB
TypeScript
/**
|
||
* 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 | null {
|
||
const columns: Record<string, ColumnDef> = {};
|
||
if (rows.length === 0) return { name: tableName, columns };
|
||
const first = rows[0];
|
||
const keys = Object.keys(first);
|
||
// v0.7.3: 主键推断 —— 优先 id;无 id 列时取第一个非 json 类型列(json 列
|
||
// String() 化为 "[object Object]" 会致所有行主键冲突)。全 json 列无可用
|
||
// 主键 → 返回 null(调用方跳过该表),此前直接抛 SCHEMA_ERROR 中断整个迁移。
|
||
const pkKey = keys.includes('id')
|
||
? 'id'
|
||
: keys.find((k) => inferFieldType(first[k]) !== 'json');
|
||
if (!pkKey) return null;
|
||
for (const key of keys) {
|
||
columns[key] = {
|
||
type: inferFieldType(first[key]),
|
||
primaryKey: key === pkKey,
|
||
};
|
||
}
|
||
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;
|
||
}
|
||
const inferred = inferSchema(tableName, rows);
|
||
// v0.7.3: 全 json 列推断无主键 → 跳过该表(此前抛 SCHEMA_ERROR 中断迁移)
|
||
if (!inferred) {
|
||
result.skippedTables.push(tableName);
|
||
continue;
|
||
}
|
||
schema = inferred;
|
||
}
|
||
|
||
// 写入目标引擎
|
||
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;
|
||
}
|