Files
Agent-System-Backend/cmd/storage-migrate/main.go
T
weichen bc1e0a36f7
ci / vet-and-test (pull_request) Successful in 51s
Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery
Why: The login recovery contract leftovers and KTD12/KTD14 require:
concurrent same-name account creation must not overwrite the winner,
first-admin bootstrap must serialize across processes, the redeem
endpoint backoff must be shared across instances, revoked API keys
must close established SSE streams, and CLI credentials need an
env-DSN path plus a non-interactive 0600 file delivery mode.

Evidence: go test ./... green; new tests cover 16-way concurrent
create (1 win/15 conflict, winner credentials intact), 8-way
concurrent bootstrap (single admin, refuse takeover after), redeem
guard shared across two store handles on one DB, SSE stream closed
after key revocation within the recheck window, and HTTP 429 after
the failure cap (SELF worktree run; CI is the GATE, PG via test-pg).

Impact: Migration v29 (redeem_attempts); CreateAccount becomes a
single-row INSERT with unique-violation conflict; BootstrapFirstAdmin
advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in
one transaction; SSE revalidation covers API-key streams; redeem
backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback
and --out file delivery for create/reset-password/issue-login-key/
bootstrap-admin.

Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers;
proposal r2 KTD12/KTD14/R5/R11.
2026-10-13 06:05:45 +00:00

