Details (original message): Add the vector RAG pipeline: paragraph chunker, OpenAI-compatible embeddings and rerank clients, dual-tier vector store with cosine search, and RRF hybrid retrieval
313 lines
18 KiB
Go
313 lines
18 KiB
Go
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
|
||
}
|