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.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user