245 lines
9.8 KiB
TypeScript
245 lines
9.8 KiB
TypeScript
/**
|
||
* migrateFromIndexedDB 迁移工具测试
|
||
*
|
||
* 用 fake-indexeddb 构造旧库(模拟 v0.5.x 及更早的 IndexedDB 引擎数据),
|
||
* 验证迁移到自研 KV 引擎(KVStoreEngine)后数据/schema/索引完整。
|
||
*/
|
||
import 'fake-indexeddb/auto';
|
||
import { MetonaSqlark } from '../src/core';
|
||
import { migrateFromIndexedDB } from '../src/migration/index';
|
||
|
||
let counter = 0;
|
||
function uniqueDB(): string {
|
||
return `mig-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
||
}
|
||
|
||
/** 构造旧 disk 模式库(IndexedDBEngine 布局:每表一个 objectStore + __metona_schema) */
|
||
async function createLegacyDiskDB(dbName: string): Promise<void> {
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(dbName, 2);
|
||
request.onupgradeneeded = () => {
|
||
const db = request.result;
|
||
const users = db.createObjectStore('users', { keyPath: 'id' });
|
||
users.createIndex('idx_email', 'email', { unique: true });
|
||
db.createObjectStore('orders', { keyPath: 'id' });
|
||
db.createObjectStore('__metona_schema', { keyPath: 'name' });
|
||
};
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction(['users', 'orders', '__metona_schema'], 'readwrite');
|
||
const users = tx.objectStore('users');
|
||
users.add({ id: '1', name: 'Alice', email: 'alice@x.com', age: 30 });
|
||
users.add({ id: '2', name: 'Bob', email: 'bob@x.com', age: 25 });
|
||
const orders = tx.objectStore('orders');
|
||
orders.add({ id: 'o1', user_id: '1', amount: 100 });
|
||
// 持久化 schema(IndexedDBEngine v0.3.2+ 布局)
|
||
tx.objectStore('__metona_schema').put({
|
||
name: 'users',
|
||
schema: JSON.stringify({
|
||
name: 'users',
|
||
columns: {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string', required: true },
|
||
email: { type: 'string', unique: true, index: true },
|
||
age: { type: 'number', default: 0 },
|
||
},
|
||
}),
|
||
});
|
||
tx.objectStore('__metona_schema').put({
|
||
name: 'orders',
|
||
schema: JSON.stringify({
|
||
name: 'orders',
|
||
columns: {
|
||
id: { type: 'string', primaryKey: true },
|
||
user_id: { type: 'string' },
|
||
amount: { type: 'number' },
|
||
},
|
||
}),
|
||
});
|
||
tx.oncomplete = () => { db.close(); resolve(); };
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
/** 构造旧 aria 模式库(IndexedDBBackend 布局:单 data store + aria- 前缀) */
|
||
async function createLegacyAriaDB(dbName: string): Promise<void> {
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(`aria-${dbName}`, 1);
|
||
request.onupgradeneeded = () => {
|
||
request.result.createObjectStore('data');
|
||
};
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction('data', 'readwrite');
|
||
const store = tx.objectStore('data');
|
||
store.put(new TextEncoder().encode('legacy').buffer, '__aria_keymeta_test');
|
||
tx.oncomplete = () => { db.close(); resolve(); };
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
describe('migrateFromIndexedDB', () => {
|
||
it('disk 模式旧库:schema + 数据完整迁移(含索引标记)', async () => {
|
||
const legacyName = uniqueDB();
|
||
await createLegacyDiskDB(legacyName);
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
|
||
expect(result.migratedTables.sort()).toEqual(['orders', 'users']);
|
||
expect(result.rowCount).toBe(3);
|
||
expect(await target.table('users').count()).toBe(2);
|
||
expect(await target.table('orders').count()).toBe(1);
|
||
|
||
// schema 保留(索引/唯一标记)
|
||
const schema = await target.getEngine().getTableSchema('users');
|
||
expect(schema!.columns.email.unique).toBe(true);
|
||
expect(schema!.columns.email.index).toBe(true);
|
||
|
||
// 数据正确
|
||
const alice = await target.table('users').select().where({ id: '1' }).execute();
|
||
expect(alice[0].email).toBe('alice@x.com');
|
||
|
||
// 重启后(持久化)数据保留
|
||
await target.close();
|
||
const target2 = new MetonaSqlark({ name: target.name, mode: 'disk' });
|
||
await target2.init();
|
||
expect(await target2.table('users').count()).toBe(2);
|
||
await target2.close();
|
||
});
|
||
|
||
it('无持久化 schema 的旧库:从数据推断', async () => {
|
||
const legacyName = uniqueDB();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(legacyName, 1);
|
||
request.onupgradeneeded = () => {
|
||
request.result.createObjectStore('legacy_tbl', { keyPath: 'id' });
|
||
};
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction('legacy_tbl', 'readwrite');
|
||
tx.objectStore('legacy_tbl').add({ id: 'a', name: 'X', age: 10 });
|
||
tx.oncomplete = () => { db.close(); resolve(); };
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
|
||
expect(result.migratedTables).toEqual(['legacy_tbl']);
|
||
expect(result.rowCount).toBe(1);
|
||
const rows = await target.table('legacy_tbl').select().execute();
|
||
expect(rows[0].name).toBe('X');
|
||
await target.close();
|
||
});
|
||
|
||
it('目标库已存在的表跳过(不覆盖)', async () => {
|
||
const legacyName = uniqueDB();
|
||
await createLegacyDiskDB(legacyName);
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
await target.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||
await target.table('users').insert({ id: 'keep' });
|
||
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
expect(result.skippedTables).toContain('users');
|
||
// 已存在的表未被覆盖
|
||
expect(await target.table('users').count()).toBe(1);
|
||
await target.close();
|
||
});
|
||
|
||
it('空旧库(无业务 store)→ 返回空迁移结果', async () => {
|
||
const legacyName = uniqueDB();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(legacyName, 1);
|
||
request.onsuccess = () => { request.result.close(); resolve(); };
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
expect(result.migratedTables).toEqual([]);
|
||
expect(result.rowCount).toBe(0);
|
||
await target.close();
|
||
});
|
||
|
||
it('aria 模式旧库(引擎私有格式)→ 明确抛错说明不支持', async () => {
|
||
const legacyName = uniqueDB();
|
||
await createLegacyAriaDB(legacyName);
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
await expect(migrateFromIndexedDB({ dbName: legacyName, engine: 'aria' as never, target }))
|
||
.rejects.toThrow('not supported');
|
||
await target.close();
|
||
});
|
||
|
||
it('无 id 列的旧库:第一个非 json 列兜底为主键(v0.7.3)', async () => {
|
||
const legacyName = uniqueDB();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(legacyName, 1);
|
||
request.onupgradeneeded = () => {
|
||
request.result.createObjectStore('nokey_tbl', { autoIncrement: true });
|
||
};
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction('nokey_tbl', 'readwrite');
|
||
tx.objectStore('nokey_tbl').add({ code: 'c1', name: 'X' });
|
||
tx.objectStore('nokey_tbl').add({ code: 'c2', name: 'Y' });
|
||
tx.oncomplete = () => { db.close(); resolve(); };
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
// 此前无主键 → createSchema 抛 SCHEMA_ERROR 中断整个迁移;现在 code 列兜底为主键
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
expect(result.migratedTables).toEqual(['nokey_tbl']);
|
||
expect(result.rowCount).toBe(2);
|
||
const rows = await target.table('nokey_tbl').select().execute();
|
||
expect(rows).toHaveLength(2);
|
||
await target.close();
|
||
});
|
||
|
||
it('全 json 列的旧库:无可用主键 → 跳过该表不中断迁移(v0.7.3)', async () => {
|
||
const legacyName = uniqueDB();
|
||
await new Promise<void>((resolve, reject) => {
|
||
const request = indexedDB.open(legacyName, 1);
|
||
request.onupgradeneeded = () => {
|
||
request.result.createObjectStore('json_tbl', { autoIncrement: true });
|
||
request.result.createObjectStore('ok_tbl', { keyPath: 'id' });
|
||
};
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction(['json_tbl', 'ok_tbl'], 'readwrite');
|
||
tx.objectStore('json_tbl').add({ payload: { a: 1 } });
|
||
tx.objectStore('ok_tbl').add({ id: '1', name: 'Z' });
|
||
tx.oncomplete = () => { db.close(); resolve(); };
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
|
||
const target = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
|
||
await target.init();
|
||
const result = await migrateFromIndexedDB({ dbName: legacyName, engine: 'disk', target });
|
||
// json_tbl 无可用主键被跳过;ok_tbl 正常迁移
|
||
expect(result.skippedTables).toContain('json_tbl');
|
||
expect(result.migratedTables).toEqual(['ok_tbl']);
|
||
await target.close();
|
||
});
|
||
});
|