| 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 文件(已有备份则跳过)。 |
| | | // 用 VACUUM INTO 而非 os.ReadFile:WAL 模式下未 checkpoint 的数据在 |
| | | // -wal 文件里,直读主文件会漏掉这部分,导致备份不一致;VACUUM INTO |
| | | // 会合并 WAL 生成一份完整的独立库文件(SQLite 3.27+)。 |
| | | func (s *Store) backupDB() error { |
| | | if s.dbPath == "" { |
| | | return nil // 无路径信息时跳过备份(测试场景) |
| | | } |
| | | dst := s.dbPath + ".bak" |
| | | if _, err := os.Stat(dst); err == nil { |
| | | return nil // 已有备份 |
| | | } |
| | | _, err := s.db.Exec(`VACUUM INTO ?`, dst) |
| | | return err |
| | | } |
| | | |
| | | // migrateV1toV2 执行 v1 → v2 的全部变更。 |
| | | // 注意:版本号(schema_versions)必须在最后一步(FTS 重建)成功后才写入, |
| | | // 否则中途失败时后续 Open 会读到 version=2 直接早退,半迁移状态无法重试。 |
| | | 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 |
| | | } |
| | | |
| | | // 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 |
| | | } |
| | | 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 表是否已有指定列 |
| | | 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 |
| | | } |