(#9) Nest message log + replay #16
+124
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Message is one entry in a nest's conversation, shaped by the EVENTS.md
|
||||
// contract. IDs are assigned by the store; MsgID is "<nest_id>-<n>" and is
|
||||
// what the contract exposes.
|
||||
type Message struct {
|
||||
NestID string `json:"nest_id"`
|
||||
ID int `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Author string `json:"author"`
|
||||
Text string `json:"text"`
|
||||
To string `json:"to,omitempty"`
|
||||
RefersTo string `json:"refers_to,omitempty"`
|
||||
TaggedBy string `json:"tagged_by,omitempty"`
|
||||
Timestamp string `json:"ts"`
|
||||
}
|
||||
|
||||
func (m Message) MsgID() string { return fmt.Sprintf("%s-%d", m.NestID, m.ID) }
|
||||
|
||||
// MessageStore is the append-only log of every nest's conversation, persisted
|
||||
// as JSON Lines (one file per nest) so a restart can replay it. Follows the
|
||||
// atomic-write style of NestStore.save().
|
||||
type MessageStore struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
log map[string][]Message // nest_id -> messages in append order
|
||||
}
|
||||
|
||||
func NewMessageStore(dir string) *MessageStore {
|
||||
return &MessageStore{dir: dir, log: map[string][]Message{}}
|
||||
}
|
||||
|
||||
// LoadMessageStore reads existing logs from dir. A missing dir is empty, not
|
||||
// an error: a fresh server has no conversations yet.
|
||||
func LoadMessageStore(dir string) (*MessageStore, error) {
|
||||
s := NewMessageStore(dir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
nestID := strings.TrimSuffix(e.Name(), ".jsonl")
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("message log %s is unreadable: %w", e.Name(), err)
|
||||
}
|
||||
var msgs []Message
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m Message
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
return nil, fmt.Errorf("message log %s has a bad line: %w", e.Name(), err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
s.log[nestID] = msgs
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Append assigns the next id, records the message and saves its nest's log.
|
||||
func (s *MessageStore) Append(m Message) (Message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m.ID = len(s.log[m.NestID]) + 1
|
||||
s.log[m.NestID] = append(s.log[m.NestID], m)
|
||||
if err := s.save(m.NestID); err != nil {
|
||||
log := s.log[m.NestID]
|
||||
s.log[m.NestID] = log[:len(log)-1]
|
||||
return Message{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// After returns the messages in nest with id greater than lastID, in order.
|
||||
func (s *MessageStore) After(nestID string, lastID int) []Message {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []Message
|
||||
for _, m := range s.log[nestID] {
|
||||
if m.ID > lastID {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// All returns every message in a nest, oldest first.
|
||||
func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) }
|
||||
|
||||
func (s *MessageStore) save(nestID string) error {
|
||||
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, m := range s.log[nestID] {
|
||||
line, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.Write(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
path := filepath.Join(s.dir, nestID+".jsonl")
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(sb.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
Reference in new issue
Block a user