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