From cb72759b568baf34281bd15f821ac9834c17bbb9 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 14:24:29 +0800
Subject: [PATCH] merge: 合并 origin/master schema v2 重构(RWR/增量对账/explore),保留 tag/entity 实验于 experiment 分支 + 本地 expandPath 修复与 draft list 过滤

---
 internal/index/migrations_test.go |  246 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 246 insertions(+), 0 deletions(-)

diff --git a/internal/index/migrations_test.go b/internal/index/migrations_test.go
new file mode 100644
index 0000000..5f9ab8e
--- /dev/null
+++ b/internal/index/migrations_test.go
@@ -0,0 +1,246 @@
+package index
+
+import (
+	"database/sql"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/aisim/kb-cli/internal/graph"
+)
+
+// 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")
+	}
+}
+
+// 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()
+	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
+}

--
Gitblit v1.10.0