feat: schema v2 迁移(aliases/status/文件指纹/provenance/unresolved_links + FTS external-content)+ Makefile 固化 FTS5 构建标志
2 files added
4 files modified
| | |
| | | .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: |
| | |
| | | if err == nil { |
| | | content := extractMainContent(string(draftContent)) |
| | | if content != "" { |
| | | fmt.Println("### 草稿主要内容\n") |
| | | fmt.Println("### 草稿主要内容") |
| | | fmt.Println(content) |
| | | } |
| | | } |
| | |
| | | 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 |
| | | ) |
| | | `) |
| | | return err |
| | | 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 |
| | | } |
| | | |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| | |
| | | |
| | | // Store SQLite 存储层 |
| | | type Store struct { |
| | | db *sql.DB |
| | | db *sql.DB |
| | | dbPath string |
| | | } |
| | | |
| | | // Open 打开或创建数据库 |
| | |
| | | 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 |
| | | } |
| | | |