From 027eb34f6ae48e06b371a0b96a74bd6c024a3917 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:02:39 -0600 Subject: [PATCH] feat(nestlog): append-only message store with JSON Lines persistence (#9) --- nestlog.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 nestlog.go diff --git a/nestlog.go b/nestlog.go new file mode 100644 index 0000000..17f4bd1 --- /dev/null +++ b/nestlog.go @@ -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 "-" 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) +}