package store import ( "fmt" ) // 版本化迁移(设计 446:每版本附回滚;双档 CI 验证)。 // // 纪律: // - 迁移严格只增不改:已发布的迁移语句不可修改,修正走新版本。 // - v1 基线 = 原 CREATE IF NOT EXISTS 集;对既有部署是空操作,对新建库先落旧形 // 再演进,保证新旧库走完全相同的迁移路径。 // - 回滚(down)尽力而为:多租户数据无法折回单租户形态的步骤在语句注释中标明。 // - 并行分支合流时核对版本号唯一与顺序(数据可靠性标准:迁移验收)。 type migration struct { version int name string up map[string][]string // 方言 → 语句 down map[string][]string } var migrations = []migration{ {version: 1, name: "baseline", up: map[string][]string{ "sqlite": { `CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, settings TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, parts TEXT NOT NULL, seq INTEGER NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE)`, `CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, parent_run_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL, project_id TEXT NOT NULL, kind TEXT NOT NULL, profile_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, phase TEXT NOT NULL DEFAULT '', agent_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, ended_at TEXT, usage TEXT, error TEXT)`, `CREATE INDEX IF NOT EXISTS idx_runs_conv ON runs(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id)`, `CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT)`, `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, source_run TEXT NOT NULL DEFAULT '', content_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS hitl_cards (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, conversation_id TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, resolved INTEGER NOT NULL DEFAULT 0, resolution TEXT)`, `CREATE TABLE IF NOT EXISTS profiles (id TEXT PRIMARY KEY, builtin INTEGER NOT NULL DEFAULT 0, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS settings (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plugins (id TEXT PRIMARY KEY, dir TEXT NOT NULL, manifest TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, loaded_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plans (conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS todos (conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS knowledge (id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL, title TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '', chunk TEXT NOT NULL, created_at TEXT NOT NULL)`, `CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(title, chunk, content='knowledge', content_rowid='rowid')`, `CREATE TRIGGER IF NOT EXISTS knowledge_ai AFTER INSERT ON knowledge BEGIN INSERT INTO knowledge_fts(rowid, title, chunk) VALUES (new.rowid, new.title, new.chunk); END`, `CREATE TRIGGER IF NOT EXISTS knowledge_ad AFTER DELETE ON knowledge BEGIN INSERT INTO knowledge_fts(knowledge_fts, rowid, title, chunk) VALUES('delete', old.rowid, old.title, old.chunk); END`, }, "postgres": { `CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, settings TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, parts TEXT NOT NULL, seq INTEGER NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE)`, `CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, parent_run_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL, project_id TEXT NOT NULL, kind TEXT NOT NULL, profile_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, phase TEXT NOT NULL DEFAULT '', agent_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, ended_at TEXT, usage TEXT, error TEXT)`, `CREATE INDEX IF NOT EXISTS idx_runs_conv ON runs(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id)`, `CREATE TABLE IF NOT EXISTS events (seq BIGSERIAL PRIMARY KEY, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT)`, `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, source_run TEXT NOT NULL DEFAULT '', content_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS hitl_cards (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, conversation_id TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, resolved INTEGER NOT NULL DEFAULT 0, resolution TEXT)`, `CREATE TABLE IF NOT EXISTS profiles (id TEXT PRIMARY KEY, builtin INTEGER NOT NULL DEFAULT 0, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS settings (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plugins (id TEXT PRIMARY KEY, dir TEXT NOT NULL, manifest TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, loaded_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plans (conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS todos (conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS knowledge (id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL, title TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '', chunk TEXT NOT NULL, created_at TEXT NOT NULL)`, // PG 档全文检索在 v4 落 tsvector/trigram;基线不含 FTS5。 }, }}, {version: 2, name: "tenancy_and_integrity", up: map[string][]string{ "sqlite": { // 租户列:空串 = 单租户默认(兼容既有数据,多租户落地不改核心)。 `ALTER TABLE projects ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE conversations ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE runs ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE artifacts ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE knowledge ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE hitl_cards ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE profiles ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, // 消息序号唯一:并发追加同会话由约束兜底(先前的 SELECT MAX+1 竞态)。 `CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_seq ON messages(conversation_id, seq)`, // 事件表重建:全局自增 → 会话内连续 seq(复合主键;批量落盘按会话分配序号)。 `CREATE TABLE events_new (conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT, PRIMARY KEY(conversation_id, seq))`, `INSERT INTO events_new(conversation_id, seq, run_id, ts, v, type, payload) SELECT conversation_id, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY seq), run_id, ts, v, type, payload FROM events`, `DROP TABLE events`, `ALTER TABLE events_new RENAME TO events`, // 设置表重建:scope → (owner, scope) 复合主键(租户隔离的设置档)。 `CREATE TABLE settings_new (owner TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL, "values" TEXT NOT NULL, PRIMARY KEY(owner, scope))`, `INSERT INTO settings_new(owner, scope, "values") SELECT '', scope, "values" FROM settings`, `DROP TABLE settings`, `ALTER TABLE settings_new RENAME TO settings`, }, "postgres": { `ALTER TABLE projects ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE conversations ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE runs ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE artifacts ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE knowledge ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE hitl_cards ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE profiles ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_seq ON messages(conversation_id, seq)`, `CREATE TABLE events_new (conversation_id TEXT NOT NULL, seq BIGINT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT, PRIMARY KEY(conversation_id, seq))`, `INSERT INTO events_new(conversation_id, seq, run_id, ts, v, type, payload) SELECT conversation_id, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY seq), run_id, ts, v, type, payload FROM events`, `DROP TABLE events`, `ALTER TABLE events_new RENAME TO events`, `CREATE TABLE settings_new (owner TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL, "values" TEXT NOT NULL, PRIMARY KEY(owner, scope))`, `INSERT INTO settings_new(owner, scope, "values") SELECT '', scope, "values" FROM settings`, `DROP TABLE settings`, `ALTER TABLE settings_new RENAME TO settings`, }, }, down: map[string][]string{ "sqlite": { `DROP INDEX IF EXISTS uq_messages_conv_seq`, `CREATE TABLE events_old (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT)`, `INSERT INTO events_old(conversation_id, run_id, ts, v, type, payload) SELECT conversation_id, run_id, ts, v, type, payload FROM events`, `DROP TABLE events`, `ALTER TABLE events_old RENAME TO events`, `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`, // 回滚假定单租户(owner='');多 owner 的设置行无法折回,仅迁移 owner='' 行。 `CREATE TABLE settings_old (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`, `INSERT INTO settings_old(scope, "values") SELECT scope, "values" FROM settings WHERE owner=''`, `DROP TABLE settings`, `ALTER TABLE settings_old RENAME TO settings`, `ALTER TABLE profiles DROP COLUMN owner`, `ALTER TABLE hitl_cards DROP COLUMN owner`, `ALTER TABLE knowledge DROP COLUMN owner`, `ALTER TABLE artifacts DROP COLUMN owner`, `ALTER TABLE runs DROP COLUMN owner`, `ALTER TABLE conversations DROP COLUMN owner`, `ALTER TABLE projects DROP COLUMN owner`, }, "postgres": { `DROP INDEX IF EXISTS uq_messages_conv_seq`, `CREATE TABLE events_old (seq BIGSERIAL PRIMARY KEY, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT)`, `INSERT INTO events_old(conversation_id, run_id, ts, v, type, payload) SELECT conversation_id, run_id, ts, v, type, payload FROM events`, `DROP TABLE events`, `ALTER TABLE events_old RENAME TO events`, `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`, `CREATE TABLE settings_old (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`, `INSERT INTO settings_old(scope, "values") SELECT scope, "values" FROM settings WHERE owner=''`, `DROP TABLE settings`, `ALTER TABLE settings_old RENAME TO settings`, `ALTER TABLE profiles DROP COLUMN owner`, `ALTER TABLE hitl_cards DROP COLUMN owner`, `ALTER TABLE knowledge DROP COLUMN owner`, `ALTER TABLE artifacts DROP COLUMN owner`, `ALTER TABLE runs DROP COLUMN owner`, `ALTER TABLE conversations DROP COLUMN owner`, `ALTER TABLE projects DROP COLUMN owner`, }, }}, } // v3 认证与租户(阶段 1):user 即租户 owner;API key 只存 SHA-256 摘要。 var migV3Up = []string{ `CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, owner TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS api_keys (key_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, last_used_at TEXT, revoked INTEGER NOT NULL DEFAULT 0)`, `CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner)`, } var migV3Down = []string{ `DROP TABLE IF EXISTS api_keys`, `DROP TABLE IF EXISTS users`, } // v4 PG 档全文检索(阶段 1):tsvector 生成列 + trigram GIN(中文子串兜底加速)。 // SQLite 档为空操作(FTS5 已在基线)。 var migV4PGUp = []string{ `CREATE EXTENSION IF NOT EXISTS pg_trgm`, `ALTER TABLE knowledge ADD COLUMN IF NOT EXISTS tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(chunk,''))) STORED`, `CREATE INDEX IF NOT EXISTS idx_knowledge_tsv ON knowledge USING gin (tsv)`, `CREATE INDEX IF NOT EXISTS idx_knowledge_chunk_trgm ON knowledge USING gin (chunk gin_trgm_ops)`, } var migV4PGDown = []string{ `DROP INDEX IF EXISTS idx_knowledge_chunk_trgm`, `DROP INDEX IF EXISTS idx_knowledge_tsv`, `ALTER TABLE knowledge DROP COLUMN IF EXISTS tsv`, } // v5 向量索引表(阶段 2:4.12 向量管线;双档同构二进制向量存储)。 var migV5Up = map[string][]string{ "sqlite": { `CREATE TABLE IF NOT EXISTS knowledge_vectors (doc_id TEXT PRIMARY KEY, owner TEXT NOT NULL DEFAULT '', ref TEXT NOT NULL DEFAULT '', dim INTEGER NOT NULL, vec BLOB NOT NULL, created_at TEXT NOT NULL)`, `CREATE INDEX IF NOT EXISTS idx_knowledge_vectors_owner ON knowledge_vectors(owner)`, }, "postgres": { `CREATE TABLE IF NOT EXISTS knowledge_vectors (doc_id TEXT PRIMARY KEY, owner TEXT NOT NULL DEFAULT '', ref TEXT NOT NULL DEFAULT '', dim INTEGER NOT NULL, vec BYTEA NOT NULL, created_at TEXT NOT NULL)`, `CREATE INDEX IF NOT EXISTS idx_knowledge_vectors_owner ON knowledge_vectors(owner)`, }, } var migV5Down = []string{ `DROP TABLE IF EXISTS knowledge_vectors`, } func init() { migrations = append(migrations, migration{version: 3, name: "auth_tenancy", up: map[string][]string{"sqlite": migV3Up, "postgres": migV3Up}, down: map[string][]string{"sqlite": migV3Down, "postgres": migV3Down}}, migration{version: 4, name: "pg_fulltext", up: map[string][]string{"sqlite": {`SELECT 1`}, "postgres": migV4PGUp}, down: map[string][]string{"sqlite": {`SELECT 1`}, "postgres": migV4PGDown}}, migration{version: 5, name: "knowledge_vectors", up: migV5Up, down: map[string][]string{"sqlite": migV5Down, "postgres": migV5Down}}, ) } func (s *Store) migrate() error { if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil { return fmt.Errorf("migrate: %w", err) } applied := map[int]bool{} rows, err := s.db.Query(`SELECT version FROM schema_migrations`) if err != nil { return fmt.Errorf("migrate: %w", err) } for rows.Next() { var v int if err := rows.Scan(&v); err != nil { rows.Close() return fmt.Errorf("migrate: %w", err) } applied[v] = true } if err := rows.Err(); err != nil { rows.Close() return fmt.Errorf("migrate: %w", err) } rows.Close() for _, m := range migrations { if applied[m.version] { continue } stmts := m.up[s.dialect.name()] if len(stmts) == 0 { return fmt.Errorf("migrate: v%d %s 缺少 %s 方言", m.version, m.name, s.dialect.name()) } if err := s.execInTx(stmts); err != nil { return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err) } if _, err := s.db.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil { return fmt.Errorf("migrate: %w", err) } } return nil } func (s *Store) execInTx(stmts []string) error { tx, err := s.db.Begin() if err != nil { return err } for _, q := range stmts { if _, err := tx.Exec(s.q(q)); err != nil { tx.Rollback() return err } } return tx.Commit() } // MigrationStatus 返回已应用版本列表(诊断/验收用)。 func (s *Store) MigrationStatus() ([]int, error) { rows, err := s.db.Query(`SELECT version FROM schema_migrations ORDER BY version`) if err != nil { return nil, err } defer rows.Close() var out []int for rows.Next() { var v int if err := rows.Scan(&v); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // MigrateDown 回滚到指定版本(不含;运维入口,多租户数据折回限制见各 down 注释)。 func (s *Store) MigrateDown(toVersion int) error { applied, err := s.MigrationStatus() if err != nil { return err } for i := len(applied) - 1; i >= 0; i-- { v := applied[i] if v <= toVersion { break } var m *migration for j := range migrations { if migrations[j].version == v { m = &migrations[j] } } if m == nil { return fmt.Errorf("migrate-down: 未知版本 v%d", v) } stmts := m.down[s.dialect.name()] if len(stmts) == 0 { return fmt.Errorf("migrate-down: v%d 缺少 %s 回滚", v, s.dialect.name()) } if err := s.execInTx(stmts); err != nil { return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err) } if _, err := s.db.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil { return err } } return nil }