// projectindex 包实现 DevDocs/design/03-记忆.md §3(「项目记忆」作用域)中的 // 项目索引层级:一个项目被索引为一条 // 条目加描述,其内容被切成可逐行检索的章节。 // 三层深度为 项目索引 → 项目描述 → 章节内容。 // // 两张表刻画该深度:project 行是指引条目(第 1 层), // 携带其描述(第 2 层),project_chapter 保存章节 // 内容(第 3 层)。项目记忆每个角色都可读写 // (design §3「人人可读可写」「项目索引各角色可查」),因此存储没有 // persona/role 列,也不在此按身份过滤。 // // 本包刻意与向量索引 // (internal/runtime/memory/vector)分离:它对项目条目、描述与章节 // 做基于文本的分层索引。语义/向量检索属于 // 向量包。 // // 存储自己拥有 schema:New 幂等地创建 project 与 project_chapter 表 // 及章节索引。所有 SQL 参数化(?),列清单 // 显式列出(无 SELECT *),读取带 context.Cancel,且每个错误都 // 用 %w 包裹,调用方可用 errors.Is 对包哨兵错误判断。 package projectindex import ( "context" "database/sql" "errors" "fmt" "io" "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // 区分 projectindex 各类失败的哨兵错误。每个都在 // 调用点用 %w 包裹,调用方可用 errors.Is 判断。 var ( // ErrProjectNotFound 在项目 id 为空或不存在时返回。 ErrProjectNotFound = errors.New("projectindex: project not found") // ErrChapterNotFound 在必需的章节引用无效时返回。 ErrChapterNotFound = errors.New("projectindex: chapter not found") // ErrNilDB 在 New 收到 nil *sql.DB 时返回。 ErrNilDB = errors.New("projectindex: nil db") // ErrMissingProject 在必需的 project id 为空时返回。 ErrMissingProject = errors.New("projectindex: project_id is required") // ErrMissingTitle 在项目或章节标题为空时返回。 ErrMissingTitle = errors.New("projectindex: title is required") // ErrMissingContent 在章节没有内容时返回。 ErrMissingContent = errors.New("projectindex: content is required") // ErrMissingKeyword 在 Search 收到空关键词时返回。 ErrMissingKeyword = errors.New("projectindex: keyword is required") ) // Store 是基于 *sql.DB 的项目索引存储。 // // 它自己拥有 schema:New 幂等地创建 project / project_chapter 表与 // 章节索引。并发交由 *sql.DB,按约定它可安全 // 并发使用,因此 Store 不再持锁。每个查询都 // 参数化,读取带 context.Cancel。 type Store struct { db *sql.DB log *slog.Logger } // rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanProject // 或 scanChapter 辅助函数即可服务 Get 与 List 两条路径。 type rowScanner interface { Scan(dest ...any) error } // New 在 db 上构建 Store,确保 schema 存在。 // // db 必须是存活的连接(迁移框架由 // internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时 // 使用丢弃型 logger;实际使用的 logger 带 source // "memory/projectindex" 标签,因此每条记录都可追溯。ctx 控制 schema // 创建,调用方必须遵守。旧的变宽时间戳 // 就地规范化(见 normalizeTimestamps)。 func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { return nil, ErrNilDB } log = logger.WithSource(discardLogger(log), "memory/projectindex") for _, ddl := range []string{ createProjectTable, createProjectChapterTable, createProjectChapterIndex, } { if _, err := db.ExecContext(ctx, ddl); err != nil { return nil, fmt.Errorf("projectindex: apply schema: %w", err) } } if err := normalizeTimestamps(ctx, db); err != nil { return nil, err } return &Store{db: db, log: log}, nil } // discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。 func discardLogger(log *slog.Logger) *slog.Logger { if log != nil { return log } return slog.New(slog.NewTextHandler(io.Discard, nil)) } // ListProjects 按插入顺序(created_at ASC、rowid ASC)返回每个 // 项目索引条目 —— rowid 打破 created_at 并列,使同一时钟刻内写入的项目 // 保持其插入顺序。没有条目时返回空的 // (非 nil)切片,调用方可以无条件 range。读 // 错误用 %w 包裹,调用方可用 errors.Is 对包 // 哨兵错误判断。 func (s *Store) ListProjects(ctx context.Context) ([]Project, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, title, desc, created_at, updated_at FROM project ORDER BY created_at ASC, rowid ASC`) if err != nil { return nil, fmt.Errorf("projectindex: list projects: %w", err) } defer func() { _ = rows.Close() }() projects := make([]Project, 0) for rows.Next() { p, err := scanProject(rows) if err != nil { return nil, fmt.Errorf("projectindex: scan project: %w", err) } projects = append(projects, *p) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("projectindex: iterate projects: %w", err) } return projects, nil } // schema DDL,由 New 幂等施加。两张表刻画三层 // 层级:project 行是指引条目(第 1 层)加其描述 // (第 2 层),project_chapter 保存章节内容(第 3 层)。 const createProjectTable = ` CREATE TABLE IF NOT EXISTS project ( id TEXT PRIMARY KEY, title TEXT NOT NULL, desc TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )` const createProjectChapterTable = ` CREATE TABLE IF NOT EXISTS project_chapter ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )` const createProjectChapterIndex = ` CREATE INDEX IF NOT EXISTS idx_project_chapter_project_id ON project_chapter (project_id)`