Files
MetonaSqlark/PLAN-v0.7.5.md
T

71 KiB
Raw Blame History

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
覆盖率门禁 不存在(无 coverageThresholdCI 不跑 --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.jsonjest.config.cjsjest.setup.jsrollup.config.jstsconfig.json.eslintrc.jsonbabel.config.cjsplaywright.config.ts.gitea/workflows/ci.yml.npmrc.gitignorecoverage/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 aliasCOUNT(*)、常量、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 t0(带表前缀的聚合参数取不到键)
  • 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/currentSegmentFileManager.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-298break并非跳过后续记录),这个方向本身正确;问题只在于"截断成空"而不是"截断到有效前缀"。

  • checkpoint 失败 → 调用方收到 rejection,但数据已提交且持久appendRecordmedium.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 也无重试路径 → 内存成唯一副本,崩溃即丢flushImmutableAsynclsm.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 互斥(compactinglsm.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:93aria-prod-load.test.ts:71aria-wal-segment.test.ts:173 等十余处),而 opfs_backend.ts:39-46close() 明确 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_ACTIVErollbackTransaction关闭前的陈旧快照覆盖新会话(数据复活)
  • close() 后再 init() 不重建 BroadcastChannel → multiTabSync 静默失效
  • close() 无 try/finally → engine.close 抛错时 isReady() 仍为 true(半关闭态),钩子已全丢
  • 连接池:用户直接 db.close() 后同名 connect() 新建实例并把 refCount 置 1任意一次 disconnect 就关闭它
  • 多标签页 reload 不检查活跃事务 → 内存快照被销毁、txActive 残留 → 提交时内存≠磁盘

根因 7 "未解析/未知"一律静默降级为 false

这是复发型缺陷模式,CHANGELOG 连续三个版本都在修它的不同实例:

  • v0.7.3queryStream$subquerymatchWhere 恒 false → 静默空结果
  • v0.7.4UPDATE/DELETE$subquery静默影响 0 行
  • 本次仍存在:WHERE t.x = t.y(唯一可解析的列对列写法)恒 0 行;IN (SELECT ... WHERE o.user_id = u.id) 关联引用静默空

修法是逐路径补丁 + 两个手写重复的递归探测器core.ts:284-306where-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:truecheckUpdateUniqueness 只遍历 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 只改内存 txnSnapshotWAL 无补偿记录;恢复按 committedTxns 应用该事务全部记录
  • 修复ROLLBACK TO 写入补偿 WAL 记录(或在恢复时按 savepoint 边界过滤)
  • 测试:上述序列后丢弃引擎实例重开,断言实时态 == 恢复态

A4 ★ SAVEPOINT 跨事务泄漏 → 当前事务写入被上一事务快照替换 【实测】

  • 现象begin; update; savepoint sp; commitsavepoints 仍含 sp;新事务 updateROLLBACK TO sp + COMMIT新写入静默消失(本方案验证时实测 v=2 被回退为 v=1)
  • 根因aria/index.ts:1467-1534 的 commit/rollback 都不清理 this.savepointsindex.ts:1563sp.snapshot 整体替换当前快照
  • 修复commit/rollback 清空 savepoint 表;rollbackToSavepoint 校验 sp.txnId === currentTxnId(存了 txnId 却从不校验)
  • 测试:跨事务复用 savepoint 名 → 必须拒绝;当前事务写入不得丢失

A5 ★ MIN/MAX 20 万行栈溢出 【实测】

  • 现象SELECT MAX(v) FROM big20 万行同组)→ RangeError: Maximum call stack size exceeded
  • 根因executor.ts:677-678Math.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 mm>0)在 JOIN / 非 JOIN / 派生表 / UNION 四条路径结果一致

A7 ★ queryStreamquery 结果不一致【实测】

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/DELETESQL 与 Table API 两条路径)fn 调用 0 次
  • 根因:全库仅 core.ts:84BroadcastChannel 外部消息)调用 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 的条件交给引擎,而引擎 matchWhereoptions.$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 软删标记"的写法都会静默错行
  • 修复parseConditionparseOr → parseAnd → parseUnary 三层
  • 测试:断言 AST 结构与值级结果,覆盖 A OR B AND CA AND B OR C、嵌套括号

