docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后, 逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致 - 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文 - 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
This commit is contained in:
1 parent
eb1828ee7a
commit
61cf80d5ee
31 files changed
+820
-820
No files matched your search
@@ -7,10 +7,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chapter is a project chapter (layer 3 of the hierarchy): the deep content a
|
||||
// project is split into. ProjectID links it to the owning project. The zero
|
||||
// value is not a valid chapter: AddChapter requires a non-blank title and
|
||||
// content, and generates the id.
|
||||
// Chapter 是一个项目章节(层级第 3 层):项目被切分成的
|
||||
// 深层内容。ProjectID 把它链到所属项目。零值
|
||||
// 不是合法章节:AddChapter 要求 title 与
|
||||
// content 非空,并生成 id。
|
||||
type Chapter struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
@@ -20,11 +20,11 @@ type Chapter struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AddChapter persists ch under projectID, assigning an id and missing
|
||||
// timestamps. It requires a non-blank projectID, title and content; a blank id
|
||||
// is generated. The project row itself does not have to exist, so chapters may
|
||||
// be added before or after their parent project, but DeleteProject cascades
|
||||
// them away together.
|
||||
// AddChapter 以 projectID 持久化 ch,分配 id 与缺失的
|
||||
// 时间戳。它要求 projectID、title 与 content 非空;id 为空时
|
||||
// 自动生成。项目行本身不必存在,因此章节可以在其父项目
|
||||
// 之前或之后添加,但 DeleteProject 会把它们一并级联
|
||||
// 删除。
|
||||
func (s *Store) AddChapter(ctx context.Context, projectID string, ch Chapter) error {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return ErrMissingProject
|
||||
@@ -61,11 +61,11 @@ func (s *Store) AddChapter(ctx context.Context, projectID string, ch Chapter) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListChapters returns every chapter for projectID in insertion order
|
||||
// (created_at ASC, rowid ASC) — rowid breaks created_at ties so chapters written
|
||||
// within the same clock tick keep their insertion order. It returns an empty
|
||||
// (non-nil) slice when there are none; a blank projectID returns
|
||||
// ErrMissingProject.
|
||||
// ListChapters 按插入顺序(created_at ASC、rowid ASC)返回 projectID 的每个
|
||||
// 章节 —— rowid 打破 created_at 并列,使同一时钟刻内写入的章节
|
||||
// 保持其插入顺序。没有条目时返回空的
|
||||
// (非 nil)切片;projectID 为空返回
|
||||
// ErrMissingProject。
|
||||
func (s *Store) ListChapters(ctx context.Context, projectID string) ([]Chapter, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProject
|
||||
@@ -92,7 +92,7 @@ func (s *Store) ListChapters(ctx context.Context, projectID string) ([]Chapter,
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// scanChapter reads one project_chapter row.
|
||||
// scanChapter 读取一行 project_chapter。
|
||||
func scanChapter(sc rowScanner) (Chapter, error) {
|
||||
var (
|
||||
ch Chapter
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"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.
|
||||
// Project 是一个项目索引条目(design §3):链接其
|
||||
// 各章节的项目标示,连同项目描述。它是层级的第 1 层(索引
|
||||
// 条目)与第 2 层(描述)。零值不是
|
||||
// 合法项目:ID 与 Title 必填。
|
||||
type Project struct {
|
||||
ID string
|
||||
Title string
|
||||
@@ -23,10 +23,10 @@ type Project struct {
|
||||
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.
|
||||
// PutProject 按 id upsert p。id 已存在时更新 title、desc 与
|
||||
// updated_at,并保留原始的 created_at;id 为新值时
|
||||
// 补齐缺失时间戳并插入。id 或 title 为空返回 ErrMissingProject
|
||||
// 或 ErrMissingTitle。
|
||||
func (s *Store) PutProject(ctx context.Context, p Project) error {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
return ErrMissingProject
|
||||
@@ -55,8 +55,8 @@ func (s *Store) PutProject(ctx context.Context, p Project) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProject returns the project with the given id. It returns ErrProjectNotFound
|
||||
// when the id is blank or does not exist.
|
||||
// GetProject 返回给定 id 的项目。id 为空或
|
||||
// 不存在时返回 ErrProjectNotFound。
|
||||
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
@@ -73,10 +73,10 @@ func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
|
||||
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.
|
||||
// DeleteProject 按 id 删除项目,并级联删除引用它的每个
|
||||
// 章节。id 为空、或没有删除任何
|
||||
// 项目行时返回 ErrProjectNotFound。删项目与删章节发生在
|
||||
// 同一事务内,因此失败绝不会留下孤儿项目或章节。
|
||||
func (s *Store) DeleteProject(ctx context.Context, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return ErrProjectNotFound
|
||||
@@ -113,7 +113,7 @@ func (s *Store) DeleteProject(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanProject reads one project row.
|
||||
// scanProject 读取一行 project。
|
||||
func scanProject(sc rowScanner) (*Project, error) {
|
||||
var (
|
||||
p Project
|
||||
@@ -132,9 +132,9 @@ func scanProject(sc rowScanner) (*Project, error) {
|
||||
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.
|
||||
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
|
||||
// UUID 依赖,使本包保持自包含。rand.Read 只在
|
||||
// 平台熵源损坏时失败,因此错误向上传播。
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
||||
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newTestStore opens an isolated in-memory SQLite DB and builds a Store over it.
|
||||
// Each call owns a private DB, so parallel tests are isolated by construction.
|
||||
// newTestStore 打开一个隔离的内存 SQLite 库并在其上构建 Store。
|
||||
// 每次调用都拥有私有 DB,因此并行测试天生相互隔离。
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -37,7 +37,7 @@ func newTestStore(t *testing.T) *Store {
|
||||
return s
|
||||
}
|
||||
|
||||
// baseProject is a minimal valid project for tests.
|
||||
// baseProject 是测试用的最小合法项目。
|
||||
func baseProject() Project {
|
||||
return Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestStore_PutGetProject_Upsert(t *testing.T) {
|
||||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T1", Desc: "D1", CreatedAt: oldTime}); err != nil {
|
||||
t.Fatalf("PutProject insert: %v", err)
|
||||
}
|
||||
// Upsert preserves the original created_at while updating title/desc.
|
||||
// upsert 保留原始的 created_at,同时更新 title/desc。
|
||||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T2", Desc: "D2", CreatedAt: oldTime.Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("PutProject update: %v", err)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestStore_AddListChapters_正常(t *testing.T) {
|
||||
t.Errorf("AddChapter did not assign timestamps")
|
||||
}
|
||||
|
||||
// A different project must not see these chapters.
|
||||
// 另一个项目必须看不到这些章节。
|
||||
other, err := s.ListChapters(ctx, "proj.other")
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters(proj.other): %v", err)
|
||||
@@ -397,7 +397,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
|
||||
t.Fatalf("AddChapter malicious: %v", err)
|
||||
}
|
||||
|
||||
// A keyword full of SQL metacharacters must not alter behavior or data.
|
||||
// 满是 SQL 元字符的关键词不得改变行为或数据。
|
||||
res, err := s.Search(ctx, maliciousID, "' OR 1=1 --")
|
||||
if err != nil {
|
||||
t.Fatalf("Search malicious keyword: %v", err)
|
||||
@@ -412,7 +412,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
|
||||
t.Fatalf("Search benign keyword on malicious id: %v", err)
|
||||
}
|
||||
|
||||
// The malicious id must round-trip intact (proves parameterized lookup).
|
||||
// 恶意的 id 必须原样往返(证明查询是参数化的)。
|
||||
got, err := s.GetProject(ctx, maliciousID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProject malicious: %v", err)
|
||||
@@ -420,7 +420,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
|
||||
if got.ID != maliciousID {
|
||||
t.Errorf("ID = %q, want %q", got.ID, maliciousID)
|
||||
}
|
||||
// And the malicious project must still be queryable, not dropped.
|
||||
// 而恶意的项目必须仍可查询,而不是被丢弃。
|
||||
if _, err := s.GetProject(ctx, "project"); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("project table intact check: err = %v, want ErrProjectNotFound", err)
|
||||
}
|
||||
@@ -490,8 +490,8 @@ func TestStore_Search_MatchInMultipleLines(t *testing.T) {
|
||||
t.Errorf("line numbers = [%d %d %d], want [1 2 4]",
|
||||
hit.Matches[0].LineNumber, hit.Matches[1].LineNumber, hit.Matches[2].LineNumber)
|
||||
}
|
||||
// The keyword was lowercase "ai" but the content holds uppercase "AI",
|
||||
// so the match is case-insensitive while Text preserves the original casing.
|
||||
// 关键词是小写 "ai" 而内容里是大写 "AI",
|
||||
// 因此匹配不区分大小写,而 Text 保留原始大小写。
|
||||
if !strings.Contains(hit.Matches[0].Text, "AI") {
|
||||
t.Errorf("expected case-insensitive match, Text = %q", hit.Matches[0].Text)
|
||||
}
|
||||
|
||||
@@ -5,42 +5,42 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// contextRadius is the number of lines shown on each side of a matched line in
|
||||
// LineMatch.Context.
|
||||
// contextRadius 是 LineMatch.Context 中匹配行每侧的
|
||||
// 显示行数。
|
||||
const contextRadius = 2
|
||||
|
||||
// LineMatch is a single matched line inside a chapter's content plus its
|
||||
// surrounding context. LineNumber is 1-based within the chapter's Content.
|
||||
// LineMatch 是章节内容中的一条匹配行及其
|
||||
// 周边上下文。LineNumber 在章节的 Content 中以 1 为基。
|
||||
type LineMatch struct {
|
||||
LineNumber int
|
||||
Text string
|
||||
Context string
|
||||
}
|
||||
|
||||
// ChapterHit is a chapter whose content matched a keyword, with the located
|
||||
// lines (and their context) that matched.
|
||||
// ChapterHit 是内容命中关键词的一个章节,附带定位到的
|
||||
// 匹配行(及其上下文)。
|
||||
type ChapterHit struct {
|
||||
Chapter *Chapter
|
||||
Matches []LineMatch
|
||||
}
|
||||
|
||||
// SearchResult is the hierarchical result of a Search. Project carries the
|
||||
// matched project index entry + description (layer 1/2), set only when the
|
||||
// keyword hits the project title or desc; Chapters carries the matched chapters
|
||||
// (layer 3) with located lines. When nothing matches, Project is nil and
|
||||
// Chapters is empty, with no error.
|
||||
// SearchResult 是一次 Search 的分层结果。Project 携带命中的
|
||||
// 项目索引条目 + 描述(第 1/2 层),仅当关键词命中
|
||||
// 项目标题或 desc 时设置;Chapters 携带命中的章节
|
||||
// (第 3 层)及定位到的行。无任何命中时,Project 为 nil,
|
||||
// Chapters 为空,且不报错。
|
||||
type SearchResult struct {
|
||||
Project *Project
|
||||
Chapters []ChapterHit
|
||||
}
|
||||
|
||||
// Search looks up keyword within a single project, walking the hierarchy
|
||||
// 项目索引 → 项目描述 → 章节内容. It first loads the project (ErrProjectNotFound
|
||||
// when it does not exist), sets SearchResult.Project when the keyword hits the
|
||||
// project title or description, then scans every chapter for content hits,
|
||||
// producing located lines plus their context. A blank keyword returns
|
||||
// ErrMissingKeyword; a keyword that hits nothing returns a non-nil SearchResult
|
||||
// with Project and Chapters unset.
|
||||
// Search 在单个项目内查找关键词,沿层级
|
||||
// 项目索引 → 项目描述 → 章节内容 遍历。它先加载项目(不存在时返回
|
||||
// ErrProjectNotFound),当关键词命中
|
||||
// 项目标题或描述时设置 SearchResult.Project,然后扫描每个章节找内容命中,
|
||||
// 产出定位到的行及其上下文。关键词为空返回
|
||||
// ErrMissingKeyword;未命中任何内容的关键词返回非 nil 的 SearchResult,
|
||||
// 其中 Project 与 Chapters 不设置。
|
||||
func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) {
|
||||
kw := strings.ToLower(strings.TrimSpace(keyword))
|
||||
if kw == "" {
|
||||
@@ -69,15 +69,15 @@ func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// containsFold reports whether s (case-insensitively) contains sub, which must
|
||||
// already be lowercased by the caller.
|
||||
// containsFold 报告 s 是否(不区分大小写地)包含 sub,
|
||||
// sub 必须已由调用方转为小写。
|
||||
func containsFold(s, sub string) bool {
|
||||
return strings.Contains(strings.ToLower(s), sub)
|
||||
}
|
||||
|
||||
// findLineMatches returns one LineMatch per content line that (case-insensitively)
|
||||
// contains keyword. Context is the surrounding contextRadius lines on each side,
|
||||
// joined by newlines and including the matched line.
|
||||
// findLineMatches 为每行(不区分大小写地)包含 keyword 的内容返回一个
|
||||
// LineMatch。Context 是每侧 contextRadius 行的周边上下文,
|
||||
// 以换行连接,并包含匹配行本身。
|
||||
func findLineMatches(content, keyword string) []LineMatch {
|
||||
lines := strings.Split(content, "\n")
|
||||
matches := make([]LineMatch, 0)
|
||||
@@ -93,8 +93,8 @@ func findLineMatches(content, keyword string) []LineMatch {
|
||||
return matches
|
||||
}
|
||||
|
||||
// buildContext returns lines[start..end] around idx (inclusive) joined by \n,
|
||||
// clamped to the slice bounds so the context never overflows.
|
||||
// buildContext 返回 idx 周围(含 idx)的 lines[start..end],以 \n 连接,
|
||||
// 并夹到切片边界内,因此上下文绝不会越界。
|
||||
func buildContext(lines []string, idx int) string {
|
||||
start := idx - contextRadius
|
||||
if start < 0 {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
// 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 项目索引 → 项目描述 → 章节内容.
|
||||
// projectindex 包实现 DevDocs/design/03-记忆.md §3(「项目记忆」作用域)中的
|
||||
// 项目索引层级:一个项目被索引为一条
|
||||
// 条目加描述,其内容被切成可逐行检索的章节。
|
||||
// 三层深度为 项目索引 → 项目描述 → 章节内容。
|
||||
//
|
||||
// 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.
|
||||
// 两张表刻画该深度:project 行是指引条目(第 1 层),
|
||||
// 携带其描述(第 2 层),project_chapter 保存章节
|
||||
// 内容(第 3 层)。项目记忆每个角色都可读写
|
||||
// (design §3「人人可读可写」「项目索引各角色可查」),因此存储没有
|
||||
// persona/role 列,也不在此按身份过滤。
|
||||
//
|
||||
// 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.
|
||||
// 本包刻意与向量索引
|
||||
// (internal/runtime/memory/vector)分离:它对项目条目、描述与章节
|
||||
// 做基于文本的分层索引。语义/向量检索属于
|
||||
// 向量包。
|
||||
//
|
||||
// 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.
|
||||
// 存储自己拥有 schema:New 幂等地创建 project 与 project_chapter 表
|
||||
// 及章节索引。所有 SQL 参数化(?),列清单
|
||||
// 显式列出(无 SELECT *),读取带 context.Cancel,且每个错误都
|
||||
// 用 %w 包裹,调用方可用 errors.Is 对包哨兵错误判断。
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
@@ -31,56 +31,56 @@ import (
|
||||
"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.
|
||||
// 区分 projectindex 各类失败的哨兵错误。每个都在
|
||||
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
|
||||
var (
|
||||
// ErrProjectNotFound is returned when a project id is blank or does not exist.
|
||||
// ErrProjectNotFound 在项目 id 为空或不存在时返回。
|
||||
ErrProjectNotFound = errors.New("projectindex: project not found")
|
||||
|
||||
// ErrChapterNotFound is returned when a required chapter reference is invalid.
|
||||
// ErrChapterNotFound 在必需的章节引用无效时返回。
|
||||
ErrChapterNotFound = errors.New("projectindex: chapter not found")
|
||||
|
||||
// ErrNilDB is returned when New receives a nil *sql.DB.
|
||||
// ErrNilDB 在 New 收到 nil *sql.DB 时返回。
|
||||
ErrNilDB = errors.New("projectindex: nil db")
|
||||
|
||||
// ErrMissingProject is returned when a required project id is blank.
|
||||
// ErrMissingProject 在必需的 project id 为空时返回。
|
||||
ErrMissingProject = errors.New("projectindex: project_id is required")
|
||||
|
||||
// ErrMissingTitle is returned when a project or chapter title is blank.
|
||||
// ErrMissingTitle 在项目或章节标题为空时返回。
|
||||
ErrMissingTitle = errors.New("projectindex: title is required")
|
||||
|
||||
// ErrMissingContent is returned when a chapter has no content.
|
||||
// ErrMissingContent 在章节没有内容时返回。
|
||||
ErrMissingContent = errors.New("projectindex: content is required")
|
||||
|
||||
// ErrMissingKeyword is returned when Search receives a blank keyword.
|
||||
// ErrMissingKeyword 在 Search 收到空关键词时返回。
|
||||
ErrMissingKeyword = errors.New("projectindex: keyword is required")
|
||||
)
|
||||
|
||||
// Store is a project-index store backed by a *sql.DB.
|
||||
// Store 是基于 *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.
|
||||
// 它自己拥有 schema:New 幂等地创建 project / project_chapter 表与
|
||||
// 章节索引。并发交由 *sql.DB,按约定它可安全
|
||||
// 并发使用,因此 Store 不再持锁。每个查询都
|
||||
// 参数化,读取带 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.
|
||||
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanProject
|
||||
// 或 scanChapter 辅助函数即可服务 Get 与 List 两条路径。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// New builds a Store on db, ensuring the schema exists.
|
||||
// New 在 db 上构建 Store,确保 schema 存在。
|
||||
//
|
||||
// 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).
|
||||
// db 必须是存活的连接(迁移框架由
|
||||
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
|
||||
// 使用丢弃型 logger;实际使用的 logger 带 source
|
||||
// "memory/projectindex" 标签,因此每条记录都可追溯。ctx 控制 schema
|
||||
// 创建,调用方必须遵守。旧的变宽时间戳
|
||||
// 就地规范化(见 normalizeTimestamps)。
|
||||
func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
@@ -101,7 +101,7 @@ func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
|
||||
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
|
||||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
if log != nil {
|
||||
return log
|
||||
@@ -109,12 +109,12 @@ func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
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.
|
||||
// ListProjects 按插入顺序(created_at ASC、rowid ASC)返回每个
|
||||
// 项目索引条目 —— rowid 打破 created_at 并列,使同一时钟刻内写入的项目
|
||||
// 保持其插入顺序。没有条目时返回空的
|
||||
// (非 nil)切片,调用方可以无条件 range。读
|
||||
// 错误用 %w 包裹,调用方可用 errors.Is 对包
|
||||
// 哨兵错误判断。
|
||||
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`)
|
||||
@@ -137,9 +137,9 @@ func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
|
||||
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).
|
||||
// schema DDL,由 New 幂等施加。两张表刻画三层
|
||||
// 层级:project 行是指引条目(第 1 层)加其描述
|
||||
// (第 2 层),project_chapter 保存章节内容(第 3 层)。
|
||||
const createProjectTable = `
|
||||
CREATE TABLE IF NOT EXISTS project (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -8,35 +8,35 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// timeLayout is the fixed-width timestamp layout used for every stored
|
||||
// created_at/updated_at value. Unlike time.RFC3339Nano it never drops trailing
|
||||
// fractional zeros, so every value is exactly storedTimeLen bytes wide; SQLite
|
||||
// compares these TEXT columns as plain strings and equal width is what makes
|
||||
// that comparison agree with chronological order. UTC values render the zone as
|
||||
// a single "Z".
|
||||
// timeLayout 是每个已存 created_at/updated_at 值使用的定宽
|
||||
// 时间戳布局。与 time.RFC3339Nano 不同,它从不丢弃末尾
|
||||
// 小数零,因此每个值恰好 storedTimeLen 字节宽;SQLite
|
||||
// 把这些 TEXT 列按普通字符串比较,而等宽正是让
|
||||
// 该比较与时间顺序一致的原因。UTC 值把时区渲染为
|
||||
// 单个 "Z"。
|
||||
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
// storedTimeLen is the byte length of a UTC value formatted with timeLayout:
|
||||
// "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone collapses to "Z" in UTC.
|
||||
// storedTimeLen 是用 timeLayout 格式化的 UTC 值的字节长度:
|
||||
// "2006-01-02T15:04:05.000000000Z" —— Z07:00 时区在 UTC 下坍缩为 "Z"。
|
||||
const storedTimeLen = 30
|
||||
|
||||
// Table names normalized on open; they must match the DDL in store.go.
|
||||
// 打开时需要规范化的表名;它们必须与 store.go 中的 DDL 一致。
|
||||
const (
|
||||
projectTable = "project"
|
||||
projectChapterTable = "project_chapter"
|
||||
)
|
||||
|
||||
// formatTime encodes t in UTC with the fixed-width timeLayout. Always writing
|
||||
// UTC keeps ordering and round-trips stable across processes, and the fixed
|
||||
// width is what lets ORDER BY created_at order these TEXT columns by time.
|
||||
// formatTime 用定宽 timeLayout 把 t 编码为 UTC。始终写
|
||||
// UTC 使排序与往返在各进程间保持稳定,而定宽
|
||||
// 正是 ORDER BY created_at 能按时间排序这些 TEXT 列的原因。
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(timeLayout)
|
||||
}
|
||||
|
||||
// parseTime decodes a stored timestamp. time.RFC3339Nano accepts both the
|
||||
// fixed-width values this package writes and the legacy variable-width ones, so
|
||||
// the same parser serves reads and the open-time normalization. Blank input
|
||||
// yields a zero time.
|
||||
// parseTime 解码一个已存时间戳。time.RFC3339Nano 同时接受
|
||||
// 本包写入的定宽值与旧的变宽值,因此同一个解析器既服务
|
||||
// 读取、也服务打开时的规范化。空输入
|
||||
// 得到零值时间。
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Time{}, nil
|
||||
@@ -48,15 +48,15 @@ func parseTime(s string) (time.Time, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// normalizeTimestamps rewrites every project / project_chapter timestamp that is
|
||||
// not storedTimeLen bytes wide into the fixed-width timeLayout. Rows written
|
||||
// before the layout existed carry variable-width RFC3339Nano text whose string
|
||||
// order is not its time order; this brings them in line.
|
||||
// normalizeTimestamps 把每个非 storedTimeLen 字节宽的 project /
|
||||
// project_chapter 时间戳重写为定宽 timeLayout。布局存在之前写入的行
|
||||
// 携带变宽 RFC3339Nano 文本,其字符串
|
||||
// 顺序并非时间顺序;这把它们拉齐。
|
||||
//
|
||||
// It is idempotent: only rows whose column length differs from storedTimeLen are
|
||||
// selected, so running it again on a normalized table is a no-op. A row whose
|
||||
// value does not parse is left exactly as it is — one bad legacy value must not
|
||||
// keep the store from opening.
|
||||
// 它是幂等的:只选取列长度与 storedTimeLen 不同的行,
|
||||
// 因此在已规范化的表上再跑一次是空操作。值解析不了的行
|
||||
// 原样保留 —— 一个坏的遗留值不该
|
||||
// 妨碍存储打开。
|
||||
func normalizeTimestamps(ctx context.Context, db *sql.DB) error {
|
||||
for _, table := range []string{projectTable, projectChapterTable} {
|
||||
for _, col := range []string{"created_at", "updated_at"} {
|
||||
@@ -68,11 +68,11 @@ func normalizeTimestamps(ctx context.Context, db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTimeColumn rewrites every table.col value that is not storedTimeLen
|
||||
// bytes wide. table and col are compile-time constants, never user input, so
|
||||
// splicing them into the statement is safe; the length comparison and every
|
||||
// value written are parameterized. All rewrites are collected before any UPDATE
|
||||
// runs, so no write happens while the SELECT cursor is still open.
|
||||
// normalizeTimeColumn 重写 table.col 中每个非 storedTimeLen
|
||||
// 字节宽的值。table 与 col 是编译期常量,绝非用户输入,因此
|
||||
// 把它们拼进语句是安全的;长度比较与每个
|
||||
// 写入值都参数化。所有重写都在任何 UPDATE
|
||||
// 执行前收集完毕,因此 SELECT 游标仍打开时不会发生写入。
|
||||
func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error {
|
||||
fixes, err := collectLegacyTimestamps(ctx, db, table, col)
|
||||
if err != nil {
|
||||
@@ -87,8 +87,8 @@ func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// timeFix is one pending timestamp rewrite: which table and column, which row
|
||||
// (by primary key id), and the fixed-width value to write.
|
||||
// timeFix 是一条待执行的时间戳重写:哪张表哪一列、哪一行
|
||||
// (按主键 id),以及要写入的定宽值。
|
||||
type timeFix struct {
|
||||
table string
|
||||
col string
|
||||
@@ -96,10 +96,10 @@ type timeFix struct {
|
||||
value string
|
||||
}
|
||||
|
||||
// collectLegacyTimestamps reads the rows of table.col whose value is not
|
||||
// storedTimeLen bytes wide and returns the parseable ones as pending rewrites.
|
||||
// Unparseable values are skipped, so normalization can never fail a row: the
|
||||
// only errors are genuine read failures.
|
||||
// collectLegacyTimestamps 读取 table.col 中值非 storedTimeLen
|
||||
// 字节宽的行,把可解析的作为待重写项返回。
|
||||
// 不可解析的值跳过,因此规范化绝不会让某行失败:
|
||||
// 唯一的错误是真正的读取失败。
|
||||
func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen)
|
||||
@@ -126,9 +126,9 @@ func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string)
|
||||
return fixes, nil
|
||||
}
|
||||
|
||||
// reformatTime re-encodes raw with timeLayout. The bool is false when raw is
|
||||
// already storedTimeLen wide or when it does not parse, in which case the caller
|
||||
// leaves the stored value untouched.
|
||||
// reformatTime 用 timeLayout 重新编码 raw。raw 已经
|
||||
// storedTimeLen 宽、或无法解析时 bool 为 false,此时调用方
|
||||
// 保持存储值不动。
|
||||
func reformatTime(raw string) (string, bool) {
|
||||
if len(raw) == storedTimeLen {
|
||||
return "", false
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// insertLegacyChapterSQL writes a project_chapter row with caller-supplied raw
|
||||
// timestamps, standing in for a row persisted before the fixed-width layout.
|
||||
// insertLegacyChapterSQL 用调用方给定的原始时间戳写入一行
|
||||
// project_chapter,代表定宽布局之前持久化的行。
|
||||
const insertLegacyChapterSQL = `INSERT INTO project_chapter
|
||||
(id, project_id, title, content, created_at, updated_at)
|
||||
VALUES (?, 'proj.1', '旧章节', '旧内容', ?, ?)`
|
||||
@@ -37,9 +37,9 @@ func TestFormatTime_定宽_正常(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_ListChapters_变宽时间戳_排序 is the projectindex regression for the
|
||||
// variable-width timestamp bug: RFC3339Nano would store these as ":05.83" and
|
||||
// ":05.8309789", whose text order is the reverse of their time order.
|
||||
// TestStore_ListChapters_变宽时间戳_排序 是 projectindex 针对变宽
|
||||
// 时间戳缺陷的回归:RFC3339Nano 会把它们存成 ":05.83" 与
|
||||
// ":05.8309789",其文本顺序与时间顺序相反。
|
||||
func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -49,8 +49,8 @@ func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
|
||||
older := sec.Add(830 * time.Millisecond)
|
||||
newer := older.Add(978900 * time.Nanosecond)
|
||||
|
||||
// The newer chapter is inserted first, so a rowid-only tiebreak would put it
|
||||
// last: only correctly ordered created_at text places it last-but-one.
|
||||
// 较新的章节先插入,因此只按 rowid 排序会把它放到
|
||||
// 最后:只有正确排序的 created_at 文本才会把它放在倒数第二。
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{
|
||||
Title: "较晚", Content: "x", CreatedAt: newer, UpdatedAt: newer,
|
||||
}); err != nil {
|
||||
@@ -83,9 +83,9 @@ func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_ListChapters_同一时间戳_插入序 covers the tie case: consecutive
|
||||
// AddChapter calls can share one clock tick, so rowid ASC must keep them in
|
||||
// insertion order.
|
||||
// TestStore_ListChapters_同一时间戳_插入序 覆盖并列情形:连续的
|
||||
// AddChapter 调用可能共享同一时钟刻,因此 rowid ASC 必须让它们保持
|
||||
// 插入顺序。
|
||||
func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -115,9 +115,9 @@ func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_归一化历史时间戳_正常 writes legacy variable-width rows into both
|
||||
// tables, re-opens the store to trigger normalization, and checks the repaired
|
||||
// text plus idempotence and the unparseable-row skip.
|
||||
// TestNew_归一化历史时间戳_正常 把旧的变宽数据写入两张
|
||||
// 表,重新打开存储触发规范化,并检查修复后的文本
|
||||
// 以及幂等性与无法解析行的跳过。
|
||||
func TestNew_归一化历史时间戳_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -135,7 +135,7 @@ func TestNew_归一化历史时间戳_正常(t *testing.T) {
|
||||
t.Fatalf("insert unparseable chapter: %v", err)
|
||||
}
|
||||
|
||||
// Re-opening normalizes both tables; an unparseable value must not block it.
|
||||
// 重新打开会规范化两张表;无法解析的值不得阻塞它。
|
||||
if _, err := New(ctx, s.db, testLogger()); err != nil {
|
||||
t.Fatalf("New (normalize): %v", err)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func TestNew_归一化历史时间戳_正常(t *testing.T) {
|
||||
t.Errorf("unparseable created_at = %q, want it left as is", broken)
|
||||
}
|
||||
|
||||
// A further open must be a no-op.
|
||||
// 再次打开必须是空操作。
|
||||
if _, err := New(ctx, s.db, testLogger()); err != nil {
|
||||
t.Fatalf("New (second pass): %v", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user