Files
Agent-System-Backend/internal/store/migrate.go
T
weichen 797a6eaab7 Add inbox activity cards: cardsvc, store, events, API and producers
Why: T2 of the interaction foundation — activity cards as first-class state
  objects behind the right-rail inbox (proposal D25): upload ingest cards
  (SR-A5 failures explicit, artifact retrievable), HITL projection cards
  (double-presence, ref-linked two-way sync), squad orchestration cards and
  the mock #upload demo of the full card lifecycle.

Evidence: go test ./... green across 14 packages incl. new cardsvc suite
  (lifecycle, action labels and idempotency, pin/dismiss, owner isolation,
  resolve-by-ref idempotency) and updated migration test (v6 activity_cards,
  both dialects); events flow through the session event buffer (persist, seq,
  replay); receipts appended to the conversation timeline on card actions.

Impact: additive; cards disabled (SetCards nil) degenerates to the T0
  baseline; upload cards persist at project level per the v1 inbox boundary
  (conversation-scoped inbox; project-level aggregation is follow-up).

Refs: modules/documents docs/proposals/2026-09-30-ui-inbox-command-workspace.md
2026-09-30 06:52:48 +00:00

326 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}},
migration{version: 6, name: "activity_cards",
up: map[string][]string{"sqlite": {
// 收件箱任务卡(core.ActivityCard):payload 存整卡 JSON;
// status/pinned/ref 冗余列供过滤、排序与 HITL 投影联动(提案 T2)。
`CREATE TABLE IF NOT EXISTS activity_cards (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, conversation_id TEXT NOT NULL DEFAULT '', ref TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL, status TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS idx_cards_conv ON activity_cards(conversation_id, updated_at)`,
`CREATE INDEX IF NOT EXISTS idx_cards_ref ON activity_cards(ref)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS activity_cards (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, conversation_id TEXT NOT NULL DEFAULT '', ref TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL, status TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS idx_cards_conv ON activity_cards(conversation_id, updated_at)`,
`CREATE INDEX IF NOT EXISTS idx_cards_ref ON activity_cards(ref)`,
}},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS activity_cards`}, "postgres": {`DROP TABLE IF EXISTS activity_cards`}}},
)
}
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()
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
}