feat: schema v2 迁移(aliases/status/文件指纹/provenance/unresolved_links + FTS external-content)+ Makefile 固化 FTS5 构建标志
2 files added
4 files modified
324 ■■■■■ changed files
Makefile 14 ●●●●● patch | view | raw | blame | history
cmd/review.go 2 ●●● patch | view | raw | blame | history
internal/index/fts.go 45 ●●●● patch | view | raw | blame | history
internal/index/migrations.go 125 ●●●●● patch | view | raw | blame | history
internal/index/migrations_test.go 131 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 7 ●●●● patch | view | raw | blame | history
Makefile
@@ -1,20 +1,24 @@
.PHONY: build test clean install
# Makefile
CGO_CFLAGS := -DSQLITE_ENABLE_FTS5
CGO_LDFLAGS := -lm
.PHONY: build test clean install test-v run-search run-index run-status
# 构建
build:
    CGO_ENABLED=1 go build -tags fts5 -o bin/kb-cli .
    CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o bin/kb-cli .
# 安装到 ~/go/bin
install:
    CGO_ENABLED=1 go install -tags fts5 .
    CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go install .
# 测试
test:
    CGO_ENABLED=1 go test -tags fts5 ./...
    CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test ./... 2>&1 | grep -v "no test files"
# 详细测试
test-v:
    CGO_ENABLED=1 go test -tags fts5 -v ./...
    CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test -v ./... 2>&1 | grep -v "no test files"
# 清理
clean:
cmd/review.go
@@ -74,7 +74,7 @@
        if err == nil {
            content := extractMainContent(string(draftContent))
            if content != "" {
                fmt.Println("### 草稿主要内容\n")
                fmt.Println("### 草稿主要内容")
                fmt.Println(content)
            }
        }
