fix: v0.7.2 语句级原子性 + 事务 DDL 拒绝 + 约束/绑定硬化 — 6 项修复 + 43 回归 + CI 重型套件串行
- UPDATE 语句级部分提交(P1,四引擎):两阶段全量预检后执行,批内唯一互查, 任何一行失败整句不执行(aria 场景 WAL 与内存不再错位) - 事务内 ALTER/CREATE INDEX/DROP INDEX 残留(P1):Memory/KVStore 显式拒绝 (对齐 Aria),createTable/dropTable 保持可回滚 - SET NULL 级联绕过 required 约束(P1):预检阶段整体拒绝 FOREIGN_KEY_VIOLATION - bindParameters 注释误判(P2):行注释/块注释中的 ? 与引号不再参与绑定 - 未闭合字符串静默接受 → lexer 抛 PARSE_ERROR;未知 where 操作符抛 QUERY_ERROR - UPDATE undefined 覆盖列值 → 语义化为不更新(null 仍置空) - Hybrid 写穿透非原子(P1):磁盘失败自动重载内存对齐磁盘再抛原错误 - CI:Run tests 拆常规并行 + 重型串行(runInBand),重型测试超时余量提升, 性能护栏 kv 120→240s / opfs 150→300s(仍拦截悬崖回归) - 测试 1155 → 1198(74 套件),覆盖率 89.82% 保持
This commit is contained in:
@@ -66,7 +66,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
||||
expect(viaIdx.length).toBe(5000);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('小批量高频写入(每批 50 行)触发极端 freeze 竞态', async () => {
|
||||
const engine = new AriaEngine({
|
||||
@@ -89,7 +89,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
||||
expect(await engine.find('items', { table: 'items', where: { tag: 'a' } })).toHaveLength(Math.ceil(TOTAL / 3));
|
||||
expect(await engine.find('items', { table: 'items', where: { tag: 'b' } })).toHaveLength(TOTAL - Math.ceil(TOTAL / 3));
|
||||
await engine.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('多索引列同时写入:每列索引都完整', async () => {
|
||||
const engine = new AriaEngine({
|
||||
@@ -119,5 +119,5 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 0 } })).toHaveLength(TOTAL / 5);
|
||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 4 } })).toHaveLength(TOTAL / 5);
|
||||
await engine.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('AriaEngine + kv 后端(storageBackend: kv)', () => {
|
||||
await e2.open(dbName, 1);
|
||||
expect(await e2.count('big')).toBe(1000);
|
||||
await e2.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
||||
await assertIndexes(engine2, 'big', 30000);
|
||||
expect((await engine2.find('big', { table: 'big', where: { id: 'k50' } }))[0].name).toBe('Tx50');
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('opfs × 3 万行 + 双索引 + 崩溃恢复', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -110,7 +110,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
||||
const engine2 = await reopen(dbName, coreConfig('opfs'));
|
||||
await assertIndexes(engine2, 'big', 30000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('memory × 2 万行 + 双索引 + 事务回滚', async () => {
|
||||
const engine = new AriaEngine(coreConfig('memory') as never);
|
||||
@@ -126,7 +126,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
||||
expect(await engine.find('big', { table: 'big', where: { tag: 't9' } })).toHaveLength(2001);
|
||||
expect(await engine.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(1999);
|
||||
await engine.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
});
|
||||
|
||||
describe('生产矩阵审计 — 特性组合', () => {
|
||||
@@ -149,7 +149,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
// 错误密码必须拒绝打开
|
||||
const bad = new AriaEngine({ ...cfg, encryption: { password: 'wrong' } } as never);
|
||||
await expect(bad.open(dbName, 1)).rejects.toThrow();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('kv × pageStorage:false(整 value)→ 2 万行 + 崩溃恢复', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -167,7 +167,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('opfs × 加密 × 压缩 × 页面化全开 → 2 万行 + 崩溃恢复', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -186,7 +186,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('opfs × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -204,7 +204,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('kv × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -221,7 +221,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('kv × walSyncMode:batch → 优雅关闭后重开完整(崩溃保底已 checkpoint 数据)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -238,7 +238,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('opfs × walSyncMode:none → 优雅关闭后重开完整(checkpoint 兜底)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -255,7 +255,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
||||
const engine2 = await reopen(dbName, cfg);
|
||||
await assertIndexes(engine2, 'big', 20000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
});
|
||||
|
||||
describe('生产矩阵审计 — 主键变更索引一致性(三后端)', () => {
|
||||
@@ -270,7 +270,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
||||
expect(await engine.find('big', { table: 'big', where: { tag: 'x' } })).toHaveLength(1);
|
||||
expect(await engine.find('big', { table: 'big', where: { id: 'a1' } })).toHaveLength(0);
|
||||
await engine.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
|
||||
it('opfs × 主键变更 + 崩溃恢复:索引一致', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -292,7 +292,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: `t${t}` } })).toHaveLength(500);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('memory × 级联删除 + 索引清理', async () => {
|
||||
const engine = new AriaEngine({ storageBackend: 'memory', memtableSizeThreshold: 256 * 1024 } as never);
|
||||
@@ -313,5 +313,5 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
||||
expect(await engine.count('child')).toBe(1999);
|
||||
expect(await engine.find('child', { table: 'child', where: { pid: 'p5' } })).toHaveLength(0);
|
||||
await engine.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
// 索引(重启重建)
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't5' } })).toHaveLength(5000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('高频更新/删除(Compaction 回收墓碑)→ 重启后一致', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -127,7 +127,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(count);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('大 value(100KB × 50)压缩写入/恢复完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -161,7 +161,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
const one = await engine2.find('docs', { table: 'docs', where: { id: 'd25' } });
|
||||
expect((one[0].body as string).length).toBe(chunk.length);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('混合操作 + 崩溃:已确认写入零丢失(20000 操作)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -217,7 +217,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
expect(rows[0].val).toBe(expected.val);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('大量删除(90% 行)+ Compaction → 重启无残留(墓碑清理)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -253,7 +253,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
const all = await engine2.find('big', { table: 'big' });
|
||||
expect(all.every((r) => Number(String(r.id).slice(1)) < 1000)).toBe(true);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('kv 后端 5 万行(页面化路径):写入 → 崩溃 → 恢复完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -289,7 +289,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
expect(await engine2.count('big')).toBe(TOTAL);
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't3' } })).toHaveLength(5000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('10 万行 kv 后端(含索引):完整查询 + 崩溃恢复(v0.6.1-perf 回归)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -314,10 +314,11 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
}
|
||||
const insertMs = Date.now() - t0;
|
||||
// 性能护栏:修复前 353s(batch 32 起每批 8~11s 性能悬崖),
|
||||
// 修复后本机 ~30s。CI(debian runner + maxWorkers=2 并行重型测试)慢 2~3 倍,
|
||||
// 护栏放宽到 120s —— 仍能拦截性能悬崖回归(353s >> 120s),不误报健康慢环境。
|
||||
// 修复后本机 ~12.5s。CI(debian runner 慢 2~3 倍、重型套件串行)下
|
||||
// 健康耗时约 30~80s;护栏放宽到 240s —— 仍能拦截性能悬崖回归(353s >> 240s),
|
||||
// 不误报健康慢环境。
|
||||
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
|
||||
expect(insertMs).toBeLessThan(120000);
|
||||
expect(insertMs).toBeLessThan(240000);
|
||||
expect(await engine.count('big')).toBe(TOTAL);
|
||||
|
||||
// 全部 10 个 tag 索引查询完整
|
||||
@@ -339,7 +340,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
expect(await engine2.count('big')).toBe(TOTAL);
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(10000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
|
||||
it('10 万行 opfs 后端(含索引):完整查询(v0.6.1-perf 回归)', async () => {
|
||||
const engine = new AriaEngine({
|
||||
@@ -362,14 +363,14 @@ describe('AriaEngine — 生产负载验证', () => {
|
||||
await engine.insert('big', rows);
|
||||
}
|
||||
const insertMs = Date.now() - t0;
|
||||
// 同上:CI 慢环境护栏放宽(本机 ~25s)
|
||||
// 同上:CI 慢环境护栏放宽(本机 ~25s;悬崖回归仍会被拦截)
|
||||
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
|
||||
expect(insertMs).toBeLessThan(150000);
|
||||
expect(insertMs).toBeLessThan(300000);
|
||||
expect(await engine.count('big')).toBe(TOTAL);
|
||||
for (let t = 0; t < 10; t++) {
|
||||
const viaIdx = await engine.find('big', { table: 'big', where: { tag: `t${t}` } });
|
||||
expect(viaIdx.length).toBe(10000);
|
||||
}
|
||||
await engine.close();
|
||||
}, 180000);
|
||||
}, 600000);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
||||
expect(Number(rows[0].val)).toBe(Number(id.slice(1)));
|
||||
}
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('5 万混合操作 + 崩溃模拟(不 checkpoint)→ 重开全部已确认写入可见', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -112,7 +112,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
||||
expect(rows[0].val).toBe(val);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
}, 600000);
|
||||
|
||||
it('多次 checkpoint 循环(500 次)数据不丢', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -135,5 +135,5 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
||||
const last = await engine2.find('t', { table: 't', where: { id: 'k499' } });
|
||||
expect(last[0].v).toBe(499);
|
||||
await engine2.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('KVStore — 边界与故障', () => {
|
||||
expect(kv2.size()).toBe(10000);
|
||||
expect(dec(await kv2.get('k9999'))).toBe('v9999');
|
||||
await kv2.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
|
||||
it('写入失败后 KVStore 继续可用(错误不污染后续操作)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
@@ -322,7 +322,7 @@ describe('KVStoreEngine — 异常与一致性', () => {
|
||||
const remaining = await e2.find('bulk', { table: 'bulk', where: { id: { $gte: 3000 } } });
|
||||
expect(remaining).toHaveLength(1000);
|
||||
await e2.close();
|
||||
}, 60000);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeEach(() => { installOPFSMock(new Map()); });
|
||||
|
||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||
test('VERSION 常量为当前版本(0.6.0)', () => {
|
||||
expect(VERSION).toBe('0.7.1');
|
||||
expect(VERSION).toBe('0.7.2');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
||||
|
||||
describe('[v0.3.3] 端到端', () => {
|
||||
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||
expect(VERSION).toBe('0.7.1');
|
||||
expect(VERSION).toBe('0.7.2');
|
||||
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* v0.7.2 修复回归测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - UPDATE 语句级原子性(多行更新撞唯一约束 → 整句拒绝,四引擎)
|
||||
* - 批内唯一互查(两行同时更新到同一新唯一值 → 整句拒绝)
|
||||
* - 事务内 DDL 拒绝(ALTER TABLE / CREATE INDEX / DROP INDEX,Memory/KVStore 对齐 Aria)
|
||||
* - SET NULL 级联撞 required 列 → FOREIGN_KEY_VIOLATION(delete 与 update 主键变更,Memory/Aria)
|
||||
* - bindParameters 注释感知(行注释 / 块注释中的 ? 与引号不参与绑定)
|
||||
* - 未闭合字符串字面量 → PARSE_ERROR
|
||||
* - 未知 where 操作符 → QUERY_ERROR(不再静默全匹配)
|
||||
* - UPDATE undefined 值语义化(保留旧值,不再删除列)
|
||||
* - Hybrid 磁盘写失败补偿(内存对齐磁盘状态)
|
||||
*/
|
||||
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { parse } from '../src/sql/parser';
|
||||
import { bindParameters } from '../src/sql/params';
|
||||
import { matchWhere } from '../src/query/where-matcher';
|
||||
import { MemoryEngine } from '../src/engine/memory';
|
||||
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
||||
import { HybridEngine } from '../src/hybrid/index';
|
||||
import { createSchema } from '../src/table/schema';
|
||||
|
||||
function uniqueDB(): string {
|
||||
return `v072-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// UPDATE 语句级原子性
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — UPDATE 语句级原子性', () => {
|
||||
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
|
||||
|
||||
for (const mode of modes) {
|
||||
it(`${mode}: 多行更新撞唯一约束 → 整句拒绝、数据不变`, async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: 'a', email: 'a@x.com' },
|
||||
{ id: 'b', email: 'b@x.com' },
|
||||
{ id: 'c', email: 'c@x.com' },
|
||||
]);
|
||||
await db.table('t').update({ email: 'z@x.com' }).where({ id: 'a' }).execute();
|
||||
|
||||
// 一条语句同时把 b、c 改成 a 已占用的 z@x.com:b 成功改到一半,c 撞唯一
|
||||
await expect(
|
||||
db.table('t').update({ email: 'z@x.com' }).where({ id: 'c' }).execute(),
|
||||
).rejects.toThrow(); // 单行冲突
|
||||
|
||||
// 关键场景:无 where 全表更新到同一值 → 第二行即冲突
|
||||
await expect(
|
||||
db.table('t').update({ email: 'y@x.com' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||
|
||||
// 整句拒绝:任何行都不该被改成 y@x.com
|
||||
const rows = await db.query('SELECT id, email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([
|
||||
{ id: 'a', email: 'z@x.com' },
|
||||
{ id: 'b', email: 'b@x.com' },
|
||||
{ id: 'c', email: 'c@x.com' },
|
||||
]);
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
|
||||
it('memory: 校验失败(required 置 null)也整句拒绝', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: '1', name: 'A' },
|
||||
{ id: '2', name: 'B' },
|
||||
]);
|
||||
await expect(
|
||||
db.table('t').update({ name: null }).execute(),
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
|
||||
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1', name: 'A' }, { id: '2', name: 'B' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('aria: 主键变更撞已有主键整句拒绝(含 WAL 一致性)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
n: { type: 'number' },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: 'a', n: 1 }, { id: 'b', n: 2 }, { id: 'c', n: 3 },
|
||||
]);
|
||||
// a → c 撞已有 c;且 d 行的更新(同语句)也不得生效
|
||||
await expect(
|
||||
db.table('t').update({ id: 'c' }).where({ id: 'a' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'DUPLICATE_KEY' });
|
||||
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// 批内唯一互查
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — 批内唯一互查', () => {
|
||||
it('两行同时更新到同一新唯一值 → 整句拒绝(memory)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: 'a', email: 'a@x.com' },
|
||||
{ id: 'b', email: 'b@x.com' },
|
||||
]);
|
||||
await expect(
|
||||
db.table('t').update({ email: 'z@x.com' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('两行同时更新到同一新唯一值 → 整句拒绝(aria)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: 'a', email: 'a@x.com' },
|
||||
{ id: 'b', email: 'b@x.com' },
|
||||
]);
|
||||
await expect(
|
||||
db.table('t').update({ email: 'z@x.com' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('唯一值不变时更新自身不误报(回归)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true },
|
||||
name: { type: 'string' },
|
||||
});
|
||||
await db.table('t').insertMany([
|
||||
{ id: 'a', email: 'a@x.com', name: 'A' },
|
||||
]);
|
||||
const n = await db.table('t').update({ name: 'A2', email: 'a@x.com' }).execute();
|
||||
expect(n).toBe(1);
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0].name).toBe('A2');
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// 事务内 DDL 拒绝
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — 事务内 DDL 拒绝(三引擎一致)', () => {
|
||||
it('memory: 事务内 ALTER TABLE 抛 NOT_SUPPORTED,事务可继续提交', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, a: { type: 'number' } });
|
||||
await db.query('BEGIN');
|
||||
await db.query("INSERT INTO t VALUES ('1', 10)");
|
||||
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await db.query('COMMIT');
|
||||
const schema = await db.getEngine().getTableSchema('t');
|
||||
expect('b' in (schema!.columns)).toBe(false);
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1', a: 10 }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('memory: ROLLBACK 后无 DDL 残留', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query('BEGIN');
|
||||
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await db.query('ROLLBACK');
|
||||
const schema = await db.getEngine().getTableSchema('t');
|
||||
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('kv: 事务内 ALTER TABLE 抛 NOT_SUPPORTED', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query('BEGIN');
|
||||
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await db.query('ROLLBACK');
|
||||
const schema = await db.getEngine().getTableSchema('t');
|
||||
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('kv: 事务内 CREATE INDEX / DROP INDEX 抛 NOT_SUPPORTED', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.query('BEGIN');
|
||||
await expect(db.query('CREATE INDEX idx_v ON t (v)')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await db.query('ROLLBACK');
|
||||
const schema = await db.getEngine().getTableSchema('t');
|
||||
expect(schema!.columns.v.index).toBeFalsy();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('memory: 事务内 createTable/dropTable 仍可回滚(既有行为保持)', async () => {
|
||||
const eng = new MemoryEngine();
|
||||
await eng.open(uniqueDB(), 1);
|
||||
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||||
await eng.beginTransaction();
|
||||
await eng.createTable(createSchema('tmp', { id: { type: 'string', primaryKey: true } }));
|
||||
await eng.rollbackTransaction();
|
||||
expect(await eng.hasTable('tmp')).toBe(false);
|
||||
expect(await eng.hasTable('t')).toBe(true);
|
||||
await eng.close();
|
||||
});
|
||||
|
||||
it('hybrid: 事务内 ALTER TABLE 抛 NOT_SUPPORTED(内存层拒绝)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query('BEGIN');
|
||||
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||
await db.query('ROLLBACK');
|
||||
const schema = await db.getEngine().getTableSchema('t');
|
||||
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// SET NULL 级联撞 required 列
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — SET NULL 级联撞 required 列', () => {
|
||||
it('memory: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||
await db.defineTable('child', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
|
||||
});
|
||||
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||
// 整体拒绝:父行仍在
|
||||
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('aria: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||
await db.defineTable('child', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
|
||||
});
|
||||
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('memory: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||
await db.defineTable('child', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
|
||||
});
|
||||
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||
await expect(
|
||||
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: 'p1' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('aria: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||
await db.defineTable('child', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
|
||||
});
|
||||
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||
await expect(
|
||||
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
|
||||
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: 'p1' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('SET NULL 到非 required 列仍正常(回归)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||
await db.defineTable('child', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
pid: { type: 'string', references: 'parent.id', onDelete: 'SET NULL' },
|
||||
});
|
||||
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||
await db.query("DELETE FROM parent WHERE id = 'p1'");
|
||||
const rows = await db.query('SELECT * FROM child') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: 'c1', pid: null }]);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// bindParameters 注释感知
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — bindParameters 注释感知', () => {
|
||||
it('行注释中的 ? 不参与占位符', () => {
|
||||
const sql = "SELECT * FROM t -- comment with ? inside\n WHERE id = ?";
|
||||
const bound = bindParameters(sql, ['1']);
|
||||
expect(bound).toContain('comment with ? inside');
|
||||
expect(bound).toContain("WHERE id = '1'");
|
||||
});
|
||||
|
||||
it('块注释中的 ? 不参与占位符', () => {
|
||||
const sql = 'SELECT * FROM t /* c ? c */ WHERE id = ?';
|
||||
const bound = bindParameters(sql, ['1']);
|
||||
expect(bound).toContain('/* c ? c */');
|
||||
expect(bound).toContain("WHERE id = '1'");
|
||||
});
|
||||
|
||||
it("行注释中的单引号不触发未闭合字符串(-- don't)", () => {
|
||||
const sql = "SELECT * FROM t WHERE id = ? -- don't touch";
|
||||
const bound = bindParameters(sql, ['1']);
|
||||
expect(bound).toContain("-- don't touch");
|
||||
});
|
||||
|
||||
it('块注释中的单引号不触发未闭合字符串', () => {
|
||||
const sql = "SELECT * FROM t /* it's fine */ WHERE id = ?";
|
||||
const bound = bindParameters(sql, ['1']);
|
||||
expect(bound).toContain("/* it's fine */");
|
||||
});
|
||||
|
||||
it('占位符数量按注释外计算(注释里两个 ? 不报参数不足)', () => {
|
||||
const sql = "SELECT * FROM t WHERE id = ? /* ? ? */ AND x = ? -- ?\n";
|
||||
const bound = bindParameters(sql, ['1', '2']);
|
||||
expect(bound).toContain("'1'");
|
||||
expect(bound).toContain("'2'");
|
||||
});
|
||||
|
||||
it('SQL 注入载荷仍被安全编码(回归)', () => {
|
||||
const sql = 'SELECT * FROM t WHERE name = ?';
|
||||
const bound = bindParameters(sql, ["O'Brien'; DROP TABLE t; --"]);
|
||||
expect(bound).toContain("'O''Brien''; DROP TABLE t; --'");
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// 未闭合字符串字面量
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — 未闭合字符串字面量', () => {
|
||||
it("SELECT 'abc → PARSE_ERROR", () => {
|
||||
expect(() => parse("SELECT 'abc")).toThrowError(/Unterminated string/);
|
||||
});
|
||||
|
||||
it('query API 同样抛 PARSE_ERROR', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await expect(db.query("SELECT 'abc")).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// 未知 where 操作符
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — 未知 where 操作符抛错', () => {
|
||||
it('$between 未实现 → QUERY_ERROR(不再静默全匹配)', () => {
|
||||
expect(() => matchWhere({ age: 3 }, { age: { $between: [1, 2] } } as never)).toThrowError(/Unknown where operator/);
|
||||
});
|
||||
|
||||
it('query API 路径同样报错', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query("INSERT INTO t VALUES ('1')");
|
||||
await expect(
|
||||
db.getEngine().find('t', { table: 't', where: { id: { $betwen: '1' } } as never }),
|
||||
).rejects.toThrowError(/Unknown where operator/);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// UPDATE undefined 语义化
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — UPDATE undefined 语义化', () => {
|
||||
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
|
||||
|
||||
for (const mode of modes) {
|
||||
it(`${mode}: update({ col: undefined }) 保留旧值`, async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tag: { type: 'string', default: 'def' },
|
||||
});
|
||||
await db.query("INSERT INTO t VALUES ('1', 'x')");
|
||||
await db.table('t').update({ tag: undefined }).where({ id: '1' }).execute();
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0]).toEqual({ id: '1', tag: 'x' });
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it(`${mode}: update({ col: null }) 仍显式置空`, async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tag: { type: 'string' },
|
||||
});
|
||||
await db.query("INSERT INTO t VALUES ('1', 'x')");
|
||||
await db.table('t').update({ tag: null }).where({ id: '1' }).execute();
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0]).toEqual({ id: '1', tag: null });
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
|
||||
it('主键 undefined 不触发主键变更路径(kv 引擎)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 1)");
|
||||
const n = await db.table('t').update({ id: undefined, n: 2 }).where({ id: '1' }).execute();
|
||||
expect(n).toBe(1);
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0]).toEqual({ id: '1', n: 2 });
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Hybrid 磁盘写失败补偿
|
||||
// ===================================================================
|
||||
|
||||
describe('v0.7.2 — Hybrid 磁盘写失败补偿', () => {
|
||||
it('insert 磁盘失败 → 错误抛出 + 内存与磁盘一致', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query("INSERT INTO t VALUES ('1')");
|
||||
|
||||
const eng = db.getEngine() as HybridEngine;
|
||||
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||
const origInsert = disk.insert.bind(disk);
|
||||
let fail = true;
|
||||
disk.insert = async (table: string, rows: Record<string, unknown>[]) => {
|
||||
if (fail) throw new Error('disk down');
|
||||
return origInsert(table, rows);
|
||||
};
|
||||
|
||||
await expect(db.query("INSERT INTO t VALUES ('2')")).rejects.toThrow('disk down');
|
||||
// 内存已对齐磁盘:'2' 不在(补偿后重载)
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1' }]);
|
||||
|
||||
// 恢复后正常写入
|
||||
fail = false;
|
||||
await db.query("INSERT INTO t VALUES ('3')");
|
||||
const rows2 = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows2).toEqual([{ id: '1' }, { id: '3' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('update 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 1)");
|
||||
|
||||
const eng = db.getEngine() as HybridEngine;
|
||||
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||
disk.update = async () => { throw new Error('disk down'); };
|
||||
|
||||
await expect(db.table('t').update({ n: 99 }).where({ id: '1' }).execute()).rejects.toThrow('disk down');
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1', n: 1 }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('delete 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query("INSERT INTO t VALUES ('1')");
|
||||
|
||||
const eng = db.getEngine() as HybridEngine;
|
||||
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||
disk.delete = async () => { throw new Error('disk down'); };
|
||||
|
||||
await expect(db.table('t').delete().where({ id: '1' }).execute()).rejects.toThrow('disk down');
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('事务内写失败仍由快照回滚(补偿不影响事务路径)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query("INSERT INTO t VALUES ('1')");
|
||||
await expect(db.transaction(async (trx) => {
|
||||
await trx.table('t').insert({ id: '2' });
|
||||
throw new Error('tx fail');
|
||||
})).rejects.toThrow('tx fail');
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ id: '1' }]);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
interface IStorageEngineMock {
|
||||
insert: (table: string, rows: Record<string, unknown>[]) => Promise<string[]>;
|
||||
update: (table: string, query: never, updates: Record<string, unknown>) => Promise<number>;
|
||||
delete: (table: string, query: never) => Promise<number>;
|
||||
}
|
||||
|
||||
// 确保 KVStoreEngine 也导出(引用用)
|
||||
void KVStoreEngine;
|
||||
Reference in New Issue
Block a user