This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/database/sqlite.go
Torjus Håkestad ea11dd5e14 fix: add nolint:errcheck comments for intentionally unchecked errors
Add //nolint:errcheck comments to intentionally unchecked error returns:

- defer X.Close() calls: errors from closing read-only resources, rows
  after iteration, files, response bodies, and gzip readers are not
  actionable and don't affect correctness

- defer tx.Rollback(): standard Go pattern where rollback after
  successful commit returns an error, which is expected behavior

- defer stmt.Close(): statements are closed with their transactions

- Cleanup operations: DeleteRevision on failure and os.RemoveAll for
  temp directories are best-effort cleanup

- HTTP response encoding: if JSON encoding fails at response time,
  there's nothing useful we can do

- Test/benchmark code: unchecked errors in test setup/cleanup where
  failures will surface through test assertions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:51:20 +01:00

659 lines
21 KiB
Go

package database
import (
"context"
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// SQLiteStore implements Store using SQLite.
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore creates a new SQLite store.
func NewSQLiteStore(path string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close() //nolint:errcheck // best-effort cleanup on connection failure
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
return &SQLiteStore{db: db}, nil
}
// Initialize creates or migrates the database schema.
func (s *SQLiteStore) Initialize(ctx context.Context) error {
// Check current schema version
var version int
err := s.db.QueryRowContext(ctx,
"SELECT version FROM schema_info LIMIT 1").Scan(&version)
needsRecreate := err != nil || version != SchemaVersion
if needsRecreate {
// Drop all tables in correct order (respecting foreign keys)
dropStmts := []string{
DropDeclarations,
DropOptions,
DropFiles,
DropRevisions,
DropSchemaInfo,
"DROP TABLE IF EXISTS options_fts",
}
for _, stmt := range dropStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to drop table: %w", err)
}
}
}
// Create tables (SQLite uses INTEGER PRIMARY KEY for auto-increment)
createStmts := []string{
SchemaInfoTable,
RevisionsTable,
OptionsTable,
DeclarationsTable,
FilesTable,
IndexOptionsRevisionName,
IndexOptionsRevisionParent,
IndexFilesRevisionPath,
IndexDeclarationsOption,
}
for _, stmt := range createStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
}
// Create FTS5 virtual table for SQLite full-text search
_, err = s.db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS options_fts USING fts5(
name,
description,
content='options',
content_rowid='id'
)
`)
if err != nil {
return fmt.Errorf("failed to create FTS table: %w", err)
}
// Create triggers to keep FTS in sync
triggers := []string{
`CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`,
`CREATE TRIGGER IF NOT EXISTS options_ad AFTER DELETE ON options BEGIN
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
END`,
`CREATE TRIGGER IF NOT EXISTS options_au AFTER UPDATE ON options BEGIN
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`,
}
for _, trigger := range triggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create trigger: %w", err)
}
}
// Set schema version
if needsRecreate {
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_info (version) VALUES (?)", SchemaVersion)
if err != nil {
return fmt.Errorf("failed to set schema version: %w", err)
}
}
return nil
}
// Close closes the database connection.
func (s *SQLiteStore) Close() error {
return s.db.Close()
}
// CreateRevision creates a new revision record.
func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
VALUES (?, ?, ?, ?)`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
)
if err != nil {
return fmt.Errorf("failed to create revision: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
rev.ID = id
// Fetch the indexed_at timestamp
err = s.db.QueryRowContext(ctx,
"SELECT indexed_at FROM revisions WHERE id = ?", id).Scan(&rev.IndexedAt)
if err != nil {
return fmt.Errorf("failed to get indexed_at: %w", err)
}
return nil
}
// GetRevision retrieves a revision by git hash.
func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
rev := &Revision{}
err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
FROM revisions WHERE git_hash = ?`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision: %w", err)
}
return rev, nil
}
// GetRevisionByChannel retrieves a revision by channel name.
func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
rev := &Revision{}
err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
FROM revisions WHERE channel_name = ?
ORDER BY indexed_at DESC LIMIT 1`, channel,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision by channel: %w", err)
}
return rev, nil
}
// ListRevisions returns all indexed revisions.
func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
FROM revisions ORDER BY indexed_at DESC`)
if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration //nolint:errcheck // rows.Err() checked after iteration
var revisions []*Revision
for rows.Next() {
rev := &Revision{}
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil {
return nil, fmt.Errorf("failed to scan revision: %w", err)
}
revisions = append(revisions, rev)
}
return revisions, rows.Err()
}
// DeleteRevision removes a revision and all associated data.
func (s *SQLiteStore) DeleteRevision(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete revision: %w", err)
}
return nil
}
// UpdateRevisionOptionCount updates the option count for a revision.
func (s *SQLiteStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET option_count = ? WHERE id = ?", count, id)
if err != nil {
return fmt.Errorf("failed to update option count: %w", err)
}
return nil
}
// CreateOption creates a new option record.
func (s *SQLiteStore) CreateOption(ctx context.Context, opt *Option) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
)
if err != nil {
return fmt.Errorf("failed to create option: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
opt.ID = id
return nil
}
// CreateOptionsBatch creates multiple options in a batch.
func (s *SQLiteStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, opt := range opts {
result, err := stmt.ExecContext(ctx,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
)
if err != nil {
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
opt.ID = id
}
return tx.Commit()
}
// GetOption retrieves an option by revision and name.
func (s *SQLiteStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) {
opt := &Option{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = ? AND name = ?`, revisionID, name,
).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get option: %w", err)
}
return opt, nil
}
// GetChildren retrieves direct children of an option.
func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = ? AND parent_path = ?
ORDER BY name`, revisionID, parentPath)
if err != nil {
return nil, fmt.Errorf("failed to get children: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// SearchOptions searches for options matching a query.
func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
var baseQuery string
var args []interface{}
// If the query looks like an option path (contains dots), prioritize name-based matching.
// This ensures "services.nginx" finds "services.nginx.*" options, not random options
// that happen to mention "nginx" in their description.
if strings.Contains(query, ".") {
// Use LIKE-based search for path queries, with ranking:
// 1. Exact match
// 2. Direct children (query.*)
// 3. All descendants (query.*.*)
baseQuery = `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options
WHERE revision_id = ?
AND (name = ? OR name LIKE ?)`
args = []interface{}{revisionID, query, query + ".%"}
} else {
// For non-path queries, use FTS5 for full-text search on name and description
baseQuery = `
SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only
FROM options o
INNER JOIN options_fts fts ON o.id = fts.rowid
WHERE o.revision_id = ?
AND options_fts MATCH ?`
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
args = []interface{}{revisionID, escapedQuery}
}
// Use table alias for filters (works for both query types)
tbl := ""
if !strings.Contains(query, ".") {
tbl = "o."
}
if filters.Type != "" {
baseQuery += " AND " + tbl + "type = ?"
args = append(args, filters.Type)
}
if filters.Namespace != "" {
baseQuery += " AND " + tbl + "name LIKE ?"
args = append(args, filters.Namespace+"%")
}
if filters.HasDefault != nil {
if *filters.HasDefault {
baseQuery += " AND " + tbl + "default_value IS NOT NULL"
} else {
baseQuery += " AND " + tbl + "default_value IS NULL"
}
}
baseQuery += " ORDER BY " + tbl + "name"
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
}
if filters.Offset > 0 {
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
}
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to search options: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// CreateDeclaration creates a new declaration record.
func (s *SQLiteStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES (?, ?, ?)`,
decl.OptionID, decl.FilePath, decl.Line,
)
if err != nil {
return fmt.Errorf("failed to create declaration: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
decl.ID = id
return nil
}
// CreateDeclarationsBatch creates multiple declarations in a batch.
func (s *SQLiteStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES (?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, decl := range decls {
result, err := stmt.ExecContext(ctx, decl.OptionID, decl.FilePath, decl.Line)
if err != nil {
return fmt.Errorf("failed to insert declaration: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
decl.ID = id
}
return tx.Commit()
}
// GetDeclarations retrieves declarations for an option.
func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, option_id, file_path, line
FROM declarations WHERE option_id = ?`, optionID)
if err != nil {
return nil, fmt.Errorf("failed to get declarations: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var decls []*Declaration
for rows.Next() {
decl := &Declaration{}
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil {
return nil, fmt.Errorf("failed to scan declaration: %w", err)
}
decls = append(decls, decl)
}
return decls, rows.Err()
}
// CreateFile creates a new file record.
func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error {
// Compute metadata if not already set
if file.ByteSize == 0 {
file.ByteSize = len(file.Content)
}
if file.LineCount == 0 {
file.LineCount = countLines(file.Content)
}
result, err := s.db.ExecContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
VALUES (?, ?, ?, ?, ?, ?)`,
file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount,
)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
file.ID = id
return nil
}
// CreateFilesBatch creates multiple files in a batch.
func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, file := range files {
// Compute metadata if not already set
if file.ByteSize == 0 {
file.ByteSize = len(file.Content)
}
if file.LineCount == 0 {
file.LineCount = countLines(file.Content)
}
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
file.ID = id
}
return tx.Commit()
}
// GetFile retrieves a file by revision and path.
func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
file := &File{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, file_path, extension, content, byte_size, line_count
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return file, nil
}
// GetDeclarationsWithMetadata retrieves declarations with file metadata for an option.
func (s *SQLiteStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT d.id, d.option_id, d.file_path, d.line,
COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL)
FROM declarations d
LEFT JOIN files f ON f.revision_id = ? AND f.file_path = d.file_path
WHERE d.option_id = ?`, revisionID, optionID)
if err != nil {
return nil, fmt.Errorf("failed to get declarations with metadata: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var decls []*DeclarationWithMetadata
for rows.Next() {
decl := &DeclarationWithMetadata{}
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line,
&decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil {
return nil, fmt.Errorf("failed to scan declaration: %w", err)
}
decls = append(decls, decl)
}
return decls, rows.Err()
}
// GetFileWithRange retrieves a file with a specified line range.
func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) {
file, err := s.GetFile(ctx, revisionID, path)
if err != nil {
return nil, err
}
if file == nil {
return nil, nil
}
return applyLineRange(file, r), nil
}
// countLines counts the number of lines in content.
func countLines(content string) int {
if content == "" {
return 0
}
count := 1
for _, c := range content {
if c == '\n' {
count++
}
}
// Don't count trailing newline as extra line
if len(content) > 0 && content[len(content)-1] == '\n' {
count--
}
return count
}
// applyLineRange extracts a range of lines from a file.
func applyLineRange(file *File, r FileRange) *FileResult {
lines := strings.Split(file.Content, "\n")
totalLines := len(lines)
// Handle trailing newline
if totalLines > 0 && lines[totalLines-1] == "" {
totalLines--
lines = lines[:totalLines]
}
// Apply defaults
offset := r.Offset
if offset < 0 {
offset = 0
}
limit := r.Limit
if limit <= 0 {
limit = 250 // Default limit
}
// Calculate range
startLine := offset + 1 // 1-based
if offset >= totalLines {
// Beyond end of file
return &FileResult{
File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: "", ByteSize: file.ByteSize, LineCount: file.LineCount},
TotalLines: totalLines,
StartLine: 0,
EndLine: 0,
}
}
endIdx := offset + limit
if endIdx > totalLines {
endIdx = totalLines
}
endLine := endIdx // 1-based (last line included)
// Extract lines
selectedLines := lines[offset:endIdx]
content := strings.Join(selectedLines, "\n")
return &FileResult{
File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: content, ByteSize: file.ByteSize, LineCount: file.LineCount},
TotalLines: totalLines,
StartLine: startLine,
EndLine: endLine,
}
}