feat: EXT-007 P2P message history gossip
After hello verification, the connecting peer sends history_request to the first peer it meets (one per room, no fan-out). The responder queries SQLite and replies with a history_chunk. Received history is stored via INSERT OR IGNORE (mid dedup) and emitted as history_loaded IPC events. - proto: MsgHistoryRequest/Chunk types, HistoryEntry, EvtHistoryLoaded, ComputeMsgID (sha256 content-addressed ID), MsgID field on ChatMessage - store: ALTER TABLE ADD COLUMN msg_id + unique index migration (idempotent); RecentMessagesSince query (msg_id IS NOT NULL filter); msg_id persisted on save - mesh: RequestHistoryFrom, HandleHistoryRequest, HandleHistoryChunk methods; historyRequested/historyFirstPeer state to ensure single-peer requests - peer: dispatch history_request/history_chunk; RequestHistoryFrom after hello; stamp MsgID on incoming chat messages - ipc: stamp MsgID on outgoing group chat messages - EXTENSIONS.md: EXT-007 documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -35,6 +36,14 @@ CREATE TABLE IF NOT EXISTS rooms (
|
||||
);
|
||||
`
|
||||
|
||||
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||
var migrations = []string{
|
||||
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||
}
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -51,6 +60,12 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate db: %w", err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migration %q: %w", m, err)
|
||||
}
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
@@ -64,9 +79,9 @@ func (s *Store) Close() error {
|
||||
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,
|
||||
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -91,14 +106,31 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
||||
|
||||
// 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
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT ?`,
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
|
||||
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||
// Only messages that have a msg_id (i.e. gossip-safe) are returned.
|
||||
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
since := time.UnixMilli(sinceMs).UTC()
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, since, limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error) {
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,7 +145,6 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
return nil, err
|
||||
}
|
||||
m.From = proto.PeerID(from)
|
||||
m.Room = room
|
||||
m.Ts = sentAt.UnixMilli()
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
@@ -168,3 +199,10 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user