release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
import { CryptoManager } from '../../src/engine/aria/crypto';
|
||||
|
||||
function toBytes(data: ArrayBuffer): number[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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); });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
// 保留 storage(OPFS 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,保留 storage(OPFS 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' }]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* KVStoreEngine — 大规模压力测试(10 万级)
|
||||
*
|
||||
* 验证生产级可靠性:
|
||||
* 1. 10 万 key 批量写入 + checkpoint + 重开全量验证
|
||||
* 2. 混合操作(insert/update/delete)5 万级 + 崩溃模拟(不 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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user