feat: project structure and nix build setup
- Add CLI entry point with urfave/cli/v2 (serve, index, list, search commands) - Add database interface and implementations for PostgreSQL and SQLite - Add schema versioning with automatic recreation on version mismatch - Add MCP protocol types and server scaffold - Add NixOS option types - Configure flake.nix with devShell and buildGoModule package Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
88
internal/database/interface.go
Normal file
88
internal/database/interface.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Package database provides database abstraction for storing NixOS options.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Revision represents an indexed nixpkgs revision.
|
||||
type Revision struct {
|
||||
ID int64
|
||||
GitHash string
|
||||
ChannelName string
|
||||
CommitDate time.Time
|
||||
IndexedAt time.Time
|
||||
OptionCount int
|
||||
}
|
||||
|
||||
// Option represents a NixOS configuration option.
|
||||
type Option struct {
|
||||
ID int64
|
||||
RevisionID int64
|
||||
Name string
|
||||
ParentPath string
|
||||
Type string
|
||||
DefaultValue string // JSON text
|
||||
Example string // JSON text
|
||||
Description string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// Declaration represents a file where an option is declared.
|
||||
type Declaration struct {
|
||||
ID int64
|
||||
OptionID int64
|
||||
FilePath string
|
||||
Line int
|
||||
}
|
||||
|
||||
// File represents a cached file from nixpkgs.
|
||||
type File struct {
|
||||
ID int64
|
||||
RevisionID int64
|
||||
FilePath string
|
||||
Extension string
|
||||
Content string
|
||||
}
|
||||
|
||||
// SearchFilters contains optional filters for option search.
|
||||
type SearchFilters struct {
|
||||
Type string
|
||||
Namespace string
|
||||
HasDefault *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Store defines the interface for database operations.
|
||||
type Store interface {
|
||||
// Schema operations
|
||||
Initialize(ctx context.Context) error
|
||||
Close() error
|
||||
|
||||
// Revision operations
|
||||
CreateRevision(ctx context.Context, rev *Revision) error
|
||||
GetRevision(ctx context.Context, gitHash string) (*Revision, error)
|
||||
GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error)
|
||||
ListRevisions(ctx context.Context) ([]*Revision, error)
|
||||
DeleteRevision(ctx context.Context, id int64) error
|
||||
UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error
|
||||
|
||||
// Option operations
|
||||
CreateOption(ctx context.Context, opt *Option) error
|
||||
CreateOptionsBatch(ctx context.Context, opts []*Option) error
|
||||
GetOption(ctx context.Context, revisionID int64, name string) (*Option, error)
|
||||
GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error)
|
||||
SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error)
|
||||
|
||||
// Declaration operations
|
||||
CreateDeclaration(ctx context.Context, decl *Declaration) error
|
||||
CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error
|
||||
GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error)
|
||||
|
||||
// File operations
|
||||
CreateFile(ctx context.Context, file *File) error
|
||||
CreateFilesBatch(ctx context.Context, files []*File) error
|
||||
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
||||
}
|
||||
473
internal/database/postgres.go
Normal file
473
internal/database/postgres.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// PostgresStore implements Store using PostgreSQL.
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL store.
|
||||
func NewPostgresStore(connStr string) (*PostgresStore, error) {
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &PostgresStore{db: db}, nil
|
||||
}
|
||||
|
||||
// Initialize creates or migrates the database schema.
|
||||
func (s *PostgresStore) 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,
|
||||
}
|
||||
for _, stmt := range dropStmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("failed to drop table: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tables
|
||||
createStmts := []string{
|
||||
SchemaInfoTable,
|
||||
// PostgreSQL uses SERIAL for auto-increment
|
||||
`CREATE TABLE IF NOT EXISTS revisions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
git_hash TEXT NOT NULL UNIQUE,
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_path TEXT NOT NULL,
|
||||
type TEXT,
|
||||
default_value TEXT,
|
||||
example TEXT,
|
||||
description TEXT,
|
||||
read_only BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS declarations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
line INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
extension TEXT,
|
||||
content TEXT NOT NULL
|
||||
)`,
|
||||
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 full-text search index for PostgreSQL
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_options_fts
|
||||
ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, '')))
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create FTS index: %w", err)
|
||||
}
|
||||
|
||||
// Set schema version
|
||||
if needsRecreate {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"INSERT INTO schema_info (version) VALUES ($1)", SchemaVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set schema version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (s *PostgresStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// CreateRevision creates a new revision record.
|
||||
func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, indexed_at`,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
|
||||
).Scan(&rev.ID, &rev.IndexedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revision: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRevision retrieves a revision by git hash.
|
||||
func (s *PostgresStore) 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 = $1`, 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 *PostgresStore) 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 = $1
|
||||
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 *PostgresStore) 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()
|
||||
|
||||
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 *PostgresStore) DeleteRevision(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = $1", 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 *PostgresStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"UPDATE revisions SET option_count = $1 WHERE id = $2", 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 *PostgresStore) CreateOption(ctx context.Context, opt *Option) error {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`,
|
||||
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||
).Scan(&opt.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create option: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOptionsBatch creates multiple options in a batch.
|
||||
func (s *PostgresStore) 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()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, opt := range opts {
|
||||
err := stmt.QueryRowContext(ctx,
|
||||
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
|
||||
).Scan(&opt.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetOption retrieves an option by revision and name.
|
||||
func (s *PostgresStore) 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 = $1 AND name = $2`, 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 *PostgresStore) 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 = $1 AND parent_path = $2
|
||||
ORDER BY name`, revisionID, parentPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get children: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
|
||||
// Use PostgreSQL full-text search
|
||||
baseQuery := `
|
||||
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
|
||||
FROM options
|
||||
WHERE revision_id = $1
|
||||
AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
|
||||
|
||||
args := []interface{}{revisionID, query}
|
||||
argNum := 3
|
||||
|
||||
if filters.Type != "" {
|
||||
baseQuery += fmt.Sprintf(" AND type = $%d", argNum)
|
||||
args = append(args, filters.Type)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if filters.Namespace != "" {
|
||||
baseQuery += fmt.Sprintf(" AND name LIKE $%d", argNum)
|
||||
args = append(args, filters.Namespace+"%")
|
||||
argNum++
|
||||
}
|
||||
|
||||
if filters.HasDefault != nil {
|
||||
if *filters.HasDefault {
|
||||
baseQuery += " AND default_value IS NOT NULL"
|
||||
} else {
|
||||
baseQuery += " AND default_value IS NULL"
|
||||
}
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY 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()
|
||||
|
||||
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 *PostgresStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO declarations (option_id, file_path, line)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id`,
|
||||
decl.OptionID, decl.FilePath, decl.Line,
|
||||
).Scan(&decl.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create declaration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDeclarationsBatch creates multiple declarations in a batch.
|
||||
func (s *PostgresStore) 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()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO declarations (option_id, file_path, line)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, decl := range decls {
|
||||
err := stmt.QueryRowContext(ctx, decl.OptionID, decl.FilePath, decl.Line).Scan(&decl.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert declaration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetDeclarations retrieves declarations for an option.
|
||||
func (s *PostgresStore) 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 = $1`, optionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get declarations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 *PostgresStore) CreateFile(ctx context.Context, file *File) error {
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO files (revision_id, file_path, extension, content)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id`,
|
||||
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
||||
).Scan(&file.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateFilesBatch creates multiple files in a batch.
|
||||
func (s *PostgresStore) 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()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO files (revision_id, file_path, extension, content)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, file := range files {
|
||||
err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetFile retrieves a file by revision and path.
|
||||
func (s *PostgresStore) 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
|
||||
FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path,
|
||||
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
102
internal/database/schema.go
Normal file
102
internal/database/schema.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package database
|
||||
|
||||
// SchemaVersion is the current database schema version.
|
||||
// When this changes, the database will be dropped and recreated.
|
||||
const SchemaVersion = 1
|
||||
|
||||
// Common SQL statements shared between implementations.
|
||||
const (
|
||||
// SchemaInfoTable creates the schema version tracking table.
|
||||
SchemaInfoTable = `
|
||||
CREATE TABLE IF NOT EXISTS schema_info (
|
||||
version INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
// RevisionsTable creates the revisions table.
|
||||
RevisionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
git_hash TEXT NOT NULL UNIQUE,
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
|
||||
// OptionsTable creates the options table.
|
||||
OptionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS options (
|
||||
id INTEGER PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_path TEXT NOT NULL,
|
||||
type TEXT,
|
||||
default_value TEXT,
|
||||
example TEXT,
|
||||
description TEXT,
|
||||
read_only BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`
|
||||
|
||||
// DeclarationsTable creates the declarations table.
|
||||
DeclarationsTable = `
|
||||
CREATE TABLE IF NOT EXISTS declarations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
line INTEGER
|
||||
)`
|
||||
|
||||
// FilesTable creates the files table.
|
||||
FilesTable = `
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
extension TEXT,
|
||||
content TEXT NOT NULL
|
||||
)`
|
||||
)
|
||||
|
||||
// Index creation statements.
|
||||
const (
|
||||
// IndexOptionsRevisionName creates an index on options(revision_id, name).
|
||||
IndexOptionsRevisionName = `
|
||||
CREATE INDEX IF NOT EXISTS idx_options_revision_name
|
||||
ON options(revision_id, name)`
|
||||
|
||||
// IndexOptionsRevisionParent creates an index on options(revision_id, parent_path).
|
||||
IndexOptionsRevisionParent = `
|
||||
CREATE INDEX IF NOT EXISTS idx_options_revision_parent
|
||||
ON options(revision_id, parent_path)`
|
||||
|
||||
// IndexFilesRevisionPath creates an index on files(revision_id, file_path).
|
||||
IndexFilesRevisionPath = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_revision_path
|
||||
ON files(revision_id, file_path)`
|
||||
|
||||
// IndexDeclarationsOption creates an index on declarations(option_id).
|
||||
IndexDeclarationsOption = `
|
||||
CREATE INDEX IF NOT EXISTS idx_declarations_option
|
||||
ON declarations(option_id)`
|
||||
)
|
||||
|
||||
// Drop statements for schema recreation.
|
||||
const (
|
||||
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
|
||||
DropDeclarations = `DROP TABLE IF EXISTS declarations`
|
||||
DropOptions = `DROP TABLE IF EXISTS options`
|
||||
DropFiles = `DROP TABLE IF EXISTS files`
|
||||
DropRevisions = `DROP TABLE IF EXISTS revisions`
|
||||
)
|
||||
|
||||
// ParentPath extracts the parent path from an option name.
|
||||
// For example, "services.nginx.enable" returns "services.nginx".
|
||||
// Top-level options return an empty string.
|
||||
func ParentPath(name string) string {
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '.' {
|
||||
return name[:i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
507
internal/database/sqlite.go
Normal file
507
internal/database/sqlite.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "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()
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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) {
|
||||
// Use SQLite FTS5 for full-text search
|
||||
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 ?`
|
||||
|
||||
args := []interface{}{revisionID, query}
|
||||
|
||||
if filters.Type != "" {
|
||||
baseQuery += " AND o.type = ?"
|
||||
args = append(args, filters.Type)
|
||||
}
|
||||
|
||||
if filters.Namespace != "" {
|
||||
baseQuery += " AND o.name LIKE ?"
|
||||
args = append(args, filters.Namespace+"%")
|
||||
}
|
||||
|
||||
if filters.HasDefault != nil {
|
||||
if *filters.HasDefault {
|
||||
baseQuery += " AND o.default_value IS NOT NULL"
|
||||
} else {
|
||||
baseQuery += " AND o.default_value IS NULL"
|
||||
}
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY o.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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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 {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO files (revision_id, file_path, extension, content)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
file.RevisionID, file.FilePath, file.Extension, file.Content,
|
||||
)
|
||||
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()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO files (revision_id, file_path, extension, content)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, file := range files {
|
||||
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content)
|
||||
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
|
||||
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
|
||||
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
329
internal/mcp/server.go
Normal file
329
internal/mcp/server.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
)
|
||||
|
||||
// Server is an MCP server that handles JSON-RPC requests over stdio.
|
||||
type Server struct {
|
||||
store database.Store
|
||||
tools map[string]ToolHandler
|
||||
initialized bool
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles a tool call.
|
||||
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
|
||||
|
||||
// NewServer creates a new MCP server.
|
||||
func NewServer(store database.Store, logger *log.Logger) *Server {
|
||||
if logger == nil {
|
||||
logger = log.New(io.Discard, "", 0)
|
||||
}
|
||||
s := &Server{
|
||||
store: store,
|
||||
tools: make(map[string]ToolHandler),
|
||||
logger: logger,
|
||||
}
|
||||
s.registerTools()
|
||||
return s
|
||||
}
|
||||
|
||||
// registerTools registers all available tools.
|
||||
func (s *Server) registerTools() {
|
||||
// Tools will be implemented in handlers.go
|
||||
}
|
||||
|
||||
// Run starts the server, reading from r and writing to w.
|
||||
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
s.logger.Printf("Failed to parse request: %v", err)
|
||||
resp := Response{
|
||||
JSONRPC: "2.0",
|
||||
Error: &Error{
|
||||
Code: ParseError,
|
||||
Message: "Parse error",
|
||||
Data: err.Error(),
|
||||
},
|
||||
}
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
return fmt.Errorf("failed to write response: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
resp := s.handleRequest(ctx, &req)
|
||||
if resp != nil {
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
return fmt.Errorf("failed to write response: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRequest processes a single request and returns a response.
|
||||
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
|
||||
s.logger.Printf("Received request: method=%s id=%v", req.Method, req.ID)
|
||||
|
||||
switch req.Method {
|
||||
case MethodInitialize:
|
||||
return s.handleInitialize(req)
|
||||
case MethodInitialized:
|
||||
// This is a notification, no response needed
|
||||
s.initialized = true
|
||||
return nil
|
||||
case MethodToolsList:
|
||||
return s.handleToolsList(req)
|
||||
case MethodToolsCall:
|
||||
return s.handleToolsCall(ctx, req)
|
||||
default:
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &Error{
|
||||
Code: MethodNotFound,
|
||||
Message: "Method not found",
|
||||
Data: req.Method,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleInitialize processes the initialize request.
|
||||
func (s *Server) handleInitialize(req *Request) *Response {
|
||||
var params InitializeParams
|
||||
if req.Params != nil {
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &Error{
|
||||
Code: InvalidParams,
|
||||
Message: "Invalid params",
|
||||
Data: err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Printf("Client: %s %s, protocol: %s",
|
||||
params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion)
|
||||
|
||||
result := InitializeResult{
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
Capabilities: Capabilities{
|
||||
Tools: &ToolsCapability{
|
||||
ListChanged: false,
|
||||
},
|
||||
},
|
||||
ServerInfo: Implementation{
|
||||
Name: "nixos-options",
|
||||
Version: "0.1.0",
|
||||
},
|
||||
}
|
||||
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolsList returns the list of available tools.
|
||||
func (s *Server) handleToolsList(req *Request) *Response {
|
||||
tools := s.getToolDefinitions()
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: ListToolsResult{Tools: tools},
|
||||
}
|
||||
}
|
||||
|
||||
// getToolDefinitions returns the tool definitions.
|
||||
func (s *Server) getToolDefinitions() []Tool {
|
||||
return []Tool{
|
||||
{
|
||||
Name: "search_options",
|
||||
Description: "Search for NixOS configuration options by name or description",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Search query (matches option names and descriptions)",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.",
|
||||
},
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Filter by option type (e.g., 'boolean', 'string', 'list')",
|
||||
},
|
||||
"namespace": {
|
||||
Type: "string",
|
||||
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 50)",
|
||||
Default: 50,
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_option",
|
||||
Description: "Get full details for a specific NixOS option including its children",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"name": {
|
||||
Type: "string",
|
||||
Description: "Full option path (e.g., 'services.nginx.enable')",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
"include_children": {
|
||||
Type: "boolean",
|
||||
Description: "Include direct children of this option (default: true)",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_file",
|
||||
Description: "Fetch the contents of a file from nixpkgs",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "index_revision",
|
||||
Description: "Index a nixpkgs revision to make its options searchable",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')",
|
||||
},
|
||||
},
|
||||
Required: []string{"revision"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_revisions",
|
||||
Description: "List all indexed nixpkgs revisions",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_revision",
|
||||
Description: "Delete an indexed revision and all its data",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name of the revision to delete",
|
||||
},
|
||||
},
|
||||
Required: []string{"revision"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolsCall handles a tool invocation.
|
||||
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
|
||||
var params CallToolParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &Error{
|
||||
Code: InvalidParams,
|
||||
Message: "Invalid params",
|
||||
Data: err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Printf("Tool call: %s with args %v", params.Name, params.Arguments)
|
||||
|
||||
handler, ok := s.tools[params.Name]
|
||||
if !ok {
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: CallToolResult{
|
||||
Content: []Content{TextContent(fmt.Sprintf("Unknown tool: %s", params.Name))},
|
||||
IsError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
result, err := handler(ctx, params.Arguments)
|
||||
if err != nil {
|
||||
s.logger.Printf("Tool error: %v", err)
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: ErrorContent(err),
|
||||
}
|
||||
}
|
||||
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
}
|
||||
139
internal/mcp/types.go
Normal file
139
internal/mcp/types.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package mcp implements the Model Context Protocol (MCP) over JSON-RPC.
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSON-RPC 2.0 types
|
||||
|
||||
// Request represents a JSON-RPC request.
|
||||
type Request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Response represents a JSON-RPC response.
|
||||
type Response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error represents a JSON-RPC error.
|
||||
type Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
const (
|
||||
ParseError = -32700
|
||||
InvalidRequest = -32600
|
||||
MethodNotFound = -32601
|
||||
InvalidParams = -32602
|
||||
InternalError = -32603
|
||||
)
|
||||
|
||||
// MCP Protocol types
|
||||
|
||||
// InitializeParams are sent by the client during initialization.
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ClientInfo Implementation `json:"clientInfo"`
|
||||
}
|
||||
|
||||
// InitializeResult is returned after successful initialization.
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ServerInfo Implementation `json:"serverInfo"`
|
||||
}
|
||||
|
||||
// Capabilities describes client or server capabilities.
|
||||
type Capabilities struct {
|
||||
Tools *ToolsCapability `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// ToolsCapability describes tool-related capabilities.
|
||||
type ToolsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
// Implementation describes a client or server implementation.
|
||||
type Implementation struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Tool describes an MCP tool.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema InputSchema `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// InputSchema describes the JSON Schema for tool inputs.
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]Property `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// Property describes a single property in an input schema.
|
||||
type Property struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// ListToolsResult is returned by tools/list.
|
||||
type ListToolsResult struct {
|
||||
Tools []Tool `json:"tools"`
|
||||
}
|
||||
|
||||
// CallToolParams are sent when calling a tool.
|
||||
type CallToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// CallToolResult is returned after calling a tool.
|
||||
type CallToolResult struct {
|
||||
Content []Content `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// Content represents a piece of content in a tool result.
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// TextContent creates a text content item.
|
||||
func TextContent(text string) Content {
|
||||
return Content{Type: "text", Text: text}
|
||||
}
|
||||
|
||||
// ErrorContent creates an error content item.
|
||||
func ErrorContent(err error) CallToolResult {
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(err.Error())},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// MCP method names
|
||||
const (
|
||||
MethodInitialize = "initialize"
|
||||
MethodInitialized = "notifications/initialized"
|
||||
MethodToolsList = "tools/list"
|
||||
MethodToolsCall = "tools/call"
|
||||
)
|
||||
|
||||
// Protocol version
|
||||
const ProtocolVersion = "2024-11-05"
|
||||
45
internal/nixos/types.go
Normal file
45
internal/nixos/types.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package nixos contains types and logic specific to NixOS options.
|
||||
package nixos
|
||||
|
||||
// RawOption represents an option as parsed from options.json.
|
||||
// The structure matches the output of `nix-build '<nixpkgs/nixos/release.nix>' -A options`.
|
||||
type RawOption struct {
|
||||
Declarations []string `json:"declarations"`
|
||||
Default *OptionValue `json:"default,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Example *OptionValue `json:"example,omitempty"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Type string `json:"type"`
|
||||
Loc []string `json:"loc,omitempty"`
|
||||
}
|
||||
|
||||
// OptionValue wraps a value that may be a literal or a Nix expression.
|
||||
type OptionValue struct {
|
||||
// Text is the raw JSON representation of the value
|
||||
Text string
|
||||
}
|
||||
|
||||
// OptionsFile represents the top-level structure of options.json.
|
||||
// It's a map from option name to option definition.
|
||||
type OptionsFile map[string]RawOption
|
||||
|
||||
// AllowedExtensions is the default set of file extensions to index.
|
||||
var AllowedExtensions = map[string]bool{
|
||||
".nix": true,
|
||||
".json": true,
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".toml": true,
|
||||
".yaml": true,
|
||||
".yml": true,
|
||||
}
|
||||
|
||||
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
|
||||
var ChannelAliases = map[string]string{
|
||||
"nixos-unstable": "nixos-unstable",
|
||||
"nixos-stable": "nixos-24.11", // Update this as new stable releases come out
|
||||
"nixos-24.11": "nixos-24.11",
|
||||
"nixos-24.05": "nixos-24.05",
|
||||
"nixos-23.11": "nixos-23.11",
|
||||
"nixos-23.05": "nixos-23.05",
|
||||
}
|
||||
Reference in New Issue
Block a user