package memory import ( "context" "database/sql" "fmt" "strings" ) // 本文件是「留多久 / 干什么用」轴(迁移 0026)的 schema 前滚:给**既有**的 // memory_items 补 `retention_tier` 列,再建读侧索引。 // // 为什么需要在这里前滚:`createMemoryItemsTable` 是 `CREATE TABLE IF NOT EXISTS`, // 它只对**新库**生效 —— 既有部署的表早就建好了,新列不会自己长出来。这与 // `ensureProjectIDColumn` / `ensureTidyMemoryColumns` 是同一手法、同一原因 // (记忆存储必须自包含、可在任意 *sql.DB 上使用)。 // // 🔴 与迁移的分工(两份真源,必须同形):权威机制是 // `internal/db/migrations/0026_memory_retention_tier`;这里是「任意 *sql.DB」的 // 前滚。启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产 // 库里 0026 先落地、这里走到 `duplicate column name` 是**预期路径**。 // // ⚠️ **反向顺序不成立**(别把上面那句读成「两个方向都行」):先 NewStore、后 // MigrateUp 会在 **0019** 就先炸(`duplicate column name: status` —— 自举早已把 // 整理列建好),这是既有问题、与本片无关,因此本仓的可迁移顺序只有「先迁移」。 // // 顺序**必须**是「建表 → 前滚列 → 建索引」:索引依赖的列在既有库上还不存在时, // 先建索引会报 "no such column: retention_tier"(tidy_schema.go 记过同型的教训)。 // ensureRetentionSchema 幂等地给 memory_items 补上档位列与读侧索引。 func ensureRetentionSchema(ctx context.Context, db *sql.DB) error { if err := ensureRetentionTierColumn(ctx, db); err != nil { return err } if _, err := db.ExecContext(ctx, createMemoryItemsRetentionTierIndex); err != nil { return fmt.Errorf("memory: retention tier index: %w", err) } return nil } // ensureRetentionTierColumn 在列缺失时补加 `retention_tier`,已存在时忽略 // SQLite 的 "duplicate column name"(那是预期路径,不是错误)。 // // 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(不设过期、读路径照常可见), // 本片也刻意**不**在启动时回填它们的档位 —— 回填是一次写全表的动作,该由整理侧 // 显式做(「按空串过滤」正是那个入口),不该藏在组合根的启动路径里。 func ensureRetentionTierColumn(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, `ALTER TABLE memory_items ADD COLUMN retention_tier TEXT NOT NULL DEFAULT ''`) if err == nil { return nil } if strings.Contains(err.Error(), "duplicate column name") { return nil } return fmt.Errorf("memory: roll forward retention_tier column: %w", err) }