// 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 ShareDiff float64 } 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 '', share_diff REAL NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at); ` // migrations additive only; safe to run every startup. SQLite ignores // ADD COLUMN that already exists only if we check, so we query // pragma table_info and apply missing ones. func (s *BlockStore) migrate() error { rows, err := s.db.Query(`PRAGMA table_info(blocks)`) if err != nil { return err } defer rows.Close() have := map[string]bool{} for rows.Next() { var cid int var name, ctype string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return err } have[name] = true } if !have["share_diff"] { if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN share_diff REAL NOT NULL DEFAULT 0`); err != nil { return fmt.Errorf("store: add share_diff: %w", err) } } return nil } // 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) } s := &BlockStore{db: db} if err := s.migrate(); err != nil { _ = db.Close() return nil, err } return s, 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, share_diff) VALUES (?, ?, ?, ?, ?, ?)`, b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, ) 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, share_diff 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, &b.ShareDiff); 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 }