CC Switch 只加一张表,schema 版本却跳两级
先说一个对不上的地方。CC Switch 在 v3.20.0 的发布说明里写着「本版包含数据库迁移」,并注明 schema 从 v16 迁到 v17(docs/release-notes/v3.20.0-zh.md:238)。但把 v3.20.1 的仓库翻开,src-tauri/src/database/mod.rs:56 那一行写的是:
pub(crate) const SCHEMA_VERSION: i32 = 18;
同一个字段在 v3.19.2 快照的同一行是 = 16。也就是说这两个版本之间,schema 版本号连跳两级。可再去数建库函数里的建表语句,v3.19.2 快照是 16 条,v3.20.1 快照是 17 条——只多了一张表。表数 +1,版本号 +2。
这个差额不是笔误,也不是谁忘了对齐。它是这类工具在做数据库演进时一条相当值得抄走的纪律:版本号编的是「迁移事件」,不是「表结构的形状」。 下面按源码位置把这件事拆开。
这篇是怎么核的
本文对应的仓库快照是 3217f725(仓库内三处版本号 package.json / src-tauri/tauri.conf.json / src-tauri/Cargo.toml 都是 3.20.1),核对日 2026-08-31,另有一份 v3.19.2 的旧快照仅用于 diff。全部结论来自对这两份源码与 docs/ 下文档的静态阅读和 grep 统计——我们没有编译、没有运行、也没有安装过这个桌面应用,所以本文不会出现任何关于界面、操作或速度的描述。文中出现的数字都是当时快照里读到的值,随版本会变。
三个数字,三套口径
「schema 改了多少」这句话至少可以指三样不同的东西,混着说必然自相矛盾。它们在源码里各有一处落点:
| 口径 | 位置 | v3.19.2 | v3.20.1 |
|---|---|---|---|
SCHEMA_VERSION 常量 | src-tauri/src/database/mod.rs:56 | 16 | 18 |
迁移分支条数(N => {}) | src-tauri/src/database/schema.rs 的迁移循环 | 16 | 18 |
| 基线建表语句条数 | create_tables_on_conn 函数体内的 CREATE TABLE | 16 | 17 |
前两个必然相等——迁移循环是 while version < SCHEMA_VERSION(新快照 schema.rs:458)逐级推进的,从 0 一路走到常量值,缺一级就会掉进兜底分支报「未知的数据库版本」。第三个是另一回事:它数的是新库从零建起来长什么样,跟历史上迁移过几次没有对应关系。
数第三个数字时还有个坑:必须限定在建库函数体内。如果直接 grep -rni "CREATE TABLE" src-tauri/src | wc -l,两个快照分别是 89 和 96,因为把 #[cfg(test)] 里的测试夹具和迁移过程中的中转表全数进去了。这个口径不能当事实用。
顺带提一处更容易撞车的地方:schema.rs:299 的建表注释写的是「18. Session Log Sync 表 (会话日志同步状态)」,前面一条是 17.、后面 schema.rs:339 是「19. Profiles 表」。这个 18 是建表顺序的序号,跟 schema 版本号 18 只是数值上恰好撞上了。版本号只在 database/mod.rs:56。
第一次迁移:v16 → v17,确实建了一张表
分发在 schema.rs:542-546,日志文案是「迁移数据库从 v16 到 v17(添加会话用量持久去重账本)」,实现在 schema.rs:1565-1578,一句 execute_batch 建一张表加一条索引:
CREATE TABLE IF NOT EXISTS session_usage_dedup (
data_source TEXT NOT NULL,
request_id TEXT NOT NULL,
semantic_id TEXT NOT NULL,
has_entry_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (data_source, request_id)
);
CREATE INDEX IF NOT EXISTS idx_session_usage_dedup_semantic
ON session_usage_dedup(data_source, semantic_id, has_entry_id);
这张表为什么要单独存在,源码注释(schema.rs:319-320)一句话说清了:会话明细行在 rollup 之后会被剪掉,而识别「分叉 / 改写」所需的 request id 必须活得比明细更久,所以另开一本紧凑的持久账本。三个字段的分工也能从名字读出来:request_id 是硬 id,semantic_id 是没有 id 时按内容算出的等价键,has_entry_id 是区分这两种情况的标志位——索引因此建在 (data_source, semantic_id, has_entry_id) 上,而不是主键上。
这次迁移带来的就是那张多出来的表,create_tables_on_conn 里也同步补了同名的建表语句(schema.rs:321-337),所以新库和迁移过来的老库能对上。基线建表从 16 条变 17 条,是这一次贡献的。
第二次迁移:v17 → v18,一张新表都没建
分发在 schema.rs:547-551,日志文案「迁移数据库从 v17 到 v18(会话日志字节游标列)」,实现在 schema.rs:1588-1600,全文很短:
fn migrate_v17_to_v18(conn: &Connection) -> Result<(), AppError> {
// 缺表的库(异常/测试夹具)跳过:create_tables 会以含列的新 DDL 建表。
if Self::table_exists(conn, "session_log_sync")? {
Self::add_column_if_missing(conn, "session_log_sync", "last_byte_offset", "INTEGER")?;
Self::add_column_if_missing(conn, "session_log_sync", "last_tail_fingerprint", "INTEGER")?;
}
Ok(())
}
它只给一张已有的表补两个可空列。这就是「版本 +1 而表数不变」的那一次。
两个新列服务的是 v3.20.1 那条会话日志增量扫描的改动:last_byte_offset 存字节游标,last_tail_fingerprint 存游标边界前那段字节的指纹。它们的空值语义在建表注释里写死了(schema.rs:299-306):last_byte_offset 为 NULL 表示尚无字节游标(旧的行号游标行,或者非 Claude 路径的行),此时回退全量读;last_tail_fingerprint 为 NULL 表示无指纹可校验,按纯追加处理。
这一点值得单拎出来:迁移没有给存量行回填一个「看起来合理」的默认值,而是让它们保持 NULL,并把 NULL 定义成一个明确的降级分支。给存量行编一个假的字节位置,代价是下一轮扫描会从错误的位置开始读;保持 NULL 则只是多读一遍。这个取舍在迁移代码里只体现为「什么都不做」,真正承担它的是读取侧的回退逻辑。
为什么第二次不能补进 v17
这是整件事最硬的一处。函数上方 schema.rs:1580-1587 的注释把理由写死了,原文:
独立成版而非搭 v17 车:v17 已在开发库上执行过(迁移不会重跑,
CREATE TABLE IF NOT EXISTS也不补列),追加进 v17 会让这些库永远缺列。
把这段话展开就是一条因果链:
- 迁移循环按
user_version逐级推进,某个库跑完 v17 之后,它的user_version就写成了 17(分支在schema.rs:542-546),v16 → v17 那个分支此生不会再进来; - 就算硬把加列语句塞进
migrate_v16_to_v17,也救不了这批库——它们不会再执行那个函数; - 而基线建表里那句
CREATE TABLE IF NOT EXISTS,遇到已存在的表是直接跳过的,不会去比对列、更不会补列; - 于是这批已经跑过 v17 的库,会永远停在缺两列的状态,且没有任何一条代码路径能发现这件事。
所以第二次改动必须换一个新号。版本号的作用不是描述「现在的表长什么样」,而是给每一次「已经发生过的迁移动作」发一个不可回收的编号——一旦有任何一个库把某个号跑完了,那个号的内容就冻结了,只能往后加。
这也解释了为什么这类版本号只会越跳越快、跟表数量对不上:同一条演进线上做两次独立的结构改动,就是两个号,哪怕第二次只加了两列。本例的这两次还分属两个发布——v16 → v17 在 v3.20.0 的发布说明里(v3.20.0-zh.md:238),v17 → v18 是 v3.20.1 才叠上去的。
这个决定被写成了测试
上面这段推理不是文章里的引申,它在测试里有对应的钉子。两个迁移各配一个测试(schema.rs:3430 与 schema.rs:3448),后者的名字直接叫 migrate_v17_to_v18_adds_byte_cursor_to_existing_sync_table,而它的夹具形状恰好就是注释里担心的那种库——测试注释(schema.rs:3449-3450)写明:模拟的是「字节游标曾短暂搭 v17 车、已执行过 v17 的开发库」。
换句话说,这段测试体(schema.rs:3448-3480)专门去构造一个「已经跑过 v17、却缺这两列」的库,再让迁移跑一遍,看列有没有补上、存量行的取值是不是仍旧维持在上一节说的那个空值状态。上一节那段注释担心的场景,在这里被翻译成了机器可以复核的形式。
把「为什么这么写」的推理落成一个可执行的断言,比写在注释里更耐得住后人重构。 前一个测试 migrate_v16_to_v17_creates_session_usage_dedup_ledger(schema.rs:3430)同样把要验的事写进了函数名:v16 → v17 那一次,去重账本那张表确实被建了出来。
号只升不降,连带出两条防线
版本号既然只能往前发,就得处理「拿旧应用打开新库」这种情况。这一条在 schema.rs:449-455:进迁移循环之前先比一次,version > SCHEMA_VERSION 就直接报错返回,文案是「数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。」
所以发布说明里那句「降级需还原备份」不是一条使用建议,是代码在这一行主动拒绝的结果——库一旦被新版本推过号,旧版本就打不开了。另有一个专门读磁盘 user_version 的函数(database/mod.rs:169-182),注释写「仅当它比应用支持的 SCHEMA_VERSION 更新时返回」,是给上层显示这个状态用的探测入口。
第二条防线是迁移前自动备份(database/mod.rs:132-135)。这个分支的形状是这样的(中间省略了实际落备份与失败处理的语句,只保留判定条件与日志文案):
if version > 0 && version < SCHEMA_VERSION {
... "Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
}
两个条件缺一不可:version > 0 排掉全新库(没什么可备份的),version < SCHEMA_VERSION 才说明真要迁移。两者都满足才落一份备份——迁移动作本身不可逆,这份备份是「降级需还原备份」那句提示背后唯一的实际抓手。
引用这个版本号的时候要带口径
最后回到开头那处对不上。发布说明 v3.20.0-zh.md:238 写的「schema 从 v16 迁移到 v17」,在它发布的那个时点是准确的;到 v3.20.1 的快照上,代码里的 SCHEMA_VERSION 已经是 18,因为中间又叠了一次 migrate_v17_to_v18。两处说的是同一条时间线上的不同点,以我们实读的仓库状态为准。
实践上的意思很直接:照抄发布说明里的版本号会写错。 要提这个数就得带上限定——「v3.19.2 时是 16,v3.20.1 已经是 18」,或者干脆只写机制、不写数值。这个数下个月还会再涨。
想自己核一遍,三条命令就够:
grep -n "SCHEMA_VERSION" src-tauri/src/database/mod.rs
grep -n "迁移数据库从" src-tauri/src/database/schema.rs
grep -n "fn create_tables_on_conn" src-tauri/src/database/schema.rs
第一条给常量当前值,第二条把每一级迁移的日志文案连同分支行号一起列出来(每条文案后面括号里就写了这一级干了什么),第三条定位建库函数,再在函数体范围内数 CREATE TABLE 就是基线表数。三个数对不上是正常的——现在你知道差额从哪来。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,
核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。