ci / vet-and-test (pull_request) Successful in 56s
Why: plan U6 vector slice (KTD11 correctness step) - same-dimension vectors from different models must never mix, and PG tiers should run filtered exact top-k in the database instead of scanning every row in-process. Evidence: full suite green on SQLite and real PostgreSQL 18.6 with pgvector (35 packages each tier) - pgvector_test covers model-identity and dimension isolation, update/delete atomicity (BLOB and vec_pg swap together), shared top-5 with score monotonicity, Recall@10 = exact-scan parity, deny-all predicate leakage; storage-migrate tests green after excluding the derived vec_pg column. Impact: v25 migration adds model/content_hash columns and (PG, best-effort DO block) the pgvector extension plus vec_pg column - absence degrades to in-process scan; UpsertKnowledgeVectorExt dual-writes BLOB (cross-tier authority) and vec_pg; SearchKnowledgeVectorsModel/SharedModel filter by model identity and use SQL exact top-k (ORDER BY vec_pg <=> q) with automatic degradation; embedders expose Model() and the rag pipeline threads it through indexing and both retrieve paths; storage-migrate excludes vec_pg as a derived column. Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 3, KTD11; R9.
993 lines
53 KiB
Go
993 lines
53 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// isBusy 识别 SQLite SQLITE_BUSY(含连接打开阶段的 journal/建库竞争)。
|
||
func isBusy(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
msg := err.Error()
|
||
return strings.Contains(msg, "database is locked") || strings.Contains(msg, "SQLITE_BUSY")
|
||
}
|
||
|
||
// 版本化迁移(设计 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`}}},
|
||
migration{version: 7, name: "command_audit",
|
||
// 命令总线审计(agent-command.AuditEnvelope):payload 存整信封 JSON;
|
||
// 冗余列支持按会话/状态/操作号检索(提案 T3,四入口同源审计)。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS command_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, command_id TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', entrance TEXT NOT NULL DEFAULT '', state TEXT NOT NULL, operation_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_conv ON command_audit(conversation_id, time)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_op ON command_audit(operation_id)`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS command_audit (id SERIAL PRIMARY KEY, time TEXT NOT NULL, command_id TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', entrance TEXT NOT NULL DEFAULT '', state TEXT NOT NULL, operation_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_conv ON command_audit(conversation_id, time)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_op ON command_audit(operation_id)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS command_audit`}, "postgres": {`DROP TABLE IF EXISTS command_audit`}}},
|
||
migration{version: 8, name: "prompt_modules",
|
||
// 提示词库用户覆盖(core.PromptModule):内置在 embed 库,本表只存
|
||
// 用户副本/覆盖行(BasedOnVersion 供上游升级 drift 提示)。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS prompt_modules (id TEXT NOT NULL, owner TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(id, owner))`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS prompt_modules (id TEXT NOT NULL, owner TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(id, owner))`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS prompt_modules`}, "postgres": {`DROP TABLE IF EXISTS prompt_modules`}}},
|
||
migration{version: 9, name: "conversation_flags",
|
||
// 会话置顶/归档(提案 2026-09-30 方案 A R1):默认 0 兼容既有行;
|
||
// owner+updated_at 索引服务跨项目任务列表(GET /api/conversations)。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`,
|
||
`CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`,
|
||
}, "postgres": {
|
||
`ALTER TABLE conversations ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE`,
|
||
`ALTER TABLE conversations ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE`,
|
||
`CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
// SQLite 兼容路径不假设支持 DROP COLUMN:重建去列;
|
||
// 回滚丢弃置顶/归档偏好(本端组织态),会话本体保留。
|
||
`DROP INDEX IF EXISTS idx_conversations_owner`,
|
||
`CREATE TABLE conversations_old (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
|
||
`INSERT INTO conversations_old(id, project_id, title, profile_id, owner, created_at, updated_at) SELECT id, project_id, title, profile_id, owner, created_at, updated_at FROM conversations`,
|
||
`DROP TABLE conversations`,
|
||
`ALTER TABLE conversations_old RENAME TO conversations`,
|
||
}, "postgres": {
|
||
`DROP INDEX IF EXISTS idx_conversations_owner`,
|
||
`ALTER TABLE conversations DROP COLUMN archived`,
|
||
`ALTER TABLE conversations DROP COLUMN pinned`,
|
||
}}},
|
||
migration{version: 10, name: "auth_accounts",
|
||
// 管理员发放制(2026-09-30 用户裁定):无自助注册;初始/重置密码后
|
||
// must_change_password=1,首次登录强制改密;会话为服务端状态(HttpOnly cookie)。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE users ADD COLUMN password_set_at TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
|
||
`CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner)`,
|
||
}, "postgres": {
|
||
`ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE users ADD COLUMN password_set_at TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE users ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT FALSE`,
|
||
`ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE`,
|
||
`CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS sessions`,
|
||
`CREATE TABLE users_old (id TEXT PRIMARY KEY, owner TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`,
|
||
`INSERT INTO users_old(id, owner, name, created_at) SELECT id, owner, name, created_at FROM users`,
|
||
`DROP TABLE users`,
|
||
`ALTER TABLE users_old RENAME TO users`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS sessions`,
|
||
`ALTER TABLE users DROP COLUMN is_admin`,
|
||
`ALTER TABLE users DROP COLUMN must_change_password`,
|
||
`ALTER TABLE users DROP COLUMN password_set_at`,
|
||
`ALTER TABLE users DROP COLUMN password_hash`,
|
||
}}},
|
||
migration{version: 11, name: "conversation_cross_read",
|
||
// 跨会话读取(提案 2026-09-30-cross-session-reading):出站默认 1(跟随
|
||
// 项目开关 crossSession.read)、入站默认 0(非私有);项目开关默认关,
|
||
// 既有行升级后行为不变。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE conversations ADD COLUMN allow_cross_read INTEGER NOT NULL DEFAULT 1`,
|
||
`ALTER TABLE conversations ADD COLUMN cross_private INTEGER NOT NULL DEFAULT 0`,
|
||
}, "postgres": {
|
||
`ALTER TABLE conversations ADD COLUMN allow_cross_read BOOLEAN NOT NULL DEFAULT TRUE`,
|
||
`ALTER TABLE conversations ADD COLUMN cross_private BOOLEAN NOT NULL DEFAULT FALSE`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
// 兼容路径不假设 DROP COLUMN:重建去列(跨会话偏好丢失,会话本体保留)。
|
||
`CREATE TABLE conversations_old (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', pinned INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
|
||
`INSERT INTO conversations_old(id, project_id, title, profile_id, owner, pinned, archived, created_at, updated_at) SELECT id, project_id, title, profile_id, owner, pinned, archived, created_at, updated_at FROM conversations`,
|
||
`DROP TABLE conversations`,
|
||
`ALTER TABLE conversations_old RENAME TO conversations`,
|
||
}, "postgres": {
|
||
`ALTER TABLE conversations DROP COLUMN cross_private`,
|
||
`ALTER TABLE conversations DROP COLUMN allow_cross_read`,
|
||
}}},
|
||
migration{version: 12, name: "cards_project_inbox",
|
||
// 项目收件箱(提案 D48–D51):项目维度索引服务聚合查询
|
||
// ListProjectCards(项目级卡 ∪ 各会话未决卡);纯索引,数据零迁移。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE INDEX IF NOT EXISTS idx_cards_project ON activity_cards(project_id, owner, status, updated_at)`,
|
||
}, "postgres": {
|
||
`CREATE INDEX IF NOT EXISTS idx_cards_project ON activity_cards(project_id, owner, status, updated_at)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP INDEX IF EXISTS idx_cards_project`,
|
||
}, "postgres": {
|
||
`DROP INDEX IF EXISTS idx_cards_project`,
|
||
}}},
|
||
migration{version: 13, name: "extension_platform",
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS plugin_grants (owner TEXT NOT NULL, plugin_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY (owner, plugin_id))`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_schema (plugin_id TEXT PRIMARY KEY, version INTEGER NOT NULL, updated_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_kv (plugin_id TEXT NOT NULL, owner TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (plugin_id, owner, k))`,
|
||
`CREATE TABLE IF NOT EXISTS background_tasks (id TEXT PRIMARY KEY, owner TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS memory_entries (id TEXT PRIMARY KEY, owner TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', typ TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', confidence REAL NOT NULL DEFAULT 0, created_at TEXT NOT NULL, last_accessed TEXT NOT NULL, expires_at TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE TABLE IF NOT EXISTS pending_confirms (card_id TEXT PRIMARY KEY, owner TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS llm_usage_daily (owner TEXT NOT NULL, day TEXT NOT NULL, tokens INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (owner, day))`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS plugin_grants (owner TEXT NOT NULL, plugin_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY (owner, plugin_id))`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_schema (plugin_id TEXT PRIMARY KEY, version INTEGER NOT NULL, updated_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_kv (plugin_id TEXT NOT NULL, owner TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (plugin_id, owner, k))`,
|
||
`CREATE TABLE IF NOT EXISTS background_tasks (id TEXT PRIMARY KEY, owner TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS memory_entries (id TEXT PRIMARY KEY, owner TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', typ TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', confidence DOUBLE PRECISION NOT NULL DEFAULT 0, created_at TEXT NOT NULL, last_accessed TEXT NOT NULL, expires_at TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE TABLE IF NOT EXISTS audit_events (id SERIAL PRIMARY KEY, time TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL DEFAULT '')`,
|
||
`CREATE TABLE IF NOT EXISTS pending_confirms (card_id TEXT PRIMARY KEY, owner TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)`,
|
||
`CREATE TABLE IF NOT EXISTS llm_usage_daily (owner TEXT NOT NULL, day TEXT NOT NULL, tokens INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (owner, day))`,
|
||
}},
|
||
down: map[string][]string{
|
||
"sqlite": {
|
||
`DROP TABLE IF EXISTS llm_usage_daily`,
|
||
`DROP TABLE IF EXISTS pending_confirms`,
|
||
`DROP TABLE IF EXISTS audit_events`,
|
||
`DROP TABLE IF EXISTS memory_entries`,
|
||
`DROP TABLE IF EXISTS background_tasks`,
|
||
`DROP TABLE IF EXISTS plugin_kv`,
|
||
`DROP TABLE IF EXISTS plugin_schema`,
|
||
`DROP TABLE IF EXISTS plugin_grants`,
|
||
},
|
||
"postgres": {
|
||
`DROP TABLE IF EXISTS llm_usage_daily`,
|
||
`DROP TABLE IF EXISTS pending_confirms`,
|
||
`DROP TABLE IF EXISTS audit_events`,
|
||
`DROP TABLE IF EXISTS memory_entries`,
|
||
`DROP TABLE IF EXISTS background_tasks`,
|
||
`DROP TABLE IF EXISTS plugin_kv`,
|
||
`DROP TABLE IF EXISTS plugin_schema`,
|
||
`DROP TABLE IF EXISTS plugin_grants`,
|
||
},
|
||
}},
|
||
migration{version: 14, name: "run_instance_and_request_id",
|
||
// 启动对账按实例收口在途 run;发消息按 request_id 去重。
|
||
// 空 request_id 不参与唯一约束,旧消息与命令路径仍可重复追加。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE runs ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE messages ADD COLUMN request_id TEXT NOT NULL DEFAULT ''`,
|
||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_request ON messages(conversation_id, request_id) WHERE request_id <> ''`,
|
||
}, "postgres": {
|
||
`ALTER TABLE runs ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE messages ADD COLUMN request_id TEXT NOT NULL DEFAULT ''`,
|
||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_request ON messages(conversation_id, request_id) WHERE request_id <> ''`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP INDEX IF EXISTS uq_messages_conv_request`,
|
||
`ALTER TABLE messages DROP COLUMN request_id`,
|
||
`ALTER TABLE runs DROP COLUMN instance_id`,
|
||
}, "postgres": {
|
||
`DROP INDEX IF EXISTS uq_messages_conv_request`,
|
||
`ALTER TABLE messages DROP COLUMN request_id`,
|
||
`ALTER TABLE runs DROP COLUMN instance_id`,
|
||
}}},
|
||
migration{version: 15, name: "conversation_permission_mode",
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`,
|
||
}, "postgres": {
|
||
`ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`ALTER TABLE conversations DROP COLUMN permission_mode`,
|
||
}, "postgres": {
|
||
`ALTER TABLE conversations DROP COLUMN permission_mode`,
|
||
}}},
|
||
migration{version: 16, name: "secrets_context_and_variants",
|
||
up: map[string][]string{"sqlite": v16Up, "postgres": v16Up},
|
||
down: map[string][]string{"sqlite": v16Down, "postgres": v16Down}},
|
||
migration{version: 17, name: "pending_notices",
|
||
up: map[string][]string{"sqlite": v17Up, "postgres": v17Up},
|
||
down: map[string][]string{"sqlite": v17Down, "postgres": v17Down}},
|
||
migration{version: 18, name: "command_audit_owner",
|
||
// 命令审计租户归属(GET /api/commands/audit 曾无 owner 过滤,跨租户可读)。
|
||
// 存量行回填取 payload.actor——修复前行未记租户,Actor 是唯一可用线索且
|
||
// 对历史 HTTP 入口恒等于发起租户;actor 为空的行无法归属,owner 留空,
|
||
// API 查询不再返回(数据保留在表内,可直查数据库取证)。索引覆盖
|
||
// owner×conversation×time 的两种查询形态。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||
`UPDATE command_audit SET owner = COALESCE(json_extract(payload, '$.actor'), '') WHERE owner = ''`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
|
||
}, "postgres": {
|
||
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||
`UPDATE command_audit SET owner = COALESCE(payload::json->>'actor', '') WHERE owner = ''`,
|
||
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
|
||
}},
|
||
// down:先删引用列的索引再 DROP COLUMN(sqlite 对被索引列直接 DROP
|
||
// 会报 no such column: owner;PG 先删同样无害)。
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP INDEX IF EXISTS idx_audit_owner`,
|
||
`ALTER TABLE command_audit DROP COLUMN owner`,
|
||
}, "postgres": {
|
||
`DROP INDEX IF EXISTS idx_audit_owner`,
|
||
`ALTER TABLE command_audit DROP COLUMN owner`,
|
||
}}},
|
||
migration{version: 19, name: "card_version",
|
||
// 卡片乐观并发(协作存储 U3,KTD7):读改写带 expected version,
|
||
// 冲突返回 409;盲写(系统镜像/流式追加)同样递增版本。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
|
||
}, "postgres": {
|
||
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`ALTER TABLE activity_cards DROP COLUMN version`,
|
||
}, "postgres": {
|
||
`ALTER TABLE activity_cards DROP COLUMN version`,
|
||
}}},
|
||
migration{version: 20, name: "recovery_holds",
|
||
// 恢复隔离(协作存储 U3):未知外部副作用的 run 记录持久 hold,
|
||
// 启动续跑入口必须跳过,人工核对后显式解除(方案 §5 U3)。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS recovery_holds (
|
||
run_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
reason TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
released_at TEXT
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS recovery_holds (
|
||
run_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
reason TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
released_at TEXT
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS recovery_holds`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS recovery_holds`,
|
||
}}},
|
||
migration{version: 21, name: "admission_leases",
|
||
// 执行许可账本(协作存储 U3 切片 2,R8):预算开启时每个在执行的
|
||
// run(会话回合与子代理同池)持有一行活跃租约;父等待让出后
|
||
// 复活。预算关闭(全零)不产生行,历史行为不变。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS admission_leases (
|
||
run_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
acquired_at TEXT NOT NULL,
|
||
released_at TEXT
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS admission_leases (
|
||
run_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
acquired_at TEXT NOT NULL,
|
||
released_at TEXT
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS admission_leases`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS admission_leases`,
|
||
}}},
|
||
migration{version: 22, name: "generative_surfaces",
|
||
// Generative UI U2(提案 KTD3/KTD7):宿主通用 Surface 存储(owner 隔离 +
|
||
// revision 乐观并发 + activationEpoch 启禁纪元)。spec 存整页 JSON 快照,
|
||
// event_pending 标记「快照已落盘、投影事件未发出」的待恢复态。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS surfaces (
|
||
surface_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
project_id TEXT NOT NULL DEFAULT '',
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
plugin_id TEXT NOT NULL,
|
||
compat TEXT NOT NULL DEFAULT '',
|
||
latest_revision INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
|
||
`CREATE TABLE IF NOT EXISTS surface_revisions (
|
||
surface_id TEXT NOT NULL,
|
||
revision INTEGER NOT NULL,
|
||
base_revision INTEGER NOT NULL DEFAULT 0,
|
||
context_version TEXT NOT NULL DEFAULT '',
|
||
activation_epoch INTEGER NOT NULL DEFAULT 0,
|
||
spec TEXT NOT NULL,
|
||
semantic_text TEXT NOT NULL DEFAULT '',
|
||
event_pending INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (surface_id, revision)
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_activation (
|
||
owner TEXT NOT NULL,
|
||
plugin_id TEXT NOT NULL,
|
||
enabled INTEGER NOT NULL DEFAULT 0,
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL,
|
||
PRIMARY KEY (owner, plugin_id)
|
||
)`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS surfaces (
|
||
surface_id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
project_id TEXT NOT NULL DEFAULT '',
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
plugin_id TEXT NOT NULL,
|
||
compat TEXT NOT NULL DEFAULT '',
|
||
latest_revision INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
|
||
`CREATE TABLE IF NOT EXISTS surface_revisions (
|
||
surface_id TEXT NOT NULL,
|
||
revision INTEGER NOT NULL,
|
||
base_revision INTEGER NOT NULL DEFAULT 0,
|
||
context_version TEXT NOT NULL DEFAULT '',
|
||
activation_epoch INTEGER NOT NULL DEFAULT 0,
|
||
spec TEXT NOT NULL,
|
||
semantic_text TEXT NOT NULL DEFAULT '',
|
||
event_pending INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (surface_id, revision)
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS plugin_activation (
|
||
owner TEXT NOT NULL,
|
||
plugin_id TEXT NOT NULL,
|
||
enabled INTEGER NOT NULL DEFAULT 0,
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL,
|
||
PRIMARY KEY (owner, plugin_id)
|
||
)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS plugin_activation`,
|
||
`DROP TABLE IF EXISTS surface_revisions`,
|
||
`DROP TABLE IF EXISTS surfaces`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS plugin_activation`,
|
||
`DROP TABLE IF EXISTS surface_revisions`,
|
||
`DROP TABLE IF EXISTS surfaces`,
|
||
}}},
|
||
migration{version: 23, name: "execution_ownership_commands",
|
||
// 执行所有权与持久命令(协作存储 U5,KTD4/KTD5):
|
||
// conversation_executions = 会话执行租约(实例/run/epoch/到期),
|
||
// 接管递增 epoch 作 fencing;run_commands = 跨实例命令收件箱
|
||
//(取消/HITL:command_id 幂等、state pending/consumed)。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS conversation_executions (
|
||
conversation_id TEXT PRIMARY KEY,
|
||
instance_id TEXT NOT NULL,
|
||
run_id TEXT NOT NULL DEFAULT '',
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
lease_until TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS run_commands (
|
||
command_id TEXT PRIMARY KEY,
|
||
kind TEXT NOT NULL,
|
||
run_id TEXT NOT NULL DEFAULT '',
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
payload TEXT NOT NULL DEFAULT '{}',
|
||
state TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL,
|
||
consumed_at TEXT,
|
||
consumer TEXT NOT NULL DEFAULT ''
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS conversation_executions (
|
||
conversation_id TEXT PRIMARY KEY,
|
||
instance_id TEXT NOT NULL,
|
||
run_id TEXT NOT NULL DEFAULT '',
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
lease_until TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS run_commands (
|
||
command_id TEXT PRIMARY KEY,
|
||
kind TEXT NOT NULL,
|
||
run_id TEXT NOT NULL DEFAULT '',
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
epoch INTEGER NOT NULL DEFAULT 0,
|
||
payload TEXT NOT NULL DEFAULT '{}',
|
||
state TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL,
|
||
consumed_at TEXT,
|
||
consumer TEXT NOT NULL DEFAULT ''
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS run_commands`,
|
||
`DROP TABLE IF EXISTS conversation_executions`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS run_commands`,
|
||
`DROP TABLE IF EXISTS conversation_executions`,
|
||
}}},
|
||
migration{version: 24, name: "surface_actions",
|
||
// Generative UI U5(提案 U5 动作范围 r2):动作提交幂等台账——
|
||
// (owner, surface, action, requestId) 唯一;同 requestId 同参数重放返回
|
||
// 已有回执,异参数拒绝;awaiting_confirm 记确认卡关联(执行权威在命令
|
||
// 总线审计台账)。
|
||
up: map[string][]string{"sqlite": {
|
||
`CREATE TABLE IF NOT EXISTS surface_actions (
|
||
owner TEXT NOT NULL,
|
||
surface_id TEXT NOT NULL,
|
||
action_id TEXT NOT NULL,
|
||
request_id TEXT NOT NULL,
|
||
revision INTEGER NOT NULL,
|
||
params_hash TEXT NOT NULL,
|
||
state TEXT NOT NULL,
|
||
receipt TEXT NOT NULL DEFAULT '',
|
||
card_id TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (owner, surface_id, action_id, request_id)
|
||
)`,
|
||
}, "postgres": {
|
||
`CREATE TABLE IF NOT EXISTS surface_actions (
|
||
owner TEXT NOT NULL,
|
||
surface_id TEXT NOT NULL,
|
||
action_id TEXT NOT NULL,
|
||
request_id TEXT NOT NULL,
|
||
revision INTEGER NOT NULL,
|
||
params_hash TEXT NOT NULL,
|
||
state TEXT NOT NULL,
|
||
receipt TEXT NOT NULL DEFAULT '',
|
||
card_id TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (owner, surface_id, action_id, request_id)
|
||
)`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`DROP TABLE IF EXISTS surface_actions`,
|
||
}, "postgres": {
|
||
`DROP TABLE IF EXISTS surface_actions`,
|
||
}}},
|
||
migration{version: 25, name: "vector_identity_pgvector",
|
||
// 协作存储 U6 切片三(KTD11:正确→测量→ANN 的「正确」步):
|
||
// 向量行补模型身份(model/content_hash,禁止同维不同模型混查);
|
||
// PG 侧启用 pgvector 扩展并加 vec_pg 列做库内精确 top-k。
|
||
// 扩展缺失不阻断迁移(DO 块吞错)——检索自动降级进程内扫描。
|
||
up: map[string][]string{"sqlite": {
|
||
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
|
||
}, "postgres": {
|
||
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
|
||
`DO $$ BEGIN
|
||
CREATE EXTENSION IF NOT EXISTS vector;
|
||
ALTER TABLE knowledge_vectors ADD COLUMN IF NOT EXISTS vec_pg vector;
|
||
EXCEPTION WHEN OTHERS THEN NULL; END $$`,
|
||
}},
|
||
down: map[string][]string{"sqlite": {
|
||
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
|
||
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
|
||
}, "postgres": {
|
||
`ALTER TABLE knowledge_vectors DROP COLUMN IF EXISTS vec_pg`,
|
||
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
|
||
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
|
||
}}},
|
||
)
|
||
}
|
||
|
||
// v17 后台子代理完成通知的持久化队列(N2 WP5:重启后重投未投递结果)。
|
||
var v17Up = []string{
|
||
`CREATE TABLE IF NOT EXISTS pending_notices (
|
||
id TEXT PRIMARY KEY,
|
||
conversation_id TEXT NOT NULL,
|
||
owner TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
payload TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_pending_notices_conv ON pending_notices(conversation_id, created_at)`,
|
||
}
|
||
|
||
var v17Down = []string{`DROP TABLE IF EXISTS pending_notices`}
|
||
|
||
// v16 密钥独立表、设置快照、条件注入、用户命令、会话要求、消息排除与候选回复。
|
||
var v16Up = []string{
|
||
`CREATE TABLE IF NOT EXISTS secret_keys (
|
||
id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
service TEXT NOT NULL,
|
||
label TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
active INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_secret_keys_owner_service ON secret_keys(owner, service)`,
|
||
`CREATE TABLE IF NOT EXISTS settings_snapshots (
|
||
id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
scope TEXT NOT NULL,
|
||
body TEXT NOT NULL,
|
||
reason TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_settings_snapshots_scope ON settings_snapshots(owner, scope, created_at)`,
|
||
`ALTER TABLE conversations ADD COLUMN session_note TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE messages ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE messages ADD COLUMN variant_group TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE messages ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0`,
|
||
`CREATE TABLE IF NOT EXISTS regen_pending (
|
||
conversation_id TEXT PRIMARY KEY,
|
||
variant_group TEXT NOT NULL,
|
||
variant_index INTEGER NOT NULL
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS context_entries (
|
||
id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
project_id TEXT NOT NULL DEFAULT '',
|
||
conversation_id TEXT NOT NULL DEFAULT '',
|
||
keys_json TEXT NOT NULL DEFAULT '[]',
|
||
secondary TEXT NOT NULL DEFAULT '',
|
||
logic TEXT NOT NULL DEFAULT 'and',
|
||
content TEXT NOT NULL,
|
||
constant INTEGER NOT NULL DEFAULT 0,
|
||
scan_depth INTEGER NOT NULL DEFAULT 4,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
budget_chars INTEGER NOT NULL DEFAULT 2000,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT NOT NULL
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS user_commands (
|
||
id TEXT PRIMARY KEY,
|
||
owner TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
template TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
UNIQUE(owner, name)
|
||
)`,
|
||
}
|
||
|
||
var v16Down = []string{
|
||
`DROP TABLE IF EXISTS user_commands`,
|
||
`DROP TABLE IF EXISTS context_entries`,
|
||
`DROP TABLE IF EXISTS regen_pending`,
|
||
`ALTER TABLE messages DROP COLUMN variant_index`,
|
||
`ALTER TABLE messages DROP COLUMN variant_group`,
|
||
`ALTER TABLE messages DROP COLUMN excluded`,
|
||
`ALTER TABLE conversations DROP COLUMN session_note`,
|
||
`DROP TABLE IF EXISTS settings_snapshots`,
|
||
`DROP INDEX IF EXISTS idx_secret_keys_owner_service`,
|
||
`DROP TABLE IF EXISTS secret_keys`,
|
||
}
|
||
|
||
func (s *Store) migrate() error {
|
||
if s.dialect.name() == "postgres" {
|
||
return s.migratePG()
|
||
}
|
||
// SQLite:_txlock=immediate(OpenSQLite DSN)保证迁移事务在 BEGIN 即取写锁,
|
||
// 跨进程迁移者按 busy_timeout 排队;后续版本读取发生在同一事务内,
|
||
// 前一个迁移者提交后本进程看到的已是完整账本(无部分版本可见)。
|
||
return s.migratePass()
|
||
}
|
||
|
||
// migratePG 以会话级咨询锁串行化并发迁移者(KTD8):锁在专用连接上获取,
|
||
// 等锁者在轮到自己后重新读取账本——前一个迁移者已完成时本轮为空操作。
|
||
// 锁与迁移事务分离:迁移语句走连接池,锁只负责让 migrator 全局互斥。
|
||
func (s *Store) migratePG() error {
|
||
ctx := context.Background()
|
||
conn, err := s.db.Conn(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("migrate lock: %w", err)
|
||
}
|
||
defer conn.Close()
|
||
const lockKey = "'agent-system:schema-migrate'"
|
||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock(hashtext(`+lockKey+`))`); err != nil {
|
||
return fmt.Errorf("migrate lock: %w", err)
|
||
}
|
||
defer conn.ExecContext(ctx, `SELECT pg_advisory_unlock(hashtext(`+lockKey+`))`)
|
||
return s.migratePass()
|
||
}
|
||
|
||
// migratePass 单事务执行全部待应用迁移:DDL 与版本登记同事务提交(KTD8),
|
||
// 中途失败整体回滚——不存在"DDL 已生效而账本未记"的中间态;两步间崩溃
|
||
// 的历史缺口(先提交 DDL 再单独 INSERT 版本)由此消除。
|
||
func (s *Store) migratePass() error {
|
||
// Begin 阶段包含连接首次打开(应用 busy_timeout/journal_mode 等 DSN
|
||
// pragma)。并发首开同一 SQLite 文件时,建库/journal 模式切换阶段可即时
|
||
// 返回 SQLITE_BUSY——busy handler 不覆盖该阶段(慢速盘 CI 实测,与
|
||
// busy_timeout 无关)。对 Begin 的瞬时 BUSY 做有界退避重试;进入
|
||
// IMMEDIATE 事务后的语句由写锁保护,不需要重试。
|
||
var tx *sql.Tx
|
||
var err error
|
||
for attempt := 0; ; attempt++ {
|
||
tx, err = s.db.Begin()
|
||
if err == nil {
|
||
break
|
||
}
|
||
if attempt >= 4 || !isBusy(err) {
|
||
return fmt.Errorf("migrate: %w", err)
|
||
}
|
||
time.Sleep(time.Duration(200*(attempt+1)) * time.Millisecond)
|
||
}
|
||
defer tx.Rollback()
|
||
if _, err := tx.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{}
|
||
known := map[int]bool{}
|
||
for _, m := range migrations {
|
||
known[m.version] = true
|
||
}
|
||
rows, err := tx.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)
|
||
}
|
||
// 兼容门禁(KTD8 应用侧只核对兼容版本):库里有本二进制不认识的版本
|
||
// 说明库由更新的构建迁移过——拒绝启动,不冒险在未知 schema 上服务。
|
||
if !known[v] {
|
||
rows.Close()
|
||
return fmt.Errorf("migrate: 数据库 schema 版本 v%d 超出本构建(最高 v%d);拒绝启动,请升级二进制", v, latestMigrationVersion())
|
||
}
|
||
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())
|
||
}
|
||
for _, q := range stmts {
|
||
if _, err := tx.Exec(s.q(q)); err != nil {
|
||
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
|
||
}
|
||
}
|
||
// 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下
|
||
// 「DDL 生效而账本缺失」的中间态;同事务设计下这里失败必须整体回滚。
|
||
if fault := s.takeMigrateFault(); fault != nil {
|
||
return fmt.Errorf("migrate v%d (%s) 注入故障: %w", m.version, m.name, fault)
|
||
}
|
||
if _, err := tx.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
|
||
return fmt.Errorf("migrate v%d (%s) 版本登记: %w", m.version, m.name, err)
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func latestMigrationVersion() int {
|
||
if len(migrations) == 0 {
|
||
return 0
|
||
}
|
||
return migrations[len(migrations)-1].version
|
||
}
|
||
|
||
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 注释)。
|
||
// 回滚语句与版本删除同事务(KTD8 原子性对称)。
|
||
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())
|
||
}
|
||
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 fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
|
||
}
|
||
}
|
||
if _, err := tx.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|