Files
Cyrene_v2/internal/db/migrate_flow_graphs_test.go
T
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00

136 lines
4.9 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.
package db
import (
"context"
"testing"
)
// flowGraphsMigrationID 是链路图落盘表的迁移 id(design/19 §4 P3;切片 F2)。
const flowGraphsMigrationID = "0022_flow_graphs"
// TestMigrateFlowGraphsReversible 钉住 0022(flow_graphs,切片 F2):up 建表与索引、
// 重复 up 幂等、down 整表消失、再 up 恢复端到端可用。它用自己的内存库,绝不碰
// data/cyrene.db。
//
// 断言的是**库里的结构事实**(表/列/索引在不在、默认值读出来是什么),不按日志文本
// 断言 —— 与 migrate_channel_events_test.go 同一口径。
func TestMigrateFlowGraphsReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should exist after MigrateUp")
}
// 列名错一个,写入面就会静默写错地方。
for _, col := range []string{"id", "session_id", "turn_id", "graph_json", "created_at", "updated_at"} {
if !columnExists(t, ctx, conn, "flow_graphs", col) {
t.Fatalf("flow_graphs.%s should exist after MigrateUp", col)
}
}
// 查询/GC 与 upsert 依赖的两个索引。
for _, idx := range []string{"idx_flow_graphs_session_id_turn_id", "idx_flow_graphs_created_at"} {
if !schemaObjectExists(t, ctx, conn, "index", idx) {
t.Fatalf("%s should exist after MigrateUp", idx)
}
}
// 默认值:只写必填列也能落行,时间戳由 SQLite 默认填。
if _, err := conn.ExecContext(ctx,
`INSERT INTO flow_graphs (session_id, turn_id, graph_json) VALUES (?, ?, ?)`,
"web", "web-1", `{"id":"web-1"}`); err != nil {
t.Fatalf("insert minimal flow graph: %v", err)
}
var createdAt string
if err := conn.QueryRowContext(ctx,
`SELECT created_at FROM flow_graphs WHERE session_id = ?`, "web").Scan(&createdAt); err != nil {
t.Fatalf("read minimal flow graph: %v", err)
}
if createdAt == "" {
t.Fatal("created_at should be filled by the migration default")
}
// 重复 up:已记录的迁移被跳过,结构仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should still exist after idempotent re-run")
}
// 恰好回滚到 0022(含它之后新加的迁移),日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == flowGraphsMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", flowGraphsMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
if schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should be dropped by the 0022 down")
}
// 重新 up 恢复端到端可用。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should exist again after re-MigrateUp")
}
}
// TestMigrate0022UpsertSameTurn_正常 钉住唯一键的**行为**(不只是「索引存在」):
// 同一 `(session_id, turn_id)` 二次写入必须更新**一行**,而不是插出两行「同一轮」
// —— 否则多轮列表会出现重复,且「第 N 轮」到底存的是哪份图就说不清了。
func TestMigrate0022UpsertSameTurn_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
const upsert = `INSERT INTO flow_graphs (session_id, turn_id, graph_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, turn_id) DO UPDATE SET
graph_json = excluded.graph_json,
updated_at = excluded.updated_at`
write := func(payload string) {
t.Helper()
if _, err := conn.ExecContext(ctx, upsert, "web", "web-1", payload,
"2026-10-05T00:00:00.000Z", "2026-10-05T00:00:00.000Z"); err != nil {
t.Fatalf("upsert flow graph: %v", err)
}
}
write(`{"id":"web-1","nodes":[]}`)
write(`{"id":"web-1","nodes":[{"id":"input"}]}`)
var rows int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&rows); err != nil {
t.Fatalf("count flow graphs: %v", err)
}
if rows != 1 {
t.Fatalf("flow_graphs rows = %d, want 1 (unique key must merge, not duplicate)", rows)
}
var payload string
if err := conn.QueryRowContext(ctx,
`SELECT graph_json FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&payload); err != nil {
t.Fatalf("read graph_json: %v", err)
}
if payload != `{"id":"web-1","nodes":[{"id":"input"}]}` {
t.Fatalf("graph_json = %q, want the second (latest) payload", payload)
}
}