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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user