internal/index/fts.go
@@ -14,25 +14,46 @@
    Rank    float64
}
// CreateFTS 创建 FTS5 虚拟表
// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
// 故 content 列对应 nodes 表的 content_fts 列
func (s *Store) CreateFTS() error {
    _, err := s.db.Exec(`
        CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
            title,
            content,
            tags,
            entities
        )
    `)
            title, content_fts, tags, aliases,
            content='nodes', content_rowid='id'
        )`)
    if err != nil {
    return err
}
    // 触发器:nodes 的增删改同步维护 FTS
    triggers := []string{
        `CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
            INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
            VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
        END`,
        `CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
            INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
            VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
        END`,
        `CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
            INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
            VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
            INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
            VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
        END`,
    }
    for _, t := range triggers {
        if _, err := s.db.Exec(t); err != nil {
            return err
        }
    }
    return nil
}
// PopulateFTS 填充 FTS 索引
// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
func (s *Store) PopulateFTS() error {
    _, err := s.db.Exec(`
        INSERT INTO nodes_fts(rowid, title, content, tags, entities)
        SELECT id, title, content_fts, tags, entities FROM nodes
    `)
    _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
    return err
}
internal/index/migrations.go
New file
@@ -0,0 +1,125 @@
package index
import (
    "fmt"
    "os"
)
const currentSchemaVersion = 2
// migrate 幂等的 schema 升级:v1 → v2。
// v2 变更:nodes 加 aliases/status/size/mtime/content_hash 列;
// edges 加 provenance 列;新建 unresolved_links、schema_versions 表;
// nodes_fts 改 external-content + 触发器。迁移前自动备份 .db 文件。
func (s *Store) migrate() error {
    // 读当前版本
    var version int
    err := s.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&version)
    if err != nil && version == 0 {
        // 首次:schema_versions 表可能不存在
        version = 1
    }
    if version >= currentSchemaVersion {
        return nil
    }
    // 备份
    if err := s.backupDB(); err != nil {
        return fmt.Errorf("备份失败: %w", err)
    }
    // v1 → v2
    if err := s.migrateV1toV2(); err != nil {
        return fmt.Errorf("v1→v2 迁移失败: %w", err)
    }
    return nil
}
// backupDB 迁移前备份 .db 文件(已有备份则跳过)
func (s *Store) backupDB() error {
    if s.dbPath == "" {
        return nil // 无路径信息时跳过备份(测试场景)
    }
    dst := s.dbPath + ".bak"
    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)
}
// migrateV1toV2 执行 v1 → v2 的全部变更
func (s *Store) migrateV1toV2() error {
    // 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 }{
        {"aliases", "ALTER TABLE nodes ADD COLUMN aliases TEXT"},
        {"status", "ALTER TABLE nodes ADD COLUMN status TEXT"},
        {"size", "ALTER TABLE nodes ADD COLUMN size INTEGER"},
        {"mtime", "ALTER TABLE nodes ADD COLUMN mtime INTEGER"},
        {"content_hash", "ALTER TABLE nodes ADD COLUMN content_hash TEXT"},
    } {
        if !s.nodeColumnExists(col.name) {
            if _, err := s.db.Exec(col.def); err != nil {
                return fmt.Errorf("加列 %s 失败: %w", col.name, err)
            }
        }
    }
    // 3. edges 加 provenance 列
    if !s.edgeColumnExists("provenance") {
        if _, err := s.db.Exec(`ALTER TABLE edges ADD COLUMN provenance TEXT`); err != nil {
            return fmt.Errorf("edges 加 provenance 失败: %w", err)
        }
    }
    // 4. unresolved_links 表
    if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS unresolved_links (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        from_node INTEGER NOT NULL,
        link_text TEXT NOT NULL,
        name_tail TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        created_at TEXT DEFAULT (datetime('now')),
        last_attempt TEXT)`); err != nil {
        return err
    }
    if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_unresolved_tail
        ON unresolved_links(name_tail) WHERE status='failed'`); err != nil {
        return err
    }
    // 5. FTS 改 external-content:删旧表重建 + 触发器 + 重灌
    if _, err := s.db.Exec(`DROP TABLE IF EXISTS nodes_fts`); err != nil {
        return err
    }
    if err := s.CreateFTS(); err != nil {
        return err
    }
    return s.PopulateFTS()
}
// nodeColumnExists 检查 nodes 表是否已有指定列
func (s *Store) nodeColumnExists(name string) bool {
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name=?`, name).Scan(&n)
    return n > 0
}
// edgeColumnExists 检查 edges 表是否已有指定列
func (s *Store) edgeColumnExists(name string) bool {
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name=?`, name).Scan(&n)
    return n > 0
}
internal/index/migrations_test.go
New file
@@ -0,0 +1,131 @@
package index
import (
    "database/sql"
    "os"
    "path/filepath"
    "testing"
)
// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
func newV1Store(t *testing.T) (*Store, string) {
    t.Helper()
    dbPath := filepath.Join(t.TempDir(), "kb-v1.db")
    db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
    if err != nil {
        t.Fatal(err)
    }
    schema := `
    CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
    CREATE TABLE nodes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        path TEXT NOT NULL UNIQUE,
        title TEXT, section TEXT, tags TEXT, entities TEXT, wikilinks TEXT,
        content_fts TEXT,
        created_at TEXT DEFAULT (datetime('now')),
        updated_at TEXT DEFAULT (datetime('now')));
    CREATE TABLE edges (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        from_node INTEGER NOT NULL, to_node INTEGER NOT NULL,
        relation TEXT NOT NULL, label TEXT,
        UNIQUE(from_node, to_node, relation, label));
    `
    if _, err := db.Exec(schema); err != nil {
        t.Fatal(err)
    }
    if _, err := db.Exec(`INSERT INTO nodes (path, title, tags, entities, wikilinks, content_fts)
        VALUES ('FAQ/001-测试.md', '测试', '["a"]', '[]', '[]', '内容')`); err != nil {
        t.Fatal(err)
    }
    return &Store{db: db, dbPath: dbPath}, dbPath
}
func TestMigrateV1ToV2(t *testing.T) {
    store, dbPath := newV1Store(t)
    defer store.Close()
    // 迁移前备份文件必须存在
    if err := store.migrate(); err != nil {
        t.Fatalf("migrate: %v", err)
    }
    if _, err := os.Stat(dbPath + ".bak"); err != nil {
        t.Fatalf("备份文件不存在: %v", err)
    }
    // v2 列必须存在
    cols := store.nodeColumns(t)
    for _, want := range []string{"aliases", "status", "size", "mtime", "content_hash"} {
        if !cols[want] {
            t.Errorf("nodes 缺列 %s", want)
        }
    }
    if !store.edgeHasProvenance(t) {
        t.Error("edges 缺 provenance 列")
    }
    if !store.tableExists(t, "unresolved_links") {
        t.Error("缺 unresolved_links 表")
    }
    // 数据保留
    var title string
    if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试.md'`).Scan(&title); err != nil {
        t.Fatal(err)
    }
    if title != "测试" {
        t.Errorf("迁移丢数据: %s", title)
    }
}
func TestMigrateIdempotent(t *testing.T) {
    store, _ := newV1Store(t)
    defer store.Close()
    if err := store.migrate(); err != nil {
        t.Fatalf("第一次: %v", err)
    }
    if err := store.migrate(); err != nil {
        t.Fatalf("第二次(幂等): %v", err)
    }
}
func TestNewDBIsV2(t *testing.T) {
    dbPath := filepath.Join(t.TempDir(), "kb-v2.db")
    store, err := Open(dbPath)
    if err != nil {
        t.Fatal(err)
    }
    defer store.Close()
    if !store.edgeHasProvenance(t) {
        t.Error("新建库应为 v2 schema")
    }
}
// 测试辅助:列检查
func (s *Store) nodeColumns(t *testing.T) map[string]bool {
    t.Helper()
    rows, err := s.db.Query(`SELECT name FROM pragma_table_info('nodes')`)
    if err != nil {
        t.Fatal(err)
    }
    defer rows.Close()
    m := map[string]bool{}
    for rows.Next() {
        var name string
        rows.Scan(&name)
        m[name] = true
    }
    return m
}
func (s *Store) edgeHasProvenance(t *testing.T) bool {
    t.Helper()
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name='provenance'`).Scan(&n)
    return n > 0
}
func (s *Store) tableExists(t *testing.T, name string) bool {
    t.Helper()
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
    return n > 0
}
internal/index/sqlite.go
@@ -16,6 +16,7 @@
// Store SQLite 存储层
type Store struct {
    db *sql.DB
    dbPath string
}
// Open 打开或创建数据库
@@ -31,11 +32,15 @@
        return nil, fmt.Errorf("打开数据库失败: %w", err)
    }
    s := &Store{db: db}
    s := &Store{db: db, dbPath: dbPath}
    if err := s.initTables(); err != nil {
        db.Close()
        return nil, err
    }
    if err := s.migrate(); err != nil {
        db.Close()
        return nil, err
    }
    return s, nil
}