// Package store persists Kamado runtime data that must survive process // restarts. Currently: the found-block history. Uses modernc.org/sqlite // (pure Go, no CGO) so the kamado-api binary stays statically linkable. package store import ( "database/sql" "errors" "fmt" "time" _ "modernc.org/sqlite" ) // BlockStore persists found blocks to a single SQLite file. type BlockStore struct { db *sql.DB } // Block mirrors state.BlockRecord without the import cycle. The store // package is the lower layer; the state package converts to/from this // shape when reading and writing. type Block struct { Height int64 Hash string RewardBT float64 FoundAt time.Time Source string } const schema = ` CREATE TABLE IF NOT EXISTS blocks ( height INTEGER PRIMARY KEY, hash TEXT NOT NULL DEFAULT '', reward_btc REAL NOT NULL DEFAULT 0, found_at INTEGER NOT NULL, source TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at); ` // Open initializes the store at path, creating the schema if needed. // Callers are responsible for Close(). func Open(path string) (*BlockStore, error) { db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, fmt.Errorf("store: open %s: %w", path, err) } if _, err := db.Exec(schema); err != nil { _ = db.Close() return nil, fmt.Errorf("store: schema: %w", err) } return &BlockStore{db: db}, nil } func (s *BlockStore) Close() error { return s.db.Close() } // InsertBlock is idempotent — duplicate heights are ignored so replayed // log events after a restart don't trip the primary key constraint. func (s *BlockStore) InsertBlock(b Block) error { _, err := s.db.Exec( `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source) VALUES (?, ?, ?, ?, ?)`, b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, ) return err } // Recent returns up to limit blocks, newest first. func (s *BlockStore) Recent(limit int) ([]Block, error) { if limit <= 0 { limit = 256 } rows, err := s.db.Query( `SELECT height, hash, reward_btc, found_at, source FROM blocks ORDER BY height DESC LIMIT ?`, limit, ) if err != nil { return nil, err } defer rows.Close() out := make([]Block, 0, limit) for rows.Next() { var b Block var unix int64 if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &unix, &b.Source); err != nil { return nil, err } b.FoundAt = time.Unix(unix, 0).UTC() out = append(out, b) } if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } return out, nil }