391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
/**
|
||
* v0.4.2 生产就绪审计(第三轮)
|
||
* 覆盖:
|
||
* - P0: Aria 事务进行中 checkpoint 截断 WAL → 崩溃恢复丢事务数据
|
||
* - P1: Aria dropTable / ALTER DROP 索引列 的二级索引清理
|
||
* - P1: OPFS 并发写乱序丢更新 + 空表/schema/索引持久化
|
||
* - P2: Aria memtable 阈值衰减、红黑树随机压力、onUpdate 级联、事务 DDL 显式拒绝
|
||
*/
|
||
|
||
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';
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `prod-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
// ===================================================================
|
||
// OPFS mock(模拟真实 I/O:getFileHandle 延迟 + 写入按内容差异化耗时)
|
||
// ===================================================================
|
||
|
||
function mockOPFS(
|
||
files?: Map<string, string>,
|
||
opts: { ioDelay?: number; writeDelayFor?: (data: string) => number } = {},
|
||
): Map<string, string> {
|
||
const store = files ?? new Map<string, string>();
|
||
|
||
const dirMock = {
|
||
getDirectoryHandle: async (_name: string, _opts?: any) => dirMock as any,
|
||
getFileHandle: async (name: string, fileOpts?: any) => {
|
||
if (fileOpts?.create) {
|
||
if (opts.ioDelay) {
|
||
await new Promise((r) => setTimeout(r, opts.ioDelay));
|
||
}
|
||
return {
|
||
createWritable: async () => ({
|
||
write: async (d: string) => {
|
||
const delay = opts.writeDelayFor ? opts.writeDelayFor(d) : 0;
|
||
if (delay > 0) {
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
}
|
||
store.set(name, d);
|
||
},
|
||
close: async () => {},
|
||
}),
|
||
};
|
||
}
|
||
if (!store.has(name)) throw new Error('Not found');
|
||
return { getFile: async () => ({ text: async () => store.get(name)!, arrayBuffer: async () => new ArrayBuffer(0) }) };
|
||
},
|
||
removeEntry: async (name: string) => { store.delete(name); },
|
||
};
|
||
(dirMock as any).entries = () => ({
|
||
[Symbol.asyncIterator]: async function* () {
|
||
for (const [k] of store) yield [k];
|
||
},
|
||
});
|
||
const nav = (globalThis as any).navigator || {};
|
||
nav.storage = { getDirectory: async () => dirMock };
|
||
(globalThis as any).navigator = nav;
|
||
return store;
|
||
}
|
||
|
||
// ===================================================================
|
||
// P0: 事务与 checkpoint 冲突
|
||
// ===================================================================
|
||
|
||
describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
|
||
it('事务中触发 checkpoint → 崩溃恢复不丢事务数据', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
checkpointInterval: 2, // 每 2 次操作即触发 checkpoint(事务中途)
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
v: { type: 'number' },
|
||
}));
|
||
|
||
await engine.beginTransaction();
|
||
await engine.insert('t', [{ id: '1', v: 1 }]);
|
||
await engine.insert('t', [{ id: '2', v: 2 }]); // opCounter=2 → tick → checkpoint(修复前截断 WAL)
|
||
await engine.commitTransaction();
|
||
|
||
// 模拟异常退出(不 close)
|
||
await (engine as any).backend.close();
|
||
(engine as any).opened = false;
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
// 修复前:checkpoint 截断了事务的 WAL 记录 → 恢复后数据丢失
|
||
expect(await engine2.count('t')).toBe(2);
|
||
await engine2.close();
|
||
});
|
||
|
||
it('事务回滚后再 checkpoint 正常截断', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
checkpointInterval: 1,
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||
await engine.insert('t', [{ id: 'keep' }]);
|
||
await engine.beginTransaction();
|
||
await engine.insert('t', [{ id: 'tx1' }]);
|
||
await engine.rollbackTransaction();
|
||
// 事务结束后 checkpoint 可正常截断
|
||
await (engine as any).checkpointManager.checkpoint();
|
||
await (engine as any).backend.close();
|
||
(engine as any).opened = false;
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
expect(await engine2.count('t')).toBe(1);
|
||
await engine2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// P1: Aria DDL 的二级索引清理
|
||
// ===================================================================
|
||
|
||
describe('P1 — Aria DDL 索引清理', () => {
|
||
it('dropTable 清理二级索引(重建同名表索引不脏)', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
email: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||
await engine.dropTable('t');
|
||
// 索引 LSM 必须清理(修复前残留)
|
||
expect((engine as any).secondaryIndexes.size).toBe(0);
|
||
|
||
// 重建同名表 + 相同 id:旧索引残留会返回错误行
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
email: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('t', [{ id: '1', email: 'b@x.com' }]);
|
||
// 旧索引残留场景:按旧 email 查询不得命中新行(主 LSM 有 t:1 → 修复前返回错误结果)
|
||
const byOld = await engine.find('t', { table: 't', where: { email: 'a@x.com' } });
|
||
expect(byOld).toHaveLength(0);
|
||
const byNew = await engine.find('t', { table: 't', where: { email: 'b@x.com' } });
|
||
expect(byNew).toHaveLength(1);
|
||
await engine.close();
|
||
});
|
||
|
||
it('ALTER TABLE DROP 索引列清理索引 LSM', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
email: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||
await engine.alterTable('t', 'DROP', { name: 'email', type: 'string' });
|
||
expect((engine as any).secondaryIndexes.size).toBe(0);
|
||
await engine.close();
|
||
});
|
||
|
||
it('DROP_TABLE 崩溃恢复同样清理索引', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
email: { type: 'string', index: true },
|
||
}));
|
||
await engine.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||
await engine.dropTable('t');
|
||
// 模拟崩溃(不 close,WAL 含 DROP_TABLE)
|
||
await (engine as any).backend.close();
|
||
(engine as any).opened = false;
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', checkpointInterval: 100000 });
|
||
await engine2.open(dbName, 1);
|
||
// 表不存在,且无索引残留
|
||
expect(await engine2.hasTable('t')).toBe(false);
|
||
expect((engine2 as any).secondaryIndexes.size).toBe(0);
|
||
await engine2.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// 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 内部状态
|
||
// ===================================================================
|
||
|
||
describe('P2 — Aria 内部状态加固', () => {
|
||
it('freezeMemtable 后阈值不衰减', async () => {
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'memory',
|
||
memtableSizeThreshold: 1024 * 1024,
|
||
});
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||
// 少量写入 + 手动 flush(freezeMemtable 用旧表已用大小当新阈值 → 衰减)
|
||
for (let i = 0; i < 10; i++) await engine.insert('t', [{ id: `${i}` }]);
|
||
await (engine as any).lsm.flush();
|
||
// 修复前:新 memtable maxSize ≈ 已用字节(远小于配置)
|
||
const memtable = (engine as any).lsm.memtable;
|
||
expect((memtable as any).maxSize).toBeGreaterThanOrEqual(1024 * 1024);
|
||
await engine.close();
|
||
});
|
||
|
||
it('红黑树随机 insert/delete 5000 次保持有序且无丢失', () => {
|
||
const mem = new MemTable(1 << 30);
|
||
const reference = new Set<string>();
|
||
let seed = 42;
|
||
const rnd = () => {
|
||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||
return seed / 0x7fffffff;
|
||
};
|
||
for (let i = 0; i < 5000; i++) {
|
||
const k = `k${Math.floor(rnd() * 800)}`;
|
||
if (rnd() < 0.3) {
|
||
reference.delete(k);
|
||
mem.delete(k);
|
||
} else {
|
||
reference.add(k);
|
||
mem.put(k, { v: i });
|
||
}
|
||
}
|
||
const entries = mem.getAllEntries();
|
||
// 有序
|
||
for (let i = 1; i < entries.length; i++) {
|
||
expect(entries[i][0] > entries[i - 1][0]).toBe(true);
|
||
}
|
||
// 与引用集合完全一致(无丢失/无残留)
|
||
expect(entries.map(([k]) => k)).toEqual([...reference].sort());
|
||
expect(mem.getEntryCount()).toBe(reference.size);
|
||
});
|
||
|
||
it('onUpdate CASCADE:更新父表主键级联更新子表外键', async () => {
|
||
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
|
||
await db.init();
|
||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||
await db.defineTable('orders', {
|
||
id: { type: 'string', primaryKey: true },
|
||
user_id: { type: 'string', references: 'users.id', onUpdate: 'CASCADE' },
|
||
});
|
||
await db.table('users').insert({ id: '1' });
|
||
await db.table('orders').insert({ id: 'o1', user_id: '1' });
|
||
// 更新父表主键 1 → 2
|
||
await db.table('users').update({ id: '2' }).where({ id: '1' }).execute();
|
||
const orders = await db.table('orders').select().execute();
|
||
expect(orders[0].user_id).toBe('2');
|
||
// 旧 id 不可再被引用
|
||
expect(await db.table('orders').count({ user_id: '1' })).toBe(0);
|
||
await db.close();
|
||
});
|
||
|
||
it('onUpdate RESTRICT:存在引用行时禁止更新主键', async () => {
|
||
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
|
||
await db.init();
|
||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||
await db.defineTable('orders', {
|
||
id: { type: 'string', primaryKey: true },
|
||
user_id: { type: 'string', references: 'users.id', onUpdate: 'RESTRICT' },
|
||
});
|
||
await db.table('users').insert({ id: '1' });
|
||
await db.table('orders').insert({ id: 'o1', user_id: '1' });
|
||
await expect(
|
||
db.table('users').update({ id: '2' }).where({ id: '1' }).execute(),
|
||
).rejects.toThrow();
|
||
// 主键未被修改
|
||
const users = await db.table('users').select().execute();
|
||
expect(users[0].id).toBe('1');
|
||
await db.close();
|
||
});
|
||
|
||
it('onUpdate CASCADE:更新父表主键级联更新子表外键(Aria)', async () => {
|
||
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||
await db.init();
|
||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||
await db.defineTable('orders', {
|
||
id: { type: 'string', primaryKey: true },
|
||
user_id: { type: 'string', references: 'users.id', onUpdate: 'CASCADE' },
|
||
});
|
||
await db.table('users').insert({ id: '1' });
|
||
await db.table('orders').insert({ id: 'o1', user_id: '1' });
|
||
await db.table('users').update({ id: '2' }).where({ id: '1' }).execute();
|
||
const orders = await db.table('orders').select().execute();
|
||
expect(orders[0].user_id).toBe('2');
|
||
expect(await db.table('orders').count({ user_id: '1' })).toBe(0);
|
||
await db.close();
|
||
});
|
||
|
||
it('Aria 事务中 DDL 显式拒绝(NOT_SUPPORTED)而非静默不一致', async () => {
|
||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||
await engine.open(uniqueDB(), 1);
|
||
await engine.beginTransaction();
|
||
await expect(
|
||
engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } })),
|
||
).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||
await engine.rollbackTransaction();
|
||
// 表未创建
|
||
expect(await engine.hasTable('t')).toBe(false);
|
||
await engine.close();
|
||
});
|
||
});
|