工作流 A 的第二梯队(18 项高优 P1,逐项列入 §5 总账)

其中必须在 v0.7.5 内完成的:

# 缺陷 实测现象 一句话修法
A12 maxLength/min/max 三引擎不校验 memory/disk/hybrid 接受 score:999max 100 统一 validateRowB-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 depts 整列消失 列绑定(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 ... SELECTmaxRowsPerQuery 静默截断 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
  • 消除A10t.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_manifestmagic + 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 === currentTxnIdAriaEngine.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-42crashPage() 改用 CDP

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.jsontests/ 做类型检查(当前 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"实际断言 resolvesv044-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 queryStreamquery 结果不一致(别名/限定列/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() 记过后台错误即跳过本次 flushlastBackgroundError 检查在入链之前) 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]) 永不触发且无 onUnmountedpackage.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 有 coverageThresholdREADME 数字与 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:189CONTRIBUTING:166 priority 完全无效,顺序 = config 数组顺序 实现 或 删除该字段
"14 种钩子 allow intercepting" CONTRIBUTING:137 只有对象就地修改有效;返回值被丢弃;SQL 路径 beforeInsert 改的是副本 明确钩子契约 + 测试
"MVCC 版本链 + 快照隔离,事务读写不互斥" README:358 snapshotLsn/prevVersion 是死字段;并发被 TX_ACTIVE 拒绝 改写为"单事务 + 快照回滚"
"LZ4 页面压缩" README:313/354 pageStorage 开启时压缩分支永不执行 修复(A38)或改称"可选压缩(整 value 模式)"
"diskEnginedisk 模式生效)" README:181 core.ts:621-623 传参但 KVStoreEngine 忽略hybrid 的 diskEngine 也无效 实现 或 删除配置项
"5 种存储引擎" README:420 4 种 mode + 3 种后端,不是 5 个引擎 修正表述
"@metona-team/metona-sqlark/migration" README:253 exports ./migrationERR_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 · 复现命令速查

# 基线
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 negativeMath.abs((h1+i*h2) % bits) 看着像经典的负余数 bugh1 + 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 换成带意图记录、可重试的串行工作队列compactingSet<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+ coverageThresholdtsconfig.test.json 并修复 103 个测试类型错误lint 清零;CI 增加 tests 类型检查 / 覆盖率门禁 / lint 阻断 / dist 同步校验;版本 0.8.0 三处一致 + 版本契约测试
83c5aa0 A11 AND/OR 优先级 parser 三层分层 or → and → simplea=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_FOUNDINSERT arity 校验;__proto__ 列名防护统一
074afd3 A8 + LSM 读自洽 行所有权写进 IStorageEngine 契约(读返副本/写收副本)+ cloneRowMemory/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.tscompileValidator)作为唯一行校验定义,消除三份分叉实现(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) continuememory 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 = NULLn 列含 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.tsIS 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 列表含 NULLx 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.tsv080-atomicity.test.tsv080-subscribe.test.tsv080-correlated.test.tsv080-sql-three-valued.test.tsv080-unified-validation.test.tsv080-query-layer.test.tsv080-single-pipeline.test.tsv080-kvstore-commit-point.test.tsv080-column-resolution.test.tsv080-foreign-key.test.tstests/engine/aria-compression.test.tsversion-contract.test.tshelpers/storage-harness.test.ts

关键修复均做过变异验证:把修复回退到修复前的行为,对应用例必须失败 (例:KVStore ①2 项、②3 项)。这是"测试真的能拦住回归"与"测试只是陪跑"的 分界线,后续新增回归套件都按此执行。

验证状态typechecksrc + 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 + 4ab04dfenginePreFilter
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 自引用外键失效 85f0f17A14 传递链实测正常,非缺陷)
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 6a0cfeb14 项 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 的目录语义)—— 它们此前让"多实例/多库"的验证跑在错误语义上。