package sqlite import ( "context" "database/sql" "errors" "fmt" "time" "modernc.org/sqlite" "git.hrafn.xyz/aether/gotes/internal/models" ) const sqlSet = ` PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 7000; ` type SQLiteStore struct { read *sql.DB write *sql.DB } type scannable interface { Scan(dest ...any) error } func NewSQLiteStore(ctx context.Context, dbPath string) (*SQLiteStore, error) { sqliteDB := &SQLiteStore{} sqlite.RegisterConnectionHook(func(conn sqlite.ExecQuerierContext, _ string) error { _, err := conn.ExecContext(ctx, sqlSet, nil) return err }) write, err := sql.Open("sqlite", "file:"+dbPath) if err != nil { return nil, err } err = write.PingContext(ctx) if err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } // only a single writer ever, no concurrency I don't trust it write.SetMaxOpenConns(1) write.SetConnMaxIdleTime(time.Minute) read, err := sql.Open("sqlite", "file:"+dbPath) if err != nil { return nil, err } err = read.PingContext(ctx) if err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } // readers can be concurrent read.SetMaxOpenConns(100) read.SetConnMaxIdleTime(time.Minute) sqliteDB.read = read sqliteDB.write = write err = sqliteDB.validateSchema(ctx) if err != nil { return nil, fmt.Errorf("failed to validate schema: %w", err) } return sqliteDB, nil } func (s *SQLiteStore) Close() error { readErr := s.read.Close() writeErr := s.write.Close() if readErr != nil || writeErr != nil { return fmt.Errorf("failed to close connections: read error: %v, write error: %v", readErr, writeErr) } return nil } func (s *SQLiteStore) SaveNote(ctx context.Context, note models.Note) (models.Note, error) { tx, err := s.getWriteTransaction(ctx) if err != nil { return models.Note{}, err } defer tx.Rollback() if err := note.Validate(); err != nil { return models.Note{}, err } if note.ID == 0 { result, err := tx.ExecContext(ctx, ` INSERT INTO notes (title, content, last_update) VALUES (?, ?, ?); `, note.Title, note.Content, note.LastUpdate) if err != nil { return models.Note{}, fmt.Errorf("failed to insert note: %w", err) } id, err := result.LastInsertId() if err != nil { return models.Note{}, fmt.Errorf("failed to get last insert ID: %w", err) } note.ID = int(id) } else { _, err := s.scanNote(tx.QueryRowContext(ctx, ` SELECT id, title, content, last_update FROM notes WHERE id = ?; `, note.ID)) if err != nil { return models.Note{}, fmt.Errorf("cannot update note not found: %w", err) } _, err = tx.ExecContext(ctx, ` UPDATE notes SET title = ?, content = ?, last_update = ? WHERE id = ?; `, note.Title, note.Content, note.LastUpdate, note.ID) if err != nil { return models.Note{}, fmt.Errorf("failed to update note: %w", err) } } if err := tx.Commit(); err != nil { return models.Note{}, fmt.Errorf("failed to commit transaction: %w", err) } return note, nil } func (s *SQLiteStore) GetNoteByID(ctx context.Context, id int) (models.Note, error) { row := s.read.QueryRowContext(ctx, ` SELECT id, title, content, last_update FROM notes WHERE id = ?; `, id) note, err := s.scanNote(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return models.Note{}, fmt.Errorf("note with ID %d not found: %w", id, err) } return models.Note{}, fmt.Errorf("failed to get note by ID: %w", err) } return note, nil } func (s *SQLiteStore) GetAllNotes(ctx context.Context) ([]models.Note, error) { rows, err := s.read.QueryContext(ctx, ` SELECT id, title, content, last_update FROM notes ORDER BY id ASC; `) if err != nil { return nil, fmt.Errorf("failed to query all notes: %w", err) } defer rows.Close() var notes []models.Note for rows.Next() { note, err := s.scanNote(rows) if err != nil { return nil, fmt.Errorf("failed to scan note: %w", err) } notes = append(notes, note) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating over notes: %w", err) } return notes, nil } func (s *SQLiteStore) DeleteNoteByID(ctx context.Context, id int) error { result, err := s.write.ExecContext(ctx, ` DELETE FROM notes WHERE id = ?; `, id) if err != nil { return fmt.Errorf("failed to delete note: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("note with ID %d not found", id) } return nil } func (s *SQLiteStore) validateSchema(ctx context.Context) error { _, err := s.write.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, last_update TIMESTAMP NOT NULL, title TEXT NOT NULL, content BLOB NOT NULL ); CREATE UNIQUE INDEX idx_id ON notes(id); CREATE INDEX idx_title ON notes(title); `, nil) return err } func (s *SQLiteStore) getWriteTransaction(ctx context.Context) (*sql.Tx, error) { tx, err := s.write.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } return tx, nil } func (s *SQLiteStore) scanNote(results scannable) (models.Note, error) { var note models.Note err := results.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.LastUpdate) if err != nil { return models.Note{}, fmt.Errorf("failed to scan note: %w", err) } return note, nil }