package db import ( "context" "database/sql" _ "embed" "errors" "fmt" "strings" ) //go:embed migrations/init.sql var initSQLPostgres string //go:embed migrations/init_sqlite.sql var initSQLSQLite string // Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。 func Migrate(ctx context.Context) error { switch D { case Postgres: // PG 侧含 DO $$ … $$ 迁移块,必须整体提交 if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil { return fmt.Errorf("migrate postgres: %w", err) } case SQLite: // modernc.org/sqlite 的 Exec 不接受多语句,逐条执行 for i, stmt := range splitStatements(initSQLSQLite) { if _, err := DB.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err) } } // CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有 // ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。 if err := addMissingColumns(ctx); err != nil { return err } default: return fmt.Errorf("migrate: 未初始化的方言") } if err := backfillMailReads(ctx); err != nil { return err } fmt.Printf("数据库迁移完成(%s)\n", D) return nil } /* backfillMailReads 把已读模型从"邮件级"迁到"读者级"时补一次历史数据。 背景:`mails.status='read'` 原先表示"有人读过",但**没记是谁读的**。新的 mail_reads 表按读者记录,因此老数据只能推断 —— 取主收件人(to_name)作为默认读者: 绝大多数已读发生在主收件人身上,而抄送方"被代读"的情况本来就是要修掉的错。 **只能跑一次**:每次启动都跑的话,它会把"某个抄送方读过"的邮件按主收件人写成已读 —— 正是这次要修的语义错误。所以用 app_meta 里的标记守住(见 init_sqlite.sql 的注释)。 */ func backfillMailReads(ctx context.Context) error { const marker = "read_model_per_recipient_v1" var v string err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v) if err == nil { return nil // 已迁过 } if !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("backfill mail_reads: 读标记失败: %w", err) } // INSERT ... SELECT ... WHERE NOT EXISTS 两种方言都认;WHERE 只是防御 // (标记保证只跑一次,但万一上次中断在半路,这条能安全续上)。 res, err := DB.ExecContext(ctx, ` INSERT INTO mail_reads (mail_id, reader_name) SELECT m.mail_id, m.to_name FROM mails m WHERE m.status = 'read' AND NOT EXISTS ( SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = m.to_name )`) if err != nil { return fmt.Errorf("backfill mail_reads: %w", err) } n, _ := res.RowsAffected() if _, err := DB.ExecContext(ctx, `INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker, fmt.Sprintf("done rows=%d", n)); err != nil { return fmt.Errorf("backfill mail_reads: 写标记失败: %w", err) } if n > 0 { fmt.Printf("已读模型迁移:把 %d 封历史已读邮件记到主收件人名下(一次性)\n", n) } return nil } // splitStatements 按分号切分 SQL 脚本并剔除注释行。 // 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号, // 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。 func splitStatements(script string) []string { var out []string for _, raw := range strings.Split(script, ";") { var lines []string for _, line := range strings.Split(raw, "\n") { if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") { continue } lines = append(lines, line) } if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" { out = append(out, stmt) } } return out } // sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。 // // 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。 // PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法, // 只能先查 pragma 再决定加不加。 // // 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。 var sqliteAddColumns = []struct{ table, column, ddl string }{ {"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"}, {"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"}, {"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"}, {"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"}, // 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。 {"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"}, {"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"}, // 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推 // (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会 // 造出一批看起来有值实际是错的数据。 {"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"}, // 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回 // to_address / agent_name,历史事件因此继续工作,不需要数据迁移。 {"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"}, {"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"}, // 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」, // 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的, // 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。 {"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"}, // 日历事件的权限档位(plan / workspace / full)。事件触发时若新建会话, // 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。 // // 旧库默认 'workspace':历史事件补发提醒不该静默升到 full(提权路径 // 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。 // 与 sessions.permission_mode 的默认取向一致。 {"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"}, // 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」, // 与新建会话的语义一致,不需要数据迁移。 {"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"}, // 派给该 Agent 的新任务默认多少个来回。 // 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同, // 不能直接搬过来当单任务预算。 {"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"}, // 会话级权限档位(plan / workspace / full)。 // // 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」, // 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权, // 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。 {"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"}, // 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。 // // 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。 // 保守方向是承认做不到,而不是假装做到了。 {"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"}, // Agent 自报的档位强制能力(native / advisory),随心跳更新。 // 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是 // 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。 {"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"}, // 待决请求类型(permission/question)与多选语义,供 ask_user_question 桥接使用。 // 旧库默认 'permission'/0:历史请求是危险工具审批,语义不变。 {"permission_requests", "kind", "ALTER TABLE permission_requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'permission'"}, {"permission_requests", "multi_select", "ALTER TABLE permission_requests ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0"}, // 邮件上的请求类型与多选标记(与 permission_requests 表一致)。 // 旧库默认 ''/0:历史权限邮件按单选审批渲染。 {"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"}, {"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"}, } // sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。 // CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。 var sqliteAddIndexes = []string{ // 接管平台会话时按 platform_id 反查(依赖上面补的列) "CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''", // 人类决策后要按 mail_id 反查上游 permission id "CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)", } func addMissingColumns(ctx context.Context) error { for _, c := range sqliteAddColumns { has, err := columnExists(ctx, c.table, c.column) if err != nil { return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err) } if has { continue } if _, err := DB.ExecContext(ctx, c.ddl); err != nil { return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err) } fmt.Printf("补列 %s.%s\n", c.table, c.column) } for _, ddl := range sqliteAddIndexes { if _, err := DB.ExecContext(ctx, ddl); err != nil { return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err) } } return nil } func columnExists(ctx context.Context, table, column string) (bool, error) { // pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。 // table 与 column 都来自上面的硬编码常量表,不存在注入面。 var n int err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&n) return n > 0, err }