package memory import ( "context" "database/sql" "fmt" "slices" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 本文件钉住**迁移 0025 与 Store 自举 DDL 的同形与幂等**(切片 W5)。 // // 背景:`memory_ingest_reviews` 原来只由 `memory.NewStore` 的幂等前滚创建,2026-10-06 // 补登记为迁移 `0025_memory_ingest_reviews`。于是 DDL 有了**两份真源** // (迁移 + preinsert_schema.go 的自举),而真实启动顺序是「先 MigrateUp、后 NewStore」; // 既有部署更特殊:**表早就存在**(自举建的),只是迁移账本里没有 0025 —— 那一跳必须 // 不炸、不丢数据、不改形状。三条判据都在这里: // // 1. TestMigration0025CreatesAndRollsBackIngestReviewTable —— 新库上迁移真的建出 // 表与两条索引,且可回滚(down 删表 ⇒ up 重建空表); // 2. TestMigration0025IsIdempotentOverStoreBootstrapTable —— **既有部署**:表已存在 // 且已有数据时重跑 0025,不报错、不丢行、形状不变; // 3. TestMigration0025ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形** // (少一列就是「新库有、老库无」的漂移,`CREATE TABLE IF NOT EXISTS` 救不了)。 // ingestReviewTable 是被测表名(与迁移、自举 DDL 里的字面量一致)。 const ingestReviewTable = "memory_ingest_reviews" // ingestReviewMigrationID 是 0025 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。 const ingestReviewMigrationID = "0025_memory_ingest_reviews" // openRawMemoryDB 开一个私有的内存库:不迁移、不建 Store,由调用方自己决定走 // 迁移还是自举(两条路径的对照是本文件的目的)。 func openRawMemoryDB(t *testing.T) *sql.DB { t.Helper() d, err := db.Open(context.Background(), ":memory:", testLogger()) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = d.Close() }) return d } // migrateUp 跑全量迁移(含本片新增的 0025)。 func migrateUp(t *testing.T, d *sql.DB) { t.Helper() if err := db.MigrateUp(context.Background(), d, testLogger()); err != nil { t.Fatalf("MigrateUp: %v", err) } } // migrateDownToBefore 把库回滚到 id **之前**(即 id 与它之后已应用的每条都撤掉)。 // // 步数从 `schema_migrations` 账本现算,而不是写死一个常数:写死的口径每加一条 // 迁移就要改一次(0026 加进来时本文件的 0025 用例的 `MigrateDown(2)`、0027 加进来时 // 0026 用例的 `MigrateDown(1)` 都是这么被打红的)—— 而「回滚到某条之前」这个 // 语义本身与后面还压着几条无关。 func migrateDownToBefore(t *testing.T, d *sql.DB, id string) { t.Helper() ctx := context.Background() rows, err := d.QueryContext(ctx, `SELECT id FROM schema_migrations ORDER BY id`) if err != nil { t.Fatalf("list applied migrations: %v", err) } defer func() { _ = rows.Close() }() steps := 0 for rows.Next() { var got string if err := rows.Scan(&got); err != nil { t.Fatalf("scan applied migration: %v", err) } if got >= id { steps++ } } if err := rows.Err(); err != nil { t.Fatalf("iterate applied migrations: %v", err) } if steps == 0 { t.Fatalf("迁移 %s 不在账本里,无法回滚到它之前", id) } if err := db.MigrateDown(ctx, d, steps, testLogger()); err != nil { t.Fatalf("MigrateDown(%d) 回到 %s 之前: %v", steps, id, err) } } // tableExists 报告表是否存在。 func tableExists(t *testing.T, d *sql.DB, name string) bool { t.Helper() var n int if err := d.QueryRowContext(context.Background(), `SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?`, name).Scan(&n); err != nil { t.Fatalf("check table %s: %v", name, err) } return n == 1 } // tableColumns 把表结构渲染成可比较的字符串切片(name|type|notnull|default|pk), // 顺序即建表顺序。 func tableColumns(t *testing.T, d *sql.DB, table string) []string { t.Helper() rows, err := d.QueryContext(context.Background(), "PRAGMA table_info("+table+")") if err != nil { t.Fatalf("table_info(%s): %v", table, err) } defer func() { _ = rows.Close() }() out := make([]string, 0) for rows.Next() { var ( cid int name, typ string notNull int dflt sql.NullString pk int ) if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { t.Fatalf("scan table_info(%s): %v", table, err) } out = append(out, fmt.Sprintf("%d|%s|%s|%d|%s|%d", cid, name, typ, notNull, dflt.String, pk)) } if err := rows.Err(); err != nil { t.Fatalf("iterate table_info(%s): %v", table, err) } return out } // tableIndexes 把表自己建的索引渲染成 `name|unique`(按名字排序,忽略建索引顺序)。 // `sqlite_autoindex_*` 是 TEXT PRIMARY KEY 的隐式索引,不由我们的 DDL 创建, // 故排除 —— 主键形状已由 tableColumns 的 pk 列覆盖。 func tableIndexes(t *testing.T, d *sql.DB, table string) []string { t.Helper() rows, err := d.QueryContext(context.Background(), "PRAGMA index_list("+table+")") if err != nil { t.Fatalf("index_list(%s): %v", table, err) } defer func() { _ = rows.Close() }() out := make([]string, 0) for rows.Next() { var ( seq int name string unique int origin string partial int ) if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil { t.Fatalf("scan index_list(%s): %v", table, err) } if strings.HasPrefix(name, "sqlite_autoindex_") { continue } out = append(out, fmt.Sprintf("%s|%d", name, unique)) } if err := rows.Err(); err != nil { t.Fatalf("iterate index_list(%s): %v", table, err) } slices.Sort(out) return out } // countRawReviewRows 直读留痕行数(不经 Store)。 func countRawReviewRows(t *testing.T, d *sql.DB) int { t.Helper() var n int if err := d.QueryRowContext(context.Background(), "SELECT count(*) FROM "+ingestReviewTable).Scan(&n); err != nil { t.Fatalf("count rows in %s: %v", ingestReviewTable, err) } return n } // TestMigration0025CreatesAndRollsBackIngestReviewTable 钉住迁移本身:up 建表 + 两条 // 索引、down 删表、再 up 能重建(回滚不留半状态)。 func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) { t.Parallel() d := openRawMemoryDB(t) migrateUp(t, d) if !tableExists(t, d, ingestReviewTable) { t.Fatalf("迁移后应存在 %s", ingestReviewTable) } wantIndexes := []string{ "idx_memory_ingest_reviews_created_at|0", "idx_memory_ingest_reviews_item_id|0", } if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, wantIndexes) { t.Fatalf("索引 = %v, want %v", got, wantIndexes) } cols := tableColumns(t, d, ingestReviewTable) for _, want := range []string{"id", "item_id", "created_at"} { if !slices.ContainsFunc(cols, func(c string) bool { return strings.Contains(c, "|"+want+"|") }) { t.Fatalf("列 %s 缺席:%v", want, cols) } } // 回滚:0025 之上还压着 0026(`memory_retention_tier`,切片 M37)与 0027 // (`temporal`,切片 M45),因此要回到 0025 **之前**才轮得到它 —— 步数由 // migrateDownToBefore 从账本现算,不写死常数(见该 helper 的注释)。 migrateDownToBefore(t, d, ingestReviewMigrationID) if tableExists(t, d, ingestReviewTable) { t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable) } // 再 up:表重建且为空(down/up 前后不留残留)。 migrateUp(t, d) if !tableExists(t, d, ingestReviewTable) { t.Fatalf("再次迁移后应存在 %s", ingestReviewTable) } if got := countRawReviewRows(t, d); got != 0 { t.Fatalf("重建后的行数 = %d, want 0", got) } if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, cols) { t.Fatalf("重建后的表结构 = %v, want %v", got, cols) } } // TestMigration0025IsIdempotentOverStoreBootstrapTable 模拟**既有部署**的真实状态: // 表已由自举建好、里面已有留痕,而迁移账本里没有 0025(本次升级的全部增量就是 // 重跑这一条)。判据:不报错、数据不丢、形状不变。 func TestMigration0025IsIdempotentOverStoreBootstrapTable(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) // 造出「既有部署」:把 0025 的成果抹掉(表 + 账本记录),让自举重新建表。 if _, err := d.ExecContext(ctx, "DROP TABLE "+ingestReviewTable); err != nil { t.Fatalf("drop table: %v", err) } if _, err := d.ExecContext(ctx, "DELETE FROM schema_migrations WHERE id = ?", ingestReviewMigrationID); err != nil { t.Fatalf("unrecord migration: %v", err) } store, err := NewStore(ctx, d, testLogger()) if err != nil { t.Fatalf("NewStore(自举建表): %v", err) } seedReviewRow(t, store, "kept", tidyTestTime(t)) beforeCols := tableColumns(t, d, ingestReviewTable) beforeIndexes := tableIndexes(t, d, ingestReviewTable) // 升级路径:只有 0025 待应用(表已存在)。 migrateUp(t, d) if got := countRawReviewRows(t, d); got != 1 { t.Fatalf("迁移后行数 = %d, want 1(既有留痕一行都不能丢)", got) } if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, beforeCols) { t.Fatalf("迁移改变了表结构:\n got=%v\nwant=%v", got, beforeCols) } if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, beforeIndexes) { t.Fatalf("迁移改变了索引:\n got=%v\nwant=%v", got, beforeIndexes) } // 账本幂等:已记账后再跑一次是纯空转。 migrateUp(t, d) if got := countRawReviewRows(t, d); got != 1 { t.Fatalf("重复迁移后行数 = %d, want 1", got) } } // TestMigration0025ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的表与自举 // 建出的表必须逐列、逐索引同形。它红的时候,正确的修法是让两份 DDL 对齐, // 而不是放宽这条断言。 func TestMigration0025ShapeMatchesStoreBootstrap(t *testing.T) { t.Parallel() ctx := context.Background() migrated := openRawMemoryDB(t) migrateUp(t, migrated) bootstrapped := openRawMemoryDB(t) if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil { t.Fatalf("NewStore: %v", err) } gotCols := tableColumns(t, migrated, ingestReviewTable) wantCols := tableColumns(t, bootstrapped, ingestReviewTable) if !slices.Equal(gotCols, wantCols) { t.Fatalf("迁移与自举的表结构不一致(两份 DDL 必须逐字同形):\n迁移 =%v\n自举 =%v", gotCols, wantCols) } gotIdx := tableIndexes(t, migrated, ingestReviewTable) wantIdx := tableIndexes(t, bootstrapped, ingestReviewTable) if !slices.Equal(gotIdx, wantIdx) { t.Fatalf("迁移与自举的索引不一致:\n迁移 =%v\n自举 =%v", gotIdx, wantIdx) } }