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
+11 -8
View File
@@ -2,11 +2,14 @@
* v0.4.1 测试 — AriaEngine 外键级联 + clearAll 重置
*/
import 'fake-indexeddb/auto';
import { AriaEngine } from '../src/engine/aria/index';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
const mkEngine = async (name: string): Promise<AriaEngine> => {
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(`cascade-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, 1);
return e;
};
@@ -126,7 +129,7 @@ describe('[v0.4.1] AriaEngine clearAll', () => {
test('clearAll 后重启(模拟刷新)无残留数据', async () => {
const name = `clr2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
let e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
await e.insert('t', [{ id: '1' }]);
@@ -134,7 +137,7 @@ describe('[v0.4.1] AriaEngine clearAll', () => {
await e.close();
// 重新打开(模拟页面刷新):不应有残留表
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
expect(await e.getTableNames()).toHaveLength(0);
await e.close();
@@ -159,7 +162,7 @@ describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
test('ALTER 持久化:重启后 schema 与行一致', async () => {
const name = `alt2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
let e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await e.insert('users', [{ id: '1', name: 'Alice' }]);
@@ -168,7 +171,7 @@ describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
await e.close();
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
const schema = await e.getTableSchema('users');
expect(Object.keys(schema!.columns)).not.toContain('phone');
@@ -179,7 +182,7 @@ describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
test('ALTER 模拟崩溃(不 close)重启:schema 与行一致', async () => {
const name = `alt3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
let e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await e.insert('users', [{ id: '1', name: 'Alice' }]);
@@ -188,7 +191,7 @@ describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
// 不 close,模拟崩溃
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
const schema = await e.getTableSchema('users');
expect(Object.keys(schema!.columns)).not.toContain('phone');
+2 -5
View File
@@ -3,7 +3,6 @@
*/
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
let diskCounter = 0;
@@ -215,13 +214,12 @@ describe('MetonaSqlark Disk 模式', () => {
beforeEach(async () => {
dbName = `test-disk-${++diskCounter}`;
db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
await db.init();
});
afterEach(async () => {
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('创建 disk 模式数据库', () => {
@@ -250,13 +248,12 @@ describe('MetonaSqlark Hybrid 模式', () => {
beforeEach(async () => {
dbName = `test-hybrid-core-${++diskCounter}`;
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
});
afterEach(async () => {
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('创建 hybrid 模式数据库', () => {
+68
View File
@@ -119,6 +119,74 @@ test.describe('OPFS 真实环境', () => {
await page2.close();
});
test('写入进行中崩溃(不等待完成)→ 重开数据不损坏且可恢复已确认写入', async ({ page }) => {
await openPage(page);
await run(page, 'open', {
name: 'e2e-crash-mid', diskEngine: 'opfs', checkpointInterval: 999999999,
});
await run(page, 'createTable', {
table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }],
});
// 前 20 条确认完成
for (let i = 0; i < 20; i++) {
await run(page, 'insert', { table: 't', rows: [{ id: `c-${i}` }] });
}
// 发起一批写入后立即崩溃(不等 Promise 完成)
const pending = page.evaluate(async () => {
const ms = (window as unknown as { __ms: { insert: (o: unknown) => Promise<unknown> } }).__ms;
const rows = [];
for (let i = 0; i < 30; i++) rows.push({ id: `p-${i}` });
await ms.insert({ table: 't', rows });
});
await new Promise((r) => setTimeout(r, 50));
await crashPage(page);
await pending.catch(() => {});
// 重开:库可打开,已确认 20 条完整;写入中数据要么全部可见要么部分(日志尾部截断)
const page2 = await page.context().newPage();
await openPage(page2);
await run(page2, 'open', {
name: 'e2e-crash-mid', diskEngine: 'opfs', checkpointInterval: 999999999,
});
const res = await run<{ count: number }>(page2, 'count', { table: 't' });
expect(res.count).toBeGreaterThanOrEqual(20);
// 已确认的 20 条必须完整
for (let i = 0; i < 20; i++) {
const found = await run<{ rows: Record<string, unknown>[] }>(page2, 'find', {
table: 't', where: { id: `c-${i}` },
});
expect(found.rows).toHaveLength(1);
}
await run(page2, 'close');
await page2.close();
});
test('checkpoint 前后崩溃 → 快照/日志双路径恢复一致', async ({ page }) => {
await openPage(page);
await run(page, 'open', { name: 'e2e-crash-cp', diskEngine: 'opfs', checkpointInterval: 999999999 });
await run(page, 'createTable', {
table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }],
});
for (let i = 0; i < 10; i++) {
await run(page, 'insert', { table: 't', rows: [{ id: `a-${i}` }] });
}
// 触发 checkpoint(强制落盘)
await run(page, 'repair'); // repair 内含 flush+checkpoint
for (let i = 0; i < 10; i++) {
await run(page, 'insert', { table: 't', rows: [{ id: `b-${i}` }] });
}
// 崩溃(checkpoint 后数据在快照,后续在日志)
await crashPage(page);
const page2 = await page.context().newPage();
await openPage(page2);
await run(page2, 'open', { name: 'e2e-crash-cp', diskEngine: 'opfs', checkpointInterval: 999999999 });
const res = await run<{ count: number }>(page2, 'count', { table: 't' });
expect(res.count).toBe(20);
await run(page2, 'close');
await page2.close();
});
test('多标签页锁:第二个标签页打开同一库 → ARIA_LOCKED', async ({ page }) => {
await openPage(page);
await run(page, 'open', { name: 'e2e-lock', diskEngine: 'opfs' });
+4
View File
@@ -10,6 +10,10 @@ import { BufferPool } from '../../src/engine/aria/buffer/pool';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — Bloom + WAL + BufferPool', () => {
// ---- BloomFilter 完整测试 ----
describe('BloomFilter', () => {
+4
View File
@@ -5,6 +5,10 @@ import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — 批量扩展测试', () => {
let engine: AriaEngine;
beforeEach(async () => { engine = new AriaEngine({ storageBackend: 'memory' }); await engine.open('bat', 1); });
+4
View File
@@ -10,6 +10,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
function createSmallCacheEngine(bufferPoolPages = 2) {
return new AriaEngine({
+37 -23
View File
@@ -8,7 +8,10 @@
*/
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import 'fake-indexeddb/auto';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -21,7 +24,7 @@ const SCHEMA = () => createSchema('users', {
age: { type: 'number' },
});
/** 篡改 MemoryBackend 中指定 key 的一个字节 */
/** 篡改指定 pg_ 页面文件的一个字节 */
async function corruptKey(engine: AriaEngine, key: string, byteOffset: number): Promise<void> {
const backend = (engine as any).backend;
const raw = await backend.read(key);
@@ -31,16 +34,24 @@ async function corruptKey(engine: AriaEngine, key: string, byteOffset: number):
await backend.write(key, buf.buffer as ArrayBuffer);
}
/** 列出 backend 中所有 sst_ 前缀 key */
/** 列出全部 SSTable 的页面文件 key(页面化存储:pg_ 前缀) */
async function listSSTKeys(engine: AriaEngine): Promise<string[]> {
const backend = (engine as any).backend;
const keys = await backend.listKeys();
return (keys as string[]).filter((k) => k.startsWith('sst_'));
const keys = (await backend.listKeys()) as string[];
return keys.filter((k) => k.startsWith('pg_'));
}
/** 读取主 LSM 的 SSTable meta 列表 */
async function listSSTMetas(engine: AriaEngine): Promise<{ id: number; pageIds?: number[] }[]> {
const backend = (engine as any).backend;
const raw = await backend.read('__aria_lsm_meta');
if (!raw) return [];
return JSON.parse(new TextDecoder().decode(raw)) as { id: number; pageIds?: number[] }[];
}
describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
it('打开时 CRC 损坏的 SSTable 被清理,其余数据可读', async () => {
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
const dbName = uniqueDB();
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
@@ -57,7 +68,7 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
await engine.close();
// 重新打开,篡改第一个 SSTable 文件的数据区
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine2.open(dbName, 1);
const sstKeys = await listSSTKeys(engine2);
expect(sstKeys.length).toBeGreaterThanOrEqual(2);
@@ -65,7 +76,7 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
// 再次打开:损坏文件应被跳过并清理,打开不抛错
await engine2.close();
const engine3 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine3.open(dbName, 1);
// 剩余未损坏文件的数据应可查询
@@ -73,18 +84,20 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
expect(remaining.length).toBeGreaterThan(0);
expect(remaining.length).toBeLessThan(200);
// 损坏文件已被清理(meta 移除)
// 损坏文件已被清理(页面 + meta 移除)
const afterKeys = await listSSTKeys(engine3);
expect(afterKeys).not.toContain(sstKeys[0]);
const lsm = (engine3 as any).lsm;
const metas = await lsm.sstableStore.listMeta();
expect(metas.some((m: { id: number }) => m.id === Number(sstKeys[0].replace('sst_', '')))).toBe(false);
const metas = await listSSTMetas(engine3);
// 被篡改页面所属的 SSTable meta 应被移除
const victimMetas = await listSSTMetas(engine2);
const victimMeta = victimMetas.find((m) => m.pageIds?.includes(Number(sstKeys[0].slice(3))));
expect(metas.some((m) => m.id === victimMeta?.id)).toBe(false);
await engine3.close();
});
it('打开时损坏全部 SSTable → 库仍可打开,数据为空但不崩溃', async () => {
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
const dbName = uniqueDB();
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
@@ -98,14 +111,14 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
}
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine2.open(dbName, 1);
for (const key of await listSSTKeys(engine2)) {
await corruptKey(engine2, key, 16);
}
await engine2.close();
const engine3 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
// 不应抛 ARIA_OPEN_ERROR
await engine3.open(dbName, 1);
const rows3 = await engine3.find('users', { table: 'users' });
@@ -114,7 +127,7 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
});
it('运行期 CRC 损坏 → 预加载不缓存损坏文件并清理(自愈)', async () => {
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
const dbName = uniqueDB();
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
@@ -133,22 +146,23 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
const victim = sstKeys[sstKeys.length - 1]; // 篡改最新(memtable 已 flush 后的文件)
await corruptKey(engine, victim, 100);
// 缓存里已有该文件(flush 时缓存)→ 清缓存模拟运行期重载
// 缓存里已有该文件(flush 时缓存)→ 清 LSM 层与 BufferPool 页面缓存模拟运行期磁盘损坏
const lsm = (engine as any).lsm;
lsm.sstableCache.delete(Number(victim.replace('sst_', '')));
lsm.sstableCache.clear();
await (engine as any).bufferPool.clear();
// 查询触发 prefetchRange → preloadSSTable 发现 CRC 失败 → 清理 + 不缓存
const remaining = await engine.find('users', { table: 'users' });
expect(remaining.length).toBeLessThan(100);
expect(lsm.sstableCache.has(Number(victim.replace('sst_', '')))).toBe(false);
expect(lsm.sstableCache.has(Number(victim.slice(3)))).toBe(false);
const metas = await lsm.sstableStore.listMeta();
expect(metas.some((m: { id: number }) => m.id === Number(victim.replace('sst_', '')))).toBe(false);
expect(metas.some((m: { id: number }) => m.id === Number(victim.slice(3)))).toBe(false);
await engine.close();
});
it('旧版无校验文件(checksum=0)在 LSM 中正常加载', async () => {
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
const dbName = uniqueDB();
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
@@ -159,7 +173,7 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
await (engine as any).lsm.flush();
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine2.open(dbName, 1);
const sstKeys = await listSSTKeys(engine2);
expect(sstKeys.length).toBe(1);
@@ -171,7 +185,7 @@ describe('AriaEngine — SSTable CRC 损坏检测(集成)', () => {
await engine2.close();
// 重开:checksum=0 跳过校验,数据完整可读
const engine3 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 64 * 1024 * 1024 });
const engine3 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine3.open(dbName, 1);
const rows3 = await engine3.find('users', { table: 'users' });
expect(rows3.length).toBe(2);
-1
View File
@@ -4,7 +4,6 @@
*
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
*/
import 'fake-indexeddb/auto';
import { CryptoManager } from '../../src/engine/aria/crypto';
function toBytes(data: ArrayBuffer): number[] {
+4
View File
@@ -5,6 +5,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — 扩展边缘测试', () => {
let engine: AriaEngine;
+20 -17
View File
@@ -14,7 +14,10 @@ import { AriaEngine } from '../../src/engine/aria/index';
import { EncryptedBackend } from '../../src/engine/aria/store/encrypted_backend';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { createSchema } from '../../src/table/schema';
import 'fake-indexeddb/auto';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -182,7 +185,7 @@ describe('AriaEngine — EncryptedBackend 单元', () => {
describe('AriaEngine — 全库加密(集成)', () => {
it('加密库:写入 → close → 同密码重开数据完整', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'master-pass' } });
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'master-pass' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [
@@ -193,7 +196,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
await engine.close();
// 重开(同密码)
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'master-pass' } });
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'master-pass' } });
await engine2.open(dbName, 1);
const rows = await engine2.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
@@ -206,42 +209,42 @@ describe('AriaEngine — 全库加密(集成)', () => {
it('加密库:错误密码重开 → ARIA_DECRYPT_ERROR', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'right-pass' } });
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'right-pass' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'wrong-pass' } });
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'wrong-pass' } });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
});
it('加密库:无密码打开 → ARIA_ENCRYPT_REQUIRED', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb' });
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_REQUIRED' });
});
it('明文库:加密配置打开 → ARIA_ENCRYPT_CONFIG_ERROR', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'plain' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_CONFIG_ERROR' });
});
it('加密库:底层存储全部为密文(SSTable/Schema/WAL 均不可见明文)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'Alice', secret: 'needle-in-cipher' }]);
@@ -267,7 +270,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
it('加密 + 压缩组合:往返完整(压缩层先压,加密层后加密)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
compression: true,
encryption: { password: 'pw-compress' },
memtableSizeThreshold: 64 * 1024 * 1024,
@@ -283,7 +286,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
await engine.close();
const engine2 = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
compression: true,
encryption: { password: 'pw-compress' },
});
@@ -296,7 +299,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
it('加密库:clearAll 保留密钥(库身份),同密码重开重建,新密码被拒', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'old-pw' } });
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'old-pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
@@ -304,11 +307,11 @@ describe('AriaEngine — 全库加密(集成)', () => {
await engine.close();
// clearAll 保留 keymeta → 换新密码会被拒绝
const engineWrong = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'new-pw' } });
const engineWrong = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'new-pw' } });
await expect(engineWrong.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
// 同密码重开可重建
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'old-pw' } });
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'old-pw' } });
await engine2.open(dbName, 1);
await engine2.createTable(SCHEMA());
await engine2.insert('users', [{ id: '1', name: 'A', secret: 'new' }]);
@@ -319,7 +322,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
it('加密库:WAL 崩溃恢复路径可用(含加密 WAL)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
encryption: { password: 'pw' },
walSyncMode: 'full',
checkpointInterval: 100000,
@@ -336,7 +339,7 @@ describe('AriaEngine — 全库加密(集成)', () => {
// 重开:WAL 重放(加密 WAL 解密后解析)
const engine2 = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
encryption: { password: 'pw' },
walSyncMode: 'full',
checkpointInterval: 100000,
+4
View File
@@ -4,6 +4,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — 补充测试', () => {
let engine: AriaEngine;
beforeEach(async () => { engine = new AriaEngine({ storageBackend: 'memory' }); await engine.open('sup', 1); });
+4
View File
@@ -7,6 +7,10 @@ import { createSchema } from '../../src/table/schema';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { checkFieldType } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — 最终扩展测试', () => {
let engine: AriaEngine;
+4
View File
@@ -5,6 +5,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — 二级索引查询', () => {
let engine: AriaEngine;
+18 -9
View File
@@ -11,7 +11,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { DatabaseLock, lockName } from '../../src/engine/aria/locks';
import { createSchema } from '../../src/table/schema';
import 'fake-indexeddb/auto';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -25,8 +28,12 @@ let lockHolders: Map<string, true>;
function installLocksMock() {
lockHolders = new Map();
// 保留 storageOPFS mock 设置过),与 locks 共存
const existingNav = (globalThis as { navigator?: { storage?: unknown } }).navigator;
const storage = existingNav?.storage;
Object.defineProperty(globalThis, 'navigator', {
value: {
storage,
locks: {
request: async (_name: string, opts: { ifAvailable?: boolean }, cb: (lock: { name: string } | null) => Promise<void> | void) => {
const name = _name;
@@ -53,8 +60,10 @@ function installLocksMock() {
}
function removeLocksMock() {
// 只移除 locks,保留 storageOPFS mock 需要)
const existingNav = (globalThis as { navigator?: { storage?: unknown } }).navigator;
Object.defineProperty(globalThis, 'navigator', {
value: {},
value: { storage: existingNav?.storage },
configurable: true,
writable: true,
});
@@ -120,11 +129,11 @@ describe('AriaEngine — 多标签页锁(集成)', () => {
it('第二个标签页打开同一库 → ARIA_LOCKED', async () => {
const dbName = uniqueDB();
const engine1 = new AriaEngine({ storageBackend: 'indexeddb' });
const engine1 = new AriaEngine({ storageBackend: 'opfs' });
await engine1.open(dbName, 1);
await engine1.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
const engine2 = new AriaEngine({ storageBackend: 'indexeddb' });
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_LOCKED' });
// engine2 未持锁、未 opened
expect((engine2 as any).opened).toBe(false);
@@ -132,7 +141,7 @@ describe('AriaEngine — 多标签页锁(集成)', () => {
await engine1.close();
// 释放后可打开
const engine3 = new AriaEngine({ storageBackend: 'indexeddb' });
const engine3 = new AriaEngine({ storageBackend: 'opfs' });
await engine3.open(dbName, 1);
expect(await engine3.count('t')).toBe(0);
await engine3.close();
@@ -140,19 +149,19 @@ describe('AriaEngine — 多标签页锁(集成)', () => {
it('open 失败(错误密码)→ 锁释放,其他标签页可打开', async () => {
const dbName = uniqueDB();
const engine1 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
const engine1 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engine1.open(dbName, 1);
await engine1.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine1.close();
// 错误密码 → open 失败
const engineBad = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'wrong' } });
const engineBad = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'wrong' } });
await expect(engineBad.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
// 失败后锁已释放(未泄漏)
expect(lockHolders.has(lockName(dbName))).toBe(false);
// 正确密码可打开
const engineOk = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
const engineOk = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engineOk.open(dbName, 1);
await engineOk.close();
});
@@ -160,7 +169,7 @@ describe('AriaEngine — 多标签页锁(集成)', () => {
it('无 Web Locks 环境 → 降级打开(不抛错)', async () => {
removeLocksMock();
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine.insert('t', [{ id: '1' }]);
+4
View File
@@ -7,6 +7,10 @@ import { createSchema } from '../../src/table/schema';
import { QueryExecutor } from '../../src/query/executor';
import { parse } from '../../src/sql/parser';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — ANALYZE/VACUUM/REINDEX + EXPLAIN', () => {
let engine: AriaEngine;
+4
View File
@@ -5,6 +5,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
describe('AriaEngine — MVCC 事务 + Savepoint', () => {
let engine: AriaEngine;
+7 -6
View File
@@ -12,7 +12,8 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { installOPFSMock } from '../helpers/opfs-mock';
import 'fake-indexeddb/auto';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -32,7 +33,7 @@ describe('AriaEngine — repair 自愈增强', () => {
it('清理孤儿页面(meta 未引用的 pg_ 文件)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
pageStorage: true, // 强制页面化(IDB 上也启用,便于断言页面文件)
memtableSizeThreshold: 64 * 1024 * 1024,
checkpointInterval: 100000,
@@ -86,7 +87,7 @@ describe('AriaEngine — repair 自愈增强', () => {
it('WAL 空洞 + repair → 截断清空(不再重放错位数据)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
// 数据落盘 + checkpoint 清 WAL
@@ -116,7 +117,7 @@ describe('AriaEngine — repair 自愈增强', () => {
it('repair 幂等(连续调用无副作用)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('items', [{ id: 'a', val: 1, tag: 'x' }]);
@@ -135,7 +136,7 @@ describe('AriaEngine — 随机操作压力 + 模拟崩溃', () => {
it('500 随机操作(insert/update/delete)→ 模拟崩溃 → 重开验证全部已确认写入', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
memtableSizeThreshold: 8 * 1024,
checkpointInterval: 50,
walSyncMode: 'full',
@@ -178,7 +179,7 @@ describe('AriaEngine — 随机操作压力 + 模拟崩溃', () => {
(engine as any).opened = false;
// 重开:WAL 重放 + SSTable 加载
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 50, walSyncMode: 'full' });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 50, walSyncMode: 'full' });
await engine2.open(dbName, 1);
const rows = await engine2.find('items', { table: 'items' });
const byId = new Map(rows.map((r) => [r.id, r]));
+11 -8
View File
@@ -13,7 +13,10 @@ import { SegmentedWALStore } from '../../src/engine/aria/wal/segmented_store';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import 'fake-indexeddb/auto';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -153,7 +156,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
it('高频写入(多条 WAL 记录)→ close → reopen 数据完整', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
@@ -171,7 +174,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
(engine as any).opened = false;
const engine2 = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
@@ -185,7 +188,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
it('分片结构落盘验证(backend 中是分片文件而非旧单记录键)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
@@ -208,7 +211,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
it('WAL 分片部分残留(空洞)→ 重开恢复已落盘数据,不丢已 checkpoint 数据', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
@@ -228,7 +231,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
await backend.delete('__wal_000000.bin');
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 已落盘的 a/b 必须保留;c/d 在 WAL 中且文件被删 → 恢复不到(可接受的保守丢弃)
const rows = await engine2.find('t', { table: 't' });
@@ -241,7 +244,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
const dbName = uniqueDB();
// 手工构造旧格式 WAL 库:直接写旧键(模拟 v0.4.4 库崩溃现场)
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 100000,
walSyncMode: 'full',
});
@@ -262,7 +265,7 @@ describe('AriaEngine — 分片 WAL 集成', () => {
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
const rows = await engine2.find('t', { table: 't' });
expect(rows).toHaveLength(1);
+5 -2
View File
@@ -8,7 +8,10 @@
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
import { MetonaSqlark } from '../../src/core';
import 'fake-indexeddb/auto';
import { installOPFSMock } from '../helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
// ===================================================================
// AriaEngine 引擎级测试 (Memory Backend)
@@ -549,7 +552,7 @@ describe('MetonaSqlark with mode=aria (Memory)', () => {
let db: MetonaSqlark;
beforeEach(async () => {
db = new MetonaSqlark({ name: `ms-aria-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: `ms-aria-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'opfs' });
await db.init();
});
-323
View File
@@ -1,323 +0,0 @@
/**
* IndexedDBEngine 完整测试(使用 fake-indexeddb
* 每次测试使用独立的数据库名避免版本冲突
*/
import 'fake-indexeddb/auto';
import { IndexedDBEngine } from '../../src/engine/indexeddb';
import { createSchema } from '../../src/table/schema';
let dbCounter = 0;
describe('IndexedDBEngine', () => {
let engine: IndexedDBEngine;
let dbName: string;
const userSchema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
age: { type: 'number', default: 0 },
email: { type: 'string', unique: true },
});
beforeEach(async () => {
dbName = `test-idb-${++dbCounter}`;
engine = new IndexedDBEngine();
await engine.open(dbName, 1);
});
afterEach(async () => {
if (engine.isOpen()) {
await engine.close();
}
// 清理:删除 IndexedDB 数据库
try {
indexedDB.deleteDatabase(dbName);
} catch {}
});
// ---- 生命周期 ----
describe('生命周期', () => {
it('打开和关闭', async () => {
expect(engine.isOpen()).toBe(true);
await engine.close();
expect(engine.isOpen()).toBe(false);
});
it('未打开时操作抛出错误', async () => {
await engine.close();
await expect(engine.count('users')).rejects.toThrow('not opened');
});
it('可以重新打开', async () => {
await engine.close();
await engine.open(dbName, 2);
expect(engine.isOpen()).toBe(true);
});
});
// ---- 表管理 ----
describe('表管理', () => {
it('创建和删除表', async () => {
await engine.createTable(userSchema);
expect(await engine.hasTable('users')).toBe(true);
await engine.dropTable('users');
expect(await engine.hasTable('users')).toBe(false);
});
it('表名列表', async () => {
await engine.createTable(userSchema);
const names = await engine.getTableNames();
expect(names).toContain('users');
});
it('获取表结构', async () => {
await engine.createTable(userSchema);
const schema = await engine.getTableSchema('users');
expect(schema).not.toBeNull();
expect(schema!.name).toBe('users');
});
it('获取不存在的表结构返回 null', async () => {
const schema = await engine.getTableSchema('nonexistent');
expect(schema).toBeNull();
});
});
// ---- 插入 ----
describe('插入', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
});
it('插入单行', async () => {
const pks = await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
]);
expect(pks).toEqual(['1']);
expect(await engine.count('users')).toBe(1);
});
it('插入多行', async () => {
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
]);
expect(await engine.count('users')).toBe(2);
});
it('必填字段缺失抛出错误', async () => {
await expect(
engine.insert('users', [{ id: '1', email: 'a@t.com' }]),
).rejects.toThrow('required');
});
});
// ---- 查询 ----
describe('查询', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
{ id: '3', name: 'Charlie', age: 35, email: 'charlie@test.com' },
]);
});
it('查询所有', async () => {
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(3);
});
it('WHERE 条件', async () => {
const rows = await engine.find('users', {
table: 'users',
where: { age: { $gt: 28 } },
});
expect(rows).toHaveLength(2);
});
it('ORDER BY + LIMIT', async () => {
const rows = await engine.find('users', {
table: 'users',
orderBy: [{ column: 'age', direction: 'desc' }],
limit: 2,
});
expect(rows.map(r => r.age)).toEqual([35, 30]);
});
it('列选择', async () => {
const rows = await engine.find('users', {
table: 'users',
columns: ['id'],
where: { id: '1' },
});
expect(Object.keys(rows[0])).toEqual(['id']);
});
});
// ---- 更新 ----
describe('更新', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
]);
});
it('更新匹配行', async () => {
const count = await engine.update(
'users',
{ table: 'users', where: { id: '1' } },
{ age: 31 },
);
expect(count).toBe(1);
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
expect(rows[0].age).toBe(31);
});
it('更新所有行', async () => {
const count = await engine.update(
'users',
{ table: 'users' },
{ age: 100 },
);
expect(count).toBe(2);
});
});
// ---- 删除 ----
describe('删除', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
]);
});
it('条件删除', async () => {
const count = await engine.delete('users', {
table: 'users',
where: { id: '1' },
});
expect(count).toBe(1);
expect(await engine.count('users')).toBe(1);
});
it('清空表', async () => {
await engine.clear('users');
expect(await engine.count('users')).toBe(0);
// 表结构仍存在
expect(await engine.hasTable('users')).toBe(true);
});
it('删除不存在的表抛出错误', async () => {
await expect(
engine.delete('nonexistent', { table: 'nonexistent' }),
).rejects.toThrow();
});
});
// ---- Count ----
describe('Count', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
]);
});
it('count 全部', async () => {
expect(await engine.count('users')).toBe(2);
});
it('count with where', async () => {
expect(await engine.count('users', {
table: 'users',
where: { age: { $gt: 28 } },
})).toBe(1);
});
});
// ---- 复杂 Where 条件 ----
describe('复杂 Where', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
{ id: '3', name: 'Charlie', age: 35, email: 'c@t.com' },
]);
});
it('$like 模糊匹配', async () => {
const rows = await engine.find('users', {
table: 'users',
where: { name: { $like: 'A%' } },
});
expect(rows).toHaveLength(1);
});
it('$in 列表', async () => {
const rows = await engine.find('users', {
table: 'users',
where: { id: { $in: ['1', '3'] } },
});
expect(rows).toHaveLength(2);
});
it('$ne 不等于', async () => {
const rows = await engine.find('users', {
table: 'users',
where: { age: { $ne: 25 } },
});
expect(rows).toHaveLength(2);
});
it('$gte $lte 范围', async () => {
const rows = await engine.find('users', {
table: 'users',
where: { age: { $gte: 25, $lte: 30 } },
});
expect(rows).toHaveLength(2);
});
});
// ---- 并发/多次操作 ----
describe('多次操作', () => {
it('连续创建删除表', async () => {
await engine.createTable(userSchema);
expect(await engine.hasTable('users')).toBe(true);
await engine.dropTable('users');
expect(await engine.hasTable('users')).toBe(false);
await engine.createTable(userSchema);
expect(await engine.hasTable('users')).toBe(true);
});
it('插入后查询再更新再查询', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
]);
let rows = await engine.find('users', { table: 'users' });
expect(rows[0].age).toBe(30);
await engine.update('users', { table: 'users' }, { age: 31 });
rows = await engine.find('users', { table: 'users' });
expect(rows[0].age).toBe(31);
});
});
});
+508
View File
@@ -0,0 +1,508 @@
/**
* 介质:SharedMemoryBackend(跨实例共享,模拟磁盘持久化)
*/
import { KVStoreEngine } from '../../src/engine/kvstore_engine';
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
import { createSchema } from '../../src/table/schema';
let dbCounter = 0;
describe('KVStoreEngine', () => {
let engine: KVStoreEngine;
let dbName: string;
const userSchema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
age: { type: 'number', default: 0 },
email: { type: 'string', unique: true },
});
beforeEach(async () => {
dbName = `test-kv-${++dbCounter}-${Math.random().toString(36).slice(2, 6)}`;
engine = new KVStoreEngine(new SharedMemoryBackend());
await engine.open(dbName, 1);
});
afterEach(async () => {
if (engine.isOpen()) {
await engine.close();
}
});
// ---- 生命周期 ----
describe('生命周期', () => {
it('打开和关闭', async () => {
expect(engine.isOpen()).toBe(true);
await engine.close();
expect(engine.isOpen()).toBe(false);
});
it('未打开时操作抛出错误', async () => {
await engine.close();
await expect(engine.count('users')).rejects.toThrow('not opened');
});
it('可以重新打开', async () => {
await engine.close();
await engine.open(dbName, 2);
expect(engine.isOpen()).toBe(true);
});
});
// ---- 表管理 ----
describe('表管理', () => {
it('创建表', async () => {
await engine.createTable(userSchema);
expect(await engine.hasTable('users')).toBe(true);
const names = await engine.getTableNames();
expect(names).toContain('users');
});
it('重复创建表抛错', async () => {
await engine.createTable(userSchema);
await expect(engine.createTable(userSchema)).rejects.toThrow('already exists');
});
it('删除表', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.dropTable('users');
expect(await engine.hasTable('users')).toBe(false);
await expect(engine.count('users')).rejects.toThrow('does not exist');
});
it('获取表结构', async () => {
await engine.createTable(userSchema);
const schema = await engine.getTableSchema('users');
expect(schema).not.toBeNull();
expect(schema!.columns.id.primaryKey).toBe(true);
});
it('schema 持久化:重启后表结构保留', async () => {
await engine.createTable(userSchema);
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.hasTable('users')).toBe(true);
const schema = await engine2.getTableSchema('users');
expect(schema!.columns.email.unique).toBe(true);
await engine2.close();
});
});
// ---- 插入 ----
describe('插入', () => {
beforeEach(async () => { await engine.createTable(userSchema); });
it('插入单行并返回主键', async () => {
const pks = await engine.insert('users', [{ id: '1', name: 'Alice', age: 30 }]);
expect(pks).toEqual(['1']);
expect(await engine.count('users')).toBe(1);
});
it('默认值生效', async () => {
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
const rows = await engine.find('users', { table: 'users' });
expect(rows[0].age).toBe(0);
});
it('重复主键抛错', async () => {
await engine.insert('users', [{ id: '1', name: 'A' }]);
await expect(engine.insert('users', [{ id: '1', name: 'B' }])).rejects.toThrow('Duplicate primary key');
});
it('唯一约束冲突抛错', async () => {
await engine.insert('users', [{ id: '1', name: 'A', email: 'a@x.com' }]);
await expect(engine.insert('users', [{ id: '2', name: 'B', email: 'a@x.com' }]))
.rejects.toThrow('Unique constraint violation');
});
it('必填字段校验', async () => {
await expect(engine.insert('users', [{ id: '1' }])).rejects.toThrow('is required');
});
it('批量插入', async () => {
const pks = await engine.insert('users', [
{ id: '1', name: 'A' }, { id: '2', name: 'B' }, { id: '3', name: 'C' },
]);
expect(pks).toEqual(['1', '2', '3']);
});
});
// ---- 查询 ----
describe('查询', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 },
{ id: '3', name: 'Carol', age: 35 },
]);
});
it('全表查询', async () => {
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(3);
});
it('WHERE 等值过滤', async () => {
const rows = await engine.find('users', { table: 'users', where: { name: 'Bob' } });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('2');
});
it('WHERE 操作符', async () => {
const rows = await engine.find('users', { table: 'users', where: { age: { $gt: 26 } } });
expect(rows.map((r) => r.id).sort()).toEqual(['1', '3']);
});
it('ORDER BY', async () => {
const rows = await engine.find('users', { table: 'users', orderBy: [{ column: 'age', direction: 'desc' }] });
expect(rows[0].id).toBe('3');
expect(rows[2].id).toBe('2');
});
it('LIMIT / OFFSET', async () => {
const rows = await engine.find('users', { table: 'users', limit: 2, offset: 1 });
expect(rows).toHaveLength(2);
});
it('列投影', async () => {
const rows = await engine.find('users', { table: 'users', columns: ['name'] });
expect(Object.keys(rows[0])).toEqual(['name']);
});
});
// ---- 更新 ----
describe('更新', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 },
]);
});
it('按条件更新', async () => {
const count = await engine.update('users', { table: 'users', where: { id: '1' } }, { age: 31 });
expect(count).toBe(1);
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
expect(rows[0].age).toBe(31);
});
it('无匹配行影响 0 行', async () => {
const count = await engine.update('users', { table: 'users', where: { id: 'nope' } }, { age: 1 });
expect(count).toBe(0);
});
it('更新主键(旧键删除 + 新键写入)', async () => {
const count = await engine.update('users', { table: 'users', where: { id: '1' } }, { id: '10', age: 99 });
expect(count).toBe(1);
expect(await engine.count('users')).toBe(2);
const rows = await engine.find('users', { table: 'users', where: { id: '10' } });
expect(rows[0].age).toBe(99);
expect(await engine.find('users', { table: 'users', where: { id: '1' } })).toHaveLength(0);
});
it('更新持久化:重启后保留', async () => {
await engine.update('users', { table: 'users', where: { id: '1' } }, { age: 99 });
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
const rows = await engine2.find('users', { table: 'users', where: { id: '1' } });
expect(rows[0].age).toBe(99);
await engine2.close();
});
});
// ---- 删除 ----
describe('删除', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, { id: '3', name: 'Carol' },
]);
});
it('按条件删除', async () => {
const count = await engine.delete('users', { table: 'users', where: { id: '1' } });
expect(count).toBe(1);
expect(await engine.count('users')).toBe(2);
});
it('删除持久化:重启后保留', async () => {
await engine.delete('users', { table: 'users', where: { id: '1' } });
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(2);
await engine2.close();
});
it('级联删除(CASCADE', async () => {
const ordersSchema = createSchema('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' },
});
await engine.createTable(ordersSchema);
await engine.insert('orders', [{ id: 'o1', user_id: '1' }, { id: 'o2', user_id: '2' }]);
await engine.delete('users', { table: 'users', where: { id: '1' } });
// 级联:orders 中 user_id=1 的行被删
expect(await engine.count('orders')).toBe(1);
// 重启后级联结果持久化
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(2);
expect(await engine2.count('orders')).toBe(1);
await engine2.close();
});
});
// ---- Count ----
describe('Count', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'A', age: 10 }, { id: '2', name: 'B', age: 20 }, { id: '3', name: 'C', age: 30 },
]);
});
it('总数', async () => {
expect(await engine.count('users')).toBe(3);
});
it('条件计数', async () => {
expect(await engine.count('users', { table: 'users', where: { age: { $gte: 20 } } })).toBe(2);
});
});
// ---- 事务 ----
describe('事务', () => {
beforeEach(async () => { await engine.createTable(userSchema); });
it('commit 后数据可见且持久化', async () => {
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.commitTransaction();
expect(await engine.count('users')).toBe(1);
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(1);
await engine2.close();
});
it('rollback 后数据消失', async () => {
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.rollbackTransaction();
expect(await engine.count('users')).toBe(0);
});
it('事务内读到自己写入的行', async () => {
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(1);
await engine.rollbackTransaction();
});
it('commit 多表原子(全部生效或全部不生效)', async () => {
await engine.createTable(createSchema('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
}));
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.insert('orders', [{ id: 'o1', user_id: '1' }]);
await engine.commitTransaction();
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(1);
expect(await engine2.count('orders')).toBe(1);
await engine2.close();
});
it('事务中 DDL 支持(create/drop 随 commit 生效)', async () => {
const txSchema = createSchema('tx_users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
});
await engine.beginTransaction();
await engine.createTable(txSchema);
await engine.insert('tx_users', [{ id: '1', name: 'A' }]);
await engine.commitTransaction();
expect(await engine.hasTable('tx_users')).toBe(true);
expect(await engine.count('tx_users')).toBe(1);
// 事务中 drop 表
await engine.beginTransaction();
await engine.dropTable('tx_users');
await engine.commitTransaction();
expect(await engine.hasTable('tx_users')).toBe(false);
// 重启后 drop 生效(KV 残留清理)
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.hasTable('tx_users')).toBe(false);
await engine2.close();
});
});
// ---- 持久化与崩溃恢复 ----
describe('持久化与崩溃恢复', () => {
it('未 checkpoint 数据重启后经日志重放恢复', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 },
]);
await engine.close(); // 模拟"崩溃后重开"(未 checkpoint,数据在 KVStore 日志)
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(2);
const rows = await engine2.find('users', { table: 'users' });
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2']);
await engine2.close();
});
it('checkpoint 后重启(快照恢复)', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await (engine as any).kv.checkpoint();
await engine.insert('users', [{ id: '2', name: 'Bob' }]);
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(2);
await engine2.close();
});
it('索引列跨重启恢复(查询走索引)', async () => {
const idxSchema = createSchema('items', {
id: { type: 'string', primaryKey: true },
tag: { type: 'string', index: true },
});
await engine.createTable(idxSchema);
await engine.insert('items', [
{ id: '1', tag: 'a' }, { id: '2', tag: 'b' }, { id: '3', tag: 'a' },
]);
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
const rows = await engine2.find('items', { table: 'items', where: { tag: 'a' } });
expect(rows).toHaveLength(2);
await engine2.close();
});
it('CREATE INDEX 持久化(重启后索引保留)', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice', age: 30 }]);
await engine.createIndex('users', 'name');
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
const schema = await engine2.getTableSchema('users');
expect(schema!.columns.name.index).toBe(true);
const rows = await engine2.find('users', { table: 'users', where: { name: 'Alice' } });
expect(rows).toHaveLength(1);
await engine2.close();
});
it('ALTER TABLE ADD/DROP 持久化', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice', age: 30 }]);
await engine.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
await engine.alterTable('users', 'DROP', { name: 'age', type: 'number' });
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
const schema = await engine2.getTableSchema('users');
expect(schema!.columns.phone).toBeDefined();
expect(schema!.columns.age).toBeUndefined();
const rows = await engine2.find('users', { table: 'users' });
expect(rows[0].phone).toBeUndefined();
expect(rows[0].age).toBeUndefined();
await engine2.close();
});
it('元数据 getMeta/setMeta 持久化', async () => {
await engine.setMeta('__metona_version', '3');
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.getMeta('__metona_version')).toBe('3');
await engine2.close();
});
it('数据损坏自愈:日志损坏 → open 不崩,repair 截断', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.close();
// 篡改日志尾部 CRC(模拟最后一条记录写入中断)
const medium = new SharedMemoryBackend();
await medium.open(dbName);
const log = await medium.read('__kv_log');
expect(log).not.toBeNull();
const corrupted = new Uint8Array(log as ArrayBuffer);
corrupted[corrupted.byteLength - 5] ^= 0xff;
await medium.write('__kv_log', corrupted.buffer as ArrayBuffer);
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1); // 损坏尾部截断,打开不崩
expect(await engine2.hasTable('users')).toBe(true);
await engine2.repair();
expect(await engine2.hasTable('users')).toBe(true);
// repair 后新写入正常
await engine2.insert('users', [{ id: '2', name: 'Bob' }]);
expect(await engine2.count('users')).toBeGreaterThanOrEqual(1);
await engine2.close();
});
});
// ---- 流式查询 ----
describe('流式查询', () => {
beforeEach(async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'A' }, { id: '2', name: 'B' }, { id: '3', name: 'C' },
]);
});
it('findStream 逐行回调', async () => {
const rows: Record<string, unknown>[] = [];
const count = await engine.findStream!('users', { table: 'users' }, (r) => rows.push(r));
expect(count).toBe(3);
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2', '3']);
});
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* KVStoreEngine — 大规模压力测试(10 万级)
*
* 验证生产级可靠性:
* 1. 10 万 key 批量写入 + checkpoint + 重开全量验证
* 2. 混合操作(insert/update/delete5 万级 + 崩溃模拟(不 checkpoint 断开)→ 重开验证已确认写入
* 3. 多 checkpoint 循环下数据不丢
*/
import { KVStoreEngine } from '../../src/engine/kvstore_engine';
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
import { createSchema } from '../../src/table/schema';
let counter = 0;
function uniqueDB(): string {
return `stress-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
}
beforeEach(() => {
SharedMemoryBackend.clearRegistry();
});
describe('KVStoreEngine — 10 万级压力', () => {
it('10 万 key 写入 + checkpoint + 重开全量验证', async () => {
const dbName = uniqueDB();
const engine = new KVStoreEngine(new SharedMemoryBackend());
await engine.open(dbName, 1);
await engine.createTable(createSchema('big', {
id: { type: 'string', primaryKey: true },
val: { type: 'number' },
name: { type: 'string' },
}));
const TOTAL = 100000;
// 分批写入(每批 1000
for (let batch = 0; batch < TOTAL / 1000; batch++) {
const rows = [] as Record<string, unknown>[];
for (let i = 0; i < 1000; i++) {
const idx = batch * 1000 + i;
rows.push({ id: `k${idx}`, val: idx, name: `User${idx}` });
}
await engine.insert('big', rows);
// 每 20 批 checkpoint 一次
if ((batch + 1) % 20 === 0) {
await (engine as any).kv.checkpoint();
}
}
expect(await engine.count('big')).toBe(TOTAL);
await engine.close();
// 重开:全量验证
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('big')).toBe(TOTAL);
// 抽样验证
for (const id of ['k0', 'k49999', 'k99999', 'k12345']) {
const rows = await engine2.find('big', { table: 'big', where: { id } });
expect(rows).toHaveLength(1);
expect(Number(rows[0].val)).toBe(Number(id.slice(1)));
}
await engine2.close();
}, 120000);
it('5 万混合操作 + 崩溃模拟(不 checkpoint)→ 重开全部已确认写入可见', async () => {
const dbName = uniqueDB();
const engine = new KVStoreEngine(new SharedMemoryBackend());
await engine.open(dbName, 1);
await engine.createTable(createSchema('ops', {
id: { type: 'string', primaryKey: true },
val: { type: 'number' },
}));
let seed = 42;
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const confirmed = new Map<string, number>();
const TOTAL = 50000;
for (let i = 0; i < TOTAL; i++) {
const r = rand();
const id = `k${Math.floor(rand() * 20000)}`;
if (r < 0.6) {
const val = Math.floor(rand() * 1000000);
try {
await engine.insert('ops', [{ id, val }]);
confirmed.set(id, val);
} catch (e) {
if ((e as { code?: string }).code !== 'DUPLICATE_KEY') throw e;
}
} else if (r < 0.8) {
const val = Math.floor(rand() * 1000000);
await engine.update('ops', { table: 'ops', where: { id } }, { val });
if (confirmed.has(id)) confirmed.set(id, val);
} else {
await engine.delete('ops', { table: 'ops', where: { id } });
confirmed.delete(id);
}
// 每 5000 次 checkpoint
if ((i + 1) % 5000 === 0) {
await (engine as any).kv.checkpoint();
}
}
// 崩溃模拟:直接断开(不 close → 数据在 KVStore 日志/快照)
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend());
await engine2.open(dbName, 1);
expect(await engine2.count('ops')).toBe(confirmed.size);
// 抽样验证值
let sampled = 0;
for (const [id, val] of confirmed) {
if (sampled++ > 1000) break;
const rows = await engine2.find('ops', { table: 'ops', where: { id } });
expect(rows[0].val).toBe(val);
}
await engine2.close();
}, 120000);
it('多次 checkpoint 循环(500 次)数据不丢', async () => {
const dbName = uniqueDB();
const engine = new KVStoreEngine(new SharedMemoryBackend(), 0);
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 500; i++) {
await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).kv.checkpoint();
}
await engine.close();
const engine2 = new KVStoreEngine(new SharedMemoryBackend(), 0);
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(500);
const last = await engine2.find('t', { table: 't', where: { id: 'k499' } });
expect(last[0].v).toBe(499);
await engine2.close();
}, 60000);
});
+342
View File
@@ -0,0 +1,342 @@
/**
* KVStore — 自研 KV 引擎单元测试
*
* 覆盖:
* 1. 基本读写(put/get/delete/exists/listKeys
* 2. putMany/deleteMany 原子性(多 key 一次落盘)
* 3. 跨实例持久化(SharedMemory 全局注册表,close 不清数据)
* 4. 崩溃恢复:日志重放(未 checkpoint 数据恢复)
* 5. checkpoint 后恢复(快照 + 水位跳过)
* 6. 快照损坏 → 全量日志重放自愈
* 7. 日志损坏 → 截断至损坏处(丢弃未确认尾部)
* 8. 写入失败原子性(日志失败不更新内存索引)
* 9. clear / repair
* 10. 并发写与 checkpoint 串行(无交错丢数据)
*/
import { KVStore } from '../../src/engine/kvstore/index';
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
import { encodeLogRecord, parseLogRecords, KVLogOp } from '../../src/engine/kvstore/log';
import { encodeSnapshot, decodeSnapshot } from '../../src/engine/kvstore/snapshot';
let dbCounter = 0;
function uniqueDB(): string {
return `kv-${Date.now()}-${++dbCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer;
const dec = (b: ArrayBuffer | null) => (b ? new TextDecoder().decode(b) : null);
beforeEach(() => {
SharedMemoryBackend.clearRegistry();
});
describe('KVStore — 基本读写', () => {
it('put/get/delete/exists/listKeys/size', async () => {
const kv = new KVStore(new SharedMemoryBackend());
await kv.open(uniqueDB());
await kv.put('a', enc('AAA'));
await kv.put('b', enc('BBB'));
expect(dec(await kv.get('a'))).toBe('AAA');
expect(await kv.exists('b')).toBe(true);
expect(await kv.exists('nope')).toBe(false);
expect((await kv.listKeys()).sort()).toEqual(['a', 'b']);
expect(kv.size()).toBe(2);
await kv.delete('a');
expect(await kv.exists('a')).toBe(false);
expect(kv.size()).toBe(1);
await kv.close();
});
it('putMany 多 key 原子写入', async () => {
const kv = new KVStore(new SharedMemoryBackend());
await kv.open(uniqueDB());
await kv.putMany({ a: enc('1'), b: enc('2'), c: enc('3') });
expect(dec(await kv.get('a'))).toBe('1');
expect(dec(await kv.get('c'))).toBe('3');
await kv.deleteMany(['a', 'c']);
expect(await kv.exists('a')).toBe(false);
expect(await kv.exists('c')).toBe(false);
expect(await kv.exists('b')).toBe(true);
await kv.close();
});
});
describe('KVStore — 持久化与崩溃恢复', () => {
it('未 checkpoint 的数据:close 后重开经日志重放恢复', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium);
await kv1.open(dbName);
await kv1.putMany({ a: enc('AAA'), b: enc('BBB') });
await kv1.put('c', enc('CCC'));
await kv1.delete('b');
await kv1.close();
// 模拟"崩溃后重开"(新实例,共享介质)
const kv2 = new KVStore(medium);
await kv2.open(dbName);
expect(dec(await kv2.get('a'))).toBe('AAA');
expect(await kv2.exists('b')).toBe(false);
expect(dec(await kv2.get('c'))).toBe('CCC');
await kv2.close();
});
it('checkpoint 后重开:快照加载 + 日志水位跳过(无重复/无丢失)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0); // 关闭自动 checkpoint
await kv1.open(dbName);
await kv1.putMany({ a: enc('A1'), b: enc('B1') });
await kv1.checkpoint();
// checkpoint 后新写入(进日志)
await kv1.put('c', enc('C1'));
await kv1.put('a', enc('A2'));
await kv1.close();
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
expect(dec(await kv2.get('a'))).toBe('A2'); // 日志重放覆盖快照值
expect(dec(await kv2.get('b'))).toBe('B1');
expect(dec(await kv2.get('c'))).toBe('C1');
await kv2.close();
});
it('多次 checkpoint + 截断日志后重开正确', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0);
await kv1.open(dbName);
for (let i = 0; i < 10; i++) {
await kv1.put(`k${i}`, enc(`v${i}`));
await kv1.checkpoint();
}
await kv1.put('last', enc('L'));
await kv1.close();
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
expect(kv2.size()).toBe(11);
expect(dec(await kv2.get('k9'))).toBe('v9');
expect(dec(await kv2.get('last'))).toBe('L');
await kv2.close();
});
it('快照损坏 → 全量日志重放自愈(数据不丢)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0);
await kv1.open(dbName);
await kv1.putMany({ a: enc('AAA'), b: enc('BBB') });
await kv1.checkpoint();
await kv1.put('c', enc('CCC'));
await kv1.close();
// 篡改快照(模拟损坏)
const snap = await medium.read('__kv_snapshot');
const corrupted = new Uint8Array(snap as ArrayBuffer);
corrupted[20] ^= 0xff;
await medium.write('__kv_snapshot', corrupted.buffer as ArrayBuffer);
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
// 快照损坏 → 全量日志重放
expect(dec(await kv2.get('a'))).toBe('AAA');
expect(dec(await kv2.get('b'))).toBe('BBB');
expect(dec(await kv2.get('c'))).toBe('CCC');
await kv2.close();
});
it('日志损坏尾部 → 截断至损坏处(丢弃未确认尾部,已确认数据保留)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0);
await kv1.open(dbName);
await kv1.put('confirmed', enc('KEEP'));
await kv1.put('confirmed2', enc('KEEP2'));
await kv1.close();
// 追加一条损坏记录(模拟写入中断:半写记录)
const bad = encodeLogRecord(999, { damaged: enc('X') });
const corrupted = new Uint8Array(bad);
corrupted[20] ^= 0xff; // 破坏 CRC
// close 后介质实例不可用,重新打开访问"磁盘"
const disk = new SharedMemoryBackend();
await disk.open(dbName);
const log = await disk.read('__kv_log');
const combined = new Uint8Array((log as ArrayBuffer).byteLength + corrupted.byteLength);
combined.set(new Uint8Array(log as ArrayBuffer), 0);
combined.set(corrupted, (log as ArrayBuffer).byteLength);
await disk.write('__kv_log', combined.buffer as ArrayBuffer);
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
expect(dec(await kv2.get('confirmed'))).toBe('KEEP');
expect(dec(await kv2.get('confirmed2'))).toBe('KEEP2');
expect(await kv2.exists('damaged')).toBe(false);
// 损坏日志已被截断(重开后再写正常)
await kv2.put('after', enc('OK'));
await kv2.close();
const kv3 = new KVStore(medium, 0);
await kv3.open(dbName);
expect(dec(await kv3.get('after'))).toBe('OK');
await kv3.close();
});
it('写入失败 → 内存索引不更新(原子性)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv = new KVStore(medium, 0);
await kv.open(dbName);
// 注入日志追加失败
const origAppend = medium.append!.bind(medium);
medium.append = async () => { throw new Error('disk full'); };
await expect(kv.put('x', enc('X'))).rejects.toMatchObject({ code: 'KV_LOG_ERROR' });
expect(await kv.exists('x')).toBe(false);
medium.append = origAppend;
await kv.put('y', enc('Y'));
expect(dec(await kv.get('y'))).toBe('Y');
await kv.close();
});
});
describe('KVStore — 维护与并发', () => {
it('clear 清空全部(保留库),重开为空', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0);
await kv1.open(dbName);
await kv1.putMany({ a: enc('1'), b: enc('2') });
await kv1.checkpoint();
await kv1.put('c', enc('3'));
await kv1.clear();
expect(kv1.size()).toBe(0);
await kv1.close();
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
expect(kv2.size()).toBe(0);
await kv2.close();
});
it('repair 清理损坏快照与日志(损坏快照数据无法恢复,日志数据保留)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium, 0);
await kv1.open(dbName);
await kv1.put('a', enc('A'));
await kv1.checkpoint(); // 快照成为 a 的唯一副本(日志已截断)
await kv1.put('b', enc('B')); // 日志中(seq=2
await kv1.close();
// 快照损坏 + 日志尾部损坏
const disk = new SharedMemoryBackend();
await disk.open(dbName);
const snap = new Uint8Array(await disk.read('__kv_snapshot') as ArrayBuffer);
snap[10] ^= 0xff;
await disk.write('__kv_snapshot', snap.buffer as ArrayBuffer);
const bad = encodeLogRecord(500, { junk: enc('J') });
bad[15] ^= 0xff;
const log = new Uint8Array(await disk.read('__kv_log') as ArrayBuffer);
const combined = new Uint8Array(log.byteLength + bad.byteLength);
combined.set(log, 0); combined.set(bad, log.byteLength);
await disk.write('__kv_log', combined.buffer as ArrayBuffer);
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
const discarded = await kv2.repair();
expect(discarded).toBeGreaterThan(0);
// 快照损坏(a 的唯一副本丢失)+ 损坏日志尾部被截断 → 日志数据 b 完整
expect(await kv2.exists('a')).toBe(false);
expect(dec(await kv2.get('b'))).toBe('B');
// repair 后介质上的损坏快照已清理
expect(await medium.exists('__kv_snapshot')).toBe(false);
await kv2.close();
});
it('并发写 + checkpoint 串行(无交错丢数据)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv = new KVStore(medium, 0);
await kv.open(dbName);
// 并发发起大量写入 + 中途 checkpoint
const writes = [] as Promise<void>[];
for (let i = 0; i < 200; i++) {
writes.push(kv.put(`k${i}`, enc(`v${i}`)));
if (i === 100) writes.push(kv.checkpoint());
}
await Promise.all(writes);
expect(kv.size()).toBe(200);
await kv.close();
// 重开验证全部恢复
const kv2 = new KVStore(medium, 0);
await kv2.open(dbName);
expect(kv2.size()).toBe(200);
expect(dec(await kv2.get('k150'))).toBe('v150');
await kv2.close();
});
it('自动 checkpoint 阈值触发(日志不无限增长)', async () => {
const dbName = uniqueDB();
const medium = new SharedMemoryBackend();
const kv = new KVStore(medium, 256); // 小阈值
await kv.open(dbName);
for (let i = 0; i < 50; i++) {
await kv.put(`k${i}`, enc(`value-${i}-`.repeat(10)));
}
// 自动 checkpoint 后日志应被截断
const log = await medium.read('__kv_log');
expect((log as ArrayBuffer).byteLength).toBeLessThan(4096);
await kv.close();
const kv2 = new KVStore(medium, 256);
await kv2.open(dbName);
expect(kv2.size()).toBe(50);
await kv2.close();
});
});
describe('KVStore — 编解码单元', () => {
it('encodeLogRecord / parseLogRecords 往返', () => {
const rec = encodeLogRecord(1, { a: enc('A'), b: enc('BB') }, ['del']);
const records: { seq: number; entries: { op: KVLogOp; key: string; value: ArrayBuffer }[] }[] = [];
parseLogRecords(rec, (r) => records.push(r));
expect(records).toHaveLength(1);
expect(records[0].seq).toBe(1);
expect(records[0].entries).toHaveLength(3);
expect(dec(records[0].entries[0].value)).toBe('A');
expect(records[0].entries[2].op).toBe(KVLogOp.DELETE);
});
it('多记录日志顺序解析', () => {
const r1 = encodeLogRecord(1, { a: enc('1') });
const r2 = encodeLogRecord(2, { b: enc('2') });
const combined = new Uint8Array(r1.byteLength + r2.byteLength);
combined.set(r1, 0); combined.set(r2, r1.byteLength);
const seqs: number[] = [];
parseLogRecords(combined, (r) => seqs.push(r.seq));
expect(seqs).toEqual([1, 2]);
});
it('encodeSnapshot / decodeSnapshot 往返', () => {
const entries = new Map<string, ArrayBuffer>([['a', enc('A')], ['b', enc('BB')]]);
const bytes = encodeSnapshot(42, entries);
const snap = decodeSnapshot(bytes);
expect(snap).not.toBeNull();
expect(snap!.seq).toBe(42);
expect(dec(snap!.entries.get('a'))).toBe('A');
expect(dec(snap!.entries.get('b'))).toBe('BB');
});
it('损坏快照 decode 返回 null', () => {
const entries = new Map<string, ArrayBuffer>([['a', enc('A')]]);
const bytes = encodeSnapshot(1, entries);
bytes[10] ^= 0xff;
expect(decodeSnapshot(bytes)).toBeNull();
});
});
-158
View File
@@ -1,158 +0,0 @@
/**
* OPFSEngine 测试 — mock FileSystemDirectoryHandle,验证逻辑不卡死
* 注:jsdom 无 OPFS API,全部使用 mock 模拟
*/
import { OPFSEngine } from '../../src/engine/opfs';
import { createSchema } from '../../src/table/schema';
// Mock navigator.storage.getDirectory + FileSystemDirectoryHandle
function mockOPFS() {
const files = new Map<string, string>();
const dirMock = {
getDirectoryHandle: async (_name: string, _opts?: any) => dirMock as any,
getFileHandle: async (name: string, opts?: any) => {
if (opts?.create) {
return { createWritable: async () => ({ write: async (d: string) => { files.set(name, d); }, close: async () => {} }) };
}
if (!files.has(name)) throw new Error('Not found');
return { getFile: async () => ({ text: async () => files.get(name)!, arrayBuffer: async () => new ArrayBuffer(0) }) };
},
removeEntry: async (name: string) => { files.delete(name); },
};
// 为 entries() 添加可迭代接口
(dirMock as any).entries = () => ({
[Symbol.asyncIterator]: async function* () {
for (const [k] of files) yield [k];
},
});
// jsdom 环境下 navigator 可能已存在,直接设置 storage 属性
const nav = (globalThis as any).navigator || {};
nav.storage = { getDirectory: async () => dirMock };
(globalThis as any).navigator = nav;
return { files };
}
const userSchema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
age: { type: 'number', default: 0 },
});
describe('OPFSEngine', () => {
let engine: OPFSEngine;
let mocks: ReturnType<typeof mockOPFS>;
beforeEach(async () => {
mocks = mockOPFS();
engine = new OPFSEngine();
await engine.open('test-opfs', 1);
});
afterEach(async () => {
await engine.close();
});
// ---- 生命周期 ----
it('打开后 isOpen 返回 true', () => {
expect(engine.isOpen()).toBe(true);
});
it('关闭后 isOpen 返回 false', async () => {
await engine.close();
expect(engine.isOpen()).toBe(false);
});
// ---- 表管理 ----
it('创建表后 hasTable 返回 true', async () => {
await engine.createTable(userSchema);
expect(await engine.hasTable('users')).toBe(true);
});
it('获取表名列表', async () => {
await engine.createTable(userSchema);
const names = await engine.getTableNames();
expect(names).toContain('users');
});
it('删除表后 hasTable 返回 false', async () => {
await engine.createTable(userSchema);
await engine.dropTable('users');
expect(await engine.hasTable('users')).toBe(false);
});
// ---- CRUD ----
it('插入后立即可查询', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 },
]);
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
});
it('count 返回正确行数', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
expect(await engine.count('users')).toBe(1);
});
it('更新后数据变化', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice', age: 30 }]);
await engine.update('users', { table: 'users', where: { id: '1' } }, { age: 31 });
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
expect(rows[0].age).toBe(31);
});
it('删除后 count 减少', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]);
await engine.delete('users', { table: 'users', where: { id: '1' } });
expect(await engine.count('users')).toBe(1);
});
// ---- 数据持久化(模拟重启) ----
it('close + reopen 后数据恢复', async () => {
// 写入数据
await engine.createTable(userSchema);
await engine.insert('users', [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 },
]);
const beforeRows = await engine.find('users', { table: 'users' });
expect(beforeRows).toHaveLength(2);
// 关闭后重新打开(模拟重启)
await engine.close();
engine = new OPFSEngine();
await engine.open('test-opfs', 1);
// 数据应恢复
const afterRows = await engine.find('users', { table: 'users' });
expect(afterRows).toHaveLength(2);
expect(afterRows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob']);
});
// ---- 事务 ----
it('事务 commit 后数据持久化', async () => {
await engine.createTable(userSchema);
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.commitTransaction();
expect(await engine.count('users')).toBe(1);
});
it('事务 rollback 后数据恢复', async () => {
await engine.createTable(userSchema);
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.beginTransaction();
await engine.insert('users', [{ id: '2', name: 'Bob' }]);
await engine.rollbackTransaction();
expect(await engine.count('users')).toBe(1);
});
});
-1
View File
@@ -6,7 +6,6 @@
* 本次验证 Table API 与 SQL query 两条路径均真实触发全部 14 个钩子。
*/
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
async function createDb(name: string): Promise<MetonaSqlark> {
const db = new MetonaSqlark({ name: `hooks-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, mode: 'memory' });
-2
View File
@@ -6,7 +6,6 @@
import { HybridEngine } from '../../src/hybrid/index';
import { createSchema } from '../../src/table/schema';
import 'fake-indexeddb/auto';
let hybridCounter = 0;
@@ -30,7 +29,6 @@ describe('HybridEngine', () => {
if (engine.isOpen()) {
await engine.close();
}
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('引擎名称', () => {
-1
View File
@@ -9,7 +9,6 @@
* - refresh 手动刷新
* - 表名校验(SQL 注入防护)
*/
import 'fake-indexeddb/auto';
// ---- 最小 Vue mock(自包含:jest.mock 工厂不能引用外部变量) ----
jest.mock('vue', () => {
+5 -2
View File
@@ -7,7 +7,10 @@
import { MetonaSqlark } from '../src/core';
import { parse } from '../src/sql/parser';
import { createSchema } from '../src/table/schema';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let dbCounter = 0;
function uniqueDB(): string {
@@ -53,7 +56,7 @@ describe('v0.5.1 — 维护语句执行(Aria 引擎)', () => {
const db = new MetonaSqlark({
name: uniqueDB(),
mode: 'aria',
diskEngine: 'indexeddb',
diskEngine: 'opfs',
aria: { checkpointInterval: 100000, walSyncMode: 'full' },
});
await db.init();
+187
View File
@@ -0,0 +1,187 @@
/**
* 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 });
// 持久化 schemaIndexedDBEngine 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();
});
});
+4 -111
View File
@@ -4,9 +4,7 @@
*/
import { MemoryEngine } from '../src/engine/memory';
import { IndexedDBEngine } from '../src/engine/indexeddb';
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
let idbCounter = 0;
@@ -51,75 +49,6 @@ describe('v0.1.14 幂等 init — MemoryEngine', () => {
});
// ===================================================================
// IndexedDB flushToIDB 原子性
// ===================================================================
describe('v0.1.14 原子性 flush — IndexedDBEngine', () => {
let engine: IndexedDBEngine;
let dbName: string;
beforeEach(async () => {
dbName = uniqueDB();
engine = new IndexedDBEngine();
await engine.open(dbName, 1);
});
afterEach(async () => {
try { await engine.close(); } catch {}
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('事务 commit 后内存数据完整', async () => {
await engine.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await engine.beginTransaction();
await engine.insert('users', [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]);
await engine.commitTransaction();
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
});
it('事务 commit 后混合操作数据完整', async () => {
await engine.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.beginTransaction();
await engine.insert('users', [{ id: '2', name: 'Bob' }]);
await engine.update('users', { table: 'users', where: { id: '1' } }, { name: 'Alice2' });
await engine.delete('users', { table: 'users', where: { id: '2' } });
await engine.commitTransaction();
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Alice2');
});
it('事务回滚后数据完全恢复', async () => {
await engine.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.beginTransaction();
await engine.insert('users', [{ id: '2', name: 'Bob' }]);
await engine.rollbackTransaction();
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Alice');
});
it('事务内的 find 能读到未提交的变更', async () => {
await engine.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
await engine.insert('users', [{ id: '1', name: 'Alice' }]);
await engine.beginTransaction();
await engine.insert('users', [{ id: '2', name: 'Bob' }]);
// 事务内的 find 应能读到事务写入的数据
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
await engine.rollbackTransaction();
});
});
// ===================================================================
// Hybrid 引擎事务持久化
// ===================================================================
@@ -130,7 +59,7 @@ describe('v0.1.14 Hybrid 引擎事务持久化', () => {
beforeEach(async () => {
dbName = uniqueDB();
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
@@ -140,7 +69,6 @@ describe('v0.1.14 Hybrid 引擎事务持久化', () => {
afterEach(async () => {
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('Hybrid 事务 commit 后内存和磁盘一致', async () => {
@@ -153,16 +81,11 @@ describe('v0.1.14 Hybrid 引擎事务持久化', () => {
const rows = await db.table('users').select().execute();
expect(rows).toHaveLength(2);
// 重新连接同一数据库(先关闭再创建新实例,但要先 deleteDatabase 让 fake-idb 干净)
// 重新打开同一库:Hybrid 从磁盘(KVStore)恢复已提交事务数据
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
// 用新实例打开(验证内存+磁盘一致性:Hybrid 应该从磁盘恢复)
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db2.init();
// 新数据库是空的(因为删了 IDB),Hybrid 从磁盘加载也是空的
const names2 = await db2.getTableNames();
expect(names2).toHaveLength(0);
expect(await db2.table('users').count()).toBe(2);
await db2.close();
});
@@ -181,33 +104,3 @@ describe('v0.1.14 Hybrid 引擎事务持久化', () => {
expect(rows[0].id).toBe('1');
});
});
// ===================================================================
// onversionchange 监听
// ===================================================================
describe('v0.1.14 onversionchange — IndexedDBEngine', () => {
it('open 后 onversionchange 回调已绑定', async () => {
const dbName = uniqueDB();
const engine = new IndexedDBEngine();
await engine.open(dbName, 1);
const rawDb = (engine as any).db;
expect(rawDb).toBeTruthy();
expect(typeof rawDb.onversionchange).toBe('function');
await engine.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('close 后 db 引用和监听器均被清理', async () => {
const dbName = uniqueDB();
const engine = new IndexedDBEngine();
await engine.open(dbName, 1);
await engine.close();
const rawDb = (engine as any).db;
expect(rawDb).toBeNull();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
});
+5 -2
View File
@@ -4,13 +4,16 @@
*
* 覆盖:多语句 / 事务语句 / INSERT...SELECT / UNION / CREATE INDEX / EXISTS
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import '../src/connection-manager';
import { parse, parseAll } from '../src/sql/parser';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
async function createDb(mode: 'memory' | 'disk' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
+5 -2
View File
@@ -4,14 +4,17 @@
*
* 覆盖:CASE WHEN 表达式 / JOIN + 关联子查询 / WAL 批量组提交
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import { parse } from '../src/sql/parser';
import { WAL, type WALStore } from '../src/engine/aria/wal/log';
import { WALRecordType } from '../src/engine/aria/types';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
async function createDb(mode: 'memory' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
+17 -18
View File
@@ -4,14 +4,13 @@
*
* 覆盖:CASE WHEN 用于 WHERE/聚合 / JOIN 哈希连接 / 多标签页同步
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
async function createDb(mode: 'memory' | 'hybrid' = 'memory', extra: Record<string, unknown> = {}) {
const db = new MetonaSqlark({
name: `sql-ext3-${mode}-${Date.now()}-${Math.random()}`,
mode,
diskEngine: 'indexeddb',
diskEngine: 'opfs',
...extra,
});
await db.init();
@@ -249,13 +248,13 @@ describe('[v0.3.2] 多标签页同步', () => {
test('SQL 写语句广播表变更,其他标签页订阅收到 external 事件', async () => {
// 先建表(DDL 版本升级会触发其他标签页 onversionchange 关闭连接,故先建表再开第二连接)
const setup = new MetonaSqlark({ name: 'mt-a', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'mt-a', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbA = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
await dbA.init();
await dbB.init();
@@ -274,13 +273,13 @@ describe('[v0.3.2] 多标签页同步', () => {
});
test('Hybrid 标签页收到广播后内存重载(读到其他标签页的新数据)', async () => {
const setup = new MetonaSqlark({ name: 'mt-b', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'mt-b', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbA = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
await dbA.init();
await dbB.init();
@@ -305,13 +304,13 @@ describe('[v0.3.2] 多标签页同步', () => {
});
test('未启用 multiTabSync 不广播', async () => {
const setup = new MetonaSqlark({ name: 'mt-c', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'mt-c', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
const dbB = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbA = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
const dbB = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
await dbA.init();
await dbB.init();
@@ -327,13 +326,13 @@ describe('[v0.3.2] 多标签页同步', () => {
});
test('Table API 写入也广播', async () => {
const setup = new MetonaSqlark({ name: 'mt-d', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'mt-d', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbA = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
await dbA.init();
await dbB.init();
@@ -354,7 +353,7 @@ describe('[v0.3.2] 多标签页同步', () => {
describe('[v0.3.2] IndexedDB reopen schema 持久化', () => {
test('close 后重新 open 表结构与数据完整', async () => {
const setup = new MetonaSqlark({ name: 'reopen-a', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'reopen-a', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', {
id: { type: 'string', primaryKey: true },
@@ -365,7 +364,7 @@ describe('[v0.3.2] IndexedDB reopen schema 持久化', () => {
await setup.close();
// 重新打开(模拟页面刷新)
const db = new MetonaSqlark({ name: 'reopen-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: 'reopen-a', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
const schema = await db.getEngine().getTableSchema('t');
@@ -383,7 +382,7 @@ describe('[v0.3.2] IndexedDB reopen schema 持久化', () => {
});
test('reopen 后 UPDATE 全列生效', async () => {
const setup = new MetonaSqlark({ name: 'reopen-b', mode: 'hybrid', diskEngine: 'indexeddb' });
const setup = new MetonaSqlark({ name: 'reopen-b', mode: 'hybrid', diskEngine: 'opfs' });
await setup.init();
await setup.defineTable('t', {
id: { type: 'string', primaryKey: true },
@@ -393,7 +392,7 @@ describe('[v0.3.2] IndexedDB reopen schema 持久化', () => {
await setup.query(`INSERT INTO t VALUES ('1', 'Alice', 42)`);
await setup.close();
const db = new MetonaSqlark({ name: 'reopen-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: 'reopen-b', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.query(`UPDATE t SET name = 'Renamed', v = 99 WHERE id = '1'`);
+2 -5
View File
@@ -4,7 +4,6 @@
*/
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
let idbCounter = 0;
@@ -128,7 +127,7 @@ describe('v0.1.13 事务回滚 — Disk 模式 (IndexedDB)', () => {
beforeEach(async () => {
dbName = `tx-disk-${++idbCounter}`;
db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
@@ -138,7 +137,6 @@ describe('v0.1.13 事务回滚 — Disk 模式 (IndexedDB)', () => {
afterEach(async () => {
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('commit: 事务成功数据持久化', async () => {
@@ -175,7 +173,7 @@ describe('v0.1.13 事务回滚 — Hybrid 模式', () => {
beforeEach(async () => {
dbName = `tx-hybrid-${++idbCounter}`;
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
@@ -185,7 +183,6 @@ describe('v0.1.13 事务回滚 — Hybrid 模式', () => {
afterEach(async () => {
await db.close();
try { indexedDB.deleteDatabase(dbName); } catch {}
});
it('commit: 事务成功', async () => {
+7 -3
View File
@@ -18,13 +18,17 @@ import type { SSTableMeta } from '../src/engine/aria/types';
import type { MetonaPlugin } from '../src/constants';
import { createSchema } from '../src/table/schema';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
// ---------------------------------------------------------------------------
// P0-1: 版本号统一
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为当前版本(0.5.1', () => {
expect(VERSION).toBe('0.5.1');
test('VERSION 常量为当前版本(0.6.0', () => {
expect(VERSION).toBe('0.6.0');
});
});
@@ -41,7 +45,7 @@ describe('[v0.2.5] P0-2: AriaEngine OPFS 后端映射', () => {
});
test('mode=aria + diskEngine=indexeddb 时应使用 indexeddb 后端', () => {
const db = new MetonaSqlark({ name: `test-idb-map-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: `test-idb-map-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'opfs' });
expect(db).toBeDefined();
});
});
+6 -3
View File
@@ -12,7 +12,6 @@
* - P1-9: Savepoint 回滚后 MVCC 版本链一致 + rollback 索引重建
*/
import 'fake-indexeddb/auto';
import { VERSION } from '../src/constants';
import { MetonaSqlark } from '../src/core';
import { AriaEngine } from '../src/engine/aria/index';
@@ -20,13 +19,17 @@ import { MemoryEngine } from '../src/engine/memory';
import { WAL } from '../src/engine/aria/wal/log';
import { tokenize } from '../src/sql/lexer';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
// ---------------------------------------------------------------------------
// P0-1: Aria WAL DROP_TABLE 崩溃恢复
// ---------------------------------------------------------------------------
describe('[v0.3.3] P0-1: WAL DROP_TABLE 崩溃恢复', () => {
const mkEngine = async (name: string): Promise<AriaEngine> => {
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await e.open(name, 1);
return e;
};
@@ -400,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
describe('[v0.3.3] 端到端', () => {
test('全部修复点可共存于 MetonaSqlark API', async () => {
expect(VERSION).toBe('0.5.1');
expect(VERSION).toBe('0.6.0');
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
await db.init();
await db.defineTable('users', {
+5 -2
View File
@@ -6,10 +6,13 @@
* - B-4: COUNT(DISTINCT) + NULLS FIRST/LAST
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import { AriaEngine } from '../src/engine/aria/index';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
const uniqueName = (prefix: string): string =>
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -366,7 +369,7 @@ describe('[v0.4.0] B-4: COUNT(DISTINCT) + NULLS FIRST/LAST', () => {
});
test('Aria $in 查询去重(IN 子查询含重复值不返回重复行)', async () => {
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'opfs' });
await db.init();
await db.getEngine().clearAll();
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
+80 -117
View File
@@ -15,11 +15,15 @@ import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import { IndexedDBEngine } from '../src/engine/indexeddb';
import { MetonaSqlark } from '../src/core';
import { IndexedDBBackend } from '../src/engine/aria/store/backend';
import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
import type { SSTableMeta } from '../src/engine/aria/types';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -133,7 +137,7 @@ describe('P0-1a — SSTableReader 残缺数据防御', () => {
describe('P0-1b — AriaEngine 打开时完整性校验', () => {
it('meta 引用残缺文件 → 打开跳过损坏 SSTable 不崩溃,库可用', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
@@ -145,17 +149,18 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
await (engine as any).lsm.flush();
await engine.close();
// 篡改存储:把第一个 sst_ 文件写成残缺内容(meta 仍引用它)
const backend = new IndexedDBBackend();
// 篡改存储:把第一个 SSTable 的第一个页面写成残缺内容(meta 仍引用它)
const backend = new OPFSBackend();
await backend.open(dbName);
const sstKeys = (await backend.listKeys())
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
expect(sstKeys.length).toBeGreaterThan(0);
await backend.write(sstKeys[0], new TextEncoder().encode('truncated-garbage').buffer);
const metas = JSON.parse(new TextDecoder().decode(
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const pageId = metas[0].pageIds[0];
await backend.write(`pg_${pageId}`, new TextEncoder().encode('truncated-garbage').buffer);
await backend.close();
// 重开:不得崩溃(此前抛 RangeError 打不开库)
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
// schema 完整,损坏的 SSTable 已被清理
@@ -165,19 +170,21 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
await engine2.close();
// 清理 meta 已验证:损坏文件被删除
const backend2 = new IndexedDBBackend();
const backend2 = new OPFSBackend();
await backend2.open(dbName);
const remaining = (await backend2.listKeys())
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
const remaining = (await backend2.listKeys()).filter((k) => k.startsWith('pg_'));
const metaRaw = await backend2.read('__aria_lsm_meta');
const metaList = JSON.parse(new TextDecoder().decode(metaRaw ?? new Uint8Array())) as { id: number }[];
expect(remaining.length).toBe(metaList.length); // 无孤儿文件 / 无悬空 meta
const metaList = JSON.parse(new TextDecoder().decode(metaRaw ?? new Uint8Array())) as { pageIds?: number[] }[];
const livePages = new Set<number>();
for (const m of metaList) if (m.pageIds) for (const pid of m.pageIds) livePages.add(pid);
const orphan = remaining.filter((k) => !livePages.has(Number(k.slice(3))));
expect(orphan.length).toBe(0); // 无孤儿页面
await backend2.close();
});
it('meta 引用缺失文件 → 打开清理 meta 不崩溃', async () => {
it('meta 引用缺失页面 → 打开清理 meta 不崩溃', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -187,15 +194,14 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
await (engine as any).lsm.flush();
await engine.close();
// 删除全部 sst_ 文件(保留 meta → 悬空引用)
const backend = new IndexedDBBackend();
// 删除全部 pg_ 页面(保留 meta → 悬空引用)
const backend = new OPFSBackend();
await backend.open(dbName);
const sstKeys = (await backend.listKeys())
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
await backend.deleteMany(sstKeys);
const pgKeys = (await backend.listKeys()).filter((k) => k.startsWith('pg_'));
await backend.deleteMany(pgKeys);
await backend.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
expect(await engine2.getTableNames()).toEqual(['t']);
@@ -210,7 +216,7 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
it('IndexedDBBackend.writeMany/deleteMany 单事务原子批量操作', async () => {
const dbName = uniqueDB();
const be = new IndexedDBBackend();
const be = new OPFSBackend();
await be.open(dbName);
await be.writeMany({
a: new TextEncoder().encode('1').buffer,
@@ -226,7 +232,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
it('P1-4: 连续快速写入 200 条 → close+重开 数据完整(实测曾丢 4 条)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -237,7 +243,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
}
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(200);
await engine2.close();
@@ -245,7 +251,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
it('P1-4: count 键丢失(模拟崩溃竞态)→ 按 key 扫描恢复不丢记录', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -256,12 +262,12 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
}
// 模拟异常退出(不 close):直接删掉 count 键,制造 count 与记录不一致
const backend = new IndexedDBBackend();
const backend = new OPFSBackend();
await backend.open(dbName);
await backend.delete('__wal_count');
await backend.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(20);
await engine2.close();
@@ -275,7 +281,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
describe('P1-5 — AriaEngine.close 截断 WAL', () => {
it('close 后 WAL 记录键全部清空(不无限重放)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -284,7 +290,7 @@ describe('P1-5 — AriaEngine.close 截断 WAL', () => {
await engine.insert('t', [{ id: '1', v: 1 }, { id: '2', v: 2 }]);
await engine.close();
const backend = new IndexedDBBackend();
const backend = new OPFSBackend();
await backend.open(dbName);
// __wal_count 计数键合法保留(作为 append 序号分配器),WAL 记录键必须清空
const walKeys = (await backend.listKeys())
@@ -294,65 +300,6 @@ describe('P1-5 — AriaEngine.close 截断 WAL', () => {
});
});
// ===================================================================
// P0-2 / P0-3 / P2-8: IndexedDB 版本管理与参数校验
// ===================================================================
describe('P0-2 / P0-3 — IndexedDB 版本管理', () => {
it('P0-2: 建表提升版本后以旧 version 重开成功(VersionError 自适应)', async () => {
const dbName = uniqueDB();
const engine = new IndexedDBEngine();
await engine.open(dbName, 1);
await engine.createTable({ name: 'a', columns: { id: { type: 'string', primaryKey: true } } });
await engine.createTable({ name: 'b', columns: { id: { type: 'string', primaryKey: true } } });
await engine.createTable({ name: 'c', columns: { id: { type: 'string', primaryKey: true } } });
await engine.close();
// 用旧版本 1 重开:此前必报 IDB_OPEN_ERROR(VersionError)
const engine2 = new IndexedDBEngine();
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
const names = await engine2.getTableNames();
expect(names).toContain('a');
expect(names).toContain('b');
expect(names).toContain('c');
await engine2.close();
});
it('P0-2: MetonaSqlark hybrid 二次启动不报 VersionErrorMarkLite 场景)', async () => {
const dbName = uniqueDB();
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 1 });
await db1.init();
await db1.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
await db1.table('users').insert({ id: '1', name: 'Alice' });
await db1.close();
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 1 });
await expect(db2.init()).resolves.toBeUndefined();
expect(await db2.table('users').count()).toBe(1);
await db2.close();
});
it('P0-3: IndexedDBEngine open(version=0) 归一化为 1,不抛 TypeError', async () => {
const dbName = uniqueDB();
const engine = new IndexedDBEngine();
await expect(engine.open(dbName, 0)).resolves.toBeUndefined();
expect(engine.isOpen()).toBe(true);
await engine.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
await engine.insert('t', [{ id: '1' }]);
expect(await engine.find('t', { table: 't' })).toHaveLength(1);
await engine.close();
});
it('P0-3: MetonaSqlark version=0 初始化正常', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
await expect(db.init()).resolves.toBeUndefined();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.table('t').insert({ id: '1' });
await db.close();
});
});
// ===================================================================
// P1-6: 引擎错误包装 DatabaseError
// ===================================================================
@@ -362,24 +309,36 @@ describe('P1-6 — 引擎内部错误统一包装 DatabaseError', () => {
// 构造一个无法打开的 backend 场景:直接调用 openInternal 模拟底层异常不可行,
// 这里验证损坏库重开时抛的是 DatabaseError 而非原生错误(不崩溃路径已由 P0-1b 覆盖)
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine.insert('t', [{ id: '1' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb' });
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
await engine2.close();
});
it('open 未初始化 IndexedDB 环境时抛 DatabaseError 而非原生错误', async () => {
const dbName = uniqueDB();
const engine = new IndexedDBEngine();
await engine.open(dbName, 1);
expect(engine.isOpen()).toBe(true);
await engine.close();
it('底层介质故障时抛 DatabaseError 而非原生错误', async () => {
const failing = {
open: async () => { throw new Error('disk failure'); },
close: async () => {},
isOpen: () => false,
read: async () => null,
write: async () => { throw new Error('disk failure'); },
append: async () => {},
writeMany: async () => {},
delete: async () => {},
deleteMany: async () => {},
listKeys: async () => [],
exists: async () => false,
clear: async () => {},
};
const engine = new KVStoreEngine(failing as never);
// 介质故障如实传播(不吞错),AriaEngine 路径由 ARIA_OPEN_ERROR 包装(上方测试覆盖)
await expect(engine.open(uniqueDB(), 1)).rejects.toThrow('disk failure');
});
});
@@ -393,7 +352,7 @@ describe('P2-7 — 迁移版本持久化', () => {
const runs: number[] = [];
// version: 0 表示"无 schema 起点"migration 1 可执行
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
await db1.init();
db1.addMigration(1, async () => { runs.push(1); });
db1.addMigration(2, async () => { runs.push(2); });
@@ -402,7 +361,7 @@ describe('P2-7 — 迁移版本持久化', () => {
await db1.close();
// 重启:version 又重置为 config.version=0(此前会重跑 migration 1/2
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
await db2.init();
db2.addMigration(1, async () => { runs.push(1); });
db2.addMigration(2, async () => { runs.push(2); });
@@ -440,35 +399,39 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
it('AriaEngine.repair 清理损坏 SSTable 后引擎可用', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 4096 });
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 100; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
for (let i = 0; i < 50; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).lsm.flush();
for (let i = 50; i < 100; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).lsm.flush();
// 篡改一个 sst 文件
const backend = new IndexedDBBackend();
// 篡改一个 SSTable 的页面文件
const backend = new OPFSBackend();
await backend.open(dbName);
const sstKeys = (await backend.listKeys())
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
expect(sstKeys.length).toBeGreaterThan(0);
await backend.write(sstKeys[0], new TextEncoder().encode('garbage').buffer);
const metas = JSON.parse(new TextDecoder().decode(
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const victimPage = metas[0].pageIds[0];
const raw = new Uint8Array(await backend.read(`pg_${victimPage}`) as ArrayBuffer);
raw[50] ^= 0xff;
await backend.write(`pg_${victimPage}`, raw.buffer as ArrayBuffer);
await backend.close();
await expect(engine.repair()).resolves.toBeUndefined();
const rows = await engine.find('t', { table: 't' });
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeLessThanOrEqual(100);
expect(engine.isOpen()).toBe(true);
// repair:校验清理损坏 SSTable,其余数据可用
await (engine as any).repair();
expect(await engine.count('t')).toBeGreaterThan(0);
expect(await engine.count('t')).toBeLessThan(100);
await engine.close();
});
it('MetonaSqlark.clearAll 统一接口(hybrid', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.table('t').insert({ id: '1' });
@@ -479,7 +442,7 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
it('MetonaSqlark.repair 统一接口(aria', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'aria', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'aria', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.table('t').insert({ id: '1', v: 42 });
@@ -490,7 +453,7 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
it('MetonaSqlark.repair 统一接口(hybrid', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.table('t').insert({ id: '1' });
+24 -21
View File
@@ -13,7 +13,10 @@ import { createSchema } from '../src/table/schema';
import { MemoryEngine } from '../src/engine/memory';
import { HybridEngine } from '../src/hybrid/index';
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -53,7 +56,7 @@ describe('P0-A — Compaction 缓存独立性', () => {
it('多层级 compaction 后数据仍完整且可重开', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
bufferPoolPages: 8, // 32KB 缓存
memtableSizeThreshold: 16 * 1024,
checkpointInterval: 100000,
@@ -74,7 +77,7 @@ describe('P0-A — Compaction 缓存独立性', () => {
// 重开:数据完整(meta/文件未被 compaction 破坏)
const engine2 = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
bufferPoolPages: 8,
memtableSizeThreshold: 16 * 1024,
checkpointInterval: 100000,
@@ -92,7 +95,7 @@ describe('P0-A — Compaction 缓存独立性', () => {
describe('P1-A — 二级索引恢复', () => {
it('Aria 重开后二级索引可用(索引 LSM 持久化恢复)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
@@ -107,7 +110,7 @@ describe('P1-A — 二级索引恢复', () => {
await engine.close();
// 重开:二级索引 LSM 应自动恢复(修复前为空 → 索引查询回退全表,createIndex 也静默跳过)
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 强断言:索引 LSM 真实存在且数据完整(防止"索引缺失静默回退全表扫描"的假通过)
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:email');
@@ -124,7 +127,7 @@ describe('P1-A — 二级索引恢复', () => {
it('WAL 恢复后二级索引与主数据一致(崩溃前索引未更新)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
@@ -138,7 +141,7 @@ describe('P1-A — 二级索引恢复', () => {
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 强断言:索引 LSM 已恢复且包含 WAL 回放的行(崩溃前索引未更新,恢复后必须重建)
const idxLsm = (engine2 as any).secondaryIndexes.get('users:idx:city');
@@ -154,7 +157,7 @@ describe('P1-A — 二级索引恢复', () => {
it('createIndex 重开后仍可新建(schema 标记恢复后不阻塞)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -163,7 +166,7 @@ describe('P1-A — 二级索引恢复', () => {
await engine.insert('t', [{ id: '1', name: 'A' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
await engine2.createIndex('t', 'name'); // 修复前 schema 无标记时正常;此处验证无标记场景
const byName = await engine2.find('t', { table: 't', where: { name: 'A' } });
@@ -177,9 +180,9 @@ describe('P1-A — 二级索引恢复', () => {
// ===================================================================
describe('P1-B — ALTER TABLE 持久化', () => {
it('IndexedDBEngine DROP COLUMN 后重启不复活', async () => {
it('KVStoreEngine DROP COLUMN 后重启不复活', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
@@ -194,7 +197,7 @@ describe('P1-B — ALTER TABLE 持久化', () => {
await db.close();
// 重启:schema 不复活,列定义已持久化
const db2 = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'indexeddb' });
const db2 = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'opfs' });
await db2.init();
const schema2 = await db2.getEngine().getTableSchema('t');
expect(schema2!.columns.old_col).toBeUndefined();
@@ -204,13 +207,13 @@ describe('P1-B — ALTER TABLE 持久化', () => {
it('HybridEngine ADD COLUMN 后重启保留', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query('ALTER TABLE t ADD COLUMN phone STRING');
await db.close();
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db2.init();
const schema2 = await db2.getEngine().getTableSchema('t');
expect(schema2!.columns.phone).toBeDefined();
@@ -219,13 +222,13 @@ describe('P1-B — ALTER TABLE 持久化', () => {
});
// ===================================================================
// P1-C: IndexedDB 事务内 DDL
// P1-C: KVStore 事务内 DDL
// ===================================================================
describe('P1-C — IndexedDB 事务内 DDL', () => {
describe('P1-C — KVStore 事务内 DDL', () => {
it('事务内建表 → commit 后磁盘一致(重启表存在且可查)', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('base', { id: { type: 'string', primaryKey: true } });
await db.table('base').insert({ id: '1' });
@@ -240,7 +243,7 @@ describe('P1-C — IndexedDB 事务内 DDL', () => {
expect(await db.table('created_in_tx').count()).toBe(1);
await db.close();
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db2.init();
expect(await db2.table('created_in_tx').count()).toBe(1);
expect(await db2.table('base').count()).toBe(2);
@@ -249,7 +252,7 @@ describe('P1-C — IndexedDB 事务内 DDL', () => {
it('事务内删表 → commit 后磁盘一致(重启无幽灵表)', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('ghost', { id: { type: 'string', primaryKey: true } });
await db.table('ghost').insert({ id: '1' });
@@ -259,7 +262,7 @@ describe('P1-C — IndexedDB 事务内 DDL', () => {
});
await db.close();
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db2.init();
const names = await db2.getTableNames();
expect(names).not.toContain('ghost');
@@ -300,7 +303,7 @@ describe('全模式抽查 — 生命周期与元数据', () => {
it('AriaEngine repair 幂等', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine.insert('t', [{ id: '1' }]);
+10 -90
View File
@@ -9,10 +9,12 @@
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { OPFSEngine } from '../src/engine/opfs';
import { MemTable } from '../src/engine/aria/index/memtable';
import { MetonaSqlark } from '../src/core';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -73,7 +75,7 @@ describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
it('事务中触发 checkpoint → 崩溃恢复不丢事务数据', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 2, // 每 2 次操作即触发 checkpoint(事务中途)
});
await engine.open(dbName, 1);
@@ -91,7 +93,7 @@ describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 修复前:checkpoint 截断了事务的 WAL 记录 → 恢复后数据丢失
expect(await engine2.count('t')).toBe(2);
@@ -101,7 +103,7 @@ describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
it('事务回滚后再 checkpoint 正常截断', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
checkpointInterval: 1,
});
await engine.open(dbName, 1);
@@ -115,7 +117,7 @@ describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(1);
await engine2.close();
@@ -168,7 +170,7 @@ describe('P1 — Aria DDL 索引清理', () => {
it('DROP_TABLE 崩溃恢复同样清理索引', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
@@ -180,7 +182,7 @@ describe('P1 — Aria DDL 索引清理', () => {
await (engine as any).backend.close();
(engine as any).opened = false;
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
// 表不存在,且无索引残留
expect(await engine2.hasTable('t')).toBe(false);
@@ -189,88 +191,6 @@ describe('P1 — Aria DDL 索引清理', () => {
});
});
// ===================================================================
// P1: OPFS 持久化与并发
// ===================================================================
describe('P1 — OPFS 生产加固', () => {
it('并发写不丢数据(内存快照总是最新,多写内容一致)', async () => {
// 模拟真实 I/O 延迟:内存写同步、持久化异步 → 并发写内容均基于最新内存快照
const files = mockOPFS(undefined, {
ioDelay: 10,
writeDelayFor: (data) => (data.length < 40 ? 30 : 5),
});
const engine = new OPFSEngine();
await engine.open('opfs-race', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
// 两个写并发:内存同步写保证两个快照一致 → 无论完成顺序文件内容完整
const p1 = engine.insert('users', [{ id: '1', name: 'A' }]);
const p2 = engine.insert('users', [{ id: '2', name: 'B' }]);
await Promise.all([p1, p2]);
const rows = await engine.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
// 文件内容完整(重启不丢)
expect(files.get('users.json')).toContain('"B"');
await engine.close();
});
it('空表重启后保留(schema 持久化)', async () => {
mockOPFS();
const e1 = new OPFSEngine();
await e1.open('opfs-schema', 1);
await e1.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await e1.close();
// 重启:空表也应恢复(修复前空表消失)+ 索引标记恢复
const e2 = new OPFSEngine();
await e2.open('opfs-schema', 1);
expect(await e2.hasTable('users')).toBe(true);
const schema = await e2.getTableSchema('users');
expect(schema!.columns.email.index).toBe(true);
await e2.close();
});
it('有数据重启后索引查询可用', async () => {
mockOPFS();
const e1 = new OPFSEngine();
await e1.open('opfs-idx', 1);
await e1.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await e1.insert('users', [{ id: '1', email: 'a@x.com' }]);
await e1.close();
const e2 = new OPFSEngine();
await e2.open('opfs-idx', 1);
const byEmail = await e2.find('users', { table: 'users', where: { email: 'a@x.com' } });
expect(byEmail).toHaveLength(1);
await e2.close();
});
it('dropTable 后重启无幽灵表', async () => {
mockOPFS();
const e1 = new OPFSEngine();
await e1.open('opfs-drop', 1);
await e1.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await e1.insert('t', [{ id: '1' }]);
await e1.dropTable('t');
await e1.close();
const e2 = new OPFSEngine();
await e2.open('opfs-drop', 1);
expect(await e2.hasTable('t')).toBe(false);
expect(await e2.getTableNames()).toEqual([]);
await e2.close();
});
});
// ===================================================================
// P2: Aria 内部状态
// ===================================================================
+5 -46
View File
@@ -10,8 +10,10 @@
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { LSM } from '../src/engine/aria/index/lsm';
import { OPFSEngine } from '../src/engine/opfs';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -56,7 +58,7 @@ describe('P0 — close 后无残留后台任务', () => {
it('close 后立即 reopen 不被旧后台任务污染', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
bufferPoolPages: 4,
memtableSizeThreshold: 32 * 1024,
checkpointInterval: 100000,
@@ -162,47 +164,4 @@ describe('P1 — 提交顺序与 close 等待', () => {
await engine.close();
});
it('OPFS close 等待挂起写完成(文件完整)', async () => {
// mock OPFS(跨实例共享)
const files = new Map<string, string>();
const dirMock = {
getDirectoryHandle: async (_n: string, _o?: any) => dirMock as any,
getFileHandle: async (name: string, opts?: any) => {
if (opts?.create) {
return {
createWritable: async () => ({
write: async (d: string) => {
// 模拟慢 I/O
await new Promise((r) => setTimeout(r, 30));
files.set(name, d);
},
close: async () => {},
}),
};
}
if (!files.has(name)) throw new Error('Not found');
return { getFile: async () => ({ text: async () => files.get(name)!, arrayBuffer: async () => new ArrayBuffer(0) }) };
},
removeEntry: async (n: string) => { files.delete(n); },
};
(dirMock as any).entries = () => ({
[Symbol.asyncIterator]: async function* () {
for (const [k] of files) yield [k];
},
});
const nav = (globalThis as any).navigator || {};
nav.storage = { getDirectory: async () => dirMock };
(globalThis as any).navigator = nav;
const engine = new OPFSEngine();
await engine.open('opfs-close', 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
// 写入不等待(写 I/O 30ms 挂起)
const p = engine.insert('t', [{ id: '1' }]);
// 立即 close:必须等待挂起写完成
await engine.close();
await p;
// 文件完整(修复前 close 不等写 → 可能读到旧/空文件)
expect(files.get('t.json')).toContain('"1"');
});
});
+8 -5
View File
@@ -13,7 +13,10 @@ import { createSchema } from '../src/table/schema';
import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import type { SSTableMeta } from '../src/engine/aria/types';
import 'fake-indexeddb/auto';
import { installOPFSMock } from './helpers/opfs-mock';
beforeEach(() => { installOPFSMock(new Map()); });
let idbCounter = 0;
function uniqueDB(): string {
@@ -164,7 +167,7 @@ describe('P1 — v1 旧格式兼容', () => {
describe('P0 — 大内容端到端(aria 模式)', () => {
it('300KB 中文写入 → close → reopen 数据完整(修复前打开必崩)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('notes', {
id: { type: 'string', primaryKey: true },
@@ -182,7 +185,7 @@ describe('P0 — 大内容端到端(aria 模式)', () => {
await engine.close();
// 修复前:打开解析半写文件 → ARIA_OPEN_ERROR
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
const rows = await engine2.find('notes', { table: 'notes' });
expect(rows).toHaveLength(2);
@@ -196,7 +199,7 @@ describe('P0 — 大内容端到端(aria 模式)', () => {
it('多行 >64KB 内容批量写入重开完整(含索引列)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'indexeddb',
storageBackend: 'opfs',
memtableSizeThreshold: 32 * 1024,
checkpointInterval: 100000,
});
@@ -214,7 +217,7 @@ describe('P0 — 大内容端到端(aria 模式)', () => {
}
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
expect(await engine2.count('docs')).toBe(10);
const evens = await engine2.find('docs', { table: 'docs', where: { tag: 'even' } });