package memory import ( "context" "database/sql" "slices" "strings" "testing" "time" ) // 本文件钉住**迁移 0027(`memory_temporal`)与 Store 自举 DDL 的同形与可回滚**, // 手法照 retention_migration_test.go 对 0026 的那一套(同一批 helpers)。 // // 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份 // 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,temporal_schema.go)。 // 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有: // // 1. TestMigration0027AddsColumnOverExistingRows —— 迁移落在**已有数据**上:行不丢、 // 列补上、默认值是空串(未分型);down 真的把列删掉且**不改写任何一行的其它 // 数据**(尤其是 `retention_tier` / `expires_at`,见 down 文件的口径);再 up 能重建。 // 2. TestMigration0027ShapeMatchesStoreBootstrap —— 新列在两份 DDL 里**逐字同形** // (TEXT NOT NULL DEFAULT 空串),且自举侧尾序是 `… retention_tier, temporal`。 // temporalColumn 是被测列名(迁移与自举 DDL 里的字面量)。 const temporalColumn = "temporal" // temporalMigrationID 是 0027 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。 const temporalMigrationID = "0027_memory_temporal" // TestMigration0027AddsColumnOverExistingRows 钉住迁移本体落在**既有数据**上的两条: // 行不丢、默认值是空串(未分型);以及再 up 后历史行回到「未分型」。 func TestMigration0027AddsColumnOverExistingRows(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) if !hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn) } // 造出「0027 之前的既有部署」:回到 0027 之前,按旧形态写入两行。 migrateDownToBefore(t, d, temporalMigrationID) if hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("0027 回滚后 %s 应已消失", temporalColumn) } insertLegacyMemoryRow(t, d, "legacy-1", "一条 0027 之前就存在的记忆") insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0027 之前就存在的记忆") // 升级:0027 落在**已有数据**上。 migrateUp(t, d) if !hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn) } 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 nat, content string if err := d.QueryRowContext(ctx, `SELECT temporal, content FROM memory_items WHERE id = ?`, id). Scan(&nat, &content); err != nil { t.Fatalf("read %s: %v", id, err) } if nat != "" { t.Fatalf("既有行 %s 的时间性位 = %q, want 空串(历史行必须是「未分型」)", id, nat) } if content == "" { t.Fatalf("既有行 %s 的正文丢了", id) } } } // TestMigration0027DownKeepsRowData 钉住 down 的**唯一**语义:只撤结构。判成 temp 的 // 行在回滚之后档位与时效必须**一个字节都没变**(回滚改数据 = 把已判定的临时记忆升回 // 永久,正是 down 文件里登记的那条反面)。 func TestMigration0027DownKeepsRowData(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) if _, err := d.ExecContext(ctx, `INSERT INTO memory_items (id, persona_id, scope, kind, content, created_at, updated_at, retention_tier, expires_at, temporal) VALUES ('temp-1', 'persona.a', 'persona', 'relationship', '一条临时记忆', `+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'temp', ?, 'one_off')`, formatTime(time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC))); err != nil { t.Fatalf("seed temp row: %v", err) } migrateDownToBefore(t, d, temporalMigrationID) if hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("回滚后 %s 应已消失", temporalColumn) } var tier, expires string if err := d.QueryRowContext(ctx, `SELECT retention_tier, expires_at FROM memory_items WHERE id = 'temp-1'`). Scan(&tier, &expires); err != nil { t.Fatalf("read retention columns after down: %v", err) } if tier != string(RetentionTemp) || expires == "" { t.Fatalf("down 改写了档位/时效 = (%q, %q), want (%q, 非空) —— 回滚只撤结构", tier, expires, RetentionTemp) } // 再 up:列以默认值重建,既有行回到「未分型」。 migrateUp(t, d) if !hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("再次迁移后应重建 %s 列", temporalColumn) } var untyped int if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items WHERE temporal = ''`).Scan(&untyped); err != nil { t.Fatalf("count untyped: %v", err) } if untyped != 1 { t.Fatalf("重建后未分型行 = %d, want 1", untyped) } } // TestMigration0027ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的 // memory_items(列集合)与自举 DDL 建出的必须一致,且新列在两份 DDL 里的形状 // 逐字相同(TEXT NOT NULL DEFAULT 空串)。 // // 列集合的等价性由 0026 的 TestMigration0026ShapeMatchesStoreBootstrap 一并覆盖 // (它比对整表列集合);这里只钉本列自己的形状与自举侧的尾序。 func TestMigration0027ShapeMatchesStoreBootstrap(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) } for name, d := range map[string]*sql.DB{"迁移": migrated, "自举": bootstrapped} { cols := sortedColumns(tableColumns(t, d, "memory_items")) if !slices.Contains(cols, temporalColumn+"|TEXT|1|''|0") { t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, temporalColumn, cols) } } bootCols := tableColumns(t, bootstrapped, "memory_items") if len(bootCols) < 3 { t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols)) } // 0028(`sensitive`)之后尾序是 `… retention_tier, temporal, sensitive`: // 新列一律追加在末尾,因此这条断言的「末尾」也随迁移推进(见 store_schema.go 头注)。 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) } } // TestMigration0027ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序** // (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0027 先建好列,随后 Store 的 // 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store // 同样幂等,且写入路径可用(时间性位随同一笔 INSERT 落下)。 func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) store, err := NewStore(ctx, d, testLogger()) if err != nil { t.Fatalf("NewStore(列已由迁移建好): %v", err) } after := tableColumns(t, d, "memory_items") if !hasMemoryColumn(t, d, temporalColumn) { t.Fatalf("自举之后 %s 列消失了", temporalColumn) } 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) } item := habitItem("主人有一条真实落库的观测样本", tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add: %v", err) } if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { t.Fatalf("迁移建列后经 Store 落库的时间性位 = %q, want %q", nat, TemporalOneOff) } }