// Package projectindex implements the project-index hierarchy from // DevDocs/design/03-记忆.md §3 ("项目记忆"作用域): a project is indexed as an // entry plus description, and its content is split into chapters that can be // searched line by line. The three-layer depth is 项目索引 → 项目描述 → 章节内容. // // The two tables model that depth: the project row is the index entry (layer 1) // carrying its description (layer 2), and project_chapter holds the chapter // content (layer 3). Project memory is readable and writable by every persona // (design §3 "人人可读可写", "项目索引各角色可查"), so the store carries no // persona/role column and never filters by identity here. // // This package is deliberately separate from the vector index // (internal/runtime/memory/vector): it does text-based hierarchical indexing of // project entries, descriptions, and chapters. Semantic/vector search belongs to // the vector package. // // The store owns its schema: New creates the project and project_chapter tables // and the chapter index idempotently. All SQL is parameterized (?), column lists // are explicit (no SELECT *), reads take a context.Cancel, and every error is // wrapped with %w so callers can use errors.Is against the package sentinels. package projectindex import ( "context" "database/sql" "errors" "fmt" "io" "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // Sentinels distinguishing projectindex failures. Each is wrapped with %w at the // call site so callers can use errors.Is. var ( // ErrProjectNotFound is returned when a project id is blank or does not exist. ErrProjectNotFound = errors.New("projectindex: project not found") // ErrChapterNotFound is returned when a required chapter reference is invalid. ErrChapterNotFound = errors.New("projectindex: chapter not found") // ErrNilDB is returned when New receives a nil *sql.DB. ErrNilDB = errors.New("projectindex: nil db") // ErrMissingProject is returned when a required project id is blank. ErrMissingProject = errors.New("projectindex: project_id is required") // ErrMissingTitle is returned when a project or chapter title is blank. ErrMissingTitle = errors.New("projectindex: title is required") // ErrMissingContent is returned when a chapter has no content. ErrMissingContent = errors.New("projectindex: content is required") // ErrMissingKeyword is returned when Search receives a blank keyword. ErrMissingKeyword = errors.New("projectindex: keyword is required") ) // Store is a project-index store backed by a *sql.DB. // // It owns its schema: New creates the project / project_chapter tables and the // chapter index idempotently. Concurrency is delegated to *sql.DB, which is safe // for concurrent use by contract, so Store holds no further lock. Every query is // parameterized and reads take a context.Cancel. type Store struct { db *sql.DB log *slog.Logger } // rowScanner is satisfied by both *sql.Row and *sql.Rows so a single scanProject // or scanChapter helper serves both the Get and List paths. type rowScanner interface { Scan(dest ...any) error } // New builds a Store on db, ensuring the schema exists. // // db must be a live connection (the migration framework is owned by // internal/db). A nil db returns ErrNilDB. log may be nil, in which case a // discard logger is used; the active logger is tagged with source // "memory/projectindex" so every record is traceable. ctx controls schema // creation and must be honored by the caller. 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) } } return &Store{db: db, log: log}, nil } // discardLogger returns log if non-nil, otherwise a logger that writes nowhere. func discardLogger(log *slog.Logger) *slog.Logger { if log != nil { return log } return slog.New(slog.NewTextHandler(io.Discard, nil)) } // ListProjects returns every project index entry in insertion order (created_at // ASC, id ASC). An empty (non-nil) slice is returned when there are none, so // callers can range over it unconditionally. A read error is wrapped with %w so // callers can use errors.Is against the package sentinels. 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, id 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, applied idempotently by New. The two tables model the three-layer // hierarchy: the project row is the index entry (layer 1) plus its description // (layer 2), and project_chapter holds the chapter content (layer 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)`