工作流 C-1 / C-3 前半 + 测试代码类型检查。 【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts - TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入 (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃) - 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现 导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness) - 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码 与 entry.content.subarray 恒等分支) - FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close() (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相) - 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组) 【覆盖率口径】jest.config.cjs - 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体 排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由 - 新增 coverageThreshold 门禁(此前完全不存在) - 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43% - 修正 testMatch 使 tests/helpers 下的测试可被发现 【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests - 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏) - 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode 以断言收窄替代 as any - 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码) - parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例, 当前红灯,对应总账第 11 项,将在工作流 A 修复) 【版本契约】新增 tests/version-contract.test.ts - 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量 【CI 门禁】.gitea/workflows/ci.yml - lint 去掉 continue-on-error(此前永远不让 CI 变红) - 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验 - 版本 0.7.4 升至 0.8.0
270 lines
11 KiB
TypeScript
270 lines
11 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 { MemTable } from '../src/engine/aria/index/memtable';
|
||
import { MetonaSqlark } from '../src/core';
|
||
|
||
import { resetOPFSMock } from './helpers/storage-harness';
|
||
|
||
beforeEach(() => { resetOPFSMock(); });
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `prod-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
// ===================================================================
|
||
// OPFS mock(模拟真实 I/O:getFileHandle 延迟 + 写入按内容差异化耗时)
|
||
// ===================================================================
|
||
|
||
|
||
// ===================================================================
|
||
// P0: 事务与 checkpoint 冲突
|
||
// ===================================================================
|
||
|
||
describe('P0 — 事务进行中 checkpoint 不得截断 WAL', () => {
|
||
it('事务中触发 checkpoint → 崩溃恢复不丢事务数据', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'opfs',
|
||
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: 'opfs', 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: 'opfs',
|
||
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: 'opfs', 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: 'opfs', 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: 'opfs', 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();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// 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();
|
||
});
|
||
});
|