Files
Cyrene_v2/internal/runtime/memory/projectindex/project.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

145 lines
4.3 KiB
Go

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
}