ax_rd
8 hours ago 9587af9c909e7a6e799b663c4d5be4b50f68382e
fix: 迁移版本写入时序 + VACUUM INTO 备份 + 触发器回归测试
2 files modified
148 ■■■■■ changed files
internal/index/migrations.go 33 ●●●●● patch | view | raw | blame | history
internal/index/migrations_test.go 115 ●●●●● patch | view | raw | blame | history
internal/index/migrations.go
@@ -35,7 +35,10 @@
    return nil
}
// backupDB 迁移前备份 .db 文件(已有备份则跳过)
// backupDB 迁移前备份 .db 文件(已有备份则跳过)。
// 用 VACUUM INTO 而非 os.ReadFile:WAL 模式下未 checkpoint 的数据在
// -wal 文件里,直读主文件会漏掉这部分,导致备份不一致;VACUUM INTO
// 会合并 WAL 生成一份完整的独立库文件(SQLite 3.27+)。
func (s *Store) backupDB() error {
    if s.dbPath == "" {
        return nil // 无路径信息时跳过备份(测试场景)
@@ -44,23 +47,19 @@
    if _, err := os.Stat(dst); err == nil {
        return nil // 已有备份
    }
    data, err := os.ReadFile(s.dbPath)
    if err != nil {
        return err
    }
    return os.WriteFile(dst, data, 0644)
    _, err := s.db.Exec(`VACUUM INTO ?`, dst)
    return err
}
// migrateV1toV2 执行 v1 → v2 的全部变更
// migrateV1toV2 执行 v1 → v2 的全部变更。
// 注意:版本号(schema_versions)必须在最后一步(FTS 重建)成功后才写入,
// 否则中途失败时后续 Open 会读到 version=2 直接早退,半迁移状态无法重试。
func (s *Store) migrateV1toV2() error {
    // 1. schema_versions 表
    // 1. schema_versions 表(只建表,版本号推迟到最后写入)
    if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
        key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
        return err
    }
    if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', '2')`); err != nil {
        return err
    }
    // 2. nodes 加列(逐列检查,幂等)
    for _, col := range []struct{ name, def string }{
@@ -107,7 +106,17 @@
    if err := s.CreateFTS(); err != nil {
        return err
    }
    return s.PopulateFTS()
    if err := s.PopulateFTS(); err != nil {
        return err
    }
    // 6. 最后一步:全部成功后才写入版本号(参数化,引用常量 currentSchemaVersion)。
    // 提前写入会导致中途失败时后续 Open 读到 version=2 直接早退,半迁移状态无法重试。
    if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', ?)`,
        currentSchemaVersion); err != nil {
        return err
    }
    return nil
}
// nodeColumnExists 检查 nodes 表是否已有指定列
internal/index/migrations_test.go
@@ -5,6 +5,8 @@
    "os"
    "path/filepath"
    "testing"
    "github.com/aisim/kb-cli/internal/graph"
)
// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
@@ -99,6 +101,119 @@
    }
}
// TestMigrateVersionNotWrittenOnFailure 验证版本写入时序:
// 若 FTS 重建中途失败,schema_versions 中不得出现 version='2'
// (否则后续 Open 读到 version=2 直接早退,半迁移状态无法重试)。
// 手法:把 v1 库的 nodes.content_fts 列改名,FTS5 external-content
// 建表引用不存在的列必然真实失败(CreateFTS 第 1 步即报错)。
func TestMigrateVersionNotWrittenOnFailure(t *testing.T) {
    store, _ := newV1Store(t)
    defer store.Close()
    // 破坏 content 表:content_fts 列改名,使 FTS external-content 建表失败
    if _, err := store.db.Exec(`ALTER TABLE nodes RENAME COLUMN content_fts TO content_x`); err != nil {
        t.Fatal(err)
    }
    if err := store.migrate(); err == nil {
        t.Fatal("migrate 应返回 FTS 建表失败错误, 实际成功")
    }
    // 失败后版本号必须未写入(行不存在)
    var v string
    err := store.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&v)
    if err == nil {
        t.Fatalf("迁移失败后版本号不应写入, 实际写入了: %s", v)
    }
    if err != sql.ErrNoRows {
        t.Fatalf("查询版本行出错: %v", err)
    }
}
// TestBackupWALConsistent 验证备份一致性:WAL 模式未 checkpoint 的
// 新增数据必须包含在 .bak 里(VACUUM INTO 合并 WAL;旧的
// ReadFile 直读主文件做不到)。
func TestBackupWALConsistent(t *testing.T) {
    store, dbPath := newV1Store(t)
    defer store.Close()
    // 通过活动连接插入一条数据,停留在未 checkpoint 的 WAL 中
    if _, err := store.db.Exec(`INSERT INTO nodes (path, title, content_fts)
        VALUES ('WAL/002-新节点.md', 'walnode', 'wal数据')`); err != nil {
        t.Fatal(err)
    }
    if err := store.backupDB(); err != nil {
        t.Fatalf("backupDB: %v", err)
    }
    // .bak 必须是合法 SQLite 库且包含 WAL 中未 checkpoint 的数据
    db, err := sql.Open("sqlite3", dbPath+".bak")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    var n int
    if err := db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE path='WAL/002-新节点.md'`).Scan(&n); err != nil {
        t.Fatal(err)
    }
    if n != 1 {
        t.Fatalf("备份缺少 WAL 未 checkpoint 数据 (count=%d)", n)
    }
}
// TestFTSTriggers FTS 增删改触发器回归测试:
// 新建库 → 插节点 → FTS 可查 → UPDATE 改内容 → 新词可查/旧词不可查 → DELETE → 不可查。
// 注意:unicode61 分词下 CJK 连续串是整串 token,故用英文词验证触发器行为。
func TestFTSTriggers(t *testing.T) {
    store, err := Open(filepath.Join(t.TempDir(), "kb-fts.db"))
    if err != nil {
        t.Fatal(err)
    }
    defer store.Close()
    // INSERT 触发器:新插节点立即可被 FTS 查到
    id, err := store.InsertNode(&graph.Node{
        Path:    "TRG/001.md",
        Title:   "trigger node",
        Content: "alpha omega",
    })
    if err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "alpha"); got != 1 {
        t.Fatalf("INSERT 后 FTS 应查到 1 条 alpha, 实际 %d", got)
    }
    // UPDATE 触发器:改内容后新词可查、旧词不可查
    if _, err := store.db.Exec(`UPDATE nodes SET content_fts=? WHERE id=?`, "beta delta", id); err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "beta"); got != 1 {
        t.Fatalf("UPDATE 后 FTS 应查到 1 条 beta, 实际 %d", got)
    }
    if got := store.ftsMatchCount(t, "alpha"); got != 0 {
        t.Fatalf("UPDATE 后旧词 alpha 应不可查, 实际 %d 条", got)
    }
    // DELETE 触发器:删节点后 FTS 不可查
    if _, err := store.db.Exec(`DELETE FROM nodes WHERE id=?`, id); err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "beta"); got != 0 {
        t.Fatalf("DELETE 后 FTS 不应查到 beta, 实际 %d 条", got)
    }
}
// ftsMatchCount 统计 FTS 虚拟表中匹配 term 的文档数(测试辅助)
func (s *Store) ftsMatchCount(t *testing.T, term string) int {
    t.Helper()
    var n int
    if err := s.db.QueryRow(`SELECT COUNT(*) FROM nodes_fts WHERE nodes_fts MATCH ?`, term).Scan(&n); err != nil {
        t.Fatalf("FTS MATCH %q: %v", term, err)
    }
    return n
}
// 测试辅助:列检查
func (s *Store) nodeColumns(t *testing.T) map[string]bool {
    t.Helper()