feat(demo): reset the demo instance every 20 minutes with a countdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s

DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the
sample data. Demo accounts get fixed IDs so sessions survive; requests
hold a read lock so the reset never runs under a half-finished request
(streams excluded). DEMO_RESET goes to every connection, /auth/status
announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in
place of the version, then clears cached answers, remounts the views,
reloads in the background and shows a short notice; if no event
arrives (long polling) it asks the server after the countdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:22:06 +01:00
1 parent afd3b0347c
commit 6b9eb62caf
12 files changed
+348 -9

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
package api
import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
// In demo mode the database is reset to the sample data every DEMO_RESET_MINUTES. Requests hold a read
// lock while they run and the reset takes the write lock, so no request sees half-dropped tables.
// Long-lived streams (WebSocket, SSE, long polling) are left out, otherwise the reset would wait for
// them forever; they reconnect or retry on their own. The demo accounts keep their IDs, so sessions
// stay valid; clients get DEMO_RESET and reload their data.
type demoState struct {
gate sync.RWMutex // read: a running request, write: the reset
mu sync.Mutex
nextReset time.Time
}
func isLongLivedRequest(r *http.Request) bool {
p := r.URL.Path
return strings.HasPrefix(p, "/api/v1/ws") || p == "/api/v1/events" ||
(strings.HasPrefix(p, "/api/v1/docs/") && strings.HasSuffix(p, "/changes"))
}
func (s *Server) demoResetGate(next http.Handler) http.Handler {
if !s.cfg.DemoMode {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLongLivedRequest(r) || !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
s.demo.gate.RLock()
defer s.demo.gate.RUnlock()
next.ServeHTTP(w, r)
})
}
// demoNextReset is zero when no periodic reset runs.
func (s *Server) demoNextReset() time.Time {
s.demo.mu.Lock()
defer s.demo.mu.Unlock()
return s.demo.nextReset
}
func (s *Server) setDemoNextReset(t time.Time) {
s.demo.mu.Lock()
s.demo.nextReset = t
s.demo.mu.Unlock()
}
func (s *Server) resetDemo() {
s.demo.gate.Lock()
err := s.store.ResetAndSeedDemo()
s.demo.gate.Unlock()
if err != nil {
slog.Error("Demo-Reset fehlgeschlagen", "error", err)
return
}
s.wsHub.BroadcastSystem("DEMO_RESET")
}
func (s *Server) startDemoResetTicker() {
interval := time.Duration(s.cfg.DemoResetMinutes) * time.Minute
for {
next := time.Now().Add(interval)
s.setDemoNextReset(next)
time.Sleep(time.Until(next))
s.resetDemo()
}
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_reset.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.ResetAndSeedDemo(); err != nil {
t.Fatal(err)
}
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
router := srv.Router()
call := func(method, path, token, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
var login struct{ Token string }
_ = json.Unmarshal(call("POST", "/api/v1/auth/login", "", `{"login":"admin","password":"admin"}`).Body.Bytes(), &login)
countDocs := func() int {
var docs []json.RawMessage
_ = json.Unmarshal(call("GET", "/api/v1/documents", login.Token, "").Body.Bytes(), &docs)
return len(docs)
}
seeded := countDocs()
if seeded == 0 {
t.Fatal("expected sample notes")
}
if rec := call("POST", "/api/v1/documents", login.Token, `{"title":"Testnotiz"}`); rec.Code >= 300 {
t.Fatalf("create note: %d %s", rec.Code, rec.Body.String())
}
// Requests running during the reset must neither fail half-way nor see missing tables
var wg sync.WaitGroup
failures := make(chan int, 50)
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if rec := call("GET", "/api/v1/documents", login.Token, ""); rec.Code != http.StatusOK {
failures <- rec.Code
}
}()
}
srv.resetDemo()
wg.Wait()
close(failures)
for code := range failures {
t.Errorf("request during reset failed with %d", code)
}
if rec := call("GET", "/api/v1/auth/me", login.Token, ""); rec.Code != http.StatusOK {
t.Fatalf("session must survive the reset, got %d", rec.Code)
}
if n := countDocs(); n != seeded {
t.Errorf("reset should restore the %d sample notes, got %d", seeded, n)
}
srv.setDemoNextReset(time.Now().Add(90 * time.Second))
var st struct {
DemoNextReset string `json:"demoNextReset"`
DemoResetInSeconds int `json:"demoResetInSeconds"`
}
_ = json.Unmarshal(call("GET", "/api/v1/auth/status", "", "").Body.Bytes(), &st)
if st.DemoNextReset == "" || st.DemoResetInSeconds < 85 || st.DemoResetInSeconds > 90 {
t.Errorf("status should announce the next reset: %+v", st)
}
}
+9 -2
View File
@@ -37,7 +37,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
instanceName = sys.InstanceName
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
status := map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
@@ -51,7 +51,14 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"privacyUrl": s.cfg.Legal.PrivacyURL,
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
},
})
}
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
if next := s.demoNextReset(); !next.IsZero() {
status["demoNextReset"] = next.UTC().Format(time.RFC3339)
status["demoResetInSeconds"] = int(time.Until(next).Seconds())
status["demoResetMinutes"] = s.cfg.DemoResetMinutes
}
writeJSON(w, http.StatusOK, status)
}
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
+6
View File
@@ -28,6 +28,8 @@ type Server struct {
leases *collab.LeaseManager // block leases and open sessions of notes
notifier *collab.Notifier // wakes long-poll requests on note changes
demo demoState // periodic reset in demo mode (demo.go)
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -46,6 +48,9 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
}
go srv.startAlarmTicker()
go srv.startCompaction()
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
go srv.startDemoResetTicker()
}
return srv
}
@@ -70,6 +75,7 @@ func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
r.Use(cors.Handler(cors.Options{