Add SQLite message and peer persistence (internal/store)

Each daemon writes to <data-dir>/messages.db on startup. Messages received
or sent are stored immediately; duplicate mids (INSERT OR IGNORE) are safe
to call multiple times. Peer aliases are upserted on peer_connected and
again after hello verification when the real nick is known.

Schema
- messages(mid UNIQUE, room, from_peer, body, sent_at) — mid is the YAW/2
  dedup key added in the proto migration; index on (room, sent_at) for
  efficient per-room queries.
- peers(peer_id PK, alias, last_seen) — cache of every peer ever seen,
  used to resolve hex ids to names when peers are offline.

Wiring
- store.Open called in cmd/daemon/main.go, passed to mesh.New.
- mesh.Mesh holds *store.Store (nil-safe; persistence is optional).
- mesh.SaveMessage called in dispatchPeerMessage (incoming) and ipc
  CmdSendMessage (outgoing) so the local node's own messages are stored.
- mesh.UpdatePeerAlias called after hello verification updates the alias
  with the verified nick rather than the placeholder short-id.

Messages only accumulate from join time forward — no history replay to
late-joining peers; each node's view starts from when it connected.

test-network.sh: added SQLite verification block that queries each node's
DB after the test and prints message + peer counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-21 18:04:42 +02:00
parent b648f7029f
commit cde611b261
9 changed files with 332 additions and 10 deletions

View File

@@ -161,6 +161,7 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
continue
}
m.Broadcast(payload)
m.SaveMessage(msg)
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg})
case proto.CmdJoinNetwork:

View File

@@ -2,10 +2,12 @@
package mesh
import (
"log"
"sync"
"github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/proto"
"github.com/waste-go/internal/store"
)
// PeerConn is a live connection to one peer.
@@ -19,9 +21,10 @@ type PeerConn struct {
// All methods are safe to call from multiple goroutines.
type Mesh struct {
Identity *crypto.Identity
Store *store.Store // may be nil if persistence is disabled
mu sync.RWMutex
peers map[proto.PeerID]*PeerConn
mu sync.RWMutex
peers map[proto.PeerID]*PeerConn
// subscribers receive a copy of every event (fan-out to IPC clients)
subMu sync.Mutex
@@ -29,9 +32,11 @@ type Mesh struct {
}
// New creates an empty mesh with the given identity.
func New(id *crypto.Identity) *Mesh {
// Pass a non-nil store to enable message and peer persistence.
func New(id *crypto.Identity, st *store.Store) *Mesh {
return &Mesh{
Identity: id,
Store: st,
peers: make(map[proto.PeerID]*PeerConn),
}
}
@@ -44,12 +49,38 @@ func (m *Mesh) AddPeer(conn *PeerConn) {
m.peers[conn.Info.ID] = conn
m.mu.Unlock()
if m.Store != nil && conn.Info.Alias != "" {
if err := m.Store.SavePeer(conn.Info.ID, conn.Info.Alias); err != nil {
log.Printf("mesh: store peer %s: %v", conn.Info.ID.Short(), err)
}
}
m.emit(proto.IpcMessage{
Type: proto.EvtPeerConnected,
Peer: &conn.Info,
})
}
// SaveMessage persists a chat message if a store is configured.
// Duplicate mids are silently dropped.
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
if m.Store == nil || msg == nil {
return
}
if err := m.Store.SaveMessage(msg); err != nil {
log.Printf("mesh: store message %s: %v", msg.Mid, err)
}
}
// UpdatePeerAlias updates the cached alias for a peer after hello verification.
func (m *Mesh) UpdatePeerAlias(id proto.PeerID, alias string) {
if m.Store != nil && alias != "" {
if err := m.Store.SavePeer(id, alias); err != nil {
log.Printf("mesh: update peer alias %s: %v", id.Short(), err)
}
}
}
// RemovePeer unregisters a peer and notifies subscribers.
func (m *Mesh) RemovePeer(id proto.PeerID) {
m.mu.Lock()

View File

@@ -128,6 +128,7 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
conn.Info.PublicKey = hello.ID
}
m.mu.Unlock()
m.UpdatePeerAlias(from, hello.Nick)
m.Emit(proto.IpcMessage{
Type: proto.EvtSessionReady,
PeerID: peerIDPtr(from),
@@ -148,6 +149,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
switch msg.Type {
case proto.MsgChat:
if msg.Chat != nil {
m.SaveMessage(msg.Chat)
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg.Chat})
}
case proto.MsgPeerGossip:

130
internal/store/store.go Normal file
View File

@@ -0,0 +1,130 @@
// 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 {
_, 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.Body, msg.SentAt.UTC(),
)
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
}
// 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.Body, &sentAt); err != nil {
return nil, err
}
m.From = proto.PeerID(from)
m.Room = room
m.SentAt = sentAt
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()
}

View File

@@ -0,0 +1,103 @@
package store
import (
"path/filepath"
"testing"
"time"
"github.com/waste-go/internal/proto"
)
func TestRoundTrip(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
msg := &proto.ChatMessage{
Mid: "aabbccdd00112233",
From: proto.PeerID("deadbeef"),
Room: "general",
Body: "hello world",
SentAt: time.Now().UTC().Truncate(time.Second),
}
if err := st.SaveMessage(msg); err != nil {
t.Fatalf("SaveMessage: %v", err)
}
// Duplicate mid must be silently ignored.
if err := st.SaveMessage(msg); err != nil {
t.Fatalf("duplicate SaveMessage: %v", err)
}
msgs, err := st.RecentMessages("general", 10)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
got := msgs[0]
if got.Mid != msg.Mid || got.Body != msg.Body || got.Room != msg.Room {
t.Fatalf("message mismatch: %+v", got)
}
}
func TestSavePeerAndKnownPeers(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
if err := st.SavePeer("aabbccdd", "alice"); err != nil {
t.Fatalf("SavePeer: %v", err)
}
// Upsert with updated alias.
if err := st.SavePeer("aabbccdd", "alice-updated"); err != nil {
t.Fatalf("SavePeer upsert: %v", err)
}
peers, err := st.KnownPeers()
if err != nil {
t.Fatalf("KnownPeers: %v", err)
}
if peers["aabbccdd"] != "alice-updated" {
t.Fatalf("alias = %q, want %q", peers["aabbccdd"], "alice-updated")
}
}
func TestRecentMessagesOrdering(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
base := time.Now().UTC().Truncate(time.Second)
for i := range 5 {
st.SaveMessage(&proto.ChatMessage{
Mid: string(rune('a'+i)) + "000000000000000",
From: "deadbeef",
Room: "general",
Body: string(rune('a' + i)),
SentAt: base.Add(time.Duration(i) * time.Second),
})
}
msgs, err := st.RecentMessages("general", 10)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 5 {
t.Fatalf("got %d messages, want 5", len(msgs))
}
// Must be oldest-first.
for i := 1; i < len(msgs); i++ {
if msgs[i].SentAt.Before(msgs[i-1].SentAt) {
t.Fatalf("messages not in ascending order at index %d", i)
}
}
}