Files
Cyrene_v2/internal/runtime/memory/projectindex/store.go
T

133 lines
5.0 KiB
Go

// 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))
}
// 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)`