// Package store persists messages and peer info to a local SQLite database. // Each daemon has its own database; nothing is shared across the network. package store import ( "database/sql" "fmt" "time" _ "modernc.org/sqlite" "github.com/waste-go/internal/proto" ) const schema = ` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mid TEXT NOT NULL UNIQUE, room TEXT NOT NULL, from_peer TEXT NOT NULL, body TEXT NOT NULL, sent_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_room ON messages (room, sent_at); CREATE TABLE IF NOT EXISTS peers ( peer_id TEXT PRIMARY KEY, alias TEXT NOT NULL, last_seen DATETIME NOT NULL ); ` // Store is a local SQLite-backed message and peer store. type Store struct { db *sql.DB } // Open opens (or creates) the SQLite database at path and runs migrations. func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open db: %w", err) } db.SetMaxOpenConns(1) // SQLite is single-writer if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("migrate db: %w", err) } return &Store{db: db}, nil } // Close releases the database connection. func (s *Store) Close() error { return s.db.Close() } // SaveMessage persists a chat message. Duplicate mids are silently ignored // (INSERT OR IGNORE), so calling this more than once is safe. func (s *Store) SaveMessage(msg *proto.ChatMessage) error { sentAt := time.UnixMilli(msg.Ts).UTC() _, err := s.db.Exec( `INSERT OR IGNORE INTO messages (mid, room, from_peer, body, sent_at) VALUES (?, ?, ?, ?, ?)`, msg.Mid, msg.Room, string(msg.From), msg.Text, sentAt, ) return err } // SavePeer upserts a peer's alias and last-seen timestamp. func (s *Store) SavePeer(peerID proto.PeerID, alias string) error { _, err := s.db.Exec( `INSERT INTO peers (peer_id, alias, last_seen) VALUES (?, ?, ?) ON CONFLICT(peer_id) DO UPDATE SET alias=excluded.alias, last_seen=excluded.last_seen`, string(peerID), alias, time.Now().UTC(), ) return err } // PeerAlias returns the cached alias for a peer, or "" if unknown. func (s *Store) PeerAlias(peerID proto.PeerID) string { var alias string s.db.QueryRow(`SELECT alias FROM peers WHERE peer_id = ?`, string(peerID)).Scan(&alias) //nolint:errcheck return alias } // RecentMessages returns up to limit messages for a room, oldest first. func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) { rows, err := s.db.Query( `SELECT mid, from_peer, body, sent_at FROM messages WHERE room = ? ORDER BY sent_at DESC LIMIT ?`, room, limit, ) if err != nil { return nil, err } defer rows.Close() var msgs []proto.ChatMessage for rows.Next() { var m proto.ChatMessage var from string var sentAt time.Time if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil { return nil, err } m.From = proto.PeerID(from) m.Room = room m.Ts = sentAt.UnixMilli() msgs = append(msgs, m) } // Reverse so oldest-first. for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { msgs[i], msgs[j] = msgs[j], msgs[i] } return msgs, rows.Err() } // KnownPeers returns all peers seen since this daemon started storing data. func (s *Store) KnownPeers() (map[proto.PeerID]string, error) { rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`) if err != nil { return nil, err } defer rows.Close() out := make(map[proto.PeerID]string) for rows.Next() { var id, alias string if err := rows.Scan(&id, &alias); err != nil { return nil, err } out[proto.PeerID(id)] = alias } return out, rows.Err() }