133 lines
5.0 KiB
Go
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)`
|