package memory import ( "context" "database/sql" "slices" "strings" "testing" "time" ) // 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**, // 手法照 preinsert_migration_test.go 对 0025 的那一套(同一批 helpers)。 // // 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份 // 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,retention_schema.go)。 // 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有: // // 1. TestMigration0026AddsColumnOverExistingRows —— 迁移落在**已有数据**上: // 行不丢、列补上、默认值是空串(未分型);down 真的把列删掉且不改任何一行的 // 其它数据;再 up 能重建。 // 2. TestMigration0026ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形** // (它红的时候,正确的修法是让两份对齐,而不是放宽断言)。 // retentionTierColumn 是被测列名(迁移与自举 DDL 里的字面量)。 const retentionTierColumn = "retention_tier" // retentionTierIndex 是被测索引名。 const retentionTierIndex = "idx_memory_items_retention_tier" // retentionMigrationID 是 0026 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。 const retentionMigrationID = "0026_memory_retention_tier" // hasMemoryColumn 报告 memory_items 上是否存在某列。 func hasMemoryColumn(t *testing.T, d *sql.DB, name string) bool { t.Helper() return slices.ContainsFunc(tableColumns(t, d, "memory_items"), func(c string) bool { return strings.Contains(c, "|"+name+"|") }) } // hasMemoryIndex 报告 memory_items 上是否存在某条索引。 func hasMemoryIndex(t *testing.T, d *sql.DB, name string) bool { t.Helper() return slices.ContainsFunc(tableIndexes(t, d, "memory_items"), func(i string) bool { return strings.HasPrefix(i, name+"|") }) } // insertLegacyMemoryRow 以**0026 之前的形态**写一行(不列 retention_tier)。 func insertLegacyMemoryRow(t *testing.T, d *sql.DB, id, content string) { t.Helper() if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items (id, persona_id, scope, kind, content, created_at, updated_at) VALUES (?, 'persona.a', 'persona', 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`)`, id, content); err != nil { t.Fatalf("insert legacy row %s: %v", id, err) } } // legacyTimeLiteral 是定宽 UTC 文本(与 formatTime 同形,读侧纯文本比较依赖它)。 const legacyTimeLiteral = "'2026-10-01T10:00:00.000000000Z'" // TestMigration0026AddsColumnOverExistingRows 钉住迁移本体的三条: // 落在既有数据上不丢行、默认值是空串(未分型)、down 真的删列且不动其它数据。 func TestMigration0026AddsColumnOverExistingRows(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) // 造出「既有部署」:回到 0026 **之前**(列与索引消失),再按**旧形态**写入两行。 // 步数由账本现算:0027(`temporal`,切片 M45)压在上面,写死 1 会撤错那条。 migrateDownToBefore(t, d, retentionMigrationID) if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex) } insertLegacyMemoryRow(t, d, "legacy-1", "一条 0026 之前就存在的记忆") insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0026 之前就存在的记忆") // 升级:0026 落在**已有数据**上。 migrateUp(t, d) if !hasMemoryColumn(t, d, retentionTierColumn) { t.Fatalf("迁移后 memory_items 应带 %s 列", retentionTierColumn) } if !hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("迁移后应存在索引 %s", retentionTierIndex) } var rows int if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil { t.Fatalf("count rows: %v", err) } if rows != 2 { t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows) } // 默认值必须是**未分型**(空串),不是某一档 —— 本迁移刻意不做回填。 for _, id := range []string{"legacy-1", "legacy-2"} { var tier, content string if err := d.QueryRowContext(ctx, `SELECT retention_tier, content FROM memory_items WHERE id = ?`, id). Scan(&tier, &content); err != nil { t.Fatalf("read %s: %v", id, err) } if tier != "" { t.Fatalf("既有行 %s 的档位 = %q, want 空串(历史行必须是「未分型」)", id, tier) } if content == "" { t.Fatalf("既有行 %s 的正文丢了", id) } } // 回滚:只撤结构,不改写任何一行的其它数据。 migrateDownToBefore(t, d, retentionMigrationID) if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex) } if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil { t.Fatalf("count rows after down: %v", err) } if rows != 2 { t.Fatalf("回滚后行数 = %d, want 2(down 只撤结构,不删行)", rows) } // 再 up:列以默认值重建,历史行回到「未分型」。 migrateUp(t, d) if !hasMemoryColumn(t, d, retentionTierColumn) { t.Fatalf("再次迁移后应重建 %s 列", retentionTierColumn) } // 空串用**单引号**字面量:`""` 在 SQLite 里先按标识符解析,靠 legacy 回退才对, // 不该在断言里留这种口径(与写入路径的 `''` 统一)。 var withTier int if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items WHERE retention_tier = ''`).Scan(&withTier); err != nil { t.Fatalf("count untyped: %v", err) } if withTier != 2 { t.Fatalf("重建后未分型行 = %d, want 2", withTier) } } // TestMigration0026ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的 // memory_items(列 + 索引)与自举 DDL 建出的必须逐项同形。 // // ⚠️ 比对的是**列集合**而不是列序,理由是既有的既成事实:`project_id` 是自举 // 前滚补上的(ensureProjectIDColumn),迁移路径上它同样会被追加到末尾,因此两条 // 路径的列序本来就不同。本片新增的 `retention_tier` 在两条路径上都追加在**当时**的 // 末尾,不制造新的漂移 —— 集合一致 + 索引一致就是这条护栏的判据。 func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) { t.Parallel() ctx := context.Background() // 生产顺序:先迁移、后自举。两边都走完这一整套再比。 migrated := openRawMemoryDB(t) migrateUp(t, migrated) if _, err := NewStore(ctx, migrated, testLogger()); err != nil { t.Fatalf("NewStore(migrated): %v", err) } bootstrapped := openRawMemoryDB(t) if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil { t.Fatalf("NewStore: %v", err) } gotCols := sortedColumns(tableColumns(t, migrated, "memory_items")) wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items")) if !slices.Equal(gotCols, wantCols) { t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v", gotCols, wantCols) } gotIdx := tableIndexes(t, migrated, "memory_items") wantIdx := tableIndexes(t, bootstrapped, "memory_items") if !slices.Equal(gotIdx, wantIdx) { t.Fatalf("迁移与自举的 memory_items 索引不一致:\n迁移=%v\n自举=%v", gotIdx, wantIdx) } // 新列在两份 DDL 里的形状必须逐字相同(TEXT NOT NULL DEFAULT + 空串字面量)。 for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} { if !slices.Contains(cols, retentionTierColumn+"|TEXT|1|''|0") { t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols) } } // 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal, sensitive`。 // 迁移路径上这几列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。 // (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后; // 0028 又把 sensitive 追加在 temporal 之后。) bootCols := tableColumns(t, bootstrapped, "memory_items") if len(bootCols) < 3 { t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols)) } tail := bootCols[len(bootCols)-3:] if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || !strings.Contains(tail[1], "|"+temporalColumn+"|") || !strings.Contains(tail[2], "|"+sensitiveColumn+"|") { t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s", tail, retentionTierColumn, temporalColumn, sensitiveColumn) } } // sortedColumns 把 `cid|name|type|notnull|default|pk` 渲染出的行去掉 cid 后排序, // 使比对与列序无关(理由见 TestMigration0026ShapeMatchesStoreBootstrap)。 func sortedColumns(rows []string) []string { out := make([]string, 0, len(rows)) for _, r := range rows { if i := strings.Index(r, "|"); i >= 0 { r = r[i+1:] } out = append(out, r) } slices.Sort(out) return out } // TestMigration0026ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序** // (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0026 先建好列,随后 Store 的 // 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store // 同样幂等,且写入路径可用。 func TestMigration0026ThenStoreBootstrapIsARegularPath(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) if !hasMemoryColumn(t, d, retentionTierColumn) { t.Fatalf("迁移后应有 %s 列", retentionTierColumn) } store, err := NewStore(ctx, d, testLogger()) if err != nil { t.Fatalf("NewStore(列已由迁移建好): %v", err) } after := tableColumns(t, d, "memory_items") // 列必须还在(自举的 duplicate column 路径不许把它当成失败)。 if !hasMemoryColumn(t, d, retentionTierColumn) { t.Fatalf("自举之后 %s 列消失了", retentionTierColumn) } if !hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("自举之后应存在索引 %s", retentionTierIndex) } // 幂等:再建一次 Store 不报错、不改形状。 if _, err := NewStore(ctx, d, testLogger()); err != nil { t.Fatalf("NewStore(第二次): %v", err) } if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) { t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after) } // 写入路径可用(档位两列在同一笔 INSERT 落下)。 got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{})) if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionTemp { t.Fatalf("迁移建表后经 Store 落库的档位 = %q, want %q", tier, RetentionTemp) } }