fix(sqlite,browser): resolve SQLite connection pool deadlock, disable stale SW cache, and update BRAIN.md
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s
This commit is contained in:
1 parent
d6bc2976d9
commit
5c4e130862
8 files changed
+414
-231
No files matched your search
@@ -112,8 +112,19 @@ func (s *Server) Router() http.Handler {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(s.staticFS, path); err != nil {
|
||||
// Never return index.html for missing JS/CSS assets
|
||||
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
// SPA client-side routing fallback to index.html
|
||||
req.URL.Path = "/"
|
||||
path = "index.html"
|
||||
}
|
||||
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
fileServer.ServeHTTP(w, req)
|
||||
})
|
||||
@@ -126,6 +137,7 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
"github.com/sandrodach/mindsift/internal/ws"
|
||||
)
|
||||
|
||||
func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
user, err := store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to ensure default user: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DBPath: dbPath,
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: true,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
return srv, store, token
|
||||
}
|
||||
|
||||
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/triage/deck",
|
||||
"/api/v1/lists",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/documents",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 3; i++ {
|
||||
for _, ep := range endpoints {
|
||||
wg.Add(1)
|
||||
go func(path string) {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}(ep)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// Generate a stale token from a previous container run (non-existent user ID)
|
||||
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate stale token: %v", err)
|
||||
}
|
||||
|
||||
// 1. Capture Quick Dump (default target: inbox) using stale token
|
||||
dumpPayload := map[string]string{
|
||||
"content": "Schneller Quick Dump Testgedanke #test",
|
||||
"priority": "high",
|
||||
"target": "inbox",
|
||||
}
|
||||
body, _ := json.Marshal(dumpPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var createdDump domain.Thought
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil {
|
||||
t.Fatalf("failed to decode created dump: %v", err)
|
||||
}
|
||||
if createdDump.Status != domain.StatusInbox {
|
||||
t.Fatalf("expected status 'inbox', got %q", createdDump.Status)
|
||||
}
|
||||
|
||||
// 2. Capture Direct Task (target: task)
|
||||
taskPayload := map[string]string{
|
||||
"content": "Direkte Aufgabe ohne Triage",
|
||||
"priority": "urgent",
|
||||
"target": "task",
|
||||
"dueDate": "2026-11-01",
|
||||
}
|
||||
body, _ = json.Marshal(taskPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdTask domain.Thought
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdTask)
|
||||
if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" {
|
||||
t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType)
|
||||
}
|
||||
|
||||
// 3. Capture Direct List Item (target: list_item)
|
||||
defUser, _ := store.EnsureDefaultUser()
|
||||
lists, err := store.GetAllListsForUser(defUser.ID)
|
||||
if err != nil || len(lists) == 0 {
|
||||
t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err)
|
||||
}
|
||||
targetListID := lists[0].ID
|
||||
|
||||
listPayload := map[string]string{
|
||||
"content": "Frische Haferflocken",
|
||||
"target": "list_item",
|
||||
"targetListId": targetListID,
|
||||
}
|
||||
body, _ = json.Marshal(listPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdListItemThought domain.Thought
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought)
|
||||
if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" {
|
||||
t.Fatalf("expected converted list_item, got %+v", createdListItemThought)
|
||||
}
|
||||
|
||||
// 4. Swipe Triage card to Task
|
||||
triagePayload := map[string]string{
|
||||
"action": "keep",
|
||||
"convertTo": "task",
|
||||
}
|
||||
body, _ = json.Marshal(triagePayload)
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
+38
-17
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
@@ -15,22 +16,30 @@ import (
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
initMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
|
||||
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
dsn := dbPath
|
||||
if !strings.Contains(dsn, "?") {
|
||||
dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1) // SQLite WAL optimal writer serialization in single process
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
|
||||
store := &Store{db: db}
|
||||
if err := store.migrate(); err != nil {
|
||||
@@ -266,6 +275,9 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
|
||||
// EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in
|
||||
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
s.initMu.Lock()
|
||||
defer s.initMu.Unlock()
|
||||
|
||||
count, err := s.CountUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -280,7 +292,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
}
|
||||
user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return s.GetFirstUser()
|
||||
}
|
||||
_ = s.SeedStarterContent(user)
|
||||
return user, nil
|
||||
@@ -872,7 +884,6 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
spaces := make([]domain.Space, 0)
|
||||
for rows.Next() {
|
||||
@@ -880,11 +891,15 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||||
var personalInt int
|
||||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||||
sp.IsPersonal = personalInt != 0
|
||||
sp.Members, _ = s.GetSpaceMembers(sp.ID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, sp.ID).Scan(&sp.ListsCount)
|
||||
spaces = append(spaces, sp)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range spaces {
|
||||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
@@ -929,16 +944,19 @@ func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
l.Items, _ = s.GetListItems(l.ID)
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
@@ -954,16 +972,19 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
l.Items, _ = s.GetListItems(l.ID)
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
@@ -1183,12 +1204,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
||||
)
|
||||
thoughts := make([]domain.Thought, 0)
|
||||
if err == nil {
|
||||
defer tRows.Close()
|
||||
for tRows.Next() {
|
||||
if t, err := scanThought(tRows); err == nil {
|
||||
thoughts = append(thoughts, *t)
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
}
|
||||
|
||||
dRows, err := s.db.Query(
|
||||
@@ -1198,12 +1219,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
||||
)
|
||||
docs := make([]domain.Document, 0)
|
||||
if err == nil {
|
||||
defer dRows.Close()
|
||||
for dRows.Next() {
|
||||
if d, err := scanDocument(dRows); err == nil {
|
||||
docs = append(docs, *d)
|
||||
}
|
||||
}
|
||||
_ = dRows.Close()
|
||||
}
|
||||
|
||||
return thoughts, docs, nil
|
||||
|
||||
Reference in new issue
Block a user