fix(A1/A2): UPDATE 批内主键碰撞丢行 + ALTER ADD UNIQUE 形同虚设(四引擎根治)

A1 UPDATE 批内新主键碰撞 → 静默丢行
  阶段 1 只用 `table.has(newPk)` 与**语句执行前**的表比对,看不到同一语句内其它行
  即将写入的新主键;阶段 2 逐行写同一个 key 互相覆盖。
  实测 `UPDATE t SET id='X'`(匹配 3 行)返回 affected=3,表中只剩 1 行。
  INSERT 路径在 v0.7.3 已做批内 Set 互查,UPDATE 漏了 —— 典型的"同类修复只打一半"。

  根治:MemoryEngine 与 AriaEngine 的阶段 1 增加批内新主键 Set 互查,
  任一行撞车即整体拒绝(DUPLICATE_KEY),不写入任何一行。
  保守语义说明:这也会拒绝"两行互换主键"(A:x→y, B:y→x,最终状态合法)——
  与既有的"唯一值交换更新保守拒绝"一致,宁可显式报错也不静默丢行。

A2 ALTER ADD COLUMN ... UNIQUE 形同虚设
  只写 schema 不建索引桶(Memory)/索引 LSM(Aria),而唯一性预检完全依赖索引
  (`tableIndexes.get(col)` 缺失即整段跳过)—— 重复值可任意写入,四个引擎全部接受。
  Memory 侧后果更严重:重启时 createTable 依 schema 建桶、回灌第二行触发
  UNIQUE_VIOLATION,而该异常被 KVStoreEngine.open 的 catch 吞掉 → **行静默消失**。

  根治:
  - MemoryEngine.alterTable ADD:index/unique 列建立索引桶并回填;回填前做存量
    唯一性校验,重复则回滚本次 ALTER(删列 + 删桶)并抛 UNIQUE_VIOLATION。
  - AriaEngine.alterTable ADD:复用既有 createIndex(它已实现"回填 + 存量唯一性
    校验 + 失败原子清理",是 v0.6.2/v0.7.3 的成果)—— 不重复实现以免再次漂移。
  - KVStore/Hybrid 通过 Memory 引擎自动获得同等语义。

新增 tests/v080-atomicity.test.ts(4 用例,四引擎参数化)。
This commit is contained in:
thzxx
2026-09-14 22:02:30 +08:00
parent 7c7ecb8b1d
commit 765df805eb
3 changed files with 178 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* v0.8.0 回归 —— 语句级原子性与 ALTER 约束(四引擎一致性)
*
* A1 UPDATE 批内主键碰撞静默丢行
* 阶段 1 只用 `table.has(newPk)` 与**语句执行前**的表比对,看不到同一语句内其它行
* 即将写入的新主键;阶段 2 逐行写同一个 key 互相覆盖。实测 `UPDATE t SET id='X'`
* (匹配 3 行)返回 affected=3,而表中只剩 1 行。
* INSERT 路径在 v0.7.3 已做批内相互检查,UPDATE 漏了。
*
* A2 ALTER ADD COLUMN ... UNIQUE 形同虚设
* 只写 schema 而不建索引桶/索引 LSM,而唯一性预检完全依赖索引 —— 重复值可任意写入。
* Memory 侧后果更严重:重启时 createTable 依 schema 建桶、回灌第二行触发
* UNIQUE_VIOLATION,而该异常被引擎 open 路径吞掉 → **行静默消失**(实测 2 行变 1 行)。
*
* 两者都在四个引擎上验证:memory / disk / hybrid / aria。
*/
import { MetonaSqlark } from '../src/core';
const MODES: Array<[string, Record<string, unknown>]> = [
['memory', {}],
['disk', {}],
['hybrid', {}],
['aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A1 UPDATE 批内主键碰撞', () => {
test('多行改到同一新主键 → 整体拒绝且不丢行', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a1-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('a',1),('b',2),('c',3)");
await expect(db.query("UPDATE t SET id = 'X'"))
.rejects.toMatchObject({ code: 'DUPLICATE_KEY' });
const rows = await db.query('SELECT * FROM t ORDER BY id') as Array<{ id: string }>;
// 关键:三行必须全部保留(此前会被逐行覆盖成 1 行)
expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']);
await db.close();
}
});
test('合法的单行主键变更仍然可用', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a1b-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('a',1),('b',2)");
const affected = await db.query("UPDATE t SET id = 'z' WHERE id = 'a'");
expect(affected).toBe(1);
const rows = await db.query('SELECT * FROM t ORDER BY id') as Array<{ id: string }>;
expect(rows.map((r) => r.id)).toEqual(['b', 'z']);
await db.close();
}
});
});
describe('[v0.8.0] A2 ALTER ADD UNIQUE 约束', () => {
test('ALTER 添加的 UNIQUE 必须真正生效', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a2-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
await db.query('ALTER TABLE t ADD COLUMN email STRING UNIQUE');
await db.query("UPDATE t SET email = 'a@x.com' WHERE id = '1'");
// 重复值必须被拒绝(此前四个引擎全部静默接受)
await expect(db.query("INSERT INTO t VALUES ('2', 'a@x.com')"))
.rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>;
expect(rows[0].c).toBe(1);
await db.close();
}
});
test('CREATE UNIQUE INDEX 存量重复必须报错,且数据不被破坏', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a2r-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, email: { type: 'string' } });
await db.query("INSERT INTO t VALUES ('1','dup@x'),('2','dup@x')");
await expect(db.query('CREATE UNIQUE INDEX uq ON t (email)'))
.rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
// 失败不得破坏既有数据
const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>;
expect(rows[0].c).toBe(2);
await db.close();
}
});
});