Files
Cyrene_v2/internal/runtime/memory/projectindex/store.go
T
AskaEth 31086ed1ab fix(memory): 定宽时间戳存储 + rowid 确定性排序 + 遗留数据归一化
- formatTime 改用固定 30 字节布局(9 位小数秒),让 TEXT 列的字典序与时间序一致(RFC3339Nano 会去掉尾随零产生变宽字符串,秒内会反序)
- memory_items / project / project_chapter 的 ORDER BY 全部补 rowid 兜底,消除同 tick 打平时的顺序不确定
- 开库时幂等归一化变宽的历史值;无法解析的行原样保留,不阻塞开库
- 回归测试:变宽排序反例、同一时间戳插入序、归一化幂等与跳过
2026-10-03 16:08:50 +08:00

165 lines
6.2 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. Legacy variable-width timestamps
// are normalized in place (see 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 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, rowid ASC) — rowid breaks created_at ties so projects written within the
// same clock tick keep their insertion order. 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, 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, 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)`