From dab6d6c9c9be748dec419f29cfe37a2ccc8d5319 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Apr 2026 14:56:56 +0000 Subject: [PATCH 01/10] feat: implement graceful shutdown with request draining (TASK-159) - Add signal handler for SIGINT/SIGTERM with 30s grace period - Add Server.Shutdown() for graceful HTTP connection draining - Add EventBus.Close() to cleanly terminate SSE subscribers - Configure HTTP server timeouts (read: 15s, header: 5s, idle: 120s) - Fix SetWebUI nil router panic by calling ensureRouter() - Add Server.Handler() for httptest compatibility --- cmd/pad/main.go | 40 +++++++++++++++++++++++++++++++++++++-- internal/events/bus.go | 12 ++++++++++++ internal/server/server.go | 33 +++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/cmd/pad/main.go b/cmd/pad/main.go index e2dc0cd9..4103e323 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -195,7 +195,8 @@ func serveCmd() *cobra.Command { srv.SetSecureCookies(cfg.SecureCookies) // Attach event bus for real-time SSE - srv.SetEventBus(events.New()) + eventBus := events.New() + srv.SetEventBus(eventBus) // Attach webhook dispatcher for outgoing notifications srv.SetWebhookDispatcher(webhooks.NewDispatcher(s)) @@ -225,7 +226,42 @@ func serveCmd() *cobra.Command { } } - return srv.ListenAndServe(cfg.Addr()) + // Graceful shutdown: listen for SIGINT/SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Start server in a goroutine + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe(cfg.Addr()) + }() + + // Wait for signal or server error + select { + case err := <-errCh: + // Server failed to start or crashed + return err + case <-ctx.Done(): + // Received shutdown signal + log.Println("Shutting down server (30s grace period)...") + stop() // Reset signal handling so a second signal force-kills + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + // Close event bus (terminates SSE connections) + if eventBus != nil { + eventBus.Close() + log.Println("Event bus closed") + } + + log.Println("Server stopped") + return nil + } }, } diff --git a/internal/events/bus.go b/internal/events/bus.go index 8660b5c3..9f6b322c 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -115,6 +115,18 @@ func (b *Bus) Publish(event Event) { } } +// Close shuts down the event bus by closing all subscriber channels. +// SSE handler goroutines will see the channel close and exit cleanly. +func (b *Bus) Close() { + b.mu.Lock() + defer b.mu.Unlock() + + for ch := range b.subscribers { + delete(b.subscribers, ch) + close(ch) + } +} + // SubscriberCount returns the number of active subscribers (for testing/debugging). func (b *Bus) SubscriberCount() int { b.mu.RLock() diff --git a/internal/server/server.go b/internal/server/server.go index 71cbb503..f6d86978 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "fmt" "io/fs" @@ -8,6 +9,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -24,6 +26,7 @@ type Server struct { store *store.Store router *chi.Mux routerOnce sync.Once // ensures setupRouter runs once, after all config + httpServer *http.Server // underlying HTTP server (set during ListenAndServe) webFS fs.FS // embedded web UI static files (optional) events *events.Bus // real-time event bus (optional) webhooks *webhooks.Dispatcher // webhook dispatcher (optional) @@ -327,6 +330,7 @@ func (s *Server) setupRouter() { // SetWebUI sets the embedded web UI filesystem for serving the SPA. func (s *Server) SetWebUI(fsys fs.FS) { s.webFS = fsys + s.ensureRouter() s.router.Handle("/*", s.spaHandler()) } @@ -376,8 +380,35 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ListenAndServe(addr string) error { s.ensureRouter() + + s.httpServer = &http.Server{ + Addr: addr, + Handler: s.router, + ReadTimeout: 15 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 120 * time.Second, + // WriteTimeout left at 0 — SSE connections are long-lived. + // Non-SSE handlers should use per-request context deadlines. + } + log.Printf("Pad server listening on %s", addr) - return http.ListenAndServe(addr, s.router) + return s.httpServer.ListenAndServe() +} + +// Shutdown gracefully drains in-flight requests and stops the HTTP server. +// The provided context controls how long to wait for active connections. +func (s *Server) Shutdown(ctx context.Context) error { + if s.httpServer == nil { + return nil + } + return s.httpServer.Shutdown(ctx) +} + +// Handler returns the configured HTTP handler (router). +// Useful for testing with httptest.NewServer. +func (s *Server) Handler() http.Handler { + s.ensureRouter() + return s.router } // --- helpers --- From e7f4448028ea5f64e022d51f2fc9c63d4b6bbd73 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Apr 2026 15:02:54 +0000 Subject: [PATCH 02/10] feat: add readiness probe and structured logging (TASK-161) - Add /health/live (liveness) and /health/ready (readiness with DB check) endpoints - Add Store.Ping() for database connectivity verification - Create internal/logging package using stdlib log/slog - Support PAD_LOG_LEVEL (debug/info/warn/error) and PAD_LOG_FORMAT (text/json) env vars - Add structured request logging middleware replacing chi's default Logger - Migrate all log.Printf calls to slog with proper levels and key-value attrs - Exempt health probe endpoints from auth middleware --- cmd/pad/main.go | 32 +++++++++----- internal/events/bus.go | 4 +- internal/logging/logging.go | 59 ++++++++++++++++++++++++++ internal/server/handlers_auth.go | 10 ++--- internal/server/handlers_events.go | 4 +- internal/server/handlers_items.go | 4 +- internal/server/handlers_members.go | 4 +- internal/server/handlers_workspaces.go | 20 +++++++++ internal/server/middleware_auth.go | 2 +- internal/server/middleware_logging.go | 48 +++++++++++++++++++++ internal/server/server.go | 14 +++--- internal/store/store.go | 5 +++ internal/webhooks/dispatcher.go | 14 +++--- 13 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 internal/logging/logging.go create mode 100644 internal/server/middleware_logging.go diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 4103e323..a1483213 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -7,7 +7,7 @@ import ( "fmt" "io" "io/fs" - "log" + "log/slog" "net/http" "net/url" "os" @@ -31,6 +31,7 @@ import ( "github.com/xarmian/pad/internal/email" "github.com/xarmian/pad/internal/events" + "github.com/xarmian/pad/internal/logging" "github.com/xarmian/pad/internal/models" "github.com/xarmian/pad/internal/server" "github.com/xarmian/pad/internal/store" @@ -162,6 +163,17 @@ func serveCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cfg := getConfig() + // Initialize structured logging + logLevel := os.Getenv("PAD_LOG_LEVEL") + if logLevel == "" { + logLevel = "info" + } + logFormat := os.Getenv("PAD_LOG_FORMAT") + if logFormat == "" { + logFormat = "text" + } + logging.Setup(logLevel, logFormat) + if cmd.Flags().Changed("host") { cfg.Host = host } @@ -178,14 +190,14 @@ func serveCmd() *cobra.Command { // Auto-upgrade: ensure all default collections exist in every workspace. // This is safe because SeedDefaultCollections skips collections that already exist. if workspaces, err := s.ListWorkspaces(); err == nil { - log.Printf("Auto-upgrade: checking %d workspace(s) for missing default collections", len(workspaces)) + slog.Info("auto-upgrade: checking workspaces for missing default collections", "count", len(workspaces)) for _, ws := range workspaces { if err := s.SeedDefaultCollections(ws.ID); err != nil { - log.Printf("Warning: failed to seed defaults for workspace %s: %v", ws.Slug, err) + slog.Warn("failed to seed defaults for workspace", "workspace", ws.Slug, "error", err) } } } else { - log.Printf("Warning: failed to list workspaces for auto-upgrade: %v", err) + slog.Warn("failed to list workspaces for auto-upgrade", "error", err) } srv := server.New(s) @@ -212,7 +224,7 @@ func serveCmd() *cobra.Command { fromName = "Pad" } srv.SetEmailSender(email.NewSender(cfg.MailerooAPIKey, fromAddr, fromName, cfg.BaseURL())) - log.Println("Email sending enabled via Maileroo (env)") + slog.Info("Email sending enabled via Maileroo (env)") } // Platform settings can override or provide email config srv.InitEmailFromSettings() @@ -222,7 +234,7 @@ func serveCmd() *cobra.Command { if err == nil { if entries, err := fs.ReadDir(webFS, "."); err == nil && len(entries) > 0 { srv.SetWebUI(webFS) - log.Println("Serving embedded web UI") + slog.Info("Serving embedded web UI") } } @@ -243,23 +255,23 @@ func serveCmd() *cobra.Command { return err case <-ctx.Done(): // Received shutdown signal - log.Println("Shutting down server (30s grace period)...") + slog.Info("Shutting down server (30s grace period)...") stop() // Reset signal handling so a second signal force-kills shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("HTTP server shutdown error: %v", err) + slog.Error("HTTP server shutdown error", "error", err) } // Close event bus (terminates SSE connections) if eventBus != nil { eventBus.Close() - log.Println("Event bus closed") + slog.Info("Event bus closed") } - log.Println("Server stopped") + slog.Info("Server stopped") return nil } }, diff --git a/internal/events/bus.go b/internal/events/bus.go index 9f6b322c..9ebe41a0 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -1,7 +1,7 @@ package events import ( - "log" + "log/slog" "sync" "time" ) @@ -110,7 +110,7 @@ func (b *Bus) Publish(event Event) { select { case sub.ch <- event: default: - log.Printf("events: dropping event %s for slow subscriber (workspace=%s)", event.Type, event.WorkspaceID) + slog.Warn("dropping event for slow subscriber", "type", event.Type, "workspace", event.WorkspaceID) } } } diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 00000000..50cf6882 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,59 @@ +// Package logging provides structured logging using log/slog. +// +// Usage: +// +// logging.Setup("info", "json") // call once at startup +// slog.Info("something happened", "key", value) +// +// All application code should use the slog package directly after Setup has +// been called — it configures the default slog logger. +package logging + +import ( + "io" + "log/slog" + "os" + "strings" +) + +// Setup configures the default slog logger. +// +// - level: "debug", "info", "warn", "error" (default "info") +// - format: "json" or "text" (default "text") +// +// After calling Setup, use slog.Info / slog.Error / etc. everywhere. +func Setup(level, format string) { + SetupWriter(os.Stderr, level, format) +} + +// SetupWriter is like Setup but writes to w instead of stderr (useful for tests). +func SetupWriter(w io.Writer, level, format string) { + lvl := parseLevel(level) + + opts := &slog.HandlerOptions{ + Level: lvl, + } + + var handler slog.Handler + switch strings.ToLower(format) { + case "json": + handler = slog.NewJSONHandler(w, opts) + default: + handler = slog.NewTextHandler(w, opts) + } + + slog.SetDefault(slog.New(handler)) +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index ba920b82..f7ea280d 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -2,7 +2,7 @@ package server import ( "context" - "log" + "log/slog" "net" "net/http" "regexp" @@ -537,7 +537,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { // Generate reset token token, err := s.store.CreatePasswordReset(user.ID) if err != nil { - log.Printf("Failed to create password reset: %v", err) + slog.Error("failed to create password reset", "error", err) writeJSON(w, http.StatusOK, okResponse) return } @@ -547,11 +547,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { resetURL := s.baseURL + "/reset-password/" + token go func() { if err := s.email.SendPasswordReset(context.Background(), user.Email, user.Name, resetURL); err != nil { - log.Printf("Failed to send password reset email: %v", err) + slog.Error("failed to send password reset email", "error", err) } }() } else { - log.Printf("Password reset token generated (email not configured). Use pad auth reset-password to manage.") + slog.Info("password reset token generated (email not configured)") } writeJSON(w, http.StatusOK, okResponse) @@ -598,7 +598,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { // Invalidate all existing sessions (force logout everywhere) if err := s.store.DeleteUserSessions(user.ID); err != nil { - log.Printf("Failed to invalidate sessions after password reset: %v", err) + slog.Error("failed to invalidate sessions after password reset", "error", err) } // Create a fresh session so the user is logged in diff --git a/internal/server/handlers_events.go b/internal/server/handlers_events.go index 4dbcad21..3a1152e2 100644 --- a/internal/server/handlers_events.go +++ b/internal/server/handlers_events.go @@ -3,7 +3,7 @@ package server import ( "encoding/json" "fmt" - "log" + "log/slog" "net/http" "time" ) @@ -89,7 +89,7 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { func writeSSEEvent(w http.ResponseWriter, eventType string, data interface{}) { jsonData, err := json.Marshal(data) if err != nil { - log.Printf("events: error marshaling SSE event: %v", err) + slog.Error("failed to marshal SSE event", "error", err) return } fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData) diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 6253644b..1e5b29f1 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -4,7 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" - "log" + "log/slog" "net/http" "strconv" "strings" @@ -369,7 +369,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { commentInput.Source = source comment, cerr := s.store.CreateComment(workspaceID, updated.ID, commentInput) if cerr != nil { - log.Printf("WARNING: failed to create comment on item update %s: %v", updated.ID, cerr) + slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr) } if cerr == nil && comment != nil { s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source) diff --git a/internal/server/handlers_members.go b/internal/server/handlers_members.go index 0fb6e2e8..9b5209c0 100644 --- a/internal/server/handlers_members.go +++ b/internal/server/handlers_members.go @@ -2,7 +2,7 @@ package server import ( "context" - "log" + "log/slog" "net/http" "github.com/go-chi/chi/v5" @@ -149,7 +149,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) { wsName = ws.Name } if err := s.email.SendInvitation(context.Background(), inv.Email, inviterName, wsName, joinURL); err != nil { - log.Printf("Failed to send invitation email: %v", err) + slog.Error("failed to send invitation email", "error", err) } }() } diff --git a/internal/server/handlers_workspaces.go b/internal/server/handlers_workspaces.go index 92b6624a..d495dce2 100644 --- a/internal/server/handlers_workspaces.go +++ b/internal/server/handlers_workspaces.go @@ -72,6 +72,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// handleHealthLive is a lightweight liveness probe — always returns 200 if the +// process is running. Kubernetes uses this to decide whether to restart the pod. +func (s *Server) handleHealthLive(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleHealthReady is a readiness probe — returns 200 only when the service +// can accept traffic (DB connection healthy). Kubernetes uses this to decide +// whether to route traffic to the pod. +func (s *Server) handleHealthReady(w http.ResponseWriter, r *http.Request) { + if err := s.store.Ping(); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "status": "not ready", + "error": "database unavailable", + }) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { type templateInfo struct { Name string `json:"name"` diff --git a/internal/server/middleware_auth.go b/internal/server/middleware_auth.go index fcd7dc23..75209d9c 100644 --- a/internal/server/middleware_auth.go +++ b/internal/server/middleware_auth.go @@ -130,7 +130,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { path := r.URL.Path // Auth endpoints are always exempt - if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" { + if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" || strings.HasPrefix(path, "/api/v1/health/") { next.ServeHTTP(w, r) return } diff --git a/internal/server/middleware_logging.go b/internal/server/middleware_logging.go new file mode 100644 index 00000000..efafbff5 --- /dev/null +++ b/internal/server/middleware_logging.go @@ -0,0 +1,48 @@ +package server + +import ( + "log/slog" + "net/http" + "time" + + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +// StructuredLogger is a chi-compatible request logger that writes structured +// log entries via slog. It replaces chi's default Logger middleware. +func StructuredLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + duration := time.Since(start) + status := ww.Status() + + level := slog.LevelInfo + if status >= 500 { + level = slog.LevelError + } else if status >= 400 { + level = slog.LevelWarn + } + + attrs := []slog.Attr{ + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Int("status", status), + slog.Duration("duration", duration), + slog.Int("bytes", ww.BytesWritten()), + } + + if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" { + attrs = append(attrs, slog.String("request_id", reqID)) + } + + if r.URL.RawQuery != "" { + attrs = append(attrs, slog.String("query", r.URL.RawQuery)) + } + + slog.LogAttrs(r.Context(), level, "http request", attrs...) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index f6d86978..7fbe03eb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "io/fs" - "log" + "log/slog" "net/http" "strings" "sync" @@ -115,9 +115,9 @@ func (s *Server) setupRouter() { // Middleware r.Use(chimiddleware.RealIP) - r.Use(chimiddleware.Logger) - r.Use(chimiddleware.Recoverer) r.Use(chimiddleware.RequestID) + r.Use(StructuredLogger) + r.Use(chimiddleware.Recoverer) r.Use(SecurityHeaders) if s.secureCookies { r.Use(StrictTransportSecurity) @@ -142,6 +142,8 @@ func (s *Server) setupRouter() { // API routes r.Route("/api/v1", func(r chi.Router) { r.Get("/health", s.handleHealth) + r.Get("/health/live", s.handleHealthLive) + r.Get("/health/ready", s.handleHealthReady) // Auth endpoints (exempt from auth middleware) r.Route("/auth", func(r chi.Router) { @@ -391,7 +393,7 @@ func (s *Server) ListenAndServe(addr string) error { // Non-SSE handlers should use per-request context deadlines. } - log.Printf("Pad server listening on %s", addr) + slog.Info("Pad server listening", "addr", addr) return s.httpServer.ListenAndServe() } @@ -425,7 +427,7 @@ func jsonContentType(next http.Handler) http.Handler { func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { - log.Printf("Error encoding JSON: %v", err) + slog.Error("failed to encode JSON response", "error", err) } } @@ -442,7 +444,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) { // message to the client. This prevents leaking SQL errors, file paths, // and other internal details. func writeInternalError(w http.ResponseWriter, err error) { - log.Printf("internal error: %v", err) + slog.Error("internal server error", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "An internal error occurred") } diff --git a/internal/store/store.go b/internal/store/store.go index 40a41b2d..fed1fdd3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -55,6 +55,11 @@ func (s *Store) Close() error { return s.db.Close() } +// Ping verifies the database connection is alive. +func (s *Store) Ping() error { + return s.db.Ping() +} + func (s *Store) migrate() error { // Create migrations tracking table _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( diff --git a/internal/webhooks/dispatcher.go b/internal/webhooks/dispatcher.go index 15f0408f..6b5b2970 100644 --- a/internal/webhooks/dispatcher.go +++ b/internal/webhooks/dispatcher.go @@ -6,7 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "log" + "log/slog" "net/http" "time" @@ -50,7 +50,7 @@ func NewDispatcher(store WebhookStore) *Dispatcher { func (d *Dispatcher) Dispatch(workspaceID, event string, data interface{}) { hooks, err := d.store.ListWebhooks(workspaceID) if err != nil { - log.Printf("webhooks: failed to list webhooks for workspace %s: %v", workspaceID, err) + slog.Error("failed to list webhooks", "workspace", workspaceID, "error", err) return } @@ -63,7 +63,7 @@ func (d *Dispatcher) Dispatch(workspaceID, event string, data interface{}) { body, err := json.Marshal(payload) if err != nil { - log.Printf("webhooks: failed to marshal payload: %v", err) + slog.Error("failed to marshal webhook payload", "error", err) return } @@ -83,7 +83,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) { // Defense in depth: re-validate URL before making the request if !d.SkipSSRF { if err := ValidateWebhookURL(hook.URL); err != nil { - log.Printf("webhooks: blocked delivery to %s: %v", hook.URL, err) + slog.Warn("blocked webhook delivery", "url", hook.URL, "error", err) d.store.UpdateWebhookFailure(hook.ID, true) return } @@ -91,7 +91,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) { req, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body)) if err != nil { - log.Printf("webhooks: failed to create request for %s: %v", hook.URL, err) + slog.Error("failed to create webhook request", "url", hook.URL, "error", err) d.store.UpdateWebhookFailure(hook.ID, true) return } @@ -106,7 +106,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) { resp, err := d.client.Do(req) if err != nil { - log.Printf("webhooks: delivery failed for %s: %v", hook.URL, err) + slog.Error("webhook delivery failed", "url", hook.URL, "error", err) d.store.UpdateWebhookFailure(hook.ID, true) return } @@ -115,7 +115,7 @@ func (d *Dispatcher) deliver(hook models.Webhook, body []byte) { if resp.StatusCode >= 200 && resp.StatusCode < 300 { d.store.UpdateWebhookFailure(hook.ID, false) } else { - log.Printf("webhooks: non-2xx response (%d) from %s", resp.StatusCode, hook.URL) + slog.Warn("webhook non-2xx response", "status", resp.StatusCode, "url", hook.URL) d.store.UpdateWebhookFailure(hook.ID, true) } } From b9d0a89195d35834fbca3b0d18d9aac5ef328050 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Apr 2026 15:16:57 +0000 Subject: [PATCH 03/10] feat: add Redis pub/sub EventBus for multi-instance SSE (TASK-158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract EventBus interface (Subscribe, Unsubscribe, Publish, Close) - Rename Bus → MemoryBus, keeping it as the default for single-instance - Add RedisBus implementation with per-workspace channel subscriptions - Lazy Redis subscribe/unsubscribe as SSE clients connect/disconnect - Configure via PAD_REDIS_URL env var; falls back to in-memory without it - Update Server.SetEventBus to accept the EventBus interface --- cmd/pad/main.go | 18 ++- go.mod | 4 + go.sum | 8 ++ internal/events/bus.go | 42 +++++-- internal/events/redis_bus.go | 209 +++++++++++++++++++++++++++++++++++ internal/server/server.go | 4 +- 6 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 internal/events/redis_bus.go diff --git a/cmd/pad/main.go b/cmd/pad/main.go index a1483213..0273f098 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -30,6 +30,7 @@ import ( "regexp" "github.com/xarmian/pad/internal/email" + "github.com/redis/go-redis/v9" "github.com/xarmian/pad/internal/events" "github.com/xarmian/pad/internal/logging" "github.com/xarmian/pad/internal/models" @@ -207,7 +208,22 @@ func serveCmd() *cobra.Command { srv.SetSecureCookies(cfg.SecureCookies) // Attach event bus for real-time SSE - eventBus := events.New() + var eventBus events.EventBus + if redisURL := os.Getenv("PAD_REDIS_URL"); redisURL != "" { + opts, err := redis.ParseURL(redisURL) + if err != nil { + return fmt.Errorf("invalid PAD_REDIS_URL: %w", err) + } + rc := redis.NewClient(opts) + if err := rc.Ping(context.Background()).Err(); err != nil { + return fmt.Errorf("redis connection failed: %w", err) + } + eventBus = events.NewRedisBus(rc) + slog.Info("Event bus using Redis pub/sub", "url", redisURL) + } else { + eventBus = events.New() + slog.Info("Event bus using in-memory (single instance)") + } srv.SetEventBus(eventBus) // Attach webhook dispatcher for outgoing notifications diff --git a/go.mod b/go.mod index d6694577..90d62138 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect @@ -13,10 +15,12 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect diff --git a/go.sum b/go.sum index 6afcc6d5..55fadc83 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -25,6 +29,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -36,6 +42,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= diff --git a/internal/events/bus.go b/internal/events/bus.go index 9ebe41a0..8aab1b87 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -47,29 +47,49 @@ type Event struct { Timestamp int64 `json:"timestamp"` } +// EventBus is the interface for pub/sub event distribution. +// Implementations include MemoryBus (in-process) and RedisBus (cross-instance). +type EventBus interface { + // Subscribe registers a new subscriber for the given workspace. + // Returns a buffered channel that will receive events for that workspace. + Subscribe(workspaceID string) chan Event + + // Unsubscribe removes a subscriber and closes its channel. + Unsubscribe(ch chan Event) + + // Publish sends an event to all subscribers for the event's workspace. + Publish(event Event) + + // Close shuts down the event bus and cleans up resources. + Close() + + // SubscriberCount returns the number of active local subscribers. + SubscriberCount() int +} + // subscriber wraps a channel with its workspace filter. type subscriber struct { ch chan Event workspaceID string } -// Bus is an in-process pub/sub event bus that fans out events -// to all subscribers for a given workspace. -type Bus struct { +// MemoryBus is an in-process pub/sub event bus that fans out events +// to all subscribers for a given workspace. Suitable for single-instance deployments. +type MemoryBus struct { mu sync.RWMutex subscribers map[chan Event]*subscriber } -// New creates a new EventBus. -func New() *Bus { - return &Bus{ +// New creates a new in-memory EventBus. +func New() *MemoryBus { + return &MemoryBus{ subscribers: make(map[chan Event]*subscriber), } } // Subscribe registers a new subscriber for the given workspace. // Returns a buffered channel that will receive events for that workspace. -func (b *Bus) Subscribe(workspaceID string) chan Event { +func (b *MemoryBus) Subscribe(workspaceID string) chan Event { b.mu.Lock() defer b.mu.Unlock() @@ -82,7 +102,7 @@ func (b *Bus) Subscribe(workspaceID string) chan Event { } // Unsubscribe removes a subscriber and closes its channel. -func (b *Bus) Unsubscribe(ch chan Event) { +func (b *MemoryBus) Unsubscribe(ch chan Event) { b.mu.Lock() defer b.mu.Unlock() @@ -95,7 +115,7 @@ func (b *Bus) Unsubscribe(ch chan Event) { // Publish sends an event to all subscribers for the event's workspace. // Non-blocking: if a subscriber's channel is full, the event is dropped // and a warning is logged. -func (b *Bus) Publish(event Event) { +func (b *MemoryBus) Publish(event Event) { if event.Timestamp == 0 { event.Timestamp = time.Now().UnixMilli() } @@ -117,7 +137,7 @@ func (b *Bus) Publish(event Event) { // Close shuts down the event bus by closing all subscriber channels. // SSE handler goroutines will see the channel close and exit cleanly. -func (b *Bus) Close() { +func (b *MemoryBus) Close() { b.mu.Lock() defer b.mu.Unlock() @@ -128,7 +148,7 @@ func (b *Bus) Close() { } // SubscriberCount returns the number of active subscribers (for testing/debugging). -func (b *Bus) SubscriberCount() int { +func (b *MemoryBus) SubscriberCount() int { b.mu.RLock() defer b.mu.RUnlock() return len(b.subscribers) diff --git a/internal/events/redis_bus.go b/internal/events/redis_bus.go new file mode 100644 index 00000000..552d4677 --- /dev/null +++ b/internal/events/redis_bus.go @@ -0,0 +1,209 @@ +package events + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + // redisChannelPrefix is prepended to workspace IDs for Redis pub/sub channels. + redisChannelPrefix = "pad:events:" + + // reconnectDelay is how long to wait before retrying a failed Redis subscription. + reconnectDelay = 2 * time.Second +) + +// RedisBus distributes events across multiple Pad instances via Redis pub/sub. +// Each instance subscribes to Redis channels for its locally-connected SSE clients, +// and publishes events to Redis so all instances see them. +type RedisBus struct { + client *redis.Client + + mu sync.RWMutex + subscribers map[chan Event]*subscriber + + // Track which workspace channels we're subscribed to in Redis, + // so we subscribe/unsubscribe as local SSE clients come and go. + wsCounts map[string]int // workspace → local subscriber count + wsSubs map[string]*redisSub // workspace → active Redis subscription + + ctx context.Context + cancel context.CancelFunc +} + +// redisSub tracks an active Redis subscription for a workspace. +type redisSub struct { + pubsub *redis.PubSub + cancel context.CancelFunc +} + +// NewRedisBus creates a new Redis-backed EventBus. +// The provided redis.Client should already be configured and connected. +func NewRedisBus(client *redis.Client) *RedisBus { + ctx, cancel := context.WithCancel(context.Background()) + return &RedisBus{ + client: client, + subscribers: make(map[chan Event]*subscriber), + wsCounts: make(map[string]int), + wsSubs: make(map[string]*redisSub), + ctx: ctx, + cancel: cancel, + } +} + +// Subscribe registers a local subscriber for the given workspace. +// Starts a Redis subscription for the workspace if this is the first local subscriber. +func (b *RedisBus) Subscribe(workspaceID string) chan Event { + b.mu.Lock() + defer b.mu.Unlock() + + ch := make(chan Event, 64) + b.subscribers[ch] = &subscriber{ + ch: ch, + workspaceID: workspaceID, + } + + b.wsCounts[workspaceID]++ + if b.wsCounts[workspaceID] == 1 { + // First local subscriber for this workspace — subscribe to Redis channel + b.startRedisSubscription(workspaceID) + } + + return ch +} + +// Unsubscribe removes a local subscriber and closes its channel. +// Cancels the Redis subscription if this was the last local subscriber for the workspace. +func (b *RedisBus) Unsubscribe(ch chan Event) { + b.mu.Lock() + defer b.mu.Unlock() + + sub, ok := b.subscribers[ch] + if !ok { + return + } + + delete(b.subscribers, ch) + close(ch) + + wsID := sub.workspaceID + b.wsCounts[wsID]-- + if b.wsCounts[wsID] <= 0 { + delete(b.wsCounts, wsID) + b.stopRedisSubscription(wsID) + } +} + +// Publish sends an event to Redis, which distributes it to all instances. +func (b *RedisBus) Publish(event Event) { + if event.Timestamp == 0 { + event.Timestamp = time.Now().UnixMilli() + } + + data, err := json.Marshal(event) + if err != nil { + slog.Error("failed to marshal event for Redis", "error", err) + return + } + + channel := redisChannelPrefix + event.WorkspaceID + if err := b.client.Publish(b.ctx, channel, data).Err(); err != nil { + slog.Error("failed to publish event to Redis", "channel", channel, "error", err) + } +} + +// Close shuts down all Redis subscriptions and closes local subscriber channels. +func (b *RedisBus) Close() { + b.cancel() // signal all subscription goroutines to stop + + b.mu.Lock() + defer b.mu.Unlock() + + for wsID, sub := range b.wsSubs { + sub.cancel() + sub.pubsub.Close() + delete(b.wsSubs, wsID) + } + + for ch := range b.subscribers { + delete(b.subscribers, ch) + close(ch) + } +} + +// SubscriberCount returns the number of active local subscribers. +func (b *RedisBus) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subscribers) +} + +// startRedisSubscription begins listening on a Redis channel for a workspace. +// Must be called with b.mu held. +func (b *RedisBus) startRedisSubscription(workspaceID string) { + channel := redisChannelPrefix + workspaceID + pubsub := b.client.Subscribe(b.ctx, channel) + + subCtx, subCancel := context.WithCancel(b.ctx) + b.wsSubs[workspaceID] = &redisSub{ + pubsub: pubsub, + cancel: subCancel, + } + + go b.receiveMessages(subCtx, pubsub, workspaceID) +} + +// stopRedisSubscription cancels and cleans up the Redis subscription for a workspace. +// Must be called with b.mu held. +func (b *RedisBus) stopRedisSubscription(workspaceID string) { + sub, ok := b.wsSubs[workspaceID] + if !ok { + return + } + sub.cancel() + sub.pubsub.Close() + delete(b.wsSubs, workspaceID) +} + +// receiveMessages reads from a Redis pub/sub channel and fans out to local subscribers. +func (b *RedisBus) receiveMessages(ctx context.Context, pubsub *redis.PubSub, workspaceID string) { + ch := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-ch: + if !ok { + return + } + var event Event + if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil { + slog.Error("failed to unmarshal Redis event", "channel", msg.Channel, "error", err) + continue + } + b.fanOutLocally(event) + } + } +} + +// fanOutLocally distributes an event to all local subscribers for the event's workspace. +func (b *RedisBus) fanOutLocally(event Event) { + b.mu.RLock() + defer b.mu.RUnlock() + + for _, sub := range b.subscribers { + if sub.workspaceID != event.WorkspaceID { + continue + } + select { + case sub.ch <- event: + default: + slog.Warn("dropping event for slow subscriber", "type", event.Type, "workspace", event.WorkspaceID) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7fbe03eb..26d42d16 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,7 +28,7 @@ type Server struct { routerOnce sync.Once // ensures setupRouter runs once, after all config httpServer *http.Server // underlying HTTP server (set during ListenAndServe) webFS fs.FS // embedded web UI static files (optional) - events *events.Bus // real-time event bus (optional) + events events.EventBus // real-time event bus (optional) webhooks *webhooks.Dispatcher // webhook dispatcher (optional) email *email.Sender // transactional email sender (optional) rateLimiters *RateLimiters // per-endpoint rate limiters @@ -60,7 +60,7 @@ func (s *Server) SetBaseURL(url string) { } // SetEventBus attaches an event bus for real-time SSE streaming. -func (s *Server) SetEventBus(bus *events.Bus) { +func (s *Server) SetEventBus(bus events.EventBus) { s.events = bus } From a4a701367aebcee1d6b511f48ba5df01a2699b61 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Apr 2026 18:50:50 +0000 Subject: [PATCH 04/10] feat: add PostgreSQL support with dual-driver store layer (TASK-157) - Create Dialect abstraction for SQLite/PostgreSQL SQL differences (JSON ops, FTS, placeholders, datetime, aggregation) - Add Store.NewPostgres() constructor with connection pooling - Create consolidated PostgreSQL schema (pgmigrations/001_initial.sql) with tsvector FTS, JSONB columns, and GIN indexes - Refactor all store queries (~150) to use s.q() for placeholder rebinding - Replace hardcoded json_extract/FTS5/GROUP_CONCAT with dialect methods - Support PAD_DB_DRIVER=postgres + PAD_DATABASE_URL env vars - Keep SQLite as the default for local/self-hosted mode - Add dialect unit tests (rebind, SQLite, PostgreSQL) --- cmd/pad/main.go | 23 +- go.mod | 33 +- go.sum | 57 +++ internal/store/activities.go | 20 +- internal/store/agent_roles.go | 30 +- internal/store/api_tokens.go | 26 +- internal/store/collections.go | 40 +- internal/store/comments.go | 20 +- internal/store/dialect.go | 253 +++++++++++ internal/store/dialect_test.go | 69 +++ internal/store/documents.go | 76 ++-- internal/store/export.go | 50 ++- internal/store/items.go | 284 ++++++++----- internal/store/password_resets.go | 16 +- internal/store/pgmigrations/001_initial.sql | 443 ++++++++++++++++++++ internal/store/platform_settings.go | 10 +- internal/store/reactions.go | 14 +- internal/store/search.go | 81 +++- internal/store/sessions.go | 14 +- internal/store/snapshots.go | 14 +- internal/store/store.go | 98 ++++- internal/store/templates.go | 14 +- internal/store/users.go | 20 +- internal/store/versions.go | 12 +- internal/store/views.go | 20 +- internal/store/webhooks.go | 22 +- internal/store/workspace_members.go | 48 +-- internal/store/workspaces.go | 26 +- 28 files changed, 1447 insertions(+), 386 deletions(-) create mode 100644 internal/store/dialect.go create mode 100644 internal/store/dialect_test.go create mode 100644 internal/store/pgmigrations/001_initial.sql diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 0273f098..801806ff 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -182,9 +182,26 @@ func serveCmd() *cobra.Command { cfg.Port = port } - s, err := store.New(cfg.DBPath) - if err != nil { - return fmt.Errorf("open database: %w", err) + // Open database (SQLite default, PostgreSQL via PAD_DB_DRIVER) + var s *store.Store + var err error + dbDriver := os.Getenv("PAD_DB_DRIVER") + if dbDriver == "postgres" { + pgURL := os.Getenv("PAD_DATABASE_URL") + if pgURL == "" { + return fmt.Errorf("PAD_DATABASE_URL is required when PAD_DB_DRIVER=postgres") + } + s, err = store.NewPostgres(pgURL) + if err != nil { + return fmt.Errorf("open postgres: %w", err) + } + slog.Info("Database using PostgreSQL") + } else { + s, err = store.New(cfg.DBPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + slog.Info("Database using SQLite", "path", cfg.DBPath) } defer s.Close() diff --git a/go.mod b/go.mod index 90d62138..8adf3cf0 100644 --- a/go.mod +++ b/go.mod @@ -3,30 +3,39 @@ module github.com/xarmian/pad go 1.25.0 require ( - github.com/BurntSushi/toml v1.6.0 // indirect + github.com/BurntSushi/toml v1.6.0 + github.com/fatih/color v1.19.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.1 + github.com/redis/go-redis/v9 v9.18.0 + github.com/sergi/go-diff v1.4.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 + golang.org/x/time v0.15.0 + modernc.org/sqlite v1.47.0 +) + +require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect - github.com/go-chi/cors v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/text v0.35.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect ) diff --git a/go.sum b/go.sum index 55fadc83..e1387190 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= @@ -15,10 +20,24 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -28,6 +47,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= @@ -41,28 +61,65 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/store/activities.go b/internal/store/activities.go index 76b3d1d0..40c127de 100644 --- a/internal/store/activities.go +++ b/internal/store/activities.go @@ -20,10 +20,10 @@ func (s *Store) CreateActivity(a models.Activity) (string, error) { } ts := now() - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts) + `), a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts) return a.ID, err } @@ -50,12 +50,12 @@ func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) { // Look for a recent activity to coalesce with. var existingID, existingMeta string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, metadata FROM activities WHERE document_id = ? AND action = ? AND created_at >= ? AND ((user_id IS NOT NULL AND user_id = ?) OR (user_id IS NULL AND ? = '')) ORDER BY created_at DESC LIMIT 1 - `, a.DocumentID, a.Action, cutoff, a.UserID, a.UserID).Scan(&existingID, &existingMeta) + `), a.DocumentID, a.Action, cutoff, a.UserID, a.UserID).Scan(&existingID, &existingMeta) if err == sql.ErrNoRows { // No recent match — create a new activity. @@ -69,9 +69,9 @@ func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) { // Merge metadata: accumulate "changes" strings from both old and new. merged := mergeActivityMeta(existingMeta, a.Metadata) - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` UPDATE activities SET metadata = ?, created_at = ? WHERE id = ? - `, merged, ts, existingID) + `), merged, ts, existingID) return existingID, err } @@ -149,7 +149,7 @@ func (s *Store) ListWorkspaceActivity(workspaceID string, params models.Activity query += fmt.Sprintf(" OFFSET %d", params.Offset) } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func (s *Store) ListDocumentActivity(documentID string, params models.ActivityLi query += fmt.Sprintf(" OFFSET %d", params.Offset) } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -200,14 +200,14 @@ func (s *Store) ListDocumentActivity(documentID string, params models.ActivityLi // ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination. func (s *Store) ListDocumentActivityBeforeTime(documentID string, before time.Time, beforeID string, limit int) ([]models.Activity, error) { ts := before.Format(time.RFC3339) - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT a.id, a.workspace_id, COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, '') FROM activities a LEFT JOIN users u ON a.user_id = u.id WHERE a.document_id = ? AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?)) ORDER BY a.created_at DESC, a.id DESC LIMIT ? - `, documentID, ts, ts, beforeID, limit) + `), documentID, ts, ts, beforeID, limit) if err != nil { return nil, err } diff --git a/internal/store/agent_roles.go b/internal/store/agent_roles.go index 26a7ee41..d044b376 100644 --- a/internal/store/agent_roles.go +++ b/internal/store/agent_roles.go @@ -26,10 +26,10 @@ func (s *Store) CreateAgentRole(workspaceID string, input models.AgentRoleCreate return nil, fmt.Errorf("unique slug: %w", err) } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO agent_roles (id, workspace_id, slug, name, description, icon, tools, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) - `, id, workspaceID, slug, input.Name, input.Description, input.Icon, input.Tools, ts, ts) + `), id, workspaceID, slug, input.Name, input.Description, input.Icon, input.Tools, ts, ts) if err != nil { return nil, fmt.Errorf("create agent role: %w", err) } @@ -41,11 +41,11 @@ func (s *Store) GetAgentRole(workspaceID, idOrSlug string) (*models.AgentRole, e var role models.AgentRole var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, slug, name, description, icon, tools, sort_order, created_at, updated_at FROM agent_roles WHERE workspace_id = ? AND (id = ? OR slug = ?) - `, workspaceID, idOrSlug, idOrSlug).Scan( + `), workspaceID, idOrSlug, idOrSlug).Scan( &role.ID, &role.WorkspaceID, &role.Slug, &role.Name, &role.Description, &role.Icon, &role.Tools, &role.SortOrder, &createdAt, &updatedAt, ) @@ -62,7 +62,7 @@ func (s *Store) GetAgentRole(workspaceID, idOrSlug string) (*models.AgentRole, e } func (s *Store) ListAgentRoles(workspaceID string) ([]models.AgentRole, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT r.id, r.workspace_id, r.slug, r.name, r.description, r.icon, r.tools, r.sort_order, r.created_at, r.updated_at, COUNT(i.id) as item_count @@ -71,7 +71,7 @@ func (s *Store) ListAgentRoles(workspaceID string) ([]models.AgentRole, error) { WHERE r.workspace_id = ? GROUP BY r.id ORDER BY r.sort_order ASC, r.name ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list agent roles: %w", err) } @@ -137,7 +137,7 @@ func (s *Store) UpdateAgentRole(workspaceID, id string, input models.AgentRoleUp args = append(args, existing.ID) query := fmt.Sprintf("UPDATE agent_roles SET %s WHERE id = ?", strings.Join(sets, ", ")) - _, err = s.db.Exec(query, args...) + _, err = s.db.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update agent role: %w", err) } @@ -146,9 +146,9 @@ func (s *Store) UpdateAgentRole(workspaceID, id string, input models.AgentRoleUp } func (s *Store) DeleteAgentRole(workspaceID, id string) error { - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` DELETE FROM agent_roles WHERE workspace_id = ? AND (id = ? OR slug = ?) - `, workspaceID, id, id) + `), workspaceID, id, id) if err != nil { return fmt.Errorf("delete agent role: %w", err) } @@ -182,15 +182,17 @@ func (s *Store) GetRoleBreakdown(workspaceID string) ([]RoleBreakdown, error) { // Count non-terminal items per role (exclude done/completed/etc. to match board view) termPlaceholders, termArgs := models.DefaultTerminalStatusPlaceholders() roleCountArgs := append([]any{workspaceID}, termArgs...) - rows, err := s.db.Query(` - SELECT i.agent_role_id, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT u.name) as users + jsonExtractStatus := s.dialect.JSONExtractText("i.fields", "status") + groupConcatUsers := s.dialect.GroupConcat("u.name", true) + rows, err := s.db.Query(s.q(fmt.Sprintf(` + SELECT i.agent_role_id, COUNT(*) as cnt, %s as users FROM items i LEFT JOIN users u ON u.id = i.assigned_user_id WHERE i.workspace_id = ? AND i.deleted_at IS NULL - AND LOWER(COALESCE(json_extract(i.fields, '$.status'), '')) NOT IN - (`+termPlaceholders+`) + AND LOWER(COALESCE(%s, '')) NOT IN + (%s) GROUP BY i.agent_role_id - `, roleCountArgs...) + `, groupConcatUsers, jsonExtractStatus, termPlaceholders)), roleCountArgs...) if err != nil { return nil, fmt.Errorf("role breakdown: %w", err) } diff --git a/internal/store/api_tokens.go b/internal/store/api_tokens.go index 495fe0fc..89f9d653 100644 --- a/internal/store/api_tokens.go +++ b/internal/store/api_tokens.go @@ -40,10 +40,10 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod wsID = input.WorkspaceID } - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts) + `), id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts) if err != nil { return nil, fmt.Errorf("insert api token: %w", err) } @@ -61,12 +61,12 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod // ListAPITokens returns all API tokens for a workspace (without secrets). func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, COALESCE(workspace_id, ''), COALESCE(user_id, ''), name, prefix, scopes, expires_at, last_used_at, created_at FROM api_tokens WHERE workspace_id = ? ORDER BY created_at ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list api tokens: %w", err) } @@ -85,12 +85,12 @@ func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) { // ListUserAPITokens returns all API tokens owned by a user (without secrets). func (s *Store) ListUserAPITokens(userID string) ([]models.APIToken, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, COALESCE(workspace_id, ''), COALESCE(user_id, ''), name, prefix, scopes, expires_at, last_used_at, created_at FROM api_tokens WHERE user_id = ? ORDER BY created_at ASC - `, userID) + `), userID) if err != nil { return nil, fmt.Errorf("list user api tokens: %w", err) } @@ -109,7 +109,7 @@ func (s *Store) ListUserAPITokens(userID string) ([]models.APIToken, error) { // DeleteAPIToken removes an API token by ID. func (s *Store) DeleteAPIToken(id string) error { - result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ?", id) + result, err := s.db.Exec(s.q("DELETE FROM api_tokens WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete api token: %w", err) } @@ -122,7 +122,7 @@ func (s *Store) DeleteAPIToken(id string) error { // DeleteUserAPIToken removes an API token by ID, verifying it belongs to the user. func (s *Store) DeleteUserAPIToken(id, userID string) error { - result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ? AND user_id = ?", id, userID) + result, err := s.db.Exec(s.q("DELETE FROM api_tokens WHERE id = ? AND user_id = ?"), id, userID) if err != nil { return fmt.Errorf("delete user api token: %w", err) } @@ -145,11 +145,11 @@ func (s *Store) ValidateToken(token string) (*models.APIToken, error) { var workspaceID *string var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, user_id, name, prefix, scopes, expires_at, last_used_at, created_at FROM api_tokens WHERE token_hash = ? - `, tokenHash).Scan( + `), tokenHash).Scan( &t.ID, &workspaceID, &userID, &t.Name, &t.Prefix, &t.Scopes, &expiresAt, &lastUsedAt, &createdAt, ) @@ -177,7 +177,7 @@ func (s *Store) ValidateToken(token string) (*models.APIToken, error) { // Update last_used_at ts := now() - _, _ = s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", ts, t.ID) + _, _ = s.db.Exec(s.q("UPDATE api_tokens SET last_used_at = ? WHERE id = ?"), ts, t.ID) return &t, nil } @@ -188,11 +188,11 @@ func (s *Store) getAPIToken(id string) (*models.APIToken, error) { var expiresAt, lastUsedAt, userID, workspaceID *string var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, user_id, name, prefix, scopes, expires_at, last_used_at, created_at FROM api_tokens WHERE id = ? - `, id).Scan( + `), id).Scan( &t.ID, &workspaceID, &userID, &t.Name, &t.Prefix, &t.Scopes, &expiresAt, &lastUsedAt, &createdAt, ) diff --git a/internal/store/collections.go b/internal/store/collections.go index 03a1bbe6..307e9e80 100644 --- a/internal/store/collections.go +++ b/internal/store/collections.go @@ -45,10 +45,10 @@ func (s *Store) CreateCollection(workspaceID string, input models.CollectionCrea return nil, fmt.Errorf("unique slug: %w", err) } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO collections (id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, 0, boolToInt(input.IsDefault), ts, ts) + `), id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, 0, boolToInt(input.IsDefault), ts, ts) if err != nil { return nil, fmt.Errorf("insert collection: %w", err) } @@ -62,11 +62,11 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) { var deletedAt *string var isDefault int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, created_at, updated_at, deleted_at FROM collections WHERE id = ? AND deleted_at IS NULL - `, id).Scan( + `), id).Scan( &c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.SortOrder, &isDefault, &createdAt, &updatedAt, &deletedAt, @@ -87,10 +87,10 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) { func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collection, error) { var id string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id FROM collections WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL - `, workspaceID, slug).Scan(&id) + `), workspaceID, slug).Scan(&id) if err == sql.ErrNoRows { return nil, nil } @@ -103,19 +103,20 @@ func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collectio func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error) { termPlaceholders, termArgs := models.DefaultTerminalStatusPlaceholders() queryArgs := append(termArgs, workspaceID) - rows, err := s.db.Query(` + jsonExtractStatus := s.dialect.JSONExtractText("i.fields", "status") + rows, err := s.db.Query(s.q(fmt.Sprintf(` SELECT c.id, c.workspace_id, c.name, c.slug, c.prefix, c.icon, c.description, c.schema, c.settings, c.sort_order, c.is_default, c.created_at, c.updated_at, COUNT(i.id) as item_count, - COUNT(CASE WHEN LOWER(json_extract(i.fields, '$.status')) NOT IN - (`+termPlaceholders+`) + COUNT(CASE WHEN LOWER(COALESCE(%s, '')) NOT IN + (%s) THEN i.id END) as active_item_count FROM collections c LEFT JOIN items i ON i.collection_id = c.id AND i.deleted_at IS NULL WHERE c.workspace_id = ? AND c.deleted_at IS NULL GROUP BY c.id ORDER BY c.sort_order ASC, c.created_at ASC - `, queryArgs...) + `, jsonExtractStatus, termPlaceholders)), queryArgs...) if err != nil { return nil, fmt.Errorf("list collections: %w", err) } @@ -196,7 +197,7 @@ func (s *Store) UpdateCollection(id string, input models.CollectionUpdate) (*mod args = append(args, id) query := fmt.Sprintf("UPDATE collections SET %s WHERE id = ?", strings.Join(sets, ", ")) - _, err = s.db.Exec(query, args...) + _, err = s.db.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update collection: %w", err) } @@ -207,7 +208,7 @@ func (s *Store) UpdateCollection(id string, input models.CollectionUpdate) (*mod func (s *Store) DeleteCollection(id string) error { // Check if it's a default collection var isDefault int - err := s.db.QueryRow("SELECT is_default FROM collections WHERE id = ? AND deleted_at IS NULL", id).Scan(&isDefault) + err := s.db.QueryRow(s.q("SELECT is_default FROM collections WHERE id = ? AND deleted_at IS NULL"), id).Scan(&isDefault) if err == sql.ErrNoRows { return sql.ErrNoRows } @@ -219,10 +220,10 @@ func (s *Store) DeleteCollection(id string) error { } ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE collections SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, ts, ts, id) + `), ts, ts, id) if err != nil { return fmt.Errorf("delete collection: %w", err) } @@ -249,15 +250,16 @@ func (s *Store) MigrateItemFieldValues(collectionID string, migrations []models. if oldVal == newVal { continue } - fieldPath := fmt.Sprintf("$.%s", m.Field) - result, err := s.db.Exec(` + jsonSet := s.dialect.JSONSet("fields", m.Field) + jsonExtract := s.dialect.JSONExtractText("fields", m.Field) + result, err := s.db.Exec(s.q(fmt.Sprintf(` UPDATE items - SET fields = json_set(fields, ?, ?), + SET fields = %s, updated_at = ? WHERE collection_id = ? - AND json_extract(fields, ?) = ? + AND %s = ? AND deleted_at IS NULL - `, fieldPath, newVal, ts, collectionID, fieldPath, oldVal) + `, jsonSet, jsonExtract)), newVal, ts, collectionID, oldVal) if err != nil { return totalAffected, fmt.Errorf("migrate field %s (%s → %s): %w", m.Field, oldVal, newVal, err) } diff --git a/internal/store/comments.go b/internal/store/comments.go index aef65a39..ab51ff01 100644 --- a/internal/store/comments.go +++ b/internal/store/comments.go @@ -26,9 +26,9 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr author = createdBy } - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, activity_id, parent_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), id, itemID, workspaceID, author, input.Body, createdBy, source, nilIfEmpty(input.ActivityID), nilIfEmpty(input.ParentID), ts, ts, ) @@ -41,14 +41,14 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr // GetComment returns a single comment by ID. func (s *Store) GetComment(id string) (*models.Comment, error) { - row := s.db.QueryRow(` + row := s.db.QueryRow(s.q(` SELECT c.id, c.item_id, c.workspace_id, c.author, c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at, i.title, i.slug FROM comments c JOIN items i ON i.id = c.item_id - WHERE c.id = ?`, id) + WHERE c.id = ?`), id) var c models.Comment var createdAt, updatedAt string @@ -71,13 +71,13 @@ func (s *Store) GetComment(id string) (*models.Comment, error) { // ListComments returns all comments for an item, ordered chronologically. func (s *Store) ListComments(itemID string) ([]models.Comment, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT c.id, c.item_id, c.workspace_id, c.author, c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at FROM comments c WHERE c.item_id = ? - ORDER BY c.created_at ASC`, itemID) + ORDER BY c.created_at ASC`), itemID) if err != nil { return nil, fmt.Errorf("list comments: %w", err) } @@ -105,14 +105,14 @@ func (s *Store) ListComments(itemID string) ([]models.Comment, error) { // ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination. func (s *Store) ListCommentsBeforeTime(itemID string, before time.Time, beforeID string, limit int) ([]models.Comment, error) { ts := before.Format(time.RFC3339) - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT c.id, c.item_id, c.workspace_id, c.author, c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at FROM comments c WHERE c.item_id = ? AND (c.created_at < ? OR (c.created_at = ? AND c.id < ?)) ORDER BY c.created_at DESC, c.id DESC - LIMIT ?`, itemID, ts, ts, beforeID, limit) + LIMIT ?`), itemID, ts, ts, beforeID, limit) if err != nil { return nil, fmt.Errorf("list comments before time: %w", err) } @@ -138,7 +138,7 @@ func (s *Store) ListCommentsBeforeTime(itemID string, before time.Time, beforeID // DeleteComment removes a comment by ID. func (s *Store) DeleteComment(id string) error { - result, err := s.db.Exec("DELETE FROM comments WHERE id = ?", id) + result, err := s.db.Exec(s.q("DELETE FROM comments WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete comment: %w", err) } @@ -152,6 +152,6 @@ func (s *Store) DeleteComment(id string) error { // CountComments returns the number of comments for an item. func (s *Store) CountComments(itemID string) (int, error) { var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM comments WHERE item_id = ?", itemID).Scan(&count) + err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM comments WHERE item_id = ?"), itemID).Scan(&count) return count, err } diff --git a/internal/store/dialect.go b/internal/store/dialect.go new file mode 100644 index 00000000..29f6b22c --- /dev/null +++ b/internal/store/dialect.go @@ -0,0 +1,253 @@ +package store + +import ( + "fmt" + "strings" +) + +// DriverType identifies the database backend. +type DriverType string + +const ( + DriverSQLite DriverType = "sqlite" + DriverPostgres DriverType = "postgres" +) + +// Dialect encapsulates SQL syntax differences between database backends. +// The Store calls dialect methods to generate backend-specific SQL fragments. +type Dialect interface { + // Driver returns the driver type. + Driver() DriverType + + // Placeholder returns the nth parameter placeholder (1-indexed). + // SQLite: "?", PostgreSQL: "$1", "$2", etc. + Placeholder(n int) string + + // Rebind converts a query with "?" placeholders to the dialect's format. + // For SQLite this is a no-op. For PostgreSQL, "?" becomes "$1", "$2", etc. + Rebind(query string) string + + // JSONExtractText returns SQL to extract a text value from a JSON column. + // SQLite: json_extract(col, '$.key') + // PostgreSQL: col->>'key' + JSONExtractText(column, key string) string + + // JSONExtractPath returns SQL to extract a value at a dotted path from a JSON column. + // SQLite: json_extract(col, '$.path.to.key') + // PostgreSQL: col #>> '{path,to,key}' + JSONExtractPath(column, path string) string + + // JSONSet returns SQL to set a value at a path in a JSON column. + // SQLite: json_set(col, '$.key', ?) + // PostgreSQL: jsonb_set(col::jsonb, '{key}', ?::jsonb) + // Returns the SQL fragment and any extra placeholders used. + JSONSet(column, key string) string + + // JSONRemove returns SQL to remove a key from a JSON column. + // SQLite: json_remove(col, '$.key') + // PostgreSQL: col::jsonb - 'key' + JSONRemove(column, key string) string + + // Now returns the SQL expression for the current UTC timestamp. + // SQLite: datetime('now') + // PostgreSQL: NOW() AT TIME ZONE 'UTC' + Now() string + + // NowRFC3339 returns the SQL expression for current UTC time in RFC3339 format. + // SQLite: strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + // PostgreSQL: TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + NowRFC3339() string + + // GroupConcat returns SQL for string aggregation with a separator. + // SQLite: GROUP_CONCAT(DISTINCT expr) + // PostgreSQL: STRING_AGG(DISTINCT expr, ',') + GroupConcat(expr string, distinct bool) string + + // BoolToInt converts a Go bool to a query parameter value. + // SQLite: 0/1 (integers) + // PostgreSQL: true/false (native booleans) + BoolToInt(b bool) interface{} + + // ILike returns the case-insensitive LIKE operator. + // SQLite: LIKE (case-insensitive by default) + // PostgreSQL: ILIKE + ILike() string + + // Concat returns SQL to concatenate string expressions. + // SQLite: expr1 || expr2 + // PostgreSQL: expr1 || expr2 (same, but useful as abstraction point) + Concat(exprs ...string) string + + // FTSMatch returns the full-text search WHERE clause fragment. + // SQLite: "table MATCH ?" + // PostgreSQL: "table.tsvector_col @@ plainto_tsquery('english', ?)" + FTSMatch(table, column string) string + + // FTSSnippet returns SQL for highlighted search result snippets. + // SQLite: snippet(fts_table, col_idx, '', '', '...', 32) + // PostgreSQL: ts_headline('english', col, plainto_tsquery('english', ?)) + FTSSnippet(ftsTable string, colIndex int, sourceColumn string) string + + // FTSRank returns the column/expression for full-text relevance ranking. + // SQLite: rank (built-in FTS5 column) + // PostgreSQL: ts_rank(tsvector_col, plainto_tsquery('english', ?)) + FTSRank(table, column string) string +} + +// ---------- SQLite dialect ---------- + +type sqliteDialect struct{} + +func (d *sqliteDialect) Driver() DriverType { return DriverSQLite } + +func (d *sqliteDialect) Placeholder(_ int) string { return "?" } + +func (d *sqliteDialect) Rebind(query string) string { return query } + +func (d *sqliteDialect) JSONExtractText(column, key string) string { + return fmt.Sprintf("json_extract(%s, '$.%s')", column, key) +} + +func (d *sqliteDialect) JSONExtractPath(column, path string) string { + return fmt.Sprintf("json_extract(%s, '$.%s')", column, path) +} + +func (d *sqliteDialect) JSONSet(column, key string) string { + return fmt.Sprintf("json_set(%s, '$.%s', ?)", column, key) +} + +func (d *sqliteDialect) JSONRemove(column, key string) string { + return fmt.Sprintf("json_remove(%s, '$.%s')", column, key) +} + +func (d *sqliteDialect) Now() string { + return "datetime('now')" +} + +func (d *sqliteDialect) NowRFC3339() string { + return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" +} + +func (d *sqliteDialect) GroupConcat(expr string, distinct bool) string { + if distinct { + return fmt.Sprintf("GROUP_CONCAT(DISTINCT %s)", expr) + } + return fmt.Sprintf("GROUP_CONCAT(%s)", expr) +} + +func (d *sqliteDialect) BoolToInt(b bool) interface{} { + if b { + return 1 + } + return 0 +} + +func (d *sqliteDialect) ILike() string { return "LIKE" } + +func (d *sqliteDialect) Concat(exprs ...string) string { + return strings.Join(exprs, " || ") +} + +func (d *sqliteDialect) FTSMatch(table, _ string) string { + return fmt.Sprintf("%s MATCH ?", table) +} + +func (d *sqliteDialect) FTSSnippet(ftsTable string, colIndex int, _ string) string { + return fmt.Sprintf("snippet(%s, %d, '', '', '...', 32)", ftsTable, colIndex) +} + +func (d *sqliteDialect) FTSRank(_, _ string) string { + return "rank" +} + +// ---------- PostgreSQL dialect ---------- + +type postgresDialect struct{} + +func (d *postgresDialect) Driver() DriverType { return DriverPostgres } + +func (d *postgresDialect) Placeholder(n int) string { + return fmt.Sprintf("$%d", n) +} + +func (d *postgresDialect) Rebind(query string) string { + return rebindQuery(query) +} + +func (d *postgresDialect) JSONExtractText(column, key string) string { + return fmt.Sprintf("%s->>'%s'", column, key) +} + +func (d *postgresDialect) JSONExtractPath(column, path string) string { + parts := strings.Split(path, ".") + return fmt.Sprintf("%s #>> '{%s}'", column, strings.Join(parts, ",")) +} + +func (d *postgresDialect) JSONSet(column, key string) string { + return fmt.Sprintf("jsonb_set(COALESCE(%s, '{}')::jsonb, '{%s}', to_jsonb(?::text))", column, key) +} + +func (d *postgresDialect) JSONRemove(column, key string) string { + return fmt.Sprintf("(%s::jsonb - '%s')", column, key) +} + +func (d *postgresDialect) Now() string { + return "(NOW() AT TIME ZONE 'UTC')" +} + +func (d *postgresDialect) NowRFC3339() string { + return "TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')" +} + +func (d *postgresDialect) GroupConcat(expr string, distinct bool) string { + if distinct { + return fmt.Sprintf("STRING_AGG(DISTINCT %s, ',')", expr) + } + return fmt.Sprintf("STRING_AGG(%s, ',')", expr) +} + +func (d *postgresDialect) BoolToInt(b bool) interface{} { + return b +} + +func (d *postgresDialect) ILike() string { return "ILIKE" } + +func (d *postgresDialect) Concat(exprs ...string) string { + return strings.Join(exprs, " || ") +} + +func (d *postgresDialect) FTSMatch(table, column string) string { + return fmt.Sprintf("%s.%s @@ plainto_tsquery('english', ?)", table, column) +} + +func (d *postgresDialect) FTSSnippet(_ string, _ int, sourceColumn string) string { + return fmt.Sprintf("ts_headline('english', %s, plainto_tsquery('english', ?), 'StartSel=,StopSel=,MaxFragments=1,MaxWords=32')", sourceColumn) +} + +func (d *postgresDialect) FTSRank(table, column string) string { + return fmt.Sprintf("ts_rank(%s.%s, plainto_tsquery('english', ?))", table, column) +} + +// ---------- Helper ---------- + +// rebindQuery converts "?" placeholders to PostgreSQL's "$1", "$2", etc. +// Respects string literals (single quotes) and does not modify "?" inside them. +func rebindQuery(query string) string { + var buf strings.Builder + buf.Grow(len(query) + 16) + n := 0 + inString := false + for i := 0; i < len(query); i++ { + ch := query[i] + if ch == '\'' { + inString = !inString + buf.WriteByte(ch) + } else if ch == '?' && !inString { + n++ + fmt.Fprintf(&buf, "$%d", n) + } else { + buf.WriteByte(ch) + } + } + return buf.String() +} diff --git a/internal/store/dialect_test.go b/internal/store/dialect_test.go new file mode 100644 index 00000000..49619dbb --- /dev/null +++ b/internal/store/dialect_test.go @@ -0,0 +1,69 @@ +package store + +import "testing" + +func TestRebindQuery(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no params", "SELECT 1", "SELECT 1"}, + {"single param", "SELECT * FROM t WHERE id = ?", "SELECT * FROM t WHERE id = $1"}, + {"multiple params", "INSERT INTO t (a, b, c) VALUES (?, ?, ?)", "INSERT INTO t (a, b, c) VALUES ($1, $2, $3)"}, + {"string literal preserved", "SELECT * FROM t WHERE name = 'what?' AND id = ?", "SELECT * FROM t WHERE name = 'what?' AND id = $1"}, + {"mixed", "SELECT * FROM t WHERE a = ? AND b = 'foo?' AND c = ?", "SELECT * FROM t WHERE a = $1 AND b = 'foo?' AND c = $2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rebindQuery(tt.input) + if got != tt.want { + t.Errorf("rebindQuery(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSQLiteDialect(t *testing.T) { + d := &sqliteDialect{} + + if d.Driver() != DriverSQLite { + t.Errorf("expected DriverSQLite, got %v", d.Driver()) + } + if got := d.JSONExtractText("i.fields", "status"); got != "json_extract(i.fields, '$.status')" { + t.Errorf("JSONExtractText = %q", got) + } + if got := d.Now(); got != "datetime('now')" { + t.Errorf("Now = %q", got) + } + if got := d.FTSMatch("items_fts", "search_vector"); got != "items_fts MATCH ?" { + t.Errorf("FTSMatch = %q", got) + } + if got := d.GroupConcat("u.name", true); got != "GROUP_CONCAT(DISTINCT u.name)" { + t.Errorf("GroupConcat = %q", got) + } +} + +func TestPostgresDialect(t *testing.T) { + d := &postgresDialect{} + + if d.Driver() != DriverPostgres { + t.Errorf("expected DriverPostgres, got %v", d.Driver()) + } + if got := d.Placeholder(3); got != "$3" { + t.Errorf("Placeholder(3) = %q", got) + } + if got := d.JSONExtractText("i.fields", "status"); got != "i.fields->>'status'" { + t.Errorf("JSONExtractText = %q", got) + } + if got := d.JSONRemove("fields", "phase"); got != "(fields::jsonb - 'phase')" { + t.Errorf("JSONRemove = %q", got) + } + if got := d.GroupConcat("u.name", true); got != "STRING_AGG(DISTINCT u.name, ',')" { + t.Errorf("GroupConcat = %q", got) + } + if got := d.ILike(); got != "ILIKE" { + t.Errorf("ILike = %q", got) + } +} diff --git a/internal/store/documents.go b/internal/store/documents.go index c94df90f..94eac70a 100644 --- a/internal/store/documents.go +++ b/internal/store/documents.go @@ -41,16 +41,28 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara } } if params.Query != "" { - // Use FTS5 for search - query = ` - SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags, - d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source, - d.created_at, d.updated_at - FROM documents d - JOIN documents_fts fts ON d.rowid = fts.rowid - WHERE d.workspace_id = ? AND d.deleted_at IS NULL - AND documents_fts MATCH ? - ` + // Use FTS for search + ftsMatch := s.dialect.FTSMatch("documents_fts", "search_vector") + if s.dialect.Driver() == DriverSQLite { + query = fmt.Sprintf(` + SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags, + d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source, + d.created_at, d.updated_at + FROM documents d + JOIN documents_fts fts ON d.rowid = fts.rowid + WHERE d.workspace_id = ? AND d.deleted_at IS NULL + AND %s + `, ftsMatch) + } else { + query = fmt.Sprintf(` + SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags, + d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source, + d.created_at, d.updated_at + FROM documents d + WHERE d.workspace_id = ? AND d.deleted_at IS NULL + AND %s + `, ftsMatch) + } args = []interface{}{workspaceID, params.Query} if params.Type != "" { @@ -88,7 +100,7 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order) } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, fmt.Errorf("list documents: %w", err) } @@ -131,11 +143,11 @@ func (s *Store) CreateDocument(workspaceID string, input models.DocumentCreate) return nil, err } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO documents (id, workspace_id, title, slug, content, doc_type, status, tags, pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) - `, id, workspaceID, input.Title, slug, input.Content, docType, status, tags, + `), id, workspaceID, input.Title, slug, input.Content, docType, status, tags, boolToInt(input.Pinned), createdBy, createdBy, source, ts, ts) if err != nil { return nil, fmt.Errorf("insert document: %w", err) @@ -150,13 +162,13 @@ func (s *Store) GetDocument(id string) (*models.Document, error) { var deletedAt *string var pinned int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, title, slug, content, doc_type, status, tags, pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at, deleted_at FROM documents WHERE id = ? AND deleted_at IS NULL - `, id).Scan( + `), id).Scan( &d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags, &pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source, &createdAt, &updatedAt, &deletedAt, @@ -177,10 +189,10 @@ func (s *Store) GetDocument(id string) (*models.Document, error) { func (s *Store) GetDocumentByTitle(workspaceID, title string) (*models.Document, error) { var id string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id FROM documents WHERE workspace_id = ? AND title = ? AND deleted_at IS NULL - `, workspaceID, title).Scan(&id) + `), workspaceID, title).Scan(&id) if err == sql.ErrNoRows { return nil, nil } @@ -241,10 +253,10 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models. isDiff = 1 } - _, err = tx.Exec(` + _, err = tx.Exec(s.q(` INSERT INTO versions (id, document_id, content, change_summary, created_by, source, is_diff, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts) + `), vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts) if err != nil { return nil, fmt.Errorf("create version: %w", err) } @@ -313,7 +325,7 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models. args = append(args, id) query := fmt.Sprintf("UPDATE documents SET %s WHERE id = ?", strings.Join(sets, ", ")) - _, err = tx.Exec(query, args...) + _, err = tx.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update document: %w", err) } @@ -328,10 +340,10 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models. func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle string) error { // Find all documents in the workspace that contain [[oldTitle]] searchTerm := "[[" + oldTitle + "]]" - rows, err := tx.Query(` + rows, err := tx.Query(s.q(` SELECT id, content FROM documents WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ? - `, workspaceID, "%"+searchTerm+"%") + `), workspaceID, "%"+searchTerm+"%") if err != nil { return err } @@ -355,7 +367,7 @@ func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle stri } for _, du := range updates { - _, err = tx.Exec("UPDATE documents SET content = ? WHERE id = ?", du.content, du.id) + _, err = tx.Exec(s.q("UPDATE documents SET content = ? WHERE id = ?"), du.content, du.id) if err != nil { return err } @@ -365,10 +377,10 @@ func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle stri func (s *Store) DeleteDocument(id string) error { ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE documents SET deleted_at = ?, updated_at = ?, status = 'archived' WHERE id = ? AND deleted_at IS NULL - `, ts, ts, id) + `), ts, ts, id) if err != nil { return err } @@ -381,10 +393,10 @@ func (s *Store) DeleteDocument(id string) error { func (s *Store) RestoreDocument(id string) (*models.Document, error) { ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE documents SET deleted_at = NULL, updated_at = ?, status = 'draft' WHERE id = ? AND deleted_at IS NOT NULL - `, ts, id) + `), ts, id) if err != nil { return nil, err } @@ -455,7 +467,7 @@ func (s *Store) BulkRead(ids []string) ([]models.Document, error) { WHERE id IN (%s) AND deleted_at IS NULL `, strings.Join(placeholders, ",")) - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -466,13 +478,13 @@ func (s *Store) BulkRead(ids []string) ([]models.Document, error) { func (s *Store) GetBacklinks(workspaceID, documentTitle string) ([]models.Document, error) { searchTerm := "[[" + documentTitle + "]]" - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, workspace_id, title, slug, content, doc_type, status, tags, pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at FROM documents WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ? - `, workspaceID, "%"+searchTerm+"%") + `), workspaceID, "%"+searchTerm+"%") if err != nil { return nil, err } @@ -501,7 +513,7 @@ func (s *Store) GetLinks(workspaceID, content string) ([]models.Document, error) WHERE workspace_id = ? AND deleted_at IS NULL AND title IN (%s) `, strings.Join(placeholders, ",")) - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -531,7 +543,7 @@ func (s *Store) GetContext(workspaceID string, types []string, includeContent bo query += " ORDER BY pinned DESC, updated_at DESC" - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } diff --git a/internal/store/export.go b/internal/store/export.go index f3f0ef1d..b5402880 100644 --- a/internal/store/export.go +++ b/internal/store/export.go @@ -30,10 +30,10 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) { } // Collections - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, created_at, updated_at FROM collections WHERE workspace_id = ? AND deleted_at IS NULL - ORDER BY sort_order, name`, ws.ID) + ORDER BY sort_order, name`), ws.ID) if err != nil { return nil, fmt.Errorf("export collections: %w", err) } @@ -52,11 +52,11 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) { } // Items - itemRows, err := s.db.Query(` + itemRows, err := s.db.Query(s.q(` SELECT id, collection_id, title, slug, content, fields, tags, pinned, sort_order, COALESCE(parent_id, ''), created_by, last_modified_by, source, COALESCE(item_number, 0), created_at, updated_at FROM items WHERE workspace_id = ? AND deleted_at IS NULL - ORDER BY collection_id, sort_order, created_at`, ws.ID) + ORDER BY collection_id, sort_order, created_at`), ws.ID) if err != nil { return nil, fmt.Errorf("export items: %w", err) } @@ -75,12 +75,12 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) { } // Comments - commentRows, err := s.db.Query(` + commentRows, err := s.db.Query(s.q(` SELECT c.id, c.item_id, c.author, c.body, c.created_by, c.source, c.created_at, c.updated_at FROM comments c JOIN items i ON c.item_id = i.id WHERE c.workspace_id = ? AND i.deleted_at IS NULL - ORDER BY c.created_at`, ws.ID) + ORDER BY c.created_at`), ws.ID) if err != nil { return nil, fmt.Errorf("export comments: %w", err) } @@ -97,10 +97,10 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) { } // Item links - linkRows, err := s.db.Query(` + linkRows, err := s.db.Query(s.q(` SELECT id, source_id, target_id, link_type, created_by, created_at FROM item_links WHERE workspace_id = ? - ORDER BY created_at`, ws.ID) + ORDER BY created_at`), ws.ID) if err != nil { return nil, fmt.Errorf("export item links: %w", err) } @@ -117,12 +117,12 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) { } // Item versions - versionRows, err := s.db.Query(` + versionRows, err := s.db.Query(s.q(` SELECT v.id, v.item_id, v.content, v.change_summary, v.created_by, v.source, v.is_diff, v.created_at FROM item_versions v JOIN items i ON v.item_id = i.id WHERE i.workspace_id = ? AND i.deleted_at IS NULL - ORDER BY v.created_at`, ws.ID) + ORDER BY v.created_at`), ws.ID) if err != nil { return nil, fmt.Errorf("export item versions: %w", err) } @@ -190,9 +190,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* isDefault = 1 } - _, err := tx.Exec(` + _, err := tx.Exec(s.q(` INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, c.Settings, c.Prefix, c.SortOrder, isDefault, c.CreatedAt, c.UpdatedAt) if err != nil { @@ -222,9 +222,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* } } - _, err := tx.Exec(` + _, err := tx.Exec(s.q(` INSERT INTO items (id, workspace_id, collection_id, title, slug, content, fields, tags, pinned, sort_order, parent_id, created_by, last_modified_by, source, item_number, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)`), newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, pinned, it.SortOrder, parentID, it.CreatedBy, it.LastModifiedBy, it.Source, it.ItemNumber, it.CreatedAt, it.UpdatedAt) @@ -247,7 +247,7 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* parentID = mapped } } - _, err := tx.Exec(`UPDATE items SET fields = ?, parent_id = NULLIF(?, '') WHERE id = ?`, + _, err := tx.Exec(s.q(`UPDATE items SET fields = ?, parent_id = NULLIF(?, '') WHERE id = ?`), fields, parentID, newItemID) if err != nil { return nil, fmt.Errorf("remap item %s: %w", it.Title, err) @@ -260,9 +260,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* if newItemID == "" { continue } - _, err := tx.Exec(` + _, err := tx.Exec(s.q(` INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), newID(), newItemID, ws.ID, cm.Author, cm.Body, cm.CreatedBy, cm.Source, cm.CreatedAt, cm.UpdatedAt) if err != nil { @@ -277,9 +277,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* if newSourceID == "" || newTargetID == "" { continue } - _, err := tx.Exec(` + _, err := tx.Exec(s.q(` INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?)`), newID(), ws.ID, newSourceID, newTargetID, lk.LinkType, lk.CreatedBy, lk.CreatedAt) if err != nil { @@ -298,9 +298,9 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* if ver.IsDiff { isDiff = 1 } - _, err := tx.Exec(` + _, err := tx.Exec(s.q(` INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, isDiff, ver.CreatedAt) if err != nil { @@ -322,8 +322,12 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (* // rebuildFTSForWorkspace rebuilds the FTS index for all items in a workspace. // This is needed after import because direct INSERTs bypass the FTS triggers. +// Only applicable to SQLite (PostgreSQL uses trigger-maintained tsvector columns). func (s *Store) rebuildFTSForWorkspace(wsID string) { - rows, err := s.db.Query(`SELECT rowid, title, content, tags FROM items WHERE workspace_id = ? AND deleted_at IS NULL`, wsID) + if s.dialect.Driver() != DriverSQLite { + return + } + rows, err := s.db.Query(s.q(`SELECT rowid, title, content, tags FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), wsID) if err != nil { return } @@ -334,7 +338,7 @@ func (s *Store) rebuildFTSForWorkspace(wsID string) { if err := rows.Scan(&rowid, &title, &content, &tags); err != nil { continue } - s.db.Exec(`INSERT INTO items_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)`, rowid, title, content, tags) + s.db.Exec(s.q(`INSERT INTO items_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)`), rowid, title, content, tags) } } diff --git a/internal/store/items.go b/internal/store/items.go index e2b13171..41fd3e26 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -86,17 +86,17 @@ func (s *Store) CreateItem(workspaceID, collectionID string, input models.ItemCr // Assign the next item_number within this collection var nextNum int - err = tx.QueryRow("SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?", collectionID).Scan(&nextNum) + err = tx.QueryRow(s.q("SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?"), collectionID).Scan(&nextNum) if err != nil { return nil, fmt.Errorf("get next item number: %w", err) } - _, err = tx.Exec(` + _, err = tx.Exec(s.q(` INSERT INTO items (id, workspace_id, collection_id, title, slug, content, fields, tags, pinned, sort_order, parent_id, assigned_user_id, agent_role_id, role_sort_order, created_by, last_modified_by, source, item_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) - `, id, workspaceID, collectionID, input.Title, slug, input.Content, fields, tags, + `), id, workspaceID, collectionID, input.Title, slug, input.Content, fields, tags, boolToInt(input.Pinned), input.ParentID, input.AssignedUserID, input.AgentRoleID, createdBy, createdBy, source, nextNum, ts, ts) if err != nil { @@ -106,10 +106,10 @@ func (s *Store) CreateItem(workspaceID, collectionID string, input models.ItemCr // Create initial version if there's content if input.Content != "" { vid := newID() - _, err = tx.Exec(` + _, err = tx.Exec(s.q(` INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at) VALUES (?, ?, ?, '', ?, ?, 0, ?) - `, vid, id, input.Content, createdBy, source, ts) + `), vid, id, input.Content, createdBy, source, ts) if err != nil { return nil, fmt.Errorf("create initial version: %w", err) } @@ -128,7 +128,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) { var deletedAt *string var pinned int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, i.created_by, i.last_modified_by, i.source, @@ -141,7 +141,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) { LEFT JOIN users au ON au.id = i.assigned_user_id LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id WHERE i.id = ? AND i.deleted_at IS NULL - `, id).Scan( + `), id).Scan( &item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug, &item.Content, &item.Fields, &item.Tags, &pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder, @@ -168,10 +168,10 @@ func (s *Store) GetItem(id string) (*models.Item, error) { func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) { var id string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id FROM items WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL - `, workspaceID, slug).Scan(&id) + `), workspaceID, slug).Scan(&id) if err == sql.ErrNoRows { return nil, nil } @@ -184,11 +184,11 @@ func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) { // GetItemByRef looks up an item by its PREFIX-NUMBER reference (e.g. "IDEA-15"). func (s *Store) GetItemByRef(workspaceID, prefix string, number int) (*models.Item, error) { var id string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT i.id FROM items i JOIN collections c ON c.id = i.collection_id WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ? AND i.deleted_at IS NULL - `, workspaceID, prefix, number).Scan(&id) + `), workspaceID, prefix, number).Scan(&id) if err == sql.ErrNoRows { return nil, nil } @@ -250,7 +250,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model var deletedAt *string var pinned int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, i.created_by, i.last_modified_by, i.source, @@ -263,7 +263,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model LEFT JOIN users au ON au.id = i.assigned_user_id LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ? - `, workspaceID, prefix, number).Scan( + `), workspaceID, prefix, number).Scan( &item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug, &item.Content, &item.Fields, &item.Tags, &pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder, @@ -324,7 +324,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I var deletedAt *string var pinned int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, i.created_by, i.last_modified_by, i.source, @@ -337,7 +337,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I LEFT JOIN users au ON au.id = i.assigned_user_id LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id WHERE i.workspace_id = ? AND i.slug = ? - `, workspaceID, slug).Scan( + `), workspaceID, slug).Scan( &item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug, &item.Content, &item.Fields, &item.Tags, &pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder, @@ -419,25 +419,25 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m args = append(args, params.PhaseID) } - // Field filters using json_extract — supports comma-separated values as OR + // Field filters — supports comma-separated values as OR for key, value := range params.Fields { + jsonExpr := s.dialect.JSONExtractText("i.fields", key) if strings.Contains(value, ",") { values := strings.Split(value, ",") placeholders := make([]string, len(values)) - args = append(args, "$."+key) for i, v := range values { placeholders[i] = "?" args = append(args, strings.TrimSpace(v)) } - query += " AND json_extract(i.fields, ?) IN (" + strings.Join(placeholders, ",") + ")" + query += " AND " + jsonExpr + " IN (" + strings.Join(placeholders, ",") + ")" } else { - query += " AND json_extract(i.fields, ?) = ?" - args = append(args, "$."+key, value) + query += " AND " + jsonExpr + " = ?" + args = append(args, value) } } // Sorting - query += buildItemSort(params.Sort) + query += buildItemSort(params.Sort, s.dialect) // Pagination if params.Limit > 0 { @@ -449,7 +449,7 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m } } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, fmt.Errorf("list items: %w", err) } @@ -459,37 +459,62 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m } func (s *Store) listItemsFTS(workspaceID string, params models.ItemListParams) ([]models.Item, error) { - query := ` - SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, - i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, - i.created_by, i.last_modified_by, i.source, - i.item_number, i.created_at, i.updated_at, - c.slug, c.name, c.icon, c.prefix, - COALESCE(au.name, ''), COALESCE(au.email, ''), - COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '') - FROM items i - JOIN items_fts fts ON i.rowid = fts.rowid - JOIN collections c ON c.id = i.collection_id - LEFT JOIN users au ON au.id = i.assigned_user_id - LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id - WHERE i.workspace_id = ? AND i.deleted_at IS NULL - AND items_fts MATCH ? - ` - args := []interface{}{workspaceID, params.Search} + ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector") + ftsRank := s.dialect.FTSRank("items_fts", "search_vector") + + var query string + var args []interface{} + + if s.dialect.Driver() == DriverPostgres { + query = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '') + FROM items i + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE i.workspace_id = ? AND i.deleted_at IS NULL + AND %s + `, ftsMatch) + args = []interface{}{workspaceID, params.Search} + } else { + query = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '') + FROM items i + JOIN items_fts fts ON i.rowid = fts.rowid + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE i.workspace_id = ? AND i.deleted_at IS NULL + AND %s + `, ftsMatch) + args = []interface{}{workspaceID, params.Search} + } if params.CollectionSlug != "" { query += " AND c.slug = ?" args = append(args, params.CollectionSlug) } - query += " ORDER BY rank" + query += " ORDER BY " + ftsRank if params.Limit > 0 { query += " LIMIT ?" args = append(args, params.Limit) } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, fmt.Errorf("search items: %w", err) } @@ -550,10 +575,10 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er isDiff = 1 } - _, err = tx.Exec(` + _, err = tx.Exec(s.q(` INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts) + `), vid, id, versionContent, input.ChangeSummary, createdBy, source, isDiff, ts) if err != nil { return nil, fmt.Errorf("create version: %w", err) } @@ -625,7 +650,7 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er args = append(args, id) query := fmt.Sprintf("UPDATE items SET %s WHERE id = ?", strings.Join(sets, ", ")) - _, err = tx.Exec(query, args...) + _, err = tx.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update item: %w", err) } @@ -639,10 +664,10 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er func (s *Store) DeleteItem(id string) error { ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE items SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, ts, ts, id) + `), ts, ts, id) if err != nil { return fmt.Errorf("delete item: %w", err) } @@ -655,10 +680,10 @@ func (s *Store) DeleteItem(id string) error { func (s *Store) RestoreItem(id string) (*models.Item, error) { ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE items SET deleted_at = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NOT NULL - `, ts, id) + `), ts, id) if err != nil { return nil, fmt.Errorf("restore item: %w", err) } @@ -670,34 +695,63 @@ func (s *Store) RestoreItem(id string) (*models.Item, error) { } func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, error) { - sqlQuery := ` - SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, - i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, - i.created_by, i.last_modified_by, i.source, - i.item_number, i.created_at, i.updated_at, - c.slug, c.name, c.icon, c.prefix, - COALESCE(au.name, ''), COALESCE(au.email, ''), - COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), - snippet(items_fts, 1, '', '', '...', 32) as snippet, - rank - FROM items_fts fts - JOIN items i ON i.rowid = fts.rowid - JOIN collections c ON c.id = i.collection_id - LEFT JOIN users au ON au.id = i.assigned_user_id - LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id - WHERE items_fts MATCH ? - AND i.deleted_at IS NULL - ` - args := []interface{}{query} + ftsSnippet := s.dialect.FTSSnippet("items_fts", 1, "i.content") + ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector") + ftsRank := s.dialect.FTSRank("items_fts", "search_vector") + + var sqlQuery string + var args []interface{} + + if s.dialect.Driver() == DriverPostgres { + sqlQuery = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), + %s as snippet, + %s as rank_score + FROM items i + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE %s + AND i.deleted_at IS NULL + `, ftsSnippet, ftsRank, ftsMatch) + // PostgreSQL: FTSSnippet, FTSRank, and FTSMatch each consume a "?" for plainto_tsquery + args = []interface{}{query, query, query} + } else { + sqlQuery = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), + %s as snippet, + %s as rank_score + FROM items_fts fts + JOIN items i ON i.rowid = fts.rowid + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE %s + AND i.deleted_at IS NULL + `, ftsSnippet, ftsRank, ftsMatch) + args = []interface{}{query} + } if workspaceID != "" { sqlQuery += " AND i.workspace_id = ?" args = append(args, workspaceID) } - sqlQuery += " ORDER BY rank LIMIT 50" + sqlQuery += " ORDER BY rank_score LIMIT 50" - rows, err := s.db.Query(sqlQuery, args...) + rows, err := s.db.Query(s.q(sqlQuery), args...) if err != nil { return nil, fmt.Errorf("search items: %w", err) } @@ -749,10 +803,10 @@ func (s *Store) CreateItemLink(workspaceID string, input models.ItemLinkCreate, createdBy = "user" } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts) + `), id, workspaceID, sourceID, input.TargetID, linkType, createdBy, ts) if err != nil { return nil, fmt.Errorf("create item link: %w", err) } @@ -768,18 +822,20 @@ func (s *Store) getItemLink(id string) (*models.ItemLink, error) { var sourceItemNumber, targetItemNumber sql.NullInt64 var sourceStatus, targetStatus sql.NullString - err := s.db.QueryRow(` + srcStatus := s.dialect.JSONExtractText("s.fields", "status") + tgtStatus := s.dialect.JSONExtractText("t.fields", "status") + err := s.db.QueryRow(s.q(fmt.Sprintf(` SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at, s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix, s.item_number, t.item_number, - json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status') + %s, %s FROM item_links l JOIN items s ON s.id = l.source_id JOIN items t ON t.id = l.target_id JOIN collections sc ON sc.id = s.collection_id JOIN collections tc ON tc.id = t.collection_id WHERE l.id = ? - `, id).Scan( + `, srcStatus, tgtStatus)), id).Scan( &link.ID, &link.WorkspaceID, &link.SourceID, &link.TargetID, &link.LinkType, &link.CreatedBy, &createdAt, &link.SourceTitle, &link.TargetTitle, @@ -812,11 +868,13 @@ func (s *Store) getItemLink(id string) (*models.ItemLink, error) { } func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) { - rows, err := s.db.Query(` + srcStatusExpr := s.dialect.JSONExtractText("s.fields", "status") + tgtStatusExpr := s.dialect.JSONExtractText("t.fields", "status") + rows, err := s.db.Query(s.q(fmt.Sprintf(` SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at, s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix, s.item_number, t.item_number, - json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status') + %s, %s FROM item_links l JOIN items s ON s.id = l.source_id JOIN items t ON t.id = l.target_id @@ -824,7 +882,7 @@ func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) { JOIN collections tc ON tc.id = t.collection_id WHERE l.source_id = ? OR l.target_id = ? ORDER BY l.created_at DESC - `, itemID, itemID) + `, srcStatusExpr, tgtStatusExpr)), itemID, itemID) if err != nil { return nil, fmt.Errorf("get item links: %w", err) } @@ -868,7 +926,7 @@ func (s *Store) GetItemLinks(itemID string) ([]models.ItemLink, error) { } func (s *Store) DeleteItemLink(id string) error { - result, err := s.db.Exec("DELETE FROM item_links WHERE id = ?", id) + result, err := s.db.Exec(s.q("DELETE FROM item_links WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete item link: %w", err) } @@ -891,17 +949,17 @@ func (s *Store) SetPhaseLink(workspaceID, itemID, phaseID, createdBy string) (*m defer tx.Rollback() // Delete existing phase link for this item (if any) - if _, err := tx.Exec(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`, itemID); err != nil { + if _, err := tx.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`), itemID); err != nil { return nil, fmt.Errorf("delete existing phase link: %w", err) } // Insert new phase link id := newID() now := time.Now().UTC().Format(time.RFC3339) - if _, err := tx.Exec(` + if _, err := tx.Exec(s.q(` INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at) VALUES (?, ?, ?, ?, 'phase', ?, ?) - `, id, workspaceID, itemID, phaseID, createdBy, now); err != nil { + `), id, workspaceID, itemID, phaseID, createdBy, now); err != nil { return nil, fmt.Errorf("insert phase link: %w", err) } @@ -924,7 +982,7 @@ func (s *Store) SetPhaseLink(workspaceID, itemID, phaseID, createdBy string) (*m // ClearPhaseLink removes the phase link for an item. func (s *Store) ClearPhaseLink(itemID string) error { - _, err := s.db.Exec(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`, itemID) + _, err := s.db.Exec(s.q(`DELETE FROM item_links WHERE source_id = ? AND link_type = 'phase'`), itemID) if err != nil { return fmt.Errorf("clear phase link: %w", err) } @@ -933,18 +991,20 @@ func (s *Store) ClearPhaseLink(itemID string) error { // GetPhaseForItem returns the phase link for an item, or nil if not in a phase. func (s *Store) GetPhaseForItem(itemID string) (*models.ItemLink, error) { - rows, err := s.db.Query(` + sStatusExpr := s.dialect.JSONExtractText("s.fields", "status") + tStatusExpr := s.dialect.JSONExtractText("t.fields", "status") + rows, err := s.db.Query(s.q(fmt.Sprintf(` SELECT l.id, l.workspace_id, l.source_id, l.target_id, l.link_type, l.created_by, l.created_at, s.title, t.title, s.slug, t.slug, sc.slug, tc.slug, sc.prefix, tc.prefix, s.item_number, t.item_number, - json_extract(s.fields, '$.status'), json_extract(t.fields, '$.status') + %s, %s FROM item_links l JOIN items s ON s.id = l.source_id JOIN items t ON t.id = l.target_id JOIN collections sc ON sc.id = s.collection_id JOIN collections tc ON tc.id = t.collection_id WHERE l.source_id = ? AND l.link_type = 'phase' - `, itemID) + `, sStatusExpr, tStatusExpr)), itemID) if err != nil { return nil, fmt.Errorf("get phase for item: %w", err) } @@ -990,10 +1050,10 @@ func (s *Store) GetPhaseForItem(itemID string) (*models.ItemLink, error) { // GetTaskPhaseMap returns a map of item ID -> phase item ID for all phase links // in a workspace. Used for efficient batch lookups (e.g., dashboard). func (s *Store) GetTaskPhaseMap(workspaceID string) (map[string]string, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT source_id, target_id FROM item_links WHERE workspace_id = ? AND link_type = 'phase' - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("get task phase map: %w", err) } @@ -1017,15 +1077,16 @@ func (s *Store) GetTaskPhaseMap(workspaceID string) (map[string]string, error) { func (s *Store) GetPhaseProgress(phaseItemID string) (total int, done int, err error) { termPlaceholders, termArgs := s.getTasksTerminalPlaceholders() args := append(termArgs, phaseItemID) - err = s.db.QueryRow(` + statusExpr := s.dialect.JSONExtractText("i.fields", "status") + err = s.db.QueryRow(s.q(fmt.Sprintf(` SELECT COUNT(*), - COUNT(CASE WHEN LOWER(json_extract(i.fields, '$.status')) IN (`+termPlaceholders+`) THEN 1 END) + COUNT(CASE WHEN LOWER(%s) IN (%s) THEN 1 END) FROM items i JOIN collections c ON c.id = i.collection_id JOIN item_links il ON il.source_id = i.id AND il.link_type = 'phase' AND il.target_id = ? WHERE c.slug = 'tasks' AND i.deleted_at IS NULL - `, args...).Scan(&total, &done) + `, statusExpr, termPlaceholders)), args...).Scan(&total, &done) if err != nil { return 0, 0, fmt.Errorf("get phase progress: %w", err) } @@ -1038,7 +1099,7 @@ func (s *Store) GetPhaseProgress(phaseItemID string) (total int, done int, err e func (s *Store) getTasksTerminalPlaceholders() (string, []any) { // Try to find the tasks collection schema in any workspace var schemaJSON sql.NullString - _ = s.db.QueryRow(`SELECT schema FROM collections WHERE slug = 'tasks' AND deleted_at IS NULL LIMIT 1`).Scan(&schemaJSON) + _ = s.db.QueryRow(s.q(`SELECT schema FROM collections WHERE slug = 'tasks' AND deleted_at IS NULL LIMIT 1`)).Scan(&schemaJSON) if schemaJSON.Valid { var schema models.CollectionSchema if err := json.Unmarshal([]byte(schemaJSON.String), &schema); err == nil { @@ -1059,10 +1120,11 @@ type PhaseProgress struct { func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error) { termPlaceholders, termArgs := s.getTasksTerminalPlaceholders() args := append(termArgs, workspaceID) - rows, err := s.db.Query(` + tStatusExpr2 := s.dialect.JSONExtractText("t.fields", "status") + rows, err := s.db.Query(s.q(fmt.Sprintf(` SELECT p.id, COUNT(t.id), - COUNT(CASE WHEN LOWER(json_extract(t.fields, '$.status')) IN (`+termPlaceholders+`) THEN 1 END) + COUNT(CASE WHEN LOWER(%s) IN (%s) THEN 1 END) FROM items p JOIN collections pc ON pc.id = p.collection_id AND pc.slug = 'phases' LEFT JOIN item_links il ON il.link_type = 'phase' AND il.target_id = p.id @@ -1072,7 +1134,7 @@ func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error WHERE p.workspace_id = ? AND p.deleted_at IS NULL GROUP BY p.id - `, args...) + `, tStatusExpr2, termPlaceholders)), args...) if err != nil { return nil, fmt.Errorf("get all phases progress: %w", err) } @@ -1094,7 +1156,7 @@ func (s *Store) GetAllPhasesProgress(workspaceID string) ([]PhaseProgress, error // GetTasksForPhase returns all non-deleted tasks linked to the given phase via item_links. func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, i.created_by, i.last_modified_by, i.source, @@ -1110,7 +1172,7 @@ func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) { WHERE c.slug = 'tasks' AND i.deleted_at IS NULL ORDER BY i.sort_order ASC, i.created_at ASC - `, phaseItemID) + `), phaseItemID) if err != nil { return nil, fmt.Errorf("get tasks for phase: %w", err) } @@ -1131,16 +1193,16 @@ func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*mod // Get next item_number in the target collection var nextNumber int - err = tx.QueryRow(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`, targetCollectionID).Scan(&nextNumber) + err = tx.QueryRow(s.q(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`), targetCollectionID).Scan(&nextNumber) if err != nil { return nil, fmt.Errorf("get next item number: %w", err) } // Update the item - _, err = tx.Exec(` + _, err = tx.Exec(s.q(` UPDATE items SET collection_id = ?, fields = ?, item_number = ?, updated_at = ? - WHERE id = ? AND deleted_at IS NULL`, + WHERE id = ? AND deleted_at IS NULL`), targetCollectionID, newFieldsJSON, nextNumber, time.Now().UTC().Format(time.RFC3339), itemID) if err != nil { return nil, fmt.Errorf("move item: %w", err) @@ -1158,15 +1220,15 @@ func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*mod // validSortField matches safe field names (alphanumeric + underscore, starting with a letter). var validSortField = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) -func buildItemSort(sort string) string { +func buildItemSort(sort string, dialect Dialect) string { if sort == "" { return " ORDER BY i.pinned DESC, i.updated_at DESC" } var parts []string - for _, s := range strings.Split(sort, ",") { - s = strings.TrimSpace(s) - tokens := strings.SplitN(s, ":", 2) + for _, seg := range strings.Split(sort, ",") { + seg = strings.TrimSpace(seg) + tokens := strings.SplitN(seg, ":", 2) col := tokens[0] dir := "ASC" if len(tokens) == 2 && strings.ToUpper(tokens[1]) == "DESC" { @@ -1183,12 +1245,12 @@ func buildItemSort(sort string) string { case "sort_order": parts = append(parts, fmt.Sprintf("i.sort_order %s", dir)) default: - // For field-based sorting, use json_extract — validate the field name + // For field-based sorting, use dialect JSON extract — validate the field name // to prevent SQL injection via crafted sort parameters. if !validSortField.MatchString(col) { continue // skip invalid field names } - parts = append(parts, fmt.Sprintf("json_extract(i.fields, '$.%s') %s", col, dir)) + parts = append(parts, fmt.Sprintf("%s %s", dialect.JSONExtractText("i.fields", col), dir)) } } @@ -1201,13 +1263,13 @@ func buildItemSort(sort string) string { // shouldCreateItemVersion mirrors ShouldCreateVersion but queries item_versions. func (s *Store) shouldCreateItemVersion(itemID, actor, source string) (bool, error) { var createdBy, src, createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT created_by, source, created_at FROM item_versions WHERE item_id = ? ORDER BY created_at DESC LIMIT 1 - `, itemID).Scan(&createdBy, &src, &createdAt) + `), itemID).Scan(&createdBy, &src, &createdAt) if err == sql.ErrNoRows { return true, nil // No versions yet } @@ -1257,13 +1319,13 @@ func (s *Store) ListItemVersionsResolved(itemID, currentContent string) ([]model // ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination. func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, beforeID string, limit int) ([]models.Version, error) { ts := before.Format(time.RFC3339) - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, item_id, content, change_summary, created_by, source, is_diff, created_at FROM item_versions WHERE item_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ? - `, itemID, ts, ts, beforeID, limit) + `), itemID, ts, ts, beforeID, limit) if err != nil { return nil, err } @@ -1286,12 +1348,12 @@ func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, befo // ListItemVersions returns all versions for an item. func (s *Store) ListItemVersions(itemID string) ([]models.Version, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, item_id, content, change_summary, created_by, source, is_diff, created_at FROM item_versions WHERE item_id = ? ORDER BY created_at DESC - `, itemID) + `), itemID) if err != nil { return nil, err } diff --git a/internal/store/password_resets.go b/internal/store/password_resets.go index 8960a686..eecd29cf 100644 --- a/internal/store/password_resets.go +++ b/internal/store/password_resets.go @@ -18,9 +18,9 @@ const resetTokenTTL = 1 * time.Hour // stored as a SHA-256 hash — the plaintext cannot be recovered. func (s *Store) CreatePasswordReset(userID string) (string, error) { // Invalidate any existing unused tokens for this user - _, _ = s.db.Exec(` + _, _ = s.db.Exec(s.q(` UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL - `, now(), userID) + `), now(), userID) // Generate token raw := make([]byte, 32) @@ -33,10 +33,10 @@ func (s *Store) CreatePasswordReset(userID string) (string, error) { expiresAt := time.Now().UTC().Add(resetTokenTTL).Format(time.RFC3339) - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?) - `, newID(), userID, tokenHash, expiresAt, now()) + `), newID(), userID, tokenHash, expiresAt, now()) if err != nil { return "", fmt.Errorf("insert reset token: %w", err) } @@ -57,12 +57,12 @@ func (s *Store) ConsumePasswordReset(token string) (*models.User, error) { // currently unused and not expired. The WHERE clause ensures only // one concurrent caller can succeed. var userID string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? RETURNING user_id - `, now(), tokenHash, now()).Scan(&userID) + `), now(), tokenHash, now()).Scan(&userID) if err == sql.ErrNoRows { return nil, nil // Invalid, expired, or already used @@ -82,8 +82,8 @@ func (s *Store) ConsumePasswordReset(token string) (*models.User, error) { // CleanExpiredPasswordResets removes old reset tokens. func (s *Store) CleanExpiredPasswordResets() error { - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at IS NOT NULL - `, now()) + `), now()) return err } diff --git a/internal/store/pgmigrations/001_initial.sql b/internal/store/pgmigrations/001_initial.sql new file mode 100644 index 00000000..b2491a11 --- /dev/null +++ b/internal/store/pgmigrations/001_initial.sql @@ -0,0 +1,443 @@ +-- Pad PostgreSQL schema (consolidated from SQLite migrations 001-021) +-- This is the initial schema for PostgreSQL deployments. + +-- Enable UUID generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ========== Core tables ========== + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + settings JSONB NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + title TEXT NOT NULL, + slug TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + doc_type TEXT NOT NULL DEFAULT 'notes' + CHECK (doc_type IN ('roadmap','phase-plan','architecture','ideation', + 'feature-spec','notes','prompt-library','reference')), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','active','completed','archived')), + tags JSONB NOT NULL DEFAULT '[]', + pinned BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT 'user' + CHECK (created_by IN ('user','agent')), + last_modified_by TEXT NOT NULL DEFAULT 'user' + CHECK (last_modified_by IN ('user','agent')), + source TEXT NOT NULL DEFAULT 'web' + CHECK (source IN ('cli','web','skill')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + + -- Full-text search vector (auto-updated via trigger) + search_vector TSVECTOR, + + UNIQUE(workspace_id, slug), + UNIQUE(workspace_id, title) +); + +CREATE INDEX IF NOT EXISTS idx_documents_workspace ON documents(workspace_id); +CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(workspace_id, doc_type); +CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(workspace_id, status); +CREATE INDEX IF NOT EXISTS idx_documents_updated ON documents(workspace_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_documents_fts ON documents USING GIN(search_vector); + +-- Trigger to maintain document search vector +CREATE OR REPLACE FUNCTION documents_search_vector_update() RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER documents_search_vector_trigger + BEFORE INSERT OR UPDATE OF title, content, tags ON documents + FOR EACH ROW EXECUTE FUNCTION documents_search_vector_update(); + +CREATE TABLE IF NOT EXISTS versions ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id), + content TEXT NOT NULL, + change_summary TEXT NOT NULL DEFAULT '', + is_diff BOOLEAN NOT NULL DEFAULT FALSE, + created_by TEXT NOT NULL DEFAULT 'user' + CHECK (created_by IN ('user','agent')), + source TEXT NOT NULL DEFAULT 'web' + CHECK (source IN ('cli','web','skill')), + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_versions_document ON versions(document_id, created_at); + +CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + document_id TEXT, + action TEXT NOT NULL + CHECK (action IN ('created','updated','archived', + 'restored','read','searched')), + actor TEXT NOT NULL CHECK (actor IN ('user','agent')), + source TEXT NOT NULL CHECK (source IN ('cli','web','skill')), + metadata JSONB NOT NULL DEFAULT '{}', + user_id TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_activities_workspace ON activities(workspace_id, created_at); +CREATE INDEX IF NOT EXISTS idx_activities_document ON activities(document_id, created_at); + +-- ========== Collections & Items ========== + +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + icon TEXT DEFAULT '', + description TEXT DEFAULT '', + prefix TEXT NOT NULL DEFAULT '', + schema JSONB NOT NULL DEFAULT '{"fields":[]}', + settings JSONB DEFAULT '{}', + sort_order INTEGER DEFAULT 0, + is_default BOOLEAN DEFAULT FALSE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + UNIQUE(workspace_id, slug) +); + +CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + collection_id TEXT NOT NULL REFERENCES collections(id), + title TEXT NOT NULL, + slug TEXT NOT NULL, + content TEXT DEFAULT '', + fields JSONB DEFAULT '{}', + tags JSONB DEFAULT '[]', + pinned BOOLEAN DEFAULT FALSE, + sort_order INTEGER DEFAULT 0, + item_number INTEGER, + parent_id TEXT REFERENCES items(id), + created_by TEXT DEFAULT 'user', + last_modified_by TEXT DEFAULT 'user', + source TEXT DEFAULT 'web', + created_by_user_id TEXT, + last_modified_by_user_id TEXT, + assigned_user_id TEXT, + agent_role_id TEXT, + role_sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + + -- Full-text search vector + search_vector TSVECTOR, + + UNIQUE(workspace_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_items_collection ON items(collection_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_workspace ON items(workspace_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_updated ON items(updated_at) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_assigned_user ON items(assigned_user_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_agent_role ON items(agent_role_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_items_fts ON items USING GIN(search_vector); + +-- Trigger to maintain item search vector +CREATE OR REPLACE FUNCTION items_search_vector_update() RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER items_search_vector_trigger + BEFORE INSERT OR UPDATE OF title, content, tags ON items + FOR EACH ROW EXECUTE FUNCTION items_search_vector_update(); + +CREATE TABLE IF NOT EXISTS item_links ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + source_id TEXT NOT NULL REFERENCES items(id), + target_id TEXT NOT NULL REFERENCES items(id), + link_type TEXT DEFAULT 'related', + created_by TEXT DEFAULT 'user', + user_id TEXT, + created_at TEXT NOT NULL, + UNIQUE(source_id, target_id, link_type) +); + +CREATE INDEX IF NOT EXISTS idx_links_source ON item_links(source_id); +CREATE INDEX IF NOT EXISTS idx_links_target ON item_links(target_id); + +CREATE TABLE IF NOT EXISTS views ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + collection_id TEXT REFERENCES collections(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + view_type TEXT NOT NULL, + config JSONB DEFAULT '{}', + sort_order INTEGER DEFAULT 0, + is_default BOOLEAN DEFAULT FALSE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(workspace_id, slug) +); + +CREATE TABLE IF NOT EXISTS item_versions ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES items(id), + content TEXT NOT NULL, + change_summary TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT 'user', + source TEXT NOT NULL DEFAULT 'web', + is_diff BOOLEAN NOT NULL DEFAULT FALSE, + user_id TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_item_versions_item ON item_versions(item_id, created_at); + +-- ========== Comments & Reactions ========== + +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES items(id), + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + author TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + user_id TEXT, + activity_id TEXT, + parent_id TEXT REFERENCES comments(id), + created_by TEXT NOT NULL DEFAULT 'user' + CHECK (created_by IN ('user', 'agent')), + source TEXT NOT NULL DEFAULT 'web' + CHECK (source IN ('cli', 'web', 'skill')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + -- Full-text search vector + search_vector TSVECTOR +); + +CREATE INDEX IF NOT EXISTS idx_comments_item ON comments(item_id, created_at); +CREATE INDEX IF NOT EXISTS idx_comments_workspace ON comments(workspace_id, created_at); +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id); +CREATE INDEX IF NOT EXISTS idx_comments_fts ON comments USING GIN(search_vector); + +-- Trigger to maintain comment search vector +CREATE OR REPLACE FUNCTION comments_search_vector_update() RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + to_tsvector('english', COALESCE(NEW.body, '')); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER comments_search_vector_trigger + BEFORE INSERT OR UPDATE OF body ON comments + FOR EACH ROW EXECUTE FUNCTION comments_search_vector_update(); + +CREATE TABLE IF NOT EXISTS comment_reactions ( + id TEXT PRIMARY KEY, + comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + user_id TEXT, + actor TEXT NOT NULL DEFAULT 'user', + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(comment_id, user_id, emoji) +); + +CREATE INDEX IF NOT EXISTS idx_comment_reactions_comment ON comment_reactions(comment_id); + +-- ========== Users & Auth ========== + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('admin', 'member')), + avatar_url TEXT DEFAULT '', + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT, + updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL, + device_info TEXT DEFAULT '', + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + user_id TEXT NOT NULL REFERENCES users(id), + role TEXT NOT NULL DEFAULT 'editor' + CHECK (role IN ('owner', 'editor', 'viewer')), + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT, + PRIMARY KEY (workspace_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); + +CREATE TABLE IF NOT EXISTS workspace_invitations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor' + CHECK (role IN ('owner', 'editor', 'viewer')), + invited_by TEXT NOT NULL REFERENCES users(id), + code TEXT NOT NULL UNIQUE, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +CREATE INDEX IF NOT EXISTS idx_invitations_workspace ON workspace_invitations(workspace_id); +CREATE INDEX IF NOT EXISTS idx_invitations_code ON workspace_invitations(code); +CREATE INDEX IF NOT EXISTS idx_invitations_email ON workspace_invitations(email); + +-- ========== API Tokens ========== + +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + user_id TEXT REFERENCES users(id), + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + prefix TEXT NOT NULL, + scopes JSONB NOT NULL DEFAULT '["*"]', + expires_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +-- ========== Webhooks ========== + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + url TEXT NOT NULL, + secret TEXT DEFAULT '', + events JSONB NOT NULL DEFAULT '["*"]', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT, + updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT, + last_triggered_at TEXT, + failure_count INTEGER NOT NULL DEFAULT 0 +); + +-- ========== Agent Roles ========== + +CREATE TABLE IF NOT EXISTS agent_roles ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT '', + tools TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(workspace_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_agent_roles_workspace ON agent_roles(workspace_id); + +-- Foreign keys for items that reference users/agent_roles (added after tables exist) +ALTER TABLE items ADD CONSTRAINT fk_items_created_by_user FOREIGN KEY (created_by_user_id) REFERENCES users(id); +ALTER TABLE items ADD CONSTRAINT fk_items_modified_by_user FOREIGN KEY (last_modified_by_user_id) REFERENCES users(id); +ALTER TABLE items ADD CONSTRAINT fk_items_assigned_user FOREIGN KEY (assigned_user_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE items ADD CONSTRAINT fk_items_agent_role FOREIGN KEY (agent_role_id) REFERENCES agent_roles(id) ON DELETE SET NULL; +ALTER TABLE comments ADD CONSTRAINT fk_comments_activity FOREIGN KEY (activity_id) REFERENCES activities(id); + +-- ========== Platform Settings ========== + +CREATE TABLE IF NOT EXISTS platform_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +-- ========== Password Resets ========== + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')::TEXT +); + +CREATE INDEX IF NOT EXISTS idx_reset_tokens_token_hash ON password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id ON password_reset_tokens(user_id); + +-- ========== Legacy tables (kept for compatibility) ========== + +CREATE TABLE IF NOT EXISTS custom_templates ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + doc_type TEXT NOT NULL DEFAULT 'notes', + icon TEXT NOT NULL DEFAULT '📝', + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(workspace_id, name) +); + +CREATE TABLE IF NOT EXISTS progress_snapshots ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + total_tasks INTEGER NOT NULL DEFAULT 0, + done_tasks INTEGER NOT NULL DEFAULT 0, + open_tasks INTEGER NOT NULL DEFAULT 0, + in_progress INTEGER NOT NULL DEFAULT 0, + percentage REAL NOT NULL DEFAULT 0.0, + phase_data JSONB NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_snapshots_workspace_time + ON progress_snapshots(workspace_id, created_at); + +-- ========== Migration tracking ========== + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +); diff --git a/internal/store/platform_settings.go b/internal/store/platform_settings.go index 855ca9d2..e300d005 100644 --- a/internal/store/platform_settings.go +++ b/internal/store/platform_settings.go @@ -5,7 +5,7 @@ import "database/sql" // GetPlatformSetting returns a single platform setting value, or empty string if not set. func (s *Store) GetPlatformSetting(key string) (string, error) { var value string - err := s.db.QueryRow("SELECT value FROM platform_settings WHERE key = ?", key).Scan(&value) + err := s.db.QueryRow(s.q("SELECT value FROM platform_settings WHERE key = ?"), key).Scan(&value) if err == sql.ErrNoRows { return "", nil } @@ -14,16 +14,16 @@ func (s *Store) GetPlatformSetting(key string) (string, error) { // SetPlatformSetting upserts a platform setting. func (s *Store) SetPlatformSetting(key, value string) error { - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO platform_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `, key, value, now()) + `), key, value, now()) return err } // GetPlatformSettings returns all platform settings as a map. func (s *Store) GetPlatformSettings() (map[string]string, error) { - rows, err := s.db.Query("SELECT key, value FROM platform_settings ORDER BY key") + rows, err := s.db.Query(s.q("SELECT key, value FROM platform_settings ORDER BY key")) if err != nil { return nil, err } @@ -42,6 +42,6 @@ func (s *Store) GetPlatformSettings() (map[string]string, error) { // DeletePlatformSetting removes a platform setting. func (s *Store) DeletePlatformSetting(key string) error { - _, err := s.db.Exec("DELETE FROM platform_settings WHERE key = ?", key) + _, err := s.db.Exec(s.q("DELETE FROM platform_settings WHERE key = ?"), key) return err } diff --git a/internal/store/reactions.go b/internal/store/reactions.go index f8e8c9d5..e054fc34 100644 --- a/internal/store/reactions.go +++ b/internal/store/reactions.go @@ -14,10 +14,10 @@ func (s *Store) AddReaction(commentID, userID, actor, emoji string) (*models.Rea // Store empty string (not NULL) for anonymous users so the UNIQUE constraint // on (comment_id, user_id, emoji) works correctly — SQLite treats NULL != NULL. - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO comment_reactions (id, comment_id, user_id, actor, emoji, created_at) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(comment_id, user_id, emoji) DO NOTHING`, + ON CONFLICT(comment_id, user_id, emoji) DO NOTHING`), id, commentID, userID, actor, emoji, ts, ) if err != nil { @@ -31,10 +31,10 @@ func (s *Store) AddReaction(commentID, userID, actor, emoji string) (*models.Rea func (s *Store) getReaction(commentID, userID, emoji string) (*models.Reaction, error) { var r models.Reaction var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, comment_id, COALESCE(user_id, ''), actor, emoji, created_at FROM comment_reactions - WHERE comment_id = ? AND user_id = ? AND emoji = ?`, + WHERE comment_id = ? AND user_id = ? AND emoji = ?`), commentID, userID, emoji, ).Scan(&r.ID, &r.CommentID, &r.UserID, &r.Actor, &r.Emoji, &createdAt) if err != nil { @@ -46,9 +46,9 @@ func (s *Store) getReaction(commentID, userID, emoji string) (*models.Reaction, // RemoveReaction removes a specific emoji reaction by a user from a comment. func (s *Store) RemoveReaction(commentID, userID, emoji string) error { - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` DELETE FROM comment_reactions - WHERE comment_id = ? AND user_id = ? AND emoji = ?`, + WHERE comment_id = ? AND user_id = ? AND emoji = ?`), commentID, userID, emoji, ) if err != nil { @@ -84,7 +84,7 @@ func (s *Store) ListReactionsByComments(commentIDs []string) (map[string][]model } query += `) ORDER BY cr.created_at ASC` - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, fmt.Errorf("list reactions: %w", err) } diff --git a/internal/store/search.go b/internal/store/search.go index ef8bfa39..10d36364 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -60,7 +60,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { } } - refRows, err := s.db.Query(refQuery, refArgs...) + refRows, err := s.db.Query(s.q(refQuery), refArgs...) if err == nil { defer refRows.Close() for refRows.Next() { @@ -92,25 +92,61 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { // If no ref matches, fall through to FTS below } - query := ` - SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, - i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, - i.created_by, i.last_modified_by, i.source, - i.item_number, i.created_at, i.updated_at, - c.slug, c.name, c.icon, c.prefix, - COALESCE(au.name, ''), COALESCE(au.email, ''), - COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), - snippet(items_fts, 1, '', '', '...', 32) as snippet, - rank - FROM items_fts fts - JOIN items i ON i.rowid = fts.rowid - JOIN collections c ON c.id = i.collection_id - LEFT JOIN users au ON au.id = i.assigned_user_id - LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id - WHERE items_fts MATCH ? - AND i.deleted_at IS NULL - ` - args := []interface{}{sanitizeFTSQuery(params.Query)} + // Build the FTS query — the approach differs between SQLite (FTS5 virtual table) + // and PostgreSQL (tsvector column on the items table). + ftsSnippet := s.dialect.FTSSnippet("items_fts", 1, "i.content") + ftsRank := s.dialect.FTSRank("items_fts", "search_vector") + ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector") + + var query string + var args []interface{} + + if s.dialect.Driver() == DriverPostgres { + // PostgreSQL: search_vector is a column on the items table; no JOIN needed. + // FTSSnippet and FTSRank reference plainto_tsquery, so each needs the query param. + query = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), + %s as snippet, + %s as rank_score + FROM items i + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE %s + AND i.deleted_at IS NULL + `, ftsSnippet, ftsRank, ftsMatch) + // PostgreSQL dialect's FTSSnippet, FTSRank, and FTSMatch each consume a "?" placeholder + // for the query parameter (plainto_tsquery('english', ?)). + searchQuery := params.Query + args = []interface{}{searchQuery, searchQuery, searchQuery} + } else { + // SQLite: uses FTS5 virtual table with JOIN on rowid. + query = fmt.Sprintf(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, ''), + %s as snippet, + %s as rank_score + FROM items_fts fts + JOIN items i ON i.rowid = fts.rowid + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE %s + AND i.deleted_at IS NULL + `, ftsSnippet, ftsRank, ftsMatch) + args = []interface{}{sanitizeFTSQuery(params.Query)} + } if params.Workspace != "" { query += ` @@ -126,9 +162,9 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { } } - query += " ORDER BY rank LIMIT 50" + query += " ORDER BY rank_score LIMIT 50" - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { // If we already have a ref match, return that instead of failing // (FTS5 may reject queries like "TASK-5" due to special syntax) @@ -180,6 +216,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { // sanitizeFTSQuery wraps each token in double quotes so FTS5 treats // special characters (like hyphens) as literals rather than operators. +// Only used for SQLite FTS5 queries. func sanitizeFTSQuery(q string) string { q = strings.TrimSpace(q) if q == "" { diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 59c71410..1e0578b8 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -29,10 +29,10 @@ func (s *Store) CreateSession(userID, deviceInfo string, ttl time.Duration) (str ts := now() expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339) - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO sessions (id, user_id, token_hash, device_info, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?) - `, id, userID, tokenHash, deviceInfo, expiresAt, ts) + `), id, userID, tokenHash, deviceInfo, expiresAt, ts) if err != nil { return "", fmt.Errorf("insert session: %w", err) } @@ -47,9 +47,9 @@ func (s *Store) ValidateSession(token string) (*models.User, error) { tokenHash := hex.EncodeToString(hash[:]) var userID, expiresAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT user_id, expires_at FROM sessions WHERE token_hash = ? - `, tokenHash).Scan(&userID, &expiresAt) + `), tokenHash).Scan(&userID, &expiresAt) if err == sql.ErrNoRows { return nil, nil } @@ -70,7 +70,7 @@ func (s *Store) DeleteSession(token string) error { hash := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(hash[:]) - _, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash) + _, err := s.db.Exec(s.q("DELETE FROM sessions WHERE token_hash = ?"), tokenHash) if err != nil { return fmt.Errorf("delete session: %w", err) } @@ -79,7 +79,7 @@ func (s *Store) DeleteSession(token string) error { // DeleteUserSessions destroys all sessions for a user (logout everywhere). func (s *Store) DeleteUserSessions(userID string) error { - _, err := s.db.Exec("DELETE FROM sessions WHERE user_id = ?", userID) + _, err := s.db.Exec(s.q("DELETE FROM sessions WHERE user_id = ?"), userID) if err != nil { return fmt.Errorf("delete user sessions: %w", err) } @@ -88,7 +88,7 @@ func (s *Store) DeleteUserSessions(userID string) error { // CleanExpiredSessions removes all sessions past their expiry time. func (s *Store) CleanExpiredSessions() error { - _, err := s.db.Exec("DELETE FROM sessions WHERE expires_at < ?", now()) + _, err := s.db.Exec(s.q("DELETE FROM sessions WHERE expires_at < ?"), now()) if err != nil { return fmt.Errorf("clean expired sessions: %w", err) } diff --git a/internal/store/snapshots.go b/internal/store/snapshots.go index d91de550..994a420b 100644 --- a/internal/store/snapshots.go +++ b/internal/store/snapshots.go @@ -9,9 +9,9 @@ import ( // CreateSnapshot inserts a new progress snapshot. func (s *Store) CreateSnapshot(snap models.ProgressSnapshot) error { - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO progress_snapshots (id, workspace_id, total_tasks, done_tasks, open_tasks, in_progress, percentage, phase_data, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), newID(), snap.WorkspaceID, snap.TotalTasks, snap.DoneTasks, snap.OpenTasks, snap.InProgress, snap.Percentage, snap.PhaseData, now(), ) if err != nil { @@ -44,7 +44,7 @@ func (s *Store) ListSnapshots(workspaceID string, params models.SnapshotListPara query += fmt.Sprintf(" LIMIT %d", params.Limit) } - rows, err := s.db.Query(query, args...) + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, fmt.Errorf("list snapshots: %w", err) } @@ -67,12 +67,12 @@ func (s *Store) ListSnapshots(workspaceID string, params models.SnapshotListPara func (s *Store) LatestSnapshot(workspaceID string) (*models.ProgressSnapshot, error) { var snap models.ProgressSnapshot var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, total_tasks, done_tasks, open_tasks, in_progress, percentage, phase_data, created_at FROM progress_snapshots WHERE workspace_id = ? ORDER BY created_at DESC - LIMIT 1`, + LIMIT 1`), workspaceID, ).Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PhaseData, &createdAt) @@ -88,9 +88,9 @@ func (s *Store) LatestSnapshot(workspaceID string) (*models.ProgressSnapshot, er // DeleteOldSnapshots removes snapshots older than the given time. func (s *Store) DeleteOldSnapshots(workspaceID string, olderThan time.Time) (int64, error) { - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` DELETE FROM progress_snapshots - WHERE workspace_id = ? AND created_at < ?`, + WHERE workspace_id = ? AND created_at < ?`), workspaceID, olderThan.UTC().Format(time.RFC3339), ) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index fed1fdd3..9a0a9259 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver "github.com/xarmian/pad/internal/collections" _ "modernc.org/sqlite" ) @@ -15,10 +16,21 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS +//go:embed pgmigrations/*.sql +var pgMigrationsFS embed.FS + type Store struct { - db *sql.DB + db *sql.DB + dialect Dialect } +// D returns the store's dialect for building backend-specific SQL. +func (s *Store) D() Dialect { return s.dialect } + +// DB returns the underlying *sql.DB (for use in migrations/testing). +func (s *Store) DB() *sql.DB { return s.db } + +// New creates a Store backed by SQLite at the given path. func New(dbPath string) (*Store, error) { db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)") if err != nil { @@ -35,7 +47,7 @@ func New(dbPath string) (*Store, error) { return nil, fmt.Errorf("enable foreign keys: %w", err) } - s := &Store{db: db} + s := &Store{db: db, dialect: &sqliteDialect{}} if err := s.migrate(); err != nil { return nil, fmt.Errorf("migrate: %w", err) } @@ -51,6 +63,40 @@ func New(dbPath string) (*Store, error) { return s, nil } +// NewPostgres creates a Store backed by PostgreSQL. +// The connStr should be a PostgreSQL connection string (e.g. "postgres://user:pass@host/db"). +func NewPostgres(connStr string) (*Store, error) { + db, err := sql.Open("pgx", connStr) + if err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + + // Verify connection + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping postgres: %w", err) + } + + // Connection pool tuning for cloud deployment + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + s := &Store{db: db, dialect: &postgresDialect{}} + if err := s.migratePostgres(); err != nil { + return nil, fmt.Errorf("migrate postgres: %w", err) + } + + if err := s.backfillItemNumbers(); err != nil { + return nil, fmt.Errorf("backfill item numbers: %w", err) + } + + if err := s.backfillWorkspaceOwners(); err != nil { + return nil, fmt.Errorf("backfill workspace owners: %w", err) + } + + return s, nil +} + func (s *Store) Close() error { return s.db.Close() } @@ -123,6 +169,49 @@ func (s *Store) migrate() error { return nil } +// migratePostgres applies PostgreSQL migrations. +// PostgreSQL supports multi-statement execution natively, so we don't need execMulti. +func (s *Store) migratePostgres() error { + // Create migrations tracking table + _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + )`) + if err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + + migrations := []string{ + "001_initial.sql", + } + + for _, name := range migrations { + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = $1", name).Scan(&count); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if count > 0 { + continue + } + + data, err := pgMigrationsFS.ReadFile("pgmigrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + if _, err := s.db.Exec(string(data)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + + _, err = s.db.Exec("INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)", name, now()) + if err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + + return nil +} + // execMulti executes multiple SQL statements by iteratively using // database/sql's Exec which processes one statement at a time, // then advancing past it using the driver's awareness of statement boundaries. @@ -243,6 +332,11 @@ func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' } +// q rebinds a query to the store's dialect (converts "?" to "$1", "$2", etc. for PostgreSQL). +func (s *Store) q(query string) string { + return s.dialect.Rebind(query) +} + func newID() string { return uuid.New().String() } diff --git a/internal/store/templates.go b/internal/store/templates.go index 7ef6eecc..4f49bc8d 100644 --- a/internal/store/templates.go +++ b/internal/store/templates.go @@ -8,12 +8,12 @@ import ( ) func (s *Store) ListCustomTemplates(workspaceID string) ([]models.CustomTemplate, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at FROM custom_templates WHERE workspace_id = ? ORDER BY name ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list custom templates: %w", err) } @@ -36,11 +36,11 @@ func (s *Store) ListCustomTemplates(workspaceID string) ([]models.CustomTemplate func (s *Store) GetCustomTemplate(id string) (*models.CustomTemplate, error) { var t models.CustomTemplate var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at FROM custom_templates WHERE id = ? - `, id).Scan(&t.ID, &t.WorkspaceID, &t.Name, &t.Description, &t.DocType, &t.Icon, &t.Content, &createdAt, &updatedAt) + `), id).Scan(&t.ID, &t.WorkspaceID, &t.Name, &t.Description, &t.DocType, &t.Icon, &t.Content, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -56,10 +56,10 @@ func (s *Store) CreateCustomTemplate(input models.CustomTemplateCreate) (*models id := newID() ts := now() - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO custom_templates (id, workspace_id, name, description, doc_type, icon, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, id, input.WorkspaceID, input.Name, input.Description, input.DocType, input.Icon, input.Content, ts, ts) + `), id, input.WorkspaceID, input.Name, input.Description, input.DocType, input.Icon, input.Content, ts, ts) if err != nil { return nil, fmt.Errorf("create custom template: %w", err) } @@ -68,7 +68,7 @@ func (s *Store) CreateCustomTemplate(input models.CustomTemplateCreate) (*models } func (s *Store) DeleteCustomTemplate(id string) error { - result, err := s.db.Exec(`DELETE FROM custom_templates WHERE id = ?`, id) + result, err := s.db.Exec(s.q(`DELETE FROM custom_templates WHERE id = ?`), id) if err != nil { return err } diff --git a/internal/store/users.go b/internal/store/users.go index 46b98cb2..83943c78 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -26,10 +26,10 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) { id := newID() ts := now() - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Name), string(hash), role, ts, ts) + `), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Name), string(hash), role, ts, ts) if err != nil { return nil, fmt.Errorf("insert user: %w", err) } @@ -42,10 +42,10 @@ func (s *Store) GetUser(id string) (*models.User, error) { var u models.User var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at FROM users WHERE id = ? - `, id).Scan( + `), id).Scan( &u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &createdAt, &updatedAt, ) @@ -66,10 +66,10 @@ func (s *Store) GetUserByEmail(email string) (*models.User, error) { var u models.User var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at FROM users WHERE email = ? - `, strings.ToLower(strings.TrimSpace(email))).Scan( + `), strings.ToLower(strings.TrimSpace(email))).Scan( &u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &createdAt, &updatedAt, ) @@ -116,7 +116,7 @@ func (s *Store) UpdateUser(id string, input models.UserUpdate) (*models.User, er args = append(args, id) query := fmt.Sprintf("UPDATE users SET %s WHERE id = ?", strings.Join(sets, ", ")) - result, err := s.db.Exec(query, args...) + result, err := s.db.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update user: %w", err) } @@ -148,10 +148,10 @@ func (s *Store) ValidatePassword(email, password string) (*models.User, error) { // ListUsers returns all users. func (s *Store) ListUsers() ([]models.User, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, email, name, password_hash, role, avatar_url, created_at, updated_at FROM users ORDER BY created_at ASC - `) + `)) if err != nil { return nil, fmt.Errorf("list users: %w", err) } @@ -177,7 +177,7 @@ func (s *Store) ListUsers() ([]models.User, error) { // UserCount returns the total number of registered users. func (s *Store) UserCount() (int, error) { var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM users")).Scan(&count) if err != nil { return 0, fmt.Errorf("count users: %w", err) } diff --git a/internal/store/versions.go b/internal/store/versions.go index dfb9608d..d1d71b39 100644 --- a/internal/store/versions.go +++ b/internal/store/versions.go @@ -52,13 +52,13 @@ func (s *Store) getLatestVersionRaw(documentID string) (*models.Version, error) var v models.Version var createdAt string var isDiff int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at FROM versions WHERE document_id = ? ORDER BY created_at DESC LIMIT 1 - `, documentID).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt) + `), documentID).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt) if err == sql.ErrNoRows { return nil, nil } @@ -71,12 +71,12 @@ func (s *Store) getLatestVersionRaw(documentID string) (*models.Version, error) } func (s *Store) ListVersions(documentID string) ([]models.Version, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at FROM versions WHERE document_id = ? ORDER BY created_at DESC - `, documentID) + `), documentID) if err != nil { return nil, err } @@ -134,11 +134,11 @@ func (s *Store) GetVersion(id string) (*models.Version, error) { var v models.Version var createdAt string var isDiff int - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at FROM versions WHERE id = ? - `, id).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt) + `), id).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt) if err == sql.ErrNoRows { return nil, nil } diff --git a/internal/store/views.go b/internal/store/views.go index 3cdb42d2..d6096ea4 100644 --- a/internal/store/views.go +++ b/internal/store/views.go @@ -31,9 +31,9 @@ func (s *Store) CreateView(workspaceID string, input models.ViewCreate) (*models viewType = "list" } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO views (id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`), id, workspaceID, input.CollectionID, input.Name, slug, viewType, config, ts, ts, ) if err != nil { @@ -50,10 +50,10 @@ func (s *Store) GetView(id string) (*models.View, error) { var isDefault int var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at FROM views - WHERE id = ?`, id).Scan( + WHERE id = ?`), id).Scan( &v.ID, &v.WorkspaceID, &collectionID, &v.Name, &v.Slug, &v.ViewType, &v.Config, &v.SortOrder, &isDefault, &createdAt, &updatedAt, ) @@ -78,10 +78,10 @@ func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) { var isDefault int var createdAt, updatedAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at FROM views - WHERE workspace_id = ? AND slug = ?`, workspaceID, slug).Scan( + WHERE workspace_id = ? AND slug = ?`), workspaceID, slug).Scan( &v.ID, &v.WorkspaceID, &collectionID, &v.Name, &v.Slug, &v.ViewType, &v.Config, &v.SortOrder, &isDefault, &createdAt, &updatedAt, ) @@ -101,11 +101,11 @@ func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) { // ListViews returns all views for a collection within a workspace. func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, workspace_id, collection_id, name, slug, view_type, config, sort_order, is_default, created_at, updated_at FROM views WHERE workspace_id = ? AND collection_id = ? - ORDER BY sort_order ASC, created_at ASC`, workspaceID, collectionID) + ORDER BY sort_order ASC, created_at ASC`), workspaceID, collectionID) if err != nil { return nil, fmt.Errorf("list views: %w", err) } @@ -169,7 +169,7 @@ func (s *Store) UpdateView(id string, input models.ViewUpdate) (*models.View, er } query += " WHERE id = ?" - result, err := s.db.Exec(query, args...) + result, err := s.db.Exec(s.q(query), args...) if err != nil { return nil, fmt.Errorf("update view: %w", err) } @@ -183,7 +183,7 @@ func (s *Store) UpdateView(id string, input models.ViewUpdate) (*models.View, er // DeleteView removes a view by ID. func (s *Store) DeleteView(id string) error { - result, err := s.db.Exec("DELETE FROM views WHERE id = ?", id) + result, err := s.db.Exec(s.q("DELETE FROM views WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete view: %w", err) } diff --git a/internal/store/webhooks.go b/internal/store/webhooks.go index 42446c28..465e998c 100644 --- a/internal/store/webhooks.go +++ b/internal/store/webhooks.go @@ -17,10 +17,10 @@ func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (* evts = `["*"]` } - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count) VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0) - `, id, workspaceID, input.URL, input.Secret, evts, ts, ts) + `), id, workspaceID, input.URL, input.Secret, evts, ts, ts) if err != nil { return nil, fmt.Errorf("insert webhook: %w", err) } @@ -35,11 +35,11 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) { var createdAt, updatedAt string var lastTriggeredAt *string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count FROM webhooks WHERE id = ? - `, id).Scan( + `), id).Scan( &wh.ID, &wh.WorkspaceID, &wh.URL, &wh.Secret, &wh.Events, &active, &createdAt, &updatedAt, &lastTriggeredAt, &wh.FailureCount, ) @@ -59,12 +59,12 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) { // ListWebhooks returns all webhooks for a workspace. func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, workspace_id, url, secret, events, active, created_at, updated_at, last_triggered_at, failure_count FROM webhooks WHERE workspace_id = ? ORDER BY created_at ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list webhooks: %w", err) } @@ -94,7 +94,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) { // DeleteWebhook removes a webhook by ID. func (s *Store) DeleteWebhook(id string) error { - result, err := s.db.Exec("DELETE FROM webhooks WHERE id = ?", id) + result, err := s.db.Exec(s.q("DELETE FROM webhooks WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete webhook: %w", err) } @@ -112,24 +112,24 @@ func (s *Store) DeleteWebhook(id string) error { func (s *Store) UpdateWebhookFailure(id string, failed bool) error { ts := now() if failed { - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` UPDATE webhooks SET failure_count = failure_count + 1, updated_at = ?, active = CASE WHEN failure_count + 1 >= 10 THEN 0 ELSE active END WHERE id = ? - `, ts, id) + `), ts, id) if err != nil { return fmt.Errorf("update webhook failure: %w", err) } } else { - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` UPDATE webhooks SET failure_count = 0, last_triggered_at = ?, updated_at = ? WHERE id = ? - `, ts, ts, id) + `), ts, ts, id) if err != nil { return fmt.Errorf("update webhook success: %w", err) } diff --git a/internal/store/workspace_members.go b/internal/store/workspace_members.go index e42a08b9..e97797f7 100644 --- a/internal/store/workspace_members.go +++ b/internal/store/workspace_members.go @@ -13,10 +13,10 @@ import ( // AddWorkspaceMember adds a user to a workspace with the given role. func (s *Store) AddWorkspaceMember(workspaceID, userID, role string) error { ts := now() - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES (?, ?, ?, ?) - `, workspaceID, userID, role, ts) + `), workspaceID, userID, role, ts) if err != nil { return fmt.Errorf("add workspace member: %w", err) } @@ -26,7 +26,7 @@ func (s *Store) AddWorkspaceMember(workspaceID, userID, role string) error { // RemoveWorkspaceMember removes a user from a workspace. func (s *Store) RemoveWorkspaceMember(workspaceID, userID string) error { result, err := s.db.Exec( - "DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?", + s.q("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID, ) if err != nil { @@ -44,11 +44,11 @@ func (s *Store) GetWorkspaceMember(workspaceID, userID string) (*models.Workspac var m models.WorkspaceMember var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT workspace_id, user_id, role, created_at FROM workspace_members WHERE workspace_id = ? AND user_id = ? - `, workspaceID, userID).Scan( + `), workspaceID, userID).Scan( &m.WorkspaceID, &m.UserID, &m.Role, &createdAt, ) if err == sql.ErrNoRows { @@ -65,14 +65,14 @@ func (s *Store) GetWorkspaceMember(workspaceID, userID string) (*models.Workspac // ListWorkspaceMembers returns all members of a workspace, enriched with // user name and email from a join. func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMember, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, u.name, u.email FROM workspace_members wm JOIN users u ON u.id = wm.user_id WHERE wm.workspace_id = ? ORDER BY wm.created_at ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list workspace members: %w", err) } @@ -96,13 +96,13 @@ func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMemb // GetUserWorkspaces returns all workspaces a user has access to. func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.deleted_at FROM workspaces w JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ? AND w.deleted_at IS NULL ORDER BY w.name ASC - `, userID) + `), userID) if err != nil { return nil, fmt.Errorf("get user workspaces: %w", err) } @@ -131,7 +131,7 @@ func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) { func (s *Store) IsWorkspaceMember(workspaceID, userID string) (bool, error) { var count int err := s.db.QueryRow( - "SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?", + s.q("SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID, ).Scan(&count) if err != nil { @@ -143,7 +143,7 @@ func (s *Store) IsWorkspaceMember(workspaceID, userID string) (bool, error) { // UpdateWorkspaceMemberRole changes a member's role in a workspace. func (s *Store) UpdateWorkspaceMemberRole(workspaceID, userID, role string) error { result, err := s.db.Exec( - "UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?", + s.q("UPDATE workspace_members SET role = ? WHERE workspace_id = ? AND user_id = ?"), role, workspaceID, userID, ) if err != nil { @@ -170,10 +170,10 @@ func (s *Store) CreateInvitation(workspaceID, email, role, invitedBy string) (*m id := newID() ts := now() - _, err := s.db.Exec(` + _, err := s.db.Exec(s.q(` INSERT INTO workspace_invitations (id, workspace_id, email, role, invited_by, code, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, id, workspaceID, strings.ToLower(strings.TrimSpace(email)), role, invitedBy, code, ts) + `), id, workspaceID, strings.ToLower(strings.TrimSpace(email)), role, invitedBy, code, ts) if err != nil { return nil, fmt.Errorf("insert invitation: %w", err) } @@ -187,10 +187,10 @@ func (s *Store) GetInvitation(id string) (*models.WorkspaceInvitation, error) { var acceptedAt *string var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at FROM workspace_invitations WHERE id = ? - `, id).Scan( + `), id).Scan( &inv.ID, &inv.WorkspaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Code, &acceptedAt, &createdAt, ) @@ -212,10 +212,10 @@ func (s *Store) GetInvitationByCode(code string) (*models.WorkspaceInvitation, e var acceptedAt *string var createdAt string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at FROM workspace_invitations WHERE code = ? AND accepted_at IS NULL - `, code).Scan( + `), code).Scan( &inv.ID, &inv.WorkspaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Code, &acceptedAt, &createdAt, ) @@ -234,7 +234,7 @@ func (s *Store) GetInvitationByCode(code string) (*models.WorkspaceInvitation, e // AcceptInvitation marks an invitation as accepted. func (s *Store) AcceptInvitation(id string) error { _, err := s.db.Exec( - "UPDATE workspace_invitations SET accepted_at = ? WHERE id = ?", + s.q("UPDATE workspace_invitations SET accepted_at = ? WHERE id = ?"), now(), id, ) if err != nil { @@ -246,7 +246,7 @@ func (s *Store) AcceptInvitation(id string) error { // DeleteInvitation removes a pending invitation. func (s *Store) DeleteInvitation(workspaceID, invitationID string) error { result, err := s.db.Exec( - "DELETE FROM workspace_invitations WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL", + s.q("DELETE FROM workspace_invitations WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL"), invitationID, workspaceID, ) if err != nil { @@ -261,12 +261,12 @@ func (s *Store) DeleteInvitation(workspaceID, invitationID string) error { // ListWorkspaceInvitations returns all invitations for a workspace. func (s *Store) ListWorkspaceInvitations(workspaceID string) ([]models.WorkspaceInvitation, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, workspace_id, email, role, invited_by, code, accepted_at, created_at FROM workspace_invitations WHERE workspace_id = ? AND accepted_at IS NULL ORDER BY created_at ASC - `, workspaceID) + `), workspaceID) if err != nil { return nil, fmt.Errorf("list workspace invitations: %w", err) } @@ -297,7 +297,7 @@ func (s *Store) backfillWorkspaceOwners() error { // Find the first admin user (if any) var adminID string err := s.db.QueryRow( - "SELECT id FROM users WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1", + s.q("SELECT id FROM users WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"), ).Scan(&adminID) if err == sql.ErrNoRows { return nil // No users yet — nothing to backfill @@ -307,11 +307,11 @@ func (s *Store) backfillWorkspaceOwners() error { } // Find workspaces with no members - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT w.id FROM workspaces w WHERE w.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = w.id) - `) + `)) if err != nil { return fmt.Errorf("find ownerless workspaces: %w", err) } diff --git a/internal/store/workspaces.go b/internal/store/workspaces.go index cc06eaad..4f7b4e44 100644 --- a/internal/store/workspaces.go +++ b/internal/store/workspaces.go @@ -8,12 +8,12 @@ import ( ) func (s *Store) ListWorkspaces() ([]models.Workspace, error) { - rows, err := s.db.Query(` + rows, err := s.db.Query(s.q(` SELECT id, name, slug, description, settings, created_at, updated_at FROM workspaces WHERE deleted_at IS NULL ORDER BY name ASC - `) + `)) if err != nil { return nil, err } @@ -69,10 +69,10 @@ func (s *Store) CreateWorkspace(input models.WorkspaceCreate) (*models.Workspace } } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` INSERT INTO workspaces (id, name, slug, description, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, id, input.Name, finalSlug, input.Description, settings, ts, ts) + `), id, input.Name, finalSlug, input.Description, settings, ts, ts) if err != nil { return nil, fmt.Errorf("insert workspace: %w", err) } @@ -84,7 +84,7 @@ func (s *Store) uniqueWorkspaceSlug(baseSlug string) (string, error) { slug := baseSlug for i := 2; ; i++ { var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM workspaces WHERE slug = ? AND deleted_at IS NULL", slug).Scan(&count) + err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM workspaces WHERE slug = ? AND deleted_at IS NULL"), slug).Scan(&count) if err != nil { return "", err } @@ -100,11 +100,11 @@ func (s *Store) GetWorkspaceBySlug(slug string) (*models.Workspace, error) { var createdAt, updatedAt string var deletedAt *string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, name, slug, description, settings, created_at, updated_at, deleted_at FROM workspaces WHERE slug = ? AND deleted_at IS NULL - `, slug).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt) + `), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt) if err == sql.ErrNoRows { return nil, nil } @@ -124,11 +124,11 @@ func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) { var createdAt, updatedAt string var deletedAt *string - err := s.db.QueryRow(` + err := s.db.QueryRow(s.q(` SELECT id, name, slug, description, settings, created_at, updated_at, deleted_at FROM workspaces WHERE id = ? AND deleted_at IS NULL - `, id).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt) + `), id).Scan(&w.ID, &w.Name, &w.Slug, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt) if err == sql.ErrNoRows { return nil, nil } @@ -178,10 +178,10 @@ func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mod w.Settings = settings } - _, err = s.db.Exec(` + _, err = s.db.Exec(s.q(` UPDATE workspaces SET name = ?, description = ?, settings = ?, updated_at = ? WHERE id = ? - `, w.Name, w.Description, w.Settings, ts, w.ID) + `), w.Name, w.Description, w.Settings, ts, w.ID) if err != nil { return nil, err } @@ -191,10 +191,10 @@ func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mod func (s *Store) DeleteWorkspace(slug string) error { ts := now() - result, err := s.db.Exec(` + result, err := s.db.Exec(s.q(` UPDATE workspaces SET deleted_at = ?, updated_at = ? WHERE slug = ? AND deleted_at IS NULL - `, ts, ts, slug) + `), ts, ts, slug) if err != nil { return err } From 313bc48419ba9d9c86c974b12df921fb531c79d6 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Apr 2026 20:10:58 +0000 Subject: [PATCH 05/10] fix: allow SvelteKit inline scripts in CSP to prevent white screen The script-src 'self' CSP directive blocked SvelteKit's inline bootstrap scripts, causing a white screen on mobile browsers which enforce CSP strictly. Add 'unsafe-inline' as a temporary fix until nonce-based CSP is implemented (TASK-163). --- internal/server/middleware_security.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/server/middleware_security.go b/internal/server/middleware_security.go index 941e6c55..1734ce94 100644 --- a/internal/server/middleware_security.go +++ b/internal/server/middleware_security.go @@ -24,9 +24,12 @@ func SecurityHeaders(next http.Handler) http.Handler { // Restrict browser features the app doesn't need h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") - // CSP: allow self-sourced scripts/styles, plus inline styles for Svelte + // CSP: allow self-sourced content, inline styles for Svelte component scoping, + // and inline scripts for SvelteKit's module bootstrap/hydration. + // Without 'unsafe-inline' on script-src, SvelteKit's generated inline