Merge pull request #68 from xarmian/phase-13/cloud-infrastructure

feat: Cloud infrastructure & scaling (PHASE-13)
This commit is contained in:
xarmian
2026-04-05 20:36:27 -04:00
committed by GitHub
41 changed files with 2142 additions and 539 deletions
+93 -11
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"io"
"io/fs"
"log"
"log/slog"
"net/http"
"net/url"
"os"
@@ -30,7 +30,9 @@ 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"
"github.com/xarmian/pad/internal/server"
"github.com/xarmian/pad/internal/store"
@@ -162,6 +164,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
}
@@ -169,23 +182,40 @@ 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()
// 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)
@@ -195,7 +225,23 @@ func serveCmd() *cobra.Command {
srv.SetSecureCookies(cfg.SecureCookies)
// Attach event bus for real-time SSE
srv.SetEventBus(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", "addr", opts.Addr, "db", opts.DB)
} else {
eventBus = events.New()
slog.Info("Event bus using in-memory (single instance)")
}
srv.SetEventBus(eventBus)
// Attach webhook dispatcher for outgoing notifications
srv.SetWebhookDispatcher(webhooks.NewDispatcher(s))
@@ -211,7 +257,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()
@@ -221,11 +267,47 @@ 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")
}
}
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
slog.Info("Shutting down server (30s grace period)...")
stop() // Reset signal handling so a second signal force-kills
// Close event bus first — this terminates SSE handler
// goroutines so http.Server.Shutdown won't block on them.
if eventBus != nil {
eventBus.Close()
slog.Info("Event bus closed")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP server shutdown error", "error", err)
}
slog.Info("Server stopped")
return nil
}
},
}
+24 -11
View File
@@ -3,26 +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/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
golang.org/x/crypto v0.49.0 // indirect
go.uber.org/atomic v1.11.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
)
+65
View File
@@ -1,8 +1,17 @@
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=
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=
@@ -11,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=
@@ -24,7 +47,10 @@ 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=
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=
@@ -35,26 +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=
+44 -12
View File
@@ -1,7 +1,7 @@
package events
import (
"log"
"log/slog"
"sync"
"time"
)
@@ -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()
}
@@ -110,13 +130,25 @@ 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)
}
}
}
// Close shuts down the event bus by closing all subscriber channels.
// SSE handler goroutines will see the channel close and exit cleanly.
func (b *MemoryBus) 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 {
func (b *MemoryBus) SubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subscribers)
+209
View File
@@ -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)
}
}
}
+59
View File
@@ -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
}
}
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
}
}()
}
+20
View File
@@ -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"`
+1 -1
View File
@@ -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
}
+48
View File
@@ -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...)
})
}
+5 -2
View File
@@ -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 <script>
// tags are blocked, causing a white screen (especially on mobile browsers).
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
+42 -9
View File
@@ -1,13 +1,15 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
@@ -24,8 +26,9 @@ 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)
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
@@ -57,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
}
@@ -112,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)
@@ -139,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) {
@@ -327,6 +332,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 +382,35 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (s *Server) ListenAndServe(addr string) error {
s.ensureRouter()
log.Printf("Pad server listening on %s", addr)
return http.ListenAndServe(addr, s.router)
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.
}
slog.Info("Pad server listening", "addr", addr)
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 ---
@@ -394,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)
}
}
@@ -411,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")
}
+10 -10
View File
@@ -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
}
+18 -16
View File
@@ -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)
}
@@ -401,7 +403,7 @@ func (s *Store) UpdateAgentRoleOrder(workspaceID string, updates []RoleOrderUpda
}
defer tx.Rollback()
stmt, err := tx.Prepare("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?")
stmt, err := tx.Prepare(s.q("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?"))
if err != nil {
return fmt.Errorf("prepare role order update: %w", err)
}
@@ -431,7 +433,7 @@ func (s *Store) UpdateRoleSortOrder(workspaceID string, updates []RoleSortUpdate
}
defer tx.Rollback()
stmt, err := tx.Prepare("UPDATE items SET role_sort_order = ? WHERE id = ? AND workspace_id = ?")
stmt, err := tx.Prepare(s.q("UPDATE items SET role_sort_order = ? WHERE id = ? AND workspace_id = ?"))
if err != nil {
return fmt.Errorf("prepare role sort update: %w", err)
}
+13 -13
View File
@@ -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,
)
+27 -25
View File
@@ -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, s.dialect.BoolToInt(input.IsDefault), ts, ts)
if err != nil {
return nil, fmt.Errorf("insert collection: %w", err)
}
@@ -60,13 +60,13 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
var c models.Collection
var createdAt, updatedAt string
var deletedAt *string
var isDefault int
var isDefault bool
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,
@@ -78,7 +78,7 @@ func (s *Store) GetCollection(id string) (*models.Collection, error) {
return nil, fmt.Errorf("get collection: %w", err)
}
c.IsDefault = isDefault == 1
c.IsDefault = isDefault
c.CreatedAt = parseTime(createdAt)
c.UpdatedAt = parseTime(updatedAt)
c.DeletedAt = parseTimePtr(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)
}
@@ -125,7 +126,7 @@ func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error)
for rows.Next() {
var c models.Collection
var createdAt, updatedAt string
var isDefault int
var isDefault bool
if err := rows.Scan(
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
&c.Schema, &c.Settings, &c.SortOrder, &isDefault,
@@ -133,7 +134,7 @@ func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error)
); err != nil {
return nil, err
}
c.IsDefault = isDefault == 1
c.IsDefault = isDefault
c.CreatedAt = parseTime(createdAt)
c.UpdatedAt = parseTime(updatedAt)
result = append(result, c)
@@ -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)
}
@@ -206,23 +207,23 @@ 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)
var isDefault bool
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
}
if err != nil {
return fmt.Errorf("check collection: %w", err)
}
if isDefault == 1 {
if isDefault {
return fmt.Errorf("cannot delete default collection")
}
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)
}
+10 -10
View File
@@ -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
}
+267
View File
@@ -0,0 +1,267 @@
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, '<mark>', '</mark>', '...', 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
// JSONArrayContains returns SQL + the arg to check if a JSON array column
// contains a given text value.
// SQLite: "column LIKE ?" with arg `%"value"%`
// PostgreSQL: "column::jsonb @> ?::jsonb" with arg `["value"]`
JSONArrayContains(column, value string) (string, interface{})
}
// ---------- 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, '<mark>', '</mark>', '...', 32)", ftsTable, colIndex)
}
func (d *sqliteDialect) FTSRank(_, _ string) string {
return "rank"
}
func (d *sqliteDialect) JSONArrayContains(column, value string) (string, interface{}) {
return column + " LIKE ?", "%\"" + value + "\"%"
}
// ---------- 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=<mark>,StopSel=</mark>,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)
}
func (d *postgresDialect) JSONArrayContains(column, value string) (string, interface{}) {
return column + "::jsonb @> ?::jsonb", `["` + value + `"]`
}
// ---------- 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()
}
+69
View File
@@ -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)
}
}
+69 -53
View File
@@ -29,28 +29,42 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara
args = append(args, params.Status)
}
if params.Tag != "" {
// Search within JSON array
query += " AND tags LIKE ?"
args = append(args, "%\""+params.Tag+"\"%")
tagExpr, tagArg := s.dialect.JSONArrayContains("tags", params.Tag)
query += " AND " + tagExpr
args = append(args, tagArg)
}
if params.Pinned != nil {
if *params.Pinned {
query += " AND pinned = 1"
query += " AND pinned = TRUE"
} else {
query += " AND pinned = 0"
query += " AND pinned = FALSE"
}
}
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
if s.dialect.Driver() == DriverSQLite {
ftsMatch := s.dialect.FTSMatch("documents_fts", "search_vector")
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 {
// PostgreSQL: search_vector lives on the documents table (aliased as "d").
ftsMatch := s.dialect.FTSMatch("d", "search_vector")
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 != "" {
@@ -83,12 +97,20 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara
}
if params.Query != "" {
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL ts_rank(): higher = more relevant → DESC
ftsRank := s.dialect.FTSRank("d", "search_vector")
query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order)
args = append(args, params.Query) // extra placeholder for ts_rank
} else {
// SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better)
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
}
} else {
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,12 +153,12 @@ 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,
boolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
`), id, workspaceID, input.Title, slug, input.Content, docType, status, tags,
s.dialect.BoolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert document: %w", err)
}
@@ -148,15 +170,15 @@ func (s *Store) GetDocument(id string) (*models.Document, error) {
var d models.Document
var createdAt, updatedAt string
var deletedAt *string
var pinned int
var pinned bool
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,
@@ -168,7 +190,7 @@ func (s *Store) GetDocument(id string) (*models.Document, error) {
return nil, err
}
d.Pinned = pinned == 1
d.Pinned = pinned
d.CreatedAt = parseTime(createdAt)
d.UpdatedAt = parseTime(updatedAt)
d.DeletedAt = parseTimePtr(deletedAt)
@@ -177,10 +199,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
}
@@ -234,17 +256,17 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
// Store a reverse diff (patch from new → old) instead of full content.
// Falls back to full content if the diff isn't meaningfully smaller.
versionContent := existing.Content
isDiff := 0
isDiff := false
patch := diff.CreateReversePatch(existing.Content, *input.Content)
if diff.IsDiffSmaller(patch, existing.Content) {
versionContent = patch
isDiff = 1
isDiff = true
}
_, 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, s.dialect.BoolToInt(isDiff), ts)
if err != nil {
return nil, fmt.Errorf("create version: %w", err)
}
@@ -296,7 +318,7 @@ func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.
}
if input.Pinned != nil {
sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*input.Pinned))
args = append(args, s.dialect.BoolToInt(*input.Pinned))
}
if input.SortOrder != nil {
sets = append(sets, "sort_order = ?")
@@ -313,7 +335,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 +350,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 +377,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 +387,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 +403,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 +477,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 +488,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 +523,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
}
@@ -516,7 +538,7 @@ func (s *Store) GetContext(workspaceID string, types []string, includeContent bo
created_at, updated_at
FROM documents
WHERE workspace_id = ? AND deleted_at IS NULL
AND (status = 'active' OR pinned = 1)
AND (status = 'active' OR pinned = TRUE)
`
args := []interface{}{workspaceID}
@@ -531,7 +553,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
}
@@ -556,7 +578,7 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
for rows.Next() {
var d models.Document
var createdAt, updatedAt string
var pinned int
var pinned bool
if err := rows.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,
@@ -564,7 +586,7 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
); err != nil {
return nil, err
}
d.Pinned = pinned == 1
d.Pinned = pinned
d.CreatedAt = parseTime(createdAt)
d.UpdatedAt = parseTime(updatedAt)
docs = append(docs, d)
@@ -572,9 +594,3 @@ func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
return docs, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+36 -46
View File
@@ -30,21 +30,21 @@ 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)
}
defer rows.Close()
for rows.Next() {
var c models.CollectionExport
var isDefault int
var isDefault bool
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.Prefix, &c.SortOrder, &isDefault, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan collection: %w", err)
}
c.IsDefault = isDefault == 1
c.IsDefault = isDefault
export.Collections = append(export.Collections, c)
}
if err := rows.Err(); err != nil {
@@ -52,22 +52,22 @@ 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)
}
defer itemRows.Close()
for itemRows.Next() {
var it models.ItemExport
var pinned int
var pinned bool
if err := itemRows.Scan(&it.ID, &it.CollectionID, &it.Title, &it.Slug, &it.Content, &it.Fields, &it.Tags, &pinned, &it.SortOrder, &it.ParentID, &it.CreatedBy, &it.LastModifiedBy, &it.Source, &it.ItemNumber, &it.CreatedAt, &it.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan item: %w", err)
}
it.Pinned = pinned == 1
it.Pinned = pinned
export.Items = append(export.Items, it)
}
if err := itemRows.Err(); err != nil {
@@ -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,23 +117,23 @@ 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)
}
defer versionRows.Close()
for versionRows.Next() {
var ver models.ItemVersionExport
var isDiff int
var isDiff bool
if err := versionRows.Scan(&ver.ID, &ver.ItemID, &ver.Content, &ver.ChangeSummary, &ver.CreatedBy, &ver.Source, &isDiff, &ver.CreatedAt); err != nil {
return nil, fmt.Errorf("scan item version: %w", err)
}
ver.IsDiff = isDiff == 1
ver.IsDiff = isDiff
export.ItemVersions = append(export.ItemVersions, ver)
}
if err := versionRows.Err(); err != nil {
@@ -185,15 +185,10 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
newCollID := newID()
collMap[c.ID] = newCollID
isDefault := 0
if c.IsDefault {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, c.Settings, c.Prefix, c.SortOrder, isDefault,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, c.Settings, c.Prefix, c.SortOrder, s.dialect.BoolToInt(c.IsDefault),
c.CreatedAt, c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("import collection %s: %w", c.Name, err)
@@ -209,11 +204,6 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
continue // skip orphaned items
}
pinned := 0
if it.Pinned {
pinned = 1
}
// On first pass, parent_id may refer to an item not yet created, so use empty
parentID := ""
if it.ParentID != "" {
@@ -222,10 +212,10 @@ 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(?, ''), ?, ?, ?, ?, ?, ?)`,
newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, pinned, it.SortOrder,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)`),
newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, s.dialect.BoolToInt(it.Pinned), it.SortOrder,
parentID, it.CreatedBy, it.LastModifiedBy, it.Source, it.ItemNumber,
it.CreatedAt, it.UpdatedAt)
if err != nil {
@@ -247,7 +237,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 +250,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 +267,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 {
@@ -294,14 +284,10 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string) (*
if newItemID == "" {
continue
}
isDiff := 0
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 (?, ?, ?, ?, ?, ?, ?, ?)`,
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, isDiff,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, s.dialect.BoolToInt(ver.IsDiff),
ver.CreatedAt)
if err != nil {
// Log detail but skip — version history is non-critical
@@ -322,8 +308,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 +324,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)
}
}
+222 -132
View File
@@ -86,18 +86,18 @@ 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,
boolToInt(input.Pinned), input.ParentID, input.AssignedUserID, input.AgentRoleID,
`), id, workspaceID, collectionID, input.Title, slug, input.Content, fields, tags,
s.dialect.BoolToInt(input.Pinned), input.ParentID, input.AssignedUserID, input.AgentRoleID,
createdBy, createdBy, source, nextNum, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert item: %w", err)
@@ -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)
VALUES (?, ?, ?, '', ?, ?, ?, ?)
`), vid, id, input.Content, createdBy, source, s.dialect.BoolToInt(false), ts)
if err != nil {
return nil, fmt.Errorf("create initial version: %w", err)
}
@@ -126,9 +126,9 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
var item models.Item
var createdAt, updatedAt string
var deletedAt *string
var pinned int
var pinned bool
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,
@@ -158,7 +158,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) {
return nil, fmt.Errorf("get item: %w", err)
}
item.Pinned = pinned == 1
item.Pinned = pinned
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
item.DeletedAt = parseTimePtr(deletedAt)
@@ -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
}
@@ -248,9 +248,9 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
var item models.Item
var createdAt, updatedAt string
var deletedAt *string
var pinned int
var pinned bool
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,
@@ -274,7 +274,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
&item.AgentRoleName, &item.AgentRoleSlug, &item.AgentRoleIcon,
)
if err == nil {
item.Pinned = pinned == 1
item.Pinned = pinned
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
item.DeletedAt = parseTimePtr(deletedAt)
@@ -322,9 +322,9 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I
var item models.Item
var createdAt, updatedAt string
var deletedAt *string
var pinned int
var pinned bool
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,
@@ -354,7 +354,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I
return nil, fmt.Errorf("get item by slug (include deleted): %w", err)
}
item.Pinned = pinned == 1
item.Pinned = pinned
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
item.DeletedAt = parseTimePtr(deletedAt)
@@ -394,8 +394,9 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
}
if params.Tag != "" {
query += " AND i.tags LIKE ?"
args = append(args, "%\""+params.Tag+"\"%")
tagExpr, tagArg := s.dialect.JSONArrayContains("i.tags", params.Tag)
query += " AND " + tagExpr
args = append(args, tagArg)
}
if params.ParentID != "" {
@@ -419,25 +420,30 @@ 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 {
// Sanitize the key to prevent SQL injection — field names must be
// alphanumeric/underscore only (user-controlled from query params).
if !isValidFieldKey(key) {
continue
}
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 +455,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 +465,74 @@ 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}
var query string
var args []interface{}
var ftsRank string
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL: search_vector lives on the items table (aliased as "i").
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
ftsRank = s.dialect.FTSRank("i", "search_vector")
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 {
// SQLite: uses FTS5 virtual table "items_fts".
ftsMatch := s.dialect.FTSMatch("items_fts", "search_vector")
ftsRank = s.dialect.FTSRank("items_fts", "search_vector")
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"
// SQLite bm25(): more negative = more relevant → ASC (default).
// PostgreSQL ts_rank(): higher = more relevant → DESC.
if s.dialect.Driver() == DriverPostgres {
query += " ORDER BY " + ftsRank + " DESC"
} else {
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)
}
@@ -543,17 +586,17 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
if shouldVersion {
vid := newID()
versionContent := existing.Content
isDiff := 0
isDiff := false
patch := diff.CreateReversePatch(existing.Content, *input.Content)
if diff.IsDiffSmaller(patch, existing.Content) {
versionContent = patch
isDiff = 1
isDiff = true
}
_, 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, s.dialect.BoolToInt(isDiff), ts)
if err != nil {
return nil, fmt.Errorf("create version: %w", err)
}
@@ -592,7 +635,7 @@ func (s *Store) UpdateItem(id string, input models.ItemUpdate) (*models.Item, er
}
if input.Pinned != nil {
sets = append(sets, "pinned = ?")
args = append(args, boolToInt(*input.Pinned))
args = append(args, s.dialect.BoolToInt(*input.Pinned))
}
if input.SortOrder != nil {
sets = append(sets, "sort_order = ?")
@@ -625,7 +668,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 +682,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 +698,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 +713,73 @@ 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, '<mark>', '</mark>', '...', 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}
var sqlQuery string
var args []interface{}
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL: search_vector lives on the items table (aliased as "i").
ftsSnippet := s.dialect.FTSSnippet("i", 1, "i.content")
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
ftsRank := s.dialect.FTSRank("i", "search_vector")
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 {
// SQLite: uses FTS5 virtual table "items_fts".
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")
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"
if s.dialect.Driver() == DriverPostgres {
sqlQuery += " ORDER BY rank_score DESC LIMIT 50"
} else {
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)
}
@@ -707,7 +789,7 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro
for rows.Next() {
var r ItemSearchResult
var createdAt, updatedAt string
var pinned int
var pinned bool
if err := rows.Scan(
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
&r.Item.Content, &r.Item.Fields, &r.Item.Tags,
@@ -721,7 +803,7 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro
); err != nil {
return nil, err
}
r.Item.Pinned = pinned == 1
r.Item.Pinned = pinned
r.Item.CreatedAt = parseTime(createdAt)
r.Item.UpdatedAt = parseTime(updatedAt)
r.Item.ComputeRef()
@@ -749,10 +831,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 +850,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 +896,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 +910,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 +954,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 +977,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 +1010,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 +1019,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 +1078,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 +1105,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 +1127,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 +1148,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 +1162,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 +1184,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 +1200,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 +1221,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 +1248,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 +1273,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 +1291,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 +1347,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
}
@@ -1273,11 +1363,11 @@ func (s *Store) ListItemVersionsBeforeTime(itemID string, before time.Time, befo
for rows.Next() {
var v models.Version
var createdAt string
var isDiff int
var isDiff bool
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
return nil, err
}
v.IsDiff = isDiff == 1
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
versions = append(versions, v)
}
@@ -1286,12 +1376,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
}
@@ -1301,11 +1391,11 @@ func (s *Store) ListItemVersions(itemID string) ([]models.Version, error) {
for rows.Next() {
var v models.Version
var createdAt string
var isDiff int
var isDiff bool
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
return nil, err
}
v.IsDiff = isDiff == 1
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
versions = append(versions, v)
}
@@ -1317,7 +1407,7 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) {
for rows.Next() {
var item models.Item
var createdAt, updatedAt string
var pinned int
var pinned bool
if err := rows.Scan(
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
&item.Content, &item.Fields, &item.Tags,
@@ -1330,7 +1420,7 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) {
); err != nil {
return nil, err
}
item.Pinned = pinned == 1
item.Pinned = pinned
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
hydrateItemComputedMetadata(&item)
+8 -8
View File
@@ -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
}
+443
View File
@@ -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 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
);
+5 -5
View File
@@ -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
}
+7 -7
View File
@@ -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)
}
+72 -26
View File
@@ -60,13 +60,13 @@ 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() {
var r SearchResult
var createdAt, updatedAt string
var pinned int
var pinned bool
if err := refRows.Scan(
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
&r.Item.Content, &r.Item.Fields, &r.Item.Tags,
@@ -79,7 +79,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
); err != nil {
continue
}
r.Item.Pinned = pinned == 1
r.Item.Pinned = pinned
r.Item.CreatedAt = parseTime(createdAt)
r.Item.UpdatedAt = parseTime(updatedAt)
hydrateItemComputedMetadata(&r.Item)
@@ -92,25 +92,64 @@ 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, '<mark>', '</mark>', '...', 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).
var query string
var args []interface{}
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL: search_vector is a column on the items table (aliased as "i"); no JOIN needed.
ftsSnippet := s.dialect.FTSSnippet("i", 1, "i.content")
ftsRank := s.dialect.FTSRank("i", "search_vector")
ftsMatch := s.dialect.FTSMatch("i", "search_vector")
// FTSSnippet, FTSRank, and FTSMatch each consume a "?" placeholder
// for the query parameter (plainto_tsquery('english', ?)).
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)
searchQuery := params.Query
args = []interface{}{searchQuery, searchQuery, searchQuery}
} else {
// SQLite: uses FTS5 virtual table with JOIN on rowid.
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")
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 +165,15 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
}
}
query += " ORDER BY rank LIMIT 50"
// SQLite bm25() returns negative values (more negative = more relevant) → ASC.
// PostgreSQL ts_rank() returns positive values (higher = more relevant) → DESC.
if s.dialect.Driver() == DriverPostgres {
query += " ORDER BY rank_score DESC LIMIT 50"
} else {
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)
@@ -148,7 +193,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
for rows.Next() {
var r SearchResult
var createdAt, updatedAt string
var pinned int
var pinned bool
if err := rows.Scan(
&r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug,
@@ -167,7 +212,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
if seen[r.Item.ID] {
continue
}
r.Item.Pinned = pinned == 1
r.Item.Pinned = pinned
r.Item.CreatedAt = parseTime(createdAt)
r.Item.UpdatedAt = parseTime(updatedAt)
hydrateItemComputedMetadata(&r.Item)
@@ -180,6 +225,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 == "" {
+7 -7
View File
@@ -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)
}
+7 -7
View File
@@ -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 {
+124 -10
View File
@@ -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,10 +63,49 @@ 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()
}
// 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 (
@@ -118,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.
@@ -223,7 +317,7 @@ func (s *Store) uniqueSlugExcluding(table, scopeCol, scopeVal, baseSlug, exclude
for i := 2; ; i++ {
var count int
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ? AND slug = ? AND id != ?", table, scopeCol)
err := s.db.QueryRow(query, scopeVal, slug, excludeID).Scan(&count)
err := s.db.QueryRow(s.q(query), scopeVal, slug, excludeID).Scan(&count)
if err != nil {
return "", err
}
@@ -238,6 +332,26 @@ func isAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_'
}
// isValidFieldKey checks that a field name contains only safe characters
// (alphanumeric, underscore, hyphen). This prevents SQL injection when
// field keys from user input are interpolated into JSON path expressions.
func isValidFieldKey(key string) bool {
if key == "" {
return false
}
for _, c := range key {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}
// 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()
}
@@ -285,7 +399,7 @@ func slugify(s string) string {
// ensures the unique index on (collection_id, item_number) exists.
func (s *Store) backfillItemNumbers() error {
// 1. Backfill collection prefixes
rows, err := s.db.Query("SELECT id, name FROM collections WHERE prefix = ''")
rows, err := s.db.Query(s.q("SELECT id, name FROM collections WHERE prefix = ''"))
if err != nil {
return fmt.Errorf("query collections for prefix backfill: %w", err)
}
@@ -311,13 +425,13 @@ func (s *Store) backfillItemNumbers() error {
if prefix == "" {
prefix = "ITEM"
}
if _, err := s.db.Exec("UPDATE collections SET prefix = ? WHERE id = ?", prefix, c.id); err != nil {
if _, err := s.db.Exec(s.q("UPDATE collections SET prefix = ? WHERE id = ?"), prefix, c.id); err != nil {
return fmt.Errorf("update prefix for collection %s: %w", c.id, err)
}
}
// 2. Backfill item numbers per collection
collRows, err := s.db.Query("SELECT id FROM collections")
collRows, err := s.db.Query(s.q("SELECT id FROM collections"))
if err != nil {
return fmt.Errorf("query collections for item number backfill: %w", err)
}
@@ -337,7 +451,7 @@ func (s *Store) backfillItemNumbers() error {
for _, collID := range collIDs {
itemRows, err := s.db.Query(
"SELECT id FROM items WHERE collection_id = ? AND item_number IS NULL ORDER BY created_at ASC, id ASC",
s.q("SELECT id FROM items WHERE collection_id = ? AND item_number IS NULL ORDER BY created_at ASC, id ASC"),
collID,
)
if err != nil {
@@ -360,13 +474,13 @@ func (s *Store) backfillItemNumbers() error {
// Get current max
var maxNum int
if err := s.db.QueryRow("SELECT COALESCE(MAX(item_number), 0) FROM items WHERE collection_id = ?", collID).Scan(&maxNum); err != nil {
if err := s.db.QueryRow(s.q("SELECT COALESCE(MAX(item_number), 0) FROM items WHERE collection_id = ?"), collID).Scan(&maxNum); err != nil {
return fmt.Errorf("get max item_number for collection %s: %w", collID, err)
}
for _, itemID := range itemIDs {
maxNum++
if _, err := s.db.Exec("UPDATE items SET item_number = ? WHERE id = ?", maxNum, itemID); err != nil {
if _, err := s.db.Exec(s.q("UPDATE items SET item_number = ? WHERE id = ?"), maxNum, itemID); err != nil {
return fmt.Errorf("update item_number for item %s: %w", itemID, err)
}
}
@@ -388,7 +502,7 @@ func (s *Store) uniqueSlug(table, scopeCol, scopeVal, baseSlug string) (string,
var count int
// Check all rows including soft-deleted to respect the DB UNIQUE constraint
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ? AND slug = ?", table, scopeCol)
err := s.db.QueryRow(query, scopeVal, slug).Scan(&count)
err := s.db.QueryRow(s.q(query), scopeVal, slug).Scan(&count)
if err != nil {
return "", err
}
+7 -7
View File
@@ -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
}
+10 -10
View File
@@ -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)
}
+12 -12
View File
@@ -51,32 +51,32 @@ func (s *Store) GetLatestVersion(documentID string) (*models.Version, error) {
func (s *Store) getLatestVersionRaw(documentID string) (*models.Version, error) {
var v models.Version
var createdAt string
var isDiff int
err := s.db.QueryRow(`
var isDiff bool
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
}
if err != nil {
return nil, err
}
v.IsDiff = isDiff == 1
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
return &v, nil
}
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
}
@@ -86,11 +86,11 @@ func (s *Store) ListVersions(documentID string) ([]models.Version, error) {
for rows.Next() {
var v models.Version
var createdAt string
var isDiff int
var isDiff bool
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
return nil, err
}
v.IsDiff = isDiff == 1
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
versions = append(versions, v)
}
@@ -133,19 +133,19 @@ func (s *Store) ListVersionsResolved(documentID, currentContent string) ([]model
func (s *Store) GetVersion(id string) (*models.Version, error) {
var v models.Version
var createdAt string
var isDiff int
err := s.db.QueryRow(`
var isDiff bool
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
}
if err != nil {
return nil, err
}
v.IsDiff = isDiff == 1
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
return &v, nil
}
+17 -17
View File
@@ -31,10 +31,10 @@ 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, ?, ?)`,
id, workspaceID, input.CollectionID, input.Name, slug, viewType, config, ts, ts,
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`),
id, workspaceID, input.CollectionID, input.Name, slug, viewType, config, s.dialect.BoolToInt(false), ts, ts,
)
if err != nil {
return nil, fmt.Errorf("insert view: %w", err)
@@ -47,13 +47,13 @@ func (s *Store) CreateView(workspaceID string, input models.ViewCreate) (*models
func (s *Store) GetView(id string) (*models.View, error) {
var v models.View
var collectionID *string
var isDefault int
var isDefault bool
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,
)
@@ -65,7 +65,7 @@ func (s *Store) GetView(id string) (*models.View, error) {
}
v.CollectionID = collectionID
v.IsDefault = isDefault == 1
v.IsDefault = isDefault
v.CreatedAt = parseTime(createdAt)
v.UpdatedAt = parseTime(updatedAt)
return &v, nil
@@ -75,13 +75,13 @@ func (s *Store) GetView(id string) (*models.View, error) {
func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) {
var v models.View
var collectionID *string
var isDefault int
var isDefault bool
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,
)
@@ -93,7 +93,7 @@ func (s *Store) GetViewBySlug(workspaceID, slug string) (*models.View, error) {
}
v.CollectionID = collectionID
v.IsDefault = isDefault == 1
v.IsDefault = isDefault
v.CreatedAt = parseTime(createdAt)
v.UpdatedAt = parseTime(updatedAt)
return &v, nil
@@ -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)
}
@@ -115,7 +115,7 @@ func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, erro
for rows.Next() {
var v models.View
var collID *string
var isDefault int
var isDefault bool
var createdAt, updatedAt string
if err := rows.Scan(
@@ -125,7 +125,7 @@ func (s *Store) ListViews(workspaceID, collectionID string) ([]models.View, erro
return nil, fmt.Errorf("scan view: %w", err)
}
v.CollectionID = collID
v.IsDefault = isDefault == 1
v.IsDefault = isDefault
v.CreatedAt = parseTime(createdAt)
v.UpdatedAt = parseTime(updatedAt)
views = append(views, v)
@@ -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)
}
+17 -17
View File
@@ -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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`), id, workspaceID, input.URL, input.Secret, evts, s.dialect.BoolToInt(true), ts, ts)
if err != nil {
return nil, fmt.Errorf("insert webhook: %w", err)
}
@@ -31,15 +31,15 @@ func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (*
// GetWebhook retrieves a single webhook by ID.
func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
var wh models.Webhook
var active int
var active bool
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,
)
@@ -50,7 +50,7 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
return nil, fmt.Errorf("get webhook: %w", err)
}
wh.Active = active == 1
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
@@ -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)
}
@@ -73,7 +73,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
var result []models.Webhook
for rows.Next() {
var wh models.Webhook
var active int
var active bool
var createdAt, updatedAt string
var lastTriggeredAt *string
@@ -83,7 +83,7 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
); err != nil {
return nil, fmt.Errorf("scan webhook: %w", err)
}
wh.Active = active == 1
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
wh.LastTriggeredAt = parseTimePtr(lastTriggeredAt)
@@ -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
active = CASE WHEN failure_count + 1 >= 10 THEN FALSE 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)
}
+24 -24
View File
@@ -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)
}
+13 -13
View File
@@ -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
}
+7 -7
View File
@@ -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)
}
}