// Package db is the database domain. It is the only owner of the store // engine: no other domain ever sees the Engine value, a WAL handle or an // index file. Requests and responses travel over fixed duplex channels. package db import ( "errors" "fmt" "sort" "time" "git.smesh.lol/torly/pkg/logx" "git.smesh.lol/torly/pkg/nostr" "git.smesh.lol/torly/pkg/store" tinyspawn "git.smesh.lol/torly/pkg/tinyspawn" "git.smesh.lol/torly/pkg/wire" ) // Data is the domain's startup configuration. type Data struct { Dir string MaxResults int Expiry time.Duration } // Chans are the channels bound at spawn. type Chans struct { Req tinyspawn.Duplex[wire.DBRequest] Resp tinyspawn.Duplex[wire.DBResponse] Ready chan<- wire.Ready Stop tinyspawn.Breaker } // Run is the domain entry point. It opens the store itself, so the Engine never // crosses a domain boundary. func Run(data Data, ch Chans) { eng, err := store.Open(data.Dir, store.Options{MaxResults: data.MaxResults}) if err != nil { ch.Ready <- wire.Ready{Err: err.Error()} return } ch.Ready <- wire.Ready{OK: true} interval := data.Expiry if interval <= 0 { interval = time.Minute } ticker := time.NewTicker(interval) defer ticker.Stop() defer eng.Close() for { select { case req := <-ch.Req.Recv(): start := time.Now() resp := handle(eng, req) if took := time.Since(start); took > time.Second { logx.Errorf("slow db request kind=%d id=%d took=%s events=%d", req.Kind, req.ID, took, len(resp.Events)) } select { case ch.Resp.Tx <- resp: case <-ch.Stop.Done(): return } case <-ticker.C: eng.PurgeExpired(time.Now().Unix()) case <-ch.Stop.Done(): return } } } func handle(eng *store.Engine, req wire.DBRequest) wire.DBResponse { resp := wire.DBResponse{ID: req.ID, Kind: req.Kind} switch req.Kind { case wire.DBSave: ev := req.Event stored, err := eng.SaveEvent(&ev) resp.Code = resultCode(err) if err != nil { resp.Err = err.Error() } resp.Stored = stored case wire.DBQuery: events, err := query(eng, req.Filters, req.Limit) if err != nil { resp.Code = wire.ResError resp.Err = err.Error() break } resp.Events = events case wire.DBCount: n, approx, err := count(eng, req.Filters) if err != nil { resp.Code = wire.ResError resp.Err = err.Error() break } resp.Count = n resp.Approximate = approx case wire.DBGraph: res, err := eng.TraverseGraph(&req.Graph) if err != nil { resp.Code = wire.ResError resp.Err = err.Error() break } resp.Graph = res default: resp.Code = wire.ResError resp.Err = "unknown database request" } return resp } func resultCode(err error) uint8 { switch { case err == nil: return wire.ResOK case errors.Is(err, store.ErrDuplicate): return wire.ResDuplicate case errors.Is(err, store.ErrReplaced): return wire.ResReplaced case errors.Is(err, store.ErrDeleted): return wire.ResDeleted default: return wire.ResError } } // query runs every filter and returns a de-duplicated, newest-first slice. func query(eng *store.Engine, filters []nostr.Filter, limit int) ([]nostr.Event, error) { if len(filters) == 0 { return nil, nil } seen := make(map[[32]byte]bool) var events []nostr.Event for i := range filters { got, err := eng.Query(&filters[i]) if err != nil { return nil, fmt.Errorf("store query: %w", err) } for _, ev := range got { if seen[ev.ID] { continue } seen[ev.ID] = true events = append(events, ev.Clone()) } } sort.SliceStable(events, func(i, j int) bool { if events[i].CreatedAt != events[j].CreatedAt { return events[i].CreatedAt > events[j].CreatedAt } return string(events[i].ID[:]) < string(events[j].ID[:]) }) if limit > 0 && len(events) > limit { events = events[:limit] } return events, nil } func count(eng *store.Engine, filters []nostr.Filter) (int, bool, error) { total := 0 approx := false for i := range filters { n, a, err := eng.Count(&filters[i]) if err != nil { return 0, false, fmt.Errorf("store count: %w", err) } total += n approx = approx || a } if len(filters) > 1 { approx = true } return total, approx, nil }