From 996660b3db619543012bcc8bf63af35b8d656fa2 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 1/2] =?UTF-8?q?test(nestlog):=20red=20for=20#9=20=E2=80=94?= =?UTF-8?q?=20append=20order,=20replay,=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nestlog_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 nestlog_test.go diff --git a/nestlog_test.go b/nestlog_test.go new file mode 100644 index 0000000..58a87d7 --- /dev/null +++ b/nestlog_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestMessageLogAppendOrder(t *testing.T) { + s := NewMessageStore(filepath.Join(t.TempDir(), "messages")) + for _, m := range []Message{{NestID: "lab", Kind: "say", Author: "owner", Text: "a"}, {NestID: "lab", Kind: "request", Author: "owner", Text: "b"}, {NestID: "lab", Kind: "claim", Author: "Hugin", Text: "c"}} { + if _, err := s.Append(m); err != nil { + t.Fatal(err) + } + } + got := s.After("lab", 0) + if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 || got[2].Kind != "claim" { + t.Fatalf("want ids 1,2,3 in order, got %+v", got) + } +} + +func TestMessageLogReplayFrom(t *testing.T) { + s := NewMessageStore(filepath.Join(t.TempDir(), "messages")) + for i := 0; i < 5; i++ { + if _, err := s.Append(Message{NestID: "lab", Kind: "say", Author: "owner"}); err != nil { + t.Fatal(err) + } + } + got := s.After("lab", 2) + if len(got) != 3 || got[0].ID != 3 || got[2].ID != 5 { + t.Fatalf("want ids 3,4,5, got %+v", got) + } + if n := len(s.After("lab", 5)); n != 0 { + t.Fatalf("want nothing after the last id, got %d", n) + } +} + +func TestMessageLogPersists(t *testing.T) { + dir := filepath.Join(t.TempDir(), "messages") + s := NewMessageStore(dir) + for _, m := range []Message{{NestID: "lab", Kind: "request", Author: "owner", Text: "do it"}, {NestID: "lab", Kind: "claim", Author: "Munin", Text: "mine", To: "owner", RefersTo: "lab-1"}} { + if _, err := s.Append(m); err != nil { + t.Fatal(err) + } + } + opened, err := LoadMessageStore(dir) + if err != nil { + t.Fatal(err) + } + got := opened.After("lab", 0) + if len(got) != 2 || got[0].Text != "do it" || got[1].RefersTo != "lab-1" || got[1].ID != 2 { + t.Fatalf("round trip lost data: %+v", got) + } + if _, err := opened.Append(Message{NestID: "lab", Kind: "deliver", Author: "Munin"}); err != nil { + t.Fatal(err) + } + reopened, err := LoadMessageStore(dir) + if err != nil { + t.Fatal(err) + } + if n := len(reopened.After("lab", 0)); n != 3 { + t.Fatalf("append after reload not persisted: %d messages", n) + } +} 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 2/2] 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) +}