package memory import ( "context" "database/sql" "fmt" "strings" ) // 本文件是记忆整理(design/23 §10)的 schema 侧:新表(候选 / 运行)的 DDL、 // memory_items 整理列的前滚,以及两者的一处装配入口。 // // 口径与 memory_items 一致(见 store.go 末尾的说明):领域表的权威机制是 // internal/db 的迁移(0019_memory_tidy),但记忆存储必须自包含、可在任意 // *sql.DB 上使用,因此 NewStore 也幂等地创建这些表、前滚这些列。两处定义 // 必须保持一致(tidy_schema_test.go 锁定)。 // createMemoryTidyCandidatesTable 是人工确认队列(design/23 §10)。 // idempotency_key 唯一:同一批数据重复跑不重复产候选(design/23 §9)。 // // run_id 是**本实现在设计稿列集之上补的一列**(切片 TIDY-1):设计稿 §10 的 // 候选表没有「哪一轮产的」这一列,于是「按 run 批量应用」只能靠时间窗猜 —— // 那是把幂等键的语义换成时间戳比较。run_id 让批量应用有确定的抓手,且 // **不进幂等键**(否则同一批数据每轮都会重复产候选),因此老语义逐字不变。 const createMemoryTidyCandidatesTable = ` CREATE TABLE IF NOT EXISTS memory_tidy_candidates ( id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, source_ids TEXT NOT NULL DEFAULT '[]', target_id TEXT NOT NULL DEFAULT '', proposed_content TEXT NOT NULL DEFAULT '', merged_content TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '{}', rule_version TEXT NOT NULL DEFAULT '', score_min REAL NOT NULL DEFAULT 0, score_max REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', idempotency_key TEXT NOT NULL UNIQUE, run_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, decided_at TEXT NOT NULL DEFAULT '', decided_by TEXT NOT NULL DEFAULT '' )` const createMemoryTidyCandidatesStatusIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status ON memory_tidy_candidates (status, scope)` const createMemoryTidyCandidatesRunIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_run ON memory_tidy_candidates (run_id, status)` // createMemoryTidyRunsTable 是整理运行的留痕(design/23 §4.1 F:留痕不靠 // 会话)。vetoed / veto_reasons 是本实现在设计稿列集之上补的两列:design/23 // §9 要求「每轮记否决原因分布」,只落日志就无法查询,故一并入库。 const createMemoryTidyRunsTable = ` CREATE TABLE IF NOT EXISTS memory_tidy_runs ( id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT NOT NULL DEFAULT '', scanned INTEGER NOT NULL DEFAULT 0, candidates INTEGER NOT NULL DEFAULT 0, applied INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, errors INTEGER NOT NULL DEFAULT 0, vetoed INTEGER NOT NULL DEFAULT 0, veto_reasons TEXT NOT NULL DEFAULT '{}', rule_version TEXT NOT NULL DEFAULT '' )` const createMemoryTidyRunsStartedIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_tidy_runs_started_at ON memory_tidy_runs (started_at)` // tidyMemoryColumns 是 memory_items 上记忆整理所需的列与前滚 DDL。 // 全部带默认值 ⇒ 既有行行为逐字不变(design/23 §10)。 var tidyMemoryColumns = []struct { name string ddl string }{ {"status", `ALTER TABLE memory_items ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`}, {"merged_into", `ALTER TABLE memory_items ADD COLUMN merged_into TEXT NOT NULL DEFAULT ''`}, {"merged_from", `ALTER TABLE memory_items ADD COLUMN merged_from TEXT NOT NULL DEFAULT '[]'`}, {"merge_count", `ALTER TABLE memory_items ADD COLUMN merge_count INTEGER NOT NULL DEFAULT 1`}, {"occurred_at", `ALTER TABLE memory_items ADD COLUMN occurred_at TEXT NOT NULL DEFAULT ''`}, {"valid_to", `ALTER TABLE memory_items ADD COLUMN valid_to TEXT NOT NULL DEFAULT ''`}, {"expires_at", `ALTER TABLE memory_items ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''`}, } const createMemoryItemsStatusIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_items_status ON memory_items (persona_id, status)` // ensureTidySchema 幂等地建整理所需的两张新表、前滚列、再建索引。它由 NewStore // 调用,因此整理能力在任何 *sql.DB 上都可用(与 memory_items 自身同一口径)。 // // 顺序**必须**是「建表 → 前滚列 → 建索引」:既有库上的候选表没有 run_id, // 若先建 run_id 上的索引,SQLite 会报 "no such column: run_id"(旧实现里 // 索引只依赖建表时就有的列,因此没暴露这个顺序问题)。 func ensureTidySchema(ctx context.Context, db *sql.DB) error { for _, ddl := range []string{ createMemoryTidyCandidatesTable, createMemoryTidyRunsTable, } { if _, err := db.ExecContext(ctx, ddl); err != nil { return fmt.Errorf("memory: tidy schema: %w", err) } } if err := ensureTidyMemoryColumns(ctx, db); err != nil { return err } if err := ensureTidyCandidateRunColumn(ctx, db); err != nil { return err } for _, ddl := range []string{ createMemoryTidyCandidatesStatusIndex, createMemoryTidyCandidatesRunIndex, createMemoryTidyRunsStartedIndex, createMemoryItemsStatusIndex, } { if _, err := db.ExecContext(ctx, ddl); err != nil { return fmt.Errorf("memory: tidy schema: %w", err) } } return nil } // ensureTidyCandidateRunColumn 把 run_id 前滚到**既有**的候选表上(切片 TIDY-1): // CREATE TABLE IF NOT EXISTS 只在建表时生效,既有库的表已经建好,因此新列必须 // 单独补一次。手法与 ensureTidyMemoryColumns 一致:已存在时 SQLite 报 // "duplicate column name: run_id",那是预期路径。 // // ⚠️ 与迁移的关系:internal/db 的 0019_memory_tidy 是权威迁移,本文件是 // 「记忆存储自包含、可在任意 *sql.DB 上使用」的前滚(两处口径必须一致)。 // 本列目前**只在内存包侧前滚**,未同步进 0019(改迁移文件超出本切片边界), // 组合根每次启动都会跑到这里,因此生产库在下次重启时即可拿到该列。 func ensureTidyCandidateRunColumn(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, `ALTER TABLE memory_tidy_candidates ADD COLUMN run_id TEXT NOT NULL DEFAULT ''`); err != nil { if strings.Contains(err.Error(), "duplicate column name") { return nil } return fmt.Errorf("memory: roll forward tidy candidate run_id: %w", err) } return nil } // ensureTidyMemoryColumns 把 memory_items 的整理列前滚到既有表上:列缺失时 // 补加,已存在时忽略重复列错误。它是 SQLite 特有的(记忆存储基于 SQLite) // 且幂等,与 ensureProjectIDColumn 同一手法。 func ensureTidyMemoryColumns(ctx context.Context, db *sql.DB) error { for _, col := range tidyMemoryColumns { if _, err := db.ExecContext(ctx, col.ddl); err != nil { // 该列已存在(由 CREATE TABLE 或上一次运行创建)时报 // "duplicate column name: