Files
MetonaSqlark/PLAN-v0.7.5.md
T

843 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MetonaSqlark v0.7.5 根治性迭代方案
> 编制日期:2026-08-15 · 对象版本:v0.7.4commit `2844a06`
> 方法:全量源码逐行通读(src 16,079 行 / tests 20,223 行 / 文档 4,349 行,共 7 个并行深度审计)+ 独立可执行探针复现
> 全文证据分为三级:**【实测】**= 本次真实运行复现;**【读码】**= 逐行代码路径确认;**【推断】**= 推理未复现
---
## 一、结论先行
**v0.7.4 不是"接近完成",而是"已经积累了系统性语义债务"。**
1304 个测试全绿、90.1% 覆盖率、lint/typecheck 干净——这些指标全部真实,但它们保护的是**已被测试钉死的旧行为**,而不是**用户实际依赖的 SQL 语义**。本次审计在每一个子系统都发现了可稳定复现的静默错误结果、静默数据丢失或崩溃(共 43 项确认缺陷,含 11 项事故级),且它们有一个共同的成因结构。
**一句话总结根因**:这个项目把"四套存储引擎 + 三条 SQL 入口"当作四个独立实现来维护,于是**任何一条关系语义都没有唯一实现**。每次审计都发现同一批 bug 出现在 3~4 个引擎里;每次修复只打在审计报告点到的那条路径上,下一版换个入口再犯一次。v0.7.2 修 UPDATE 原子性、v0.7.3 修 INSERT 原子性、v0.7.4 修写语句子查询——三个连续版本修的是同一个结构缺口的不同侧面。
**因此 v0.7.5 的正确形态不是"再来一轮修复",而是"先止血,再砍掉让 bug 必然复发的结构"。** 方案分三条并行工作流 + 一道硬门禁。
### 关键基线(本次实测,非引用文档)
| 指标 | 文档宣称 | 本次实测 | 备注 |
|---|---|---|---|
| Jest 测试 | 1304 / 76 套件 | **1304 通过 / 76 套件 / 395s** | 数字真实 |
| 行覆盖率 | "90.1% 行覆盖率" | 语句 **90.08%**;行 **92.91%**;分支 **82.92%** | 口径写错;分支率从未公布 |
| 覆盖率分母 | — | `jest.config.cjs:18``!src/**/index.ts` 排除 15 个文件 | 含 AriaEngine 主实现 2,282 行 |
| 全量覆盖率(不排除任何文件) | — | 语句 **90.66%** / 行 **93.43%** / 分支 **82.94%** | 被排除文件覆盖率并不低(Aria 96.79% |
| typecheck / lint | 干净 | **均 exit 0** | 但 CI 里 lint `continue-on-error: true` |
| 覆盖率门禁 | — | **不存在**(无 `coverageThreshold`CI 不跑 `--coverage` | 覆盖率掉到 0% 也全绿 |
| 崩溃注入 | "崩溃恢复验证" | **不存在**:单测全是 `backend.close()`(优雅停机);e2e 的 `crashPage()``page.close()` | 见 §3.3 根因 6 |
---
## 二、范围与方法
### 已完整读取的内容(无截断)
| 类别 | 内容 |
|---|---|
| 源码 | `src/` 全部 59 个文件、16,079 行 |
| 测试 | `tests/` 全部 77 个文件、20,223 行(含 e2e harness 与 mock |
| 文档 | `README.md` 479、`CHANGELOG.md` 1090(全部 81 个提交历史)、`CONTRIBUTING.md` 219、`site/*.html` 2,561 |
| 工程配置 | `package.json``jest.config.cjs``jest.setup.js``rollup.config.js``tsconfig.json``.eslintrc.json``babel.config.cjs``playwright.config.ts``.gitea/workflows/ci.yml``.npmrc``.gitignore``coverage/lcov.info` |
| Git | 全部 81 个提交的元数据与关键 commit 的 diff |
### 复现强度
自行编写并运行了 10 轮可执行探针(覆盖 core / SQL / query / 四引擎 / Aria 存储 / 并发语义),**每一轮跑完即删除,仓库最终无残留**。§4 缺陷总账中标注 **【实测】** 的条目均来自这些探针的真实输出。
### 方法边界(玥玥的坦诚声明)
- 子代理的报告存在**可验证性分级**:标注【实测】的我逐一交叉复跑过关键项;标注【读码】的我没有全部独立复现,已在总账中如实标注来源。
- 崩溃注入类缺陷(WAL 半写、介质读故障、多实例 seq 撞号)依赖**故障注入实验**,部分由子代理完成后我做了代码路径核对,**未全部独立复跑**。
- 性能数字(如 `compressLZ4` 64KB→4167ms、主键变更 1.8MB 单条日志)来自子代理实测,我核对过代码路径但未重跑基准。
- **子代理的结论我做过修正**:KVStore 审计中有两条的**机制描述**与我的独立复现不一致(详见附录 C 的"附带修正")。凡本方案与子代理报告冲突处,以本方案为准。
- `src/engine/aria/index/lsm.ts` 的深度审计在本方案定稿时**已完成**,结论已并入 §3 根因 4 与 §5 总账(见附录 E)。
---
## 三、根因分析:为什么同一个 bug 会修四次
八个根因,按"砍掉它能消除多少缺陷"排序。每个根因都给出证据链——**这不是推测,是 CHANGELOG 自己记录的历史**。
### 根因 1 ★ 关系语义没有唯一实现(最高杠杆)
同一套关系语义被独立实现了 4~5 份:
| 语义 | 实现位置 | 已发生的漂移(实测) |
|---|---|---|
| 行校验 `validateRow/checkType` | `memory.ts:691-722`(私有)· `aria/index.ts:1647-1673`(私有)· `table/schema.ts:87-202`(导出) | **memory/disk/hybrid 完全不校验 `maxLength`/`min`/`max`,仅 Aria 校验**【实测】 |
| 值相等/比较 | `where-matcher.ts``===` · 哈希连接 `String(v ?? '\0')` · 分组键 `encodeGroupKey` · `COUNT(DISTINCT)``String(v)` · Aria 索引键 `String(v)` | 5 套并存 → NULL 三值逻辑完全缺失【实测】 |
| "受影响表" | Memory 单层且跳过自引用 · KVStore `affectedTables` 传递闭包 · Aria 自有 | ON UPDATE CASCADE 在 memory 只级联一层;自引用外键 CASCADE/RESTRICT **全部静默失效**【实测】 |
| 主键取值 | `getPrimaryKey` 三份拷贝 | — |
| 事务内 DDL 语义 | aria 拒绝 CREATE/DROPmemory/disk/hybrid 允许 | dev(memory) 通过,prod(aria) 抛 `NOT_SUPPORTED`【实测】 |
| 索引维护 | **8 处调用点,纯人工纪律** | v0.3.3 / v0.6.3 / v0.7.3 **连续三个版本**都在修"某条路径忘了清/建索引" |
**这就是为什么 v0.7.2→v0.7.4 每个版本都要在 3~4 个引擎里重复修同一个 bug。**
### 根因 2 ★ SQL 语义有三条互不校验的入口
| 入口 | 实现 | 后果(实测) |
|---|---|---|
| `db.query()` | `core.ts:207-242` → executor 完整管线 | 基准 |
| `db.queryStream()` 快路径 | `core.ts:311-325` 自己推导列投影/limit/offset | `SELECT id AS x` 返回**全列原始名**`OFFSET 1 LIMIT 2` 返回 2 行而非 1 行;`LIMIT 0` 返回 1 行;`SELECT t.id` 返回 `{}`;不触发 `beforeQuery`/`afterQuery`;不尊重 `maxRowsPerQuery` |
| `QueryBuilder` | `builder.ts:105` 无 JOIN 直通 `engine.find` | 链式 `.where({a:1}).where({a:2})` **覆盖**而非 AND(返回 a=2 的行);`$subquery` 静默 0 行;事务内 JOIN **静默忽略** |
**同一个语义三条实现,没有任何机制保证三者一致。** 132 个 queryStream 测试没有一条断言"流式结果必须等于物化结果"。
### 根因 3 ★ AST 把表达式当字符串,导致文法被迫写两遍
`SelectStatement.columns: string[]``ast.ts:20`)把列引用、`col AS alias``COUNT(*)`、常量、**CASE 原文**全部混为字符串。于是 executor 用 6+ 处正则再解析一遍(`executor.ts:60/66/79/93/1027/1033/1051/1071/1079`)。
直接后果【全部实测】:
- `SELECT 1 AS one FROM t``[{}]`(数字常量投影丢失)
- `SELECT COUNT(t.v) FROM t``0`(带表前缀的聚合参数取不到键)
- `CASE WHEN a=1 THEN 'a ELSE b' ELSE 'other' END``"'a"`(不感知字符串字面量的正则切分)
- 嵌套 CASE → 解析器吞掉 `FROM` 之后全部文本,返回 1 行空对象
- `SELECT dept AS d, COUNT(*) GROUP BY dept` → 别名丢失;`SELECT v AS s ... GROUP BY dept`**整列静默消失**
### 根因 4 ★ 存储层没有单一提交点,"什么已持久"靠猜
WAL `lsn`/`currentSegment``FileManager.nextPageId`、KVStore `seq`、LSM `levels+meta` 各自是独立的内存计数器,恢复时与介质的一致性全靠推理。已发生的后果:
- FileManager 页面 id 崩溃回退 → 复用页面 → compaction 误删 → **丢 75%**v0.6.1 修)
- LSM.flush 与 compaction 并发写 meta → 产物变孤儿 → **丢 75%**v0.6.1 修)
- KVStore 快照损坏水位 bug → **静默丢数据**v0.6.1 修)
**本次逐一独立复现的三个 KVStore 事故级缺陷**(探针输出见附录 C;其中前两处的**确切机制**比子代理报告的更精确,已在 B-6 中修正):
- **损坏日志恢复后崩溃 → 已确认数据永久丢失**:`open` 遇损坏记录会 `truncateLog()` 把日志**写成 0 字节**`kvstore/index.ts:394-396`),而有效前缀(实测 r1、r2)**只存在于内存 index 里**,既不在日志也不在快照。实测首次 open 后 `logBytes=0 / snapshotBytes=0`;不 checkpoint 直接崩溃重开 → r1/r2 全部消失。
> 附带修正一条审计结论:恢复的实现是"遇首个坏记录即停止重放"`log.ts:287-298` 的 `break`**并非跳过后续记录**),这个方向本身正确;问题只在于"截断成空"而不是"截断到有效前缀"。
- **checkpoint 失败 → 调用方收到 rejection,但数据已提交且持久**:`appendRecord``medium.append` 与"内存更新 + 自动 checkpoint"分在两段(`kvstore/index.ts:334-362` vs `:385-390`),后者在 try/catch 之外。实测注入快照写失败后 `put` 抛错、内存里值仍在、重启后**值依然存在**——调用方以为写失败而执行回滚,数据却已落地(事务回滚无效)。
- **陈旧实例 checkpoint 吞掉对方已提交数据**:`seq` 是**每实例独立**计数器。实测 A、B 同时 openseq 均为 0)→ A.putA.seq=1,写入共享日志)→ B.putB.seq=**也是 1**)触发自动 checkpoint → B 用自己的空快照覆盖共享快照并把日志截断为 0 → 重启后 **fromA 丢失、fromB 存在**。根因是 KVStore 默认独占介质,而引擎层**没有给它加锁**(Web Lock 只加在 AriaEngine 一侧)。
> 附带确认一条**持久化契约缺口**`KVStore.close()` **不做 checkpoint**(只 `opQueue` 排空 + `medium.close()`)。数据的持久化完全依赖上层引擎的优雅关闭路径 —— 这是根因 5"崩溃语义靠声称"的又一处实例。
- SAVEPOINT 回滚不写补偿 WAL → 崩溃后已回滚的行**复活**【实测】
**LSM 层新增一个同类事故级缺陷**(子代理完成审计 + 我代码路径核对):
- **后台 flush 失败后,冻结数据既不入 levels 也无重试路径 → 内存成唯一副本,崩溃即丢**:`flushImmutableAsync``lsm.ts:220-261`)在 `save`/`saveMeta` 抛错时,`levels[0].unshift(meta)`253)与 `frozenMemtables` 清理(255)都不会执行 —— 数据留在 `frozenMemtables` 里"读得到",但**只有 `flush()` 里那一次入链机会**`lsm.ts:605/612` 是唯一两处入链点)。此后每次 `flush()` 又被 `lastBackgroundError` 检查(`lsm.ts:580-590`)在**所有入链动作之前**直接抛出并清空标志 → 该次 flush 完全没执行。**结果:调用方早就收到 `insert()` 成功,数据却只存在于内存,进程崩溃即永久丢失。**
> 这是我独立验证过并确认的:`frozenMemtables` 只在 `flush()/freezeMemtable` 内被写入(`lsm.ts:188`),而 `flush()` 从不遍历它重新入链(618 行的 filter 只删空表)—— 重试路径**不存在**。
> 与 KVStore 的三个缺陷同源:**"待落盘意图"没有持久化记录**(根因 4)。
- LSM 的具体成因:`sstable.ts` 的解析循环被抄成**三份**`80-100`/`145-166`/`182-201`)且越界策略不一致(点查与扫描对同一损坏文件行为不同);5 条隐式不变量(层间新鲜度、层内不重叠、索引键=块尾 key、minKey-maxKey 覆盖、bloom 参数一致)**无任何断言或属性测试**;后台任务是 fire-and-forget + 单 `boolean` 互斥(`compacting``lsm.ts:78,269-283`,跨层触发被静默丢弃);`LSM.get` 缓存未命中返回 `null` 与"确实不存在"**不可区分**`lsm.ts:734-753`),整条读路径靠"每次读前必须 prefetch"维持 —— 而 `compactLevelAsync:508-515` 的同类问题**已经修了**`get` 没修(同类问题只修一半)。
### 根因 5 ★ 崩溃语义是"声称"而非"已验证"
- 单测的"崩溃"统一是 `await engine.backend.close()``aria-matrix-audit.test.ts:93``aria-prod-load.test.ts:71``aria-wal-segment.test.ts:173` 等十余处),而 `opfs_backend.ts:39-46``close()` 明确 `await this.writeQueue` ——**这是优雅停机,把所有在途写刷完**。
- `tests/helpers/opfs-mock.ts` 是立即生效、原子、**绝不撕裂**的内存 Map,无法表达半写/部分删除失败/读故障。
- e2e 的 `crashPage()``opfs.spec.ts:32-42`)只做 `window.__ms=null; page.close()`,注释自承"Playwright 无 Page.crash 公共 API"(实际 CDP 可用)。
- 结论:**这套测试在结构上不可能发现根因 4 的任何一条。**
### 根因 6 生命周期只有布尔,没有状态机
`ready`/`opened`/`txActive`/`snapshot` 分散在 core、四个引擎、连接池、React、Vue 六处各自维护,`close/init/reload` 都非幂等且非异常安全【全部实测】:
- `MemoryEngine.close()` 不清事务快照 → 重开后 `beginTransaction` 永久 `TX_ACTIVE``rollbackTransaction` 用**关闭前的陈旧快照覆盖新会话**(数据复活)
- `close()` 后再 `init()` 不重建 BroadcastChannel → `multiTabSync` 静默失效
- `close()` 无 try/finally → engine.close 抛错时 `isReady()` 仍为 true(半关闭态),钩子已全丢
- 连接池:用户直接 `db.close()` 后同名 `connect()` 新建实例并把 refCount 置 1**任意一次 disconnect 就关闭它**
- 多标签页 reload 不检查活跃事务 → 内存快照被销毁、`txActive` 残留 → 提交时内存≠磁盘
### 根因 7 "未解析/未知"一律静默降级为 false
这是**复发型缺陷模式**,CHANGELOG 连续三个版本都在修它的不同实例:
- v0.7.3`queryStream``$subquery``matchWhere` 恒 false → 静默空结果
- v0.7.4`UPDATE/DELETE``$subquery`**静默影响 0 行**
- 本次仍存在:`WHERE t.x = t.y`(唯一可解析的列对列写法)恒 0 行;`IN (SELECT ... WHERE o.user_id = u.id)` 关联引用静默空
修法是**逐路径补丁 + 两个手写重复的递归探测器**(`core.ts:284-306``where-matcher.ts:40-62`),而不是让 matcher 对无法表达的操作数直接抛错。
同类:未知列 `SELECT bogus FROM t``[{},{},{},{}]``GROUP BY bogus` → 全表并一组;`ORDER BY bogus` → 随机顺序;未知 WHERE 操作符 → 静默全匹配。
### 根因 8 错误分类与所有权边界缺失
- 错误码散落字符串,`onError` 逐方法手写调用(`core.ts` 里 20+ 处 try/catch),Table API 路径、export/backup、migrateTo、repair、clearAll **都不触发 onError**【实测】
- **行所有权无边界**`find/findStream` 返回引擎内部行对象引用 → `rows[0].tag='HACKED'` 直接改库,memory/disk/hybrid 下**索引与行失配,该行变得不可检索**【实测】
- 而 ALTER DROP COLUMN 又**依赖**"find 返回引用"这一副作用来删列数据 —— 一个 bug 被另一个 bug 依赖
---
## 四、v0.7.5 迭代方案
### 总览:三条并行工作流 + 一道硬门禁
```
┌──────────────────────────────────────────────────────────────────┐
│ 工作流 A · 止血(阻断发布) 11 项事故级 + 18 项高优 P1 │
│ 目标:让 v0.7.5 不再产生错误结果 / 静默丢数据 / 崩溃 │
├──────────────────────────────────────────────────────────────────┤
│ 工作流 B · 根治(砍掉复发结构) 6 个结构性改造 │
│ 目标:让"同一 bug 修四次"在结构上不可能发生 │
├──────────────────────────────────────────────────────────────────┤
│ 工作流 C · 验证(让门禁真的能拦) 故障注入 + 覆盖率口径 + CI 门禁 │
│ 目标:让 A 和 B 的成果可证明、可回归 │
└──────────────────────────────────────────────────────────────────┘
▼ 硬门禁 G1~G6(§7)全部满足才允许发版
```
**排期建议**:A 与 C 可并行开始(C 先做,因为它能验证 A);B 必须在 A 完成后开始,否则会在移动的地基上重构。
---
### 工作流 A · 止血(11 项事故级,必须全修)
按"用户能感知的伤害"排序。每项给出:现象 → 根因位置 → 修复方向 → 必须新增的回归测试。
#### A1 ★ `UPDATE` 多行撞同一新主键 → 静默丢行 【实测】
- **现象**`UPDATE t SET id='X'`(匹配 2 行)返回 affected=2,表中只剩 1 行
- **根因**`memory.ts:274` 阶段 1 只与"语句执行前的表"比对,**批内新主键互查缺失**;阶段 2 逐行 `set` 互相覆盖。`primaryKey:true` 不隐含 `unique:true``checkUpdateUniqueness` 只遍历 `unique` 列,兜不住
- **对照**INSERT 路径 v0.7.3 已做批内 PK Set 互查 —— **UPDATE 漏了**
- **修复**:UPDATE 两阶段预检增加批内新主键 Set 互查(与 INSERT 同一模式);四引擎统一
- **测试**:2 行改同一新主键 → 必须抛 `DUPLICATE_KEY` 且**表内容不变**(值级断言,非仅行数)
#### A2 ★ `ALTER ADD COLUMN ... UNIQUE` 唯一约束永久失效 → 重启静默丢行 【实测】
- **现象**`ALTER TABLE t ADD COLUMN email STRING UNIQUE` → 插入两条相同 email 都不报错 → close/reopen → **只剩 1 行,无任何错误或警告**
- **根因链**`memory.ts:122-127` ALTER ADD 只写 `schema.columns` **不建索引桶**`memory.ts:316-339` 唯一预检依赖索引桶,桶缺失即整段跳过 → `kvstore_engine.ts:103-108` 重启回灌时第 2 行触发 `UNIQUE_VIOLATION`**异常被 `catch {}` 吞掉**
- **修复**:① ALTER ADD 同步建索引桶;② 重启回灌的静默 catch 必须记录并上报(`KV_REBUILD_ERROR`
- **测试**ALTER ADD UNIQUE → 重复插入必须报错;已存在重复数据的表重启后必须**报错而非静默丢行**
#### A3 ★ 崩溃后已回滚的行复活(SAVEPOINT)【实测】
- **现象**`BEGIN; INSERT a; SAVEPOINT s; INSERT b; ROLLBACK TO s; COMMIT` → 实时只剩 a,**崩溃重开变成 a+b**
- **根因**`aria/index.ts:1550-1579` 只改内存 `txnSnapshot`**WAL 无补偿记录**;恢复按 `committedTxns` 应用该事务**全部**记录
- **修复**`ROLLBACK TO` 写入补偿 WAL 记录(或在恢复时按 savepoint 边界过滤)
- **测试**:上述序列后丢弃引擎实例重开,断言实时态 == 恢复态
#### A4 ★ SAVEPOINT 跨事务泄漏 → 当前事务写入被上一事务快照替换 【实测】
- **现象**`begin; update; savepoint sp; commit``savepoints` 仍含 `sp`;新事务 `update``ROLLBACK TO sp` + `COMMIT`**新写入静默消失**(本方案验证时实测 v=2 被回退为 v=1)
- **根因**`aria/index.ts:1467-1534` 的 commit/rollback **都不清理 `this.savepoints`**`index.ts:1563``sp.snapshot` 整体替换当前快照
- **修复**commit/rollback 清空 savepoint 表;`rollbackToSavepoint` 校验 `sp.txnId === currentTxnId`(存了 txnId 却从不校验)
- **测试**:跨事务复用 savepoint 名 → 必须拒绝;当前事务写入不得丢失
#### A5 ★ `MIN`/`MAX` 20 万行栈溢出 【实测】
- **现象**`SELECT MAX(v) FROM big`20 万行同组)→ `RangeError: Maximum call stack size exceeded`
- **根因**`executor.ts:677-678``Math.min(...distinctNums)` 展开实参
- **修复**:改为单次遍历归约
- **测试**20 万行 `MIN`/`MAX`/`SUM`/`AVG` 全部正常返回
#### A6 ★ `LIMIT/OFFSET` 被应用两次 → 丢行 【实测】
- **现象**id=1..4 上 `SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1``["3"]`SQL 应为 `2,3`);`LIMIT 10 OFFSET 3``[]`
- **根因**`compiler.ts:40-41` 已把 limit/offset 放进 plan → 引擎 `memory.ts:203`/`aria/index.ts:696` 已切一次 → `executor.ts:391-393` **再切一次**
- **注**:JOIN 路径与派生表路径**正确**,同一 executor 内自相矛盾
- **修复**limit/offset 只在**一处**执行(见工作流 B-3 单管线)
- **测试**`LIMIT n OFFSET m`m>0)在 JOIN / 非 JOIN / 派生表 / UNION 四条路径结果一致
#### A7 ★ `queryStream` 与 `query` 结果不一致【实测】
| SQL | `query()` | `queryStream()` |
|---|---|---|
| `SELECT id AS x FROM t` | `[{x:'a'}]` | `[{id:'a',v:1}]`**全列+原列名** |
| `SELECT t.id FROM t` | `[{id:'a'}]` | `[{}]`**空对象** |
| `LIMIT 2 OFFSET 1` | 1 行 | 2 行 |
| `LIMIT 0` | `[]` | 1 行(Aria 却为 `[]` |
- **根因**`core.ts:316-319` 用正则自行推导投影,未复用 executor 的 `projectRow`/别名规则
- **修复**:流式路径必须复用同一套投影与 limit/offset 语义(B-3
- **测试**:**参数化契约测试**——同一 SQL 集合,断言 `queryStream` 收集到的行严格等于 `query` 结果(这是防复发的关键测试)
#### A8 ★ 行引用泄漏 → 调用方可直接改库并破坏索引 【实测】
- **现象**`rows = await db.query('SELECT * FROM t'); rows[0].tag='HACKED'` → 存储被改,且 **`tag='HACKED'``tag='x'` 两条索引查询都返回 0 行**(行不可检索)
- **引擎差异**memory/disk/hybrid 全部泄漏;**Aria 不泄漏**(走反序列化)
- **根因**`memory.ts:209/228/765` 直接返回内部行对象
- **修复**:在引擎接口定义"读返回副本"(`structuredClone` 或按 schema 深拷贝),并把依赖引用的 ALTER DROP COLUMN 改为显式 `deleteRowColumn` API
- **测试**:修改查询结果后重读必须不变;索引查询必须仍能命中
#### A9 ★ `subscribe()` 对本地写入永不触发 【实测】
- **现象**`db.subscribe('t', fn)` 后本地 `INSERT`/`UPDATE`/`DELETE`SQL 与 Table API 两条路径)**fn 调用 0 次**
- **根因**:全库仅 `core.ts:84`BroadcastChannel 外部消息)调用 `emit`;写路径只调 `broadcastChange`
- **文档冲突**`site/docs.html:667-679` 明确示例 `event.type: 'insert' | 'update' | 'delete'`README:232 宣称"订阅表变更"
- **修复**:写路径接入 `emit`(含 row 与 type),或**明确降级文档**并移除误导示例
- **测试**:两条入口的三种写操作都必须通知订阅者
#### A10 ★ `t.x = t.y` 与关联子查询静默空结果 【实测】
- **现象**`SELECT id FROM t WHERE t.x = t.y``[]`(应 3 行);`WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)``[]`(应 2 行),而同结构 EXISTS 正确
- **根因**`executor.ts:344` 把含 `$col` 的条件交给引擎,而引擎 `matchWhere``options.$col` 上下文 → 行全被滤光,`filterCorrelated` 拿到空数组;`executor.ts:1417` 执行子查询时**不传外层行**
- **注**`tests/v073-fixes.test.ts:267-278` 是这条语义的"护栏",但它只比较 `query()``queryStream()` 的**行数**(两者都是 0)→ **空断言掩盖错误**
- **修复**:统一表达式求值器,取消"是否相关"预判(B-4
- **测试**:值级断言 + 修正那条空断言护栏
#### A11 ★ `AND`/`OR` 无优先级(影响面最大,改动最小)【实测】
- **现象**`WHERE a=1 OR a=2 AND b=3` → 解析为 `(a=1 OR a=2) AND b=3`,返回 `["2","4"]`;加括号的正确写法返回 `["1","2","4"]`
- **根因**`parser.ts:780-797` 见 AND/OR 就左结合包一层,无优先级分层
- **影响**:任何"权限条件 OR 业务条件 AND 软删标记"的写法都会静默错行
- **修复**`parseCondition``parseOr → parseAnd → parseUnary` 三层
- **测试**:断言 AST 结构与值级结果,覆盖 `A OR B AND C``A AND B OR C`、嵌套括号
### 工作流 A 的第二梯队(18 项高优 P1,逐项列入 §5 总账)
其中**必须在 v0.7.5 内完成**的:
| # | 缺陷 | 实测现象 | 一句话修法 |
|---|---|---|---|
| A12 | `maxLength`/`min`/`max` 三引擎不校验 | memory/disk/hybrid 接受 `score:999`max 100 | 统一 `validateRow`B-1 |
| A13 | 自引用外键全失效 | 同表 CASCADE 不删、RESTRICT 不拦 | 用 `visited` 集合安全处理(`memory.ts:393/432/498/822` 四处跳过) |
| A14 | ON UPDATE CASCADE 只一层 | A→B→C 链改 A 主键后 C 悬空 | 递归 + 传递闭包 |
| A15 | `= NULL` / `NOT BETWEEN` 三值逻辑错 | `v = NULL` 命中 null 行;`NOT BETWEEN 1 AND 2` **排除** NULL 行 | 引入 `sqlEquals/sqlCompare` 返回 TRUE/FALSE/UNKNOWNB-2 |
| A16 | INSERT arity 不校验 | `INSERT INTO t(id) VALUES('1','LOST')` 静默丢值 | 解析期校验 arity |
| A17 | INSERT 未知列静默丢弃 | `INSERT INTO t(id, nmae)` 静默丢 typo 列 | 与 UPDATE 对齐,`COLUMN_NOT_FOUND` |
| A18 | 未知列投影返回 `{}` | `SELECT bogus FROM t``[{},{},...]` | 投影期 `COLUMN_NOT_FOUND` |
| A19 | 双引号当字符串 | `SELECT "name" FROM t` → 常量列 `{"'name'":"name"}` | 双引号 = 标识符(或明确拒绝) |
| A20 | 未闭合块注释静默接受 | `db.query("DELETE FROM t WHERE id='4' /*")` **真的删了 1 行** | 抛 `PARSE_ERROR` |
| A21 | 连续注释递归爆栈 | `' /*c*/'.repeat(20000)``RangeError` | 改循环跳过 |
| A22 | GROUP BY 别名列静默消失 | `SELECT v AS s ... GROUP BY dept``s` 整列消失 | 列绑定(B-5 |
| A23 | HAVING 不能引用未在 SELECT 的聚合 | `HAVING COUNT(*)>1``[]` | HAVING 独立求值顺序 |
| A24 | 空集聚合返回 0 而非 NULL | `SUM(v) FROM t WHERE 1=0``{s:0}` | SQL 语义修正 |
| A25 | 带表前缀的聚合参数恒 0 | `COUNT(t.v)` → 0(应 4 | 参数归一 |
| A26 | UNION 尾部 ORDER BY/LIMIT 丢失 | `UNION ... ORDER BY v LIMIT 2` → 4 行乱序 | 归属 UNION 而非右侧 SELECT |
| A27 | DISTINCT 作用在投影前 | `DISTINCT dept AS d` → 4 行(应 2) | 子句顺序编码(B-3) |
| A28 | `UPDATE t SET __proto__=...` 静默吞掉 | 返回 1 但无变化 | 全链路 `Object.create(null)`v0.7.1 只修了建表) |
| A29 | `INSERT ... SELECT``maxRowsPerQuery` 静默截断 | maxRows=2 时 4 行源只插 2 行 | 上限只作用于**用户可见结果** |
---
### 工作流 B · 根治(砍掉复发结构)
**B 的每一项都是"一次性消除一整类缺陷",而不是再修一个实例。** 建议按 B-1 → B-2 → B-3 → B-4 → B-5 → B-6 顺序推进,每项独立可交付。
#### B-1 唯一校验与规范化咽喉
- **做什么**:把 `validateRow` 收敛为 `src/table/schema.ts` 的**唯一实现**,引擎边界只接受"已校验 + 已规范化"的行;统一 JSON 安全编码(`NaN`/`Infinity` 显式拒绝或归一为 NULL
- **消除**:A12(三引擎约束失效)、NaN 落盘变 null 的引擎差异、INSERT 未知列(A17)、schema 外列静默丢弃
- **验收**:跨引擎参数化契约测试——**同一份 schema + 同一批非法数据,四个引擎必须抛同样的错误码**
#### B-2 唯一值比较与编码(SQL 三值逻辑)
- **做什么**:提供唯一的 `sqlEquals(a,b) / sqlCompare(a,b) → TRUE|FALSE|UNKNOWN` 与唯一的 `encodeValueKey(v)`;比较、`IN`/`NOT IN`、LIKE、JOIN 键、分组键、DISTINCT 键、UNION 去重**全部**走它
- **消除**A15NULL 三值)、JOIN 的 NULL 键"取决于右表有没有索引"、`COUNT(DISTINCT)``1``'1'` 合并、`encodeGroupKey``\x1f` 连接可被伪造、混合类型排序回退 `localeCompare`
- **验收**:NULL 语义矩阵测试(`= NULL`/`!= NULL`/`IN (1,NULL)`/`NOT IN`/`NOT LIKE`/`NOT(...)` 各 6 种数据形态);`1` vs `'1'` 在各引擎行为一致
#### B-3 单管线:SQL 只有一条执行路径
- **做什么**
1. `SelectQueryBuilder`/`UpdateQueryBuilder`/`DeleteQueryBuilder` **只产出 AST**,统一交给 executor(删除 `builder.ts:105` 的引擎直通)
2. `queryStream` 改为对**同一管线**的惰性消费者,禁止自建投影/limit 逻辑
3. 把 SQL 标准子句顺序编码为显式 stage 列表:`FROM → JOIN → WHERE → GROUP BY → HAVING → 聚合 → SELECT 投影 → DISTINCT → ORDER BY → LIMIT/OFFSET`
4. limit/offset/投影/排序**只在管线的一处执行**,引擎退化为"带索引的行迭代器 + WHERE 匹配"
- **消除**A6(双重 LIMIT)、A7(流式不一致)、A22GROUP BY 别名)、A27DISTINCT 顺序)、A29、事务内 JOIN 静默忽略、`maxRowsPerQuery` 只在一条路径生效、链式 `where` 覆盖
- **验收**
- **等价性契约测试**:同一 SQL 经 `query` / `queryStream` / `QueryBuilder` / 事务内**四条入口**,结果必须逐值相等(这是本方案最重要的防复发测试)
- 子句顺序 golden 测试(LIMIT/DISTINCT/GROUP BY/HAVING 组合矩阵)
#### B-4 统一表达式求值器(取消"关联性"预判)
- **做什么**:表达式求值统一携带 `outerRow` 上下文;删除 `hasCorrelatedRefs` / `stripCorrelatedExists` / `filterCorrelated` 这条特殊通道;**matcher 遇到无法表达的操作数(未解析 `$subquery`、未知操作符)必须抛 `QUERY_ERROR`,禁止返回 false**
- **消除**A10`t.x=t.y` 与关联子查询静默空)、根因 7 整类(v0.7.3/v0.7.4 修过两次的同一模式)、`WHERE EXISTS(...)` 不相关查询被误判为相关而抛 `NOT_SUPPORTED`
- **验收**:所有"未解析标记"路径的参数化测试必须断言**抛错**而非空结果
#### B-5 AST 携带绑定后的列身份
- **做什么**`columns: string[]` 升级为结构化表达式节点(列引用 / 别名 / 聚合 / CASE / 常量),并为每个输出列分配**稳定序号**GROUP BY / ORDER BY / HAVING / 别名统一按序号对齐;executor 删除 6+ 处正则再解析
- **消除**A22/A23/A24/A25/A26、`SELECT 1 AS one` 返回 `{}`、CASE 正则切分(`'a ELSE b'``"'a"`)、嵌套 CASE 吞掉 FROM、`COUNT(DISTINCT *)` 忽略 DISTINCT、`GROUP BY` 返回每组第一行的任意值
- **验收**:表达式矩阵 golden 测试(每类表达式 × 投影/别名/分组/排序/HAVING/流式)
#### B-6 存储层单一提交点(manifest
- **做什么**:引入 `__aria_manifest``magic + formatVersion + generation + CRC`,OPFS 单文件 COW 原子写),内容为 `{各 LSM 命名空间 meta 快照, WAL 起始分片与起始 LSN, pageId 水位, **待落盘冻结表意图**}`;写入顺序固定为 **数据落盘 → manifest 提交 → 才允许截断 WAL/删除旧分片**;恢复只认"最后一份 CRC 通过的 manifest";**任何引用不到的东西一律保留而非删除**;所有计数器在 manifest 中单调推进、永不复用
- **消除**:根因 4 整类 —— 页面 id 回退、flush/compaction meta 竞态、KVStore 截断清零、介质读故障误判、陈旧实例 checkpoint 覆盖、DDL 非原子、SSTable meta 损坏静默空库 + repair 删活页、WAL 中段损坏导致尾部全丢、分片 truncate 失败导致旧世代记录排在最新记录之后、**后台 flush 失败后冻结数据无重试路径(总账第 44 项)**
- **同时要做**LSM 单项改造,可与 manifest 并行):
- `LSM.get/rangeScan` 改为**自洽异步读**(未命中 `await store.load` + 用类型区分 MISS 与不存在),可一次删掉引擎层全部 7 处 `drainChain()+prefetch*`
- `flushChain` 换成**带意图记录、可重试**的串行工作队列;`compacting` 由单 boolean 改 `Set<level>`
- `flush()``lastBackgroundError` 检查移到**入链之后**(错误要报告,但不能因此跳过本次 flush)
- `sstable.ts` 三份解析循环合并为一份并统一越界策略
- **前置**:必须先有工作流 C 的故障注入后端,否则无法证明修好了
- **验收**:故障注入矩阵(撕裂写 × 崩溃点 × 后端)下"已确认写入零丢失 + 无幻影行"不变量恒成立;另外新增两条针对性断言:**注入一次 `save()` 失败后,第二次 `flush()` 必须真的落盘**(当前失败);**`limit=5` 的流式扫描生成器产出必须恰好 5 条**(当前 6 条)
**B-6 的降级选项**(若 v0.7.5 周期不够):至少完成五处止血 ——
① KVStore open 损坏分支改为"截断到有效前缀"(复用 `repair()``log.subarray(0,validBytes)` 写法),而不是写空文件;
`appendRecord` 的"内存更新 + 自动 checkpoint"必须纳入失败语义:要么 checkpoint 失败**不得**让已提交的写报错(改为记 `lastBackgroundError` 并让下一次 `checkpoint()` 抛出),要么在报错时**同时回滚内存** —— 当前两者都不做,是最坏组合;
③ 给 KVStoreEngine 补 Web Lock,或把实例 id / 启动世代编进 `seq` 与快照水位,杜绝陈旧实例覆盖;
`commitTransaction`/`rollbackTransaction` 清空 `savepoints`,并校验 `sp.txnId === currentTxnId`
`AriaEngine.close()` 增加活跃事务守卫(当前会截断 WAL 并把未提交的二级索引条目落盘 → 重开后幽灵 `UNIQUE_VIOLATION`)。
---
### 工作流 C · 验证基础设施(让门禁真的能拦)
**C 应该最先做**,因为它能验证 A 和 B 的成果。
#### C-1 故障注入后端(约 60 行,最高性价比)
实现 `IStorageBackend` 包装器,支持:
```
failNextWrite / failNextAppend / truncateTail(n) / tearAt(awaitPoint) / dropNextDelete
```
并把 `tests/helpers/opfs-mock.ts` 升级为**可按字节粒度半提交**(当前是立即生效、原子、绝不撕裂的内存 Map)。
**没有它,本层所有崩溃相关声称都只是"重开测试"。**
#### C-2 真崩溃注入(e2e
`tests/e2e/opfs.spec.ts:32-42``crashPage()` 改用 CDP
```ts
const cdp = await context.newCDPSession(page);
await cdp.send('Page.crash');
```
至少覆盖三种窗口:**写入不 await 即崩溃** / **checkpoint 中途崩溃** / **WAL 半写**
#### C-3 覆盖率口径修正与门禁
1. `jest.config.cjs:18``!src/**/index.ts` 改为**显式列白名单**(只排除真正的 barrel 与纯类型文件),并删除残留的 `!src/engine/opfs.ts`(文件已不存在)
2. README/CHANGELOG/site 的"90.1% 行覆盖率"改为准确表述:**语句 / 行 / 分支三个数字 + 明确采集范围**
3. 新增 `coverageThreshold`:先按当前实测(行 93%、分支 83%)**各减 2pp** 设为下限,并对 `src/engine/**``src/query/**` 设 per-path 阈值
4. CI 常规 job 加 `--coverage`
5. CI 的 lint 去掉 `continue-on-error: true`
6. 新增 `tsconfig.test.json``tests/` 做类型检查(当前 babel 剥离类型,测试里的类型错误完全不可见)
7. CI 加 `dist` 同步校验(build 后 `git diff --exit-code dist`
8. `coverage/` 生成前清理(当前残留 9 个已删除源文件的幽灵 HTML)
#### C-4 契约测试取代行为固化
现有测试有三类"虚假信心"必须清理:
| 类型 | 实例 | 处理 |
|---|---|---|
| 标题与断言不符 | `v042-fixes.test.ts:308-322` 题为"抛 ARIA_OPEN_ERROR"实际断言 `resolves``v044-hardening.test.ts:137-142` 断言 `not.toBe('pending')`(成功/失败都过) | 改写为断言具体错误码 |
| 空断言护栏 | `v073-fixes.test.ts:267-278` 比较两条**同样错**的路径(都是 0 行) | 改为值级断言 |
| 只比数量不比内容 | `aria-prod-load.test.ts:117-128` 2000 次随机 update/delete 只比 count | 改为结果值比对 |
| 恒真/无断言 | `aria-batch.test.ts:109-110`(无 expect);`maintenance-sql.test.ts:105``>=0`);`v074-fixes.test.ts:452-456`(断言测试自己写的字面量) | 补齐或删除 |
| 错误码吞掉 | `v073-fixes.test.ts` 等 12 处 `catch { threw = true }` 不绑定异常 | 统一用仓库已有的 `expectCode()` |
**新增必测矩阵**(这些正是历史 P0/P1 的形态):
- 跨引擎参数化契约(同一断言跑 memory/disk/hybrid/aria:约束、钩子、事务、流式、错误码)
- 入口等价性契约(`query` == `queryStream` == `QueryBuilder` == 事务内)
- 崩溃不变量(故障注入下"已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效")
- NULL 三值逻辑矩阵
- 错误码契约表(当前 **36 个错误码中 14 个从未被任何测试断言**
- 性能护栏改为**相对基线**(当前 `aria-prod-load.test.ts` 的 240s/300s 阈值只能拦住"353s 级"总崩,拦不住 2~5 倍回退)
#### C-5 属性测试(property-based)—— 最高价值的长期投资
随机操作序列(insert/update/delete/事务/savepoint/checkpoint/崩溃点)后断言四引擎不变量:
- 行集合 == 已确认操作集合
- 每个二级索引与主表一致
- 唯一约束仍生效
- `count()``find()` 一致
**CHANGELOG 里横跨 7 个版本的 P0/P1 全部属于此类不变量违反**。随机化能把它们一次网住,而不是每版抓一只。
---
## 五、缺陷总账(55 项,按优先级)
**级别定义**:**P0** = 崩溃 / 静默丢数据 / 静默错误结果;**P1** = 约束或事务语义破坏;**P2** = 边界与健壮性;**P3** = 工程与文档。
### 已实测复现(本次或子代理实跑)
| # | 级别 | 缺陷 | 位置 | 工作流 |
|---|---|---|---|---|
| 1 | P0 | UPDATE 多行撞同一新主键 → 静默丢行 | `memory.ts:274,289-300` | A1 |
| 2 | P0 | ALTER ADD UNIQUE 失效 → 重启静默丢行 | `memory.ts:122-127,316-339,562` + `kvstore_engine.ts:103-108` | A2 |
| 3 | P0 | SAVEPOINT 回滚不写补偿 WAL → 崩溃后行复活 | `aria/index.ts:1550-1579` | A3 |
| 4 | P0 | SAVEPOINT 跨事务泄漏 → 当前事务写入被替换 | `aria/index.ts:1467-1534,1563` | A4 |
| 5 | P0 | `MIN`/`MAX` 20 万行栈溢出 | `executor.ts:677-678` | A5 |
| 6 | P0 | `LIMIT/OFFSET` 双重应用 → 丢行 | `executor.ts:391-393` vs 引擎 | A6 |
| 7 | P0 | `queryStream``query` 结果不一致(别名/限定列/OFFSET/LIMIT 0 | `core.ts:316-319` | A7 |
| 8 | P0 | 行引用泄漏 → 改库 + 索引失配不可检索 | `memory.ts:209,228,765` | A8 |
| 9 | P0 | `subscribe()` 本地写入永不触发 | `core.ts:423-437` | A9 |
| 10 | P0 | `t.x=t.y` 与关联 IN 子查询静默空结果 | `executor.ts:344,1417` | A10 |
| 11 | P0 | `AND`/`OR` 无优先级 → 静默错行 | `parser.ts:780-797` | A11 |
| 12 | P0 | `KVStore.open` 损坏日志 → 有效前缀只留在内存,崩溃即永久丢失 | `kvstore/index.ts:135-138,394-399` | B-6 |
| 13 | P0 | 自动 checkpoint 失败 → 报错但数据已提交(调用方回滚无效) | `kvstore/index.ts:334-362,385-390` | B-6 |
| 14 | P0 | KVStore 无锁 → 陈旧实例 checkpoint 覆盖快照并截断日志吞掉对方数据 | `kvstore/index.ts:261-280,385-390` | B-6 |
| 15 | P1 | `maxLength`/`min`/`max` 三引擎不校验 | `memory.ts:713-722` vs `schema.ts:146-167` | A12/B-1 |
| 16 | P1 | 自引用外键 CASCADE/RESTRICT/ON UPDATE 全失效 | `memory.ts:393,432,498,822` | A13 |
| 17 | P1 | ON UPDATE CASCADE 只级联一层 | `memory.ts:430-448` | A14 |
| 18 | P1 | `= NULL` 命中 null 行;`NOT BETWEEN` 排除 NULL 行 | `parser.ts:845,967` + `where-matcher.ts:163` | A15/B-2 |
| 19 | P1 | INSERT arity 不校验 → 静默写半行/丢值 | `parser.ts:515-560,1166-1174` | A16 |
| 20 | P1 | INSERT 未知列静默丢弃 | `schema.ts:87-126` | A17 |
| 21 | P1 | 未知列投影返回 `{}`;标识符大小写敏感 | `where-matcher.ts:214-228` | A18 |
| 22 | P1 | 双引号当字符串 → `SELECT "name"` 返回常量列 | `lexer.ts:81-84,194-235` | A19 |
| 23 | P1 | 未闭合块注释静默接受 → DELETE 照常执行 | `lexer.ts:157-167` | A20 |
| 24 | P1 | 连续注释递归爆栈 | `lexer.ts:92,97` | A21 |
| 25 | P1 | GROUP BY 别名列静默消失 | `executor.ts:631-648` | A22/B-5 |
| 26 | P1 | HAVING 不能引用未在 SELECT 的聚合 | `executor.ts:628-651` | A23/B-5 |
| 27 | P1 | 空集聚合返回 0 而非 NULL | `executor.ts:660-680` | A24 |
| 28 | P1 | 带表前缀的聚合参数恒 0 | `executor.ts:328-336,662` | A25 |
| 29 | P1 | UNION 尾部 ORDER BY/LIMIT 丢失 | `parser.ts:366-388` + `executor.ts:153-200` | A26 |
| 30 | P1 | DISTINCT 作用在投影前 | `executor.ts:352,364,383` | A27/B-3 |
| 31 | P1 | `UPDATE SET __proto__` 静默吞掉 | `parser.ts:572-578` | A28 |
| 32 | P1 | `maxRowsPerQuery` 截断 `INSERT...SELECT` 源 | `executor.ts:707,396-398` | A29 |
| 33 | P1 | 嵌套 CASE 吞掉 FROM 之后全部文本 | `parser.ts:1088-1098` | B-5 |
| 34 | P1 | CASE 值含 ` ELSE `/`WHEN`/`''` → 静默错值 | `executor.ts:60,66,79,86-99` | B-5 |
| 35 | P1 | JOIN NULL 键结果取决于右表有无索引 | `where-matcher.ts:139-150` vs `executor.ts:531,543,553` | B-2 |
| 36 | P1 | 派生表别名限定失效 | `executor.ts:297-307` | B-3 |
| 37 | P1 | JOIN 内未限定列名静默失效 | `executor.ts:317-336,447-451` | B-3/B-5 |
| 38 | P1 | `compression:true` 在 pageStorage 开启时被静默忽略 | `aria/index.ts:1744-1749` | B-6 |
| 39 | P1 | `compressLZ4` 最坏 O(n²)64KB→4167ms | `lz4.ts:41-46` | B-6 |
| 40 | P1 | `close()` 不检查活跃事务 → 幽灵唯一冲突 | `aria/index.ts:281-296` | B-6 |
| 41 | P1 | `dropTable` DDL 非原子 → 表和数据复活 | `aria/index.ts:483-507` | B-6 |
| 42 | P1 | WAL 中段损坏 → 其后完好记录全丢 | `log.ts:264,270,276` | B-6 |
| 43 | P1 | WAL 单条 CRC 失配跳过 → 已提交事务部分应用 | `log.ts:287-298` | B-6 |
| 44 | **P0** | 后台 flush 失败后冻结数据无重试路径 → 崩溃即丢(`insert` 早已返回成功) | `lsm.ts:220-261,605,612,618` | B-6 |
| 45 | P1 | `flush()` 记过后台错误即跳过本次 flush`lastBackgroundError` 检查在入链之前) | `lsm.ts:580-590` | B-6 |
| 46 | P1 | `LSM.get` 缓存未命中当"不存在"(同类问题 `compactLevelAsync` 已修、`get` 未修) | `lsm.ts:734-753,417-422` | B-6 |
| 47 | P1 | `rangeScanLazy` 早停多算 1 条(生成器已推进到下一项才收到 stop) | `lsm.ts:440-479` + `index.ts:1220-1225` | B-6 |
| 48 | P2 | `MemTable.delete` 大小记账无条件下调 → 可致负数与 flush 阈值失真 | `memtable.ts:441-447` | — |
| 49 | P2 | `compacting` 单 boolean 跨层丢触发 → 深层维护饥饿 | `lsm.ts:78,269-283` | B-6 |
| 50 | P2 | compaction 预先 `splice` 整层 → 长 await 窗口内该层对读者不可见 | `lsm.ts:502,569` | B-6 |
| 51 | P2 | compaction 不回收墓碑与历史版本 → 空间/写放大无界(90% 删除场景不回收) | `lsm.ts:537-552` | B-6 |
| 52 | P2 | `vacuum()` 硬编码返回 `compactedLevels: 6`,而 `compactLevel` 对单文件层是静默 no-op | `lsm.ts:486-500` + `index.ts:2247-2261` | — |
| 53 | P2 | SSTable 解析循环三份拷贝、越界策略不一致(点查 vs 扫描行为不同) | `sstable.ts:80-100,145-166,182-201` | B-6 |
| 54 | P2 | 孤儿 `sst_*` blob 无清理路径(`cleanupOrphanPages` 只管 `pg_*` | `index.ts:352-388` | B-6 |
| 55 | P2 | `checkpointManager.tick()``lsm.flush()``drainChain()` 等完整 compactionv0.6.1 "8~11s 悬崖"的另一半来源) | `index.ts:664` + `lsm.ts:592` | B-6 |
### 已读码确认但未独立复现(清单存档,动工前需补复现)
生命周期与 API 面:MemoryEngine.close 泄漏快照(`memory.ts:43-45`);`init()` 无重入保护;`close()` 无 try/finally;连接池 refCount 重置与失败泄漏(`connection-manager.ts:36-77`);`migrateTo` 非原子(`core.ts:527-542`);`addMigration(v)``v <= config.version` **永久静默跳过**(默认 version=1 使迁移 1 永不执行,无任何警告)【实测】;`subscribe`/`emit` 语义;插件 `priority` 完全无效【实测】;`unregister` 不摘钩子【实测】;`onError` 覆盖不一致。
事务面:SQL `COMMIT` 可提前提交 `db.transaction` 外层事务(随后抛 `TX_NONE`)【实测】;事务结束后 `trx` 仍可写入且脱离事务【实测】;事务内 JOIN 静默忽略【实测】;事务内写入不广播、不触发钩子;`setMeta` 不受回滚影响。
集成面:React `useDatabase` StrictMode 下呈现"已关闭实例 + ready:true"Vue `watch([()=>sql])` 永不触发且无 `onUnmounted``package.json``./react`/`./vue` 指向裸 TS 源码且 d.ts 无导出;`./migration` 子路径未在 `exports` 声明(README 示例直接不可用);`DatabaseError`/`MetonaPlugin` 未从根入口导出。
存储面:`compression` 与 pageStorage 分支(A38);SSTable meta 无校验且 repair 删活页;介质 read 故障折叠为 null;`flushAll` 与 delete 竞争复活已删页;页面头 checksum 恒 0;加密无 AAD/无改密路径;PBKDF2 迭代数 100000(低于当前 OWASP 建议);Web Locks 不支持即无保护;MVCC 的 `snapshotLsn`/`prevVersion` 是死字段(README:358 "版本链 + 快照隔离"与实现不符)。
---
## 六、非目标(v0.7.5 明确不做)
避免范围蔓延,以下**明确排除**
- ❌ 复合主键(v0.8 路线图,已由 `SCHEMA_ERROR` 显式拒绝)
- ❌ 算术/字符串表达式与完整表达式求值器(`a + 1``||`)——B-5 只做**结构化列身份**,不做运算符体系
- ❌ 真正的多版本 MVCC 并发(当前是单事务 + 全量快照)
- ❌ 标准 LZ4 兼容(当前是自定义编解码)
- ❌ 密钥轮换 / KDF 升级
- ❌ 表级约束、多列索引、`CREATE TABLE AS SELECT`
- ❌ 新存储引擎或新前端特性
**这些都是好功能,但把它们加到一个语义已经漂移的底座上,只会扩大缺陷面。**
---
## 七、验收标准(硬门禁)
**G1~G6 全部满足才允许发 v0.7.5。** 每条都是可机器验证的。
| 门禁 | 内容 | 验证方式 |
|---|---|---|
| **G1** | 工作流 A 的 11 项事故级全部修复,且每项有**值级**回归测试(非行数断言) | 新增测试套件全绿 |
| **G2** | 入口等价性成立:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口结果**逐值相等** | 参数化契约测试 |
| **G3** | 跨引擎一致性:同一 schema + 同一操作在 memory/disk/hybrid/aria 四引擎**行为一致**(含错误码) | 参数化契约测试 |
| **G4** | 故障注入下崩溃不变量恒成立:已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效 | 故障注入矩阵(撕裂写 × 崩溃点 × 后端) |
| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`README 数字与 lcov 一致 | CI 产出比对 |
| **G6** | 文档与实现一致:README/CHANGELOG/site 中**每一条能力宣称**都有对应测试或已删除 | 逐条核对清单(见 §8) |
**额外要求**:CI 的 lint 必须真正阻断(去掉 `continue-on-error`),并新增 `tests/` 类型检查。
---
## 八、宣称与实现不符清单(G6 核对表)
文档里目前有 **12 条**能力宣称缺少实现或测试支撑。每条给出二选一处理:
| 宣称 | 位置 | 实际 | 处理 |
|---|---|---|---|
| "在线一致性快照 `backup()`" | README:230 | Aria 逐表 `getAllRows`,**无跨表快照/无隔离**,且并入本事务未提交数据;公开 API 覆盖率 0 | 改为"逐表导出"或实现真快照 |
| "订阅表变更 `subscribe()`" | README:232 + `site/docs.html:667-679` | 本地写入**永不触发**(仅跨标签页 `external` | 接线 或 修正文档与示例 |
| "插件 priority 越大越先执行" | README:62、`constants.ts:189``CONTRIBUTING:166` | priority 完全无效,顺序 = config 数组顺序 | 实现 或 删除该字段 |
| "14 种钩子 allow intercepting" | `CONTRIBUTING:137` | 只有**对象就地修改**有效;返回值被丢弃;SQL 路径 `beforeInsert` 改的是副本 | 明确钩子契约 + 测试 |
| "MVCC 版本链 + 快照隔离,事务读写不互斥" | README:358 | `snapshotLsn`/`prevVersion` 是死字段;并发被 `TX_ACTIVE` 拒绝 | 改写为"单事务 + 快照回滚" |
| "LZ4 页面压缩" | README:313/354 | pageStorage 开启时**压缩分支永不执行** | 修复(A38)或改称"可选压缩(整 value 模式)" |
| "`diskEngine`disk 模式生效)" | README:181 | `core.ts:621-623` 传参但 KVStoreEngine **忽略**hybrid 的 diskEngine 也无效 | 实现 或 删除配置项 |
| "5 种存储引擎" | README:420 | 4 种 mode + 3 种后端,不是 5 个引擎 | 修正表述 |
| "`@metona-team/metona-sqlark/migration`" | README:253 | `exports` **无 `./migration`**`ERR_PACKAGE_PATH_NOT_EXPORTED` | 补 exports |
| React/Vue 集成 | README:386-393 + `package.json:19-27` | 指向**裸 TS 源码**d.ts 无导出;无 peerDependencies | 构建 `dist/react.js`/`vue.js` + 补 d.ts + peerDeps |
| "连接池 `db.disconnect()`" | README:166-168,269-274 | `any` 注入,**d.ts 无声明**,tsc 不通过 | 进类型系统 |
| "行覆盖率 90.1%" | README:6,418 + CHANGELOG + site | 实为**语句 90.08%**;行 92.91%;分支 82.92% 未公布 | 修正口径(C-3 |
---
## 九、风险与破坏性变更
| 风险 | 说明 | 缓解 |
|---|---|---|
| **B-2/B-3 改变既有结果** | 修复 NULL 三值逻辑、LIMIT 语义、AND/OR 优先级后,**此前返回错误结果的查询结果会变** | 视为 **minor 版本**0.7.5 → 建议改称 0.8.0),CHANGELOG 单列"语义修正(行为变更)"章节,逐条给出 before/after |
| **被测试钉死的错误行为** | `v062-fixes.test.ts:169-176` 把非标准字符串排序写成期望;`lexer.test.ts:47-51` 把双引号=字符串钉死;`hooks-crud.test.ts:127-138` 把"事务不触发钩子"钉死;`vue.test.ts:126-138` 把 watch 失效钉死;`kvstore.test.ts:176-210` 把"截断丢弃"钉死;`aria-wal-crc.test.ts:118` 把"记录跳过后继续"钉死 | 修复时**同步改断言并在 CHANGELOG 说明**;这些"护栏"本身就是缺陷的一部分 |
| **`forceExit: true` 掩盖泄漏** | 所有句柄/定时器泄漏都不会让测试失败 | C-3 增加一轮不带 `forceExit``--detectOpenHandles` 运行 |
| **性能护栏过宽** | 240s/300s 阈值只能拦总崩 | 改为相对基线 ±30% |
| **`src/engine/aria/index/lsm.ts` 审计未完成** | 本方案未覆盖 LSM 内部(红黑树不变量、compaction 级别、key 编码一致性、tombstone 复活) | **动工前必须补完**,其结论可能新增 P0 |
| **`dist/` 已提交入库** | 42 个提交都在改 `dist/`,仓库膨胀且无同步校验 | 建议移出版本控制(`files` 已在 package.json,发布时构建);若保留则加 CI 校验 |
---
## 十、建议里程碑
| 阶段 | 内容 | 出口条件 |
|---|---|---|
| **M0 · 准备** | 建立缺陷跟踪表(§5 的 55 项);实现 C-1 故障注入后端 + C-2 真崩溃注入 | 故障注入能稳定复现至少 3 个已知崩溃缺陷 |
| **M1 · 止血** | 工作流 A 全部 11 项事故级 + 18 项高优 P1 | G1 通过 |
| **M2 · 口径** | C-3 覆盖率修正与门禁 + C-4 契约测试清理 | G5 通过;CI lint 阻断 |
| **M3 · 根治** | B-1 → B-2 → B-3 → B-4B-5/B-6 可视周期裁剪,但 B-6 至少完成三处止血) | G2/G3 通过 |
| **M4 · 收口** | B-5/B-6 收尾 + C-5 属性测试 + G6 文档核对 | G1~G6 全绿 |
---
## 十一、玥玥的判断(需要爸爸拍板的三件事)
**1. 版本号**:我倾向于**把它当作 0.8.0 而不是 0.7.5**。
理由:工作流 A/B 会**改变既有查询的返回结果**NULL 语义、LIMIT、AND/OR 优先级),按语义化版本这是破坏性变更。把它藏在 patch 号里会让下游用户在无声中拿到不同的数据。**但爸爸更了解发布节奏与依赖方,最终请爸爸定。**
**2. 范围裁剪**:B-5AST 结构升级)与 B-6manifest)是**工作量最大的两项**,也是收益最大的两项。
- 若周期充足 → 全做,一次性还清语义债
- 若周期紧张 → 我的建议是 **B-3 + B-2 优先**(单管线 + 三值逻辑),因为它们消除的缺陷最多(A6/A7/A15/A19/A22/A27/A35/A37 共 8 项事故级的一半),而 B-5 可以推后到 0.9
- B-6 无论如何都建议至少完成三处止血(见 §B-6 降级选项),否则崩溃恢复的宣称始终是空头支票
**3. `dist/` 与 `.npmrc`**
- `dist/` 已提交 42 个提交,我建议移出版本控制(发布时构建 + CI 校验)
- 我注意到本地 `.npmrc` 含一行 base64 的 registry 认证凭据(已解码可见用户名与口令)。**它未被 git 跟踪(`.gitignore` 已覆盖),这一点是好的**;但既然它存在于工作区,仍建议轮换一次该口令——凭据一旦以明文形式落过盘,就没有"确定没泄漏"这一说。
---
## 附录 A · 报告关系说明
本方案是**总纲**:§3 根因、§4 工作流、§5 总账、§7 验收标准均以本文件为准。
四份子系统明细报告(见附录 E)提供逐条 `file:line` 证据与验证方法,供动工时按图索骥。
凡明细报告与本方案冲突处,**以本方案为准**(本方案对冲突项做过独立复现与修正,见附录 C)。
## 附录 B · 复现命令速查
```bash
# 基线
npm test # 1304 通过 / 76 套件 / ~395s
npm run typecheck # exit 0
npm run lint # exit 0
# 真实覆盖率(不排除任何文件)
npx jest --coverage --collectCoverageFrom='src/**/*.ts' --coverageReporters=text-summary
# 不带 forceExit 检查句柄泄漏(当前被 jest.config.cjs:30 掩盖)
npx jest --detectOpenHandles --forceExit=false # 需临时改配置
# e2e(需先 build
npm run build && npm run test:e2e
```
## 附录 C · KVStore 三个事故级缺陷的探针原始输出
以下是本方案定稿前,用真实 `KVStore` + `SharedMemoryBackend` 跑出的原始输出(探针跑完已删除)。
这三个缺陷是 B-6 的直接证据,也是"崩溃语义靠声称"的最清晰样本。
```
# ① 损坏日志恢复后崩溃 → 已确认前缀永久丢失
KV corruption setup: records=3 logBytes=84
KV 1st open: inMemory=[true,true,false] logBytes=0 snapshotBytes=0
KV after crash+restart: [false,false,false] <-- confirmed rows lost = BUG
# ② checkpoint 失败 → 报错但数据已提交
KV checkpoint-failure: put rejected='INJECTED snapshot write failure' inMemory=true logBytes=27
KV checkpoint-failure: durable after restart=true (caller saw REJECTION)
# ③ 陈旧实例 checkpoint 吞掉对方已提交数据
MULTI2: A.seq=0 B.seq=0 (both start from same snapshot/log)
MULTI2: after A.put A.seq=1 B.seq=0
MULTI2: after B.put+autoCheckpoint snapshotBytes=30 logBytes=0
MULTI2: restart -> fromA=LOST fromB=OK <-- cross-instance data loss
# ④ 附带:close() 不做 checkpoint(持久化契约缺口)
KV close-without-checkpoint: value after reopen = present # 仅在优雅关闭路径下成立
```
## 附录 D · LSM 层审计结论摘要(已完成)
`src/engine/aria/index/lsm.ts` 的深度审计已完成,**结论与其它子系统明显不同,值得单独说明**:
**常规路径是对的。** 8 组差分 fuzzengine vs 参照 Map,含 `compactLevel(0..2)`)得到 **0 处不一致**
红黑树 4000 步随机增删后中序与参照集完全一致;SSTable 13 组边界(块尾/跨前缀/空表/末块)全绿。
**所以不要期望 LSM 存在"普遍算错"类缺陷。**
审计同时**主动排除了三个很像 P0 的假警报**(这一步的价值不低于找到真 bug):
- **Bloom Filter 不会产生 false negative**`Math.abs((h1+i*h2) % bits)` 看着像经典的负余数 bug
`h1 + i*h2` 在双精度下永不溢出、`% bits` 恒非负 —— `Math.abs` 是冗余而非错误。
30k 随机 + 5k 密集 + 5 种真实 key 形态各 2000 keyFN **全为 0**
- **崩溃中断 compaction 不会让旧文件遮蔽新数据**(逐时点验证层间新鲜度)。
- **PK 墓碑前缀不会误删 `k10`**(字符串比较下 `t:k1 < t:k10`,实测只删 1 行)。
**真正的问题在"未被执行的不变量"与"后台任务模型"**,已并入 §5 总账第 44~55 项。
**最高杠杆的单点改动**(已并入 B-6):把 `LSM.get/rangeScan` 从"同步 + 调用方负责预取"改为
**自洽的异步读**(未命中就 `await store.load`,并用类型区分 MISS 与"不存在"),`rangeScanLazy` 改为
async generator 按块读取。这一改动可**一次性删掉引擎层全部 7 处 `drainChain()+prefetch*`**
`index.ts:574,1219,1605,2024,2077,2106,2113`),同时消掉性能悬崖与第 46 项;
配套把 `flushChain` 换成**带意图记录、可重试的串行工作队列**(`compacting``Set<level>`),
即同时解决第 44、45、49 项。
---
## 附录 E · 审计证据索引
完整报告(均为中文,含逐条 file:line 与验证方法):
| 报告 | 行数 | 覆盖范围 |
|---|---|---|
| `AUDIT-query-layer-v0.7.4.md` | 346 | query 层(ast/builder/compiler/executor/where-matcher |
| `AUDIT-storage-engines-v0.7.4.md` | 243 | MemoryEngine / KVStore / KVStoreEngine / Hybrid |
| `AUDIT-aria-lsm-v0.7.4.md` | 558 | AriaEngine 门面 + LSM / MemTable / SSTable / Bloom / MergeIterator |
| `PLAN-v0.7.5.md`(本文件) | — | 根因分析 + 迭代方案 + 缺陷总账 + 验收标准 |
另有 SQL 层(tokens/lexer/parser/params)与 core/支撑层(core/table/transaction/plugin/
connection-manager/integrations/migration)两份完整报告,以及测试质量与 CI 审计报告,
内容已全部并入本方案的 §3 根因与 §5 总账。
---
## 附录 F · v0.8.0 实施进度日志
> 本附录记录**实际落地情况**(与 §4 的方案对照)。按提交顺序,每条都可 `git show` 复核。
### 已完成
| 提交 | 范围 | 关键结果 |
|---|---|---|
| `0dba1ab` | **工作流 C 基座 + 工程门禁** | 故障注入基座(TransactionalFileStore / FaultyBackend / 忠实 OPFS mock);覆盖率口径修正(移除吞掉 AriaEngine 的 `!src/**/index.ts`+ coverageThreshold`tsconfig.test.json` 并修复 **103 个测试类型错误**lint 清零;CI 增加 tests 类型检查 / 覆盖率门禁 / lint 阻断 / dist 同步校验;版本 0.8.0 三处一致 + 版本契约测试 |
| `83c5aa0` | **A11** AND/OR 优先级 | parser 三层分层 `or → and → simple``a=1 OR a=2 AND b=3` 从返回 1 行修正为 3 行(与显式括号一致) |
| `7526951` | **A19/A20/A21/A2(词法)** | 双引号 → `QUOTED_IDENTIFIER`(可引用保留字列名);未闭合块注释报错(此前 `DELETE ... /*` 照常执行);注释跳过去递归(消除栈溢出);移除 MySQL 方言反斜杠转义(与绑定器字符串边界统一) |
| `752bdea` | **A5/A6/A7/A16/A18/A24/A28** | LIMIT/OFFSET 从"应用两次"修正为下推安全判定 + 只应用一次(10 种查询形态验证);**queryStream 与 query 在 16 形态 × 4 引擎下逐值相等**;MIN/MAX 改单次归约(消除 20 万行栈溢出);空集聚合返回 NULL;未知列抛 `COLUMN_NOT_FOUND`INSERT arity 校验;`__proto__` 列名防护统一 |
| `074afd3` | **A8 + LSM 读自洽** | 行所有权写进 `IStorageEngine` 契约(读返副本/写收副本)+ `cloneRow`Memory/KVStore/Hybrid/Aria 全部生效(改返回值不再改库、索引不再失配);**LSM 读取自洽**:未命中即回源 + CRC 校验,消除"缓存未命中 = 静默丢数据"(实测小缓存下 300 行只回 59 行);缓存上限语义修正(超大 SSTable 常驻) |
| `89243ef` | **A3/A4 SAVEPOINT** | 新增 `SAVEPOINT_ROLLBACK` WAL 记录(复用 `data` 字段,格式不变);恢复按**事务内**下标窗口重放 → 已回滚的行不再复活;事务结束清空 savepoint 并校验归属 → 陈旧 savepoint 不再静默吞写入 |
| `765df80` | **A1/A2 约束** | UPDATE 阶段 1 增加批内新主键互查(四引擎)→ 不再静默丢行;ALTER ADD UNIQUE 真正建索引并做存量校验(复用 `createIndex`),Memory 侧"重启静默丢行"随之消失 |
| `674da6b` | **A9/A10** | `ChangeNotifierEngine` 引擎装饰器:INSERT/UPDATE/DELETE/CLEAR/DDL 统一派发变更事件,`db.subscribe` 对本地写入生效(此前只在跨标签页广播时触发);关联子查询的 `$col` 绑定补上外层别名剥离与**递归进入子查询**(`WHERE id IN (SELECT ... WHERE o.user_id = u.id)` 此前静默空结果) |
| `4ab04df` | **A15 三值逻辑(PB-2** | 见下节"三值逻辑根治" |
| `2a109ef` | **B-1 统一校验 choke point** | 新增 `src/table/validation.ts``compileValidator`)作为**唯一**行校验定义,消除三份分叉实现(memory 缺 maxLength/min/max);四个引擎新增 `validatePayload` 契约,Executor 在任何副作用前校验;未知列由静默丢弃改为 `COLUMN_NOT_FOUND`(A17);外键级联写入也过校验;NaN/±Infinity 显式拒绝(JSON 无法表示,落盘会变 null) |
| `841db2e` | **A22/A23/A25/A26/A27/A29/A30/A36** | 查询层 8 项:GROUP BY 别名、HAVING 未选中聚合、带前缀聚合参数、UNION 尾部子句归属、DISTINCT 作用于输出列、`maxRowsPerQuery` 不再静默截断写入、INSERT 值多于列、派生表别名引用。连带根治"同步抛错穿过 async 边界"与"缺列的行形状不一致" |
| `b20d47b` | **B-3 单管线** | QueryBuilder 只产出 AST、执行一律经 Executor(删除无 JOIN 时直通 `engine.find` 的快路);钩子改为由 `Table` 注入、顺序与传参不变;删除引擎层 4 处"未解析标记 → NOT_SUPPORTED"的过时防御 |
| `edd9f1d` | **B-6 ①②(两处 P0** | ① `open()` 遇损坏日志尾部不再**清空整库**(新增 `truncateLogTo(keepBytes)`,与 `repair()` 同口径);② 自动 checkpoint 失败不再让**已确认写入**报错(WAL 已持久化),改为记 `lastBackgroundError` 并由下一次显式 `checkpoint()` 报告 |
| `68e4731` | **B-6 ③ + 测试介质** | ③ meta 增加 `owner`,陈旧实例 `checkpoint()``STALE_INSTANCE` 拒绝覆盖新实例的写入(实测修复前会**静默**抹掉);同时修复 `SharedMemoryBackend` 的跨实例可见性缺陷(私有拼接缓存使介质退化为"每实例一份快照",此前所有多实例用例都跑在错误介质上) |
| `6a0cfeb` | **PC-2 真崩溃注入** | e2e 的 `crashPage()` 原本只是 `win.__ms = null; page.close()` —— **优雅关闭**,所有"崩溃恢复"用例测的其实是"正常关闭后重开"(CI 注释却声称覆盖 CDP `Page.crash`)。现改用 CDP `Page.crash` 终止渲染进程,并新增两个真实窗口:`createWritable().write()` 中途、`close()` 原子替换前(copy-on-write 的核心不变量)。实测要点:崩溃后 `page.isClosed()` 仍为 falseOPFS 数据在同 context 内跨崩溃保留 |
| `567d150` | **A37 列引用** | 未限定列可作比较操作数(`WHERE x = y` / `ON k = k` 此前 PARSE_ERROR,列对列比较被迫写限定形态);新增 WHERE / JOIN ON 的列存在性校验 —— `$col` 引用不存在的列此前**静默返回空集**(`WHERE id = oops``[]`),现报 `COLUMN_NOT_FOUND`;顺带解除 `schema ↔ validation` 循环依赖 |
| `85f0f17` | **A13 自引用外键** | 删除 6 处 `refTableName === tableName) continue`memory 3 + aria 3):自引用外键的删除级联/置空/更新级联/预检四条路径全部失效,`DELETE root` 只删根、子树永久悬挂。A14(传递链)经实测**审计结论有误**——`ON UPDATE CASCADE` 链正常,已固化为护栏 |
| `97b9fa4` | **A38/A39 压缩** | A38 `compression` 在页面化路径(默认)被静默忽略 → 改为切页前整体压缩,并修正 `SSTableMeta.totalSize` 必须写压缩长度(否则会被 0 填充撑大);A39 `compressLZ4` 逐位置扫描 O(n²)60KB 伪随机 2345ms)→ 4 字节哈希链(6ms,390×),输出格式不变并保留线性参考实现作对照。连带修正 OPFS mock 忽略库名导致的**跨库共享文件树** |
### 三值逻辑根治(A15 / PB-2,提交 `4ab04df`
**根因**`matchWhere`(布尔版,自带 `matchField`)与三值求值器**并存**,同一条 SQL
的语义取决于走哪个函数;更严重的是字段级 `$or` 递归进了"where 子句级"求值器。
**三类实测静默错值**(修复前的实际返回):
| SQL | 修复前 | 修复后 | 机制 |
|---|---|---|---|
| `WHERE n = NULL`(n 列含 NULL 行) | 命中 NULL 行 | 空集 | `===` 比较 `null === null` 为真 |
| `WHERE n != NULL` | 所有非 NULL 行 | 空集 | 同上取反 |
| `WHERE s NOT LIKE 'x'`(含 NULL 行) | NULL 行被判真 | 仅真正不匹配的行 | 布尔取反未传播 UNKNOWN |
| `WHERE n NOT BETWEEN 1 AND 2` | **恒空集** | 真正的越界行 | 字段级 `$or` 子项 `{ $lt: 1 }` 被当成"查询列 `$lt`" |
**根治方式(取消第二套实现,而非打补丁)**
1. `query/where-matcher.ts` 重写为**唯一一个递归求值器**,同时理解 where 子句级
(键是列名 / 逻辑连接词)与操作符级(键是 `$gt` 之类)。区别由**位置**承载,
不再由另一个函数承载;行上下文随求值上下文下传,`$col` 在任意嵌套深度都能解析。
`matchWhere` 退化为"三值结果是否恰为 TRUE",并新增 `matchWhereThreeValued`
2. `sql/parser.ts``IS NULL` / `IS NOT NULL` 生成 `$isNull` / `$isNotNull` **谓词**
(此前与 `= NULL` / `!= NULL` 共用 `$eq: null` / `$ne: null`,调用方无法表达区别);
`BETWEEN` 生成真正的范围条件(此前把同一对象同时当操作符对象与操作数);
`NOT BETWEEN` 展开为 `$or: [{ $lt }, { $gt }]`
3. `query/executor.ts` 新增 `enginePreFilter`:逐行求值谓词(`$col` / `$exists` /
CASE 键)必须整体移出引擎层 —— 引擎无外层行上下文,会把它们判 UNKNOWN 并把
**所有行**过滤掉,逐行求值再正确也无行可算(这正是 `WHERE t.x = t.y` 静默空结果的
第二层原因)。粒度按连接词决定:`$and` 成员可单独移除;`$or` / `$not` 成员一旦
移除就改变结果集(漏行 / 多行),故整条放弃下推。
**契约变更**(旧测试编码了错误语义,已按 SQL 标准改正并在测试内注明理由):
- `{ $eq: null }` 不再命中 NULL 行(`= NULL` 恒 UNKNOWN)→ 需 NULL 匹配时用 `$isNull`
- `IN` 列表含 NULL`x IN (NULL, 'a')` 只命中 `'a'``null = NULL` 为 UNKNOWN),
未命中的非 NULL 行因 UNKNOWN 同样不保留;
- 引擎层(`MemoryEngine.find` / `AriaEngine.find`)与 Query Builder 路径同步适用。
**验证**:新增 `tests/v080-sql-three-valued.test.ts` —— 26 条 SQL 语义矩阵 ×
4 引擎(memory/disk/hybrid/aria+ UPDATE/DELETE 写路径,共 **104 断言**
**测试规模**1304 → **1742 通过 / 89 套件**(另 e2e 14 项)。新增:
`tests/v080-savepoint.test.ts``v080-atomicity.test.ts``v080-subscribe.test.ts`
`v080-correlated.test.ts``v080-sql-three-valued.test.ts`
`v080-unified-validation.test.ts``v080-query-layer.test.ts`
`v080-single-pipeline.test.ts``v080-kvstore-commit-point.test.ts`
`v080-column-resolution.test.ts``v080-foreign-key.test.ts`
`tests/engine/aria-compression.test.ts``version-contract.test.ts`
`helpers/storage-harness.test.ts`
**关键修复均做过变异验证**:把修复回退到修复前的行为,对应用例必须失败
(例:KVStore ①2 项、②3 项)。这是"测试真的能拦住回归"与"测试只是陪跑"的
分界线,后续新增回归套件都按此执行。
**验证状态**`typecheck`src + tests)、`lint` 全部为零错误;Aria 生产负载
10 万行 / OPFS / KV 后端崩溃恢复)全绿。
### 实施过程中的额外发现(方案未列出,已一并修复)
1. **`SELECT 1 AS one FROM t` 返回 `[{}]`** —— parser 数字常量分支提前 return 吞掉别名;裸 `SELECT 1` 也未处理匿名常量列。现按 SQLite 语义以表达式原文为键。
2. **`SELECT 0` 类查询的常量列投影**、`TRUE/FALSE/NULL AS alias` 同样修正。
3. **`async` 回调判定** —— 原 `constructor.name === 'AsyncFunction'` 对"普通函数返回 Promise"完全失效;现双条件识别并走物化 + await。
4. **`queryStream` 不受 `maxRowsPerQuery` 约束、不触发钩子** —— 现与物化路径一致。
5. **缓存上限契约不可满足** —— 原测试断言"缓存字节数 ≤ 上限",但单文件大于上限时驱逐它等价于丢数据;已改为断言"数据完整"这一真正不变量,并明确上限 = `cacheLimit` + 单个最大 SSTable。
6. **测试直接读私有字段**`lsm.cacheSize` / `cacheLimitBytes`)—— 那是 TS 错误,只因测试不做类型检查才没暴露;已补公开访问器。
### 待完成(下一轮继续)
| 优先级 | 项目 | 状态 |
|---|---|---|
| ~~P0~~ | ~~A9 `subscribe()` 对本地写入不触发~~ | ✅ `674da6b` |
| ~~P0~~ | ~~A10 `t.x = t.y` 与关联 `IN (SELECT ...)` 静默空结果~~ | ✅ `674da6b` + `4ab04df`enginePreFilter |
| ~~P1~~ | ~~A15 三值逻辑(PB-2 统一 `sqlCompare`~~ | ✅ `4ab04df` |
| ~~P1~~ | ~~A12 `maxLength`/`min`/`max` 在 memory/disk/hybrid 失效(PB-1 统一校验)~~ | ✅ `2a109ef` |
| ~~P1~~ | ~~A17 INSERT 未知列静默丢弃~~ | ✅ `2a109ef` |
| ~~P1~~ | ~~A22/A23/A25/A26/A27/A29/A30/A36 查询层~~ | ✅ `841db2e` |
| ~~P0~~ | ~~**B-3 单管线**builder 只产出 AST~~ | ✅ `b20d47b` |
| ~~P0~~ | ~~**B-6 KVStore 三处止血**~~ | ✅ `edd9f1d` + `68e4731` |
| ~~P1~~ | ~~A13 自引用外键失效~~ | ✅ `85f0f17`(A14 传递链实测正常,非缺陷) |
| ~~P1~~ | ~~A35 JOIN NULL 键~~ | ✅ `841db2e`(JOIN 的 NULL 键不成立,且与右表有无索引无关) |
| ~~P1~~ | ~~A37 未限定列名 / WHERE 列存在性~~ | ✅ `567d150` |
| ~~P1~~ | ~~A38/A39 压缩被遮蔽 / O(n²)~~ | ✅ `97b9fa4` |
| P1 | A40/A41 Aria `close()` 活跃事务守卫、`dropTable` DDL 原子性 | 待做 |
| P0 | **B-6 存储单一提交点(manifest** | 部分(KVStore 三处止血已完成;Aria 见 A40/A41 |
| P1 | **B-4 统一表达式求值器**(CASE 正则切分、聚合表达式、列引用) | 部分(聚合与列引用已统一,见文末说明) |
| P1 | **B-5 结构化 AST 表达式节点 + 输出列序号** | 待做 |
| ~~—~~ | ~~PC-2 e2e 真崩溃注入(CDP `Page.crash`~~ | ✅ `6a0cfeb`14 项 e2e,含两个 OPFS 写入窗口) |
| — | PC-4/PC-5 契约测试 + 属性测试 | 部分(跨引擎参数化已用于 A1/A2/A15/B-1/B-3 |
| — | PG 文档与站点全量同步 | 待做 |
**下一步建议**A40/A41Aria `close()` 活跃事务守卫、`dropTable` DDL 原子性)
→ B-4 统一表达式求值(CASE 的正则切分整类)→ B-5 结构化 AST + 输出列序号
→ PG 文档与站点同步。
**已完成的验证基础设施(本轮重点)**
- PC-2 真崩溃注入(CDP `Page.crash`+ OPFS `createWritable` 的两个写入窗口;
- `FaultyBackend` 字节级故障注入(撕裂/bit-flip/掉电/写失败);
- **变异验证**成为回归套件的标准做法:把修复回退到修复前的行为,对应用例
必须失败。本轮 A15/B-6(①②③)/A13/A38/A39 全部通过该检查 ——
这是"测试真能拦住回归"与"测试只是陪跑"的分界线。
- 测试介质的两处**忠实性**缺陷已被修正(`SharedMemoryBackend` 跨实例可见性、
OPFS mock 的目录语义)—— 它们此前让"多实例/多库"的验证跑在错误语义上。
---
## 附录 G · G1~G6 硬门禁验收记录(v0.8.0
> 每条门禁给出**可机器验证的命令与实测结果**。发版前必须全绿。
| 门禁 | 内容 | 验证方式与实测结果 | 状态 |
|---|---|---|---|
| **G1** | 事故级缺陷全部修复,且每项有**值级**回归测试(非行数断言) | 12 项事故级(A1~A10、A15)+ 全部 P1 已修;每项均有独立回归套件,断言值/结构而非行数:`v080-sql-three-valued`(26 条 SQL 语义矩阵 × 4 引擎)、`v080-query-layer`8 项 × 4 引擎)、`v080-column-resolution``v080-foreign-key``v080-aria-ddl-atomicity``v080-unified-validation``v080-case-expression`54 项)、`v080-output-ordinals`68 项)、`v080-kvstore-commit-point``v080-single-pipeline``v080-savepoint``v080-atomicity``v080-subscribe``v080-correlated` | ✅ |
| **G2** | 入口等价性:同一 SQL 经 `query`/`queryStream`/`QueryBuilder`/事务内四条入口**逐值相等** | `v080-single-pipeline`TABLE API 与 SQL API 逐值等价,4 引擎 × 12 项 + 跨引擎 1 项,共 57 断言);`queryStream``query` 在 16 形态 × 4 引擎逐值相等(`752bdea`);事务内入口经同一 Executor(`b20d47b` | ✅ |
| **G3** | 跨引擎一致性:同 schema + 同操作在 memory/disk/hybrid/aria 行为一致(含错误码) | 全部新增套件均按 `describe.each(ENGINES)` 参数化到四引擎:`v080-unified-validation`41)、`v080-sql-three-valued`104)、`v080-query-layer`111)、`v080-single-pipeline`57)、`v080-column-resolution`32)、`v080-foreign-key`32)、`v080-output-ordinals`64)、`v080-case-expression` 等;错误码断言用 `rejects.toMatchObject({ code })` | ✅ |
| **G4** | 故障注入下崩溃不变量恒成立:已确认写入零丢失 + 索引与主表一致 + 唯一约束仍生效 | 单元层:`FaultyBackend`(撕裂写 / bit-flip / 掉电丢弃 / 写失败)+ `TransactionalFileStore`pending/commit/crash 语义)+ `CrashableStoreBackend``v080-kvstore-commit-point`(19 项,含三处 P0 的变异验证);`v080-atomicity``v080-savepoint`。e2e 层:CDP `Page.crash` 真崩溃 + OPFS `createWritable` 的 write/commit 两个窗口(`tests/e2e/opfs.spec.ts`14 项) | ✅ |
| **G5** | 覆盖率真实且不倒退:`collectCoverageFrom` 无实现文件被排除;CI 有 `coverageThreshold`README 数字与产出一致 | `jest.config.cjs` 仅排除两个**纯类型**文件(`engine/interface.ts``query/ast.ts`);阈值 statements 90 / branches 82 / functions 94 / lines 93CI 常规 job 带 `--coverage`;README 写明产出命令与四个实测数字(90.43 / 82.21 / 94.27 / 93.44),且本轮经历"阈值真的失败 → 补测/删死代码"的闭环(见 `d14663e` | ✅ |
| **G6** | 文档与实现一致:README/CHANGELOG/site 中每一条能力宣称都有对应测试或已删除 | §8 的 **12 条**全部收口:订阅(已接线)、LZ4 页面压缩(A38 已修)、`./migration` 导出、react/vue 产物+d.ts+peerDeps、`disconnect` 进类型、priority(改为实现符合文档)、MVCC 快照隔离(改为如实描述)、backup 一致性快照(改为如实描述并列入已知限制)、"5 种引擎"(改为 4 模式 + 3 后端)、`diskEngine` 生效范围(改为仅 aria)、"在线一致性快照"(改为全库导出)、钩子契约(CONTRIBUTING 写明返回值语义) | ✅ |
**额外要求**CI lint 已去掉 `continue-on-error`(真正阻断);`tsconfig.test.json``tests/` 做类型检查并修复了 **103 个**被 babel 剥离类型掩盖的类型错误。 ✅