主题
21 · RB 确认:push dirty-check 按 user_id 过滤已落地
物理位置:本文件在 RVH 仓库
~/reading_vocab_helper/docs/cross-end/21-rb-push-user-scoping-confirmation.md(回写到发起方目录,对应20-rb-push-user-scoping-handoff.md§6 的要求)。 RB 侧副本:~/reading-browser/docs/cross-end/21-rb-push-user-scoping-confirmation.md创建:2026-08-05 · 执行方:RB 会话 · 基线
2c3c871· 对应 RB 红线 #5i / RVH 红线 #5h 不涉及 schema 变更、不涉及 Supabase 变更、无迁移。
0. 一句话结论
交接单 §1 的 6 处缺陷全部已修,并超出交接范围多修了 2 处同类缺陷 (upload_snapshots 的 Storage 上传、get_sync_status 的待推送计数)。
修法没有采用「6 处各自补一段 WHERE」,而是把谓词抽成单一常量 push.rs::USER_SCOPED_DIRTY,全部 11 个调用点复用——括号写在常量里一次, 每个调用点物理上不可能漏。§4.2 那个「最危险的地方」被结构性消除,而不是靠纪律守住。
word_page_links 的写入点已逐个核实:没有漏填 user_id 的路径,交接单 §2「产生不了 NULL 行」的结论成立,本条维持「防御性修复」定性,无需上调优先级。
1. 逐项回答交接单 §6
1.1 六个函数是否都改了、括号是否都加了
都改了。括号问题不复存在——所有调用点不再各写一份谓词,而是内插同一个常量:
rust
pub(super) const USER_SCOPED_DIRTY: &str = "user_id = ?1 \
AND ((synced_at IS NULL) \
OR (updated_at IS NOT NULL AND updated_at > synced_at) \
OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at)))";调用点形如 conn.prepare(&format!("SELECT ... FROM t WHERE {USER_SCOPED_DIRTY} LIMIT 100")), 参数绑 [&config.user_id]。
| 表 | 交接单标注 | 现状 |
|---|---|---|
learning_entries | ❌ 无过滤 | ✅ 复用常量(顺手去掉了没必要的 ne. 别名) |
reading_notes | ❌ 无过滤 | ✅ 复用常量 |
reading_pages | ❌ 无过滤 | ✅ 复用常量 |
word_page_links | ❌ 无过滤 | ✅ 复用常量 |
word_cloze_contexts | ❌ 无过滤 | ✅ 复用常量 |
known_words | ❌ 无过滤 | ✅ 复用常量 |
page_annotations | ✅ 已正确 | ✅ 改为复用常量(消除第二份副本) |
favorite_sites | ✅ 已正确 | ✅ 改为复用常量 |
rss_feeds | (交接单未列,本来就正确) | ✅ 改为复用常量 |
domain_prefs | (交接单未列,本来就正确) | ✅ 改为复用常量 |
注:交接单说「8 个 push 里 6 个有缺陷」。实际是 10 个 push 函数(另有
rss_feeds/domain_prefs两个本来就写对了,交接单的表格没列到)。缺陷数 6 无误。
为什么把已经写对的 4 个也一并改掉:留 5 份手抄副本 = 留 5 个未来漏括号的机会。 现在 push.rs 里「归属 + 脏」这件事只有一处定义。
1.2 = ?1 还是 IS ?1:选 = ?1
依据(RB 自己的读侧,已 grep 核实,非照抄 RVH):
- RB 读侧压倒性用
user_id = ?1——非 sync/auth 路径共 65 处。 - 只有 2 处遗留的游客兼容读法 OR 进了 NULL 行,且都自带「兼容旧数据」注释:
commands/notes.rs:485get_continue_reading:WHERE (rs.user_id = ?1 OR rs.user_id IS NULL)commands/history.rs:52ensure_source_for_url:WHERE ... AND (user_id = ?3 OR user_id IS NULL)
即 RB 与 RVH 结论一致但理由不同:RVH 是「NULL 行读侧根本看不见」, RB 是「NULL 行只在两处遗留兼容读法里可见,而这两处所依赖的 NULL 行在登录后根本不存在」—— clear_learning_data_if_user_changed 用 user_id IS NULL OR user_id != ?1 在登录时把 NULL 行 一并清掉,且 current_user_id 在未登录时返回 Err 使写入直接失败,登录后产生不了新的 NULL 行。
所以 §4.3 提的「UI 里看得见但永远同步不上去」这个副作用在 RB 不成立(那些行不会存在)。
未回填:确认没有把 NULL 行 UPDATE 成当前 user_id。常量的 doc comment 里把这条禁令写死了。
1.3 §4.4 word_page_links INSERT 点核实结论(最需要看的一节)
结论:没有漏填 user_id 的写入路径。交接单第 2 节的结论成立,优先级维持「防御性」。
grep -rn "INTO word_page_links" 全仓命中 8 处(交接单估的 7 处应是 grep 窗口差异), 逐个核实如下:
| # | 位置 | 性质 | user_id |
|---|---|---|---|
| 1 | commands/vocabulary/crud.rs:351 (save_word) | 生产写入 | ✅ 显式列 + 绑 &user_id |
| 2 | commands/vocabulary/crud.rs:466 (batch_save_words) | 生产写入 | ✅ 同上 |
| 3 | commands/vocabulary/crud.rs:566 | 生产写入 | ✅ 同上 |
| 4 | commands/sync/pull.rs:703 (pull_word_sources) | 生产写入 | ✅ 绑 &config.user_id(红线 #5b) |
| 5-7 | commands/notes.rs:1321,1322,1333 | 单测 fixture(4 列裸表) | n/a |
| 8 | commands/sync/pull.rs:1677 | 单测 fixture(墓碑回声环测试的 4 列裸表) | n/a |
4 个生产写入点全部显式写 user_id,且 crud.rs 三处的 user_id 均来自 commands::user_ctx::current_user_id(&conn)?(文件头 use + 函数内 ? 传播), 未登录时整个命令 Err 返回,不会落 NULL 行。
交接单看到「只在 3 处附近看到 user_id」,是因为剩下 4 处里有 3 处是 notes.rs 的测试 fixture(裸 VALUES ('w1','p1',NULL,NULL),表结构都不是真的),第 4 处是 pull.rs 的测试 fixture。真实写入点只有 4 个,且全都填了。
1.4 回归测试覆盖场景
两组,共 12 例,全绿(cargo test --lib 94 passed / 0 failed)。
A. push.rs::push_user_scoping_tests(10 例) —— 直接对 USER_SCOPED_DIRTY 实跑, 断言「哪些行会被选中推上云」:
| 用例 | 场景 | 断言 |
|---|---|---|
own_new_row_is_pushed | 自己的新增行 | 推 |
own_modified_row_is_pushed | 自己的「已同步后又被改」 | 推 |
own_tombstone_row_is_pushed | 自己的「软删但不 bump updated_at」 | 推 |
own_clean_row_is_not_pushed | 自己的干净行 | 不推(防修过头) |
new_foreign_row_is_not_pushed | 外来新增行 | 不推 |
modified_dirty_foreign_row_is_not_pushed | 外来的「已同步后又被改」 | 不推 ← 漏括号探针 |
tombstoned_dirty_foreign_row_is_not_pushed | 外来的「已同步后被软删」 | 不推 ← 漏括号探针 |
null_user_dirty_rows_are_not_claimed | NULL 归属行的三种脏法 | 都不认领(锁 = ?1 语义) |
mixed_population_selects_only_own_dirty_rows | 8 行混合种群 | 精确等于自己的 3 条脏行 |
missing_parens_would_leak_foreign_rows | 反向断言 | 见下 |
§4.2 点名要求的「已同步过、之后又被改 / 被软删的外来行」那一组 = 上表加粗的两例, 这两例是唯一能红的漏括号探针。
额外加的反向断言 missing_parens_would_leak_foreign_rows:在测试里显式写出漏括号 版本的谓词,断言它确实会泄漏那两行,同时正确谓词一行都不选。这锁的是 「探针数据本身有鉴别力」——防止将来有人改动 fixture 让上面两例变成假绿。 (体例上参考了本仓 pull.rs::tombstone_echo_tests 的 old_form_would_echo_forever。)
B. sync/mod.rs::pending_push_sql_tests(2 例) —— 见 §2.2。
C. 结构守卫(在 A 组内,every_push_query_uses_the_shared_predicate): 断言 push.rs 实现部分里 LIMIT 100 的出现次数 == WHERE {USER_SCOPED_DIRTY} 的次数 == 10。 新写一个 push 函数却手搓 WHERE,两个计数就对不上 → 红。
这里没有采用 RVH 那种 awk 版 CI grep。原因:RB 把谓词抽成了单一常量, 「逐行校验
WHERE user_id = ?下一行以AND (开头」已无对象可校验(全文件只有一处, 且它就是常量定义本身)。真正还需要守的不变式变成了「新 push 函数有没有复用常量」, 这条用上面的源码级计数守卫更贴切,也不会像 grep 那样因换行/改名假绿。 交接单提醒的「别用grep -c 'AND (synced_at IS NULL'松匹配」这个坑,RB 侧不适用。
1.5 §4.5 待推送计数:确认存在,且口径不一致,已跟改
RB 有等价物:commands/sync/mod.rs::get_sync_status 的 pending_push(10 张表 COUNT(*) 相加,喂 UI 的「待同步」数)。
实测它两个方向都与 push 口径不一致:
- 漏 user_id 归属过滤(10 张表全漏)——正是交接单预警的那个「清不掉的计数」: 修好 push 之后,外来脏行推不走了,但计数照数,UI 会永远显示一个非零待同步数。 即:不跟改这里,本次修复反而会制造一个新 bug。
learning_entries那条还漏了墓碑分支((SELECT COUNT(*) FROM learning_entries WHERE synced_at IS NULL OR updated_at > synced_at),没有deleted_at那一支), 而push_notebook_entries有——反方向漂移:软删行 push 会推、计数不算。 这条是既有的、与本次任务无关的漂移,顺手一并根除。
改法:计数 SQL 改为按 PENDING_PUSH_TABLES 列表 + 同一个 USER_SCOPED_DIRTY 拼出, 10 个子查询共用一个 ?1(SQLite 编号参数可重复引用)。两处口径现在物理上无法漂移。 未登录(current_user_id 返回 Err)时计 0——push 本来也跑不了,计 0 才诚实。
配套测试 sync/mod.rs::pending_push_sql_tests:
pending_push_sql_executes_against_head_schema:对真实 head schema(跑完整迁移链的 内存库)实跑那条运行时拼出来的 SQL。这条测试有独立价值——该 SQL 的失败被.unwrap_or(0)吞掉,任一表少一列,生产里只会永远显示「0 待同步」,不报错不留日志。 (reading_notes.deleted_at就来自 v30 ALTER-ADD 而非冻结的 schema.sql,正是这类风险的实例。) 为此把db::migrations::migration_chain_tests::apply_all提升为pub(crate)。pending_push_counts_only_own_dirty_rows:4 行混合种群(含外来的「已同步后又被改」行), 断言只数到 1。
2. 超出交接范围的第 7 处:upload_snapshots
交接单没列到,但属于同一缺陷类,且泄漏的东西更实在。
push.rs::upload_snapshots 选 reading_pages 里「有本地快照、尚未上传」的行:
sql
-- 修前
SELECT id, cached_file_path FROM reading_pages
WHERE cached_file_path IS NOT NULL AND (storage_path IS NULL OR storage_path = '')
LIMIT ?1无归属过滤,而后把读出的文件字节 POST 到 {config.user_id}/{rel_path} —— 当前用户的 Storage 前缀下,再把 storage_path 写回该行。
即:外来行的页面正文快照本身会被上传到当前用户的 bucket 目录。比 push 表的元数据泄漏更直接, 且不受本次 push 修复的保护(它走 Storage API,不走 post_rows)。
已加 user_id = ?1(LIMIT 参数顺延为 ?2)。这条不能复用 USER_SCOPED_DIRTY—— 它的谓词语义不同(不是脏检查),只共享归属过滤那一半。
3. 改动清单
| 文件 | 改动 |
|---|---|
src-tauri/src/commands/sync/push.rs | 新增 USER_SCOPED_DIRTY 常量(含完整 why 注释);10 个 push 函数改为复用;upload_snapshots 加归属过滤;新增 push_user_scoping_tests(10 例) |
src-tauri/src/commands/sync/mod.rs | 新增 PENDING_PUSH_TABLES;get_sync_status 计数改为复用常量 + 按当前用户过滤;新增 pending_push_sql_tests(2 例) |
src-tauri/src/db/migrations.rs | migration_chain_tests / apply_all 提升 pub(crate)(供上面的 head-schema 冒烟测试复用),无逻辑改动 |
CLAUDE.md | 立红线 #5i(§4 技术红线表,紧跟 5d 之后) |
验证:cargo test --lib 94 passed / 0 failed(新增 12 例); cargo check --all-targets 干净;cargo clippy 在 push.rs 上零警告。
未动:schema、迁移链、Supabase DDL、pull 路径、payload 结构。行为对「本地只有当前用户数据」 的正常库完全无变化(这也是它是防御性修复的原因)。
4. 给 RVH 的两点回馈
= ?vsIS ?两端结论一致(都用=),但理由不同——RB 靠「登录硬闸清掉 NULL 行 +current_user_id使未登录写入失败」,RVH 靠「读侧IS ?看不见 NULL 行」。 RVH 那边 NULL 行是永久残留的(清库 WHERE 是user_id = ?,删不掉 NULL), 所以 RVH 侧「不认领」是在跟一批真实存在的行较劲;RB 侧是在跟一批不该存在的行较劲。 如果 RVH 将来想根治,可以考虑把清库条件对齐成 RB 的user_id IS NULL OR user_id != ?。建议 RVH 也核一下自己的 Storage / 文件上传路径(若有)与
getSyncStatus的口径。 RB 这边这两处都中招了,其中getSyncStatus等价物如果不跟改, 修 push 反而会造出一个「清不掉的待同步计数」——交接单 §4.5 的预警在 RB 实测成立。