package store import ( "context" "database/sql" "encoding/json" "fmt" "path/filepath" "strings" "time" "ai-workflow-skill/packages/repo-memory-runtime/internal/documents" _ "github.com/mattn/go-sqlite3" ) const schema = ` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS repos ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, root_path TEXT NOT NULL UNIQUE, vcs TEXT NOT NULL DEFAULT 'git', default_branch TEXT, last_seen_branch TEXT, last_seen_commit TEXT, last_sync_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS knowledge_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, kind TEXT NOT NULL, key TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL, detail_md TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, confidence REAL NOT NULL DEFAULT 0.7, scope_branch TEXT, scope_module TEXT, scope_path_prefix TEXT, source_path TEXT, source_line INTEGER, verified_at TEXT, verified_on_commit TEXT, last_checked_at TEXT, last_checked_commit TEXT, stale_reason TEXT, superseded_by INTEGER REFERENCES knowledge_entries(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(repo_id, kind, key, scope_branch, scope_module, scope_path_prefix) ); CREATE TABLE IF NOT EXISTS knowledge_dependencies ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, dep_type TEXT NOT NULL, locator TEXT NOT NULL, is_hard INTEGER NOT NULL DEFAULT 1, baseline_hash TEXT, note TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_entry_id ON knowledge_dependencies(entry_id); CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_locator ON knowledge_dependencies(dep_type, locator); CREATE TABLE IF NOT EXISTS knowledge_aliases ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, alias TEXT NOT NULL, normalized_alias TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(entry_id, normalized_alias) ); CREATE INDEX IF NOT EXISTS idx_knowledge_aliases_normalized ON knowledge_aliases(normalized_alias); CREATE TABLE IF NOT EXISTS knowledge_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, to_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, relation TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledge_links_from ON knowledge_links(from_entry_id); CREATE INDEX IF NOT EXISTS idx_knowledge_links_to ON knowledge_links(to_entry_id); CREATE TABLE IF NOT EXISTS knowledge_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, event_type TEXT NOT NULL, from_status TEXT, to_status TEXT, reason TEXT, evidence TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledge_events_entry_id_created_at ON knowledge_events(entry_id, created_at); ` type Store struct { db *sql.DB } type RepoState struct { RootPath string Name string VCS string DefaultBranch string LastSeenBranch string LastSeenCommit string } type DependencyInput struct { Type string Locator string IsHard bool BaselineHash string Note string } type EntryInput struct { RepoRoot string Kind string Key string Title string Summary string DetailMD string Status string Confidence float64 ScopeBranch string ScopeModule string ScopePathPrefix string SourcePath string SourceLine int VerifiedAt string VerifiedOnCommit string LastCheckedAt string LastCheckedCommit string StaleReason string Aliases []string Dependencies []DependencyInput } type SearchParams struct { Query string RepoFilter string Limit int } type SearchResult struct { ID int64 RepoPath string Kind string Key string Title string Status string Summary string Snippet string } type ListParams struct { RepoFilter string Kind string Status string Limit int } type EntryListItem struct { ID int64 RepoPath string Kind string Key string Title string Status string Summary string VerifiedOnCommit string UpdatedAt time.Time } type EntryRef struct { ID int64 RepoRoot string Kind string Key string } type EventRecord struct { ID int64 EventType string FromStatus string ToStatus string Reason string Evidence string CreatedAt time.Time } type VerifyCandidate struct { ID int64 RepoPath string Kind string Key string Title string Status string VerifiedOnCommit string Dependencies []DependencyInput } type RepoInfo struct { Path string Name string EntryCount int UpdatedAt time.Time } func Open(path string) (*Store, error) { db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } db.SetMaxOpenConns(1) return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) Init(ctx context.Context) error { _, err := s.db.ExecContext(ctx, schema) return err } func (s *Store) UpsertRepo(ctx context.Context, state RepoState) (int64, error) { now := nowUTC() if strings.TrimSpace(state.RootPath) == "" { return 0, fmt.Errorf("repo root path is required") } if strings.TrimSpace(state.Name) == "" { state.Name = filepath.Base(state.RootPath) } if strings.TrimSpace(state.VCS) == "" { state.VCS = "git" } _, err := s.db.ExecContext(ctx, ` INSERT INTO repos(name, root_path, vcs, default_branch, last_seen_branch, last_seen_commit, last_sync_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(root_path) DO UPDATE SET name = excluded.name, vcs = excluded.vcs, default_branch = excluded.default_branch, last_seen_branch = excluded.last_seen_branch, last_seen_commit = excluded.last_seen_commit, last_sync_at = excluded.last_sync_at, updated_at = excluded.updated_at `, state.Name, state.RootPath, state.VCS, nullable(state.DefaultBranch), nullable(state.LastSeenBranch), nullable(state.LastSeenCommit), now, now, now) if err != nil { return 0, err } var repoID int64 if err := s.db.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, state.RootPath).Scan(&repoID); err != nil { return 0, err } return repoID, nil } func (s *Store) UpsertEntry(ctx context.Context, in EntryInput) (int64, error) { if strings.TrimSpace(in.RepoRoot) == "" { return 0, fmt.Errorf("repo root is required") } if strings.TrimSpace(in.Kind) == "" { return 0, fmt.Errorf("kind is required") } if strings.TrimSpace(in.Key) == "" { return 0, fmt.Errorf("key is required") } if strings.TrimSpace(in.Summary) == "" { return 0, fmt.Errorf("summary is required") } if in.Confidence <= 0 { in.Confidence = 0.7 } if strings.TrimSpace(in.Status) == "" { in.Status = "draft" } if strings.TrimSpace(in.Title) == "" { in.Title = in.Key } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer tx.Rollback() var repoID int64 if err := tx.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, in.RepoRoot).Scan(&repoID); err != nil { if err == sql.ErrNoRows { return 0, fmt.Errorf("repo %s is not registered", in.RepoRoot) } return 0, err } now := nowUTC() var entryID int64 var oldStatus sql.NullString err = tx.QueryRowContext(ctx, ` SELECT id, status FROM knowledge_entries WHERE repo_id = ? AND kind = ? AND key = ? AND scope_branch IS ? AND scope_module IS ? AND scope_path_prefix IS ? `, repoID, in.Kind, in.Key, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix)).Scan(&entryID, &oldStatus) if err != nil && err != sql.ErrNoRows { return 0, err } eventType := "created" fromStatus := "" if err == sql.ErrNoRows { res, execErr := tx.ExecContext(ctx, ` INSERT INTO knowledge_entries( repo_id, kind, key, title, summary, detail_md, status, confidence, scope_branch, scope_module, scope_path_prefix, source_path, source_line, verified_at, verified_on_commit, last_checked_at, last_checked_commit, stale_reason, created_at, updated_at ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, repoID, in.Kind, in.Key, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, now) if execErr != nil { return 0, execErr } entryID, err = res.LastInsertId() if err != nil { return 0, err } } else { eventType = "updated" fromStatus = oldStatus.String if _, err := tx.ExecContext(ctx, ` UPDATE knowledge_entries SET title = ?, summary = ?, detail_md = ?, status = ?, confidence = ?, scope_branch = ?, scope_module = ?, scope_path_prefix = ?, source_path = ?, source_line = ?, verified_at = ?, verified_on_commit = ?, last_checked_at = ?, last_checked_commit = ?, stale_reason = ?, updated_at = ? WHERE id = ? `, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, entryID); err != nil { return 0, err } if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_aliases WHERE entry_id = ?`, entryID); err != nil { return 0, err } if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_dependencies WHERE entry_id = ?`, entryID); err != nil { return 0, err } } for _, alias := range dedupeStrings(in.Aliases) { if strings.TrimSpace(alias) == "" { continue } if _, err := tx.ExecContext(ctx, ` INSERT INTO knowledge_aliases(entry_id, alias, normalized_alias, created_at) VALUES(?, ?, ?, ?) `, entryID, alias, normalizeAlias(alias), now); err != nil { return 0, err } } for _, dep := range in.Dependencies { if strings.TrimSpace(dep.Type) == "" || strings.TrimSpace(dep.Locator) == "" { continue } isHard := 0 if dep.IsHard { isHard = 1 } if _, err := tx.ExecContext(ctx, ` INSERT INTO knowledge_dependencies(entry_id, dep_type, locator, is_hard, baseline_hash, note, created_at) VALUES(?, ?, ?, ?, ?, ?, ?) `, entryID, dep.Type, dep.Locator, isHard, nullable(dep.BaselineHash), nullable(dep.Note), now); err != nil { return 0, err } } if _, err := tx.ExecContext(ctx, ` INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at) VALUES(?, ?, ?, ?, ?, ?, ?) `, entryID, eventType, nullable(fromStatus), nullable(in.Status), nullable(in.StaleReason), nullable(eventEvidence(in)), now); err != nil { return 0, err } return entryID, tx.Commit() } func (s *Store) ImportDocument(ctx context.Context, doc documents.Document, repoState RepoState) error { repoState.RootPath = doc.RepoPath if _, err := s.UpsertRepo(ctx, repoState); err != nil { return err } for _, sec := range doc.Sections { key := buildImportedKey(doc.DocPath, sec.Heading, sec.Ordinal) entry := EntryInput{ RepoRoot: doc.RepoPath, Kind: classifyImportedKind(doc.Kind, sec.Heading), Key: key, Title: sec.Heading, Summary: sectionSummary(sec.Body), DetailMD: sec.Body, Status: "confirmed", Confidence: 0.8, SourcePath: doc.DocPath, VerifiedAt: nowUTC(), VerifiedOnCommit: repoState.LastSeenCommit, Dependencies: []DependencyInput{{ Type: "file", Locator: doc.DocPath, IsHard: true, Note: "Imported from markdown knowledge source", }}, } if _, err := s.UpsertEntry(ctx, entry); err != nil { return fmt.Errorf("import section %s: %w", sec.Heading, err) } } return nil } func (s *Store) Search(ctx context.Context, params SearchParams) ([]SearchResult, error) { limit := params.Limit if limit <= 0 { limit = 10 } terms := dedupeStrings(strings.Fields(strings.ToLower(params.Query))) if len(terms) == 0 { return nil, nil } query := ` SELECT DISTINCT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, e.detail_md FROM knowledge_entries e JOIN repos r ON r.id = e.repo_id LEFT JOIN knowledge_aliases a ON a.entry_id = e.id WHERE 1 = 1 ` args := make([]any, 0, len(terms)*5+2) if params.RepoFilter != "" { query += ` AND r.root_path LIKE ?` args = append(args, "%"+params.RepoFilter+"%") } for _, term := range terms { query += ` AND ( LOWER(e.key) LIKE ? OR LOWER(e.title) LIKE ? OR LOWER(e.summary) LIKE ? OR LOWER(e.detail_md) LIKE ? OR LOWER(COALESCE(a.normalized_alias, '')) LIKE ? )` like := "%" + term + "%" args = append(args, like, like, like, like, like) } query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?` args = append(args, limit) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var results []SearchResult for rows.Next() { var res SearchResult var detail string if err := rows.Scan(&res.ID, &res.RepoPath, &res.Kind, &res.Key, &res.Title, &res.Status, &res.Summary, &detail); err != nil { return nil, err } res.Snippet = makeSnippet(res.Summary+"\n"+detail, terms) results = append(results, res) } return results, rows.Err() } func (s *Store) ListRepos(ctx context.Context) ([]RepoInfo, error) { rows, err := s.db.QueryContext(ctx, ` SELECT r.root_path, r.name, COUNT(e.id) AS entry_count, r.updated_at FROM repos r LEFT JOIN knowledge_entries e ON e.repo_id = r.id GROUP BY r.id ORDER BY r.root_path `) if err != nil { return nil, err } defer rows.Close() var repos []RepoInfo for rows.Next() { var repo RepoInfo var updated string if err := rows.Scan(&repo.Path, &repo.Name, &repo.EntryCount, &updated); err != nil { return nil, err } repo.UpdatedAt, err = time.Parse(time.RFC3339, updated) if err != nil { return nil, fmt.Errorf("parse repo updated_at: %w", err) } repos = append(repos, repo) } return repos, rows.Err() } func (s *Store) ListEntries(ctx context.Context, params ListParams) ([]EntryListItem, error) { limit := params.Limit if limit <= 0 { limit = 20 } query := ` SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at FROM knowledge_entries e JOIN repos r ON r.id = e.repo_id WHERE 1 = 1 ` args := make([]any, 0, 4) if params.RepoFilter != "" { query += ` AND r.root_path LIKE ?` args = append(args, "%"+params.RepoFilter+"%") } if params.Kind != "" { query += ` AND e.kind = ?` args = append(args, params.Kind) } if params.Status != "" { query += ` AND e.status = ?` args = append(args, params.Status) } query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?` args = append(args, limit) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var items []EntryListItem for rows.Next() { var item EntryListItem var updated string if err := rows.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated); err != nil { return nil, err } item.UpdatedAt, err = time.Parse(time.RFC3339, updated) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Store) ResolveEntry(ctx context.Context, ref EntryRef) (EntryListItem, error) { if ref.ID > 0 { return s.getEntryByID(ctx, ref.ID) } if strings.TrimSpace(ref.RepoRoot) == "" || strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Key) == "" { return EntryListItem{}, fmt.Errorf("either id or repo+kind+key is required") } row := s.db.QueryRowContext(ctx, ` SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at FROM knowledge_entries e JOIN repos r ON r.id = e.repo_id WHERE r.root_path = ? AND e.kind = ? AND e.key = ? ORDER BY e.updated_at DESC LIMIT 1 `, ref.RepoRoot, ref.Kind, ref.Key) return scanEntryListItem(row) } func (s *Store) ListEvents(ctx context.Context, entryID int64, limit int) ([]EventRecord, error) { if limit <= 0 { limit = 20 } rows, err := s.db.QueryContext(ctx, ` SELECT id, event_type, COALESCE(from_status, ''), COALESCE(to_status, ''), COALESCE(reason, ''), COALESCE(evidence, ''), created_at FROM knowledge_events WHERE entry_id = ? ORDER BY created_at DESC LIMIT ? `, entryID, limit) if err != nil { return nil, err } defer rows.Close() var events []EventRecord for rows.Next() { var ev EventRecord var created string if err := rows.Scan(&ev.ID, &ev.EventType, &ev.FromStatus, &ev.ToStatus, &ev.Reason, &ev.Evidence, &created); err != nil { return nil, err } ev.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { return nil, err } events = append(events, ev) } return events, rows.Err() } func (s *Store) AddLink(ctx context.Context, fromEntryID, toEntryID int64, relation string) error { if fromEntryID <= 0 || toEntryID <= 0 { return fmt.Errorf("both entry ids are required") } if strings.TrimSpace(relation) == "" { return fmt.Errorf("relation is required") } _, err := s.db.ExecContext(ctx, ` INSERT INTO knowledge_links(from_entry_id, to_entry_id, relation, created_at) VALUES(?, ?, ?, ?) `, fromEntryID, toEntryID, relation, nowUTC()) return err } func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]VerifyCandidate, error) { rows, err := s.db.QueryContext(ctx, ` SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, COALESCE(e.verified_on_commit, '') FROM knowledge_entries e JOIN repos r ON r.id = e.repo_id WHERE r.root_path = ? AND e.status IN ('confirmed', 'needs_review') ORDER BY e.kind, e.key `, repoRoot) if err != nil { return nil, err } var candidates []VerifyCandidate for rows.Next() { var c VerifyCandidate if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil { rows.Close() return nil, err } candidates = append(candidates, c) } if err := rows.Err(); err != nil { rows.Close() return nil, err } if err := rows.Close(); err != nil { return nil, err } for i := range candidates { deps, err := s.listDependencies(ctx, candidates[i].ID) if err != nil { return nil, err } candidates[i].Dependencies = deps } return candidates, nil } func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error { item, err := s.getEntryByID(ctx, entryID) if err != nil { return err } now := nowUTC() eventType := "" switch { case item.Status != nextStatus && nextStatus == "needs_review": eventType = "downgraded" case item.Status != nextStatus && nextStatus == "stale": eventType = "marked_stale" case item.Status != nextStatus: eventType = "updated" } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, ` UPDATE knowledge_entries SET status = ?, stale_reason = ?, last_checked_at = ?, last_checked_commit = ?, updated_at = ? WHERE id = ? `, nextStatus, nullable(reason), now, nullable(currentCommit), now, entryID); err != nil { return err } if eventType != "" { if _, err := tx.ExecContext(ctx, ` INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at) VALUES(?, ?, ?, ?, ?, ?, ?) `, entryID, eventType, item.Status, nextStatus, nullable(reason), nil, now); err != nil { return err } } return tx.Commit() } func normalizeAlias(alias string) string { alias = strings.ToLower(strings.TrimSpace(alias)) alias = strings.ReplaceAll(alias, "_", " ") alias = strings.ReplaceAll(alias, "-", " ") return strings.Join(strings.Fields(alias), " ") } func buildImportedKey(docPath, heading string, ordinal int) string { base := strings.TrimSuffix(filepath.Base(docPath), filepath.Ext(docPath)) if normalized := slug(heading); normalized != "" { return base + ":" + normalized } return fmt.Sprintf("%s:%d", base, ordinal) } func classifyImportedKind(docKind, heading string) string { lowerDoc := strings.ToLower(docKind) lowerHeading := strings.ToLower(heading) switch lowerDoc { case "glossary": return "term" case "playbooks": return "entry" case "repo-memory", "repo-brief": switch { case strings.Contains(lowerHeading, "module"): return "module" case strings.Contains(lowerHeading, "chain"): return "chain" case strings.Contains(lowerHeading, "danger"): return "danger" case strings.Contains(lowerHeading, "anchor"), strings.Contains(lowerHeading, "entry"): return "entry" case strings.Contains(lowerHeading, "command"), strings.Contains(lowerHeading, "verify"): return "command" default: return "decision" } default: return "decision" } } func sectionSummary(body string) string { body = strings.TrimSpace(body) if body == "" { return "Imported knowledge section" } body = strings.ReplaceAll(body, "\n", " ") body = strings.Join(strings.Fields(body), " ") runes := []rune(body) if len(runes) > 160 { return string(runes[:160]) + " …" } return body } func makeSnippet(body string, terms []string) string { body = strings.TrimSpace(strings.ReplaceAll(body, "\n", " ")) if body == "" { return "" } lower := strings.ToLower(body) pos := -1 match := "" for _, term := range terms { idx := strings.Index(lower, term) if idx >= 0 && (pos == -1 || idx < pos) { pos = idx match = term } } if pos == -1 { if len(body) > 160 { return body[:160] + " …" } return body } start := pos - 50 if start < 0 { start = 0 } end := pos + len(match) + 80 if end > len(body) { end = len(body) } snippet := strings.TrimSpace(body[start:end]) if start > 0 { snippet = "… " + snippet } if end < len(body) { snippet += " …" } return snippet } func eventEvidence(in EntryInput) string { payload := map[string]any{ "source_path": in.SourcePath, "source_line": in.SourceLine, "dependencies": in.Dependencies, } encoded, err := json.Marshal(payload) if err != nil { return "" } return string(encoded) } func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) } func nullable(value string) any { if strings.TrimSpace(value) == "" { return nil } return value } func nullableInt(value int) any { if value <= 0 { return nil } return value } func slug(value string) string { value = normalizeAlias(value) return strings.ReplaceAll(value, " ", "-") } func dedupeStrings(values []string) []string { seen := make(map[string]struct{}, len(values)) var out []string for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } key := trimmed if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, trimmed) } return out } func (s *Store) getEntryByID(ctx context.Context, id int64) (EntryListItem, error) { row := s.db.QueryRowContext(ctx, ` SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at FROM knowledge_entries e JOIN repos r ON r.id = e.repo_id WHERE e.id = ? `, id) return scanEntryListItem(row) } func scanEntryListItem(row *sql.Row) (EntryListItem, error) { var item EntryListItem var updated string err := row.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated) if err != nil { return EntryListItem{}, err } item.UpdatedAt, err = time.Parse(time.RFC3339, updated) if err != nil { return EntryListItem{}, err } return item, nil } func (s *Store) listDependencies(ctx context.Context, entryID int64) ([]DependencyInput, error) { rows, err := s.db.QueryContext(ctx, ` SELECT dep_type, locator, is_hard, COALESCE(baseline_hash, ''), COALESCE(note, '') FROM knowledge_dependencies WHERE entry_id = ? ORDER BY id `, entryID) if err != nil { return nil, err } defer rows.Close() var deps []DependencyInput for rows.Next() { var dep DependencyInput var hard int if err := rows.Scan(&dep.Type, &dep.Locator, &hard, &dep.BaselineHash, &dep.Note); err != nil { return nil, err } dep.IsHard = hard == 1 deps = append(deps, dep) } return deps, rows.Err() }