/** * 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 { await new Promise((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 { await new Promise((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((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((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((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((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(); }); });