// Package retention prunes old CHAT messages (kinds 9/10) so the event store // does not grow without bound. Group-management and addressable metadata events // (9007/9000/9001/9021/9022, 39000-39002) are NEVER pruned — they define // membership and must survive to be replayed on boot. package retention import ( "log" "slices" "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore" ) // Config controls what the retention sweeper deletes. type Config struct { // MaxAge: chat messages older than this are deleted. Zero disables age pruning. MaxAge time.Duration // MaxMessages: keep at most this many chat messages (newest kept). Zero // disables count pruning. MaxMessages int // Interval between sweeps. Interval time.Duration } // chatKinds are the only kinds eligible for retention pruning. var chatKinds = []nostr.Kind{ nostr.KindSimpleGroupChatMessage, nostr.KindSimpleGroupThreadedReply, } // Sweep runs a single retention pass and returns the number of events deleted. // `now` is injected so the logic is deterministically testable. func Sweep(store eventstore.Store, cfg Config, now time.Time) int { // Collect all chat events (newest-first from the store). var chats []nostr.Event for evt := range store.QueryEvents(nostr.Filter{Kinds: chatKinds}, 1_000_000) { chats = append(chats, evt) } // Sort newest-first so index >= MaxMessages are the surplus oldest ones. slices.SortFunc(chats, nostr.CompareEventReverse) cutoff := nostr.Timestamp(0) if cfg.MaxAge > 0 { cutoff = nostr.Timestamp(now.Add(-cfg.MaxAge).Unix()) } deleted := 0 for i, evt := range chats { tooOld := cfg.MaxAge > 0 && evt.CreatedAt < cutoff surplus := cfg.MaxMessages > 0 && i >= cfg.MaxMessages if tooOld || surplus { if err := store.DeleteEvent(evt.ID); err == nil { deleted++ } } } return deleted } // Start launches a background sweeper. It is a no-op (returns a no-op stop) when // neither limit is configured. The returned stop function halts it. func Start(store eventstore.Store, cfg Config) (stop func()) { if cfg.MaxAge == 0 && cfg.MaxMessages == 0 { log.Println("retention disabled (no RELAY_RETENTION_* limits set)") return func() {} } if cfg.Interval == 0 { cfg.Interval = time.Hour } done := make(chan struct{}) go func() { // Sweep once at startup, then on the interval. if n := Sweep(store, cfg, time.Now()); n > 0 { log.Printf("retention: pruned %d old chat message(s)", n) } ticker := time.NewTicker(cfg.Interval) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: if n := Sweep(store, cfg, time.Now()); n > 0 { log.Printf("retention: pruned %d old chat message(s)", n) } } } }() log.Printf("retention enabled (maxAge=%s maxMessages=%d interval=%s)", cfg.MaxAge, cfg.MaxMessages, cfg.Interval) return func() { close(done) } }