862 lines
26 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.
// storage-migrate:SQLite → PostgreSQL 受管迁移入口(协作存储方案 U4/R7)。
//
// 纪律(方案 §5 U4 / §6):
// - 只处理 Backend 库;不执行 SQLite dump 作为 PG SQL(按行复制 + 类型映射);
// - 迁移身份(migration_id + source_snapshot_id + source_schema_version)落
// 目标库 migration_control:仅同一冻结快照可续传;目标已含其他数据即拒绝;
// - 报告只含计数、校验和与归属检查结论——不含 DSN、密钥与行正文(KTD14)。
//
// 用法:
//
// storage-migrate inventory --source agent-system.db [--artifacts dir] [--out report.json]
// storage-migrate backup --source agent-system.db --out snapshot.db
// storage-migrate migrate --source snapshot.db --target "postgres://…" \
// --migration-id <名> [--artifacts dir] [--out report.json]
package main
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // SQLite 驱动(源,只读打开)
"agent-system-backend/internal/store"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "inventory":
err = cmdInventory(os.Args[2:])
case "backup":
err = cmdBackup(os.Args[2:])
case "migrate":
err = cmdMigrate(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "storage-migrate:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "用法:storage-migrate <inventory|backup|migrate> [flags](-h 看各子命令 flags)")
}
// ---- 公共小件 ----
func openSourceRO(path string) (*sql.DB, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("source %s: %w", path, err)
}
// query_only:迁移源只读(冻结快照,任何写入都是缺陷)。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=query_only(1)", path))
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("source %s: %w", path, err)
}
return db, nil
}
func fileSHA256(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// ---- inventory:只读盘点(§6.1) ----
func cmdInventory(args []string) error {
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
source := fs.String("source", "", "SQLite 库路径(活跃库或冻结快照)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;盘点文件清单与哈希)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" {
return fmt.Errorf("--source 必填")
}
db, err := openSourceRO(*source)
if err != nil {
return err
}
defer db.Close()
rep, err := inventoryReport(db, *source, *artifacts)
if err != nil {
return err
}
return writeReport(rep, *out)
}
func inventoryReport(db *sql.DB, source, artifactsDir string) (map[string]any, error) {
version, err := sourceSchemaVersion(db)
if err != nil {
return nil, err
}
var mode string
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
return nil, fmt.Errorf("journal_mode: %w", err)
}
var chk string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return nil, fmt.Errorf("integrity_check: %v (%q)", err, chk)
}
tables, err := sqliteTableCounts(db)
if err != nil {
return nil, err
}
snap, size, err := fileSHA256(source)
if err != nil {
return nil, err
}
rep := map[string]any{
"command": "inventory", "source": source, "source_sha256": snap,
"source_bytes": size, "source_schema_version": version,
"journal_mode": mode, "integrity": chk, "table_counts": tables,
"generated_at": nowISO(),
}
if artifactsDir != "" {
manifest, err := artifactManifest(artifactsDir)
if err != nil {
return nil, err
}
rep["artifacts"] = manifest
}
return rep, nil
}
func sourceSchemaVersion(db *sql.DB) (int, error) {
var v int
err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&v)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
return 0, fmt.Errorf("源库缺 schema_migrations(不是本工具管理的库):%w", err)
}
return 0, err
}
return v, nil
}
// sqliteTableCounts:仅盘点本工具认识的业务表(见 copyOrder)。
func sqliteTableCounts(db *sql.DB) (map[string]int64, error) {
out := map[string]int64{}
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "no such table") {
continue // 旧库可能没有新表
}
return nil, fmt.Errorf("count %s: %w", t, err)
}
out[t] = n
}
return out, nil
}
func artifactManifest(dir string) (map[string]any, error) {
type entry struct {
Name, SHA string
Size int64
}
var entries []entry
hAll := sha256.New()
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
sum, _, err := fileSHA256(p)
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
entries = append(entries, entry{rel, sum, info.Size()})
fmt.Fprintf(hAll, "%s %d %s\n", rel, info.Size(), sum)
return nil
})
if err != nil {
return nil, fmt.Errorf("artifacts %s: %w", dir, err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
return map[string]any{
"dir": dir, "files": entries, "file_count": len(entries),
"manifest_sha256": hex.EncodeToString(hAll.Sum(nil)),
}, nil
}
// ---- backup:一致性快照(§6.2:VACUUM INTO + 可打开校验 + 计数对账) ----
func cmdBackup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
source := fs.String("source", "", "活跃 SQLite 库路径")
out := fs.String("out", "", "快照输出路径(不得与源同文件;已存在则拒绝)")
fs.Parse(args)
if *source == "" || *out == "" {
return fmt.Errorf("--source 与 --out 必填")
}
if same, _ := filepath.EvalSymlinks(*source); same != "" {
if abs1, _ := filepath.Abs(same); abs1 != "" {
if abs2, _ := filepath.Abs(*out); abs2 == abs1 {
return fmt.Errorf("快照输出不得与源同文件")
}
}
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("快照输出已存在(不覆盖):%s", *out)
}
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
return err
}
// VACUUM INTO 在线安全(含 WAL 内容);query_only 会禁写,需普通打开。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)", *source))
if err != nil {
return err
}
defer db.Close()
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
return fmt.Errorf("vacuum into: %w", err)
}
// 校验:快照可打开、完整性、逐表计数与源一致。
snap, err := openSourceRO(*out)
if err != nil {
return err
}
defer snap.Close()
var chk string
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return fmt.Errorf("快照完整性校验失败:%v (%q)", err, chk)
}
srcCounts, err := sqliteTableCounts(db)
if err != nil {
return err
}
snapCounts, err := sqliteTableCounts(snap)
if err != nil {
return err
}
for t, n := range srcCounts {
if snapCounts[t] != n {
return fmt.Errorf("快照计数不符:%s 源=%d 快照=%d", t, n, snapCounts[t])
}
}
sum, size, err := fileSHA256(*out)
if err != nil {
return err
}
fmt.Printf("backup ok: %s(%d 字节,sha256 %s…,%d 表计数一致)\n", *out, size, sum[:12], len(srcCounts))
return nil
}
// ---- migrate:受管迁移(§6.3 + U4) ----
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
// v26 login_keys(#171 合入 main 时漏登记;本 PR 一并补齐)
"login_keys",
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
"agent_system_identity",
"account_events",
"redeem_attempts",
}
const controlDDL = `
CREATE TABLE IF NOT EXISTS migration_control (
migration_id TEXT PRIMARY KEY,
source_snapshot_id TEXT NOT NULL,
source_schema_version INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
verified_at TEXT,
report TEXT
);
CREATE TABLE IF NOT EXISTS migration_table_state (
migration_id TEXT NOT NULL,
table_name TEXT NOT NULL,
source_count INTEGER NOT NULL,
target_count INTEGER NOT NULL,
checksum TEXT NOT NULL,
state TEXT NOT NULL,
finished_at TEXT,
PRIMARY KEY (migration_id, table_name)
);`
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
source := fs.String("source", "", "冻结快照路径(backup 子命令产物;不接受活跃库)")
target := fs.String("target", "", "目标 PostgreSQL DSN(须为空库或同身份续传库)")
migrationID := fs.String("migration-id", "", "迁移实例名(人工指定,报告与续传身份的一部分)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;仅盘点清单入报告,不复制)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" || *target == "" || *migrationID == "" {
return fmt.Errorf("--source、--target、--migration-id 必填")
}
src, err := openSourceRO(*source)
if err != nil {
return err
}
defer src.Close()
snapID, _, err := fileSHA256(*source)
if err != nil {
return err
}
srcVersion, err := sourceSchemaVersion(src)
if err != nil {
return err
}
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
st, err := store.OpenPostgres(*target, filepath.Join(filepath.Dir(*source), "migrate-artifacts"))
if err != nil {
return fmt.Errorf("open target: %w", err)
}
defer st.Close()
db := st.DB()
if _, err := db.Exec(controlDDL); err != nil {
return fmt.Errorf("control tables: %w", err)
}
if err := guardIdentity(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
if err := ensureControlRow(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
rep := map[string]any{
"command": "migrate", "migration_id": *migrationID,
"source_snapshot_id": snapID, "source_schema_version": srcVersion,
"started_at": nowISO(),
}
var tables []map[string]any
for _, t := range copyOrder {
if !sqliteHasTable(src, t) {
continue // 旧库无此表
}
entry, err := migrateTable(db, src, *migrationID, t)
if err != nil {
return fmt.Errorf("table %s: %w", t, err)
}
tables = append(tables, entry)
}
// 未知 owner 检查(U4:阻塞,不静默)。'' 与 'local' 是单租户默认身份。
if err := checkOwners(db, src); err != nil {
return err
}
// 校验:目标逐表重算计数与校验和,全部一致才落 verified。
if err := verifyTables(db, *migrationID, tables); err != nil {
return err
}
var totalSrc, totalDst int64
for _, e := range tables {
totalSrc += e["source_count"].(int64)
totalDst += e["target_count"].(int64)
}
rep["tables"] = tables
rep["rows_total"] = totalSrc
rep["verified_at"] = nowISO()
if *artifacts != "" {
manifest, err := artifactManifest(*artifacts)
if err != nil {
return err
}
rep["artifacts"] = manifest
}
repJSON, _ := json.Marshal(rep)
if _, err := db.Exec(`UPDATE migration_control SET finished_at=$1, verified_at=$2, report=$3 WHERE migration_id=$4`,
nowISO(), rep["verified_at"], string(repJSON), *migrationID); err != nil {
return err
}
return writeReport(rep, *out)
}
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
// - 存在其他 migration_id 的完成记录 → 拒绝;
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
// - 业务表已有数据而控制表为空(人工造过数据)→ 拒绝。
func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_control WHERE migration_id<>$1`, migrationID).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("目标库已有其他迁移记录(%d 条):拒绝覆盖,换空库或专项回迁方案", n)
}
var snap, started string
var sv int
err := db.QueryRow(`SELECT source_snapshot_id, source_schema_version, started_at FROM migration_control WHERE migration_id=$1`, migrationID).Scan(&snap, &sv, &started)
switch {
case err == sql.ErrNoRows:
// 新迁移:业务表必须为空。
busy, err := businessRows(db)
if err != nil {
return err
}
if busy > 0 {
return fmt.Errorf("目标库业务表已有 %d 行且无迁移身份:拒绝(非空目标不得作为迁移目标)", busy)
}
case err != nil:
return err
default:
if snap != snapID {
return fmt.Errorf("同 migration_id 但快照不同(库=%s… 本次=%s…):仅同一冻结快照可续传", snap[:12], snapID[:12])
}
_ = sv
_ = started
}
return nil
}
func businessRows(db *sql.DB) (int64, error) {
var total int64
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "does not exist") {
continue
}
return 0, err
}
total += n
}
return total, nil
}
func ensureControlRow(db *sql.DB, migrationID, snapID string, srcVersion int) error {
_, err := db.Exec(`INSERT INTO migration_control (migration_id, source_snapshot_id, source_schema_version, started_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT(migration_id) DO NOTHING`, migrationID, snapID, srcVersion, nowISO())
return err
}
func sqliteHasTable(db *sql.DB, name string) bool {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n); err != nil {
return false
}
return n > 0
}
// migrateTable:done 则对账(幂等重跑);pending 则按行复制(布尔映射 +
// 批量 INSERT + 序列推进)后落 done 与源侧校验和。
func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[string]any, error) {
cols, pk, err := targetColumns(db, table)
if err != nil {
return nil, err
}
var state string
var srcCount, dstCountStored int64
var srcChecksum string
err = db.QueryRow(`SELECT state, source_count, target_count, checksum FROM migration_table_state WHERE migration_id=$1 AND table_name=$2`,
migrationID, table).Scan(&state, &srcCount, &dstCountStored, &srcChecksum)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows {
state = "pending"
}
// 源侧计数与校验和始终重算(冻结快照上必然稳定;差异=快照被换,拒绝)。
bools, err := boolColumns(db, table, cols)
if err != nil {
return nil, err
}
countNow, sumNow, err := sourceScan(src, table, cols, pk, bools)
if err != nil {
return nil, err
}
if state == "done" {
if countNow != srcCount || sumNow != srcChecksum {
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
}
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
return entry, nil
}
if err := copyRows(db, src, table, cols); err != nil {
return nil, err
}
dstCount, _, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return nil, err
}
if dstCount != countNow {
return nil, fmt.Errorf("复制后计数不符:源=%d 目标=%d", countNow, dstCount)
}
if err := advanceSequences(db, table); err != nil {
return nil, err
}
if _, err := db.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
migrationID, table, countNow, dstCount, sumNow, "done", nowISO()); err != nil {
return nil, err
}
return map[string]any{"table": table, "source_count": countNow, "target_count": dstCount, "checksum": sumNow, "state": "done"}, nil
}
// targetColumns:目标侧列清单(按名排序)+ 主键列。布尔列集合同取自目标
// information_schema(类型映射以 PG schema 为准——SQLite 侧 0/1 → bool)。
func targetColumns(db *sql.DB, table string) (cols []string, pk []string, err error) {
// 生成列(PG GENERATED ALWAYS,如 knowledge.tsv)与库内向量副本列
// (vec_pg:BLOB 权威的派生表示,U6-3)不复制不校验:PG 从业务列
// 派生或由 Upsert 双写,源库(SQLite)没有该列。
rows, err := db.Query(`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND is_generated='NEVER'
AND column_name NOT IN ('vec_pg') ORDER BY column_name`, table)
if err != nil {
return nil, nil, err
}
defer rows.Close()
exists := false
for rows.Next() {
var name, dtype string
if err := rows.Scan(&name, &dtype); err != nil {
return nil, nil, err
}
exists = true
cols = append(cols, name)
}
if !exists {
return nil, nil, fmt.Errorf("目标缺表 %s(迁移器未建?)", table)
}
prows, err := db.Query(`
SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
WHERE i.indisprimary AND i.indrelid=$1::regclass`, table)
if err != nil {
return nil, nil, err
}
defer prows.Close()
for prows.Next() {
var p string
if err := prows.Scan(&p); err != nil {
return nil, nil, err
}
pk = append(pk, p)
}
if len(pk) == 0 {
pk = cols // 无主键表按全列序校验
}
return cols, pk, nil
}
func boolColumns(db *sql.DB, table string, cols []string) (map[string]bool, error) {
out := map[string]bool{}
rows, err := db.Query(`
SELECT column_name FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND data_type='boolean'`, table)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out[c] = true
}
return out, nil
}
// canonicalRow:稳定行编码(校验和用):列名排序、值规范化
// (bool→true/false、[]byte→base64、NULL→null、数值/字符串原样)。
func canonicalRow(cols []string, vals []any, bools map[string]bool) string {
type kv struct {
K string
V any
}
pairs := make([]kv, len(cols))
for i := range cols {
pairs[i] = kv{cols[i], normalize(vals[i], bools[cols[i]])}
}
b, _ := json.Marshal(pairs)
return string(b)
}
// normalize:布尔列按目标 schema 对称归一(源 int64 0/1、目标 bool 编码一致,
// 否则校验和必然不符——真库实测);[]byte→base64。
func normalize(v any, isBool bool) any {
if isBool {
return sqliteBool(v)
}
switch t := v.(type) {
case []byte:
return base64.StdEncoding.EncodeToString(t)
default:
return v
}
}
// hashChain:按主键序哈希链(前后衔接,顺序敏感)。
func hashChain(prev string, rowJSON string) string {
h := sha256.Sum256([]byte(prev + "|" + rowJSON))
return hex.EncodeToString(h[:])
}
// scanChecksum:源/目标共用的扫描对账(列清单一致、主键序、行哈希链)。
func scanChecksum(rows *sql.Rows, cols []string, bools map[string]bool) (int64, string, error) {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
var count int64
sum := ""
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
return 0, "", err
}
sum = hashChain(sum, canonicalRow(cols, vals, bools))
count++
}
return count, sum, rows.Err()
}
func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
orderBy := "ORDER BY " + quoteJoin(pk, `"`)
rows, err := src.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ` + orderBy)
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func quoteJoin(names []string, q string) string {
out := make([]string, len(names))
for i, n := range names {
out[i] = q + n + q
}
return strings.Join(out, ",")
}
// copyRows:按行复制。布尔列 0/1→false/true;其余原样(TEXT/INT/REAL/BLOB)。
func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
colList := quoteJoin(cols, `"`)
ph := make([]string, len(cols))
for i := range ph {
ph[i] = fmt.Sprintf("$%d", i+1)
}
placeholders := strings.Join(ph, ",")
srcRows, err := src.Query(`SELECT ` + colList + ` FROM "` + table + `"`)
if err != nil {
return err
}
defer srcRows.Close()
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
if err != nil {
return err
}
defer stmt.Close()
var batch int
for srcRows.Next() {
if err := srcRows.Scan(ptrs...); err != nil {
return err
}
args := make([]any, len(cols))
for i, c := range cols {
v := vals[i]
if bools[c] {
args[i] = sqliteBool(v)
} else {
args[i] = v
}
}
if _, err := stmt.Exec(args...); err != nil {
return fmt.Errorf("insert: %w", err)
}
batch++
}
if err := srcRows.Err(); err != nil {
return err
}
return tx.Commit()
}
func sqliteBool(v any) bool {
switch t := v.(type) {
case int64:
return t != 0
case bool:
return t
default:
return false
}
}
// advanceSequences:SERIAL/identity 列复制后推进序列(显式插入不推进,
// 后续应用插入会撞主键)。无序列表为空操作。
func advanceSequences(db *sql.DB, table string) error {
rows, err := db.Query(`
SELECT pg_get_serial_sequence($1, column_name)
FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
if err != nil {
return err
}
defer rows.Close()
var seqs []string
for rows.Next() {
var s any
if err := rows.Scan(&s); err != nil {
return err
}
if s != nil {
seqs = append(seqs, s.(string))
}
}
if err := rows.Err(); err != nil {
return err
}
for _, s := range seqs {
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
return fmt.Errorf("setval %s: %w", s, err)
}
}
return nil
}
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
}
rows, err := src.Query(`SELECT DISTINCT owner FROM "` + t + `" WHERE owner<>'' AND owner<>'local'`)
if err != nil {
return err
}
var unknown []string
for rows.Next() {
var o string
if err := rows.Scan(&o); err != nil {
rows.Close()
return err
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE owner=$1`, o).Scan(&n); err != nil {
rows.Close()
return err
}
if n == 0 {
unknown = append(unknown, o)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(unknown) > 0 {
return fmt.Errorf("表 %s 引用未知 owner(users 无此行):%v——先在目标补建用户或修正归属,再迁移", t, unknown)
}
}
return nil
}
// verifyTables:目标逐表重算,与登记的源计数/校验和全部一致才通过。
func verifyTables(db *sql.DB, migrationID string, tables []map[string]any) error {
for _, e := range tables {
table := e["table"].(string)
wantCount := e["source_count"].(int64)
wantSum := e["checksum"].(string)
cols, pk, err := targetColumns(db, table)
if err != nil {
return err
}
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
gotCount, gotSum, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return err
}
if gotCount != wantCount || gotSum != wantSum {
return fmt.Errorf("表 %s 校验不符:count %d/%d checksum %s…/%s…", table, gotCount, wantCount, gotSum[:12], wantSum[:12])
}
}
return nil
}
func writeReport(rep map[string]any, out string) error {
b, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return err
}
if out == "" {
fmt.Println(string(b))
return nil
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
return os.WriteFile(out, append(b, '\n'), 0o644)
}