feat(memory/projectindex): 项目索引分层(索引+描述+章节,Search 定位行+上下文,级联删除)
This commit is contained in:
1 parent
d90ec7bd4f
commit
ef79edd96a
5 files changed
+1010
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project is a project index entry (design §3): the project marker that links
|
||||
// its chapters, together with the project's description. It is layer 1 (index
|
||||
// entry) and layer 2 (description) of the hierarchy. The zero value is not a
|
||||
// valid project: ID and Title are required.
|
||||
type Project struct {
|
||||
ID string
|
||||
Title string
|
||||
Desc string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PutProject upserts p by id. On an existing id it updates title, desc, and
|
||||
// updated_at while preserving the original created_at; on a new id it assigns
|
||||
// missing timestamps and inserts. A blank id or title returns ErrMissingProject
|
||||
// or ErrMissingTitle.
|
||||
func (s *Store) PutProject(ctx context.Context, p Project) error {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
return ErrMissingProject
|
||||
}
|
||||
if strings.TrimSpace(p.Title) == "" {
|
||||
return ErrMissingTitle
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
desc = excluded.desc,
|
||||
updated_at = excluded.updated_at`,
|
||||
p.ID, p.Title, p.Desc, formatTime(p.CreatedAt), formatTime(p.UpdatedAt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: upsert project: %w", err)
|
||||
}
|
||||
s.log.Debug("project upserted", "id", p.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProject returns the project with the given id. It returns ErrProjectNotFound
|
||||
// when the id is blank or does not exist.
|
||||
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, title, desc, created_at, updated_at
|
||||
FROM project WHERE id = ?`, id)
|
||||
p, err := scanProject(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("projectindex: get project: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteProject removes the project by id and, as a cascade, every chapter that
|
||||
// references it. It returns ErrProjectNotFound when the id is blank or no
|
||||
// project row was removed. The project removal and the chapter removal happen in
|
||||
// one transaction so a failure never leaves an orphaned project or chapter.
|
||||
func (s *Store) DeleteProject(ctx context.Context, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: begin delete tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM project_chapter WHERE project_id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete chapters: %w", err)
|
||||
}
|
||||
if _, err := res.RowsAffected(); err != nil {
|
||||
return fmt.Errorf("projectindex: delete chapters row count: %w", err)
|
||||
}
|
||||
|
||||
res, err = tx.ExecContext(ctx, `DELETE FROM project WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete project: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete row count: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("projectindex: commit delete tx: %w", err)
|
||||
}
|
||||
s.log.Debug("project deleted", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanProject reads one project row.
|
||||
func scanProject(sc rowScanner) (*Project, error) {
|
||||
var (
|
||||
p Project
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := sc.Scan(&p.ID, &p.Title, &p.Desc, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
if p.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a
|
||||
// UUID dependency so the package stays self-contained. rand.Read fails only if
|
||||
// the platform entropy source is broken, so the error is propagated.
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("projectindex: generate id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// formatTime encodes t in UTC as an RFC3339Nano string so ordering and
|
||||
// round-trips are stable across processes.
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// parseTime decodes a stored RFC3339 string; blank input yields a zero time.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("projectindex: parse time %q: %w", s, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
Reference in new issue
Block a user