From 67a843b1e0093717ab7819f0b7ffdf92e424d753 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 11 Oct 2026 12:04:41 +0330 Subject: [PATCH] feat(store): keep an in-memory newest-first message store Provide a concurrency-safe, per-topic message store with id-based deduplication, delete, clear, and a search predicate. --- internal/store/store.go | 144 +++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 32 ++++++++ 2 files changed, 176 insertions(+) create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..ec57534 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,144 @@ +// Package store keeps an in-memory, newest-first view of ntfy messages. +package store + +import ( + "strings" + "sync" +) + +// Attachment is a file linked from a message. +type Attachment struct { + Name string + URL string + Type string + Size int64 +} + +// Message is one delivered ntfy notification. +type Message struct { + ID string + Time int64 + Expires int64 + Topic string + Title string + Body string + Tags []string + Priority int + Click string + Attachment *Attachment + Actions string +} + +// Store is safe for concurrent readers and writers. +type Store struct { + mu sync.Mutex + byTopic map[string][]Message + index map[string]string + order []string +} + +// New returns an empty store. +func New() *Store { + return &Store{ + byTopic: map[string][]Message{}, + index: map[string]string{}, + } +} + +// Put inserts a message. A repeated id is ignored. +func (s *Store) Put(m Message) { + s.mu.Lock() + defer s.mu.Unlock() + if m.ID != "" { + if _, ok := s.index[m.ID]; ok { + return + } + s.index[m.ID] = m.Topic + } + s.ensure(m.Topic) + s.byTopic[m.Topic] = insertNewest(s.byTopic[m.Topic], m) +} + +// Delete removes the message with id. +func (s *Store) Delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + topic, ok := s.index[id] + if !ok { + return + } + list := s.byTopic[topic] + for i, m := range list { + if m.ID == id { + s.byTopic[topic] = append(list[:i], list[i+1:]...) + break + } + } + delete(s.index, id) +} + +// Clear removes every message for a topic. +func (s *Store) Clear(topic string) { + s.mu.Lock() + defer s.mu.Unlock() + for _, m := range s.byTopic[topic] { + if m.ID != "" { + delete(s.index, m.ID) + } + } + s.byTopic[topic] = nil +} + +// List returns a copy of one topic, newest first. +func (s *Store) List(topic string) []Message { + s.mu.Lock() + defer s.mu.Unlock() + src := s.byTopic[topic] + out := make([]Message, len(src)) + copy(out, src) + return out +} + +// Topics returns topic names in the order they were first seen. +func (s *Store) Topics() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.order)) + copy(out, s.order) + return out +} + +func (s *Store) ensure(topic string) { + if _, ok := s.byTopic[topic]; ok { + return + } + s.byTopic[topic] = nil + s.order = append(s.order, topic) +} + +func insertNewest(list []Message, m Message) []Message { + i := 0 + for i < len(list) && list[i].Time > m.Time { + i++ + } + list = append(list, Message{}) + copy(list[i+1:], list[i:]) + list[i] = m + return list +} + +// Match reports whether needle is contained in the message's visible fields. +func Match(m Message, title, needle string) bool { + needle = strings.ToLower(strings.TrimSpace(needle)) + if needle == "" { + return true + } + hay := strings.ToLower(strings.Join([]string{ + title, + m.Title, + m.Body, + m.ID, + strings.Join(m.Tags, " "), + }, " ")) + return strings.Contains(hay, needle) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..e1aa091 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,32 @@ +package store + +import "testing" + +func TestPutDedupeOrderDeleteClear(t *testing.T) { + s := New() + s.Put(Message{ID: "a", Time: 1, Topic: "alerts", Body: "old"}) + s.Put(Message{ID: "b", Time: 2, Topic: "alerts", Body: "new"}) + s.Put(Message{ID: "b", Time: 3, Topic: "alerts", Body: "dup"}) + s.Put(Message{ID: "c", Time: 2, Topic: "backups", Body: "bak"}) + list := s.List("alerts") + if len(list) != 2 || list[0].ID != "b" || list[1].ID != "a" { + t.Fatalf("%+v", list) + } + if got := s.Topics(); len(got) != 2 || got[0] != "alerts" || got[1] != "backups" { + t.Fatalf("topics %v", got) + } + s.Delete("a") + if len(s.List("alerts")) != 1 { + t.Fatal(s.List("alerts")) + } + s.Clear("alerts") + if len(s.List("alerts")) != 0 || len(s.List("backups")) != 1 { + t.Fatal("clear") + } + if !Match(Message{Body: "Hello Disk"}, "DiskFull", "disk") { + t.Fatal("match") + } + if Match(Message{Body: "Hello"}, "x", "nope") { + t.Fatal("unexpected match") + } +}