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 }