Files
Agent-System-Backend/internal/store/migrate.go
T

1787 lines
99 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 (
"context"
"database/sql"
"errors"
"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`,
}}},
migration{version: 26, name: "login_keys_session_scope",
// 一次性登录 Key(激活/恢复)与会话 scope(full | set_password_only)。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS login_keys (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
purpose TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
instance_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS login_keys (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
purpose TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
instance_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE sessions DROP COLUMN scope`,
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
`DROP TABLE IF EXISTS login_keys`,
}, "postgres": {
`ALTER TABLE sessions DROP COLUMN scope`,
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
`DROP TABLE IF EXISTS login_keys`,
}}},
// U1 I3:服务实例 DB 身份 marker(设计 docs/recovery/u1-identity-manifest.md §5.2)。
// 全表至多一行由 Ensure/Verify 强制;与 runs.instance_id(进程标识)无关。
// v26 已被 login_keys 占用,本表为 v27。
migration{version: 27, name: "agent_system_identity",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
dbref TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
dbref TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS agent_system_identity`,
}, "postgres": {
`DROP TABLE IF EXISTS agent_system_identity`,
}}},
// 登录恢复流程(2026-10-13):认证版本与会话绑定,堵「旧密码验证在途 →
// 恢复完成 → 旧请求又建会话」竞态;账户事件审计与密码变更同事务落库。
migration{version: 28, name: "auth_version_account_events",
up: map[string][]string{"sqlite": {
`ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS account_events (
id TEXT PRIMARY KEY,
time TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
entrance TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`,
}, "postgres": {
`ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS account_events (
id TEXT PRIMARY KEY,
time TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
entrance TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`,
}},
down: map[string][]string{"sqlite": {
`DROP INDEX IF EXISTS idx_account_events_owner_time`,
`DROP TABLE IF EXISTS account_events`,
`ALTER TABLE sessions DROP COLUMN auth_version`,
`ALTER TABLE users DROP COLUMN auth_version`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_account_events_owner_time`,
`DROP TABLE IF EXISTS account_events`,
`ALTER TABLE sessions DROP COLUMN auth_version`,
`ALTER TABLE users DROP COLUMN auth_version`,
}}},
// 登录恢复收尾(2026-10-13):兑换入口的跨实例失败退避(共享库窗口计数)。
migration{version: 29, name: "redeem_attempts",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS redeem_attempts (
ip TEXT NOT NULL,
at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS redeem_attempts (
ip TEXT NOT NULL,
at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`,
}},
down: map[string][]string{"sqlite": {
`DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`,
`DROP TABLE IF EXISTS redeem_attempts`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`,
`DROP TABLE IF EXISTS redeem_attempts`,
}}},
migration{version: 30, name: "project_event_publication_proofs",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq INTEGER NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq BIGINT NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
},
migration{version: 31, name: "account_audit",
up: map[string][]string{"sqlite": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}, "postgres": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}}},
migration{version: 32, name: "account_profile_state_and_provenance",
up: map[string][]string{
"sqlite": accountFoundationUp("INTEGER NOT NULL DEFAULT 1", "lower(hex(randomblob(16)))"),
"postgres": accountFoundationUp("BOOLEAN NOT NULL DEFAULT TRUE", "md5(random()::text || clock_timestamp()::text)"),
},
down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown},
},
migration{version: 33, name: "owner_proof_recovery",
up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp},
down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown},
},
migration{version: 34, name: "shared_account_cursor_key",
up: map[string][]string{"sqlite": {
`ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`,
`ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`,
}, "postgres": {
`ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`,
`ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`,
}}},
migration{version: 35, name: "exec_env_layers",
up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp},
down: map[string][]string{"sqlite": execEnvDown, "postgres": execEnvDown},
},
// U8:活动服务锁的库内标记。锁文件 inode 与本行不一致表示锁文件被重建。
migration{version: 36, name: "instance_guard",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS instance_guard (
id INTEGER PRIMARY KEY CHECK (id = 1),
db_dev INTEGER NOT NULL,
db_ino INTEGER NOT NULL,
lock_dev INTEGER NOT NULL,
lock_ino INTEGER NOT NULL,
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS instance_guard (
id INTEGER PRIMARY KEY CHECK (id = 1),
db_dev BIGINT NOT NULL,
db_ino BIGINT NOT NULL,
lock_dev BIGINT NOT NULL,
lock_ino BIGINT NOT NULL,
instance_id TEXT NOT NULL DEFAULT '',
lock_gen TEXT NOT NULL DEFAULT ''
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS instance_guard`,
}, "postgres": {
`DROP TABLE IF EXISTS instance_guard`,
}}},
migration{version: 37, name: "work_task_bindings",
up: map[string][]string{
"sqlite": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS work_task_bindings`}, "postgres": {`DROP TABLE IF EXISTS work_task_bindings`}},
},
migration{version: 38, name: "trusted_context_manifests",
up: map[string][]string{
"sqlite": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`},
"postgres": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`},
},
down: map[string][]string{"sqlite": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}, "postgres": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}},
},
migration{version: 39, name: "workflow_step_sources",
up: map[string][]string{
"sqlite": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS workflow_step_sources`}, "postgres": {`DROP TABLE IF EXISTS workflow_step_sources`}},
},
migration{version: 40, name: "context_manifest_resolve_scope",
up: map[string][]string{
"sqlite": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`},
"postgres": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`},
},
down: map[string][]string{
"sqlite": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`},
"postgres": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`},
},
},
migration{version: 41, name: "scrub_candidate_context_scope_text",
up: map[string][]string{
"sqlite": {`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY CHECK(id=1),pending INTEGER NOT NULL CHECK(pending IN (0,1)))`},
"postgres": {`SELECT 1`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS context_scope_scrub_state`}, "postgres": {`SELECT 1`}},
},
migration{version: 42, name: "command_audit_attribution_snapshot",
up: map[string][]string{
"sqlite": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
"postgres": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
"postgres": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
}},
migration{version: 43, name: "project_audit_committed_links",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_audit_links (id INTEGER PRIMARY KEY AUTOINCREMENT,host_audit_offset INTEGER NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
"postgres": {`CREATE TABLE project_audit_links (id BIGSERIAL PRIMARY KEY,host_audit_offset BIGINT NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
},
down: map[string][]string{
"sqlite": {`DROP TABLE IF EXISTS project_audit_links`},
"postgres": {`DROP TABLE IF EXISTS project_audit_links`},
}},
migration{version: 44, name: "project_audit_committed_prefix",
up: map[string][]string{
// SQLite has one writer and one connection per Store; this migration
// only changes PostgreSQL's implicit command_audit ID allocation.
"sqlite": {`SELECT 1`},
"postgres": {
`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`,
`CREATE FUNCTION project_audit_cutoff_safe(target regclass) RETURNS boolean LANGUAGE plpgsql VOLATILE AS $$
DECLARE seq regclass; default_expr text; increment_by bigint; min_value bigint; start_value bigint; cache_size bigint; cycles boolean;
last_number numeric; was_called boolean; next_number numeric; min_id bigint; max_id bigint;
BEGIN
seq := pg_get_serial_sequence(target::text,'id')::regclass;
IF seq IS NULL THEN RETURN false; END IF;
SELECT pg_get_expr(d.adbin,d.adrelid) INTO default_expr FROM pg_attrdef d
JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum
WHERE d.adrelid=target AND a.attname='id';
IF default_expr IS DISTINCT FROM format('next_command_audit_id(%L::regclass)',target::text) THEN RETURN false; END IF;
SELECT seqincrement,seqmin,seqstart,seqcache,seqcycle
INTO increment_by,min_value,start_value,cache_size,cycles FROM pg_sequence WHERE seqrelid=seq;
IF increment_by IS NULL OR increment_by<=0 OR min_value<=0 OR start_value<=0 OR cache_size<>1 OR cycles THEN RETURN false; END IF;
-- Supported writers allocate under the lock before they commit. Read
-- committed rows first, then the non-MVCC sequence state: a writer
-- committing between these reads can only advance the latter.
EXECUTE format('SELECT COALESCE(MIN(id),1),COALESCE(MAX(id),0) FROM %s',target) INTO min_id,max_id;
EXECUTE format('SELECT last_value,is_called FROM %s',seq) INTO last_number,was_called;
next_number := last_number;
IF was_called THEN next_number := last_number+increment_by; END IF;
RETURN min_id>0 AND next_number>0 AND next_number>max_id;
END $$`,
`CREATE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$
DECLARE seq regclass; allocated bigint; max_id bigint;
BEGIN
PERFORM pg_advisory_xact_lock(715328491,target::oid::int4);
IF NOT project_audit_cutoff_safe(target) THEN RAISE EXCEPTION 'project audit cutoff sequence is unsafe'; END IF;
seq := pg_get_serial_sequence(target::text,'id')::regclass;
allocated := nextval(seq);
EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id;
IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF;
RETURN allocated;
END $$`,
`ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT next_command_audit_id('command_audit'::regclass)`,
`DO $$ BEGIN IF NOT project_audit_cutoff_safe('command_audit'::regclass) THEN RAISE EXCEPTION 'project audit cutoff migration precondition failed'; END IF; END $$`,
},
},
down: map[string][]string{
"sqlite": {`SELECT 1`},
"postgres": {
`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`,
`ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT nextval(pg_get_serial_sequence('command_audit','id')::regclass)`,
`DROP FUNCTION next_command_audit_id(regclass)`,
`DROP FUNCTION project_audit_cutoff_safe(regclass)`,
},
}},
migration{version: 45, name: "project_audit_all_writers_guard",
up: map[string][]string{
"sqlite": {`SELECT 1`},
"postgres": {
`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`,
`CREATE FUNCTION project_audit_insert_guard_v1() RETURNS trigger LANGUAGE plpgsql VOLATILE AS $$
DECLARE seq regclass; last_number numeric; was_called boolean; max_id bigint;
BEGIN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION 'command audit insert requires read committed isolation';
END IF;
PERFORM pg_advisory_xact_lock(715328491,TG_RELID::int4);
IF NOT project_audit_cutoff_safe(TG_RELID) OR NOT project_audit_writers_guarded(TG_RELID) THEN
RAISE EXCEPTION 'command audit write guard is unsafe';
END IF;
seq := pg_get_serial_sequence(TG_RELID::regclass::text,'id')::regclass;
EXECUTE format('SELECT last_value,is_called FROM %s',seq) INTO last_number,was_called;
EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',TG_RELID::regclass) INTO max_id;
IF NEW.id IS NULL OR NEW.id<=0 OR NEW.id<=max_id OR NOT was_called OR NEW.id>last_number THEN
RAISE EXCEPTION 'command audit ID is not commit-monotone';
END IF;
RETURN NEW;
END $$`,
`CREATE FUNCTION project_audit_mutation_guard_v1() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'command audit is append-only'; END $$`,
`CREATE TRIGGER project_audit_insert_guard_v1 BEFORE INSERT ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_insert_guard_v1()`,
`CREATE TRIGGER project_audit_mutation_guard_v1 BEFORE UPDATE OR DELETE ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_mutation_guard_v1()`,
`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`,
`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_mutation_guard_v1`,
`CREATE FUNCTION project_audit_writers_guarded(target regclass) RETURNS boolean LANGUAGE plpgsql VOLATILE AS $$
DECLARE insert_ok boolean; mutation_ok boolean;
BEGIN
SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid=target AND tgname='project_audit_insert_guard_v1'
AND NOT tgisinternal AND tgenabled='A' AND tgtype=7
AND tgfoid=to_regprocedure('project_audit_insert_guard_v1()')) INTO insert_ok;
SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid=target AND tgname='project_audit_mutation_guard_v1'
AND NOT tgisinternal AND tgenabled='A' AND tgtype=27
AND tgfoid=to_regprocedure('project_audit_mutation_guard_v1()')) INTO mutation_ok;
RETURN insert_ok AND mutation_ok;
END $$`,
`CREATE OR REPLACE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$
DECLARE seq regclass; allocated bigint; max_id bigint;
BEGIN
PERFORM pg_advisory_xact_lock(715328491,target::oid::int4);
IF NOT project_audit_cutoff_safe(target) OR NOT project_audit_writers_guarded(target) THEN
RAISE EXCEPTION 'project audit cutoff guard is unsafe';
END IF;
seq := pg_get_serial_sequence(target::text,'id')::regclass;
allocated := nextval(seq);
EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id;
IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF;
RETURN allocated;
END $$`,
`DO $$ BEGIN IF NOT project_audit_cutoff_safe('command_audit'::regclass) OR NOT project_audit_writers_guarded('command_audit'::regclass) THEN RAISE EXCEPTION 'project audit writer guard migration precondition failed'; END IF; END $$`,
},
},
down: map[string][]string{
"sqlite": {`SELECT 1`},
"postgres": {
`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`,
`CREATE OR REPLACE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$
DECLARE seq regclass; allocated bigint; max_id bigint;
BEGIN
PERFORM pg_advisory_xact_lock(715328491,target::oid::int4);
IF NOT project_audit_cutoff_safe(target) THEN RAISE EXCEPTION 'project audit cutoff sequence is unsafe'; END IF;
seq := pg_get_serial_sequence(target::text,'id')::regclass;
allocated := nextval(seq);
EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id;
IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF;
RETURN allocated;
END $$`,
`DROP TRIGGER project_audit_insert_guard_v1 ON command_audit`,
`DROP TRIGGER project_audit_mutation_guard_v1 ON command_audit`,
`DROP FUNCTION project_audit_insert_guard_v1()`,
`DROP FUNCTION project_audit_mutation_guard_v1()`,
`DROP FUNCTION project_audit_writers_guarded(regclass)`,
},
}},
migration{version: 46, name: "project_work_observation_outbox",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`},
"postgres": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
"postgres": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
}},
migration{version: 47, name: "human_decision_proofs",
up: map[string][]string{
"sqlite": {`CREATE TABLE human_decisions (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
audience TEXT NOT NULL,
command_id TEXT NOT NULL,
human_actor TEXT NOT NULL,
owner TEXT NOT NULL,
project_id TEXT NOT NULL,
proposal_id TEXT NOT NULL,
proposal_revision INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
question TEXT NOT NULL,
status TEXT NOT NULL,
decision TEXT NOT NULL DEFAULT '',
proof_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
responded_at TEXT NOT NULL DEFAULT ''
)`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`},
"postgres": {`CREATE TABLE human_decisions (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
audience TEXT NOT NULL,
command_id TEXT NOT NULL,
human_actor TEXT NOT NULL,
owner TEXT NOT NULL,
project_id TEXT NOT NULL,
proposal_id TEXT NOT NULL,
proposal_revision INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
question TEXT NOT NULL,
status TEXT NOT NULL,
decision TEXT NOT NULL DEFAULT '',
proof_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
responded_at TEXT NOT NULL DEFAULT ''
)`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
"postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
}},
migration{version: 48, name: "document_render_deliveries",
up: map[string][]string{
"sqlite": {`CREATE TABLE document_render_deliveries (
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
"postgres": {`CREATE TABLE document_render_deliveries (
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
"postgres": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
}},
migration{version: 49, name: "role_policy_authority",
up: map[string][]string{
"sqlite": rolePolicyFoundationUp("INTEGER"),
"postgres": rolePolicyFoundationUp("BOOLEAN"),
},
down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown},
},
migration{version: 50, name: "role_authority_import_stage",
up: map[string][]string{"sqlite": roleAuthorityImportStageUp, "postgres": roleAuthorityImportStageUp},
down: map[string][]string{"sqlite": {`DROP TABLE role_authority_import_state`}, "postgres": {`DROP TABLE role_authority_import_state`}},
},
migration{version: 51, name: "super_admin_identity",
// 超级管理员是独立的持久标记(KTD1):不从 is_admin/角色/成员资格推断;
// 部分唯一索引在库层保证至多一个标记行。
up: map[string][]string{
"sqlite": {
`ALTER TABLE users ADD COLUMN is_super_admin INTEGER NOT NULL DEFAULT 0`,
`CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin=1`,
},
"postgres": {
`ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT FALSE`,
`CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin`,
},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
"postgres": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
},
},
)
}
// v35 分层 Python 环境:扩展包申请与会话绑定。层目录本身在环境根,不入库。
var execEnvUp = []string{
`CREATE TABLE IF NOT EXISTS exec_env_requests (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
spec TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
layer_id TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
approver TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_exec_env_requests_conv ON exec_env_requests(conversation_id, created_at)`,
`CREATE TABLE IF NOT EXISTS exec_env_bindings (
conversation_id TEXT PRIMARY KEY,
base_id TEXT NOT NULL DEFAULT '',
layers TEXT NOT NULL DEFAULT '[]',
isolated TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)`,
}
var execEnvDown = []string{
`DROP TABLE IF EXISTS exec_env_bindings`,
`DROP INDEX IF EXISTS idx_exec_env_requests_conv`,
`DROP TABLE IF EXISTS exec_env_requests`,
}
var roleAuthorityImportStageUp = []string{
`CREATE TABLE role_authority_import_state (
id INTEGER PRIMARY KEY,source_sha256 TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'idle',
payload_json TEXT NOT NULL DEFAULT '',staged_by TEXT NOT NULL DEFAULT '',updated_at TEXT NOT NULL DEFAULT '')`,
`INSERT INTO role_authority_import_state(id,source_sha256,state,payload_json,staged_by,updated_at)
VALUES(1,'','idle','','','')`,
}
func rolePolicyFoundationUp(boolType string) []string {
statements := []string{
`CREATE TABLE roles (
role_id TEXT PRIMARY KEY,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',enabled ` + boolType + ` NOT NULL,
role_version BIGINT NOT NULL DEFAULT 1,created_by TEXT NOT NULL,updated_by TEXT NOT NULL,
created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
FOREIGN KEY(created_by) REFERENCES users(id),FOREIGN KEY(updated_by) REFERENCES users(id))`,
`CREATE TABLE role_permissions (
role_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL,
cap_allowed ` + boolType + ` NOT NULL,permission_version BIGINT NOT NULL DEFAULT 1,
updated_by TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(role_id,action_id),
FOREIGN KEY(role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
FOREIGN KEY(updated_by) REFERENCES users(id))`,
`CREATE TABLE user_role_bindings (
binding_id TEXT PRIMARY KEY,user_id TEXT NOT NULL,role_id TEXT NOT NULL,
active ` + boolType + ` NOT NULL,binding_version BIGINT NOT NULL DEFAULT 1,
assigned_by TEXT NOT NULL,assigned_at TEXT NOT NULL,ended_at TEXT NOT NULL DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id),FOREIGN KEY(role_id) REFERENCES roles(role_id),
FOREIGN KEY(assigned_by) REFERENCES users(id))`,
`CREATE UNIQUE INDEX uq_user_role_bindings_active ON user_role_bindings(user_id) WHERE active=TRUE`,
`CREATE INDEX idx_user_role_bindings_role ON user_role_bindings(role_id,active)`,
`CREATE TABLE system_grants (
user_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL,
grant_version BIGINT NOT NULL DEFAULT 1,updated_by TEXT NOT NULL,updated_at TEXT NOT NULL,
PRIMARY KEY(user_id,action_id),FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(updated_by) REFERENCES users(id))`,
`CREATE TABLE authority_state (
id INTEGER PRIMARY KEY,authority_epoch BIGINT NOT NULL,catalog_version BIGINT NOT NULL,
updated_at TEXT NOT NULL)`,
`INSERT INTO authority_state(id,authority_epoch,catalog_version,updated_at) VALUES(1,1,1,'')`,
`CREATE TABLE policy_receipts (
receipt_id TEXT PRIMARY KEY,actor_user_id TEXT NOT NULL,idempotency_key TEXT NOT NULL,
operation TEXT NOT NULL,request_hash TEXT NOT NULL,result_json TEXT NOT NULL,created_at TEXT NOT NULL,
UNIQUE(actor_user_id,idempotency_key),FOREIGN KEY(actor_user_id) REFERENCES users(id))`,
}
for _, actionID := range rolePolicyAdminActionsV49 {
statements = append(statements, `INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
SELECT id,'`+actionID+`',TRUE,1,id,created_at FROM users WHERE is_admin=TRUE`)
}
return statements
}
var rolePolicyAdminActionsV49 = []string{
"system.settings.read", "system.settings.write",
"system.users.view", "system.users.create", "system.users.edit", "system.users.disable",
"system.roles.view", "system.roles.manage", "system.roles.assign",
"system.permissions.view", "system.permissions.manage", "project.create",
}
var rolePolicyFoundationDown = []string{
`DROP TABLE policy_receipts`,
`DROP TABLE authority_state`,
`DROP TABLE system_grants`,
`DROP INDEX idx_user_role_bindings_role`,
`DROP INDEX uq_user_role_bindings_active`,
`DROP TABLE user_role_bindings`,
`DROP TABLE role_permissions`,
`DROP TABLE roles`,
}
var recoveryFoundationUp = []string{
`ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
`ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE login_keys ADD COLUMN issued_auth_version INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE login_keys ADD COLUMN proposal_id TEXT NOT NULL DEFAULT ''`,
`CREATE TABLE recovery_proposals (
id TEXT PRIMARY KEY,user_id TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 1,generation BIGINT NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL,
issued_auth_version INTEGER NOT NULL,idempotency_key TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX idx_recovery_proposals_owner_status ON recovery_proposals(owner,status)`,
`CREATE UNIQUE INDEX uq_recovery_proposals_actor_idempotency ON recovery_proposals(created_by,idempotency_key) WHERE idempotency_key<>''`,
`CREATE TABLE recovery_proposal_proofs (
proposal_id TEXT NOT NULL,session_hash TEXT NOT NULL,PRIMARY KEY(proposal_id,session_hash)
)`,
}
var recoveryFoundationDown = []string{
`DROP TABLE recovery_proposal_proofs`,
`DROP INDEX uq_recovery_proposals_actor_idempotency`,
`DROP INDEX idx_recovery_proposals_owner_status`,
`DROP TABLE recovery_proposals`,
`ALTER TABLE login_keys DROP COLUMN proposal_id`,
`ALTER TABLE login_keys DROP COLUMN issued_auth_version`,
`ALTER TABLE login_keys DROP COLUMN generation`,
`ALTER TABLE login_keys DROP COLUMN grant_origin`,
}
// v32 preserves legacy identities and credentials. Only the explicit pending
// account primitive sets pending initialization; migration cannot infer proof.
func accountFoundationUp(enabledType, revisionExpression string) []string {
return []string{
`ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE users ADD COLUMN staff_no TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE users ADD COLUMN notes TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE users ADD COLUMN profile_version BIGINT NOT NULL DEFAULT 1 CHECK(profile_version>=1)`,
`ALTER TABLE users ADD COLUMN enabled ` + enabledType,
`ALTER TABLE users ADD COLUMN state_version BIGINT NOT NULL DEFAULT 1 CHECK(state_version>=1)`,
`ALTER TABLE users ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
`ALTER TABLE users ADD COLUMN initialization_state TEXT NOT NULL DEFAULT 'legacy'`,
`ALTER TABLE users ADD COLUMN initialization_generation BIGINT NOT NULL DEFAULT 0 CHECK(initialization_generation>=0)`,
`ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'legacy_unknown'`,
`ALTER TABLE sessions ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
`ALTER TABLE sessions ADD COLUMN grant_id TEXT NOT NULL DEFAULT ''`,
`CREATE TABLE account_directory_revision(id INTEGER PRIMARY KEY CHECK(id=1),public_revision TEXT NOT NULL,staff_no_revision TEXT NOT NULL,private_revision TEXT NOT NULL)`,
`INSERT INTO account_directory_revision(id,public_revision,staff_no_revision,private_revision) VALUES(1,` + revisionExpression + `,` + revisionExpression + `,` + revisionExpression + `)`,
}
}
// Down is for isolated migration/recovery rehearsal. Operational rollback must
// respect the security compatibility floor and cannot re-enable old writers.
var accountFoundationDown = []string{
`DROP TABLE account_directory_revision`,
`ALTER TABLE sessions DROP COLUMN grant_id`,
`ALTER TABLE sessions DROP COLUMN credential_origin`,
`ALTER TABLE sessions DROP COLUMN auth_method`,
`ALTER TABLE users DROP COLUMN initialization_generation`,
`ALTER TABLE users DROP COLUMN initialization_state`,
`ALTER TABLE users DROP COLUMN credential_origin`,
`ALTER TABLE users DROP COLUMN state_version`,
`ALTER TABLE users DROP COLUMN enabled`,
`ALTER TABLE users DROP COLUMN profile_version`,
`ALTER TABLE users DROP COLUMN notes`,
`ALTER TABLE users DROP COLUMN department`,
`ALTER TABLE users DROP COLUMN staff_no`,
`ALTER TABLE users DROP COLUMN email`,
`ALTER TABLE users DROP COLUMN phone`,
}
// 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 排队;后续版本读取发生在同一事务内,
// 前一个迁移者提交后本进程看到的已是完整账本(无部分版本可见)。
if err := s.migratePass(); err != nil {
return err
}
pending, err := s.ensureContextScrubState()
if err != nil {
return err
}
if pending {
// The prior candidate schema may have stored full prompts. VACUUM is
// outside the DDL transaction; it compacts the isolated SQLite file
// after v41 drops those columns. Refuse to serve if compaction fails.
if _, err := s.db.Exec(`PRAGMA secure_delete=ON`); err != nil {
return fmt.Errorf("candidate context scrub: %w", err)
}
if _, err := s.db.Exec(`VACUUM`); err != nil {
return fmt.Errorf("candidate context scrub: %w", err)
}
var busy, logFrames, checkpointed int
if err := s.db.QueryRow(`PRAGMA wal_checkpoint(TRUNCATE)`).Scan(&busy, &logFrames, &checkpointed); err != nil {
return fmt.Errorf("candidate context scrub checkpoint: %w", err)
}
if busy != 0 {
return errors.New("candidate context scrub checkpoint remains busy")
}
if _, err := s.db.Exec(`UPDATE context_scope_scrub_state SET pending=0 WHERE id=1 AND pending=1`); err != nil {
return fmt.Errorf("candidate context scrub completion: %w", err)
}
}
return nil
}
// ensureContextScrubState handles databases that ran the earlier candidate
// an earlier candidate before a durable marker existed. They are re-scrubbed.
func (s *Store) ensureContextScrubState() (bool, error) {
var rawColumns int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil {
return false, err
}
if rawColumns != 0 {
return false, errors.New("candidate context raw scope columns remain after migration")
}
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY CHECK(id=1),pending INTEGER NOT NULL CHECK(pending IN (0,1)))`); err != nil {
return false, err
}
// An absent row may be a partially initialized earlier candidate; mark it
// pending before any physical cleanup. A fresh v41 inserted pending=0.
if _, err := s.db.Exec(`INSERT OR IGNORE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`); err != nil {
return false, err
}
var pending int
if err := s.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil {
return false, err
}
return pending != 0, nil
}
// 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]string{}
for _, m := range migrations {
known[m.version] = m.name
}
rows, err := tx.Query(`SELECT version,name FROM schema_migrations`)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
for rows.Next() {
var v int
var name string
if err := rows.Scan(&v, &name); err != nil {
rows.Close()
return fmt.Errorf("migrate: %w", err)
}
// 兼容门禁(KTD8 应用侧只核对兼容版本):库里有本二进制不认识的版本
// 说明库由更新的构建迁移过——拒绝启动,不冒险在未知 schema 上服务。
expectedName, knownVersion := known[v]
if !knownVersion {
rows.Close()
return fmt.Errorf("migrate: 数据库 schema 版本 v%d 超出本构建(最高 v%d);拒绝启动,请升级二进制", v, latestMigrationVersion())
}
if name != expectedName {
rows.Close()
return fmt.Errorf("migrate: %w", ValidateMigrationIdentity(v, name))
}
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
}
candidateRawScope := false
if m.version == 41 {
candidateRawScope, err = scrubCandidateContextScope(tx, s.dialect.name())
if err != nil {
return fmt.Errorf("migrate v41: %w", err)
}
}
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)
}
}
if m.version == 41 && s.dialect.name() == "sqlite" {
pending := 0
if candidateRawScope {
pending = 1
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,?)`, pending); err != nil {
return fmt.Errorf("migrate v41 scrub marker: %w", 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()
}
// Earlier candidate databases were never released but may contain raw Resolve
// goals. Fresh v40 creates no such columns; v41 removes them conditionally
// from an earlier candidate schema before serving.
func scrubCandidateContextScope(tx *sql.Tx, dialect string) (bool, error) {
found := false
for _, column := range []string{"resolve_goal", "resolve_target_id"} {
var exists int
var err error
if dialect == "sqlite" {
err = tx.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name=?`, column).Scan(&exists)
} else {
err = tx.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name=$1`, column).Scan(&exists)
}
if err != nil {
return false, err
}
if exists == 0 {
continue
}
found = true
if _, err := tx.Exec(`UPDATE context_manifests SET ` + column + `=''`); err != nil {
return false, err
}
if _, err := tx.Exec(`ALTER TABLE context_manifests DROP COLUMN ` + column); err != nil {
return false, err
}
}
return found, nil
}
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,name FROM schema_migrations ORDER BY version`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []int{}
for rows.Next() {
var v int
var name string
if err := rows.Scan(&v, &name); err != nil {
return nil, err
}
if err := ValidateMigrationIdentity(v, name); err != nil {
return nil, fmt.Errorf("migrate: %w; refuse to roll back another schema", 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
}