Report inbox: /api/reports in its own bounded reports.db, dashboard Reports page (list, filter, triage, copy as text)
Build and Push Docker Image / build (push) Failing after 1m20s
Build and Push Docker Image / build (push) Failing after 1m20s
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
// Report inbox (/api/reports): bug reports and feedback filed from the client
|
||||
// apps. An app's browser builds the report, the app's server forwards it here
|
||||
// server-to-server, and the dashboard's Reports page reads it back. Storage
|
||||
// and bounds live in internal/reports.
|
||||
//
|
||||
// Auth: like the rest of /api, gated at the perimeter — do NOT add to the
|
||||
// nginx-hash-lock unauth bypass. Apps reach meta-core on the internal network.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
)
|
||||
|
||||
// maxReportBody bounds one POST. A report is at most a few blocks of 32 KiB
|
||||
// plus a 200-line log tail; anything larger is not a report.
|
||||
const maxReportBody = 512 << 10
|
||||
|
||||
// SetReports attaches the report inbox. Separate from NewServer so its
|
||||
// signature (and its tests) stay untouched; nil leaves the routes answering
|
||||
// 503.
|
||||
func (s *Server) SetReports(r *reports.Store) {
|
||||
s.reports = r
|
||||
}
|
||||
|
||||
func (s *Server) setupReportRoutes() {
|
||||
s.router.HandleFunc("/api/reports", s.handlePostReport).Methods("POST")
|
||||
s.router.HandleFunc("/api/reports", s.handleListReports).Methods("GET")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleGetReport).Methods("GET")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handlePatchReport).Methods("PATCH")
|
||||
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleDeleteReport).Methods("DELETE")
|
||||
}
|
||||
|
||||
func (s *Server) reportsReady(w http.ResponseWriter) bool {
|
||||
if s.reports == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "report inbox not available")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reportID(r *http.Request) int64 {
|
||||
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// handlePostReport handles POST /api/reports → 201 {id}.
|
||||
func (s *Server) handlePostReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
var in map[string]json.RawMessage
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody)).Decode(&in); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "report larger than 512 KiB")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "body must be a JSON object")
|
||||
return
|
||||
}
|
||||
rep, err := reports.Normalize(in, time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := s.reports.Insert(rep)
|
||||
if err != nil {
|
||||
log.Printf("[reports] insert failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not store the report")
|
||||
return
|
||||
}
|
||||
log.Printf("[reports] #%d filed by %s (%s)", id, rep.App, firstLine(rep.Note))
|
||||
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
|
||||
}
|
||||
|
||||
// handleListReports handles GET /api/reports?app=&status=&q=&before=&limit=.
|
||||
func (s *Server) handleListReports(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := reports.Filter{App: q.Get("app"), Status: q.Get("status"), Q: q.Get("q")}
|
||||
f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
|
||||
f.Limit, _ = strconv.Atoi(q.Get("limit"))
|
||||
if f.Status != "" && !reports.ValidStatus(f.Status) {
|
||||
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
|
||||
return
|
||||
}
|
||||
page, err := s.reports.List(f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
// handleGetReport handles GET /api/reports/{id}.
|
||||
func (s *Server) handleGetReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
rep, err := s.reports.Get(reportID(r))
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handlePatchReport handles PATCH /api/reports/{id} {status?, triage_note?}.
|
||||
func (s *Server) handlePatchReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Status *string `json:"status"`
|
||||
TriageNote *string `json:"triage_note"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "body must be a JSON object")
|
||||
return
|
||||
}
|
||||
if in.Status == nil && in.TriageNote == nil {
|
||||
writeError(w, http.StatusBadRequest, "nothing to change: send status and/or triage_note")
|
||||
return
|
||||
}
|
||||
if in.Status != nil && !reports.ValidStatus(*in.Status) {
|
||||
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
|
||||
return
|
||||
}
|
||||
rep, err := s.reports.Update(reportID(r), in.Status, in.TriageNote)
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handleDeleteReport handles DELETE /api/reports/{id}.
|
||||
func (s *Server) handleDeleteReport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.reportsReady(w) {
|
||||
return
|
||||
}
|
||||
err := s.reports.Delete(reportID(r))
|
||||
if errors.Is(err, reports.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such report")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// pruneReports re-applies the inbox bounds hourly. Insert already prunes, so
|
||||
// this only matters when a bound was lowered between restarts.
|
||||
func (s *Server) pruneReports() {
|
||||
run := func() {
|
||||
if n, err := s.reports.Prune(); err != nil {
|
||||
log.Printf("[reports] prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[reports] pruned %d report(s) over the inbox bounds", n)
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if r := []rune(s); len(r) > 80 {
|
||||
s = string(r[:80]) + "…"
|
||||
}
|
||||
if s == "" {
|
||||
return "no note"
|
||||
}
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
)
|
||||
|
||||
func newReportServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store, err := reports.Open(filepath.Join(t.TempDir(), "reports.db"), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
s := &Server{router: mux.NewRouter(), reports: store}
|
||||
s.setupReportRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
func TestReportsRoundTrip(t *testing.T) {
|
||||
s := newReportServer(t)
|
||||
|
||||
rec, out := do(t, s, "POST", "/api/reports", map[string]any{
|
||||
"app": "meta-read", "note": "blank page", "uid": "u1",
|
||||
"reader": map[string]any{"page": 3}, "log": []string{"a", "b"},
|
||||
})
|
||||
if rec.Code != http.StatusCreated || out["id"] == nil {
|
||||
t.Fatalf("post: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := int64(out["id"].(float64))
|
||||
|
||||
rec, out = do(t, s, "GET", "/api/reports", nil)
|
||||
list, _ := out["reports"].([]any)
|
||||
if rec.Code != 200 || len(list) != 1 {
|
||||
t.Fatalf("list: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
first := list[0].(map[string]any)
|
||||
if _, hasBody := first["body"]; hasBody || first["log_lines"].(float64) != 2 {
|
||||
t.Fatalf("summary should carry log_lines, not the body: %v", first)
|
||||
}
|
||||
|
||||
path := "/api/reports/" + itoa(id)
|
||||
rec, out = do(t, s, "GET", path, nil)
|
||||
body, _ := out["body"].(map[string]any)
|
||||
if rec.Code != 200 || body["reader"] == nil || out["uid"] != "u1" {
|
||||
t.Fatalf("get: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
rec, out = do(t, s, "PATCH", path, map[string]any{"status": "triaged", "triage_note": "Deck #1"})
|
||||
if rec.Code != 200 || out["status"] != "triaged" || out["triage_note"] != "Deck #1" {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, _ = do(t, s, "GET", "/api/reports?status=triaged&app=meta-read", nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"triaged":1`) {
|
||||
t.Fatalf("filtered list: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
if rec, _ = do(t, s, "DELETE", path, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
if rec, _ = do(t, s, "GET", path, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("get after delete: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsRejects(t *testing.T) {
|
||||
s := newReportServer(t)
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
want int
|
||||
}{
|
||||
{"no app", "POST", "/api/reports", map[string]any{"note": "x"}, 400},
|
||||
{"bad status filter", "GET", "/api/reports?status=done", nil, 400},
|
||||
{"patch nothing", "PATCH", "/api/reports/1", map[string]any{}, 400},
|
||||
{"patch bad status", "PATCH", "/api/reports/1", map[string]any{"status": "done"}, 400},
|
||||
{"patch missing", "PATCH", "/api/reports/1", map[string]any{"status": "closed"}, 404},
|
||||
{"delete missing", "DELETE", "/api/reports/1", nil, 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if rec, _ := do(t, s, c.method, c.path, c.body); rec.Code != c.want {
|
||||
t.Errorf("%s: got %d want %d (%s)", c.name, rec.Code, c.want, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Not a JSON object.
|
||||
req := httptest.NewRequest("POST", "/api/reports", strings.NewReader(`[1,2]`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("array body: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Over the body cap.
|
||||
big := bytes.Repeat([]byte("x"), maxReportBody+1)
|
||||
req = httptest.NewRequest("POST", "/api/reports", strings.NewReader(`{"app":"a","note":"`+string(big)+`"}`))
|
||||
rec = httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized body: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsUnavailableWithoutStore(t *testing.T) {
|
||||
s := &Server{router: mux.NewRouter()}
|
||||
s.setupReportRoutes()
|
||||
if rec, _ := do(t, s, "GET", "/api/reports", nil); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("no store: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
+11
-1
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/metazla/meta-core/internal/leader"
|
||||
"github.com/metazla/meta-core/internal/meshdisco"
|
||||
"github.com/metazla/meta-core/internal/mounts"
|
||||
"github.com/metazla/meta-core/internal/reports"
|
||||
"github.com/metazla/meta-core/internal/schema"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
"github.com/metazla/meta-core/internal/watcher"
|
||||
@@ -47,6 +48,8 @@ type Server struct {
|
||||
mesh *meshdisco.Node
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
// reports is the bug-report inbox (reports.go); nil = routes answer 503.
|
||||
reports *reports.Store
|
||||
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
||||
// dashboard never puts a multi-second store walk on the request path.
|
||||
stats *statsCache
|
||||
@@ -279,6 +282,10 @@ func (s *Server) setupRoutes() {
|
||||
s.router.HandleFunc("/neighbors", s.handleListNeighbors).Methods("GET")
|
||||
s.router.HandleFunc("/api/neighbors", s.handleListNeighbors).Methods("GET")
|
||||
|
||||
// Report inbox: bug reports / feedback forwarded by the client apps,
|
||||
// read by the dashboard's Reports page. Perimeter-gated (reports.go).
|
||||
s.setupReportRoutes()
|
||||
|
||||
// Mount management routes (if manager initialized)
|
||||
if s.mountsHandlers != nil {
|
||||
s.mountsHandlers.RegisterRoutes(s.router)
|
||||
@@ -363,6 +370,9 @@ func (s *Server) Start() error {
|
||||
}()
|
||||
go s.logWriteStats()
|
||||
}
|
||||
if s.reports != nil {
|
||||
go s.pruneReports()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -468,7 +478,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
|
||||
@@ -23,6 +23,11 @@ type Config struct {
|
||||
DBPathOverride string // META_CORE_DB_PATH; empty = <MetaCorePath>/db/meta-core.db (truth)
|
||||
CachePathOverride string // META_CORE_CACHE_PATH; empty = <MetaCorePath>/db-cache/cache.db
|
||||
|
||||
// Report inbox (internal/reports): its own SQLite file, bounded.
|
||||
ReportsPathOverride string // META_CORE_REPORTS_DB_PATH; empty = <MetaCorePath>/db/reports.db
|
||||
ReportsMax int // META_CORE_REPORTS_MAX: reports kept (default 5000; closed evicted first)
|
||||
ReportsMaxMB int // META_CORE_REPORTS_MAX_MB: total body size kept, MiB (default 256)
|
||||
|
||||
// HTTP API configuration
|
||||
HTTPPort int // HTTP API port (default: 9000)
|
||||
HTTPHost string // HTTP API host (default: "0.0.0.0" — meta-core always runs in a container and must answer siblings)
|
||||
@@ -63,6 +68,9 @@ func Load() *Config {
|
||||
PublicURL: getEnv("META_CORE_PUBLIC_URL", ""),
|
||||
DBPathOverride: getEnv("META_CORE_DB_PATH", ""),
|
||||
CachePathOverride: getEnv("META_CORE_CACHE_PATH", ""),
|
||||
ReportsPathOverride: getEnv("META_CORE_REPORTS_DB_PATH", ""),
|
||||
ReportsMax: getEnvInt("META_CORE_REPORTS_MAX", 5000),
|
||||
ReportsMaxMB: getEnvInt("META_CORE_REPORTS_MAX_MB", 256),
|
||||
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
|
||||
HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"),
|
||||
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
||||
@@ -110,6 +118,16 @@ func (c *Config) CachePath() string {
|
||||
return c.MetaCorePath + "/db-cache/cache.db"
|
||||
}
|
||||
|
||||
// ReportsDBPath returns the path of the report inbox database. It sits beside
|
||||
// the truth database (so it is backed up with it) but is a separate file:
|
||||
// reports are diagnostics, never metadata.
|
||||
func (c *Config) ReportsDBPath() string {
|
||||
if c.ReportsPathOverride != "" {
|
||||
return c.ReportsPathOverride
|
||||
}
|
||||
return c.MetaCorePath + "/db/reports.db"
|
||||
}
|
||||
|
||||
// MountsFilePath returns the path to the mounts configuration file
|
||||
func (c *Config) MountsFilePath() string {
|
||||
return c.MountsDir + "/mounts.json"
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
// Package reports is meta-core's bug-report / feedback inbox.
|
||||
//
|
||||
// Client apps (meta-watch, meta-read, …) build a report in the browser — the
|
||||
// line the person typed, the page they were on, the build, the device, the
|
||||
// tail of the client log, and whatever app-specific state explains the moment
|
||||
// (a player pipeline, a reader position) — and their server forwards it here,
|
||||
// server-to-server, to the box's own core. meta-core stores it, lists it and
|
||||
// shows it on the dashboard's Reports page, so triage reads one inbox instead
|
||||
// of one per app.
|
||||
//
|
||||
// # Why a third SQLite file
|
||||
//
|
||||
// Reports are diagnostics, not metadata. They do not belong in the truth
|
||||
// database (signed claims only) nor in the cache (unsynchronised, never backed
|
||||
// up, deleted as the recovery procedure). They live in their own file,
|
||||
// reports.db, so neither a cache rebuild nor POST /api/metadata/clear touches
|
||||
// them, and a full report table can never slow a record write.
|
||||
//
|
||||
// # What meta-core knows about a report
|
||||
//
|
||||
// Almost nothing, on purpose. A handful of top-level fields are lifted into
|
||||
// columns so the inbox can filter and headline them (app, build, uid, page,
|
||||
// route, cid, title, note); everything else is kept verbatim in body as JSON
|
||||
// and rendered generically by the dashboard. An app can add a new block of
|
||||
// state without a meta-core release.
|
||||
//
|
||||
// ⚠ A report is a piece of viewing/reading history (cids, titles, a uid, a
|
||||
// timestamp). It stays on the box, behind the same perimeter as the rest of
|
||||
// meta-core's API; nothing here is federated.
|
||||
package reports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/metazla/meta-core/internal/kv"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS report(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL, -- when the report was filed (ms)
|
||||
received INTEGER NOT NULL, -- when meta-core stored it (ms)
|
||||
app TEXT NOT NULL,
|
||||
build TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
page TEXT NOT NULL DEFAULT '',
|
||||
route TEXT NOT NULL DEFAULT '',
|
||||
cid TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
triage_note TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
log_lines INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS report_app_status ON report(app, status, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS report_status ON report(status, id DESC);
|
||||
`
|
||||
|
||||
// Statuses a report moves through. "open" is where every report starts.
|
||||
const (
|
||||
StatusOpen = "open"
|
||||
StatusTriaged = "triaged"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
// ValidStatus reports whether s is a status a report can be set to.
|
||||
func ValidStatus(s string) bool {
|
||||
return s == StatusOpen || s == StatusTriaged || s == StatusClosed
|
||||
}
|
||||
|
||||
// Bounds on what one report may carry. The string caps match the ones
|
||||
// meta-watch applied when it kept reports itself, so moving the inbox loses
|
||||
// nothing a report used to hold.
|
||||
const (
|
||||
maxApp = 32
|
||||
maxBuild = 4000
|
||||
maxUID = 128
|
||||
maxPage = 32
|
||||
maxRoute = 512
|
||||
maxCID = 128
|
||||
maxTitle = 200
|
||||
maxNote = 1000
|
||||
maxTriageNote = 2000
|
||||
// MaxLogLines / MaxLogLineChars bound the client log tail.
|
||||
MaxLogLines = 200
|
||||
MaxLogLineChars = 600
|
||||
// MaxBlockBytes bounds every other top-level block (context, device,
|
||||
// app-specific state). A larger block is replaced by a marker naming its
|
||||
// size, never dropped silently.
|
||||
MaxBlockBytes = 32 * 1024
|
||||
// maxBlocks bounds the number of top-level keys.
|
||||
maxBlocks = 64
|
||||
// filed-at plausibility window: a report spooled by an app while the core
|
||||
// was down arrives late but keeps the time it was filed.
|
||||
maxFiledAge = 30 * 24 * time.Hour
|
||||
maxFiledFuture = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ErrNotFound is returned for an id that does not exist.
|
||||
var ErrNotFound = errors.New("report not found")
|
||||
|
||||
// Summary is a report without its body: what the inbox list shows.
|
||||
type Summary struct {
|
||||
ID int64 `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
Received int64 `json:"received"`
|
||||
App string `json:"app"`
|
||||
Build string `json:"build,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TriageNote string `json:"triage_note,omitempty"`
|
||||
Updated int64 `json:"updated,omitempty"`
|
||||
LogLines int `json:"log_lines"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Report is a stored report with its full body.
|
||||
type Report struct {
|
||||
Summary
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// Filter selects reports for List.
|
||||
type Filter struct {
|
||||
App string
|
||||
Status string
|
||||
Q string // substring of note, title or cid
|
||||
Before int64 // only ids below this (paging); 0 = from the newest
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Page is one page of List.
|
||||
type Page struct {
|
||||
Reports []Summary `json:"reports"`
|
||||
Apps map[string]int64 `json:"apps"` // every app with a report, and how many
|
||||
Counts map[string]int64 `json:"counts"` // per status, within the App filter
|
||||
More bool `json:"more"`
|
||||
}
|
||||
|
||||
// Store is the open reports database.
|
||||
type Store struct {
|
||||
db *kv.Store
|
||||
maxRows int
|
||||
maxBytes int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the reports database at path. maxRows and
|
||||
// maxBytes bound what Prune keeps; <= 0 disables that bound.
|
||||
func Open(path string, maxRows int, maxBytes int64) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("reports dir: %w", err)
|
||||
}
|
||||
db, err := kv.Open(path, kv.Options{
|
||||
NoKVTable: true,
|
||||
Schema: schema,
|
||||
WriterCacheMB: 4,
|
||||
ReaderCacheMB: 2,
|
||||
Readers: 2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open reports %s: %w", path, err)
|
||||
}
|
||||
return &Store{db: db, maxRows: maxRows, maxBytes: maxBytes, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Path is the database file.
|
||||
func (s *Store) Path() string { return s.db.Path() }
|
||||
|
||||
func opCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 30*time.Second)
|
||||
}
|
||||
|
||||
// ── Normalisation ─────────────────────────────────────────────────────────
|
||||
|
||||
// Normalize turns a posted envelope into a storable report: the indexed
|
||||
// fields lifted and clamped, the log tail bounded, every other block size-
|
||||
// checked. It never fails on content an app might plausibly send; the only
|
||||
// error is a report that names no app.
|
||||
func Normalize(in map[string]json.RawMessage, now time.Time) (Report, error) {
|
||||
var r Report
|
||||
str := func(key string, n int) string {
|
||||
raw, ok := in[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var v string
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return ""
|
||||
}
|
||||
return clamp(strings.TrimSpace(v), n)
|
||||
}
|
||||
|
||||
r.App = str("app", maxApp)
|
||||
if r.App == "" {
|
||||
return r, errors.New("app is required")
|
||||
}
|
||||
r.Build = str("build", maxBuild)
|
||||
r.UID = str("uid", maxUID)
|
||||
r.Page = str("page", maxPage)
|
||||
r.Route = str("route", maxRoute)
|
||||
r.CID = str("cid", maxCID)
|
||||
r.Title = str("title", maxTitle)
|
||||
r.Note = str("note", maxNote)
|
||||
r.Status = StatusOpen
|
||||
r.Received = now.UnixMilli()
|
||||
r.TS = r.Received
|
||||
if raw, ok := in["filedAt"]; ok {
|
||||
var f float64
|
||||
if json.Unmarshal(raw, &f) == nil {
|
||||
t := time.UnixMilli(int64(f))
|
||||
if t.After(now.Add(-maxFiledAge)) && t.Before(now.Add(maxFiledFuture)) {
|
||||
r.TS = t.UnixMilli()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The body repeats the lifted fields (clamped), so a report exported as
|
||||
// JSON is complete on its own.
|
||||
body := make(map[string]any, len(in))
|
||||
for k, v := range map[string]string{
|
||||
"app": r.App, "build": r.Build, "uid": r.UID, "page": r.Page,
|
||||
"route": r.Route, "cid": r.CID, "title": r.Title, "note": r.Note,
|
||||
} {
|
||||
if v != "" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
if raw, ok := in["log"]; ok {
|
||||
var lines []string
|
||||
if json.Unmarshal(raw, &lines) == nil {
|
||||
if len(lines) > MaxLogLines {
|
||||
lines = lines[len(lines)-MaxLogLines:]
|
||||
}
|
||||
for i, l := range lines {
|
||||
lines[i] = clamp(l, MaxLogLineChars)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
body["log"] = lines
|
||||
r.LogLines = len(lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remaining blocks in key order, so which ones survive maxBlocks does not
|
||||
// depend on map iteration.
|
||||
keys := make([]string, 0, len(in))
|
||||
for k := range in {
|
||||
if k != "log" && k != "filedAt" && !isLifted(k) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
blocks := 0
|
||||
for _, k := range keys {
|
||||
raw := in[k]
|
||||
if string(raw) == "null" {
|
||||
continue
|
||||
}
|
||||
if blocks >= maxBlocks {
|
||||
break
|
||||
}
|
||||
blocks++
|
||||
key := clamp(k, 64)
|
||||
if len(raw) > MaxBlockBytes {
|
||||
body[key] = map[string]any{"truncated": true, "bytes": len(raw)}
|
||||
continue
|
||||
}
|
||||
body[key] = raw
|
||||
}
|
||||
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("encode body: %w", err)
|
||||
}
|
||||
r.Body = b
|
||||
r.Bytes = int64(len(b))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func isLifted(k string) bool {
|
||||
switch k {
|
||||
case "app", "build", "uid", "page", "route", "cid", "title", "note":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clamp keeps at most n characters (runes) of s, never splitting one.
|
||||
func clamp(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
i := 0
|
||||
for j := range s {
|
||||
if i == n {
|
||||
return s[:j]
|
||||
}
|
||||
i++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Insert stores a normalised report and returns its id. The store is pruned
|
||||
// to its bounds in the same transaction, so it never sits over them.
|
||||
func (s *Store) Insert(r Report) (int64, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var id int64
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
res, err := tx.Exec(`INSERT INTO report(ts, received, app, build, uid, page, route, cid, title, note,
|
||||
status, log_lines, bytes, body) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.TS, r.Received, r.App, r.Build, r.UID, r.Page, r.Route, r.CID, r.Title, r.Note,
|
||||
StatusOpen, r.LogLines, r.Bytes, string(r.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err = res.LastInsertId(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.prune(tx)
|
||||
return err
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
// Update sets a report's status and/or triage note. A nil argument leaves
|
||||
// that field as it is.
|
||||
func (s *Store) Update(id int64, status, triageNote *string) (Report, error) {
|
||||
if status != nil && !ValidStatus(*status) {
|
||||
return Report{}, fmt.Errorf("invalid status %q", *status)
|
||||
}
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
sets, args := []string{"updated = ?"}, []any{s.now().UnixMilli()}
|
||||
if status != nil {
|
||||
sets, args = append(sets, "status = ?"), append(args, *status)
|
||||
}
|
||||
if triageNote != nil {
|
||||
sets, args = append(sets, "triage_note = ?"), append(args, clamp(strings.TrimSpace(*triageNote), maxTriageNote))
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE report SET `+strings.Join(sets, ", ")+` WHERE id = ?`, append(args, id)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
// Delete removes a report.
|
||||
func (s *Store) Delete(id int64) error {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
return s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
res, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Prune brings the store back within its bounds and returns how many reports
|
||||
// it removed. Insert already prunes; this is for a bound lowered by config.
|
||||
func (s *Store) Prune() (int, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var n int
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
var err error
|
||||
n, err = s.prune(tx)
|
||||
return err
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
// prune evicts closed reports first, oldest first, then any report, oldest
|
||||
// first, until both the row and the byte bound hold. An open report is the
|
||||
// last thing to go: it is the one nobody has read yet.
|
||||
func (s *Store) prune(tx *kv.Tx) (int, error) {
|
||||
if s.maxRows <= 0 && s.maxBytes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var rows, bytes int64
|
||||
if err := tx.QueryRow(`SELECT count(*), coalesce(sum(bytes), 0) FROM report`).Scan(&rows, &bytes); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
over := func() bool {
|
||||
return (s.maxRows > 0 && rows > int64(s.maxRows)) || (s.maxBytes > 0 && bytes > s.maxBytes)
|
||||
}
|
||||
if !over() {
|
||||
return 0, nil
|
||||
}
|
||||
q, err := tx.Query(`SELECT id, bytes FROM report ORDER BY (status = 'closed') DESC, id ASC`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var victims []int64
|
||||
for q.Next() && over() {
|
||||
var id, b int64
|
||||
if err := q.Scan(&id, &b); err != nil {
|
||||
q.Close()
|
||||
return 0, err
|
||||
}
|
||||
victims = append(victims, id)
|
||||
rows--
|
||||
bytes -= b
|
||||
}
|
||||
q.Close()
|
||||
for _, id := range victims {
|
||||
if _, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(victims), nil
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const summaryCols = `id, ts, received, app, build, uid, page, route, cid, title, note, status, triage_note, updated, log_lines, bytes`
|
||||
|
||||
func scanSummary(sc interface{ Scan(...any) error }, extra ...any) (Summary, error) {
|
||||
var r Summary
|
||||
dst := append([]any{&r.ID, &r.TS, &r.Received, &r.App, &r.Build, &r.UID, &r.Page, &r.Route,
|
||||
&r.CID, &r.Title, &r.Note, &r.Status, &r.TriageNote, &r.Updated, &r.LogLines, &r.Bytes}, extra...)
|
||||
return r, sc.Scan(dst...)
|
||||
}
|
||||
|
||||
// Get returns one report with its body.
|
||||
func (s *Store) Get(id int64) (Report, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var body string
|
||||
row := s.db.DB().QueryRowContext(ctx, `SELECT `+summaryCols+`, body FROM report WHERE id = ?`, id)
|
||||
sum, err := scanSummary(row, &body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Report{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
return Report{Summary: sum, Body: json.RawMessage(body)}, nil
|
||||
}
|
||||
|
||||
// List returns one page of summaries, newest first, with the counts the
|
||||
// inbox's filter bar needs.
|
||||
func (s *Store) List(f Filter) (Page, error) {
|
||||
if f.Limit <= 0 || f.Limit > 200 {
|
||||
f.Limit = 50
|
||||
}
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
db := s.db.DB()
|
||||
|
||||
var where []string
|
||||
var args []any
|
||||
if f.App != "" {
|
||||
where, args = append(where, "app = ?"), append(args, f.App)
|
||||
}
|
||||
appWhere, appArgs := append([]string(nil), where...), append([]any(nil), args...)
|
||||
if f.Status != "" {
|
||||
where, args = append(where, "status = ?"), append(args, f.Status)
|
||||
}
|
||||
if q := strings.TrimSpace(f.Q); q != "" {
|
||||
like := "%" + escapeLike(q) + "%"
|
||||
where = append(where, `(note LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\' OR cid LIKE ? ESCAPE '\' OR uid LIKE ? ESCAPE '\')`)
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if f.Before > 0 {
|
||||
where, args = append(where, "id < ?"), append(args, f.Before)
|
||||
}
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
page := Page{Reports: []Summary{}, Apps: map[string]int64{}, Counts: map[string]int64{}}
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+summaryCols+` FROM report`+clause+` ORDER BY id DESC LIMIT ?`,
|
||||
append(args, f.Limit+1)...)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
for rows.Next() {
|
||||
sum, err := scanSummary(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return page, err
|
||||
}
|
||||
page.Reports = append(page.Reports, sum)
|
||||
}
|
||||
rows.Close()
|
||||
if len(page.Reports) > f.Limit {
|
||||
page.Reports, page.More = page.Reports[:f.Limit], true
|
||||
}
|
||||
|
||||
if err := groupCount(ctx, db, `SELECT app, count(*) FROM report GROUP BY app`, nil, page.Apps); err != nil {
|
||||
return page, err
|
||||
}
|
||||
statusQ := `SELECT status, count(*) FROM report`
|
||||
if len(appWhere) > 0 {
|
||||
statusQ += " WHERE " + strings.Join(appWhere, " AND ")
|
||||
}
|
||||
if err := groupCount(ctx, db, statusQ+` GROUP BY status`, appArgs, page.Counts); err != nil {
|
||||
return page, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func groupCount(ctx context.Context, db *sql.DB, q string, args []any, into map[string]int64) error {
|
||||
rows, err := db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int64
|
||||
if err := rows.Scan(&k, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
into[k] = n
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package reports
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func open(t *testing.T, maxRows int, maxBytes int64) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "db", "reports.db"), maxRows, maxBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func envelope(t *testing.T, v map[string]any) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(v)
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func file(t *testing.T, s *Store, v map[string]any) int64 {
|
||||
t.Helper()
|
||||
r, err := Normalize(envelope(t, v), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := s.Insert(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestNormalizeRequiresApp(t *testing.T) {
|
||||
if _, err := Normalize(envelope(t, map[string]any{"note": "x"}), time.Now()); err == nil {
|
||||
t.Fatal("a report without app must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClampsAndBounds(t *testing.T) {
|
||||
log := make([]string, 250)
|
||||
for i := range log {
|
||||
log[i] = strings.Repeat("é", 700)
|
||||
}
|
||||
now := time.Now()
|
||||
r, err := Normalize(envelope(t, map[string]any{
|
||||
"app": "meta-read",
|
||||
"note": strings.Repeat("n", 1500),
|
||||
"title": strings.Repeat("t", 300),
|
||||
"log": log,
|
||||
"context": map[string]string{"big": strings.Repeat("x", MaxBlockBytes)},
|
||||
"reader": map[string]int{"page": 4},
|
||||
"device": nil,
|
||||
"filedAt": now.Add(-time.Hour).UnixMilli(),
|
||||
}), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Note) != maxNote || len(r.Title) != maxTitle {
|
||||
t.Fatalf("note/title not clamped: %d %d", len(r.Note), len(r.Title))
|
||||
}
|
||||
if r.TS != now.Add(-time.Hour).UnixMilli() || r.Received != now.UnixMilli() {
|
||||
t.Fatalf("filedAt should set ts, received stays now: %d %d", r.TS, r.Received)
|
||||
}
|
||||
var body map[string]json.RawMessage
|
||||
_ = json.Unmarshal(r.Body, &body)
|
||||
var lines []string
|
||||
_ = json.Unmarshal(body["log"], &lines)
|
||||
if len(lines) != MaxLogLines || r.LogLines != MaxLogLines {
|
||||
t.Fatalf("log tail: %d lines (col %d)", len(lines), r.LogLines)
|
||||
}
|
||||
if n := len([]rune(lines[0])); n != MaxLogLineChars {
|
||||
t.Fatalf("log line clamped by rune: %d", n)
|
||||
}
|
||||
if !strings.Contains(string(body["context"]), `"truncated":true`) {
|
||||
t.Fatalf("oversized block not replaced by a marker: %.80s", body["context"])
|
||||
}
|
||||
if string(body["reader"]) != `{"page":4}` {
|
||||
t.Fatalf("app block not kept verbatim: %s", body["reader"])
|
||||
}
|
||||
if _, ok := body["device"]; ok {
|
||||
t.Fatal("null block should be dropped")
|
||||
}
|
||||
if _, ok := body["filedAt"]; ok {
|
||||
t.Fatal("filedAt belongs in ts, not the body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIgnoresImplausibleFiledAt(t *testing.T) {
|
||||
now := time.Now()
|
||||
for _, at := range []time.Time{now.Add(-60 * 24 * time.Hour), now.Add(time.Hour)} {
|
||||
r, _ := Normalize(envelope(t, map[string]any{"app": "a", "filedAt": at.UnixMilli()}), now)
|
||||
if r.TS != now.UnixMilli() {
|
||||
t.Fatalf("filedAt %v should be ignored", at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertGetListFilters(t *testing.T) {
|
||||
s := open(t, 0, 0)
|
||||
a := file(t, s, map[string]any{"app": "meta-watch", "note": "stall at 3%", "cid": "bafyA"})
|
||||
b := file(t, s, map[string]any{"app": "meta-read", "note": "blank page", "uid": "u1"})
|
||||
c := file(t, s, map[string]any{"app": "meta-read", "note": "wrong cover 100%"})
|
||||
|
||||
got, err := s.Get(a)
|
||||
if err != nil || got.App != "meta-watch" || got.CID != "bafyA" || got.Status != StatusOpen {
|
||||
t.Fatalf("get: %+v %v", got.Summary, err)
|
||||
}
|
||||
if _, err := s.Get(999); err != ErrNotFound {
|
||||
t.Fatalf("missing id: %v", err)
|
||||
}
|
||||
|
||||
p, err := s.List(Filter{})
|
||||
if err != nil || len(p.Reports) != 3 || p.Reports[0].ID != c {
|
||||
t.Fatalf("list newest first: %+v %v", p.Reports, err)
|
||||
}
|
||||
if p.Apps["meta-read"] != 2 || p.Apps["meta-watch"] != 1 || p.Counts[StatusOpen] != 3 {
|
||||
t.Fatalf("counts: %+v %+v", p.Apps, p.Counts)
|
||||
}
|
||||
|
||||
p, _ = s.List(Filter{App: "meta-read"})
|
||||
if len(p.Reports) != 2 || p.Counts[StatusOpen] != 2 || p.Apps["meta-watch"] != 1 {
|
||||
t.Fatalf("app filter: %+v counts %+v apps %+v", p.Reports, p.Counts, p.Apps)
|
||||
}
|
||||
p, _ = s.List(Filter{Q: "100%"})
|
||||
if len(p.Reports) != 1 || p.Reports[0].ID != c {
|
||||
t.Fatalf("q is a literal substring (%% escaped): %+v", p.Reports)
|
||||
}
|
||||
p, _ = s.List(Filter{Q: "u1"})
|
||||
if len(p.Reports) != 1 || p.Reports[0].ID != b {
|
||||
t.Fatalf("q matches uid: %+v", p.Reports)
|
||||
}
|
||||
|
||||
p, _ = s.List(Filter{Limit: 2})
|
||||
if len(p.Reports) != 2 || !p.More {
|
||||
t.Fatalf("page 1: %d more=%v", len(p.Reports), p.More)
|
||||
}
|
||||
p, _ = s.List(Filter{Limit: 2, Before: p.Reports[1].ID})
|
||||
if len(p.Reports) != 1 || p.More || p.Reports[0].ID != a {
|
||||
t.Fatalf("page 2: %+v more=%v", p.Reports, p.More)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAndDelete(t *testing.T) {
|
||||
s := open(t, 0, 0)
|
||||
id := file(t, s, map[string]any{"app": "a"})
|
||||
st, note := StatusTriaged, " Deck #900 "
|
||||
r, err := s.Update(id, &st, ¬e)
|
||||
if err != nil || r.Status != StatusTriaged || r.TriageNote != "Deck #900" || r.Updated == 0 {
|
||||
t.Fatalf("update: %+v %v", r.Summary, err)
|
||||
}
|
||||
bad := "done"
|
||||
if _, err := s.Update(id, &bad, nil); err == nil {
|
||||
t.Fatal("invalid status accepted")
|
||||
}
|
||||
if _, err := s.Update(42, &st, nil); err != ErrNotFound {
|
||||
t.Fatalf("update missing: %v", err)
|
||||
}
|
||||
p, _ := s.List(Filter{Status: StatusTriaged})
|
||||
if len(p.Reports) != 1 {
|
||||
t.Fatalf("status filter: %+v", p.Reports)
|
||||
}
|
||||
if err := s.Delete(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(id); err != ErrNotFound {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEvictsClosedThenOldest(t *testing.T) {
|
||||
s := open(t, 3, 0)
|
||||
ids := []int64{
|
||||
file(t, s, map[string]any{"app": "a", "note": "1"}),
|
||||
file(t, s, map[string]any{"app": "a", "note": "2"}),
|
||||
file(t, s, map[string]any{"app": "a", "note": "3"}),
|
||||
}
|
||||
closed := StatusClosed
|
||||
if _, err := s.Update(ids[2], &closed, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fourth report: the closed one (newest of the three) goes first.
|
||||
file(t, s, map[string]any{"app": "a", "note": "4"})
|
||||
if _, err := s.Get(ids[2]); err != ErrNotFound {
|
||||
t.Fatal("closed report should be evicted before older open ones")
|
||||
}
|
||||
// Fifth: no closed left, so the oldest open goes.
|
||||
file(t, s, map[string]any{"app": "a", "note": "5"})
|
||||
if _, err := s.Get(ids[0]); err != ErrNotFound {
|
||||
t.Fatal("oldest open report should be evicted next")
|
||||
}
|
||||
p, _ := s.List(Filter{})
|
||||
if len(p.Reports) != 3 {
|
||||
t.Fatalf("store over its row bound: %d", len(p.Reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneByBytes(t *testing.T) {
|
||||
s := open(t, 0, 3000)
|
||||
for i := 0; i < 5; i++ {
|
||||
file(t, s, map[string]any{"app": "a", "note": strings.Repeat("x", 900)})
|
||||
}
|
||||
p, _ := s.List(Filter{})
|
||||
var total int64
|
||||
for _, r := range p.Reports {
|
||||
total += r.Bytes
|
||||
}
|
||||
if total > 3000 || len(p.Reports) == 0 {
|
||||
t.Fatalf("byte bound not held: %d bytes in %d reports", total, len(p.Reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampKeepsRunes(t *testing.T) {
|
||||
if got := clamp("héllo", 2); got != "hé" {
|
||||
t.Fatalf("clamp: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user